From b98a2e59188badd4e002568467663127300fbe2f Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 27 Jun 2026 14:43:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(intelligence):=20monorepo=20manifest?= =?UTF-8?q?=20=E6=8E=A2=E7=B4=A2=E3=81=A8=20manifest=5Fpath=20=E6=B0=B8?= =?UTF-8?q?=E7=B6=9A=E5=8C=96=EF=BC=88ADR-0016=20D9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recursive Trees API でサブツリーの manifest を探索し、backend/requirements.txt や web/package.json のような分散構成でも依存を拾えるようにする。検出した manifest の相対パスと部分スキャンフラグを github_skill_evidence に永続化する。 - github_collector: recursive Trees API でのパス列挙・除外セグメント (node_modules / vendor 等)・深さ/件数キャップ・partial フラグ伝播 - api_client: fetch_manifest_paths(truncated 判定込み)/ fetch_repo_file - skills: PackageDeclaration.source_path / EvidenceRecord.manifest_path / partial_scan を types・aggregator に伝播 - models / schema: github_skill_evidence に manifest_path・partial_scan を追加 (migration 0046)。OpenAPI 型を再生成 - tests: api_client / collector / aggregator のテストを追加 Co-Authored-By: Claude Opus 4.8 --- ..._manifest_path_to_github_skill_evidence.py | 40 +++++++ backend/app/models/skill.py | 8 ++ backend/app/repositories/skill.py | 2 + backend/app/routers/github_link.py | 2 + backend/app/schemas/github_skill.py | 8 ++ .../intelligence/github/api_client.py | 51 ++++++--- .../services/intelligence/github_collector.py | 95 +++++++++++++--- .../intelligence/github_link_service.py | 1 + .../intelligence/skills/aggregator.py | 10 ++ .../app/services/intelligence/skills/types.py | 2 + backend/tests/test_github_api_client.py | 73 ++++++++++++ .../tests/test_github_collector_extended.py | 105 ++++++++++++++++++ backend/tests/test_github_skills_api.py | 8 ++ backend/tests/test_skill_aggregator.py | 34 ++++++ docs/adr/0016-github-skill-inference.md | 40 ++++++- web/src/api/generated.ts | 11 ++ 16 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 backend/alembic_migrations/versions/0046_add_manifest_path_to_github_skill_evidence.py create mode 100644 backend/tests/test_github_api_client.py diff --git a/backend/alembic_migrations/versions/0046_add_manifest_path_to_github_skill_evidence.py b/backend/alembic_migrations/versions/0046_add_manifest_path_to_github_skill_evidence.py new file mode 100644 index 00000000..c8d45bbe --- /dev/null +++ b/backend/alembic_migrations/versions/0046_add_manifest_path_to_github_skill_evidence.py @@ -0,0 +1,40 @@ +"""github_skill_evidence に manifest_path / partial_scan を追加する(ADR-0016 D9) + +monorepo サブツリー探索(D9)で検出した manifest の相対パス(証跡 / D9(f))と、 +走査が網羅でない部分スキャンだったか(D9(d))を Layer 2 根拠に保持する。 + +子テーブル(他から FK 参照されない)への素の ADD COLUMN のみ。libSQL は +``ALTER TABLE ADD COLUMN`` を直接サポートするため ``batch_alter_table`` は使わない。 + +Revision ID: 0046_add_manifest_path_to_github_skill_evidence +Revises: 0045_add_github_skill_tables +Create Date: 2026-06-26 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0046_add_manifest_path_to_github_skill_evidence" +down_revision: Union[str, None] = "0045_add_github_skill_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "github_skill_evidence", + sa.Column("manifest_path", sa.String(length=255), nullable=True), + ) + op.add_column( + "github_skill_evidence", + sa.Column( + "partial_scan", sa.Boolean(), nullable=False, server_default="0" + ), + ) + + +def downgrade() -> None: + op.drop_column("github_skill_evidence", "partial_scan") + op.drop_column("github_skill_evidence", "manifest_path") diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 0b14da24..4f43f68f 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -122,6 +122,14 @@ class GitHubSkillEvidence(Base): dependency_kind: Mapped[str | None] = mapped_column( String(20), nullable=True, default=None ) + # D9(f): manifest の相対パス(package 根拠のみ。例 backend/requirements.txt) + manifest_path: Mapped[str | None] = mapped_column( + String(255), nullable=True, default=None + ) + # D9(d): 網羅でない部分スキャン由来か(truncated / cap 打ち切り。証跡の過信防止) + partial_scan: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="0" + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=func.now(), server_default=func.now(), nullable=False ) diff --git a/backend/app/repositories/skill.py b/backend/app/repositories/skill.py index c476b6ab..e922734d 100644 --- a/backend/app/repositories/skill.py +++ b/backend/app/repositories/skill.py @@ -64,6 +64,8 @@ def replace_for_user(self, detected: list[DetectedSkill]) -> None: confidence=ev.confidence, language_bytes=ev.language_bytes, dependency_kind=ev.dependency_kind, + manifest_path=ev.manifest_path, + partial_scan=ev.partial_scan, ) for ev in item.evidence ] diff --git a/backend/app/routers/github_link.py b/backend/app/routers/github_link.py index cd9a335c..d45dc423 100644 --- a/backend/app/routers/github_link.py +++ b/backend/app/routers/github_link.py @@ -122,6 +122,8 @@ def _to_skill_item(skill) -> GitHubSkillItem: confidence=ev.confidence, language_bytes=ev.language_bytes, dependency_kind=ev.dependency_kind, + manifest_path=ev.manifest_path, + partial_scan=ev.partial_scan, ) for ev in skill.evidence ], diff --git a/backend/app/schemas/github_skill.py b/backend/app/schemas/github_skill.py index 663a8a62..f3a6457c 100644 --- a/backend/app/schemas/github_skill.py +++ b/backend/app/schemas/github_skill.py @@ -21,6 +21,14 @@ class SkillEvidence(BaseModel): default=None, description="依存の種類(direct/dev/indirect/peer/build。言語では null)", ) + manifest_path: Optional[str] = Field( + default=None, + description="manifest の相対パス(package のみ。例 backend/requirements.txt。言語では null)", + ) + partial_scan: bool = Field( + default=False, + description="網羅でない部分スキャン由来か(証跡の過信防止。言語では常に false)", + ) class SkillProficiency(BaseModel): diff --git a/backend/app/services/intelligence/github/api_client.py b/backend/app/services/intelligence/github/api_client.py index aa27885d..d0559293 100644 --- a/backend/app/services/intelligence/github/api_client.py +++ b/backend/app/services/intelligence/github/api_client.py @@ -165,29 +165,50 @@ async def fetch_languages( return {} -async def fetch_root_filenames( +async def fetch_manifest_paths( client: httpx.AsyncClient, owner: str, repo: str, -) -> List[str]: - """リポジトリ直下のファイル名一覧を取得する(declare の manifest 探索用 / D7)。 - - manifest の取得はベストエフォート(1 リポの失敗で連携全体を落とさない)なので、 - エラー時は空リストを返す。サブツリー探索は v1 では行わない(直下のみ)。 + 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 リポの失敗で連携全体を落とさない)なので、失敗時は ``([], False)``。 """ if not _is_valid_owner_repo(owner, repo): - return [] + return [], False + branch = default_branch or "main" try: - resp = await client.get(f"/repos/{owner}/{repo}/contents") + resp = await client.get( + f"/repos/{owner}/{repo}/git/trees/{branch}", + params={"recursive": "1"}, + ) if resp.status_code != 200: - return [] - entries = resp.json() - if not isinstance(entries, list): - return [] - return [e["name"] for e in entries if e.get("type") == "file" and e.get("name")] + return [], False + data = resp.json() + if not isinstance(data, dict): + return [], False + tree = data.get("tree") + if not isinstance(tree, list): + return [], False + 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 + ] + return paths, bool(data.get("truncated")) except httpx.HTTPError: - logger.warning("Failed to list root contents for %s/%s", owner, repo) - return [] + logger.warning("Failed to fetch git tree for %s/%s", owner, repo) + return [], False async def fetch_repo_file( diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py index 22d49786..9cd033d5 100644 --- a/backend/app/services/intelligence/github_collector.py +++ b/backend/app/services/intelligence/github_collector.py @@ -7,7 +7,7 @@ import logging from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timezone from typing import Dict, List, Optional @@ -20,15 +20,38 @@ GITHUB_API, GitHubUserNotFoundError, fetch_languages, + fetch_manifest_paths, fetch_repo_file, fetch_repos_raw, - fetch_root_filenames, ) from .skills.manifests import MANIFEST_FILENAMES, parse_manifest from .skills.types import PackageDeclaration logger = logging.getLogger(__name__) +# monorepo manifest 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、 +# env_keys 同期コストに見合わないためモジュール定数として持つ(チューニングはコード変更)。 +# D9(b): パスのいずれかのセグメントがこの集合に該当したら manifest 候補から除外する。 +_MANIFEST_PATH_EXCLUDE_SEGMENTS = frozenset( + { + "node_modules", + "vendor", + ".venv", + "venv", + "site-packages", + "bower_components", + "third_party", + "testdata", + "dist", + "build", + ".git", + } +) +# D9(c): manifest パスのセグメント数上限(例: a/b/c/package.json = 4)。 +_MANIFEST_MAX_DEPTH = 4 +# D9(c): 1 リポあたり fetch する manifest 件数上限。 +_MANIFEST_MAX_COUNT = 20 + # このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が # ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。 __all__ = [ @@ -52,26 +75,59 @@ class RepoData: fork: bool stargazers_count: int default_branch: str = field(default="main") - # declare ステージ: 直下 manifest が宣言する依存(D7)。未取得なら空。 + # declare ステージ: manifest が宣言する依存(D7・D9。サブツリー含む)。未取得なら空。 package_declarations: List[PackageDeclaration] = field(default_factory=list) + # D9(d): manifest 走査が網羅的でない(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("/")) async def _collect_manifests( - client: httpx.AsyncClient, owner: str, repo: str -) -> List[PackageDeclaration]: - """リポジトリ直下の manifest を取得・解析して依存宣言を返す(declare / D7)。 + client: httpx.AsyncClient, owner: str, repo: str, default_branch: str +) -> tuple[List[PackageDeclaration], bool]: + """サブツリーを含む manifest を取得・解析して依存宣言を返す(declare / D7・D9)。 - 直下のファイル一覧と既知 manifest 名の積集合だけを取得する。取得・解析失敗は - ベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。 + recursive Trees API で候補パスを列挙し、除外セグメント(D9(b))・深さ/件数キャップ + (D9(c))でフィルタしてから本文を取得する。取得・解析失敗はベストエフォートで握りつぶす + (1 リポの失敗で連携全体を落とさない)。第 2 戻り値は走査が部分的だったか(D9(d))。 """ - filenames = await fetch_root_filenames(client, owner, repo) - targets = [name for name in filenames if name in MANIFEST_FILENAMES] + 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.sort(key=lambda p: (p.count("/"), p)) + within_depth = [p for p in candidates if p.count("/") + 1 <= _MANIFEST_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, + ) + declarations: List[PackageDeclaration] = [] - for filename in targets: - content = await fetch_repo_file(client, owner, repo, filename) - if content: - declarations.extend(parse_manifest(filename, content)) - return declarations + for path in selected: + content = await fetch_repo_file(client, owner, repo, path) + if not content: + continue + filename = path.rsplit("/", 1)[-1] + # D9(f): 検出した相対パスを証跡として各宣言に付与する。 + declarations.extend( + replace(decl, source_path=path) for decl in parse_manifest(filename, content) + ) + return declarations, partial def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool: @@ -135,10 +191,12 @@ async def collect_repos( languages = await fetch_languages(client, owner_login, repo_name) + default_branch = raw.get("default_branch", "main") declarations: List[PackageDeclaration] = [] + manifest_partial = False if collect_manifests: - declarations = await _collect_manifests( - client, owner_login, repo_name + declarations, manifest_partial = await _collect_manifests( + client, owner_login, repo_name, default_branch ) repos.append( @@ -152,8 +210,9 @@ async def collect_repos( pushed_at=raw.get("pushed_at", ""), fork=raw.get("fork", False), stargazers_count=raw.get("stargazers_count", 0), - default_branch=raw.get("default_branch", "main"), + default_branch=default_branch, package_declarations=declarations, + manifest_scan_partial=manifest_partial, ) ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index ef3219cf..68cb150e 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -133,6 +133,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, + 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 c5143b6c..d8466bd8 100644 --- a/backend/app/services/intelligence/skills/aggregator.py +++ b/backend/app/services/intelligence/skills/aggregator.py @@ -54,6 +54,10 @@ class EvidenceRecord: confidence: float language_bytes: int | None = None dependency_kind: str | None = None + # D9(f): manifest の相対パス(package 根拠のみ。language では None)。 + manifest_path: str | None = None + # D9(d): 部分スキャン由来か(manifest 根拠のみ True になりうる。language は常に False)。 + partial_scan: bool = False @dataclass @@ -76,6 +80,8 @@ class RepoSkillInput: url: str languages: dict[str, int] package_declarations: list[PackageDeclaration] = field(default_factory=list) + # D9(d): このリポの manifest 走査が部分的だったか。package 根拠へ伝播する。 + manifest_scan_partial: bool = False def aggregate_skills(repos: list[RepoSkillInput]) -> list[DetectedSkill]: @@ -182,6 +188,10 @@ def _collect_packages( signal_source=_SIGNAL_MANIFEST_DECLARED, confidence=_confidence(decl.dependency_kind), dependency_kind=decl.dependency_kind, + # D9(f): 採用した宣言の manifest パスを証跡として残す。 + manifest_path=decl.source_path, + # D9(d): partial は manifest 根拠にのみ立てる(language には立てない)。 + partial_scan=repo.manifest_scan_partial, ) ) diff --git a/backend/app/services/intelligence/skills/types.py b/backend/app/services/intelligence/skills/types.py index 46cf0dcd..970cb9fe 100644 --- a/backend/app/services/intelligence/skills/types.py +++ b/backend/app/services/intelligence/skills/types.py @@ -30,3 +30,5 @@ class PackageDeclaration: name: str # エコシステム内で一意な package ID dependency_kind: str # DEPENDENCY_KINDS のいずれか version_spec: str | None = None # バージョン制約(生文字列。解釈はしない) + # D9(f): manifest の相対パス(例: backend/requirements.txt)。証跡用。直下なら "package.json" 等。 + source_path: str | None = None diff --git a/backend/tests/test_github_api_client.py b/backend/tests/test_github_api_client.py new file mode 100644 index 00000000..e4025b35 --- /dev/null +++ b/backend/tests/test_github_api_client.py @@ -0,0 +1,73 @@ +"""github/api_client の fetch_manifest_paths のテスト(ADR-0016 D9)。 + +recursive Trees API のレスポンスをモックし、実 GitHub API は叩かない。 +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from app.services.intelligence.github.api_client import fetch_manifest_paths +from app.services.intelligence.skills.manifests import MANIFEST_FILENAMES + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _client_with_tree(tree, truncated=False, status_code=200): + """Trees API レスポンスを返す AsyncClient モックを生成する。""" + resp = MagicMock(status_code=status_code) + resp.json = MagicMock(return_value={"tree": tree, "truncated": truncated}) + client = MagicMock() + client.get = AsyncMock(return_value=resp) + return client + + +def test_returns_blob_paths_matching_manifest_basenames(): + """basename が既知 manifest 名の blob だけを返すこと。""" + tree = [ + {"type": "blob", "path": "package.json"}, + {"type": "blob", "path": "backend/requirements.txt"}, + {"type": "blob", "path": "README.md"}, # manifest でない + {"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"} + 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) + ) + assert paths == ["go.mod"] + assert truncated is True + + +def test_non_200_returns_empty_besteffort(): + """非 200 はベストエフォートで ([], False) を返すこと。""" + client = _client_with_tree([], status_code=404) + assert _run( + fetch_manifest_paths(client, "u", "repo", "main", MANIFEST_FILENAMES) + ) == ([], False) + + +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) + ) + 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 a7fe0407..d4c2612e 100644 --- a/backend/tests/test_github_collector_extended.py +++ b/backend/tests/test_github_collector_extended.py @@ -13,9 +13,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from app.services.intelligence import github_collector from app.services.intelligence.github.api_client import GitHubUserNotFoundError from app.services.intelligence.github_collector import ( RepoData, + _collect_manifests, + _is_excluded_manifest_path, _passes_filter, collect_repos, ) @@ -273,3 +276,105 @@ def test_multiple_repos_returned(self): names = {r.name for r in repos} assert "repo0" in names assert "repo4" in names + + +# ── monorepo manifest 探索(ADR-0016 D9)─────────────────────────────────── + + +class TestIsExcludedManifestPath: + def test_excludes_node_modules_segment(self): + assert _is_excluded_manifest_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 + + def test_keeps_clean_subtree_path(self): + assert _is_excluded_manifest_path("backend/requirements.txt") is False + + def test_keeps_root_manifest(self): + assert _is_excluded_manifest_path("package.json") is False + + +class TestCollectManifests: + """`_collect_manifests` の除外 / 深さ・件数キャップ / partial / source_path。""" + + def _patched_collect(self, paths, truncated, *, monkeypatch=None): + """fetch_manifest_paths / fetch_repo_file をモックして _collect_manifests を実行。 + + fetch_repo_file は basename に応じた最小 manifest 内容を返す。fetch された + パス一覧と (declarations, partial) を返す。 + """ + fetched: list[str] = [] + + async def _fetch_repo_file(_client, _owner, _repo, path): + fetched.append(path) + name = path.rsplit("/", 1)[-1] + if name == "package.json": + return '{"dependencies": {"react": "^18.0.0"}}' + if name in ("requirements.txt",): + return "fastapi==0.110.0" + return None + + with ( + patch( + "app.services.intelligence.github_collector.fetch_manifest_paths", + new_callable=AsyncMock, + return_value=(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") + ) + return decls, partial, fetched + + def test_detects_subtree_manifest_and_attaches_source_path(self): + """サブツリーの manifest を検出し source_path を付与すること(D9 a/f)。""" + decls, partial, fetched = self._patched_collect( + ["backend/requirements.txt"], False + ) + assert fetched == ["backend/requirements.txt"] + assert partial is False + assert len(decls) == 1 + assert decls[0].name == "fastapi" + assert decls[0].source_path == "backend/requirements.txt" + + def test_excluded_segment_paths_are_dropped(self): + """除外セグメントを含むパスは fetch せず捨てること。partial にはしない(D9 b)。""" + decls, partial, fetched = self._patched_collect( + ["requirements.txt", "web/node_modules/x/package.json"], False + ) + assert fetched == ["requirements.txt"] + assert partial is False + assert {d.source_path for d in decls} == {"requirements.txt"} + + def test_truncated_marks_partial(self): + """Trees API の truncated を partial として伝播すること(D9 d)。""" + _decls, partial, _fetched = self._patched_collect( + ["requirements.txt"], True + ) + assert partial is True + + 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( + ["requirements.txt", "a/b/c/requirements.txt"], False + ) + # 深さ2まで("requirements.txt" のみ)。"a/b/c/requirements.txt" は4セグメントで除外。 + assert fetched == ["requirements.txt"] + assert partial is True + assert {d.source_path for d in decls} == {"requirements.txt"} + + 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( + ["sub/requirements.txt", "requirements.txt"], False + ) + # 浅い順ソートで root を優先し 1 件で打ち切る。 + assert fetched == ["requirements.txt"] + assert partial is True diff --git a/backend/tests/test_github_skills_api.py b/backend/tests/test_github_skills_api.py index be7ac824..f071e09e 100644 --- a/backend/tests/test_github_skills_api.py +++ b/backend/tests/test_github_skills_api.py @@ -43,6 +43,8 @@ def _sample_detected() -> list[DetectedSkill]: signal_source="manifest_declared", confidence=0.6, dependency_kind="direct", + manifest_path="web/package.json", + partial_scan=True, ) ], ), @@ -83,6 +85,12 @@ def test_replace_then_get_returns_layers(client) -> None: react = by_name["react"] assert react["ecosystem"] == "npm" assert react["evidence"][0]["dependency_kind"] == "direct" + # D9: manifest パスと partial フラグが往復で永続化・取得されること。 + assert react["evidence"][0]["manifest_path"] == "web/package.json" + assert react["evidence"][0]["partial_scan"] is True + # 言語根拠には manifest_path / partial が立たないこと。 + assert python["evidence"][0]["manifest_path"] is None + assert python["evidence"][0]["partial_scan"] is False def test_replace_is_idempotent(client) -> None: diff --git a/backend/tests/test_skill_aggregator.py b/backend/tests/test_skill_aggregator.py index 144f550a..68c7ce71 100644 --- a/backend/tests/test_skill_aggregator.py +++ b/backend/tests/test_skill_aggregator.py @@ -120,3 +120,37 @@ def test_non_pypi_names_not_normalized() -> None: def test_empty_repo_yields_no_skills() -> None: assert aggregate_skills([_repo()]) == [] + + +def test_manifest_path_and_partial_propagate_to_package_evidence() -> None: + """D9: source_path / partial が package 根拠へ伝播すること。""" + repo = RepoSkillInput( + full_name="u/mono", + url="https://github.com/u/mono", + languages={"Python": 1000}, + package_declarations=[ + PackageDeclaration( + "pypi", "fastapi", "direct", source_path="backend/requirements.txt" + ) + ], + manifest_scan_partial=True, + ) + by_name = _by_name(aggregate_skills([repo])) + ev = by_name["fastapi"].evidence[0] + assert ev.manifest_path == "backend/requirements.txt" + assert ev.partial_scan is True + + +def test_partial_scan_not_set_on_language_evidence() -> None: + """D9: partial は manifest 根拠のみ。language 根拠は常に False のままにすること。""" + repo = RepoSkillInput( + full_name="u/mono", + url="https://github.com/u/mono", + languages={"Python": 1000}, + package_declarations=[], + manifest_scan_partial=True, + ) + by_name = _by_name(aggregate_skills([repo])) + lang_ev = by_name["Python"].evidence[0] + assert lang_ev.partial_scan is False + assert lang_ev.manifest_path is None diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index 715fdba2..82865358 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -113,7 +113,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **(d) truncated / 打ち切りは partial としてマークする**: 巨大リポで Trees API が `truncated: true` を返した場合、または件数キャップ(c)で打ち切った場合は、取得済み分(浅い側優先)を使い `logger.warning` を残す(1 リポの取りこぼしで連携全体を落とさないベストエフォート方針)。加えて、**その走査が網羅的でない(partial)ことを当該リポの evidence にマークして永続化**し、下流が「網羅スキャン」と「ベストエフォートの部分スキャン」を区別できるようにする(保持は細かく / D8。証跡の過信を防ぐ)。 - **(e) 主従重み付けはしない(keep-all)**: 複数 manifest を主従判定して集約せず、全 manifest を evidence として保持する(D1/D8「非可逆な切り捨てを入力時に行わない」に従い、畳み込み・重み付けは後段ビュー変換へ)。延期理由の 3 つ目(主従判定)はこれで回避する。 - **(f) manifest パスを証跡として永続化する**: 検出した相対パス(例: `backend/requirements.txt`)を evidence に保存する(`PackageDeclaration.source_path` → `EvidenceRecord` → `github_skill_evidence.manifest_path`)。対象は public リポ前提で相対パスは公開メタデータ(既存の `repo_url` と同等の性質)、raw code は D6 どおり破棄するため機密情報を持たない設計を崩さない。証跡性が「どのディレクトリの何で宣言したか」まで強化される。 -- **(g) 対象は manifest 限定(infra の .tf は対象外)**: Terraform/HCL は package manifest ではなく language 層(Linguist → 表示名「Terraform」/ D3)で捕捉済みのため、本探索は D7 の Tier1 4 エコシステム(Go / Python / JS-TS / Rust。manifest ファイルは Python が 2 種のため計 5 ファイル)の manifest にのみ適用する。 +- **(g) 対象は manifest 限定(infra の .tf は対象外)**: Terraform/HCL は package manifest ではなく language 層(Linguist → 表示名「Terraform」/ D3)で捕捉済みのため、本探索は D7 の Tier1 4 エコシステム(Go / Python / JS-TS / Rust。manifest ファイルは Python が 2 種のため計 5 ファイル)の manifest にのみ適用する。(ただし `.tf` から**インフラリソース**(プロバイダ/サービス)を抽出する検出軸は本 manifest 探索とは別物で、後述「将来課題: IaC からのインフラリソース検出」に記載する。) 実装で触る想定箇所(後続 PR): `github/api_client.py`(`fetch_root_filenames` を `fetch_manifest_paths(..., default_branch)` に拡張し、truncated/打ち切りの有無も返す)/ `github_collector.py`(パス一覧反復・`source_path` 付与・partial フラグ伝播)/ `skills/types.py`(`PackageDeclaration.source_path` / `EvidenceRecord.manifest_path` / partial マーカー)/ `skills/aggregator.py`(path・partial 伝播)/ `models/skill.py` + 新規 migration(`github_skill_evidence.manifest_path` と partial フラグを `op.add_column`)/ `schemas/github_skill.py`(`SkillEvidence` への追加、`make codegen-types` 再生成)。 @@ -145,10 +145,45 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **verify ステージの詳細設計**: import サンプリング戦略、言語別の import 検出、打ち切り条件。 - **monorepo 対応**: D9 で採用済み(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all)。残課題は除外定義の高度化(Linguist `vendor.yml` 流用)・キャップ閾値の実データチューニング・規模シグナルの導入。 +- **IaC からのインフラリソース検出**: 後述「将来課題: IaC からのインフラリソース検出」を参照(Terraform/OpenTofu 先行・provider+service 粒度・kind=infra 案・D9 探索流用)。 - **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 - **deps.dev エンリッチ**: 横断名寄せの範囲・実行タイミング。 - **閾値・粒度のデフォルト**: 言語足切りの初期値、表示名 alias の初期セット。 +## 将来課題: IaC からのインフラリソース検出(検討中 / 未採用) + +**背景**: Terraform/HCL は Linguist により*言語*スキル「Terraform」として検出済み(D3・D9(g))。一方「どのクラウドの何のサービスを IaC で構築・運用したか」は捉えられていない。インフラが **IaC で記述されている場合に限り**、宣言から具体的なインフラリソースを抽出すれば、インフラ系スキルの幅と証跡性が上がる。本項は新しい検出軸の**課題提起**であり、採用(実装)は別途判断する。 + +**スコープ(v1 課題時)**: + +- 対象 IaC: まず **Terraform / OpenTofu(HCL `.tf`)** に限定。parser は **plugin 型**(D7 の `ManifestParser` と同思想)とし、CloudFormation(yaml/json) / Pulumi / k8s manifest / Helm / Serverless Framework は後追いで差し込める設計にとどめる(v1 では実装しない)。 +- 抽出粒度: **プロバイダとサービスの両方**。 + - `provider` / `required_providers` ブロック → クラウドプロバイダ(AWS / Google Cloud / Cloudflare 等)。 + - `resource "" ""` ブロック → 具体サービス。type 接頭辞でプロバイダを判定(`aws_` → AWS)、type 自体がサービスを表す(`aws_s3_bucket` → S3)。 + +**3 層モデルへの収め方(案)**: + +- Layer 1 に**新 kind `infra`**(`SKILL_KIND_INFRA`)を追加。`package` の「エコシステム内で一意」前提(D3)に乗らないため別 kind とする(D2 の「検出方法と信頼度の出方が違うなら別型」と同思想)。 +- canonical は **raw な resource type / provider 名を保持**(例 `aws_s3_bucket`)。`aws_s3_bucket` → 「Amazon S3」のような**表示名・粒度の畳み込みは文脈依存で機械検証不能**なので、D8 同様 **agent 提案 → 人間確定**の human-in-the-loop に委ねる(機械に辞書を確定させない / D3・D8)。「保持は細かく、提案は荒目」を踏襲。 +- Layer 2 Evidence に**新 signal_source `infra_declared`**を追加。量的シグナルは resource 出現回数・provider 宣言の有無。`github_skill_evidence.signal_source` は値域拡張のみ(カラム追加不要)。provider version 等の追加メタを持つ場合のみ ADD COLUMN migration を別途。 + +**既存基盤の流用**: + +- **探索は D9 をそのまま流用**: `.tf` は `infra/modules/...` 等サブツリーに分散するのが常で、recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all がそのまま効く。除外集合に `.terraform`(プロバイダキャッシュ)を追加する。manifest 探索とは別の対象集合(`*.tf`)として扱う。 +- **証跡**: D9(f) と同様、検出した `.tf` の相対パスを evidence に保持。raw HCL は D6 同様 parse 後に破棄。 + +**ステージ**: declare 相当(宣言検出)に位置づく。`resource` ブロックは「宣言 ≒ 実構築」に近く、import 解析(verify / D6)のような昇格段階は基本不要。 + +**新規値オブジェクト(案)**: `InfraResourceDeclaration(tool, provider, resource_type, source_path)` を IaC parser plugin が返し、aggregator に `_collect_infra()` を追加して `kind=infra` で集約する(`_collect_languages` / `_collect_packages` と並ぶ)。 + +**触る想定箇所(将来実装時)**: `skills/types.py`(`SKILL_KIND_INFRA` / `InfraResourceDeclaration`)/ `skills/manifests/`(IaC parser plugin・Protocol 一般化 or `InfraParser` 別定義)/ 新規 `skills/manifests/terraform.py`(HCL の provider・resource 抽出)/ `skills/aggregator.py`(`_collect_infra`)/ `github_collector.py`(`.tf` 探索・除外集合に `.terraform` 追加)/ `models/skill.py`・`schemas/github_skill.py`(kind / signal_source の値域・docstring 更新、必要なら provider_version の ADD COLUMN migration + `make codegen-types`)。 + +**トレードオフ・リスク**: + +- **IaC 限定の取りこぼし**: コンソール手動構築・他者管理基盤など IaC 化されていないインフラスキルは検出不能。public 限定(既知リスク)と同様、Layer 3 で人間が補完する。 +- **HCL の動的生成**: `module` 呼び出し・`count` / `for_each` / `dynamic` で resource が動的に増える構成は静的列挙しきれない。v1 課題は**静的 `resource` ブロックの type 抽出**に限定し、module 解決は将来課題とする。 +- **resource type → 表示名マッピングの維持コスト**: D3「辞書を持たない」思想とテンション。canonical を raw type 保持・表示名のみ human-in-the-loop 提案にすることで、機械辞書の常時メンテを避ける。横断名寄せが要る場合のみ Terraform Registry を参照し、D4 のホットパス非依存に合わせ内部マスタ化を検討する。 + ## 関連リンク - ADR-0010(DevForge Agent / 制約の責務分離の元思想)/ ADR-0013(マルチプロバイダ LLM)/ ADR-0015(Vertex AI 経由) @@ -158,7 +193,10 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - [GitHub REST API: Git Trees(`recursive`)](https://docs.github.com/en/rest/git/trees) - [GitHub Linguist `vendor.yml`](https://github.com/github-linguist/linguist/blob/main/lib/linguist/vendor.yml) - [deps.dev](https://deps.dev/) +- [Terraform Registry](https://registry.terraform.io/)(resource type ↔ provider/service 正規化の参照元 / 将来課題「IaC リソース検出」用) +- [OpenTofu](https://opentofu.org/) ## 改訂履歴 - **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 層モデルは不変。 diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 34639272..72d2e268 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -1879,6 +1879,17 @@ export interface components { * @description 言語シグナルのバイト数(package では null) */ language_bytes?: number | null; + /** + * Manifest Path + * @description manifest の相対パス(package のみ。例 backend/requirements.txt。言語では null) + */ + manifest_path?: string | null; + /** + * Partial Scan + * @description 網羅でない部分スキャン由来か(証跡の過信防止。言語では常に false) + * @default false + */ + partial_scan: boolean; /** * Repo Full Name * @description 根拠リポジトリ(owner/name) From 92cd2657eb4cf1bbd4fc41deeded5023bd4fcdc8 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 27 Jun 2026 14:54:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?review(intelligence):=20tree=20=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E5=A4=B1=E6=95=97=E3=82=92=E9=83=A8=E5=88=86=E3=82=B9?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=B3=E3=81=A8=E3=81=97=E3=81=A6=E4=BC=9D?= =?UTF-8?q?=E6=92=AD=EF=BC=88CodeRabbit=20=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_manifest_paths が非200 / 不正レスポンス / httpx.HTTPError 時に ([], False) を返していたため、_collect_manifests が「取得失敗」と 「manifest が本当に無い」を区別できず manifest_scan_partial を立てられて いなかった。失敗時は partial=True を返し、依存ゼロの過信を防ぐ(D9(d))。 不正 owner/repo は走査対象ですらないため ([], False) を維持。 Co-Authored-By: Claude Opus 4.8 --- .../services/intelligence/github/api_client.py | 13 ++++++++----- backend/tests/test_github_api_client.py | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/backend/app/services/intelligence/github/api_client.py b/backend/app/services/intelligence/github/api_client.py index d0559293..d11895bd 100644 --- a/backend/app/services/intelligence/github/api_client.py +++ b/backend/app/services/intelligence/github/api_client.py @@ -180,7 +180,10 @@ async def fetch_manifest_paths( 除外リストや深さ・件数キャップといった探索ポリシーは呼び出し側(collector)の責務とし、 ここでは「API 呼び出し + basename フィルタ + truncated 返却」に留める。manifest 取得は - ベストエフォート(1 リポの失敗で連携全体を落とさない)なので、失敗時は ``([], False)``。 + ベストエフォート(1 リポの失敗で連携全体を落とさない)。ただし tree 取得自体が失敗した場合 + (非200 / 不正レスポンス / ``httpx.HTTPError``)は「依存ゼロ」と「走査不能」を区別するため、 + 第 2 戻り値の partial を ``True`` にして部分スキャンとして伝播する(D9(d))。不正 owner/repo は + 実在リポではなく走査対象ですらないため ``([], False)`` のままとする。 """ if not _is_valid_owner_repo(owner, repo): return [], False @@ -191,13 +194,13 @@ async def fetch_manifest_paths( params={"recursive": "1"}, ) if resp.status_code != 200: - return [], False + return [], True data = resp.json() if not isinstance(data, dict): - return [], False + return [], True tree = data.get("tree") if not isinstance(tree, list): - return [], False + return [], True paths = [ entry["path"] for entry in tree @@ -208,7 +211,7 @@ async def fetch_manifest_paths( return paths, bool(data.get("truncated")) except httpx.HTTPError: logger.warning("Failed to fetch git tree for %s/%s", owner, repo) - return [], False + return [], True async def fetch_repo_file( diff --git a/backend/tests/test_github_api_client.py b/backend/tests/test_github_api_client.py index e4025b35..ec8bed78 100644 --- a/backend/tests/test_github_api_client.py +++ b/backend/tests/test_github_api_client.py @@ -6,6 +6,7 @@ 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 @@ -55,16 +56,25 @@ def test_propagates_truncated_flag(): assert truncated is True -def test_non_200_returns_empty_besteffort(): - """非 200 はベストエフォートで ([], False) を返すこと。""" +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) - ) == ([], False) + ) == ([], 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) def test_invalid_owner_repo_returns_empty(): - """不正な owner/repo は API を叩かず ([], False) を返すこと。""" + """不正な owner/repo は走査対象ですらないため API を叩かず ([], False) を返すこと。""" client = _client_with_tree([{"type": "blob", "path": "go.mod"}]) result = _run( fetch_manifest_paths(client, "../evil", "repo", "main", MANIFEST_FILENAMES)