mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
test(architecture): freeze direct adapter debt
This commit is contained in:
+158
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"scope": {
|
||||
"dependency_kind": "static_runtime_imports",
|
||||
"excluded_roots": [
|
||||
@@ -57,5 +57,161 @@
|
||||
"reason": "隔离的移植代码保持精确成员 containment;普通单向包外依赖允许,任何成员扩张或包外反向入环均失败。",
|
||||
"tracking": "replace-or-upgrade-vendored-package"
|
||||
}
|
||||
]
|
||||
],
|
||||
"direct_adapter_imports": {
|
||||
"classification": "temporary_debt",
|
||||
"scope": {
|
||||
"source_roots": [
|
||||
"app.application",
|
||||
"app.chain"
|
||||
],
|
||||
"target_root": "app.adapters",
|
||||
"runtime_only": true,
|
||||
"parent_package_expansion": false,
|
||||
"imported_symbols": false
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"source": "app.application.backup",
|
||||
"target": "app.adapters.system.backup.files",
|
||||
"tracking": "S2-L5"
|
||||
},
|
||||
{
|
||||
"source": "app.application.directory",
|
||||
"target": "app.adapters.system.host",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.image",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.image",
|
||||
"target": "app.adapters.network.ip",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.messaging.ingress",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.rss",
|
||||
"target": "app.adapters.network.browser",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.rss",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.rss",
|
||||
"target": "app.adapters.system",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.rules",
|
||||
"target": "app.adapters.system",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.cookie",
|
||||
"target": "app.adapters.external.ocr",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.cookie",
|
||||
"target": "app.adapters.network.browser",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.cookie",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis",
|
||||
"tracking": "S2-L4"
|
||||
},
|
||||
{
|
||||
"source": "app.application.torrent",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.application.transfer",
|
||||
"target": "app.adapters.system.host",
|
||||
"tracking": "S2-L6"
|
||||
},
|
||||
{
|
||||
"source": "app.chain._recognition",
|
||||
"target": "app.adapters.external.server",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain._transfer",
|
||||
"target": "app.adapters.system.host",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.download",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.download",
|
||||
"target": "app.adapters.system.host",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.message",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.scraping",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.site",
|
||||
"target": "app.adapters.external.cookiecloud",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.site",
|
||||
"target": "app.adapters.network.browser",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.site",
|
||||
"target": "app.adapters.network.cloudflare",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.site",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.subscribe",
|
||||
"target": "app.adapters.external.server",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.system",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L7"
|
||||
},
|
||||
{
|
||||
"source": "app.chain.system",
|
||||
"target": "app.adapters.system.host",
|
||||
"tracking": "S2-L7"
|
||||
}
|
||||
],
|
||||
"target_state": "empty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Application/Chain 到 Adapter 原始直连事实的收集契约。"""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.architecture.baseline import (
|
||||
@@ -7,35 +9,48 @@ from scripts.architecture.baseline import (
|
||||
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"),
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
DEPENDENCY_POLICY_PATH = (
|
||||
PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json"
|
||||
)
|
||||
DIRECT_ADAPTER_SOURCE_ROOTS = ("app.application", "app.chain")
|
||||
DIRECT_ADAPTER_TARGET_ROOT = "app.adapters"
|
||||
DIRECT_ADAPTER_SCOPE: dict[str, object] = {
|
||||
"source_roots": list(DIRECT_ADAPTER_SOURCE_ROOTS),
|
||||
"target_root": DIRECT_ADAPTER_TARGET_ROOT,
|
||||
"runtime_only": True,
|
||||
"parent_package_expansion": False,
|
||||
"imported_symbols": False,
|
||||
}
|
||||
FROZEN_DIRECT_ADAPTER_IMPORTS = {
|
||||
("app.application.backup", "app.adapters.system.backup.files"): "S2-L5",
|
||||
("app.application.directory", "app.adapters.system.host"): "S2-L6",
|
||||
("app.application.image", "app.adapters.network.http"): "S2-L6",
|
||||
("app.application.image", "app.adapters.network.ip"): "S2-L6",
|
||||
("app.application.messaging.ingress", "app.adapters.network.http"): "S2-L6",
|
||||
("app.application.rss", "app.adapters.network.browser"): "S2-L6",
|
||||
("app.application.rss", "app.adapters.network.http"): "S2-L6",
|
||||
("app.application.rss", "app.adapters.system"): "S2-L6",
|
||||
("app.application.rules", "app.adapters.system"): "S2-L6",
|
||||
("app.application.security.cookie", "app.adapters.external.ocr"): "S2-L6",
|
||||
("app.application.security.cookie", "app.adapters.network.browser"): "S2-L6",
|
||||
("app.application.security.cookie", "app.adapters.network.http"): "S2-L6",
|
||||
("app.application.security.passkey", "app.adapters.cache.redis"): "S2-L4",
|
||||
("app.application.torrent", "app.adapters.network.http"): "S2-L6",
|
||||
("app.application.transfer", "app.adapters.system.host"): "S2-L6",
|
||||
("app.chain._recognition", "app.adapters.external.server"): "S2-L7",
|
||||
("app.chain._transfer", "app.adapters.system.host"): "S2-L7",
|
||||
("app.chain.download", "app.adapters.network.http"): "S2-L7",
|
||||
("app.chain.download", "app.adapters.system.host"): "S2-L7",
|
||||
("app.chain.message", "app.adapters.network.http"): "S2-L7",
|
||||
("app.chain.scraping", "app.adapters.network.http"): "S2-L7",
|
||||
("app.chain.site", "app.adapters.external.cookiecloud"): "S2-L7",
|
||||
("app.chain.site", "app.adapters.network.browser"): "S2-L7",
|
||||
("app.chain.site", "app.adapters.network.cloudflare"): "S2-L7",
|
||||
("app.chain.site", "app.adapters.network.http"): "S2-L7",
|
||||
("app.chain.subscribe", "app.adapters.external.server"): "S2-L7",
|
||||
("app.chain.system", "app.adapters.network.http"): "S2-L7",
|
||||
("app.chain.system", "app.adapters.system.host"): "S2-L7",
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +61,57 @@ def _source(tmp_path: Path, name: str, content: str) -> tuple[str, Path]:
|
||||
return name, path
|
||||
|
||||
|
||||
def _adapter_policy_violations(
|
||||
actual: list[dict[str, str]],
|
||||
reviewed: list[dict[str, str]],
|
||||
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
||||
"""返回新增直连与已经消失但未清理的陈旧 policy。"""
|
||||
actual_edges = {(edge["source"], edge["target"]) for edge in actual}
|
||||
reviewed_edges = {(edge["source"], edge["target"]) for edge in reviewed}
|
||||
return sorted(actual_edges - reviewed_edges), sorted(reviewed_edges - actual_edges)
|
||||
|
||||
|
||||
def _is_module_or_child(module_name: str, root: str) -> bool:
|
||||
"""判断模块是否等于指定根或位于其点分子树内。"""
|
||||
return module_name == root or module_name.startswith(f"{root}.")
|
||||
|
||||
|
||||
def _adapter_policy_entry_errors(entries: list[dict[str, str]]) -> list[str]:
|
||||
"""校验 policy 条目结构、范围、唯一性和冻结 owner。"""
|
||||
errors: list[str] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for index, entry in enumerate(entries):
|
||||
if set(entry) != {"source", "target", "tracking"}:
|
||||
errors.append(f"entry[{index}]: fields")
|
||||
continue
|
||||
source = entry["source"]
|
||||
target = entry["target"]
|
||||
edge = (source, target)
|
||||
if edge in seen:
|
||||
errors.append(f"entry[{index}]: duplicate")
|
||||
seen.add(edge)
|
||||
if not any(
|
||||
_is_module_or_child(source, root)
|
||||
for root in DIRECT_ADAPTER_SOURCE_ROOTS
|
||||
):
|
||||
errors.append(f"entry[{index}]: source scope")
|
||||
if not _is_module_or_child(target, DIRECT_ADAPTER_TARGET_ROOT):
|
||||
errors.append(f"entry[{index}]: target scope")
|
||||
if FROZEN_DIRECT_ADAPTER_IMPORTS.get(edge) != entry["tracking"]:
|
||||
errors.append(f"entry[{index}]: frozen edge or tracking")
|
||||
return errors
|
||||
|
||||
|
||||
def _adapter_policy_scope_errors(
|
||||
policy_scope: dict[str, object],
|
||||
fact_scope: dict[str, object],
|
||||
) -> list[str]:
|
||||
"""要求人工 policy 与事实收集器共同锁定同一精确范围。"""
|
||||
if policy_scope == fact_scope == DIRECT_ADAPTER_SCOPE:
|
||||
return []
|
||||
return ["direct adapter scope drift"]
|
||||
|
||||
|
||||
def test_direct_adapter_collector_preserves_raw_runtime_imports(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -155,19 +221,145 @@ def test_direct_adapter_collector_handles_relative_imports_and_scope(
|
||||
|
||||
|
||||
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"]
|
||||
edges = contract["edges"]
|
||||
sources = sorted({edge["source"] for edge in edges})
|
||||
targets = sorted({edge["target"] for edge in edges})
|
||||
counts_by_root = Counter(
|
||||
root
|
||||
for edge in edges
|
||||
for root in DIRECT_ADAPTER_SOURCE_ROOTS
|
||||
if _is_module_or_child(edge["source"], root)
|
||||
)
|
||||
expected_counts = {
|
||||
root: counts_by_root[root]
|
||||
for root in DIRECT_ADAPTER_SOURCE_ROOTS
|
||||
}
|
||||
|
||||
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 edges == sorted(edges, key=lambda edge: (edge["source"], edge["target"]))
|
||||
assert contract["scope"] == DIRECT_ADAPTER_SCOPE
|
||||
assert contract["count"] == len(edges)
|
||||
assert contract["counts_by_source_root"] == expected_counts
|
||||
assert contract["sources"] == sources
|
||||
assert contract["source_count"] == len(sources)
|
||||
assert contract["targets"] == targets
|
||||
assert contract["target_count"] == len(targets)
|
||||
assert all(set(edge) == {"source", "target"} for edge in contract["edges"])
|
||||
|
||||
|
||||
def test_current_direct_adapter_imports_match_temporary_debt_policy() -> None:
|
||||
"""现存直连必须逐条绑定冻结 owner,并允许债务集合只减不增。"""
|
||||
policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8"))
|
||||
contract = collect_dependency_baseline()["direct_adapter_imports"]
|
||||
adapter_policy = policy["direct_adapter_imports"]
|
||||
entries = adapter_policy["entries"]
|
||||
|
||||
assert policy["schema_version"] == 2
|
||||
assert adapter_policy["classification"] == "temporary_debt"
|
||||
assert adapter_policy["target_state"] == "empty"
|
||||
assert _adapter_policy_scope_errors(adapter_policy["scope"], contract["scope"]) == []
|
||||
assert entries == sorted(entries, key=lambda item: (item["source"], item["target"]))
|
||||
assert Counter(FROZEN_DIRECT_ADAPTER_IMPORTS.values()) == {
|
||||
"S2-L4": 1,
|
||||
"S2-L5": 1,
|
||||
"S2-L6": 13,
|
||||
"S2-L7": 13,
|
||||
}
|
||||
assert _adapter_policy_entry_errors(entries) == []
|
||||
|
||||
unreviewed, stale = _adapter_policy_violations(contract["edges"], entries)
|
||||
assert unreviewed == []
|
||||
assert stale == []
|
||||
|
||||
|
||||
def test_adapter_policy_rejects_add_remove_and_replacement() -> None:
|
||||
"""新增、删除后未清 policy、以及换成另一条边都不能静默通过。"""
|
||||
reviewed = [
|
||||
{
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis",
|
||||
"tracking": "S2-L4",
|
||||
}
|
||||
]
|
||||
original = [
|
||||
{
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis",
|
||||
}
|
||||
]
|
||||
added = [
|
||||
*original,
|
||||
{
|
||||
"source": "app.chain.sample",
|
||||
"target": "app.adapters.system.host",
|
||||
},
|
||||
]
|
||||
replacement = [
|
||||
{
|
||||
"source": "app.application.sample",
|
||||
"target": "app.adapters.network.browser",
|
||||
}
|
||||
]
|
||||
|
||||
assert _adapter_policy_violations(original, reviewed) == ([], [])
|
||||
assert _adapter_policy_violations(added, reviewed) == (
|
||||
[("app.chain.sample", "app.adapters.system.host")],
|
||||
[],
|
||||
)
|
||||
assert _adapter_policy_violations([], reviewed) == (
|
||||
[],
|
||||
[("app.application.security.passkey", "app.adapters.cache.redis")],
|
||||
)
|
||||
assert _adapter_policy_violations(replacement, reviewed) == (
|
||||
[("app.application.sample", "app.adapters.network.browser")],
|
||||
[("app.application.security.passkey", "app.adapters.cache.redis")],
|
||||
)
|
||||
|
||||
assert _adapter_policy_violations([], []) == ([], [])
|
||||
assert _adapter_policy_entry_errors([]) == []
|
||||
|
||||
|
||||
def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
|
||||
"""手工 policy 也不能接纳新边、错 owner、重复项或越界范围。"""
|
||||
valid = {
|
||||
"source": "app.application.security.passkey",
|
||||
"target": "app.adapters.cache.redis",
|
||||
"tracking": "S2-L4",
|
||||
}
|
||||
invalid_entries = [
|
||||
{
|
||||
"source": "app.application.sample",
|
||||
"target": "app.adapters.network.http",
|
||||
"tracking": "S2-L6",
|
||||
},
|
||||
{
|
||||
"source": valid["source"],
|
||||
"target": "app.adapters.network.browser",
|
||||
"tracking": "S2-L4",
|
||||
},
|
||||
{**valid, "tracking": "S2-L5"},
|
||||
{
|
||||
"source": "app.api.sample",
|
||||
"target": valid["target"],
|
||||
"tracking": "S2-L4",
|
||||
},
|
||||
{
|
||||
"source": valid["source"],
|
||||
"target": "app.db.adapters.subscription",
|
||||
"tracking": "S2-L4",
|
||||
},
|
||||
{
|
||||
"source": "app.application.*",
|
||||
"target": "app.adapters.*",
|
||||
"tracking": "S2-L6",
|
||||
},
|
||||
]
|
||||
|
||||
assert _adapter_policy_entry_errors([valid]) == []
|
||||
assert all(_adapter_policy_entry_errors([entry]) for entry in invalid_entries)
|
||||
assert any("duplicate" in error for error in _adapter_policy_entry_errors([valid, valid]))
|
||||
drifted_scope = {**DIRECT_ADAPTER_SCOPE, "runtime_only": False}
|
||||
assert _adapter_policy_scope_errors(drifted_scope, DIRECT_ADAPTER_SCOPE) == [
|
||||
"direct adapter scope drift"
|
||||
]
|
||||
|
||||
@@ -31,6 +31,7 @@ def test_unit_test_workflow_has_independent_host_architecture_gate():
|
||||
assert "pull_request" in workflow["on"]
|
||||
assert "push" in workflow["on"]
|
||||
assert "tests/test_architecture_dependencies.py" in commands
|
||||
assert "tests/test_architecture_adapter_imports.py" in commands
|
||||
assert "tests/test_architecture_contract_baseline.py" in commands
|
||||
assert "scripts/architecture/baseline.py --check-host" in commands
|
||||
assert "scripts/architecture/ruff_ratchet.py" in commands
|
||||
|
||||
@@ -70,13 +70,14 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None:
|
||||
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["count"] == len(direct_imports["edges"])
|
||||
assert sum(direct_imports["counts_by_source_root"].values()) == direct_imports["count"]
|
||||
assert set(direct_imports["counts_by_source_root"]) <= {
|
||||
"app.application",
|
||||
"app.chain",
|
||||
}
|
||||
assert direct_imports["source_count"] == len(direct_imports["sources"]) == 18
|
||||
assert direct_imports["target_count"] == len(direct_imports["targets"]) == 11
|
||||
assert direct_imports["source_count"] == len(direct_imports["sources"])
|
||||
assert direct_imports["target_count"] == len(direct_imports["targets"])
|
||||
|
||||
|
||||
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():
|
||||
|
||||
@@ -1087,7 +1087,7 @@ def test_complete_host_sccs_match_reviewed_policy() -> None:
|
||||
policy = _load_dependency_policy()
|
||||
entries = policy["allowed_sccs"]
|
||||
|
||||
assert policy["schema_version"] == 1
|
||||
assert policy["schema_version"] == 2
|
||||
assert policy["scope"] == {
|
||||
"dependency_kind": "static_runtime_imports",
|
||||
"excluded_roots": ["app/plugins"],
|
||||
|
||||
Reference in New Issue
Block a user