test(architecture): record direct adapter imports

This commit is contained in:
jxxghp
2026-08-27 05:56:38 +08:00
parent a884ab5c2b
commit e1483e85dc
6 changed files with 455 additions and 20 deletions
+17 -13
View File
@@ -74,8 +74,8 @@ G-ARCH 只有在以下条件全部满足后才可完成:
|---|---|---|---|
| S0-L1 可信基线恢复 | `DELIVERED` | 无 | `5df388719`:交付架构审计/路线图,修复 `ARCH-001` 两个 mypy 增量错误;远端 `0/0` |
| S0-L2.1 Host Oper/UoW 规范 | `DELIVERED` | S0-L1 | `3bf94ffed`:宿主无 Session Oper 规范债务归零,远端 `0/0` |
| S0-L2.2 完整宿主 SCC policy | `VERIFIED` | S0-L2.1 | 完整宿主 SCC 全部精确分类;Chain 临时债务和 TMDB containment 分离 |
| S0-L2.3 Adapter 直连事实 | `PLANNED` | S0-L2.1 | 收集 Application/Chain 的原始 Adapter import,不因父包展开重复计数 |
| S0-L2.2 完整宿主 SCC policy | `DELIVERED` | S0-L2.1 | `a884ab5c2`:完整宿主 SCC 精确 policy 生效,远端 `0/0` |
| S0-L2.3 Adapter 直连事实 | `VERIFIED` | S0-L2.1 | 收集 Application/Chain 的原始 Adapter import,不因父包展开重复计数 |
| S0-L2.4 Adapter zero-growth | `PLANNED` | S0-L2.3 | 当前直连均登记迁移 owner,新增/替换失败,删除后要求清理陈旧 policy |
| S0-L2.5 Event consumer 识别 | `PLANNED` | S0-L2.1 | consumer 只识别可静态证明的 EventManager 注册,动态误报归零 |
| S0-L2.6 事实源与 CI 投影 | `PLANNED` | S0-L2.2,S0-L2.4,S0-L2.5 | fixture/policy/overview 职责固定,CI 分开报告语义 policy 与快照一致性 |
@@ -153,42 +153,46 @@ G-ARCH 只有在以下条件全部满足后才可完成:
## 4. 当前活动叶子
### S0-L2.2 完整宿主 SCC policy
### S0-L2.3 Adapter 直连事实
**Status:** `VERIFIED`(本地验收完成,等待提交、推送和远端一致性确认)
**Outcome**
让完整宿主静态依赖图中的每个 SCC 都有精确、人工审查的分类;生成快照只记录事实,
不能通过刷新 baseline 自动批准新环
用原始 AST import 记录 Application/Chain 到 Adapter 的稳定直连事实,不把父包初始化边、
导入符号、行号、TYPE_CHECKING 或动态字符串导入混入治理面
**Ownership**
- `tests/test_architecture_dependencies.py` 的统一图算法和完整 SCC 语义门禁
- `tests/fixtures/architecture/dependency-policy.json` 的精确人工 policy
- `docs/rules/05-architecture.md``docs/architecture-overview.md` 的事实/policy 边界
- `scripts/architecture/baseline.py` 的 direct Adapter import collector
- `tests/test_architecture_adapter_imports.py` 的 AST 语义和当前 28 条事实断言
- `tests/fixtures/architecture/dependency-baseline.json` 的 schema v2 生成字段
- 本路线图的叶子状态和交付记录。
**Excluded**
-在本叶消除 Chain SCC;由 `ARCH-107`/S2-L2 删除包根环和临时 policy
-拆分 TMDB 移植包,不把普通单向包外依赖误判为 SCC 成员扩张
-修改 `app/plugins/**`、运行时代码或独立插件仓
-批准当前直连、不写 owner、不建立豁免;这些属于紧随其后的 S0-L2.4
-修改现有完整依赖图、SCC、digest、`app/plugins/**` 或运行时代码
- `app.db.adapters`、SDK、插件副本或第三方 HTTP egress 混入本字段
**Acceptance**
```bash
.venv/bin/python -m pytest \
tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py \
tests/test_architecture_dependencies.py \
tests/test_architecture_baseline_cli.py -q
.venv/bin/python scripts/architecture/baseline.py --check-host --diagnostics
.venv/bin/python scripts/architecture/ruff_ratchet.py
.venv/bin/python scripts/architecture/mypy_ratchet.py
.venv/bin/pylint tests/test_architecture_dependencies.py
.venv/bin/pylint scripts/architecture/baseline.py \
tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py
git diff --check
```
**Delivery**
- 单一提交主题:建立完整宿主 SCC policy 与 zero-growth 语义门禁
- 单一提交主题:生成并锁定 Adapter 原始直连事实
- 推送 `origin/v3` 后确认提交祖先关系、远端 SHA 和 ahead/behind `0/0`
+89 -1
View File
@@ -252,6 +252,62 @@ def resolve_imports(
return dependencies
def _is_module_or_child(module_name: str, root: str) -> bool:
"""判断模块是否等于指定根或位于其点分子树内。"""
return module_name == root or module_name.startswith(f"{root}.")
def _resolve_import_from_module(
module_name: str,
path: Path,
node: ast.ImportFrom,
) -> str:
"""把 from-import 的相对模块解析为绝对模块名,非法越顶时返回空串。"""
if not node.level:
return node.module or ""
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
package_parts = package.split(".") if package else []
keep_count = len(package_parts) - node.level + 1
if keep_count < 0:
return ""
base = ".".join(package_parts[:keep_count])
return ".".join(part for part in (base, node.module or "") if part)
def collect_direct_adapter_imports(
modules: dict[str, Path],
) -> list[dict[str, str]]:
"""收集 Application/Chain 对 Adapter 的原始运行期 import,不展开父包。"""
source_roots = ("app.application", "app.chain")
target_root = "app.adapters"
edges: set[tuple[str, str]] = set()
for source, path in modules.items():
if not any(_is_module_or_child(source, root) for root in source_roots):
continue
for node in iter_runtime_import_nodes(parse_source(path)):
targets: list[str] = []
if isinstance(node, ast.Import):
targets.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
imported_module = _resolve_import_from_module(source, path, node)
if imported_module:
targets.append(imported_module)
targets.extend(
f"{imported_module}.{alias.name}"
for alias in node.names
if imported_module == "app" and alias.name != "*"
)
edges.update(
(source, target)
for target in targets
if _is_module_or_child(target, target_root)
)
return [
{"source": source, "target": target}
for source, target in sorted(edges)
]
def strongly_connected_components(
graph: dict[str, set[str]],
) -> list[list[str]]:
@@ -376,8 +432,15 @@ def collect_dependency_baseline() -> dict[str, Any]:
for target in dependencies
)
digest = hashlib.sha256("\n".join(edges).encode("utf-8")).hexdigest()
direct_adapter_imports = collect_direct_adapter_imports(modules)
direct_adapter_sources = sorted(
{edge["source"] for edge in direct_adapter_imports}
)
direct_adapter_targets = sorted(
{edge["target"] for edge in direct_adapter_imports}
)
return {
"schema_version": 1,
"schema_version": 2,
"scope": "MoviePilot host app excluding app/plugins",
"module_count": len(modules),
"edge_count": len(edges),
@@ -385,6 +448,31 @@ def collect_dependency_baseline() -> dict[str, Any]:
"modules": sorted(modules),
"edges": edges,
"strongly_connected_components": strongly_connected_components(graph),
"direct_adapter_imports": {
"scope": {
"source_roots": ["app.application", "app.chain"],
"target_root": "app.adapters",
"runtime_only": True,
"parent_package_expansion": False,
"imported_symbols": False,
},
"count": len(direct_adapter_imports),
"counts_by_source_root": {
"app.application": sum(
_is_module_or_child(edge["source"], "app.application")
for edge in direct_adapter_imports
),
"app.chain": sum(
_is_module_or_child(edge["source"], "app.chain")
for edge in direct_adapter_imports
),
},
"source_count": len(direct_adapter_sources),
"sources": direct_adapter_sources,
"target_count": len(direct_adapter_targets),
"targets": direct_adapter_targets,
"edges": direct_adapter_imports,
},
"boundary_edges": collect_boundary_edges(graph, modules),
}
+167 -1
View File
@@ -13,6 +13,172 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"direct_adapter_imports": {
"count": 28,
"counts_by_source_root": {
"app.application": 15,
"app.chain": 13
},
"edges": [
{
"source": "app.application.backup",
"target": "app.adapters.system.backup.files"
},
{
"source": "app.application.directory",
"target": "app.adapters.system.host"
},
{
"source": "app.application.image",
"target": "app.adapters.network.http"
},
{
"source": "app.application.image",
"target": "app.adapters.network.ip"
},
{
"source": "app.application.messaging.ingress",
"target": "app.adapters.network.http"
},
{
"source": "app.application.rss",
"target": "app.adapters.network.browser"
},
{
"source": "app.application.rss",
"target": "app.adapters.network.http"
},
{
"source": "app.application.rss",
"target": "app.adapters.system"
},
{
"source": "app.application.rules",
"target": "app.adapters.system"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.external.ocr"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.network.browser"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.network.http"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http"
},
{
"source": "app.application.transfer",
"target": "app.adapters.system.host"
},
{
"source": "app.chain._recognition",
"target": "app.adapters.external.server"
},
{
"source": "app.chain._transfer",
"target": "app.adapters.system.host"
},
{
"source": "app.chain.download",
"target": "app.adapters.network.http"
},
{
"source": "app.chain.download",
"target": "app.adapters.system.host"
},
{
"source": "app.chain.message",
"target": "app.adapters.network.http"
},
{
"source": "app.chain.scraping",
"target": "app.adapters.network.http"
},
{
"source": "app.chain.site",
"target": "app.adapters.external.cookiecloud"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.browser"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.cloudflare"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.http"
},
{
"source": "app.chain.subscribe",
"target": "app.adapters.external.server"
},
{
"source": "app.chain.system",
"target": "app.adapters.network.http"
},
{
"source": "app.chain.system",
"target": "app.adapters.system.host"
}
],
"scope": {
"imported_symbols": false,
"parent_package_expansion": false,
"runtime_only": true,
"source_roots": [
"app.application",
"app.chain"
],
"target_root": "app.adapters"
},
"source_count": 18,
"sources": [
"app.application.backup",
"app.application.directory",
"app.application.image",
"app.application.messaging.ingress",
"app.application.rss",
"app.application.rules",
"app.application.security.cookie",
"app.application.security.passkey",
"app.application.torrent",
"app.application.transfer",
"app.chain._recognition",
"app.chain._transfer",
"app.chain.download",
"app.chain.message",
"app.chain.scraping",
"app.chain.site",
"app.chain.subscribe",
"app.chain.system"
],
"target_count": 11,
"targets": [
"app.adapters.cache.redis",
"app.adapters.external.cookiecloud",
"app.adapters.external.ocr",
"app.adapters.external.server",
"app.adapters.network.browser",
"app.adapters.network.cloudflare",
"app.adapters.network.http",
"app.adapters.network.ip",
"app.adapters.system",
"app.adapters.system.backup.files",
"app.adapters.system.host"
]
},
"edge_count": 6810,
"edge_sha256": "141ed79f9097aaed4b1933f2fe931b7364e7a1a88d63ea8ca79a6012255f8d9c",
"edges": [
@@ -7665,7 +7831,7 @@
"app.workflow.actions.send_message",
"app.workflow.actions.transfer_file"
],
"schema_version": 1,
"schema_version": 2,
"scope": "MoviePilot host app excluding app/plugins",
"strongly_connected_components": [
[
-3
View File
@@ -1266,9 +1266,6 @@
"tests/test_api_response.py": {
"I001": 1
},
"tests/test_architecture_contract_baseline.py": {
"I001": 1
},
"tests/test_async_db_pooling.py": {
"I001": 1
},
+173
View File
@@ -0,0 +1,173 @@
"""Application/Chain 到 Adapter 原始直连事实的收集契约。"""
from pathlib import Path
from scripts.architecture.baseline import (
collect_dependency_baseline,
collect_direct_adapter_imports,
)
EXPECTED_DIRECT_ADAPTER_IMPORTS = {
("app.application.backup", "app.adapters.system.backup.files"),
("app.application.directory", "app.adapters.system.host"),
("app.application.image", "app.adapters.network.http"),
("app.application.image", "app.adapters.network.ip"),
("app.application.messaging.ingress", "app.adapters.network.http"),
("app.application.rss", "app.adapters.network.browser"),
("app.application.rss", "app.adapters.network.http"),
("app.application.rss", "app.adapters.system"),
("app.application.rules", "app.adapters.system"),
("app.application.security.cookie", "app.adapters.external.ocr"),
("app.application.security.cookie", "app.adapters.network.browser"),
("app.application.security.cookie", "app.adapters.network.http"),
("app.application.security.passkey", "app.adapters.cache.redis"),
("app.application.torrent", "app.adapters.network.http"),
("app.application.transfer", "app.adapters.system.host"),
("app.chain._recognition", "app.adapters.external.server"),
("app.chain._transfer", "app.adapters.system.host"),
("app.chain.download", "app.adapters.network.http"),
("app.chain.download", "app.adapters.system.host"),
("app.chain.message", "app.adapters.network.http"),
("app.chain.scraping", "app.adapters.network.http"),
("app.chain.site", "app.adapters.external.cookiecloud"),
("app.chain.site", "app.adapters.network.browser"),
("app.chain.site", "app.adapters.network.cloudflare"),
("app.chain.site", "app.adapters.network.http"),
("app.chain.subscribe", "app.adapters.external.server"),
("app.chain.system", "app.adapters.network.http"),
("app.chain.system", "app.adapters.system.host"),
}
def _source(tmp_path: Path, name: str, content: str) -> tuple[str, Path]:
"""创建一个供 AST collector 使用的独立源码文件。"""
path = tmp_path / f"{name.replace('.', '_')}.py"
path.write_text(content, encoding="utf-8")
return name, path
def test_direct_adapter_collector_preserves_raw_runtime_imports(
tmp_path: Path,
) -> None:
"""原始模块只去重,不展开父包、猜测导入符号或纳入类型期依赖。"""
modules = dict(
[
_source(
tmp_path,
"app.application.sample",
"""
from typing import TYPE_CHECKING
import typing
import app.adapters.network.http as http
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.adapters.system import rust as rust_accel
if TYPE_CHECKING:
import app.adapters.network.browser
if typing.TYPE_CHECKING:
import app.adapters.network.cloudflare
def load():
from app.adapters.cache import redis
try:
from app.adapters.external import ocr
except ImportError:
pass
from app import adapters
from app.adapters.network import *
__import__("app.adapters.network.ip")
""",
),
_source(
tmp_path,
"app.chain.sample",
"import app.adapters.system.backup.files\n",
),
]
)
assert collect_direct_adapter_imports(modules) == [
{"source": "app.application.sample", "target": "app.adapters"},
{"source": "app.application.sample", "target": "app.adapters.cache"},
{"source": "app.application.sample", "target": "app.adapters.external"},
{"source": "app.application.sample", "target": "app.adapters.network"},
{"source": "app.application.sample", "target": "app.adapters.network.http"},
{"source": "app.application.sample", "target": "app.adapters.system"},
{"source": "app.chain.sample", "target": "app.adapters.system.backup.files"},
]
def test_direct_adapter_collector_handles_relative_imports_and_scope(
tmp_path: Path,
) -> None:
"""相对导入按 package 解析,伪前缀、DB Adapter 和非目标 source 必须排除。"""
modules = dict(
[
_source(
tmp_path,
"app.application.feature.worker",
"from ...adapters.network import ip\n",
),
_source(
tmp_path,
"app.application.too_high",
"from ....adapters.network import http\n",
),
_source(
tmp_path,
"app.application.db",
"import app.db.adapters.workflow\n",
),
_source(
tmp_path,
"app.application.fake",
"import app.adaptersx.network\n",
),
_source(
tmp_path,
"app.applicationx.fake",
"import app.adapters.network.http\n",
),
_source(
tmp_path,
"app.api.fake",
"import app.adapters.network.http\n",
),
_source(
tmp_path,
"app.plugins.fake",
"import app.adapters.network.http\n",
),
]
)
package_path = tmp_path / "__init__.py"
package_path.write_text(
"from ...adapters.cache import redis\n",
encoding="utf-8",
)
modules["app.chain.feature"] = package_path
assert collect_direct_adapter_imports(modules) == [
{
"source": "app.application.feature.worker",
"target": "app.adapters.network",
},
{"source": "app.chain.feature", "target": "app.adapters.cache"},
]
def test_current_direct_adapter_imports_are_stable_generated_facts() -> None:
"""当前 28 条直连必须完整进入生成事实,且不保存符号或行号。"""
contract = collect_dependency_baseline()["direct_adapter_imports"]
edges = {
(edge["source"], edge["target"])
for edge in contract["edges"]
}
assert contract["count"] == 28
assert contract["counts_by_source_root"] == {
"app.application": 15,
"app.chain": 13,
}
assert contract["source_count"] == 18
assert contract["target_count"] == 11
assert edges == EXPECTED_DIRECT_ADAPTER_IMPORTS
assert all(set(edge) == {"source", "target"} for edge in contract["edges"])
+9 -2
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from app.schemas.types import ChainEventType, EventType
PROJECT_ROOT = Path(__file__).parents[1]
BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
@@ -65,11 +64,19 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None:
baseline_path = BASELINE_ROOT / "dependency-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["schema_version"] == 2
assert baseline["module_count"] == len(baseline["modules"])
assert baseline["edge_count"] == len(baseline["edges"])
assert baseline["module_count"] > 0
assert baseline["edge_count"] > 0
direct_imports = baseline["direct_adapter_imports"]
assert direct_imports["count"] == len(direct_imports["edges"]) == 28
assert direct_imports["counts_by_source_root"] == {
"app.application": 15,
"app.chain": 13,
}
assert direct_imports["source_count"] == len(direct_imports["sources"]) == 18
assert direct_imports["target_count"] == len(direct_imports["targets"]) == 11
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():