mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor(architecture): unify event facts and policy
This commit is contained in:
+1858
-636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"scope": {
|
||||
"root": "app",
|
||||
"excluded": [
|
||||
"app.plugins"
|
||||
],
|
||||
"receiver_contract": "canonical_event_manager_only"
|
||||
},
|
||||
"event_consumers": {
|
||||
"match_mode": "exact_fingerprint_set",
|
||||
"entries": [
|
||||
{
|
||||
"caller": "app.chain.download",
|
||||
"qualname": "DownloadChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.DownloadFileDeleted"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "DownloadChain.download_file_deleted",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "eb33287636f1bb928cb221c64dfb9663fb20b87ad699c41182a673f5af19ab27",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.download",
|
||||
"reason": "下载文件删除事件由 DownloadChain 处理关联状态清理。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.scraping",
|
||||
"qualname": "ScrapingChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.MetadataScrape"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "ScrapingChain.scrape_metadata_event",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "b744f0a05247f5ad9871c180f3e9fd8952d382e94b6e1acad7234796bfea7b4c",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.scraping",
|
||||
"reason": "元数据刮削事件由 ScrapingChain 执行宿主刮削流程。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.search",
|
||||
"qualname": "SearchChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.SiteDeleted"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SearchChain.remove_site",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "5a7967a84e5716f1939530b1f5d561dbf97130d22330ad7c282d2924e0743fe5",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.search",
|
||||
"reason": "站点删除后由 SearchChain 清理搜索侧站点状态。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.site",
|
||||
"qualname": "SiteChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.SiteUpdated"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SiteChain.cache_site_icon",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "1200b7097150958ec2003a597237ab746defe80a865b0efef30596dc3b503598",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.site",
|
||||
"reason": "站点更新后由 SiteChain 刷新站点图标缓存。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.site",
|
||||
"qualname": "SiteChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.SiteUpdated"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SiteChain.clear_site_data",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "6ca5d8f1adb42c94670be1125435387ea8b9f3f20b4505337b2adc090a4b5714",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.site",
|
||||
"reason": "站点更新后由 SiteChain 清理已经失效的站点数据。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.site",
|
||||
"qualname": "SiteChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.SiteUpdated"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SiteChain.cache_site_userdata",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "c43b969848df947bc8e13d1777a08282cd45386867991786fba7f7ffe5027b47",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.site",
|
||||
"reason": "站点更新后由 SiteChain 刷新站点用户数据缓存。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.subscribe",
|
||||
"qualname": "SubscribeChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.SiteDeleted"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SubscribeChain.remove_site",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "f29dd0d5dd598e19281e2d1a577b98adc5a0c0722c569bb3e974cc6b984c0737",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.subscribe",
|
||||
"reason": "站点删除后由 SubscribeChain 清理订阅侧站点引用。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.subscribe",
|
||||
"qualname": "SubscribeChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.ConfigChanged"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "SubscribeChain.reconcile_rule_group_references",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "5764fa4e793a602ce36195edf6ce15a16d4875910c0104589b1c5aa9b3173077",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.subscribe",
|
||||
"reason": "配置变更后由 SubscribeChain 对齐订阅规则组引用。"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.workflow",
|
||||
"qualname": "WorkflowChain",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.WorkflowExecute"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "WorkflowChain.event_process",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "50704edda70674af0932e769ddda40d2c21d50b134155c975d675035d7c933cd",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.chain.workflow",
|
||||
"reason": "工作流执行事件由 WorkflowChain 进入标准执行编排。"
|
||||
},
|
||||
{
|
||||
"caller": "app.command",
|
||||
"qualname": "Command",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.CommandExcute"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "Command.command_event",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "64006f45841531463b1489576e1aee24ae1b50a2509a9773bc2149415f2c7d18",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.command",
|
||||
"reason": "命令执行事件由 Command 分派到宿主命令处理入口。"
|
||||
},
|
||||
{
|
||||
"caller": "app.command",
|
||||
"qualname": "Command",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.ModuleReload"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "Command.module_reload_event",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "89480fbdb61c366e8c77e42fd3bdaa330e634579e7d85cf28e2475b1e3ba7ae6",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.command",
|
||||
"reason": "模块重载事件由 Command 更新命令注册状态。"
|
||||
},
|
||||
{
|
||||
"caller": "app.runtime.extensions.module_manager",
|
||||
"qualname": "ModuleManager.__init__",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.ConfigChanged"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "self.handle_config_changed",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "37daee8e6104140ee28a6efebefd30f1f9ac6a7c44f9609199c1b4863b30c564",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.runtime.extensions.module_manager",
|
||||
"reason": "模块管理器监听配置变化并重建受影响的模块运行状态。"
|
||||
},
|
||||
{
|
||||
"caller": "app.runtime.reload",
|
||||
"qualname": "ConfigReloadMixin.__init_subclass__",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.ConfigChanged"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "handler",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "c53927bd5752ec004ac403f6dc07fca7b253f0db152fc773643972d7c3bf5d34",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.runtime.reload",
|
||||
"reason": "配置重载 Mixin 为声明式宿主类型安装统一刷新处理器。"
|
||||
},
|
||||
{
|
||||
"caller": "app.scheduler",
|
||||
"qualname": "Scheduler",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.PluginReload"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "Scheduler.on_plugin_reload",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.scheduler",
|
||||
"reason": "插件重载后由 Scheduler 对齐插件提供的定时任务。"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.agent",
|
||||
"qualname": "AgentInitializer.__init__",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [
|
||||
"EventType.ConfigChanged"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "_handle_agent_config_changed",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "829bb1b5d09ee66c26f4f500a25e12a768f041a8bcc5dab8954453a186fe0909",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.startup.initializers.agent",
|
||||
"reason": "Agent 初始化器监听配置变化并刷新 Agent 运行配置。"
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.initializers.modules",
|
||||
"qualname": "init_modules",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "constructed_manager",
|
||||
"events": [
|
||||
"EventType.NoticeMessage"
|
||||
],
|
||||
"dynamic": false,
|
||||
"invalid": false,
|
||||
"handler": "dispatch_web_agent_message_event",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "59263eddd85e4e5e30606a9d0ab6d02ba6a9483bff7f262a3818495e2c4f6ef5",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.startup.initializers.modules",
|
||||
"reason": "模块初始化阶段把通知消息桥接到 WebAgent 消息分发入口。"
|
||||
},
|
||||
{
|
||||
"caller": "app.workflow",
|
||||
"qualname": "WorkFlowManager.register_workflow_event",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [],
|
||||
"dynamic": true,
|
||||
"invalid": false,
|
||||
"handler": "self._handle_event",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188",
|
||||
"classification": "approved_dynamic_exception",
|
||||
"owner": "app.workflow",
|
||||
"reason": "工作流配置在运行期决定事件类型,receiver 与 handler 仍可静态证明。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from scripts.architecture import baseline as architecture_baseline
|
||||
from scripts.architecture.event_policy import DEFAULT_EVENT_POLICY_PATH
|
||||
from scripts.startup import performance as startup_performance
|
||||
|
||||
|
||||
@@ -153,7 +154,7 @@ def test_architecture_report_only_supports_check_operations(capsys, tmp_path: Pa
|
||||
|
||||
|
||||
def test_runtime_semantics_ignore_line_changes_but_keep_call_count(tmp_path: Path):
|
||||
"""旧 fixture 的行号变化不影响门禁,重复调用次数仍属于语义。"""
|
||||
"""v1 行号变化经链式迁移不影响语义,重复调用次数仍被保留。"""
|
||||
baseline_path = tmp_path / "runtime-contract-baseline.json"
|
||||
old_value = {
|
||||
"schema_version": 1,
|
||||
@@ -199,11 +200,110 @@ def test_runtime_semantics_ignore_line_changes_but_keep_call_count(tmp_path: Pat
|
||||
)
|
||||
|
||||
assert old_semantic == moved_semantic
|
||||
assert old_semantic["schema_version"] == 3
|
||||
assert old_semantic["event_facts"]["migration_required"] is True
|
||||
assert old_semantic["run_module"]["methods"]["search"] == [
|
||||
{"caller": "app.chain.search", "mode": "sync", "count": 2}
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_v2_fixture_migrates_to_explicit_v3_review_projection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""v2 聚合事件只能迁移为待审查投影,不能伪造逐调用事实身份。"""
|
||||
baseline_path = tmp_path / "runtime-contract-baseline.json"
|
||||
value = {
|
||||
"schema_version": 2,
|
||||
"run_module": {"method_count": 0, "call_count": 0},
|
||||
"events": {
|
||||
"event_count": 1,
|
||||
"producer_count": 1,
|
||||
"consumer_count": 0,
|
||||
"events": {},
|
||||
"dynamic_producers": [],
|
||||
"dynamic_consumers": [],
|
||||
},
|
||||
"event_specs": {"EventType.Alpha": {"schema_version": 1}},
|
||||
"module_method_specs": {"search": {"mode": "sync"}},
|
||||
"sdk_exports": {},
|
||||
"compat_manifest": {},
|
||||
}
|
||||
|
||||
migrated = architecture_baseline.semantic_baseline(baseline_path, value)
|
||||
|
||||
assert migrated["schema_version"] == 3
|
||||
assert migrated["scope"]["excluded"] == ["app/plugins"]
|
||||
assert migrated["event_facts"] == {
|
||||
"migration_required": True,
|
||||
"legacy_v2_projection": value["events"],
|
||||
}
|
||||
assert migrated["event_specs"] == value["event_specs"]
|
||||
assert migrated["module_method_specs"] == value["module_method_specs"]
|
||||
|
||||
|
||||
def test_runtime_v2_check_and_report_fail_cleanly_until_explicit_refresh(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""旧 v2 fixture 面对逐调用 v3 事实时应正常报告差异而非崩溃。"""
|
||||
baseline_path = tmp_path / "runtime-contract-baseline.json"
|
||||
old_value = {
|
||||
"schema_version": 2,
|
||||
"run_module": {},
|
||||
"events": {
|
||||
"event_count": 0,
|
||||
"producer_count": 0,
|
||||
"consumer_count": 0,
|
||||
"events": {},
|
||||
"dynamic_producers": [],
|
||||
"dynamic_consumers": [],
|
||||
},
|
||||
"sdk_exports": {},
|
||||
"compat_manifest": {},
|
||||
}
|
||||
baseline_path.write_text(json.dumps(old_value), encoding="utf-8")
|
||||
actual = {
|
||||
"schema_version": 3,
|
||||
"scope": {
|
||||
"repository": "MoviePilot",
|
||||
"roots": ["app"],
|
||||
"excluded": ["app/plugins"],
|
||||
},
|
||||
"run_module": {},
|
||||
"module_method_specs": {},
|
||||
"event_facts": {
|
||||
"producer_call_count": 0,
|
||||
"producers": [],
|
||||
"consumers": [],
|
||||
},
|
||||
"event_specs": {},
|
||||
"sdk_exports": {},
|
||||
"compat_manifest": {},
|
||||
}
|
||||
|
||||
assert not architecture_baseline.check_json(
|
||||
baseline_path,
|
||||
actual,
|
||||
write_hint="--write-host",
|
||||
)
|
||||
report = architecture_baseline.build_comparison_report(baseline_path, actual)
|
||||
assert report["semantic_match"] is False
|
||||
assert report["added"] or report["removed"] or report["changed"]
|
||||
|
||||
|
||||
def test_comparison_report_preserves_duplicate_fact_multiplicity(tmp_path: Path) -> None:
|
||||
"""相同 producer fingerprint 的调用次数变化也必须出现在审查报告。"""
|
||||
baseline_path = tmp_path / "other-baseline.json"
|
||||
baseline_path.write_text('{"facts": [{"id": "same"}]}', encoding="utf-8")
|
||||
|
||||
report = architecture_baseline.build_comparison_report(
|
||||
baseline_path,
|
||||
{"facts": [{"id": "same"}, {"id": "same"}]},
|
||||
)
|
||||
|
||||
assert report["semantic_match"] is False
|
||||
assert report["added"] == [{"path": "$.facts", "value": {"id": "same"}}]
|
||||
|
||||
|
||||
def test_plugin_provenance_does_not_participate_in_semantic_gate(tmp_path: Path):
|
||||
"""插件仓 revision 和源码摘要变化不应伪装成 ABI 变化。"""
|
||||
baseline_path = tmp_path / "official-plugin-baseline.json"
|
||||
@@ -455,6 +555,7 @@ def test_architecture_write_host_only_updates_host_files(
|
||||
policy_path = tmp_path / "dependency-policy.json"
|
||||
plugin_path = tmp_path / "plugin.json"
|
||||
policy_path.write_text('{"manual": true}\n', encoding="utf-8")
|
||||
event_policy_before = DEFAULT_EVENT_POLICY_PATH.read_bytes()
|
||||
monkeypatch.setattr(
|
||||
architecture_baseline,
|
||||
"DEPENDENCY_BASELINE_PATH",
|
||||
@@ -513,6 +614,7 @@ def test_architecture_write_host_only_updates_host_files(
|
||||
"scope": "host-configuration"
|
||||
}
|
||||
assert json.loads(policy_path.read_text()) == {"manual": True}
|
||||
assert DEFAULT_EVENT_POLICY_PATH.read_bytes() == event_policy_before
|
||||
assert not plugin_path.exists()
|
||||
output = capsys.readouterr().out
|
||||
assert "即将写入" in output
|
||||
@@ -521,6 +623,7 @@ def test_architecture_write_host_only_updates_host_files(
|
||||
assert "transaction.json" in output
|
||||
assert "configuration.json" in output
|
||||
assert "dependency-policy.json" not in output
|
||||
assert "runtime-contract-policy.json" not in output
|
||||
|
||||
|
||||
def test_architecture_write_plugins_only_updates_plugin_file(
|
||||
|
||||
@@ -27,15 +27,37 @@ def test_unit_test_workflow_has_independent_host_architecture_gate():
|
||||
"""主仓 PR 与推送必须在全量分片外快速执行宿主架构门禁。"""
|
||||
workflow = _load_workflow("test.yml")
|
||||
commands = _step_commands(workflow, "architecture")
|
||||
steps = workflow["jobs"]["architecture"]["steps"]
|
||||
semantic_step = next(
|
||||
step for step in steps if step.get("name") == "Check event semantic policy"
|
||||
)
|
||||
snapshot_step = next(
|
||||
step for step in steps if step.get("name") == "Check host architecture snapshot"
|
||||
)
|
||||
|
||||
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_egress.py" in commands
|
||||
assert "tests/test_architecture_event_consumers.py" in commands
|
||||
assert "tests/test_architecture_event_facts.py" in semantic_step["run"]
|
||||
assert "tests/test_architecture_event_policy.py" in semantic_step["run"]
|
||||
assert "tests/test_architecture_dependencies.py" in semantic_step["run"]
|
||||
assert "tests/test_architecture_adapter_imports.py" in semantic_step["run"]
|
||||
assert "tests/test_architecture_egress.py" in semantic_step["run"]
|
||||
assert "scripts/architecture/event_policy.py" in semantic_step["run"]
|
||||
assert "scripts/architecture/baseline.py" not in semantic_step["run"]
|
||||
assert not {
|
||||
"tests/test_architecture_dependencies.py",
|
||||
"tests/test_architecture_adapter_imports.py",
|
||||
"tests/test_architecture_egress.py",
|
||||
"tests/test_architecture_event_facts.py",
|
||||
"tests/test_architecture_event_policy.py",
|
||||
} & set(snapshot_step["run"].split())
|
||||
assert steps.index(semantic_step) < steps.index(snapshot_step)
|
||||
assert "tests/test_architecture_contract_baseline.py" in commands
|
||||
assert "scripts/architecture/baseline.py --check-host" in commands
|
||||
assert commands.count("scripts/architecture/baseline.py --check-host") == 1
|
||||
assert "scripts/architecture/ruff_ratchet.py" in commands
|
||||
assert "scripts/architecture/mypy_ratchet.py" in commands
|
||||
assert "scripts/architecture/ruff_ratchet.py --write" not in commands
|
||||
|
||||
@@ -11,33 +11,6 @@ PROJECT_ROOT = Path(__file__).parents[1]
|
||||
BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
|
||||
|
||||
|
||||
def test_architecture_contract_baselines_match_current_source():
|
||||
"""宿主依赖图和公开运行契约变化必须显式刷新基线。"""
|
||||
baseline_paths = (
|
||||
BASELINE_ROOT / "dependency-baseline.json",
|
||||
BASELINE_ROOT / "runtime-contract-baseline.json",
|
||||
BASELINE_ROOT / "transaction-debt-baseline.json",
|
||||
BASELINE_ROOT / "configuration-debt-baseline.json",
|
||||
)
|
||||
contents_before = {
|
||||
path: path.read_bytes()
|
||||
for path in baseline_paths
|
||||
}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/architecture/baseline.py", "--check-host"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert {
|
||||
path: path.read_bytes()
|
||||
for path in baseline_paths
|
||||
} == contents_before
|
||||
|
||||
|
||||
def test_official_plugin_baseline_records_external_source():
|
||||
"""官方插件快照必须绑定独立仓提交,且不得引用宿主插件副本。"""
|
||||
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
|
||||
@@ -93,6 +66,69 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None:
|
||||
assert all("line" not in entry for entry in direct_egress["entries"])
|
||||
|
||||
|
||||
def test_architecture_documents_match_generated_quality_metrics() -> None:
|
||||
"""高漂移量化指标必须与生成 fixture 同步,不能在多份文档中分叉。"""
|
||||
dependency = json.loads(
|
||||
(BASELINE_ROOT / "dependency-baseline.json").read_text(encoding="utf-8")
|
||||
)
|
||||
ruff = json.loads(
|
||||
(BASELINE_ROOT / "ruff-baseline.json").read_text(encoding="utf-8")
|
||||
)
|
||||
coverage = json.loads(
|
||||
(BASELINE_ROOT / "coverage-baseline.json").read_text(encoding="utf-8")
|
||||
)
|
||||
runtime = json.loads(
|
||||
(BASELINE_ROOT / "runtime-contract-baseline.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
overview = (PROJECT_ROOT / "docs" / "architecture-overview.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
checklist = (
|
||||
PROJECT_ROOT / "docs" / "architecture-optimization-checklist.md"
|
||||
).read_text(encoding="utf-8")
|
||||
roadmap = (
|
||||
PROJECT_ROOT / "docs" / "architecture-refactor-roadmap.md"
|
||||
).read_text(encoding="utf-8")
|
||||
edge_count = f"{dependency['edge_count']:,}"
|
||||
ruff_count = sum(
|
||||
count
|
||||
for diagnostics in ruff.values()
|
||||
for count in diagnostics.values()
|
||||
)
|
||||
application_coverage = coverage["application"]["percent"]
|
||||
domain_coverage = coverage["domain"]["percent"]
|
||||
event_facts = runtime["event_facts"]
|
||||
|
||||
assert edge_count in overview
|
||||
assert f"{dependency['module_count']} / {edge_count}" in checklist
|
||||
assert f"Ruff 历史诊断 | {ruff_count}" in checklist
|
||||
assert f"Application {application_coverage:.2f}%" in checklist
|
||||
assert f"Domain {domain_coverage:.2f}%" in checklist
|
||||
assert f"当前受控 {ruff_count} 条诊断归零" in roadmap
|
||||
assert (
|
||||
f"当前宿主有 {event_facts['producer_call_count']} 个\n"
|
||||
f"生产调用,其中 {event_facts['static_producer_call_count']} 个静态解析为 "
|
||||
f"{event_facts['producer_event_reference_count']} 个事件引用"
|
||||
) in overview
|
||||
assert (
|
||||
f"{event_facts['consumer_registration_count']} 个消费注册中 "
|
||||
f"{event_facts['static_consumer_count']} 个静态、"
|
||||
f"{event_facts['dynamic_consumer_count']} 个动态"
|
||||
) in overview
|
||||
assert (
|
||||
f"{event_facts['producer_call_count']} 个 producer("
|
||||
f"{event_facts['static_producer_call_count']} 静态、"
|
||||
f"{event_facts['dynamic_producer_count']} 动态)"
|
||||
) in checklist
|
||||
assert (
|
||||
f"{event_facts['consumer_registration_count']} 个 consumer("
|
||||
f"{event_facts['static_consumer_count']} 静态、"
|
||||
f"{event_facts['dynamic_consumer_count']} 动态)"
|
||||
) in checklist
|
||||
|
||||
|
||||
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():
|
||||
"""宿主探索页消费的官方插件 API 不得依赖动态路由隐式包装。"""
|
||||
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
|
||||
@@ -139,7 +175,12 @@ def test_runtime_contract_baseline_excludes_diagnostic_line_numbers():
|
||||
baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["schema_version"] == 2
|
||||
assert baseline["schema_version"] == 3
|
||||
assert baseline["scope"] == {
|
||||
"repository": "MoviePilot",
|
||||
"roots": ["app"],
|
||||
"excluded": ["app/plugins"],
|
||||
}
|
||||
assert '"line"' not in json.dumps(baseline)
|
||||
|
||||
|
||||
@@ -383,24 +424,30 @@ for package_name, symbol_name in contracts:
|
||||
|
||||
|
||||
def test_event_contract_baseline_covers_every_public_event_enum() -> None:
|
||||
"""事件生产者/消费者快照必须覆盖全部广播和链式事件枚举。"""
|
||||
"""统一事件事实快照必须覆盖全部枚举和真实生产、消费调用。"""
|
||||
baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
events = baseline["events"]
|
||||
events = baseline["event_facts"]
|
||||
expected = {
|
||||
*(f"EventType.{member.name}" for member in EventType),
|
||||
*(f"ChainEventType.{member.name}" for member in ChainEventType),
|
||||
}
|
||||
|
||||
assert set(events["events"]) == expected
|
||||
assert set(events["event_index"]) == expected
|
||||
assert events["event_count"] == len(expected)
|
||||
assert events["producer_count"] > 0
|
||||
assert events["consumer_count"] == 16
|
||||
assert events["dynamic_consumers"] == [
|
||||
{"caller": "app.workflow", "count": 1}
|
||||
]
|
||||
assert events["producer_call_count"] == 99
|
||||
assert events["static_producer_call_count"] == 98
|
||||
assert events["dynamic_producer_count"] == 1
|
||||
assert events["invalid_producer_count"] == 0
|
||||
assert events["producer_event_reference_count"] == 100
|
||||
assert events["consumer_registration_count"] == 17
|
||||
assert events["static_consumer_count"] == 16
|
||||
assert events["dynamic_consumer_count"] == 1
|
||||
assert events["invalid_consumer_count"] == 0
|
||||
assert events["consumer_event_reference_count"] == 16
|
||||
assert events["fact_count"] == 116
|
||||
assert len({fact["fingerprint"] for fact in events["consumers"]}) == 17
|
||||
assert all(
|
||||
not item["caller"].startswith("app.plugins")
|
||||
for contract in events["events"].values()
|
||||
for item in contract["consumers"]
|
||||
not fact["caller"].startswith("app.plugins")
|
||||
for fact in (*events["producers"], *events["consumers"])
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.architecture.baseline import (
|
||||
collect_event_contracts as _collect_event_contracts,
|
||||
collect_current_event_facts as _collect_current_event_facts,
|
||||
)
|
||||
from scripts.architecture.baseline import (
|
||||
discover_modules as _discover_modules,
|
||||
@@ -1288,13 +1288,8 @@ def test_application_services_do_not_resolve_event_manager_singleton():
|
||||
|
||||
def test_http_endpoints_do_not_register_process_event_listeners():
|
||||
"""HTTP 端点不得拥有进程级事件监听器,监听装配必须留在 startup。"""
|
||||
contracts = _collect_event_contracts()
|
||||
callers = {
|
||||
item["caller"]
|
||||
for event in contracts["events"].values()
|
||||
for item in event["consumers"]
|
||||
}
|
||||
callers.update(item["caller"] for item in contracts["dynamic_consumers"])
|
||||
facts = _collect_current_event_facts()
|
||||
callers = {item["caller"] for item in facts["consumers"]}
|
||||
|
||||
assert {caller for caller in callers if caller.startswith("app.api")} == set()
|
||||
|
||||
|
||||
+343
-6
@@ -1,8 +1,12 @@
|
||||
"""Event consumer AST collector tests."""
|
||||
"""Unified Event producer and consumer AST collector tests."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.architecture.event_consumers import collect_event_consumers
|
||||
from scripts.architecture.event_facts import (
|
||||
collect_event_facts,
|
||||
fingerprint_event_fact,
|
||||
)
|
||||
|
||||
EVENT_MEMBERS = {
|
||||
"EventType": ("Alpha", "Beta", "Gamma"),
|
||||
@@ -10,6 +14,18 @@ EVENT_MEMBERS = {
|
||||
}
|
||||
|
||||
|
||||
def _collect_facts(
|
||||
tmp_path: Path,
|
||||
source: str,
|
||||
*,
|
||||
module_name: str = "app.sample",
|
||||
) -> dict[str, list[dict]]:
|
||||
"""从单个临时宿主模块收集统一 Event 事实。"""
|
||||
path = tmp_path / f"{module_name.replace('.', '_')}.py"
|
||||
path.write_text(source, encoding="utf-8")
|
||||
return collect_event_facts({module_name: path}, EVENT_MEMBERS)
|
||||
|
||||
|
||||
def _collect(
|
||||
tmp_path: Path,
|
||||
source: str,
|
||||
@@ -19,7 +35,29 @@ def _collect(
|
||||
"""从单个临时宿主模块收集 Event consumer。"""
|
||||
path = tmp_path / f"{module_name.replace('.', '_')}.py"
|
||||
path.write_text(source, encoding="utf-8")
|
||||
return collect_event_consumers({module_name: path}, EVENT_MEMBERS)
|
||||
facts = collect_event_facts({module_name: path}, EVENT_MEMBERS)
|
||||
static: dict[str, list[dict]] = defaultdict(list)
|
||||
dynamic: list[dict] = []
|
||||
for fact in facts["consumers"]:
|
||||
location = {
|
||||
"caller": fact["caller"],
|
||||
"line": fact["line"],
|
||||
"handler": fact["handler"],
|
||||
"identity": "|".join(
|
||||
(
|
||||
fact["registration_kind"],
|
||||
fact["handler"],
|
||||
fact["priority"],
|
||||
)
|
||||
),
|
||||
"priority": fact["priority"],
|
||||
"registration_kind": fact["registration_kind"],
|
||||
}
|
||||
for event in fact["events"]:
|
||||
static[event].append(dict(location))
|
||||
if fact["dynamic"]:
|
||||
dynamic.append(dict(location))
|
||||
return dict(static), dynamic
|
||||
|
||||
|
||||
def _stable_locations(locations: list[dict]) -> list[dict]:
|
||||
@@ -627,7 +665,273 @@ def handler(event):
|
||||
host_path.write_text(source, encoding="utf-8")
|
||||
plugin_path.write_text(source.replace("Alpha", "Beta"), encoding="utf-8")
|
||||
|
||||
static, dynamic = collect_event_consumers(
|
||||
facts = collect_event_facts(
|
||||
{
|
||||
"app.host": host_path,
|
||||
"app.plugins.sample": plugin_path,
|
||||
},
|
||||
EVENT_MEMBERS,
|
||||
)
|
||||
static: dict[str, list[dict]] = defaultdict(list)
|
||||
dynamic: list[dict] = []
|
||||
for fact in facts["consumers"]:
|
||||
for event in fact["events"]:
|
||||
static[event].append(fact)
|
||||
if fact["dynamic"]:
|
||||
dynamic.append(fact)
|
||||
|
||||
assert dynamic == []
|
||||
assert set(static) == {"EventType.Alpha"}
|
||||
|
||||
|
||||
def test_collect_event_facts_resolves_producer_receivers_and_bound_aliases(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Producer 仅接受 canonical receiver,并保留方法与 qualname。"""
|
||||
facts = _collect_facts(
|
||||
tmp_path,
|
||||
'''
|
||||
from app.runtime.events import EventManager, eventmanager
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
import app.runtime.events as runtime_events
|
||||
import app.schemas.types as schema_types
|
||||
|
||||
receiver = eventmanager
|
||||
emit = receiver.send_event
|
||||
emit(EventType.Alpha)
|
||||
EventManager().send_event(EventType.Gamma)
|
||||
EventManager.get_existing_instance().send_event(ChainEventType.Delta)
|
||||
|
||||
async def publish():
|
||||
await runtime_events.eventmanager.async_send_event(
|
||||
etype=schema_types.EventType.Beta,
|
||||
)
|
||||
''',
|
||||
)
|
||||
|
||||
assert facts["consumers"] == []
|
||||
assert len(facts["producers"]) == 4
|
||||
assert {
|
||||
(
|
||||
fact["qualname"],
|
||||
fact["method"],
|
||||
fact["receiver_kind"],
|
||||
tuple(fact["events"]),
|
||||
)
|
||||
for fact in facts["producers"]
|
||||
} == {
|
||||
("<module>", "send_event", "canonical_singleton", ("EventType.Alpha",)),
|
||||
("<module>", "send_event", "constructed_manager", ("EventType.Gamma",)),
|
||||
("<module>", "send_event", "existing_manager", ("ChainEventType.Delta",)),
|
||||
("publish", "async_send_event", "canonical_singleton", ("EventType.Beta",)),
|
||||
}
|
||||
producer_keys = {
|
||||
"caller",
|
||||
"line",
|
||||
"qualname",
|
||||
"method",
|
||||
"receiver_kind",
|
||||
"events",
|
||||
"dynamic",
|
||||
"invalid",
|
||||
"fingerprint",
|
||||
}
|
||||
assert all(set(fact) == producer_keys for fact in facts["producers"])
|
||||
assert all(
|
||||
fact["fingerprint"] == fingerprint_event_fact(fact)
|
||||
for fact in facts["producers"]
|
||||
)
|
||||
|
||||
|
||||
def test_collect_event_facts_scopes_injected_ports_to_owning_classes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""构造注入端口可沿继承使用,但不得证明无关类的同名字段。"""
|
||||
facts = _collect_facts(
|
||||
tmp_path,
|
||||
'''
|
||||
from typing import Protocol
|
||||
from app.runtime.events import EventManager as CanonicalEventManager
|
||||
from app.schemas.types import EventType
|
||||
|
||||
class SampleEventPublisher(Protocol):
|
||||
def send_event(self, etype, data=None, priority=10): ...
|
||||
|
||||
class PublisherMixin:
|
||||
def emit(self):
|
||||
receiver = self.publisher
|
||||
bound = receiver.send_event
|
||||
bound(EventType.Alpha)
|
||||
|
||||
class GoodPublisher(PublisherMixin):
|
||||
def __init__(self, publisher: SampleEventPublisher):
|
||||
self.publisher = publisher
|
||||
|
||||
class SiblingPublisher(PublisherMixin):
|
||||
def emit_sibling(self):
|
||||
self.publisher.send_event(EventType.Beta)
|
||||
|
||||
class BadPublisher:
|
||||
def emit(self):
|
||||
self.publisher.send_event(EventType.Beta)
|
||||
|
||||
def free_function(publisher: SampleEventPublisher):
|
||||
publisher.send_event(EventType.Beta)
|
||||
|
||||
class GoodManager:
|
||||
def __init__(self, manager: CanonicalEventManager):
|
||||
self.manager = manager
|
||||
|
||||
def emit(self):
|
||||
self.manager.async_send_event(EventType.Gamma)
|
||||
|
||||
class EventManager:
|
||||
pass
|
||||
|
||||
class FakeManagerOwner:
|
||||
def __init__(self, manager: EventManager):
|
||||
self.manager = manager
|
||||
|
||||
def emit(self):
|
||||
self.manager.send_event(EventType.Beta)
|
||||
''',
|
||||
)
|
||||
|
||||
assert facts["consumers"] == []
|
||||
assert [fact["events"] for fact in facts["producers"]] == [
|
||||
["EventType.Gamma"],
|
||||
["EventType.Alpha"],
|
||||
]
|
||||
assert {fact["receiver_kind"] for fact in facts["producers"]} == {
|
||||
"injected_event_manager",
|
||||
"injected_event_publisher",
|
||||
}
|
||||
|
||||
|
||||
def test_collect_event_facts_separates_static_dynamic_and_invalid_producers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""有限 IfExp 展开,运行期值与伪 enum 成员分别标记。"""
|
||||
facts = _collect_facts(
|
||||
tmp_path,
|
||||
'''
|
||||
from app.runtime.events import eventmanager
|
||||
from app.schemas.types import EventType
|
||||
|
||||
selected = EventType.Alpha if enabled else EventType.Beta
|
||||
eventmanager.send_event(selected)
|
||||
eventmanager.send_event(EventType(name))
|
||||
eventmanager.send_event(EventType.Missing)
|
||||
unknown.send_event(EventType.Gamma)
|
||||
|
||||
eventmanager.send_event()
|
||||
eventmanager.send_event(event_type=EventType.Alpha)
|
||||
eventmanager.send_event(EventType.Alpha, etype=EventType.Beta)
|
||||
eventmanager.send_event(EventType.Alpha, unknown=True)
|
||||
''',
|
||||
)
|
||||
|
||||
producers = facts["producers"]
|
||||
assert len(producers) == 3
|
||||
assert [
|
||||
(fact["events"], fact["dynamic"], fact["invalid"])
|
||||
for fact in producers
|
||||
] == [
|
||||
(["EventType.Alpha", "EventType.Beta"], False, False),
|
||||
([], True, False),
|
||||
([], False, True),
|
||||
]
|
||||
|
||||
|
||||
def test_collect_event_facts_accepts_only_event_manager_internal_producers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""EventManager 内部 self 可生产事件,但注册委托不重复计 consumer。"""
|
||||
facts = _collect_facts(
|
||||
tmp_path,
|
||||
'''
|
||||
from app.schemas.types import EventType
|
||||
|
||||
class EventManager:
|
||||
def __init__(self):
|
||||
self.send_event(EventType.Alpha)
|
||||
|
||||
def register(self, handler):
|
||||
self.add_event_listener(EventType.Beta, handler)
|
||||
''',
|
||||
module_name="app.runtime.events",
|
||||
)
|
||||
|
||||
assert facts["consumers"] == []
|
||||
assert len(facts["producers"]) == 1
|
||||
assert facts["producers"][0]["receiver_kind"] == "event_manager_self"
|
||||
assert facts["producers"][0]["events"] == ["EventType.Alpha"]
|
||||
|
||||
|
||||
def test_collect_event_facts_consumer_schema_and_fingerprint_are_stable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Consumer 使用完整字段名,摘要排除 line 且覆盖全部治理字段。"""
|
||||
source = '''
|
||||
from app.runtime.events import eventmanager
|
||||
from app.schemas.types import EventType
|
||||
|
||||
@eventmanager.register(EventType.Alpha, priority=3)
|
||||
def handler(event):
|
||||
pass
|
||||
'''
|
||||
fact = _collect_facts(tmp_path, source)["consumers"][0]
|
||||
assert set(fact) == {
|
||||
"caller",
|
||||
"line",
|
||||
"qualname",
|
||||
"method",
|
||||
"receiver_kind",
|
||||
"events",
|
||||
"dynamic",
|
||||
"invalid",
|
||||
"fingerprint",
|
||||
"handler",
|
||||
"priority",
|
||||
"registration_kind",
|
||||
}
|
||||
assert fact["registration_kind"] == "decorator"
|
||||
moved = dict(fact, line=fact["line"] + 100, fingerprint="stale")
|
||||
assert fingerprint_event_fact(moved) == fact["fingerprint"]
|
||||
for key, value in {
|
||||
"caller": "app.changed",
|
||||
"qualname": "Changed.handler",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "constructed_manager",
|
||||
"events": ["EventType.Beta"],
|
||||
"dynamic": True,
|
||||
"invalid": True,
|
||||
"handler": "other",
|
||||
"priority": "4",
|
||||
"registration_kind": "listener",
|
||||
}.items():
|
||||
changed = dict(fact, **{key: value})
|
||||
assert fingerprint_event_fact(changed) != fact["fingerprint"]
|
||||
|
||||
|
||||
def test_collect_event_facts_excludes_all_plugin_facts(tmp_path: Path) -> None:
|
||||
"""误传插件路径时 producer 与 consumer 都不得进入宿主事实。"""
|
||||
host_path = tmp_path / "host_facts.py"
|
||||
plugin_path = tmp_path / "plugin_facts.py"
|
||||
source = '''
|
||||
from app.runtime.events import eventmanager
|
||||
from app.schemas.types import EventType
|
||||
|
||||
eventmanager.send_event(EventType.Alpha)
|
||||
|
||||
@eventmanager.register(EventType.Beta)
|
||||
def handler(event):
|
||||
pass
|
||||
'''
|
||||
host_path.write_text(source, encoding="utf-8")
|
||||
plugin_path.write_text(source.replace("Alpha", "Gamma"), encoding="utf-8")
|
||||
|
||||
facts = collect_event_facts(
|
||||
{
|
||||
"app.host": host_path,
|
||||
"app.plugins.sample": plugin_path,
|
||||
@@ -635,5 +939,38 @@ def handler(event):
|
||||
EVENT_MEMBERS,
|
||||
)
|
||||
|
||||
assert dynamic == []
|
||||
assert set(static) == {"EventType.Alpha"}
|
||||
assert len(facts["producers"]) == 1
|
||||
assert facts["producers"][0]["caller"] == "app.host"
|
||||
assert len(facts["consumers"]) == 1
|
||||
assert facts["consumers"][0]["caller"] == "app.host"
|
||||
|
||||
|
||||
def test_collect_event_facts_matches_current_host_inventory() -> None:
|
||||
"""统一事实覆盖当前 99 个 producer 调用与 17 个 consumer 调用。"""
|
||||
from scripts.architecture.baseline import (
|
||||
_event_enum_members,
|
||||
discover_modules,
|
||||
)
|
||||
|
||||
facts = collect_event_facts(
|
||||
discover_modules(),
|
||||
{
|
||||
enum_name: _event_enum_members(enum_name)
|
||||
for enum_name in ("EventType", "ChainEventType")
|
||||
},
|
||||
)
|
||||
producers = facts["producers"]
|
||||
consumers = facts["consumers"]
|
||||
|
||||
assert len(producers) == 99
|
||||
assert sum(not fact["dynamic"] and not fact["invalid"] for fact in producers) == 98
|
||||
assert sum(fact["dynamic"] for fact in producers) == 1
|
||||
assert sum(fact["invalid"] for fact in producers) == 0
|
||||
assert sum(len(fact["events"]) for fact in producers) == 100
|
||||
assert len(consumers) == 17
|
||||
assert sum(not fact["dynamic"] and not fact["invalid"] for fact in consumers) == 16
|
||||
assert sum(fact["dynamic"] for fact in consumers) == 1
|
||||
assert sum(fact["invalid"] for fact in consumers) == 0
|
||||
assert sum(len(fact["events"]) for fact in consumers) == 16
|
||||
consumer_fingerprints = [fact["fingerprint"] for fact in consumers]
|
||||
assert len(consumer_fingerprints) == len(set(consumer_fingerprints))
|
||||
@@ -0,0 +1,323 @@
|
||||
"""宿主 Event consumer 人工政策测试。"""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.architecture.baseline import collect_current_event_facts
|
||||
from scripts.architecture.event_facts import fingerprint_event_fact
|
||||
from scripts.architecture.event_policy import (
|
||||
DEFAULT_EVENT_POLICY_PATH,
|
||||
DYNAMIC_CLASSIFICATION,
|
||||
EVENT_CONSUMER_POLICY_SCOPE,
|
||||
STATIC_CLASSIFICATION,
|
||||
EventPolicyCheck,
|
||||
check_event_consumer_policy,
|
||||
parse_args,
|
||||
validate_event_consumer_policy,
|
||||
)
|
||||
|
||||
|
||||
def _fact(
|
||||
*,
|
||||
caller: str = "app.sample",
|
||||
events: list[str] | None = None,
|
||||
dynamic: bool = False,
|
||||
invalid: bool = False,
|
||||
handler: str = "Sample.handle",
|
||||
line: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""构造一条与统一 collector 相同字段的 consumer fact。"""
|
||||
fact: dict[str, Any] = {
|
||||
"caller": caller,
|
||||
"line": line,
|
||||
"qualname": "Sample",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_instance",
|
||||
"events": events if events is not None else ["EventType.Alpha"],
|
||||
"dynamic": dynamic,
|
||||
"invalid": invalid,
|
||||
"handler": handler,
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
}
|
||||
fact["fingerprint"] = fingerprint_event_fact(fact)
|
||||
return fact
|
||||
|
||||
|
||||
def _entry(
|
||||
fact: dict[str, Any],
|
||||
*,
|
||||
classification: str | None = None,
|
||||
owner: str | None = None,
|
||||
reason: str = "宿主显式注册并拥有该事件处理器。",
|
||||
) -> dict[str, Any]:
|
||||
"""把 collector fact 转换为完整 line-free policy entry。"""
|
||||
entry = {key: value for key, value in fact.items() if key != "line"}
|
||||
entry.update({
|
||||
"classification": classification
|
||||
or (
|
||||
DYNAMIC_CLASSIFICATION
|
||||
if fact["dynamic"] is True
|
||||
else STATIC_CLASSIFICATION
|
||||
),
|
||||
"owner": owner or str(fact["caller"]),
|
||||
"reason": reason,
|
||||
})
|
||||
return entry
|
||||
|
||||
|
||||
def _policy(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""构造 exact fingerprint set policy。"""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scope": dict(EVENT_CONSUMER_POLICY_SCOPE),
|
||||
"event_consumers": {
|
||||
"match_mode": "exact_fingerprint_set",
|
||||
"entries": entries,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _codes(result: EventPolicyCheck) -> list[str]:
|
||||
"""返回校验结果中的稳定错误码列表。"""
|
||||
return [violation.code for violation in result.violations]
|
||||
|
||||
|
||||
def test_event_policy_accepts_exact_static_and_dynamic_facts() -> None:
|
||||
"""静态注册和明确动态例外应按完整 fingerprint 精确匹配。"""
|
||||
static = _fact()
|
||||
dynamic = _fact(
|
||||
caller="app.workflow",
|
||||
events=[],
|
||||
dynamic=True,
|
||||
handler="self._handle_event",
|
||||
line=20,
|
||||
)
|
||||
|
||||
result = validate_event_consumer_policy(
|
||||
_policy([_entry(static), _entry(dynamic)]),
|
||||
[static, dynamic],
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.actual_count == result.reviewed_count == 2
|
||||
assert result.static_count == 1
|
||||
assert result.dynamic_count == 1
|
||||
assert result.invalid_count == 0
|
||||
|
||||
|
||||
def test_current_event_consumer_policy_matches_exact_reviewed_set() -> None:
|
||||
"""当前 17 条宿主 consumer 必须逐条匹配人工政策且没有非法成员。"""
|
||||
facts = collect_current_event_facts()["consumers"]
|
||||
policy = json.loads(DEFAULT_EVENT_POLICY_PATH.read_text(encoding="utf-8"))
|
||||
entries = policy["event_consumers"]["entries"]
|
||||
|
||||
result = check_event_consumer_policy(facts)
|
||||
|
||||
assert result.ok
|
||||
assert result.actual_count == result.reviewed_count == 17
|
||||
assert result.static_count == 16
|
||||
assert result.dynamic_count == 1
|
||||
assert result.invalid_count == 0
|
||||
assert len({fact["fingerprint"] for fact in facts}) == 17
|
||||
assert {entry["fingerprint"] for entry in entries} == {
|
||||
fact["fingerprint"] for fact in facts
|
||||
}
|
||||
assert sum(
|
||||
entry["classification"] == STATIC_CLASSIFICATION for entry in entries
|
||||
) == 16
|
||||
dynamic_entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry["classification"] == DYNAMIC_CLASSIFICATION
|
||||
]
|
||||
assert dynamic_entries == [{
|
||||
"caller": "app.workflow",
|
||||
"qualname": "WorkFlowManager.register_workflow_event",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
"events": [],
|
||||
"dynamic": True,
|
||||
"invalid": False,
|
||||
"handler": "self._handle_event",
|
||||
"registration_kind": "listener",
|
||||
"priority": "<default>",
|
||||
"fingerprint": (
|
||||
"042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188"
|
||||
),
|
||||
"classification": DYNAMIC_CLASSIFICATION,
|
||||
"owner": "app.workflow",
|
||||
"reason": "工作流配置在运行期决定事件类型,receiver 与 handler 仍可静态证明。",
|
||||
}]
|
||||
assert all("line" not in entry for entry in entries)
|
||||
|
||||
|
||||
def test_event_policy_reuses_line_free_event_fact_fingerprint() -> None:
|
||||
"""源码行移动不改变摘要,任一 line-free 语义字段变化都改变摘要。"""
|
||||
fact = _fact()
|
||||
moved = {**fact, "line": 999}
|
||||
assert fingerprint_event_fact(fact) == fingerprint_event_fact(moved)
|
||||
|
||||
for field, value in {
|
||||
"caller": "app.other",
|
||||
"qualname": "Other",
|
||||
"method": "add_event_listener",
|
||||
"receiver_kind": "constructed_instance",
|
||||
"events": ["EventType.Beta"],
|
||||
"dynamic": True,
|
||||
"invalid": True,
|
||||
"handler": "Other.handle",
|
||||
"registration_kind": "listener",
|
||||
"priority": "10",
|
||||
}.items():
|
||||
changed = {**fact, field: value}
|
||||
assert fingerprint_event_fact(changed) != fact["fingerprint"], field
|
||||
|
||||
|
||||
def test_event_policy_rejects_add_remove_and_same_count_replacement() -> None:
|
||||
"""新增、陈旧和同计数替换都不能通过刷新事实集合隐藏。"""
|
||||
original = _fact()
|
||||
added = _fact(handler="Sample.added", line=20)
|
||||
policy = _policy([_entry(original)])
|
||||
|
||||
assert _codes(validate_event_consumer_policy(policy, [original])) == []
|
||||
assert "unreviewed_fact" in _codes(
|
||||
validate_event_consumer_policy(policy, [original, added])
|
||||
)
|
||||
assert _codes(validate_event_consumer_policy(policy, [])) == ["stale_policy"]
|
||||
assert _codes(validate_event_consumer_policy(policy, [added])) == [
|
||||
"stale_policy",
|
||||
"unreviewed_fact",
|
||||
]
|
||||
|
||||
|
||||
def test_event_policy_rejects_classification_and_owner_swaps() -> None:
|
||||
"""事实仍相同时也不得互换静态/动态分类或责任 owner。"""
|
||||
fact = _fact()
|
||||
classification_swap = _entry(fact, classification=DYNAMIC_CLASSIFICATION)
|
||||
owner_swap = _entry(fact, owner="app.other")
|
||||
|
||||
assert "classification_mismatch" in _codes(
|
||||
validate_event_consumer_policy(_policy([classification_swap]), [fact])
|
||||
)
|
||||
assert "owner_mismatch" in _codes(
|
||||
validate_event_consumer_policy(_policy([owner_swap]), [fact])
|
||||
)
|
||||
|
||||
|
||||
def test_event_policy_rejects_invalid_schema_wildcards_and_reasons() -> None:
|
||||
"""人工 policy 必须保持精确 scope、字段和可审查理由。"""
|
||||
def schema_drift(policy: dict[str, Any]) -> None:
|
||||
policy["schema_version"] = 2
|
||||
|
||||
def scope_drift(policy: dict[str, Any]) -> None:
|
||||
policy["scope"]["root"] = "app.chain"
|
||||
|
||||
def wildcard_handler(policy: dict[str, Any]) -> None:
|
||||
policy["event_consumers"]["entries"][0]["handler"] = "Sample.*"
|
||||
|
||||
def empty_reason(policy: dict[str, Any]) -> None:
|
||||
policy["event_consumers"]["entries"][0]["reason"] = " "
|
||||
|
||||
def placeholder_reason(policy: dict[str, Any]) -> None:
|
||||
policy["event_consumers"]["entries"][0]["reason"] = "TODO"
|
||||
|
||||
mutations: tuple[tuple[Callable[[dict[str, Any]], None], str], ...] = (
|
||||
(schema_drift, "invalid_schema"),
|
||||
(scope_drift, "scope_mismatch"),
|
||||
(wildcard_handler, "wildcard_entry"),
|
||||
(empty_reason, "empty_reason"),
|
||||
(placeholder_reason, "empty_reason"),
|
||||
)
|
||||
fact = _fact()
|
||||
for mutate, expected_code in mutations:
|
||||
policy = _policy([_entry(fact)])
|
||||
mutate(policy)
|
||||
assert expected_code in _codes(
|
||||
validate_event_consumer_policy(policy, [fact])
|
||||
)
|
||||
|
||||
|
||||
def test_event_policy_rejects_duplicate_policy_and_fact_fingerprints() -> None:
|
||||
"""重复 identity 不能被 exact set 的字典或集合投影吞掉。"""
|
||||
fact = _fact()
|
||||
entry = _entry(fact)
|
||||
|
||||
policy_result = validate_event_consumer_policy(
|
||||
_policy([entry, deepcopy(entry)]),
|
||||
[fact],
|
||||
)
|
||||
fact_result = validate_event_consumer_policy(
|
||||
_policy([entry]),
|
||||
[fact, deepcopy(fact)],
|
||||
)
|
||||
|
||||
assert "duplicate_policy_fingerprint" in _codes(policy_result)
|
||||
assert "duplicate_fact_fingerprint" in _codes(fact_result)
|
||||
|
||||
|
||||
def test_event_policy_rejects_invalid_and_plugin_consumers() -> None:
|
||||
"""非法事件成员与 app.plugins 副本均不得写入人工准入。"""
|
||||
invalid = _fact(invalid=True)
|
||||
plugin = _fact(caller="app.plugins.demo")
|
||||
|
||||
invalid_result = validate_event_consumer_policy(
|
||||
_policy([_entry(invalid)]),
|
||||
[invalid],
|
||||
)
|
||||
plugin_result = validate_event_consumer_policy(
|
||||
_policy([_entry(plugin)]),
|
||||
[plugin],
|
||||
)
|
||||
|
||||
assert "invalid_consumer" in _codes(invalid_result)
|
||||
assert "plugin_scope_violation" in _codes(plugin_result)
|
||||
|
||||
|
||||
def test_event_policy_rejects_tampered_fingerprint() -> None:
|
||||
"""policy 不得保留旧摘要同时篡改 receiver、method 或其他事实字段。"""
|
||||
fact = _fact()
|
||||
entry = _entry(fact)
|
||||
entry["receiver_kind"] = "constructed_instance"
|
||||
|
||||
result = validate_event_consumer_policy(_policy([entry]), [fact])
|
||||
|
||||
assert "fingerprint_mismatch" in _codes(result)
|
||||
|
||||
|
||||
def test_event_policy_file_check_reports_invalid_json(tmp_path: Path) -> None:
|
||||
"""只读文件入口把缺失或损坏 policy 报告为结构化失败。"""
|
||||
path = tmp_path / "runtime-contract-policy.json"
|
||||
path.write_text("{", encoding="utf-8")
|
||||
|
||||
result = check_event_consumer_policy([_fact()], path)
|
||||
|
||||
assert not result.ok
|
||||
assert _codes(result) == ["invalid_policy_file"]
|
||||
|
||||
|
||||
def test_event_policy_cli_has_no_write_mode() -> None:
|
||||
"""人工 policy CLI 永远不暴露自动写入入口。"""
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args(["--write"])
|
||||
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
def test_event_policy_violation_order_is_deterministic() -> None:
|
||||
"""CI 输出必须按错误码和 fingerprint 稳定排序。"""
|
||||
first = _fact(handler="Sample.first")
|
||||
second = _fact(handler="Sample.second")
|
||||
|
||||
result = validate_event_consumer_policy(_policy([]), [second, first])
|
||||
|
||||
assert result.violations == tuple(
|
||||
sorted(
|
||||
result.violations,
|
||||
key=lambda item: (item.code, item.fingerprint or "", item.detail),
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user