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
5 changes: 3 additions & 2 deletions .claude/rules/backend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ backend/app/
│ │ │ └── repo_analyzer.py
│ │ ├── response_mapper.py
│ │ └── skills/ # スキル推論基盤(ADR-0016 / 3層モデル)
│ │ ├── aggregator.py # discover+declare 合流 → DetectedSkill
│ │ ├── aggregator.py # discover+declare+verify 合流 → DetectedSkill
│ │ ├── linguist.py # 言語正規化(Linguist languages.yml)
│ │ └── manifests/ # エコシステム別 manifest パーサ(plugin 型)
│ │ ├── manifests/ # エコシステム別 manifest パーサ(declare / plugin 型)
│ │ └── imports/ # エコシステム別 import スキャナ(verify / plugin 型)
│ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル)
│ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ)
│ │ ├── exceptions.py # RetryableError / NonRetryableError
Expand Down
31 changes: 14 additions & 17 deletions backend/app/services/intelligence/github/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,25 +165,24 @@ async def fetch_languages(
return {}


async def fetch_manifest_paths(
async def fetch_repo_tree(
client: httpx.AsyncClient,
owner: str,
repo: str,
default_branch: str,
manifest_filenames: frozenset[str],
) -> tuple[List[str], bool]:
"""recursive Trees API でサブツリーを含む manifest パス一覧を取得する(declare / D7・D9)。

``GET /git/trees/{default_branch}?recursive=1`` を 1 回呼び、blob のうち basename が
``manifest_filenames`` に一致する相対パスだけを返す(1 リポ 1 コール / D9(a))。
第 2 戻り値は GitHub が木構造を打ち切ったか(``truncated`` / D9(d))

除外リストや深さ・件数キャップといった探索ポリシーは呼び出し側(collector)の責務とし、
ここでは「API 呼び出し + basename フィルタ + truncated 返却」に留める。manifest 取得は
ベストエフォート(1 リポの失敗で連携全体を落とさない)。ただし tree 取得自体が失敗した場合
(非200 / 不正レスポンス / ``httpx.HTTPError``)は「依存ゼロ」と「走査不能」を区別するため、
第 2 戻り値の partial を ``True`` にして部分スキャンとして伝播する(D9(d))。不正 owner/repo は
実在リポではなく走査対象ですらないため ``([], False)`` のままとする
"""recursive Trees API でリポジトリの全 blob パスを 1 コールで取得する(D9(a))。

``GET /git/trees/{default_branch}?recursive=1`` を **1 回だけ**呼び、blob の相対パス一覧を
返す(1 リポ 1 コール)。manifest 探索(declare / D7・D9)と import 解析(verify / D6)の
双方がこの単一ツリーを共有することで、verify のために tree を再取得しない
basename / 拡張子による絞り込みや除外・キャップといった探索ポリシーは呼び出し側
(collector)の責務とし、ここは「API 呼び出し + 全 blob パス + truncated 返却」に留める。

第 2 戻り値は走査が部分的か(partial)。GitHub が木構造を打ち切った(``truncated``)場合に
加え、tree 取得自体が失敗した場合(非200 / 不正レスポンス / ``httpx.HTTPError``)
「依存ゼロ」と「走査不能」を区別するため ``True`` を返す(D9(d))。不正 owner/repo は
実在リポではなく走査対象ですらないため ``([], False)`` とする
"""
if not _is_valid_owner_repo(owner, repo):
return [], False
Expand All @@ -204,9 +203,7 @@ async def fetch_manifest_paths(
paths = [
entry["path"]
for entry in tree
if entry.get("type") == "blob"
and entry.get("path")
and entry["path"].rsplit("/", 1)[-1] in manifest_filenames
if entry.get("type") == "blob" and entry.get("path")
]
return paths, bool(data.get("truncated"))
except httpx.HTTPError:
Expand Down
136 changes: 95 additions & 41 deletions backend/app/services/intelligence/github_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@
GITHUB_API,
GitHubUserNotFoundError,
fetch_languages,
fetch_manifest_paths,
fetch_repo_file,
fetch_repo_tree,
fetch_repos_raw,
)
from .skills.imports import scanner_for_extension
from .skills.manifests import MANIFEST_FILENAMES, parse_manifest
from .skills.types import PackageDeclaration

logger = logging.getLogger(__name__)

# monorepo manifest 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、
# monorepo 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、
# env_keys 同期コストに見合わないためモジュール定数として持つ(チューニングはコード変更)。
# D9(b): パスのいずれかのセグメントがこの集合に該当したら manifest 候補から除外する
_MANIFEST_PATH_EXCLUDE_SEGMENTS = frozenset(
# D9(b): パスのいずれかのセグメントがこの集合に該当したら候補から除外する(manifest / source 共通)
_PATH_EXCLUDE_SEGMENTS = frozenset(
{
"node_modules",
"vendor",
Expand All @@ -51,6 +52,10 @@
_MANIFEST_MAX_DEPTH = 4
# D9(c): 1 リポあたり fetch する manifest 件数上限。
_MANIFEST_MAX_COUNT = 20
# D6: verify で 1 リポあたり fetch するソースファイル件数上限(import サンプリングの打ち切り)。
_SOURCE_MAX_COUNT = 30
# D6: verify 対象ソースのセグメント数上限(浅い側を優先サンプリング)。
_SOURCE_MAX_DEPTH = 6

# このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が
# ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。
Expand All @@ -77,48 +82,55 @@ class RepoData:
default_branch: str = field(default="main")
# declare ステージ: manifest が宣言する依存(D7・D9。サブツリー含む)。未取得なら空。
package_declarations: List[PackageDeclaration] = field(default_factory=list)
# D9(d): manifest 走査が網羅的でない(truncated / cap で打ち切り)場合 True。
# verify ステージ(D6): import 解析で実際に使われていた名前の集合(ecosystem → import 名)。
# aggregator が direct 宣言と照合し actual_import 証跡へ昇格させる素にする。未取得なら空。
imported_symbols: Dict[str, set] = field(default_factory=dict)
# D9(d): ツリー走査(manifest / source)が網羅的でない(truncated / cap で打ち切り)場合 True。
manifest_scan_partial: bool = False


def _is_excluded_manifest_path(path: str) -> bool:
"""パスのいずれかのセグメントが除外集合に該当するか(D9(b))。"""
return any(seg in _MANIFEST_PATH_EXCLUDE_SEGMENTS for seg in path.split("/"))
def _is_excluded_path(path: str) -> bool:
"""パスのいずれかのセグメントが除外集合に該当するか(D9(b)。manifest / source 共通)。"""
return any(seg in _PATH_EXCLUDE_SEGMENTS for seg in path.split("/"))


async def _collect_manifests(
client: httpx.AsyncClient, owner: str, repo: str, default_branch: str
) -> tuple[List[PackageDeclaration], bool]:
"""サブツリーを含む manifest を取得・解析して依存宣言を返す(declare / D7・D9)。
def _select_shallow(paths: List[str], max_depth: int, max_count: int) -> tuple[List[str], bool]:
"""除外・浅い順ソート・深さ/件数キャップで候補を絞る(D9(b)(c) / D6 サンプリング)。

recursive Trees API で候補パスを列挙し、除外セグメント(D9(b))・深さ/件数キャップ
(D9(c))でフィルタしてから本文を取得する。取得・解析失敗はベストエフォートで握りつぶす
(1 リポの失敗で連携全体を落とさない)。第 2 戻り値は走査が部分的だったか(D9(d))。
戻り値は (採用パス, 打ち切りが発生したか)。
"""
paths, truncated = await fetch_manifest_paths(
client, owner, repo, default_branch, MANIFEST_FILENAMES
)
# D9(b): 除外セグメントを含むパスを捨てる(取得済みツリーの in-memory フィルタ)。
candidates = [p for p in paths if not _is_excluded_manifest_path(p)]
# D9(c): 浅い順に並べ、深さ上限超を落とし、件数上限で打ち切る。
candidates = [p for p in paths if not _is_excluded_path(p)]
candidates.sort(key=lambda p: (p.count("/"), p))
within_depth = [p for p in candidates if p.count("/") + 1 <= _MANIFEST_MAX_DEPTH]
within_depth = [p for p in candidates if p.count("/") + 1 <= max_depth]
depth_dropped = len(within_depth) < len(candidates)
selected = within_depth[:_MANIFEST_MAX_COUNT]
count_dropped = len(within_depth) > _MANIFEST_MAX_COUNT
partial = bool(truncated or depth_dropped or count_dropped)
if partial:
logger.warning(
"manifest 探索が部分的: %s/%s (truncated=%s, depth_dropped=%s, count_dropped=%s)",
owner,
repo,
truncated,
depth_dropped,
count_dropped,
)
selected = within_depth[:max_count]
count_dropped = len(within_depth) > max_count
return selected, bool(depth_dropped or count_dropped)


async def _collect_repo_signals(
client: httpx.AsyncClient, owner: str, repo: str, default_branch: str
) -> tuple[List[PackageDeclaration], Dict[str, set], bool]:
"""1 回のツリー取得から declare(manifest)と verify(import 解析)の両シグナルを集める。

recursive Trees API を **1 回だけ**呼び(D9(a))、その結果を manifest 探索と source 探索で
共有する。manifest は宣言依存(D7・D9)、source は実 import(D6)を抽出する。いずれも
除外セグメント(D9(b))・深さ/件数キャップ(D9(c) / D6 サンプリング)で絞る。取得・解析
失敗はベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。

戻り値は (依存宣言, ecosystem→import名集合, 走査が部分的だったか / D9(d))。
"""
tree_paths, truncated = await fetch_repo_tree(client, owner, repo, default_branch)

# ── declare: manifest を basename で抽出して解析する ──────────────────────
manifest_paths = [
p for p in tree_paths if p.rsplit("/", 1)[-1] in MANIFEST_FILENAMES
]
selected_manifests, manifest_dropped = _select_shallow(
manifest_paths, _MANIFEST_MAX_DEPTH, _MANIFEST_MAX_COUNT
)
declarations: List[PackageDeclaration] = []
for path in selected:
for path in selected_manifests:
content = await fetch_repo_file(client, owner, repo, path)
if not content:
continue
Expand All @@ -127,7 +139,44 @@ async def _collect_manifests(
declarations.extend(
replace(decl, source_path=path) for decl in parse_manifest(filename, content)
)
return declarations, partial

# ── verify: direct 宣言のあるエコシステムだけソースを import 解析する(D6)──
direct_ecosystems = {
decl.ecosystem for decl in declarations if decl.dependency_kind == "direct"
}
imported_symbols: Dict[str, set] = {}
source_dropped = False
if direct_ecosystems:
# path → scanner を一度だけ引き、後段の取得ループで再計算しない。
path_scanners = {}
for path in tree_paths:
scanner = scanner_for_extension(path)
if scanner is not None and scanner.ecosystem in direct_ecosystems:
path_scanners[path] = scanner
selected_sources, source_dropped = _select_shallow(
list(path_scanners), _SOURCE_MAX_DEPTH, _SOURCE_MAX_COUNT
)
for path in selected_sources:
scanner = path_scanners[path]
content = await fetch_repo_file(client, owner, repo, path)
if not content:
continue
# 生コードは scan 後に破棄(永続化しない / D6)。
imported_symbols.setdefault(scanner.ecosystem, set()).update(
scanner.scan(content)
)

partial = bool(truncated or manifest_dropped or source_dropped)
if partial:
logger.warning(
"ツリー走査が部分的: %s/%s (truncated=%s, manifest_dropped=%s, source_dropped=%s)",
owner,
repo,
truncated,
manifest_dropped,
source_dropped,
)
return declarations, imported_symbols, partial


def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool:
Expand Down Expand Up @@ -158,7 +207,8 @@ async def collect_repos(
言語の内訳を含む RepoData のリストを返す。
on_repo_fetched が渡された場合、各リポジトリの詳細取得後に
on_repo_fetched(done, total) を呼び出す(進捗通知用)。
collect_manifests=True のとき、直下 manifest を解析して package_declarations を埋める(declare / D7)。
collect_manifests=True のとき、1 回のツリー取得から manifest 宣言(declare / D7・D9)と
import 実使用(verify / D6)の両シグナルを集め、package_declarations と imported_symbols を埋める。
"""
headers = {
"Accept": "application/vnd.github+json",
Expand Down Expand Up @@ -193,10 +243,13 @@ async def collect_repos(

default_branch = raw.get("default_branch", "main")
declarations: List[PackageDeclaration] = []
manifest_partial = False
imported_symbols: Dict[str, set] = {}
scan_partial = False
if collect_manifests:
declarations, manifest_partial = await _collect_manifests(
client, owner_login, repo_name, default_branch
declarations, imported_symbols, scan_partial = (
await _collect_repo_signals(
client, owner_login, repo_name, default_branch
)
)

repos.append(
Expand All @@ -212,7 +265,8 @@ async def collect_repos(
stargazers_count=raw.get("stargazers_count", 0),
default_branch=default_branch,
package_declarations=declarations,
manifest_scan_partial=manifest_partial,
imported_symbols=imported_symbols,
manifest_scan_partial=scan_partial,
)
)

Expand Down
1 change: 1 addition & 0 deletions backend/app/services/intelligence/github_link_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ async def _on_repo_fetched(done: int, total: int) -> None:
url=f"https://github.com/{repo.owner}/{repo.name}",
languages=repo.languages,
package_declarations=repo.package_declarations,
imported_symbols=repo.imported_symbols,
manifest_scan_partial=repo.manifest_scan_partial,
)
for repo in repos
Expand Down
38 changes: 37 additions & 1 deletion backend/app/services/intelligence/skills/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import re
from dataclasses import dataclass, field

from .imports import scanner_for_ecosystem
from .linguist import resolve_language
from .types import (
SKILL_KIND_LANGUAGE,
Expand All @@ -25,8 +26,11 @@
"dev": 0.3,
"indirect": 0.1,
}
# verify(D6): direct 宣言が実際に import されていたときの昇格後 confidence。
_ACTUAL_IMPORT_CONFIDENCE = 0.85
_SIGNAL_LANGUAGE_BYTES = "language_bytes"
_SIGNAL_MANIFEST_DECLARED = "manifest_declared"
_SIGNAL_ACTUAL_IMPORT = "actual_import"

# PEP 503 正規化用(連続する -_. を - に畳む)。
_PYPI_NAME_RE = re.compile(r"[-_.]+")
Expand Down Expand Up @@ -80,7 +84,9 @@ class RepoSkillInput:
url: str
languages: dict[str, int]
package_declarations: list[PackageDeclaration] = field(default_factory=list)
# D9(d): このリポの manifest 走査が部分的だったか。package 根拠へ伝播する。
# verify(D6): import 解析で実使用が確認された名前の集合(ecosystem → import 名)。
imported_symbols: dict[str, set[str]] = field(default_factory=dict)
# D9(d): このリポのツリー走査が部分的だったか。package 根拠へ伝播する。
manifest_scan_partial: bool = False


Expand Down Expand Up @@ -194,7 +200,37 @@ def _collect_packages(
partial_scan=repo.manifest_scan_partial,
)
)
# verify(D6): direct 宣言が実際に import されていたら actual_import 証跡を **追加**する。
# declare 証跡は残したまま昇格証跡を足す(昇格のみ・降格なし / 保持は細かく / D8)。
if decl.dependency_kind == "direct" and _is_imported(
ecosystem, name, repo.imported_symbols
):
skill.evidence.append(
EvidenceRecord(
repo_full_name=repo.full_name,
repo_url=repo.url,
signal_source=_SIGNAL_ACTUAL_IMPORT,
confidence=_ACTUAL_IMPORT_CONFIDENCE,
dependency_kind=decl.dependency_kind,
manifest_path=decl.source_path,
partial_scan=repo.manifest_scan_partial,
)
)


def _confidence(dependency_kind: str | None) -> float:
return _DEPENDENCY_CONFIDENCE.get(dependency_kind or "", 0.2)


def _is_imported(
ecosystem: str, canonical_name: str, imported_symbols: dict[str, set[str]]
) -> bool:
"""canonical 名がこのリポの import 解析結果(verify / D6)で実使用されていたか。

照合規則(``-``→``_`` 変換・接頭辞一致など)はエコシステム別スキャナに委譲する。
未対応エコシステム・スキャン結果なしは False(昇格しないだけで declare 証跡は残る)。
"""
scanner = scanner_for_ecosystem(ecosystem)
if scanner is None:
return False
return scanner.matches(canonical_name, imported_symbols.get(ecosystem, set()))
20 changes: 20 additions & 0 deletions backend/app/services/intelligence/skills/imports/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""import 解析(verify ステージ / ADR-0016 D6)。

宣言された direct 依存が実際に import されているかをソースから判定し、``manifest_declared``
を ``actual_import`` へ昇格させる素を作る。辞書は持たず、エコシステム別の機械的規則のみで
照合する(取りこぼしは false negative として受容)。
"""

from .base import ImportScanner
from .registry import (
SOURCE_EXTENSIONS,
scanner_for_ecosystem,
scanner_for_extension,
)

__all__ = [
"ImportScanner",
"SOURCE_EXTENSIONS",
"scanner_for_ecosystem",
"scanner_for_extension",
]
Loading
Loading