diff --git a/backend/app/services/intelligence/skills/imports/python.py b/backend/app/services/intelligence/skills/imports/python.py index e3e855ce..4e899962 100644 --- a/backend/app/services/intelligence/skills/imports/python.py +++ b/backend/app/services/intelligence/skills/imports/python.py @@ -1,11 +1,19 @@ """Python import スキャナ(ecosystem=pypi / D6)。 ``import X`` / ``from X import ...`` のトップレベルモジュール名を抽出する。 -pypi の package ID と import 名はしばしば異なる(PyYAML→yaml 等)が、辞書は持たず -``canonical_name`` の ``-``/``.``→``_`` 変換のみで照合する。差異は false negative として受容。 +pypi の package ID と import 名はしばしば異なる(PyYAML→yaml 等)。まず ``canonical_name`` の +``-``/``.``→``_`` 変換で機械的に照合し、当たらない既知の乖離のみ内部マスタ +(``resources/pypi_import_aliases.json`` / #477)で補正する。マスタ未収録の乖離は +引き続き false negative として受容する(辞書はホットパス外で生成し実行時は読むだけ / D3・D4)。 """ +import json import re +from functools import lru_cache +from pathlib import Path + +# 配布名(canonical)→ トップレベル import 名 の内部マスタ(#477 / D3・D4)。 +_ALIAS_MASTER_PATH = Path(__file__).parent.parent / "resources" / "pypi_import_aliases.json" # 行頭(インデント可)の import 文。`import a.b`, `import a as b`, `from a.b import c` を拾う。 _IMPORT_RE = re.compile( @@ -14,6 +22,16 @@ ) +@lru_cache(maxsize=1) +def _load_import_aliases() -> dict[str, frozenset[str]]: + """canonical 配布名 → import 名集合 の内部マスタを読み込む(プロセス内 1 回)。""" + raw = json.loads(_ALIAS_MASTER_PATH.read_text(encoding="utf-8")) + return { + canonical: frozenset(names) + for canonical, names in raw.get("aliases", {}).items() + } + + def _top_level(module: str) -> str: """ドット区切りの先頭要素(トップレベルモジュール名)を小文字で返す。""" return module.split(".", 1)[0].lower() @@ -30,4 +48,8 @@ def matches(self, canonical_name: str, imported: set[str]) -> bool: # pypi canonical は PEP 503 正規化済み(小文字・``-`` 区切り)。 # import 名は区切りが ``_`` になるのが一般的なので変換して照合する。 candidate = canonical_name.replace("-", "_").replace(".", "_").lower() - return candidate in imported + if candidate in imported: + return True + # 機械変換で当たらない既知の乖離(PyYAML→yaml 等)を内部マスタで補正する(#477)。 + aliases = _load_import_aliases().get(canonical_name) + return bool(aliases and (aliases & imported)) diff --git a/backend/app/services/intelligence/skills/resources/pypi_import_aliases.json b/backend/app/services/intelligence/skills/resources/pypi_import_aliases.json new file mode 100644 index 00000000..f88d8889 --- /dev/null +++ b/backend/app/services/intelligence/skills/resources/pypi_import_aliases.json @@ -0,0 +1,22 @@ +{ + "_comment": "PyPI 配布名(canonical / PEP 503 正規化済み)→ トップレベル import 名 の内部マスタ(ADR-0016 #477 / D3・D4)。verify(D6)の機械的照合(-→_ 変換)では当たらない『配布名≠import名』の乖離を補正する。値は wheel の top_level.txt に相当するトップレベルモジュール名(小文字)。連携ホットパスで外部を叩かず実行時はここへ resolve するだけ(D4)。本ファイルは top_level.txt からのオフライン再生成を想定した暫定キュレーションで、既知の乖離 package を初期集合とする(実データでの拡張は後追い)。google.* のような汎用名前空間に畳まれる package(protobuf 等)は false positive を避けるため意図的に含めない。", + "aliases": { + "pyyaml": ["yaml"], + "pillow": ["pil"], + "beautifulsoup4": ["bs4"], + "scikit-learn": ["sklearn"], + "scikit-image": ["skimage"], + "opencv-python": ["cv2"], + "opencv-python-headless": ["cv2"], + "opencv-contrib-python": ["cv2"], + "python-dateutil": ["dateutil"], + "python-dotenv": ["dotenv"], + "pyjwt": ["jwt"], + "python-jose": ["jose"], + "python-magic": ["magic"], + "python-slugify": ["slugify"], + "pymupdf": ["fitz"], + "websocket-client": ["websocket"], + "msgpack-python": ["msgpack"] + } +} diff --git a/backend/tests/test_skill_aggregator.py b/backend/tests/test_skill_aggregator.py index 942ac61b..b5a1a47c 100644 --- a/backend/tests/test_skill_aggregator.py +++ b/backend/tests/test_skill_aggregator.py @@ -230,6 +230,21 @@ def test_verify_uses_ecosystem_matching_rules() -> None: assert "actual_import" in _signals(gin.evidence) +def test_verify_promotes_pypi_import_name_divergence_via_alias_master() -> None: + """配布名≠import名の乖離(PyYAML→yaml)も内部マスタ経由で actual_import へ昇格すること(#477)。""" + skills = aggregate_skills( + [ + _repo( + declarations=[PackageDeclaration("pypi", "PyYAML", "direct")], + imported={"pypi": {"yaml"}}, + ) + ] + ) + # 宣言名は PEP503 正規化で canonical "pyyaml" になり、import 名 "yaml" と照合される。 + pyyaml = _by_name(skills)["pyyaml"] + assert "actual_import" in _signals(pyyaml.evidence) + + # ── IaC 検出(kind=infra / D10)────────────────────────────────────────────── diff --git a/backend/tests/test_skill_import_scanners.py b/backend/tests/test_skill_import_scanners.py index df6804bd..3df4c5e4 100644 --- a/backend/tests/test_skill_import_scanners.py +++ b/backend/tests/test_skill_import_scanners.py @@ -1,8 +1,8 @@ """import スキャナ(verify / ADR-0016 D6)の単体テスト。 エコシステム別の import 抽出(scan)と、宣言 package との照合(matches)を検証する。 -辞書レスの保守的照合のため、import 名がずれるケース(PyYAML→yaml 等)は -昇格漏れ(false negative)になることも明示的に固定する。 +機械的照合で当たらない既知の乖離(PyYAML→yaml 等)は内部マスタで補正されること(#477)、 +マスタ未収録の乖離は依然 false negative になることの両方を明示的に固定する。 """ from app.services.intelligence.skills.imports import ( @@ -61,9 +61,21 @@ def test_matches_with_dash_to_underscore(self): # canonical(PEP503)は小文字・ダッシュ。import 名はアンダースコア。 assert self.scanner.matches("google-cloud-storage", {"google_cloud_storage"}) - def test_false_negative_when_import_name_differs(self): - # PyYAML→yaml のような乖離は照合できない(昇格漏れを受容)。 - assert self.scanner.matches("pyyaml", {"yaml"}) is False + def test_matches_divergent_import_name_via_alias_master(self): + # 配布名≠import名の既知乖離は内部マスタ(pypi_import_aliases.json)で昇格する(#477 / D3・D4)。 + assert self.scanner.matches("pyyaml", {"yaml"}) is True + assert self.scanner.matches("pillow", {"pil"}) is True + assert self.scanner.matches("beautifulsoup4", {"bs4"}) is True + assert self.scanner.matches("scikit-learn", {"sklearn"}) is True + assert self.scanner.matches("opencv-python", {"cv2"}) is True + + def test_known_alias_not_imported_stays_false(self): + # マスタ収録済みでも実 import が無ければ昇格しない(過剰昇格の防止)。 + assert self.scanner.matches("pyyaml", {"requests"}) is False + + def test_unknown_divergence_still_false(self): + # マスタ未収録の乖離は依然として昇格漏れ(false negative)を受容する。 + assert self.scanner.matches("some-obscure-dist", {"obscuremod"}) is False # ── JS/TS ─────────────────────────────────────────────────────────────── diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index fc328f8a..a8f5731b 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -159,7 +159,8 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ## 将来の移行条件 -- **verify ステージ**: D6 として実装済み(2026-06)。recursive Trees API の 1 コールを manifest 探索と共有し、direct 宣言のエコシステムの source だけを浅い順・件数キャップでサンプリング → import 解析。辞書レスの保守的照合(`-`→`_` 変換・go 接頭辞一致・npm 完全一致)で direct 宣言を `actual_import` へ**昇格のみ**(降格なし)。残課題は import 名乖離(PyYAML→yaml 等)の取りこぼし低減・サンプリング閾値の実データチューニング。 +- **verify ステージ**: D6 として実装済み(2026-06)。recursive Trees API の 1 コールを manifest 探索と共有し、direct 宣言のエコシステムの source だけを浅い順・件数キャップでサンプリング → import 解析。辞書レスの保守的照合(`-`→`_` 変換・go 接頭辞一致・npm 完全一致)で direct 宣言を `actual_import` へ**昇格のみ**(降格なし)。 + - **import 名乖離の補正(#477 / 2026-07)**: 機械変換で当たらない配布名≠import名の乖離(PyYAML→yaml・Pillow→PIL・beautifulsoup4→bs4 等)を、pypi のみ内部マスタ(`skills/resources/pypi_import_aliases.json`)経由で補正する。**D3(辞書を持たない)とのテンション**は `linguist_master.json` と同じモデルで解消: マスタは wheel の `top_level.txt` 由来の機械的事実であり taxonomy 判断を含まない/連携ホットパスで外部を叩かず実行時は読むだけ(D4)/`top_level.txt` からのオフライン再生成を想定した暫定キュレーションで、既知の乖離 package を初期集合とする。`google.*` のような汎用名前空間へ畳まれる package(protobuf 等)は false positive を避け意図的に除外。マスタ未収録の乖離は引き続き false negative として受容(昇格漏れのみ・過剰昇格なし)。残課題は初期集合の実データ拡張・サンプリング閾値の実データチューニング。 - **monorepo 対応**: D9 で採用済み(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all)。残課題は除外定義の高度化(Linguist `vendor.yml` 流用)・キャップ閾値の実データチューニング・規模シグナルの導入。 - **IaC からのインフラリソース検出**: 機械検出は **D10 で採用・実装済み**(2026-07。Terraform/OpenTofu・provider+service 粒度・kind=infra・signal_source=infra_declared・D9 探索流用・正規表現 parser)。残課題は表示名の HITL 畳み込み・動的 module 解決・Tier2 IaC・resource 出現回数の量的シグナル。 - **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 @@ -216,6 +217,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ## 改訂履歴 +- **2026-07**: verify(D6)の **import 名乖離の取りこぼしを低減(#477)**。pypi のみ、機械変換で当たらない配布名≠import名の乖離を内部マスタ(`skills/resources/pypi_import_aliases.json`)で補正。D3 テンションは `linguist_master.json` と同じ「ホットパス外で生成する暫定キュレーション・実行時は読むだけ」モデルで解消。既知の乖離 package を初期集合とし、`google.*` 等の汎用名前空間は false positive 回避のため除外。未収録は引き続き false negative 受容(過剰昇格なし)。schema / API 契約は不変(migration 不要)。3 層モデル・D1〜D10 は不変。 - **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 層モデルは不変。