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
28 changes: 25 additions & 3 deletions backend/app/services/intelligence/skills/imports/python.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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()
Expand All @@ -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))
Original file line number Diff line number Diff line change
@@ -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"]
}
}
15 changes: 15 additions & 0 deletions backend/tests/test_skill_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)──────────────────────────────────────────────


Expand Down
22 changes: 17 additions & 5 deletions backend/tests/test_skill_import_scanners.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0016-github-skill-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。
Expand Down Expand Up @@ -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 層モデルは不変。
Expand Down
Loading