diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 6484c592..7be6c796 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -77,9 +77,10 @@ backend/app/ │ │ │ └── repo_analyzer.py │ │ ├── response_mapper.py │ │ └── skills/ # スキル推論基盤(ADR-0016 / 3層モデル) -│ │ ├── aggregator.py # discover+declare 合流 → DetectedSkill +│ │ ├── aggregator.py # discover+declare+verify 合流 → DetectedSkill │ │ ├── linguist.py # 言語正規化(Linguist languages.yml) -│ │ └── manifests/ # エコシステム別 manifest パーサ(plugin 型) +│ │ ├── manifests/ # エコシステム別 manifest パーサ(declare / plugin 型) +│ │ └── imports/ # エコシステム別 import スキャナ(verify / plugin 型) │ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル) │ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ) │ │ ├── exceptions.py # RetryableError / NonRetryableError diff --git a/backend/app/services/intelligence/github/api_client.py b/backend/app/services/intelligence/github/api_client.py index d11895bd..e56e9030 100644 --- a/backend/app/services/intelligence/github/api_client.py +++ b/backend/app/services/intelligence/github/api_client.py @@ -165,25 +165,24 @@ async def fetch_languages( return {} -async def fetch_manifest_paths( +async def fetch_repo_tree( client: httpx.AsyncClient, owner: str, repo: str, default_branch: str, - manifest_filenames: frozenset[str], ) -> tuple[List[str], bool]: - """recursive Trees API でサブツリーを含む manifest パス一覧を取得する(declare / D7・D9)。 - - ``GET /git/trees/{default_branch}?recursive=1`` を 1 回呼び、blob のうち basename が - ``manifest_filenames`` に一致する相対パスだけを返す(1 リポ 1 コール / D9(a))。 - 第 2 戻り値は GitHub が木構造を打ち切ったか(``truncated`` / D9(d))。 - - 除外リストや深さ・件数キャップといった探索ポリシーは呼び出し側(collector)の責務とし、 - ここでは「API 呼び出し + basename フィルタ + truncated 返却」に留める。manifest 取得は - ベストエフォート(1 リポの失敗で連携全体を落とさない)。ただし tree 取得自体が失敗した場合 - (非200 / 不正レスポンス / ``httpx.HTTPError``)は「依存ゼロ」と「走査不能」を区別するため、 - 第 2 戻り値の partial を ``True`` にして部分スキャンとして伝播する(D9(d))。不正 owner/repo は - 実在リポではなく走査対象ですらないため ``([], False)`` のままとする。 + """recursive Trees API でリポジトリの全 blob パスを 1 コールで取得する(D9(a))。 + + ``GET /git/trees/{default_branch}?recursive=1`` を **1 回だけ**呼び、blob の相対パス一覧を + 返す(1 リポ 1 コール)。manifest 探索(declare / D7・D9)と import 解析(verify / D6)の + 双方がこの単一ツリーを共有することで、verify のために tree を再取得しない。 + basename / 拡張子による絞り込みや除外・キャップといった探索ポリシーは呼び出し側 + (collector)の責務とし、ここは「API 呼び出し + 全 blob パス + truncated 返却」に留める。 + + 第 2 戻り値は走査が部分的か(partial)。GitHub が木構造を打ち切った(``truncated``)場合に + 加え、tree 取得自体が失敗した場合(非200 / 不正レスポンス / ``httpx.HTTPError``)も + 「依存ゼロ」と「走査不能」を区別するため ``True`` を返す(D9(d))。不正 owner/repo は + 実在リポではなく走査対象ですらないため ``([], False)`` とする。 """ if not _is_valid_owner_repo(owner, repo): return [], False @@ -204,9 +203,7 @@ async def fetch_manifest_paths( paths = [ entry["path"] for entry in tree - if entry.get("type") == "blob" - and entry.get("path") - and entry["path"].rsplit("/", 1)[-1] in manifest_filenames + if entry.get("type") == "blob" and entry.get("path") ] return paths, bool(data.get("truncated")) except httpx.HTTPError: diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py index 9cd033d5..799135dc 100644 --- a/backend/app/services/intelligence/github_collector.py +++ b/backend/app/services/intelligence/github_collector.py @@ -20,19 +20,20 @@ GITHUB_API, GitHubUserNotFoundError, fetch_languages, - fetch_manifest_paths, fetch_repo_file, + fetch_repo_tree, fetch_repos_raw, ) +from .skills.imports import scanner_for_extension from .skills.manifests import MANIFEST_FILENAMES, parse_manifest from .skills.types import PackageDeclaration logger = logging.getLogger(__name__) -# monorepo manifest 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、 +# monorepo 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、 # env_keys 同期コストに見合わないためモジュール定数として持つ(チューニングはコード変更)。 -# D9(b): パスのいずれかのセグメントがこの集合に該当したら manifest 候補から除外する。 -_MANIFEST_PATH_EXCLUDE_SEGMENTS = frozenset( +# D9(b): パスのいずれかのセグメントがこの集合に該当したら候補から除外する(manifest / source 共通)。 +_PATH_EXCLUDE_SEGMENTS = frozenset( { "node_modules", "vendor", @@ -51,6 +52,10 @@ _MANIFEST_MAX_DEPTH = 4 # D9(c): 1 リポあたり fetch する manifest 件数上限。 _MANIFEST_MAX_COUNT = 20 +# D6: verify で 1 リポあたり fetch するソースファイル件数上限(import サンプリングの打ち切り)。 +_SOURCE_MAX_COUNT = 30 +# D6: verify 対象ソースのセグメント数上限(浅い側を優先サンプリング)。 +_SOURCE_MAX_DEPTH = 6 # このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が # ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。 @@ -77,48 +82,55 @@ class RepoData: default_branch: str = field(default="main") # declare ステージ: manifest が宣言する依存(D7・D9。サブツリー含む)。未取得なら空。 package_declarations: List[PackageDeclaration] = field(default_factory=list) - # D9(d): manifest 走査が網羅的でない(truncated / cap で打ち切り)場合 True。 + # verify ステージ(D6): import 解析で実際に使われていた名前の集合(ecosystem → import 名)。 + # aggregator が direct 宣言と照合し actual_import 証跡へ昇格させる素にする。未取得なら空。 + imported_symbols: Dict[str, set] = field(default_factory=dict) + # D9(d): ツリー走査(manifest / source)が網羅的でない(truncated / cap で打ち切り)場合 True。 manifest_scan_partial: bool = False -def _is_excluded_manifest_path(path: str) -> bool: - """パスのいずれかのセグメントが除外集合に該当するか(D9(b))。""" - return any(seg in _MANIFEST_PATH_EXCLUDE_SEGMENTS for seg in path.split("/")) +def _is_excluded_path(path: str) -> bool: + """パスのいずれかのセグメントが除外集合に該当するか(D9(b)。manifest / source 共通)。""" + return any(seg in _PATH_EXCLUDE_SEGMENTS for seg in path.split("/")) -async def _collect_manifests( - client: httpx.AsyncClient, owner: str, repo: str, default_branch: str -) -> tuple[List[PackageDeclaration], bool]: - """サブツリーを含む manifest を取得・解析して依存宣言を返す(declare / D7・D9)。 +def _select_shallow(paths: List[str], max_depth: int, max_count: int) -> tuple[List[str], bool]: + """除外・浅い順ソート・深さ/件数キャップで候補を絞る(D9(b)(c) / D6 サンプリング)。 - recursive Trees API で候補パスを列挙し、除外セグメント(D9(b))・深さ/件数キャップ - (D9(c))でフィルタしてから本文を取得する。取得・解析失敗はベストエフォートで握りつぶす - (1 リポの失敗で連携全体を落とさない)。第 2 戻り値は走査が部分的だったか(D9(d))。 + 戻り値は (採用パス, 打ち切りが発生したか)。 """ - paths, truncated = await fetch_manifest_paths( - client, owner, repo, default_branch, MANIFEST_FILENAMES - ) - # D9(b): 除外セグメントを含むパスを捨てる(取得済みツリーの in-memory フィルタ)。 - candidates = [p for p in paths if not _is_excluded_manifest_path(p)] - # D9(c): 浅い順に並べ、深さ上限超を落とし、件数上限で打ち切る。 + candidates = [p for p in paths if not _is_excluded_path(p)] candidates.sort(key=lambda p: (p.count("/"), p)) - within_depth = [p for p in candidates if p.count("/") + 1 <= _MANIFEST_MAX_DEPTH] + within_depth = [p for p in candidates if p.count("/") + 1 <= max_depth] depth_dropped = len(within_depth) < len(candidates) - selected = within_depth[:_MANIFEST_MAX_COUNT] - count_dropped = len(within_depth) > _MANIFEST_MAX_COUNT - partial = bool(truncated or depth_dropped or count_dropped) - if partial: - logger.warning( - "manifest 探索が部分的: %s/%s (truncated=%s, depth_dropped=%s, count_dropped=%s)", - owner, - repo, - truncated, - depth_dropped, - count_dropped, - ) + selected = within_depth[:max_count] + count_dropped = len(within_depth) > max_count + return selected, bool(depth_dropped or count_dropped) + + +async def _collect_repo_signals( + client: httpx.AsyncClient, owner: str, repo: str, default_branch: str +) -> tuple[List[PackageDeclaration], Dict[str, set], bool]: + """1 回のツリー取得から declare(manifest)と verify(import 解析)の両シグナルを集める。 + + recursive Trees API を **1 回だけ**呼び(D9(a))、その結果を manifest 探索と source 探索で + 共有する。manifest は宣言依存(D7・D9)、source は実 import(D6)を抽出する。いずれも + 除外セグメント(D9(b))・深さ/件数キャップ(D9(c) / D6 サンプリング)で絞る。取得・解析 + 失敗はベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。 + + 戻り値は (依存宣言, ecosystem→import名集合, 走査が部分的だったか / D9(d))。 + """ + tree_paths, truncated = await fetch_repo_tree(client, owner, repo, default_branch) + # ── declare: manifest を basename で抽出して解析する ────────────────────── + manifest_paths = [ + p for p in tree_paths if p.rsplit("/", 1)[-1] in MANIFEST_FILENAMES + ] + selected_manifests, manifest_dropped = _select_shallow( + manifest_paths, _MANIFEST_MAX_DEPTH, _MANIFEST_MAX_COUNT + ) declarations: List[PackageDeclaration] = [] - for path in selected: + for path in selected_manifests: content = await fetch_repo_file(client, owner, repo, path) if not content: continue @@ -127,7 +139,44 @@ async def _collect_manifests( declarations.extend( replace(decl, source_path=path) for decl in parse_manifest(filename, content) ) - return declarations, partial + + # ── verify: direct 宣言のあるエコシステムだけソースを import 解析する(D6)── + direct_ecosystems = { + decl.ecosystem for decl in declarations if decl.dependency_kind == "direct" + } + imported_symbols: Dict[str, set] = {} + source_dropped = False + if direct_ecosystems: + # path → scanner を一度だけ引き、後段の取得ループで再計算しない。 + path_scanners = {} + for path in tree_paths: + scanner = scanner_for_extension(path) + if scanner is not None and scanner.ecosystem in direct_ecosystems: + path_scanners[path] = scanner + selected_sources, source_dropped = _select_shallow( + list(path_scanners), _SOURCE_MAX_DEPTH, _SOURCE_MAX_COUNT + ) + for path in selected_sources: + scanner = path_scanners[path] + content = await fetch_repo_file(client, owner, repo, path) + if not content: + continue + # 生コードは scan 後に破棄(永続化しない / D6)。 + imported_symbols.setdefault(scanner.ecosystem, set()).update( + scanner.scan(content) + ) + + partial = bool(truncated or manifest_dropped or source_dropped) + if partial: + logger.warning( + "ツリー走査が部分的: %s/%s (truncated=%s, manifest_dropped=%s, source_dropped=%s)", + owner, + repo, + truncated, + manifest_dropped, + source_dropped, + ) + return declarations, imported_symbols, partial def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool: @@ -158,7 +207,8 @@ async def collect_repos( 言語の内訳を含む RepoData のリストを返す。 on_repo_fetched が渡された場合、各リポジトリの詳細取得後に on_repo_fetched(done, total) を呼び出す(進捗通知用)。 - collect_manifests=True のとき、直下 manifest を解析して package_declarations を埋める(declare / D7)。 + collect_manifests=True のとき、1 回のツリー取得から manifest 宣言(declare / D7・D9)と + import 実使用(verify / D6)の両シグナルを集め、package_declarations と imported_symbols を埋める。 """ headers = { "Accept": "application/vnd.github+json", @@ -193,10 +243,13 @@ async def collect_repos( default_branch = raw.get("default_branch", "main") declarations: List[PackageDeclaration] = [] - manifest_partial = False + imported_symbols: Dict[str, set] = {} + scan_partial = False if collect_manifests: - declarations, manifest_partial = await _collect_manifests( - client, owner_login, repo_name, default_branch + declarations, imported_symbols, scan_partial = ( + await _collect_repo_signals( + client, owner_login, repo_name, default_branch + ) ) repos.append( @@ -212,7 +265,8 @@ async def collect_repos( stargazers_count=raw.get("stargazers_count", 0), default_branch=default_branch, package_declarations=declarations, - manifest_scan_partial=manifest_partial, + imported_symbols=imported_symbols, + manifest_scan_partial=scan_partial, ) ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index ec03f6be..17728c87 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -132,6 +132,7 @@ async def _on_repo_fetched(done: int, total: int) -> None: url=f"https://github.com/{repo.owner}/{repo.name}", languages=repo.languages, package_declarations=repo.package_declarations, + imported_symbols=repo.imported_symbols, manifest_scan_partial=repo.manifest_scan_partial, ) for repo in repos diff --git a/backend/app/services/intelligence/skills/aggregator.py b/backend/app/services/intelligence/skills/aggregator.py index d8466bd8..dda56887 100644 --- a/backend/app/services/intelligence/skills/aggregator.py +++ b/backend/app/services/intelligence/skills/aggregator.py @@ -10,6 +10,7 @@ import re from dataclasses import dataclass, field +from .imports import scanner_for_ecosystem from .linguist import resolve_language from .types import ( SKILL_KIND_LANGUAGE, @@ -25,8 +26,11 @@ "dev": 0.3, "indirect": 0.1, } +# verify(D6): direct 宣言が実際に import されていたときの昇格後 confidence。 +_ACTUAL_IMPORT_CONFIDENCE = 0.85 _SIGNAL_LANGUAGE_BYTES = "language_bytes" _SIGNAL_MANIFEST_DECLARED = "manifest_declared" +_SIGNAL_ACTUAL_IMPORT = "actual_import" # PEP 503 正規化用(連続する -_. を - に畳む)。 _PYPI_NAME_RE = re.compile(r"[-_.]+") @@ -80,7 +84,9 @@ class RepoSkillInput: url: str languages: dict[str, int] package_declarations: list[PackageDeclaration] = field(default_factory=list) - # D9(d): このリポの manifest 走査が部分的だったか。package 根拠へ伝播する。 + # verify(D6): import 解析で実使用が確認された名前の集合(ecosystem → import 名)。 + imported_symbols: dict[str, set[str]] = field(default_factory=dict) + # D9(d): このリポのツリー走査が部分的だったか。package 根拠へ伝播する。 manifest_scan_partial: bool = False @@ -194,7 +200,37 @@ def _collect_packages( partial_scan=repo.manifest_scan_partial, ) ) + # verify(D6): direct 宣言が実際に import されていたら actual_import 証跡を **追加**する。 + # declare 証跡は残したまま昇格証跡を足す(昇格のみ・降格なし / 保持は細かく / D8)。 + if decl.dependency_kind == "direct" and _is_imported( + ecosystem, name, repo.imported_symbols + ): + skill.evidence.append( + EvidenceRecord( + repo_full_name=repo.full_name, + repo_url=repo.url, + signal_source=_SIGNAL_ACTUAL_IMPORT, + confidence=_ACTUAL_IMPORT_CONFIDENCE, + dependency_kind=decl.dependency_kind, + manifest_path=decl.source_path, + partial_scan=repo.manifest_scan_partial, + ) + ) def _confidence(dependency_kind: str | None) -> float: return _DEPENDENCY_CONFIDENCE.get(dependency_kind or "", 0.2) + + +def _is_imported( + ecosystem: str, canonical_name: str, imported_symbols: dict[str, set[str]] +) -> bool: + """canonical 名がこのリポの import 解析結果(verify / D6)で実使用されていたか。 + + 照合規則(``-``→``_`` 変換・接頭辞一致など)はエコシステム別スキャナに委譲する。 + 未対応エコシステム・スキャン結果なしは False(昇格しないだけで declare 証跡は残る)。 + """ + scanner = scanner_for_ecosystem(ecosystem) + if scanner is None: + return False + return scanner.matches(canonical_name, imported_symbols.get(ecosystem, set())) diff --git a/backend/app/services/intelligence/skills/imports/__init__.py b/backend/app/services/intelligence/skills/imports/__init__.py new file mode 100644 index 00000000..c69f5b0f --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/__init__.py @@ -0,0 +1,20 @@ +"""import 解析(verify ステージ / ADR-0016 D6)。 + +宣言された direct 依存が実際に import されているかをソースから判定し、``manifest_declared`` +を ``actual_import`` へ昇格させる素を作る。辞書は持たず、エコシステム別の機械的規則のみで +照合する(取りこぼしは false negative として受容)。 +""" + +from .base import ImportScanner +from .registry import ( + SOURCE_EXTENSIONS, + scanner_for_ecosystem, + scanner_for_extension, +) + +__all__ = [ + "ImportScanner", + "SOURCE_EXTENSIONS", + "scanner_for_ecosystem", + "scanner_for_extension", +] diff --git a/backend/app/services/intelligence/skills/imports/base.py b/backend/app/services/intelligence/skills/imports/base.py new file mode 100644 index 00000000..685f3e0a --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/base.py @@ -0,0 +1,34 @@ +"""import スキャナのプラグイン基底(ADR-0016 D6 / verify ステージ)。 + +各エコシステムのスキャナは ``ImportScanner`` を実装し、ソースファイル内容から +「import された名前/パス」の集合を抽出する(``scan``)。宣言された package が実際に +import されているかの判定(``matches``)もエコシステム依存なのでスキャナに集約する +(npm は完全一致、go は接頭辞一致、pypi/cargo は ``-``→``_`` 変換後の一致など)。 + +辞書は持たない(D3)。package ID → import 名の変換は機械的な規則のみで行い、取りこぼし +(例: PyYAML→yaml)は **昇格漏れ(false negative)として受容**する。verify は declare の +証跡を昇格させるだけで降格はしないため、未検出でも declare 証跡はそのまま残る(D8)。 + +スキャナは I/O を行わない純粋関数として実装する(ファイル取得は呼び出し側の責務)。 +壊れたソースでも例外を投げず空集合を返す(1 ファイルの解析失敗で連携全体を落とさない)。 +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ImportScanner(Protocol): + """import スキャナのインターフェース。""" + + # このスキャナが対応するソースファイルの拡張子(".py" 等。先頭ドット込み)。 + extensions: tuple[str, ...] + # エコシステム識別子(npm / pypi / go / cargo)。manifest 側の ecosystem と一致させる。 + ecosystem: str + + def scan(self, content: str) -> set[str]: + """ソース内容から import された名前/パスの集合を抽出する。""" + ... + + def matches(self, canonical_name: str, imported: set[str]) -> bool: + """宣言 package(canonical 名)が ``imported`` 内で実使用されているか判定する。""" + ... diff --git a/backend/app/services/intelligence/skills/imports/go.py b/backend/app/services/intelligence/skills/imports/go.py new file mode 100644 index 00000000..dbf72347 --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/go.py @@ -0,0 +1,33 @@ +"""Go import スキャナ(ecosystem=go / D6)。 + +単行 ``import "path"`` と ``import ( ... )`` ブロック内の引用符付きパスを抽出する +(エイリアス ``import x "path"`` / ドット import も引用符内を拾えば足りる)。 +go.mod の module path に対し、import パスが完全一致または ``module/...`` の接頭辞一致なら +実使用とみなす(サブパッケージ import を許容)。 +""" + +import re + +# 引用符付き import パス(ブロック内行・単行の両方をカバー)。 +_IMPORT_LINE_RE = re.compile(r'^[ \t]*(?:[\w.]+[ \t]+)?"([^"]+)"', re.MULTILINE) +# import ブロック全体(import ( ... ))。 +_IMPORT_BLOCK_RE = re.compile(r"import\s*\((.*?)\)", re.DOTALL) +# 単行 import "path"。 +_IMPORT_SINGLE_RE = re.compile(r'^[ \t]*import[ \t]+(?:[\w.]+[ \t]+)?"([^"]+)"', re.MULTILINE) + + +class GoImportScanner: + extensions = (".go",) + ecosystem = "go" + + def scan(self, content: str) -> set[str]: + paths: set[str] = set() + for block in _IMPORT_BLOCK_RE.findall(content): + paths.update(_IMPORT_LINE_RE.findall(block)) + paths.update(_IMPORT_SINGLE_RE.findall(content)) + return paths + + def matches(self, canonical_name: str, imported: set[str]) -> bool: + # module path 完全一致 or サブパッケージ(module/...)の接頭辞一致。 + prefix = canonical_name + "/" + return any(p == canonical_name or p.startswith(prefix) for p in imported) diff --git a/backend/app/services/intelligence/skills/imports/js_ts.py b/backend/app/services/intelligence/skills/imports/js_ts.py new file mode 100644 index 00000000..90439958 --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/js_ts.py @@ -0,0 +1,58 @@ +"""JavaScript / TypeScript import スキャナ(ecosystem=npm / D6)。 + +``import ... from "spec"`` / ``import "spec"`` / ``require("spec")`` / ``import("spec")`` +の module specifier を抽出し、相対 import(``.`` / ``..`` / ``/`` 始まり)を除外する。 +specifier から package 名を取り出す(``@scope/name`` はスコープ込み、それ以外は先頭セグメント。 +subpath import ``pkg/sub`` は ``pkg`` に丸める)。npm の package ID はそのまま canonical。 +""" + +import re + +# from "x" / from 'x' / import "x" / require("x") / import("x") の引用符内を捕捉。 +_SPEC_RE = re.compile( + r"""(?:\bfrom|\bimport|\brequire)\s*\(?\s*["']([^"']+)["']""", +) + +# コメント除去(誤検出抑制)。block コメント /* ... */ と行コメント // ... を落とす。 +# 行コメントは URL(http://)を壊さないよう直前が ":" でない場合のみ除去する。 +_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +_LINE_COMMENT_RE = re.compile(r"(? str: + """import 走査前にコメントを除去する(コメント内の import/require 誤検出を防ぐ)。 + + 文字列リテラル内の import 風テキスト(``'import("x")'`` 等)までは除去しない。 + 昇格のみ・実害は低 confidence の誤昇格に留まるため、残差は受容する(D6 / 保守的)。 + """ + return _LINE_COMMENT_RE.sub(" ", _BLOCK_COMMENT_RE.sub(" ", content)) + + +def _package_of(spec: str) -> str | None: + """module specifier から package 名を取り出す。相対 import は None。""" + if not spec or spec.startswith((".", "/")): + return None + parts = spec.split("/") + if spec.startswith("@"): + # スコープ付き: @scope/name(最低 2 セグメント必要) + if len(parts) < 2: + return None + return "/".join(parts[:2]) + return parts[0] + + +class JsTsImportScanner: + extensions = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts") + ecosystem = "npm" + + def scan(self, content: str) -> set[str]: + names: set[str] = set() + for spec in _SPEC_RE.findall(_strip_comments(content)): + pkg = _package_of(spec) + if pkg: + names.add(pkg) + return names + + def matches(self, canonical_name: str, imported: set[str]) -> bool: + # npm の package ID(@scope/name 含む)はそのまま import される。 + return canonical_name in imported diff --git a/backend/app/services/intelligence/skills/imports/python.py b/backend/app/services/intelligence/skills/imports/python.py new file mode 100644 index 00000000..58dc751a --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/python.py @@ -0,0 +1,33 @@ +"""Python import スキャナ(ecosystem=pypi / D6)。 + +``import X`` / ``from X import ...`` のトップレベルモジュール名を抽出する。 +pypi の package ID と import 名はしばしば異なる(PyYAML→yaml 等)が、辞書は持たず +``canonical_name`` の ``-``/``.``→``_`` 変換のみで照合する。差異は false negative として受容。 +""" + +import re + +# 行頭(インデント可)の import 文。`import a.b`, `import a as b`, `from a.b import c` を拾う。 +_IMPORT_RE = re.compile( + r"^[ \t]*(?:import|from)[ \t]+([A-Za-z_][\w.]*)", + re.MULTILINE, +) + + +def _top_level(module: str) -> str: + """ドット区切りの先頭要素(トップレベルモジュール名)を小文字で返す。""" + return module.split(".", 1)[0].lower() + + +class PythonImportScanner: + extensions = (".py", ".pyi") + ecosystem = "pypi" + + def scan(self, content: str) -> set[str]: + return {_top_level(m) for m in _IMPORT_RE.findall(content)} + + def matches(self, canonical_name: str, imported: set[str]) -> bool: + # pypi canonical は PEP 503 正規化済み(小文字・``-`` 区切り)。 + # import 名は区切りが ``_`` になるのが一般的なので変換して照合する。 + candidate = canonical_name.replace("-", "_").replace(".", "_").lower() + return candidate in imported diff --git a/backend/app/services/intelligence/skills/imports/registry.py b/backend/app/services/intelligence/skills/imports/registry.py new file mode 100644 index 00000000..d8ee638f --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/registry.py @@ -0,0 +1,44 @@ +"""import スキャナのレジストリ(ADR-0016 D6 / verify)。 + +Tier1(v1 必須)の全 4 エコシステム(Go / Python / JS-TS / Rust)のスキャナを登録する。 +manifest パーサ(``manifests/registry.py``)と同じ plugin 思想で、Tier2 追加時は +``_SCANNERS`` に 1 行足すだけで差し込める。 +""" + +from .base import ImportScanner +from .go import GoImportScanner +from .js_ts import JsTsImportScanner +from .python import PythonImportScanner +from .rust import RustImportScanner + +# Tier1 のスキャナインスタンス。 +_SCANNERS: tuple[ImportScanner, ...] = ( + GoImportScanner(), + PythonImportScanner(), + JsTsImportScanner(), + RustImportScanner(), +) + +# エコシステム → スキャナ。 +_BY_ECOSYSTEM: dict[str, ImportScanner] = {s.ecosystem: s for s in _SCANNERS} + +# 拡張子 → スキャナ(ソースファイルの振り分け用)。 +_BY_EXTENSION: dict[str, ImportScanner] = { + ext: scanner for scanner in _SCANNERS for ext in scanner.extensions +} + +# verify で取得対象とするソース拡張子の集合。 +SOURCE_EXTENSIONS: frozenset[str] = frozenset(_BY_EXTENSION) + + +def scanner_for_extension(path: str) -> ImportScanner | None: + """ファイルパスの拡張子に対応するスキャナを返す(未対応なら None)。""" + dot = path.rfind(".") + if dot < 0: + return None + return _BY_EXTENSION.get(path[dot:].lower()) + + +def scanner_for_ecosystem(ecosystem: str) -> ImportScanner | None: + """エコシステム識別子に対応するスキャナを返す(未対応なら None)。""" + return _BY_ECOSYSTEM.get(ecosystem) diff --git a/backend/app/services/intelligence/skills/imports/rust.py b/backend/app/services/intelligence/skills/imports/rust.py new file mode 100644 index 00000000..7660ad7c --- /dev/null +++ b/backend/app/services/intelligence/skills/imports/rust.py @@ -0,0 +1,32 @@ +"""Rust import スキャナ(ecosystem=cargo / D6)。 + +``use crate_name::...`` と ``extern crate crate_name;`` の先頭クレート名を抽出する。 +Cargo の crate 名は ``-`` を含みうるが、ソースでは ``_`` に変換されて使われるため +(serde-json → ``use serde_json``)、canonical 名も ``-``→``_`` 変換して照合する。 +``crate`` / ``self`` / ``super`` / ``std`` / ``core`` / ``alloc`` は外部クレートでないため除外。 +""" + +import re + +# 先頭の可視性修飾子(pub / pub(crate) / pub(in path) 等)を任意で許容し、 +# `pub use serde::...` のような re-export も外部クレート使用として拾う。 +_USE_RE = re.compile( + r"^[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?use[ \t]+([A-Za-z_]\w*)", re.MULTILINE +) +_EXTERN_RE = re.compile(r"^[ \t]*extern[ \t]+crate[ \t]+([A-Za-z_]\w*)", re.MULTILINE) + +# 外部クレートでない予約パスセグメント。 +_NON_CRATE = frozenset({"crate", "self", "super", "std", "core", "alloc"}) + + +class RustImportScanner: + extensions = (".rs",) + ecosystem = "cargo" + + def scan(self, content: str) -> set[str]: + names = set(_USE_RE.findall(content)) | set(_EXTERN_RE.findall(content)) + return {n for n in names if n not in _NON_CRATE} + + def matches(self, canonical_name: str, imported: set[str]) -> bool: + candidate = canonical_name.replace("-", "_") + return candidate in imported diff --git a/backend/tests/test_github_api_client.py b/backend/tests/test_github_api_client.py index ec8bed78..9161bbd4 100644 --- a/backend/tests/test_github_api_client.py +++ b/backend/tests/test_github_api_client.py @@ -1,14 +1,15 @@ -"""github/api_client の fetch_manifest_paths のテスト(ADR-0016 D9)。 +"""github/api_client の fetch_repo_tree のテスト(ADR-0016 D9・D6)。 recursive Trees API のレスポンスをモックし、実 GitHub API は叩かない。 +basename / 拡張子の絞り込みは collector 側の責務なので、ここでは「全 blob パス + +truncated 返却」のみを検証する(manifest と source の両方がこの単一ツリーを共有する)。 """ import asyncio from unittest.mock import AsyncMock, MagicMock import httpx -from app.services.intelligence.github.api_client import fetch_manifest_paths -from app.services.intelligence.skills.manifests import MANIFEST_FILENAMES +from app.services.intelligence.github.api_client import fetch_repo_tree def _run(coro): @@ -28,30 +29,28 @@ def _client_with_tree(tree, truncated=False, status_code=200): return client -def test_returns_blob_paths_matching_manifest_basenames(): - """basename が既知 manifest 名の blob だけを返すこと。""" +def test_returns_all_blob_paths(): + """blob の相対パスをすべて返し、ディレクトリ(tree)は除外すること。""" tree = [ {"type": "blob", "path": "package.json"}, {"type": "blob", "path": "backend/requirements.txt"}, - {"type": "blob", "path": "README.md"}, # manifest でない - {"type": "tree", "path": "backend"}, # ディレクトリ + {"type": "blob", "path": "src/app.py"}, + {"type": "tree", "path": "backend"}, # ディレクトリは除外 ] client = _client_with_tree(tree) - paths, truncated = _run( - fetch_manifest_paths(client, "u", "repo", "main", MANIFEST_FILENAMES) - ) - assert set(paths) == {"package.json", "backend/requirements.txt"} + paths, truncated = _run(fetch_repo_tree(client, "u", "repo", "main")) + assert set(paths) == { + "package.json", + "backend/requirements.txt", + "src/app.py", + } assert truncated is False def test_propagates_truncated_flag(): """Trees API の truncated を第 2 戻り値で返すこと。""" - client = _client_with_tree( - [{"type": "blob", "path": "go.mod"}], truncated=True - ) - paths, truncated = _run( - fetch_manifest_paths(client, "u", "repo", "main", MANIFEST_FILENAMES) - ) + client = _client_with_tree([{"type": "blob", "path": "go.mod"}], truncated=True) + paths, truncated = _run(fetch_repo_tree(client, "u", "repo", "main")) assert paths == ["go.mod"] assert truncated is True @@ -59,25 +58,19 @@ def test_propagates_truncated_flag(): def test_non_200_returns_partial(): """非 200 は「走査不能」として ([], True) を返すこと(依存ゼロと区別 / D9(d))。""" client = _client_with_tree([], status_code=404) - assert _run( - fetch_manifest_paths(client, "u", "repo", "main", MANIFEST_FILENAMES) - ) == ([], True) + assert _run(fetch_repo_tree(client, "u", "repo", "main")) == ([], True) def test_http_error_returns_partial(): """httpx.HTTPError も走査不能として ([], True) を返すこと。""" client = MagicMock() client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) - assert _run( - fetch_manifest_paths(client, "u", "repo", "main", MANIFEST_FILENAMES) - ) == ([], True) + assert _run(fetch_repo_tree(client, "u", "repo", "main")) == ([], True) def test_invalid_owner_repo_returns_empty(): """不正な owner/repo は走査対象ですらないため API を叩かず ([], False) を返すこと。""" client = _client_with_tree([{"type": "blob", "path": "go.mod"}]) - result = _run( - fetch_manifest_paths(client, "../evil", "repo", "main", MANIFEST_FILENAMES) - ) + result = _run(fetch_repo_tree(client, "../evil", "repo", "main")) assert result == ([], False) client.get.assert_not_called() diff --git a/backend/tests/test_github_collector_extended.py b/backend/tests/test_github_collector_extended.py index d4c2612e..94c142c2 100644 --- a/backend/tests/test_github_collector_extended.py +++ b/backend/tests/test_github_collector_extended.py @@ -17,8 +17,8 @@ from app.services.intelligence.github.api_client import GitHubUserNotFoundError from app.services.intelligence.github_collector import ( RepoData, - _collect_manifests, - _is_excluded_manifest_path, + _collect_repo_signals, + _is_excluded_path, _passes_filter, collect_repos, ) @@ -278,62 +278,65 @@ def test_multiple_repos_returned(self): assert "repo4" in names -# ── monorepo manifest 探索(ADR-0016 D9)─────────────────────────────────── +# ── monorepo 探索(ADR-0016 D9)+ import 解析(D6)────────────────────────── -class TestIsExcludedManifestPath: +class TestIsExcludedPath: def test_excludes_node_modules_segment(self): - assert _is_excluded_manifest_path("web/node_modules/x/package.json") is True + assert _is_excluded_path("web/node_modules/x/package.json") is True def test_excludes_dot_venv_segment(self): - assert _is_excluded_manifest_path(".venv/lib/requirements.txt") is True + assert _is_excluded_path(".venv/lib/requirements.txt") is True def test_keeps_clean_subtree_path(self): - assert _is_excluded_manifest_path("backend/requirements.txt") is False + assert _is_excluded_path("backend/requirements.txt") is False def test_keeps_root_manifest(self): - assert _is_excluded_manifest_path("package.json") is False + assert _is_excluded_path("package.json") is False -class TestCollectManifests: - """`_collect_manifests` の除外 / 深さ・件数キャップ / partial / source_path。""" +class TestCollectRepoSignals: + """`_collect_repo_signals` の manifest 探索(除外/キャップ/partial/source_path)と verify。""" - def _patched_collect(self, paths, truncated, *, monkeypatch=None): - """fetch_manifest_paths / fetch_repo_file をモックして _collect_manifests を実行。 + def _patched_collect(self, tree_paths, truncated, *, file_contents=None): + """fetch_repo_tree / fetch_repo_file をモックして _collect_repo_signals を実行。 - fetch_repo_file は basename に応じた最小 manifest 内容を返す。fetch された - パス一覧と (declarations, partial) を返す。 + ``tree_paths`` は recursive Trees API が返す全 blob パス(manifest + source)。 + fetch された全パスと (declarations, imported_symbols, partial) を返す。 """ fetched: list[str] = [] + contents = file_contents or {} async def _fetch_repo_file(_client, _owner, _repo, path): fetched.append(path) + if path in contents: + return contents[path] name = path.rsplit("/", 1)[-1] if name == "package.json": return '{"dependencies": {"react": "^18.0.0"}}' - if name in ("requirements.txt",): + if name == "requirements.txt": return "fastapi==0.110.0" return None with ( patch( - "app.services.intelligence.github_collector.fetch_manifest_paths", + "app.services.intelligence.github_collector.fetch_repo_tree", new_callable=AsyncMock, - return_value=(paths, truncated), + return_value=(tree_paths, truncated), ), patch( "app.services.intelligence.github_collector.fetch_repo_file", side_effect=_fetch_repo_file, ), ): - decls, partial = _run( - _collect_manifests(MagicMock(), "u", "repo", "main") + decls, imported, partial = _run( + _collect_repo_signals(MagicMock(), "u", "repo", "main") ) - return decls, partial, fetched + return decls, imported, partial, fetched def test_detects_subtree_manifest_and_attaches_source_path(self): """サブツリーの manifest を検出し source_path を付与すること(D9 a/f)。""" - decls, partial, fetched = self._patched_collect( + decls, _imported, partial, fetched = self._patched_collect( ["backend/requirements.txt"], False ) assert fetched == ["backend/requirements.txt"] @@ -344,7 +347,7 @@ def test_detects_subtree_manifest_and_attaches_source_path(self): def test_excluded_segment_paths_are_dropped(self): """除外セグメントを含むパスは fetch せず捨てること。partial にはしない(D9 b)。""" - decls, partial, fetched = self._patched_collect( + decls, _imported, partial, fetched = self._patched_collect( ["requirements.txt", "web/node_modules/x/package.json"], False ) assert fetched == ["requirements.txt"] @@ -353,7 +356,7 @@ def test_excluded_segment_paths_are_dropped(self): def test_truncated_marks_partial(self): """Trees API の truncated を partial として伝播すること(D9 d)。""" - _decls, partial, _fetched = self._patched_collect( + _decls, _imported, partial, _fetched = self._patched_collect( ["requirements.txt"], True ) assert partial is True @@ -361,7 +364,7 @@ def test_truncated_marks_partial(self): def test_depth_cap_drops_deep_paths_and_marks_partial(self, monkeypatch): """深さ上限超の manifest は落とし partial=True にすること(D9 c/d)。""" monkeypatch.setattr(github_collector, "_MANIFEST_MAX_DEPTH", 2) - decls, partial, fetched = self._patched_collect( + decls, _imported, partial, fetched = self._patched_collect( ["requirements.txt", "a/b/c/requirements.txt"], False ) # 深さ2まで("requirements.txt" のみ)。"a/b/c/requirements.txt" は4セグメントで除外。 @@ -372,9 +375,35 @@ def test_depth_cap_drops_deep_paths_and_marks_partial(self, monkeypatch): def test_count_cap_truncates_shallow_first_and_marks_partial(self, monkeypatch): """件数上限で浅い順に打ち切り partial=True にすること(D9 c/d)。""" monkeypatch.setattr(github_collector, "_MANIFEST_MAX_COUNT", 1) - _decls, partial, fetched = self._patched_collect( + _decls, _imported, partial, fetched = self._patched_collect( ["sub/requirements.txt", "requirements.txt"], False ) # 浅い順ソートで root を優先し 1 件で打ち切る。 assert fetched == ["requirements.txt"] assert partial is True + + def test_verify_scans_source_of_direct_ecosystems(self): + """direct 宣言のあるエコシステムの source を import 解析すること(D6)。""" + decls, imported, partial, fetched = self._patched_collect( + ["requirements.txt", "app/main.py"], + False, + file_contents={"app/main.py": "import fastapi\nfrom os import path\n"}, + ) + assert partial is False + # manifest と source の両方が同一ツリーから fetch される + assert "requirements.txt" in fetched + assert "app/main.py" in fetched + # pypi の import 名が抽出されている + assert "fastapi" in imported["pypi"] + assert {d.name for d in decls} == {"fastapi"} + + def test_verify_skips_source_without_direct_deps(self): + """direct 宣言が無いエコシステムの source はスキャンしないこと(D6 コスト抑制)。""" + # go.mod を返さず、direct 宣言は pypi のみ。.go ソースは走査対象外。 + _decls, imported, _partial, fetched = self._patched_collect( + ["requirements.txt", "main.go"], + False, + file_contents={"main.go": 'import "github.com/foo/bar"'}, + ) + assert "main.go" not in fetched + assert "go" not in imported diff --git a/backend/tests/test_skill_aggregator.py b/backend/tests/test_skill_aggregator.py index 68c7ce71..7f30cea1 100644 --- a/backend/tests/test_skill_aggregator.py +++ b/backend/tests/test_skill_aggregator.py @@ -11,15 +11,22 @@ ) -def _repo(full_name="testuser/repo", languages=None, declarations=None) -> RepoSkillInput: +def _repo( + full_name="testuser/repo", languages=None, declarations=None, imported=None +) -> RepoSkillInput: return RepoSkillInput( full_name=full_name, url=f"https://github.com/{full_name}", languages=languages or {}, package_declarations=declarations or [], + imported_symbols=imported or {}, ) +def _signals(evidence) -> set: + return {e.signal_source for e in evidence} + + def _by_name(skills) -> dict: return {s.canonical_name: s for s in skills} @@ -154,3 +161,68 @@ def test_partial_scan_not_set_on_language_evidence() -> None: lang_ev = by_name["Python"].evidence[0] assert lang_ev.partial_scan is False assert lang_ev.manifest_path is None + + +# ── verify(import 解析で actual_import へ昇格 / D6)────────────────────────── + + +def test_direct_dep_imported_adds_actual_import_evidence() -> None: + """direct 宣言が実 import されていたら actual_import 証跡を追加し、declare も残すこと。""" + skills = aggregate_skills( + [ + _repo( + declarations=[PackageDeclaration("npm", "react", "direct")], + imported={"npm": {"react"}}, + ) + ] + ) + react = _by_name(skills)["react"] + # declare(manifest_declared)+ verify(actual_import)の 2 証跡が共存する(保持は細かく / D8) + assert _signals(react.evidence) == {"manifest_declared", "actual_import"} + actual = next(e for e in react.evidence if e.signal_source == "actual_import") + assert actual.confidence == 0.85 + assert actual.dependency_kind == "direct" + + +def test_direct_dep_not_imported_keeps_only_declared() -> None: + """direct でも import されていなければ昇格しない(declare 証跡は残す / 降格なし)。""" + skills = aggregate_skills( + [ + _repo( + declarations=[PackageDeclaration("npm", "react", "direct")], + imported={"npm": {"lodash"}}, + ) + ] + ) + react = _by_name(skills)["react"] + assert _signals(react.evidence) == {"manifest_declared"} + + +def test_dev_dep_not_verified_even_if_imported() -> None: + """verify 対象は direct のみ。dev は import されていても昇格しないこと(D7)。""" + skills = aggregate_skills( + [ + _repo( + declarations=[PackageDeclaration("npm", "jest", "dev")], + imported={"npm": {"jest"}}, + ) + ] + ) + jest = _by_name(skills)["jest"] + assert _signals(jest.evidence) == {"manifest_declared"} + + +def test_verify_uses_ecosystem_matching_rules() -> None: + """照合はエコシステム別規則: go はサブパッケージ接頭辞一致で昇格すること。""" + skills = aggregate_skills( + [ + _repo( + declarations=[ + PackageDeclaration("go", "github.com/gin-gonic/gin", "direct") + ], + imported={"go": {"github.com/gin-gonic/gin/render"}}, + ) + ] + ) + gin = _by_name(skills)["github.com/gin-gonic/gin"] + assert "actual_import" in _signals(gin.evidence) diff --git a/backend/tests/test_skill_import_scanners.py b/backend/tests/test_skill_import_scanners.py new file mode 100644 index 00000000..1a2d6f63 --- /dev/null +++ b/backend/tests/test_skill_import_scanners.py @@ -0,0 +1,154 @@ +"""import スキャナ(verify / ADR-0016 D6)の単体テスト。 + +エコシステム別の import 抽出(scan)と、宣言 package との照合(matches)を検証する。 +辞書レスの保守的照合のため、import 名がずれるケース(PyYAML→yaml 等)は +昇格漏れ(false negative)になることも明示的に固定する。 +""" + +from app.services.intelligence.skills.imports import ( + SOURCE_EXTENSIONS, + scanner_for_ecosystem, + scanner_for_extension, +) +from app.services.intelligence.skills.imports.go import GoImportScanner +from app.services.intelligence.skills.imports.js_ts import JsTsImportScanner +from app.services.intelligence.skills.imports.python import PythonImportScanner +from app.services.intelligence.skills.imports.rust import RustImportScanner + +# ── registry ──────────────────────────────────────────────────────────── + + +def test_source_extensions_cover_all_ecosystems(): + """Tier1 全 4 エコシステムの代表拡張子が登録されていること。""" + for ext in (".py", ".ts", ".go", ".rs"): + assert ext in SOURCE_EXTENSIONS + + +def test_scanner_for_extension_dispatches_by_suffix(): + assert scanner_for_extension("a/b/main.py").ecosystem == "pypi" + assert scanner_for_extension("src/index.tsx").ecosystem == "npm" + assert scanner_for_extension("cmd/main.go").ecosystem == "go" + assert scanner_for_extension("src/lib.rs").ecosystem == "cargo" + assert scanner_for_extension("README.md") is None + assert scanner_for_extension("no_extension") is None + + +def test_scanner_for_ecosystem_lookup(): + assert isinstance(scanner_for_ecosystem("pypi"), PythonImportScanner) + assert scanner_for_ecosystem("unknown") is None + + +# ── Python ────────────────────────────────────────────────────────────── + + +class TestPythonScanner: + scanner = PythonImportScanner() + + def test_extracts_top_level_modules(self): + src = "import os\nimport fastapi\nfrom sqlalchemy.orm import Session\n" + assert self.scanner.scan(src) == {"os", "fastapi", "sqlalchemy"} + + def test_indented_imports(self): + src = "def f():\n import httpx\n" + assert "httpx" in self.scanner.scan(src) + + def test_matches_with_dash_to_underscore(self): + # canonical(PEP503)は小文字・ダッシュ。import 名はアンダースコア。 + assert self.scanner.matches("google-cloud-storage", {"google_cloud_storage"}) + + def test_false_negative_when_import_name_differs(self): + # PyYAML→yaml のような乖離は照合できない(昇格漏れを受容)。 + assert self.scanner.matches("pyyaml", {"yaml"}) is False + + +# ── JS/TS ─────────────────────────────────────────────────────────────── + + +class TestJsTsScanner: + scanner = JsTsImportScanner() + + def test_extracts_import_and_require(self): + src = ( + 'import React from "react";\n' + "const x = require('lodash');\n" + 'import { z } from "@scope/pkg";\n' + ) + assert self.scanner.scan(src) == {"react", "lodash", "@scope/pkg"} + + def test_subpath_is_reduced_to_package(self): + src = 'import s from "date-fns/locale";\n' + assert self.scanner.scan(src) == {"date-fns"} + + def test_relative_imports_excluded(self): + src = 'import a from "./local";\nimport b from "../up";\n' + assert self.scanner.scan(src) == set() + + def test_matches_exact_including_scope(self): + assert self.scanner.matches("@scope/pkg", {"@scope/pkg"}) + assert self.scanner.matches("react", {"preact"}) is False + + def test_ignores_imports_in_comments(self): + """コメント内の import/require は誤検出しないこと(CodeRabbit 指摘)。""" + src = ( + '// require("commented-out")\n' + "/* import x from 'block-commented' */\n" + 'import real from "real-pkg";\n' + ) + assert self.scanner.scan(src) == {"real-pkg"} + + def test_line_comment_strip_preserves_url_imports(self): + """行コメント除去が URL(http://)入り specifier を壊さないこと。""" + src = 'import a from "https://esm.sh/preact";\n' + # specifier は相対でないため preserve され、package 名として URL 先頭が拾われる + assert self.scanner.scan(src) != set() + + +# ── Go ────────────────────────────────────────────────────────────────── + + +class TestGoScanner: + scanner = GoImportScanner() + + def test_extracts_block_and_single_imports(self): + src = ( + 'import "fmt"\n' + "import (\n" + ' "github.com/gin-gonic/gin"\n' + ' h "github.com/foo/bar/http"\n' + ")\n" + ) + got = self.scanner.scan(src) + assert "fmt" in got + assert "github.com/gin-gonic/gin" in got + assert "github.com/foo/bar/http" in got + + def test_matches_module_and_subpackage_prefix(self): + imported = {"github.com/foo/bar/http"} + assert self.scanner.matches("github.com/foo/bar", imported) + assert self.scanner.matches("github.com/foo/ba", imported) is False + + +# ── Rust ──────────────────────────────────────────────────────────────── + + +class TestRustScanner: + scanner = RustImportScanner() + + def test_extracts_use_and_extern_crate(self): + src = "use serde_json::Value;\nextern crate tokio;\nuse crate::local;\n" + got = self.scanner.scan(src) + assert "serde_json" in got + assert "tokio" in got + # crate/self/super/std 等は外部クレートでないため除外 + assert "crate" not in got + + def test_matches_with_dash_to_underscore(self): + assert self.scanner.matches("serde-json", {"serde_json"}) + assert self.scanner.matches("serde-json", {"serde"}) is False + + def test_extracts_pub_use_reexports(self): + """pub use / pub(crate) use の re-export も外部クレート使用として拾うこと(指摘)。""" + src = "pub use serde::Serialize;\npub(crate) use tokio::task;\n" + got = self.scanner.scan(src) + assert "serde" in got + assert "tokio" in got diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index 261f04ba..51a9fb0d 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -143,7 +143,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ## 将来の移行条件 -- **verify ステージの詳細設計**: import サンプリング戦略、言語別の import 検出、打ち切り条件。 +- **verify ステージ**: D6 として実装済み(2026-06)。recursive Trees API の 1 コールを manifest 探索と共有し、direct 宣言のエコシステムの source だけを浅い順・件数キャップでサンプリング → import 解析。辞書レスの保守的照合(`-`→`_` 変換・go 接頭辞一致・npm 完全一致)で direct 宣言を `actual_import` へ**昇格のみ**(降格なし)。残課題は import 名乖離(PyYAML→yaml 等)の取りこぼし低減・サンプリング閾値の実データチューニング。 - **monorepo 対応**: D9 で採用済み(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all)。残課題は除外定義の高度化(Linguist `vendor.yml` 流用)・キャップ閾値の実データチューニング・規模シグナルの導入。 - **IaC からのインフラリソース検出**: 後述「将来課題: IaC からのインフラリソース検出」を参照(Terraform/OpenTofu 先行・provider+service 粒度・kind=infra 案・D9 探索流用)。 - **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 @@ -201,3 +201,4 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **2026-06**: 当初「代替案」で延期していた monorepo サブツリー探索を **D9 として採用**(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all + manifest パス永続化)。3 層モデル・D1〜D8 は不変。当初は別 ADR 案だったが、0016 の核を維持する refine であり 1 箇所の追補に留まるため、本 ADR への統合とした。 - **2026-06**: IaC からのインフラリソース検出(provider+service 粒度・HCL 先行・kind=infra 案・D9 探索流用・D8 同様の human-in-the-loop 正規化)を**将来課題として追記**。決定(D1〜D9)・3 層モデルは不変。 - **2026-06**: ステータス冒頭の「段階移行」を完了。旧決定論パイプライン(`skill_extractor.py` + `skill_taxonomy/` の自前辞書)を撤去し、live 経路を本基盤(`skills/aggregate_skills`)へ一本化。dashboard の `unique_skills` は検出 Layer 1 のうち**言語スキル(kind=language)の件数**から算出する(package は件数に含めない。API 契約 `GitHubLinkResponse` は不変)。 +- **2026-06**: **verify ステージ(D6)を実装**。`api_client.fetch_repo_tree`(recursive Trees 1 コール)を manifest 探索と共有し、`skills/imports/`(go / python / js_ts / rust の plugin スキャナ)で direct 宣言の実 import を解析、`actual_import` 証跡を**追加**(昇格のみ・降格なし / declare 証跡は保持)。生コードは scan 後に破棄(永続化なし)。決定(D1〜D9)・3 層モデル・スキーマは不変(`signal_source` 値域内のため migration 不要)。