diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index 5a529b547..d50e9ee04 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -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`。 diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index b84eac232..9af5e11a1 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -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), } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d54af5782..b5f78a09f 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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": [ [ diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index bf376b392..25e5f41fb 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -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 }, diff --git a/tests/test_architecture_adapter_imports.py b/tests/test_architecture_adapter_imports.py new file mode 100644 index 000000000..512b1cab5 --- /dev/null +++ b/tests/test_architecture_adapter_imports.py @@ -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"]) diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 89acbfb0a..a486c65d9 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -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():