Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions backend/app/schemas/github_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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(
Expand Down
71 changes: 56 additions & 15 deletions backend/app/services/intelligence/github_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -46,6 +47,8 @@
"dist",
"build",
".git",
# D10: Terraform の provider キャッシュ。IaC 探索のノイズになるため除外する。
".terraform",
}
)
# D9(c): manifest パスのセグメント数上限(例: a/b/c/package.json = 4)。
Expand All @@ -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`` で参照するため再エクスポートする。
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)
)

Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/intelligence/github_link_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/intelligence/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
RepoSkillInput,
aggregate_skills,
)
from .types import PackageDeclaration
from .types import InfraResourceDeclaration, PackageDeclaration

__all__ = [
"DetectedSkill",
"EvidenceRecord",
"InfraResourceDeclaration",
"PackageDeclaration",
"RepoSkillInput",
"aggregate_skills",
Expand Down
71 changes: 71 additions & 0 deletions backend/app/services/intelligence/skills/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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"[-_.]+")
Expand Down Expand Up @@ -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]:
Expand All @@ -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())

Expand Down Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions backend/app/services/intelligence/skills/infra/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
32 changes: 32 additions & 0 deletions backend/app/services/intelligence/skills/infra/base.py
Original file line number Diff line number Diff line change
@@ -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`` 列へ変換する。"""
...
Loading
Loading