From 033b01e90265bf4354bcf2c2af74ead03e3f0841 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:56:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(intelligence):=20IaC=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=82=A4=E3=83=B3=E3=83=95=E3=83=A9=E3=83=AA=E3=82=BD?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=82=92=E6=A4=9C=E5=87=BA=EF=BC=88ADR-0016?= =?UTF-8?q?=20D10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terraform/OpenTofu の .tf から provider(AWS/Google 等)と resource(具体 サービス。aws_s3_bucket 等)を抽出し、kind=infra / signal_source=infra_declared として 3 層モデルへ投入する。当初「検討中/未採用」だった将来課題を機械検出に 限定して採用(D10)。表示名の human-in-the-loop 畳み込みは別 PR。 - skills/infra/: 正規表現ベースの Terraform parser を plugin 型で新設(依存なし) - skills/types.py: SKILL_KIND_INFRA / InfraResourceDeclaration を追加 - github_collector.py: 同一 tree(1 コール)から .tf を探索、.terraform 除外・ 深さ/件数キャップ・partial 伝播(D9 探索を流用) - skills/aggregator.py: _collect_infra を追加。provider/resource を別スキルで keep-all し、リポあたり 1 evidence にデデュープ - schemas/github_skill.py: description を拡張し generated.ts を再生成 - docs/adr/0016: D10 を追記、将来課題を格上げ・改訂履歴を更新 kind/signal_source/ecosystem は既存 String カラムの値域内のため migration 不要。 既存 language/package 検出・unique_skills・API 契約は不変。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ae3B21DsBFV7ev8SozSYt9 --- backend/app/schemas/github_skill.py | 23 +++- .../services/intelligence/github_collector.py | 71 ++++++++-- .../intelligence/github_link_service.py | 2 + .../services/intelligence/skills/__init__.py | 3 +- .../intelligence/skills/aggregator.py | 71 ++++++++++ .../intelligence/skills/infra/__init__.py | 22 +++ .../intelligence/skills/infra/base.py | 32 +++++ .../intelligence/skills/infra/registry.py | 43 ++++++ .../intelligence/skills/infra/terraform.py | 112 +++++++++++++++ .../app/services/intelligence/skills/types.py | 20 +++ .../tests/test_github_collector_extended.py | 74 +++++++++- backend/tests/test_github_skills_api.py | 61 +++++++++ backend/tests/test_skill_aggregator.py | 95 +++++++++++++ backend/tests/test_skill_infra_parsers.py | 128 ++++++++++++++++++ docs/adr/0016-github-skill-inference.md | 25 +++- web/src/api/generated.ts | 10 +- 16 files changed, 759 insertions(+), 33 deletions(-) create mode 100644 backend/app/services/intelligence/skills/infra/__init__.py create mode 100644 backend/app/services/intelligence/skills/infra/base.py create mode 100644 backend/app/services/intelligence/skills/infra/registry.py create mode 100644 backend/app/services/intelligence/skills/infra/terraform.py create mode 100644 backend/tests/test_skill_infra_parsers.py diff --git a/backend/app/schemas/github_skill.py b/backend/app/schemas/github_skill.py index f3a6457c..3797645d 100644 --- a/backend/app/schemas/github_skill.py +++ b/backend/app/schemas/github_skill.py @@ -11,7 +11,9 @@ class SkillEvidence(BaseModel): repo_full_name: str = Field(description="根拠リポジトリ(owner/name)") repo_url: str = Field(description="リポジトリ URL(経歴書の証跡用)") signal_source: str = Field( - description="根拠の出所(language_bytes / manifest_declared / actual_import)" + description=( + "根拠の出所(language_bytes / manifest_declared / actual_import / infra_declared)" + ) ) confidence: float = Field(description="信頼度(0.0–1.0)") language_bytes: Optional[int] = Field( @@ -23,7 +25,10 @@ class SkillEvidence(BaseModel): ) manifest_path: Optional[str] = Field( default=None, - description="manifest の相対パス(package のみ。例 backend/requirements.txt。言語では null)", + description=( + "根拠ファイルの相対パス(package の manifest / infra の .tf。" + "例 backend/requirements.txt・infra/main.tf。言語では null)" + ), ) partial_scan: bool = Field( default=False, @@ -45,10 +50,18 @@ class SkillProficiency(BaseModel): class GitHubSkillItem(BaseModel): """Layer 1: 正規化スキルと、その根拠・習熟度。""" - kind: str = Field(description="スキル種別(language / package)") - canonical_name: str = Field(description="正規名(言語=Linguist 名 / package=package ID)") + kind: str = Field(description="スキル種別(language / package / infra)") + canonical_name: str = Field( + description=( + "正規名(言語=Linguist 名 / package=package ID / " + "infra=provider 名または raw resource type)" + ) + ) ecosystem: Optional[str] = Field( - default=None, description="package のエコシステム(npm/pypi/go/cargo)。言語では null" + default=None, + description=( + "エコシステム(package は npm/pypi/go/cargo、infra は terraform 等)。言語では null" + ), ) parent: Optional[str] = Field(default=None, description="親(Linguist の group)") display_name: Optional[str] = Field( diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py index 799135dc..03044c81 100644 --- a/backend/app/services/intelligence/github_collector.py +++ b/backend/app/services/intelligence/github_collector.py @@ -25,8 +25,9 @@ fetch_repos_raw, ) from .skills.imports import scanner_for_extension +from .skills.infra import INFRA_EXTENSIONS, parse_infra from .skills.manifests import MANIFEST_FILENAMES, parse_manifest -from .skills.types import PackageDeclaration +from .skills.types import InfraResourceDeclaration, PackageDeclaration logger = logging.getLogger(__name__) @@ -46,6 +47,8 @@ "dist", "build", ".git", + # D10: Terraform の provider キャッシュ。IaC 探索のノイズになるため除外する。 + ".terraform", } ) # D9(c): manifest パスのセグメント数上限(例: a/b/c/package.json = 4)。 @@ -56,6 +59,10 @@ _SOURCE_MAX_COUNT = 30 # D6: verify 対象ソースのセグメント数上限(浅い側を優先サンプリング)。 _SOURCE_MAX_DEPTH = 6 +# D10: IaC(.tf)探索のセグメント数上限。infra/modules/... へ分散するため manifest より深め。 +_INFRA_MAX_DEPTH = 6 +# D10: 1 リポあたり fetch する IaC ファイル件数上限(浅い側を優先サンプリング)。 +_INFRA_MAX_COUNT = 30 # このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が # ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。 @@ -87,6 +94,10 @@ class RepoData: imported_symbols: Dict[str, set] = field(default_factory=dict) # D9(d): ツリー走査(manifest / source)が網羅的でない(truncated / cap で打ち切り)場合 True。 manifest_scan_partial: bool = False + # D10: IaC(.tf)が宣言する provider / resource。未取得なら空。 + infra_declarations: List[InfraResourceDeclaration] = field(default_factory=list) + # D10: IaC 走査が網羅的でない(truncated / cap で打ち切り)場合 True。infra 根拠へ伝播する。 + infra_scan_partial: bool = False def _is_excluded_path(path: str) -> bool: @@ -110,15 +121,17 @@ def _select_shallow(paths: List[str], max_depth: int, max_count: int) -> tuple[L 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 解析)の両シグナルを集める。 +) -> tuple[List[PackageDeclaration], Dict[str, set], bool, List[InfraResourceDeclaration], bool]: + """1 回のツリー取得から declare(manifest)・verify(import 解析)・IaC の各シグナルを集める。 - recursive Trees API を **1 回だけ**呼び(D9(a))、その結果を manifest 探索と source 探索で - 共有する。manifest は宣言依存(D7・D9)、source は実 import(D6)を抽出する。いずれも - 除外セグメント(D9(b))・深さ/件数キャップ(D9(c) / D6 サンプリング)で絞る。取得・解析 - 失敗はベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。 + recursive Trees API を **1 回だけ**呼び(D9(a))、その結果を manifest 探索 / source 探索 / + IaC 探索で共有する。manifest は宣言依存(D7・D9)、source は実 import(D6)、IaC は + provider / resource(D10)を抽出する。いずれも除外セグメント(D9(b))・深さ/件数キャップ + (D9(c) / D6 サンプリング / D10)で絞る。取得・解析失敗はベストエフォートで握りつぶす + (1 リポの失敗で連携全体を落とさない)。 - 戻り値は (依存宣言, ecosystem→import名集合, 走査が部分的だったか / D9(d))。 + 戻り値は (依存宣言, ecosystem→import名集合, manifest 走査が部分的か / D9(d), + IaC 宣言 / D10, IaC 走査が部分的か / D10)。 """ tree_paths, truncated = await fetch_repo_tree(client, owner, repo, default_branch) @@ -166,17 +179,37 @@ async def _collect_repo_signals( scanner.scan(content) ) + # ── IaC: .tf を拡張子で抽出して provider / resource を解析する(D10)────────── + infra_paths = [ + p for p in tree_paths if any(p.endswith(ext) for ext in INFRA_EXTENSIONS) + ] + selected_infra, infra_dropped = _select_shallow( + infra_paths, _INFRA_MAX_DEPTH, _INFRA_MAX_COUNT + ) + infra_declarations: List[InfraResourceDeclaration] = [] + for path in selected_infra: + content = await fetch_repo_file(client, owner, repo, path) + if not content: + continue + # D9(f): 検出した相対パスを証跡として各宣言に付与する。生 HCL は解析後に破棄。 + infra_declarations.extend( + replace(decl, source_path=path) for decl in parse_infra(path, content) + ) + partial = bool(truncated or manifest_dropped or source_dropped) - if partial: + infra_partial = bool(truncated or infra_dropped) + if partial or infra_partial: logger.warning( - "ツリー走査が部分的: %s/%s (truncated=%s, manifest_dropped=%s, source_dropped=%s)", + "ツリー走査が部分的: %s/%s (truncated=%s, manifest_dropped=%s, " + "source_dropped=%s, infra_dropped=%s)", owner, repo, truncated, manifest_dropped, source_dropped, + infra_dropped, ) - return declarations, imported_symbols, partial + return declarations, imported_symbols, partial, infra_declarations, infra_partial def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool: @@ -245,11 +278,17 @@ async def collect_repos( declarations: List[PackageDeclaration] = [] imported_symbols: Dict[str, set] = {} scan_partial = False + infra_declarations: List[InfraResourceDeclaration] = [] + infra_scan_partial = False if collect_manifests: - declarations, imported_symbols, scan_partial = ( - await _collect_repo_signals( - client, owner_login, repo_name, default_branch - ) + ( + declarations, + imported_symbols, + scan_partial, + infra_declarations, + infra_scan_partial, + ) = await _collect_repo_signals( + client, owner_login, repo_name, default_branch ) repos.append( @@ -267,6 +306,8 @@ async def collect_repos( package_declarations=declarations, imported_symbols=imported_symbols, manifest_scan_partial=scan_partial, + infra_declarations=infra_declarations, + infra_scan_partial=infra_scan_partial, ) ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index 17728c87..036c3e86 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -134,6 +134,8 @@ async def _on_repo_fetched(done: int, total: int) -> None: package_declarations=repo.package_declarations, imported_symbols=repo.imported_symbols, manifest_scan_partial=repo.manifest_scan_partial, + infra_declarations=repo.infra_declarations, + infra_scan_partial=repo.infra_scan_partial, ) for repo in repos ] diff --git a/backend/app/services/intelligence/skills/__init__.py b/backend/app/services/intelligence/skills/__init__.py index a9006df1..8d907dfe 100644 --- a/backend/app/services/intelligence/skills/__init__.py +++ b/backend/app/services/intelligence/skills/__init__.py @@ -10,11 +10,12 @@ RepoSkillInput, aggregate_skills, ) -from .types import PackageDeclaration +from .types import InfraResourceDeclaration, PackageDeclaration __all__ = [ "DetectedSkill", "EvidenceRecord", + "InfraResourceDeclaration", "PackageDeclaration", "RepoSkillInput", "aggregate_skills", diff --git a/backend/app/services/intelligence/skills/aggregator.py b/backend/app/services/intelligence/skills/aggregator.py index dda56887..e4659400 100644 --- a/backend/app/services/intelligence/skills/aggregator.py +++ b/backend/app/services/intelligence/skills/aggregator.py @@ -13,8 +13,10 @@ from .imports import scanner_for_ecosystem from .linguist import resolve_language from .types import ( + SKILL_KIND_INFRA, SKILL_KIND_LANGUAGE, SKILL_KIND_PACKAGE, + InfraResourceDeclaration, PackageDeclaration, ) @@ -31,6 +33,11 @@ _SIGNAL_LANGUAGE_BYTES = "language_bytes" _SIGNAL_MANIFEST_DECLARED = "manifest_declared" _SIGNAL_ACTUAL_IMPORT = "actual_import" +# D10: IaC の宣言(provider / resource)。宣言 ≒ 実構築に近く verify 昇格は行わない。 +_SIGNAL_INFRA_DECLARED = "infra_declared" +# D10: infra 宣言の信頼度。resource(具体サービス)は provider より使用実績が明確なので高め。 +_INFRA_PROVIDER_CONFIDENCE = 0.5 +_INFRA_RESOURCE_CONFIDENCE = 0.6 # PEP 503 正規化用(連続する -_. を - に畳む)。 _PYPI_NAME_RE = re.compile(r"[-_.]+") @@ -88,6 +95,10 @@ class RepoSkillInput: imported_symbols: dict[str, set[str]] = field(default_factory=dict) # D9(d): このリポのツリー走査が部分的だったか。package 根拠へ伝播する。 manifest_scan_partial: bool = False + # D10: IaC(.tf)が宣言する provider / resource。 + infra_declarations: list[InfraResourceDeclaration] = field(default_factory=list) + # D10: IaC 走査が部分的だったか。infra 根拠へ伝播する。 + infra_scan_partial: bool = False def aggregate_skills(repos: list[RepoSkillInput]) -> list[DetectedSkill]: @@ -101,6 +112,7 @@ def aggregate_skills(repos: list[RepoSkillInput]) -> list[DetectedSkill]: for repo in repos: _collect_languages(skills, repo) _collect_packages(skills, repo) + _collect_infra(skills, repo) return list(skills.values()) @@ -218,6 +230,65 @@ def _collect_packages( ) +def _collect_infra( + skills: dict[tuple[str, str, str], DetectedSkill], repo: RepoSkillInput +) -> None: + """IaC 宣言(provider / resource)を kind=infra スキルへ集約する(declare 相当 / D10)。 + + provider(``aws``)と resource type(``aws_s3_bucket``)を別スキルとして keep-all する + (畳み込みは後段 HITL に委ねる / D8)。同一リポ内で同じ provider / resource が複数の .tf に + 現れても、``github_skill_evidence`` の一意制約 (skill_id, repo, signal_source) に合わせて + **リポあたり 1 根拠**にデデュープする(最初に見た source_path を証跡に採る)。 + """ + # canonical → 最初に採用する宣言(source_path 付き)。リポ内で 1 evidence に畳む。 + provider_seen: dict[str, InfraResourceDeclaration] = {} + resource_seen: dict[str, InfraResourceDeclaration] = {} + for decl in repo.infra_declarations: + if decl.provider and decl.provider not in provider_seen: + provider_seen[decl.provider] = decl + if decl.resource_type and decl.resource_type not in resource_seen: + resource_seen[decl.resource_type] = decl + + for provider, decl in provider_seen.items(): + _append_infra_skill( + skills, repo, decl, provider, _INFRA_PROVIDER_CONFIDENCE + ) + for resource_type, decl in resource_seen.items(): + _append_infra_skill( + skills, repo, decl, resource_type, _INFRA_RESOURCE_CONFIDENCE + ) + + +def _append_infra_skill( + skills: dict[tuple[str, str, str], DetectedSkill], + repo: RepoSkillInput, + decl: InfraResourceDeclaration, + canonical_name: str, + confidence: float, +) -> None: + """1 つの infra スキル(provider or resource)を upsert し根拠を積む。""" + # ecosystem は IaC ツール名("terraform")。将来の Pulumi / CloudFormation と区別する。 + skill = _upsert( + skills, + kind=SKILL_KIND_INFRA, + canonical_name=canonical_name, + ecosystem=decl.tool, + parent=None, + display_name=None, + ) + skill.evidence.append( + EvidenceRecord( + repo_full_name=repo.full_name, + repo_url=repo.url, + signal_source=_SIGNAL_INFRA_DECLARED, + confidence=confidence, + # D9(f): 検出した .tf の相対パスを証跡として残す。 + manifest_path=decl.source_path, + partial_scan=repo.infra_scan_partial, + ) + ) + + def _confidence(dependency_kind: str | None) -> float: return _DEPENDENCY_CONFIDENCE.get(dependency_kind or "", 0.2) diff --git a/backend/app/services/intelligence/skills/infra/__init__.py b/backend/app/services/intelligence/skills/infra/__init__.py new file mode 100644 index 00000000..db16a055 --- /dev/null +++ b/backend/app/services/intelligence/skills/infra/__init__.py @@ -0,0 +1,22 @@ +"""IaC からのインフラリソース検出(ADR-0016 D10)。 + +Terraform 等の HCL を解析し、provider(クラウド事業者)と resource(具体サービス)の +宣言を ``InfraResourceDeclaration`` として抽出する。plugin 型パーサ(``manifests`` / +``imports`` と同構造)で、対応 IaC ツールは ``registry.py`` で差し込む。 +""" + +from ..types import InfraResourceDeclaration +from .base import InfraParser +from .registry import ( + INFRA_EXTENSIONS, + parse_infra, + parser_for_path, +) + +__all__ = [ + "INFRA_EXTENSIONS", + "InfraParser", + "InfraResourceDeclaration", + "parse_infra", + "parser_for_path", +] diff --git a/backend/app/services/intelligence/skills/infra/base.py b/backend/app/services/intelligence/skills/infra/base.py new file mode 100644 index 00000000..c3a8b213 --- /dev/null +++ b/backend/app/services/intelligence/skills/infra/base.py @@ -0,0 +1,32 @@ +"""IaC パーサのプラグイン基底(ADR-0016 D10)。 + +各 IaC ツールのパーサは ``InfraParser`` を実装し、``extensions``(対応するファイル拡張子)と +``parse``(ファイル内容 → ``InfraResourceDeclaration`` 列)だけを提供する。新しい IaC ツール +(CloudFormation / Pulumi 等)は ``registry.py`` に 1 行足すだけで差し込める(plugin 型)。 + +manifest パーサ(``manifests/base.py``)との違い: + - manifest は basename(``go.mod`` 等)で一致、IaC は拡張子(``.tf``)で一致する。 + - 出力型が ``PackageDeclaration`` ではなく ``InfraResourceDeclaration``(provider / resource 粒度)。 + +パーサは I/O を行わない純粋関数として実装する(ファイル取得は呼び出し側の責務)。 +壊れた IaC ファイルは例外を投げずベストエフォート(取れた分だけ返す)で処理する +(1 リポの解析失敗で連携全体を落とさない)。 +""" + +from typing import Protocol, runtime_checkable + +from ..types import InfraResourceDeclaration + + +@runtime_checkable +class InfraParser(Protocol): + """IaC パーサのインターフェース。""" + + # このパーサが対応するファイル拡張子(先頭ドット付き。例: (".tf",))。 + extensions: tuple[str, ...] + # IaC ツール識別子("terraform")。 + tool: str + + def parse(self, content: str) -> list[InfraResourceDeclaration]: + """IaC ファイルの内容を ``InfraResourceDeclaration`` 列へ変換する。""" + ... diff --git a/backend/app/services/intelligence/skills/infra/registry.py b/backend/app/services/intelligence/skills/infra/registry.py new file mode 100644 index 00000000..730a94a1 --- /dev/null +++ b/backend/app/services/intelligence/skills/infra/registry.py @@ -0,0 +1,43 @@ +"""IaC パーサのレジストリ(ADR-0016 D10)。 + +v1 は Terraform / OpenTofu(``.tf``)のみ登録する。CloudFormation / Pulumi 等の +Tier2 は ``_PARSERS`` に 1 行足すだけで差し込める(plugin 型)。 +""" + +import os + +from ..types import InfraResourceDeclaration +from .base import InfraParser +from .terraform import TerraformParser + +# 登録済みパーサインスタンス。 +_PARSERS: tuple[InfraParser, ...] = (TerraformParser(),) + +# 拡張子(先頭ドット付き・小文字)→ パーサ。 +_BY_EXTENSION: dict[str, InfraParser] = { + ext: parser for parser in _PARSERS for ext in parser.extensions +} + +# リポジトリ内で探索対象とする IaC ファイルの拡張子集合(D9 探索の対象判定に使う)。 +INFRA_EXTENSIONS: frozenset[str] = frozenset(_BY_EXTENSION) + + +def _extension_of(path: str) -> str: + """パスの拡張子(先頭ドット付き・小文字)を返す。""" + return os.path.splitext(path)[1].lower() + + +def parser_for_path(path: str) -> InfraParser | None: + """パスの拡張子に対応する IaC パーサを返す(無ければ None)。""" + return _BY_EXTENSION.get(_extension_of(path)) + + +def parse_infra(path: str, content: str) -> list[InfraResourceDeclaration]: + """パスの拡張子に対応するパーサで IaC ファイルを解析する。 + + 未対応の拡張子なら空リストを返す。 + """ + parser = parser_for_path(path) + if parser is None: + return [] + return parser.parse(content) diff --git a/backend/app/services/intelligence/skills/infra/terraform.py b/backend/app/services/intelligence/skills/infra/terraform.py new file mode 100644 index 00000000..08927a70 --- /dev/null +++ b/backend/app/services/intelligence/skills/infra/terraform.py @@ -0,0 +1,112 @@ +"""Terraform / OpenTofu パーサ(tool=terraform / D10)。 + +static な HCL ブロックから provider(クラウド事業者)と resource(具体サービス)を抽出する: + + - ``provider "aws" {`` → provider 宣言(resource_type=None) + - ``required_providers { aws = { source } }``→ provider 宣言(source 末尾 or local 名) + - ``resource "aws_s3_bucket" "x" {`` → resource 宣言(provider は type 接頭辞) + +正規表現ベースで依存を持たない(既存の manifest パーサと同方針)。``module`` / ``count`` / +``for_each`` / ``dynamic`` による動的生成は静的列挙できないため対象外(将来課題)。壊れた HCL は +例外を投げずベストエフォート(取れた分だけ返す)で処理する。 +""" + +import re + +from ..types import InfraResourceDeclaration + +_TOOL = "terraform" + +# provider "aws" { +_PROVIDER_BLOCK = re.compile(r'^provider\s+"([^"]+)"\s*\{') +# resource "aws_s3_bucket" "name" { +_RESOURCE_BLOCK = re.compile(r'^resource\s+"([^"]+)"\s+"[^"]+"\s*\{') +# required_providers { +_REQUIRED_PROVIDERS_OPEN = re.compile(r'^required_providers\s*\{') +# required_providers 内の各エントリ(local 名): `aws = {` +_RP_LOCAL_ENTRY = re.compile(r'^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{') +# required_providers 内の source 行: `source = "hashicorp/aws"` +_RP_SOURCE = re.compile(r'source\s*=\s*"([^"]+)"') + + +class TerraformParser: + extensions: tuple[str, ...] = (".tf",) + tool: str = _TOOL + + def parse(self, content: str) -> list[InfraResourceDeclaration]: + declarations: list[InfraResourceDeclaration] = [] + in_required_providers = False + rp_depth = 0 # required_providers ブロック内のネスト深さ + pending_local: str | None = None # source 待ちの local 名 + pending_source: str | None = None + + def flush_provider() -> None: + nonlocal pending_local, pending_source + if pending_local is None: + return + # provider の「型」は source の末尾セグメント(hashicorp/aws → aws)を優先し、 + # 無ければ local 名を使う(resource の type 接頭辞と一致させるため)。 + provider = ( + pending_source.rsplit("/", 1)[-1] if pending_source else pending_local + ) + declarations.append( + InfraResourceDeclaration(tool=_TOOL, provider=provider) + ) + pending_local = None + pending_source = None + + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith(("#", "//")): + continue + + if in_required_providers: + # source を先に拾う(エントリと同一行のワンライナーにも対応)。 + source_match = _RP_SOURCE.search(line) + local_match = _RP_LOCAL_ENTRY.match(line) + if local_match: + # 新しいエントリに入る前に直前のエントリを確定する。 + flush_provider() + pending_local = local_match.group(1) + pending_source = source_match.group(1) if source_match else None + elif source_match and pending_local is not None: + pending_source = source_match.group(1) + + rp_depth += line.count("{") - line.count("}") + if rp_depth <= 0: + flush_provider() + in_required_providers = False + continue + + if _REQUIRED_PROVIDERS_OPEN.match(line): + in_required_providers = True + rp_depth = line.count("{") - line.count("}") + continue + + provider_match = _PROVIDER_BLOCK.match(line) + if provider_match: + declarations.append( + InfraResourceDeclaration( + tool=_TOOL, provider=provider_match.group(1) + ) + ) + continue + + resource_match = _RESOURCE_BLOCK.match(line) + if resource_match: + resource_type = resource_match.group(1) + # type 接頭辞(最初の `_` の前)が provider。例: aws_s3_bucket → aws。 + provider = resource_type.split("_", 1)[0] + declarations.append( + InfraResourceDeclaration( + tool=_TOOL, + provider=provider, + resource_type=resource_type, + ) + ) + + # ファイルが途中で切れて required_providers が閉じない場合も取れた分を確定する。 + if in_required_providers: + flush_provider() + + return declarations diff --git a/backend/app/services/intelligence/skills/types.py b/backend/app/services/intelligence/skills/types.py index 970cb9fe..04fbc01e 100644 --- a/backend/app/services/intelligence/skills/types.py +++ b/backend/app/services/intelligence/skills/types.py @@ -17,6 +17,9 @@ # Layer 1 スキルの種別(D2)。 SKILL_KIND_LANGUAGE = "language" SKILL_KIND_PACKAGE = "package" +# IaC(Terraform 等)から検出するインフラリソースのスキル種別(D10)。 +# language(幅)/ package(依存)とは検出方法が異なるため別 kind とする。 +SKILL_KIND_INFRA = "infra" @dataclass(frozen=True) @@ -32,3 +35,20 @@ class PackageDeclaration: version_spec: str | None = None # バージョン制約(生文字列。解釈はしない) # D9(f): manifest の相対パス(例: backend/requirements.txt)。証跡用。直下なら "package.json" 等。 source_path: str | None = None + + +@dataclass(frozen=True) +class InfraResourceDeclaration: + """IaC が宣言する 1 インフラリソース(declare 相当の出力 / D10)。 + + Terraform 等の HCL から抽出する。provider(クラウド事業者)と resource_type + (具体サービス。例 ``aws_s3_bucket``)を保持する。canonical は raw type をそのまま + 使い(辞書を持たない / D3)、表示名への畳み込みは後段の human-in-the-loop に委ねる(D8)。 + """ + + tool: str # IaC ツール("terraform")。将来の Pulumi / CloudFormation と区別する + provider: str # クラウドプロバイダ("aws" / "google" / "cloudflare" 等) + # 具体サービスの raw な resource type("aws_s3_bucket")。provider 宣言のみなら None + resource_type: str | None = None + # D9(f): 検出した .tf の相対パス(例: infra/modules/vpc/main.tf)。証跡用 + source_path: str | None = None diff --git a/backend/tests/test_github_collector_extended.py b/backend/tests/test_github_collector_extended.py index 94c142c2..c3296758 100644 --- a/backend/tests/test_github_collector_extended.py +++ b/backend/tests/test_github_collector_extended.py @@ -301,9 +301,19 @@ class TestCollectRepoSignals: def _patched_collect(self, tree_paths, truncated, *, file_contents=None): """fetch_repo_tree / fetch_repo_file をモックして _collect_repo_signals を実行。 - ``tree_paths`` は recursive Trees API が返す全 blob パス(manifest + source)。 - fetch された全パスと (declarations, imported_symbols, partial) を返す。 + ``tree_paths`` は recursive Trees API が返す全 blob パス(manifest + source + .tf)。 + fetch された全パスと (declarations, imported_symbols, partial) を返す + (infra 系は ``_patched_collect_full`` で参照する)。 """ + decls, imported, partial, _infra, _infra_partial, fetched = ( + self._patched_collect_full( + tree_paths, truncated, file_contents=file_contents + ) + ) + return decls, imported, partial, fetched + + def _patched_collect_full(self, tree_paths, truncated, *, file_contents=None): + """`_collect_repo_signals` の全戻り値(infra 含む)と fetch されたパスを返す。""" fetched: list[str] = [] contents = file_contents or {} @@ -329,10 +339,10 @@ async def _fetch_repo_file(_client, _owner, _repo, path): side_effect=_fetch_repo_file, ), ): - decls, imported, partial = _run( + decls, imported, partial, infra, infra_partial = _run( _collect_repo_signals(MagicMock(), "u", "repo", "main") ) - return decls, imported, partial, fetched + return decls, imported, partial, infra, infra_partial, fetched def test_detects_subtree_manifest_and_attaches_source_path(self): """サブツリーの manifest を検出し source_path を付与すること(D9 a/f)。""" @@ -407,3 +417,59 @@ def test_verify_skips_source_without_direct_deps(self): ) assert "main.go" not in fetched assert "go" not in imported + + # ── IaC(.tf)探索(D10)──────────────────────────────────────────────── + + def test_detects_tf_and_attaches_source_path(self): + """サブツリーの .tf を検出し provider / resource と source_path を付与すること。""" + tf = 'provider "aws" {}\nresource "aws_s3_bucket" "b" {}\n' + _decls, _imported, _partial, infra, infra_partial, fetched = ( + self._patched_collect_full( + ["infra/modules/s3/main.tf"], + False, + file_contents={"infra/modules/s3/main.tf": tf}, + ) + ) + assert "infra/modules/s3/main.tf" in fetched + assert infra_partial is False + providers = {d.provider for d in infra if d.resource_type is None} + resources = {d.resource_type for d in infra if d.resource_type} + assert providers == {"aws"} + assert resources == {"aws_s3_bucket"} + assert all(d.source_path == "infra/modules/s3/main.tf" for d in infra) + + def test_dot_terraform_cache_is_excluded(self): + """.terraform(provider キャッシュ)配下の .tf は fetch せず捨てること(D10)。""" + _decls, _imported, _partial, infra, infra_partial, fetched = ( + self._patched_collect_full( + [".terraform/modules/x/main.tf", "main.tf"], + False, + file_contents={"main.tf": 'provider "google" {}\n'}, + ) + ) + assert ".terraform/modules/x/main.tf" not in fetched + assert infra_partial is False + assert {d.provider for d in infra} == {"google"} + + def test_infra_count_cap_marks_infra_partial(self, monkeypatch): + """IaC 件数上限で打ち切ると infra_partial=True になること(D10 / D9 c/d)。""" + monkeypatch.setattr(github_collector, "_INFRA_MAX_COUNT", 1) + _decls, _imported, _partial, _infra, infra_partial, fetched = ( + self._patched_collect_full( + ["sub/a.tf", "b.tf"], + False, + file_contents={"b.tf": 'provider "aws" {}\n', "sub/a.tf": ""}, + ) + ) + # 浅い順で root の b.tf を優先し 1 件で打ち切る。 + assert fetched == ["b.tf"] + assert infra_partial is True + + def test_truncated_marks_infra_partial(self): + """Trees API の truncated は infra_partial にも伝播すること(D10 / D9 d)。""" + _decls, _imported, _partial, _infra, infra_partial, _fetched = ( + self._patched_collect_full( + ["main.tf"], True, file_contents={"main.tf": 'provider "aws" {}\n'} + ) + ) + assert infra_partial is True diff --git a/backend/tests/test_github_skills_api.py b/backend/tests/test_github_skills_api.py index da75fcdb..2b48a4b2 100644 --- a/backend/tests/test_github_skills_api.py +++ b/backend/tests/test_github_skills_api.py @@ -53,6 +53,44 @@ def _sample_detected() -> list[DetectedSkill]: ] +def _sample_infra_detected() -> list[DetectedSkill]: + """kind=infra の provider / resource スキル(D10)。""" + return [ + DetectedSkill( + kind="infra", + canonical_name="aws", + ecosystem="terraform", + parent=None, + display_name=None, + evidence=[ + EvidenceRecord( + repo_full_name="u/a", + repo_url="https://github.com/u/a", + signal_source="infra_declared", + confidence=0.5, + manifest_path="infra/main.tf", + ) + ], + ), + DetectedSkill( + kind="infra", + canonical_name="aws_s3_bucket", + ecosystem="terraform", + parent=None, + display_name=None, + evidence=[ + EvidenceRecord( + repo_full_name="u/a", + repo_url="https://github.com/u/a", + signal_source="infra_declared", + confidence=0.6, + manifest_path="infra/main.tf", + ) + ], + ), + ] + + def test_skills_requires_auth(client) -> None: """未認証では 401 になること。""" resp = client.get("/api/github-link/skills") @@ -95,6 +133,29 @@ def test_replace_then_get_returns_layers(client) -> None: assert python["evidence"][0]["partial_scan"] is False +def test_infra_skills_persist_and_return(client) -> None: + """kind=infra の provider / resource が往復で永続化・取得できること(D10)。""" + headers = auth_header(client, "skilluser_infra") + uid = _user_id(client, "skilluser_infra") + GitHubSkillRepository(client._db_session, uid).replace_for_user( + _sample_infra_detected() + ) + + resp = client.get("/api/github-link/skills", headers=headers) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + + provider = by_name["aws"] + assert provider["kind"] == "infra" + assert provider["ecosystem"] == "terraform" + assert provider["evidence"][0]["signal_source"] == "infra_declared" + assert provider["evidence"][0]["manifest_path"] == "infra/main.tf" + + resource = by_name["aws_s3_bucket"] + assert resource["kind"] == "infra" + assert resource["evidence"][0]["confidence"] == 0.6 + + def test_replace_is_idempotent(client) -> None: """洗い替えで前回分が消えること(再連携で古いスキルが残らない)。""" headers = auth_header(client, "skilluser_idem") diff --git a/backend/tests/test_skill_aggregator.py b/backend/tests/test_skill_aggregator.py index 7f30cea1..942ac61b 100644 --- a/backend/tests/test_skill_aggregator.py +++ b/backend/tests/test_skill_aggregator.py @@ -1,11 +1,13 @@ """スキル集計のテスト(ADR-0016 D1・D8)。""" from app.services.intelligence.skills import ( + InfraResourceDeclaration, PackageDeclaration, RepoSkillInput, aggregate_skills, ) from app.services.intelligence.skills.types import ( + SKILL_KIND_INFRA, SKILL_KIND_LANGUAGE, SKILL_KIND_PACKAGE, ) @@ -226,3 +228,96 @@ def test_verify_uses_ecosystem_matching_rules() -> None: ) gin = _by_name(skills)["github.com/gin-gonic/gin"] assert "actual_import" in _signals(gin.evidence) + + +# ── IaC 検出(kind=infra / D10)────────────────────────────────────────────── + + +def _infra_repo(full_name="u/infra", infra=None, partial=False) -> RepoSkillInput: + return RepoSkillInput( + full_name=full_name, + url=f"https://github.com/{full_name}", + languages={}, + infra_declarations=infra or [], + infra_scan_partial=partial, + ) + + +def test_infra_provider_and_resource_become_separate_skills() -> None: + """provider と resource が別々の kind=infra スキルになること(keep-all / D8)。""" + skills = aggregate_skills( + [ + _infra_repo( + infra=[ + InfraResourceDeclaration("terraform", "aws", None, "infra/main.tf"), + InfraResourceDeclaration( + "terraform", "aws", "aws_s3_bucket", "infra/main.tf" + ), + ] + ) + ] + ) + by_name = _by_name(skills) + assert set(by_name) == {"aws", "aws_s3_bucket"} + for skill in by_name.values(): + assert skill.kind == SKILL_KIND_INFRA + assert skill.ecosystem == "terraform" + assert skill.display_name is None # 表示名 HITL は別 PR + provider_ev = by_name["aws"].evidence[0] + resource_ev = by_name["aws_s3_bucket"].evidence[0] + assert provider_ev.signal_source == "infra_declared" + assert provider_ev.confidence == 0.5 + assert resource_ev.confidence == 0.6 + assert resource_ev.manifest_path == "infra/main.tf" + + +def test_infra_deduped_per_repo_to_single_evidence() -> None: + """同一 provider を複数 .tf が宣言してもリポあたり 1 evidence に畳むこと(一意制約対策)。""" + skills = aggregate_skills( + [ + _infra_repo( + infra=[ + InfraResourceDeclaration("terraform", "aws", None, "a/main.tf"), + InfraResourceDeclaration("terraform", "aws", None, "b/main.tf"), + ] + ) + ] + ) + aws = _by_name(skills)["aws"] + assert len(aws.evidence) == 1 + # 最初に見た source_path を証跡に採る。 + assert aws.evidence[0].manifest_path == "a/main.tf" + + +def test_infra_partial_scan_propagates() -> None: + """infra_scan_partial が infra 根拠へ伝播すること(D10 / D9(d))。""" + skills = aggregate_skills( + [ + _infra_repo( + infra=[ + InfraResourceDeclaration("terraform", "google", None, "main.tf") + ], + partial=True, + ) + ] + ) + google = _by_name(skills)["google"] + assert google.evidence[0].partial_scan is True + + +def test_infra_skill_deduped_across_repos() -> None: + """複数リポの同一 provider は 1 スキルに畳み、evidence が積み上がること(D8)。""" + skills = aggregate_skills( + [ + _infra_repo( + full_name="u/a", + infra=[InfraResourceDeclaration("terraform", "aws", None, "main.tf")], + ), + _infra_repo( + full_name="u/b", + infra=[InfraResourceDeclaration("terraform", "aws", None, "main.tf")], + ), + ] + ) + aws = _by_name(skills)["aws"] + assert {e.repo_full_name for e in aws.evidence} == {"u/a", "u/b"} diff --git a/backend/tests/test_skill_infra_parsers.py b/backend/tests/test_skill_infra_parsers.py new file mode 100644 index 00000000..8738f077 --- /dev/null +++ b/backend/tests/test_skill_infra_parsers.py @@ -0,0 +1,128 @@ +"""IaC(Terraform)パーサのテスト(ADR-0016 D10)。""" + +from app.services.intelligence.skills.infra import ( + INFRA_EXTENSIONS, + parse_infra, + parser_for_path, +) +from app.services.intelligence.skills.infra.terraform import TerraformParser + + +def _providers(declarations) -> set: + """resource_type を持たない(provider 宣言)の provider 名集合。""" + return {d.provider for d in declarations if d.resource_type is None} + + +def _resource_types(declarations) -> set: + return {d.resource_type for d in declarations if d.resource_type is not None} + + +def test_provider_block_detected() -> None: + """provider "aws" {} ブロックから provider を検出すること。""" + content = 'provider "aws" {\n region = "ap-northeast-1"\n}\n' + decls = TerraformParser().parse(content) + assert _providers(decls) == {"aws"} + + +def test_resource_block_derives_provider_from_prefix() -> None: + """resource "" から resource_type と、接頭辞由来の provider を検出すること。""" + content = ( + 'resource "aws_s3_bucket" "assets" {\n bucket = "x"\n}\n' + 'resource "google_storage_bucket" "b" {\n}\n' + ) + decls = TerraformParser().parse(content) + assert _resource_types(decls) == {"aws_s3_bucket", "google_storage_bucket"} + # resource 宣言は provider をタイプ接頭辞から導出する。 + by_type = {d.resource_type: d for d in decls if d.resource_type} + assert by_type["aws_s3_bucket"].provider == "aws" + assert by_type["google_storage_bucket"].provider == "google" + assert all(d.tool == "terraform" for d in decls) + + +def test_required_providers_uses_source_last_segment() -> None: + """required_providers の source 末尾セグメントを provider 名に採ること。""" + content = ( + "terraform {\n" + " required_providers {\n" + " aws = {\n" + ' source = "hashicorp/aws"\n' + ' version = "~> 5.0"\n' + " }\n" + " cloudflare = {\n" + ' source = "cloudflare/cloudflare"\n' + " }\n" + " }\n" + "}\n" + ) + decls = TerraformParser().parse(content) + assert _providers(decls) == {"aws", "cloudflare"} + + +def test_required_providers_oneline_entry() -> None: + """ワンライナーの required_providers エントリも拾えること。""" + content = ( + "terraform {\n" + " required_providers {\n" + ' google = { source = "hashicorp/google" }\n' + " }\n" + "}\n" + ) + decls = TerraformParser().parse(content) + assert _providers(decls) == {"google"} + + +def test_required_providers_without_source_falls_back_to_local_name() -> None: + """source が無いエントリは local 名を provider 名に採ること。""" + content = ( + "terraform {\n" + " required_providers {\n" + ' mycloud = { version = "1.0" }\n' + " }\n" + "}\n" + ) + decls = TerraformParser().parse(content) + assert _providers(decls) == {"mycloud"} + + +def test_dynamic_constructs_are_ignored() -> None: + """module / count / for_each 等の動的生成は静的列挙しない(D10 スコープ外)。""" + content = ( + 'module "vpc" {\n source = "terraform-aws-modules/vpc/aws"\n}\n' + 'resource "aws_instance" "web" {\n count = 3\n}\n' + ) + decls = TerraformParser().parse(content) + # module は無視、resource は静的に 1 件だけ拾う(count で増える分は列挙しない)。 + assert _resource_types(decls) == {"aws_instance"} + assert all("module" not in (d.resource_type or "") for d in decls) + + +def test_broken_hcl_is_best_effort() -> None: + """途中で切れた HCL でも取れた分を返し、例外を投げないこと。""" + content = ( + 'provider "aws" {\n region = "x"\n}\n' + 'resource "aws_s3_bucket" "a" {\n' # 閉じ括弧なしで終端 + ) + decls = TerraformParser().parse(content) + assert "aws" in _providers(decls) + assert "aws_s3_bucket" in _resource_types(decls) + + +def test_comments_are_skipped() -> None: + """コメント行はブロックとして誤検出しないこと。""" + content = ( + '# provider "fake" {\n' + '// resource "fake_thing" "x" {\n' + 'provider "azurerm" {\n}\n' + ) + decls = TerraformParser().parse(content) + assert _providers(decls) == {"azurerm"} + assert _resource_types(decls) == set() + + +def test_registry_dispatches_by_extension() -> None: + """.tf は Terraform パーサへ振り分け、非対応拡張子は空を返すこと。""" + assert ".tf" in INFRA_EXTENSIONS + assert parser_for_path("infra/main.tf") is not None + assert parser_for_path("README.md") is None + assert parse_infra("infra/main.tf", 'provider "aws" {\n}\n') + assert parse_infra("main.py", 'provider "aws" {\n}\n') == [] diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index 51a9fb0d..fc328f8a 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -117,6 +117,22 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 実装で触る想定箇所(後続 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` 再生成)。 +### D10. IaC(Terraform)からインフラリソースを検出する(2026-07 改訂で追加) + +当初「将来課題」としていた IaC からのインフラリソース検出を、**機械検出(幅)に限定して採用**する。Terraform/HCL は Linguist の*言語*スキル「Terraform」(kind=language)としては捕捉済みだが、「どのクラウドの何のサービスを IaC で構築したか」(provider / service 粒度)が取れていない。この検出軸を Layer 1-2 に足す。表示名の畳み込み(`aws_s3_bucket` → 「Amazon S3」)を伴う human-in-the-loop(D8)は package 層含め未実装のため、本 D10 のスコープからは外し別途とする(canonical は raw type を保持するだけ)。 + +- **(a) 対象 = Terraform / OpenTofu(`.tf`)**: parser は plugin 型(`InfraParser`)とし、CloudFormation / Pulumi / k8s manifest 等は後追いで差し込める設計にとどめる(v1 は Terraform のみ実装)。 +- **(b) 抽出粒度 = provider + service 両方**: `provider ""` / `required_providers` → クラウドプロバイダ、`resource "" ""` → 具体サービス(type 接頭辞で provider を導出。`aws_s3_bucket` → provider `aws`)。**static な `resource` ブロックの type 抽出に限定**し、`module` / `count` / `for_each` / `dynamic` による動的生成は静的列挙できないため対象外(将来課題)。 +- **(c) 新 kind `infra`(`SKILL_KIND_INFRA`)**: `package` の「エコシステム内で一意」前提(D3)に乗らないため別 kind とする(D2 の思想)。provider(`aws`)と resource type(`aws_s3_bucket`)を**別スキルとして keep-all**(D8「保持は細かく」。畳み込みは後段ビュー変換 / HITL へ)。canonical は raw な resource type / provider 名を保持(辞書を持たない / D3)。`ecosystem` は IaC ツール名(`terraform`)を入れ、将来の Pulumi / CloudFormation と区別する。 +- **(d) 新 signal_source `infra_declared`**: Layer 2 の値域拡張のみ(`github_skill_evidence.signal_source` は `String(30)`・CHECK なしのため **migration 不要**。同様に kind=`infra` / ecosystem=`terraform` も既存カラムに収まる)。`resource` ブロックは「宣言 ≒ 実構築」に近く、import 解析(verify / D6)のような昇格段階は設けない。 +- **(e) 探索は D9 を流用**: `.tf` は `infra/modules/...` 等サブツリーに分散するため、recursive Trees API の 1 コール(manifest 探索と共有)+ パスセグメント除外(`.terraform` を追加)+ 深さ/件数キャップ + keep-all がそのまま効く。生 HCL は parse 後に破棄(D6 と同じく永続化しない)。 +- **(f) 証跡 = `.tf` の相対パス**: D9(f) と同様、検出した `.tf` の相対パスを evidence(`manifest_path`)に保持する。public リポ前提で相対パスは公開メタデータ。 +- **(g) parser は依存を持たない**: HCL ライブラリを導入せず正規表現ベースで実装する(既存 manifest パーサと同方針。static ブロックは正規表現で十分)。 + +新規値オブジェクト: `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/infra/`(`InfraParser` Protocol・`terraform.py`・registry)/ `github_collector.py`(`.tf` 探索・`.terraform` 除外・partial 伝播)/ `skills/aggregator.py`(`_collect_infra`・`infra_declared`)/ `github_link_service.py`(`RepoSkillInput` へ伝播)/ `schemas/github_skill.py`(docstring 拡張 + `make codegen-types`)。migration・新規依存はなし。 + ### この設計で得られるもの - エビデンス系スキルに裏付けが付き、経歴書の GitHub URL との整合(証跡性)が立つ。 @@ -145,14 +161,16 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **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 探索流用)。 +- **IaC からのインフラリソース検出**: 機械検出は **D10 で採用・実装済み**(2026-07。Terraform/OpenTofu・provider+service 粒度・kind=infra・signal_source=infra_declared・D9 探索流用・正規表現 parser)。残課題は表示名の HITL 畳み込み・動的 module 解決・Tier2 IaC・resource 出現回数の量的シグナル。 - **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 - **deps.dev エンリッチ**: 横断名寄せの範囲・実行タイミング。 - **閾値・粒度のデフォルト**: 言語足切りの初期値、表示名 alias の初期セット。 -## 将来課題: IaC からのインフラリソース検出(検討中 / 未採用) +## 将来課題: IaC からのインフラリソース検出(機械検出は D10 で採用済み / 2026-07) + +> **更新(2026-07)**: 本項の「機械検出(provider+service・static resource・kind=infra)」は **D10 として採用・実装済み**。以下は当初の課題提起の記録であり、**残る未採用部分**は「表示名の human-in-the-loop 畳み込み」「動的 module 解決」「Tier2 IaC(CloudFormation / Pulumi / k8s / Helm)」「resource 出現回数の量的シグナル(ADD COLUMN 案)」。実装済みの決定は D10 を正とする。 -**背景**: Terraform/HCL は Linguist により*言語*スキル「Terraform」として検出済み(D3・D9(g))。一方「どのクラウドの何のサービスを IaC で構築・運用したか」は捉えられていない。インフラが **IaC で記述されている場合に限り**、宣言から具体的なインフラリソースを抽出すれば、インフラ系スキルの幅と証跡性が上がる。本項は新しい検出軸の**課題提起**であり、採用(実装)は別途判断する。 +**背景**: Terraform/HCL は Linguist により*言語*スキル「Terraform」として検出済み(D3・D9(g))。一方「どのクラウドの何のサービスを IaC で構築・運用したか」は捉えられていない。インフラが **IaC で記述されている場合に限り**、宣言から具体的なインフラリソースを抽出すれば、インフラ系スキルの幅と証跡性が上がる。 **スコープ(v1 課題時)**: @@ -198,6 +216,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ## 改訂履歴 +- **2026-07**: 「将来課題」だった IaC からのインフラリソース検出を **D10 として採用・実装**(Terraform/OpenTofu の `.tf` を対象に provider+service を抽出、kind=`infra` / signal_source=`infra_declared`、static resource ブロック限定、正規表現 parser で依存なし、D9 探索流用で `.terraform` 除外を追加、canonical=raw type で keep-all)。表示名の human-in-the-loop 畳み込み・動的 module 解決・Tier2 IaC・出現回数の量的シグナルは残課題。kind / signal_source / ecosystem は既存カラムの値域内のため migration 不要。3 層モデル・D1〜D9 は不変。 - **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` は不変)。 diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 06f9c965..d2394e3a 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -1521,7 +1521,7 @@ export interface components { GitHubSkillItem: { /** * Canonical Name - * @description 正規名(言語=Linguist 名 / package=package ID) + * @description 正規名(言語=Linguist 名 / package=package ID / infra=provider 名または raw resource type) */ canonical_name: string; /** @@ -1531,14 +1531,14 @@ export interface components { display_name?: string | null; /** * Ecosystem - * @description package のエコシステム(npm/pypi/go/cargo)。言語では null + * @description エコシステム(package は npm/pypi/go/cargo、infra は terraform 等)。言語では null */ ecosystem?: string | null; /** Evidence */ evidence?: components["schemas"]["SkillEvidence"][]; /** * Kind - * @description スキル種別(language / package) + * @description スキル種別(language / package / infra) */ kind: string; /** @@ -1843,7 +1843,7 @@ export interface components { language_bytes?: number | null; /** * Manifest Path - * @description manifest の相対パス(package のみ。例 backend/requirements.txt。言語では null) + * @description 根拠ファイルの相対パス(package の manifest / infra の .tf。例 backend/requirements.txt・infra/main.tf。言語では null) */ manifest_path?: string | null; /** @@ -1864,7 +1864,7 @@ export interface components { repo_url: string; /** * Signal Source - * @description 根拠の出所(language_bytes / manifest_declared / actual_import) + * @description 根拠の出所(language_bytes / manifest_declared / actual_import / infra_declared) */ signal_source: string; }; From c32526ee69686ea51336372093df868baf2eb4c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 23:12:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(intelligence):=20IaC=20=E6=8E=A2?= =?UTF-8?q?=E7=B4=A2=E3=81=AE=E6=B7=B1=E3=81=95=E3=82=AD=E3=83=A3=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 指摘対応。_MANIFEST_MAX_DEPTH と同様に _INFRA_MAX_DEPTH の 深さ上限テストを追加し、深い .tf が fetch から落ち infra_partial が立つことを ガードする(count キャップ・truncated に続く D10 / D9(c)(d) の網羅)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ae3B21DsBFV7ev8SozSYt9 --- .../tests/test_github_collector_extended.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/tests/test_github_collector_extended.py b/backend/tests/test_github_collector_extended.py index c3296758..11902652 100644 --- a/backend/tests/test_github_collector_extended.py +++ b/backend/tests/test_github_collector_extended.py @@ -451,6 +451,24 @@ def test_dot_terraform_cache_is_excluded(self): assert infra_partial is False assert {d.provider for d in infra} == {"google"} + def test_infra_depth_cap_drops_deep_paths_and_marks_infra_partial(self, monkeypatch): + """深さ上限超の .tf は落とし infra_partial=True にすること(D10 / D9 c/d)。""" + monkeypatch.setattr(github_collector, "_INFRA_MAX_DEPTH", 2) + _decls, _imported, _partial, infra, infra_partial, fetched = ( + self._patched_collect_full( + ["main.tf", "a/b/c/main.tf"], + False, + file_contents={ + "main.tf": 'provider "aws" {}\n', + "a/b/c/main.tf": 'provider "google" {}\n', + }, + ) + ) + # 深さ2まで("main.tf" のみ)。"a/b/c/main.tf" は4セグメントで除外。 + assert fetched == ["main.tf"] + assert infra_partial is True + assert {d.provider for d in infra} == {"aws"} + def test_infra_count_cap_marks_infra_partial(self, monkeypatch): """IaC 件数上限で打ち切ると infra_partial=True になること(D10 / D9 c/d)。""" monkeypatch.setattr(github_collector, "_INFRA_MAX_COUNT", 1)