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
+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():