mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor(architecture): unify event facts and policy
This commit is contained in:
+124
-128
@@ -9,7 +9,7 @@ import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -19,9 +19,9 @@ except ModuleNotFoundError:
|
||||
from egress import collect_direct_egress
|
||||
|
||||
try:
|
||||
from scripts.architecture.event_consumers import collect_event_consumers
|
||||
from scripts.architecture.event_facts import collect_event_facts
|
||||
except ModuleNotFoundError:
|
||||
from event_consumers import collect_event_consumers
|
||||
from event_facts import collect_event_facts
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_ROOT = PROJECT_ROOT / "app"
|
||||
@@ -701,17 +701,6 @@ def collect_run_module_diagnostics() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _event_reference(node: ast.AST) -> str | None:
|
||||
"""从 AST 节点解析 EventType/ChainEventType 的静态成员引用。"""
|
||||
if (
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id in {"EventType", "ChainEventType"}
|
||||
):
|
||||
return f"{node.value.id}.{node.attr}"
|
||||
return None
|
||||
|
||||
|
||||
def _event_enum_members(enum_name: str) -> tuple[str, ...]:
|
||||
"""从 schema 源码读取事件枚举成员,避免基线脚本导入宿主运行时。"""
|
||||
tree = parse_source(APP_ROOT / "schemas" / "types.py")
|
||||
@@ -738,120 +727,91 @@ def _event_enum_members(enum_name: str) -> tuple[str, ...]:
|
||||
)
|
||||
|
||||
|
||||
def _collect_event_locations() -> tuple[
|
||||
list[str],
|
||||
dict[str, list[dict[str, Any]]],
|
||||
dict[str, list[dict[str, Any]]],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
]:
|
||||
"""扫描事件枚举及其生产、消费位置,供语义和诊断视图复用。"""
|
||||
def collect_current_event_facts() -> dict[str, list[dict[str, Any]]]:
|
||||
"""收集排除插件副本的当前宿主 Event producer/consumer 事实。"""
|
||||
event_members = _event_enum_members("EventType")
|
||||
chain_event_members = _event_enum_members("ChainEventType")
|
||||
modules = discover_modules()
|
||||
|
||||
producers: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
dynamic_producers: list[dict[str, Any]] = []
|
||||
consumers, dynamic_consumers = collect_event_consumers(
|
||||
modules,
|
||||
return collect_event_facts(
|
||||
discover_modules(),
|
||||
{
|
||||
"EventType": event_members,
|
||||
"ChainEventType": chain_event_members,
|
||||
},
|
||||
)
|
||||
for module_name, path in modules.items():
|
||||
tree = parse_source(path)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(
|
||||
node.func,
|
||||
ast.Attribute,
|
||||
):
|
||||
continue
|
||||
location = {"caller": module_name, "line": node.lineno}
|
||||
if node.func.attr in {"send_event", "async_send_event"}:
|
||||
reference = _event_reference(node.args[0]) if node.args else None
|
||||
if reference:
|
||||
producers[reference].append(location)
|
||||
else:
|
||||
dynamic_producers.append(location)
|
||||
|
||||
enum_names = [
|
||||
*(f"EventType.{member}" for member in event_members),
|
||||
*(f"ChainEventType.{member}" for member in chain_event_members),
|
||||
]
|
||||
return (
|
||||
enum_names,
|
||||
producers,
|
||||
consumers,
|
||||
dynamic_producers,
|
||||
dynamic_consumers,
|
||||
|
||||
def _line_free_event_fact(fact: dict[str, Any]) -> dict[str, Any]:
|
||||
"""移除只用于诊断的源码行号,保留完整稳定事件身份。"""
|
||||
return {key: value for key, value in fact.items() if key != "line"}
|
||||
|
||||
|
||||
def collect_event_fact_contract() -> dict[str, Any]:
|
||||
"""生成逐调用事实与按 fingerprint 索引的宿主事件契约。"""
|
||||
event_names = sorted([
|
||||
*(
|
||||
f"EventType.{member}"
|
||||
for member in _event_enum_members("EventType")
|
||||
),
|
||||
*(
|
||||
f"ChainEventType.{member}"
|
||||
for member in _event_enum_members("ChainEventType")
|
||||
),
|
||||
])
|
||||
current = collect_current_event_facts()
|
||||
producers = sorted(
|
||||
(_line_free_event_fact(fact) for fact in current["producers"]),
|
||||
key=lambda fact: (fact["caller"], fact["qualname"], fact["fingerprint"]),
|
||||
)
|
||||
|
||||
|
||||
def collect_event_contracts() -> dict[str, Any]:
|
||||
"""收集不受源码行号变化影响的宿主事件语义契约。"""
|
||||
(
|
||||
enum_names,
|
||||
producers,
|
||||
consumers,
|
||||
dynamic_producers,
|
||||
dynamic_consumers,
|
||||
) = _collect_event_locations()
|
||||
contracts = {
|
||||
name: {
|
||||
"producers": _aggregate_locations(
|
||||
producers.get(name, []),
|
||||
("caller",),
|
||||
consumers = sorted(
|
||||
(_line_free_event_fact(fact) for fact in current["consumers"]),
|
||||
key=lambda fact: (fact["caller"], fact["qualname"], fact["fingerprint"]),
|
||||
)
|
||||
all_facts = (*producers, *consumers)
|
||||
event_index = {
|
||||
event_name: {
|
||||
"producer_fingerprints": sorted(
|
||||
fact["fingerprint"]
|
||||
for fact in producers
|
||||
if event_name in fact["events"]
|
||||
),
|
||||
"consumers": _aggregate_locations(
|
||||
consumers.get(name, []),
|
||||
("caller",),
|
||||
"consumer_fingerprints": sorted(
|
||||
fact["fingerprint"]
|
||||
for fact in consumers
|
||||
if event_name in fact["events"]
|
||||
),
|
||||
}
|
||||
for name in sorted(enum_names)
|
||||
for event_name in event_names
|
||||
}
|
||||
return {
|
||||
"event_count": len(contracts),
|
||||
"producer_count": sum(len(items) for items in producers.values()),
|
||||
"consumer_count": sum(len(items) for items in consumers.values()),
|
||||
"events": contracts,
|
||||
"dynamic_producers": _aggregate_locations(dynamic_producers, ("caller",)),
|
||||
"dynamic_consumers": _aggregate_locations(dynamic_consumers, ("caller",)),
|
||||
"event_count": len(event_names),
|
||||
"producer_call_count": len(producers),
|
||||
"static_producer_call_count": sum(
|
||||
not fact["dynamic"] and not fact["invalid"] for fact in producers
|
||||
),
|
||||
"dynamic_producer_count": sum(fact["dynamic"] for fact in producers),
|
||||
"invalid_producer_count": sum(fact["invalid"] for fact in producers),
|
||||
"producer_event_reference_count": sum(
|
||||
len(fact["events"]) for fact in producers
|
||||
),
|
||||
"consumer_registration_count": len(consumers),
|
||||
"static_consumer_count": sum(
|
||||
not fact["dynamic"] and not fact["invalid"] for fact in consumers
|
||||
),
|
||||
"dynamic_consumer_count": sum(fact["dynamic"] for fact in consumers),
|
||||
"invalid_consumer_count": sum(fact["invalid"] for fact in consumers),
|
||||
"consumer_event_reference_count": sum(
|
||||
len(fact["events"]) for fact in consumers
|
||||
),
|
||||
"fact_count": len(all_facts),
|
||||
"producers": producers,
|
||||
"consumers": consumers,
|
||||
"event_index": event_index,
|
||||
}
|
||||
|
||||
|
||||
def collect_event_diagnostics() -> dict[str, Any]:
|
||||
"""收集事件生产与消费的当前源码位置,仅用于人工诊断。"""
|
||||
(
|
||||
enum_names,
|
||||
producers,
|
||||
consumers,
|
||||
dynamic_producers,
|
||||
dynamic_consumers,
|
||||
) = _collect_event_locations()
|
||||
return {
|
||||
"events": {
|
||||
name: {
|
||||
"producers": sorted(
|
||||
producers.get(name, []),
|
||||
key=lambda item: (item["caller"], item["line"]),
|
||||
),
|
||||
"consumers": sorted(
|
||||
consumers.get(name, []),
|
||||
key=lambda item: (item["caller"], item["line"]),
|
||||
),
|
||||
}
|
||||
for name in sorted(enum_names)
|
||||
},
|
||||
"dynamic_producers": sorted(
|
||||
dynamic_producers,
|
||||
key=lambda item: (item["caller"], item["line"]),
|
||||
),
|
||||
"dynamic_consumers": sorted(
|
||||
dynamic_consumers,
|
||||
key=lambda item: (item["caller"], item["line"]),
|
||||
),
|
||||
}
|
||||
def collect_event_fact_diagnostics() -> dict[str, list[dict[str, Any]]]:
|
||||
"""返回带源码行号的逐调用事件事实,仅用于人工诊断。"""
|
||||
return collect_current_event_facts()
|
||||
|
||||
|
||||
def _sdk_all_names(tree: ast.Module, path: Path) -> tuple[str, ...]:
|
||||
@@ -1021,10 +981,15 @@ def collect_compat_manifest() -> dict[str, Any]:
|
||||
def collect_runtime_baseline() -> dict[str, Any]:
|
||||
"""生成模块调度、SDK 和兼容层公开契约基线。"""
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"scope": {
|
||||
"repository": "MoviePilot",
|
||||
"roots": ["app"],
|
||||
"excluded": ["app/plugins"],
|
||||
},
|
||||
"run_module": collect_run_module_contracts(),
|
||||
"module_method_specs": collect_module_method_specs(),
|
||||
"events": collect_event_contracts(),
|
||||
"event_facts": collect_event_fact_contract(),
|
||||
"event_specs": collect_event_specs(),
|
||||
"sdk_exports": collect_sdk_exports(),
|
||||
"compat_manifest": collect_compat_manifest(),
|
||||
@@ -1087,7 +1052,7 @@ def collect_runtime_diagnostics() -> dict[str, Any]:
|
||||
"""生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。"""
|
||||
return {
|
||||
"run_module": collect_run_module_diagnostics(),
|
||||
"events": collect_event_diagnostics(),
|
||||
"event_facts": collect_event_fact_diagnostics(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1295,10 +1260,8 @@ def _display_path(path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _migrate_runtime_baseline(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把包含源码行号的 v1 运行契约转换为稳定语义结构。"""
|
||||
if value.get("schema_version") != 1:
|
||||
return value
|
||||
def _migrate_runtime_v1_to_v2(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把包含源码行号的 v1 运行契约转换为 v2 聚合语义。"""
|
||||
run_module = value["run_module"]
|
||||
events = value["events"]
|
||||
return {
|
||||
@@ -1349,6 +1312,37 @@ def _migrate_runtime_baseline(value: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _migrate_runtime_v2_to_v3(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把 v2 聚合事件投影为显式待刷新的 v3 兼容视图。"""
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"scope": {
|
||||
"repository": "MoviePilot",
|
||||
"roots": ["app"],
|
||||
"excluded": ["app/plugins"],
|
||||
},
|
||||
"run_module": value["run_module"],
|
||||
"module_method_specs": value.get("module_method_specs", {}),
|
||||
"event_facts": {
|
||||
"migration_required": True,
|
||||
"legacy_v2_projection": value["events"],
|
||||
},
|
||||
"event_specs": value.get("event_specs", {}),
|
||||
"sdk_exports": value["sdk_exports"],
|
||||
"compat_manifest": value["compat_manifest"],
|
||||
}
|
||||
|
||||
|
||||
def _migrate_runtime_baseline(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""链式迁移旧运行契约,保证检查只报告语义变化而不崩溃。"""
|
||||
migrated = value
|
||||
if migrated.get("schema_version") == 1:
|
||||
migrated = _migrate_runtime_v1_to_v2(migrated)
|
||||
if migrated.get("schema_version") == 2:
|
||||
migrated = _migrate_runtime_v2_to_v3(migrated)
|
||||
return migrated
|
||||
|
||||
|
||||
def _migrate_plugin_baseline(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把来源信息混排的 v2 插件基线转换为 scope/provenance 结构。"""
|
||||
if value.get("schema_version") != 2:
|
||||
@@ -1484,18 +1478,20 @@ def _compare_semantic_values(
|
||||
)
|
||||
return
|
||||
if isinstance(expected, list) and isinstance(actual, list):
|
||||
expected_items = {
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True): item
|
||||
expected_keys = [
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in expected
|
||||
}
|
||||
actual_items = {
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True): item
|
||||
]
|
||||
actual_keys = [
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in actual
|
||||
}
|
||||
for key in sorted(expected_items.keys() - actual_items.keys()):
|
||||
report["removed"].append({"path": path, "value": expected_items[key]})
|
||||
for key in sorted(actual_items.keys() - expected_items.keys()):
|
||||
report["added"].append({"path": path, "value": actual_items[key]})
|
||||
]
|
||||
expected_counts = Counter(expected_keys)
|
||||
actual_counts = Counter(actual_keys)
|
||||
for key in sorted((expected_counts - actual_counts).elements()):
|
||||
report["removed"].append({"path": path, "value": json.loads(key)})
|
||||
for key in sorted((actual_counts - expected_counts).elements()):
|
||||
report["added"].append({"path": path, "value": json.loads(key)})
|
||||
return
|
||||
if expected != actual:
|
||||
report["changed"].append(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""静态收集可证明的宿主 EventManager consumer。"""
|
||||
"""静态收集可证明的宿主 EventManager producer 与 consumer 事实。"""
|
||||
|
||||
import ast
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeAlias
|
||||
@@ -9,6 +11,7 @@ from typing import Any, Literal, TypeAlias
|
||||
_DEFAULT_IDENTITY = "<default>"
|
||||
_DYNAMIC_IDENTITY = "<dynamic>"
|
||||
_EVENT_MANAGER_METHODS = {"add_event_listener", "register"}
|
||||
_PRODUCER_METHODS = {"async_send_event", "send_event"}
|
||||
_COMPREHENSION_SCOPES = (
|
||||
ast.ListComp,
|
||||
ast.SetComp,
|
||||
@@ -33,6 +36,8 @@ class _Symbol:
|
||||
"manager_class",
|
||||
"manager_factory",
|
||||
"manager_instance",
|
||||
"publisher_instance",
|
||||
"injected_owner",
|
||||
"type_checking",
|
||||
]
|
||||
value: str = ""
|
||||
@@ -45,6 +50,20 @@ class _EventSelection:
|
||||
events: tuple[str, ...]
|
||||
kind: Literal["member", "enum", "list"]
|
||||
dynamic: bool = False
|
||||
invalid: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BoundEventMethod:
|
||||
"""记录已证明 receiver 的 EventManager 或 EventPublisher 绑定方法。"""
|
||||
|
||||
method: Literal[
|
||||
"add_event_listener",
|
||||
"register",
|
||||
"send_event",
|
||||
"async_send_event",
|
||||
]
|
||||
receiver_kind: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -53,6 +72,7 @@ class _DecoratorFactory:
|
||||
|
||||
selection: _EventSelection
|
||||
priority: str
|
||||
receiver_kind: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -63,9 +83,12 @@ class _Registration:
|
||||
selection: _EventSelection
|
||||
handler: str
|
||||
priority: str
|
||||
receiver_kind: str
|
||||
|
||||
|
||||
_ScopeValue: TypeAlias = _Symbol | _EventSelection | _DecoratorFactory | None
|
||||
_ScopeValue: TypeAlias = (
|
||||
_Symbol | _EventSelection | _DecoratorFactory | _BoundEventMethod | None
|
||||
)
|
||||
|
||||
|
||||
def _expression_name(node: ast.AST | None) -> str:
|
||||
@@ -98,15 +121,47 @@ def _priority_identity(node: ast.AST | None) -> str:
|
||||
return _DYNAMIC_IDENTITY
|
||||
|
||||
|
||||
def _location_sort_key(item: dict[str, Any]) -> tuple[str, int, str]:
|
||||
"""返回 consumer location 的稳定排序键。"""
|
||||
def fingerprint_event_fact(fact: Mapping[str, object]) -> str:
|
||||
"""计算排除诊断行号与已有摘要后的字段敏感 SHA256。"""
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in fact.items()
|
||||
if key not in {"fingerprint", "line"}
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _fact_sort_key(item: dict[str, Any]) -> tuple[str, str, int, str]:
|
||||
"""返回逐调用 Event fact 的确定排序键。"""
|
||||
return (
|
||||
str(item["caller"]),
|
||||
str(item["qualname"]),
|
||||
int(item["line"]),
|
||||
str(item["identity"]),
|
||||
str(item["fingerprint"]),
|
||||
)
|
||||
|
||||
|
||||
def _annotation_names(node: ast.AST | None) -> set[str]:
|
||||
"""提取类型注解中的有限点分名称。"""
|
||||
if node is None:
|
||||
return set()
|
||||
if isinstance(node, ast.Name):
|
||||
return {node.id}
|
||||
if isinstance(node, ast.Attribute):
|
||||
return {_expression_name(node)}
|
||||
return {
|
||||
name
|
||||
for child in ast.iter_child_nodes(node)
|
||||
for name in _annotation_names(child)
|
||||
}
|
||||
|
||||
|
||||
def _bound_names(target: ast.AST) -> set[str]:
|
||||
"""返回赋值目标在当前 lexical scope 绑定的名称。"""
|
||||
if isinstance(target, ast.Name):
|
||||
@@ -236,8 +291,221 @@ def _function_local_names(
|
||||
return local_names - global_names - nonlocal_names
|
||||
|
||||
|
||||
class _EventConsumerCollector(ast.NodeVisitor):
|
||||
"""以有限 lexical provenance 收集单个宿主模块的事件消费者。"""
|
||||
def _event_port_symbol(
|
||||
annotation: ast.AST | None,
|
||||
canonical_aliases: Mapping[str, str] | None = None,
|
||||
) -> _Symbol | None:
|
||||
"""把明确的 EventPublisher/EventManager 注解转换为 receiver provenance。"""
|
||||
annotation_names = _annotation_names(annotation)
|
||||
leaf_names = {name.rsplit(".", 1)[-1] for name in annotation_names}
|
||||
if any(name.endswith("EventPublisher") for name in leaf_names):
|
||||
return _Symbol("publisher_instance", "injected_event_publisher")
|
||||
if any(name.endswith("EventManagerPort") for name in leaf_names):
|
||||
return _Symbol("manager_instance", "injected_event_manager")
|
||||
for name in annotation_names:
|
||||
head, *tail = name.split(".")
|
||||
canonical = ".".join(
|
||||
((canonical_aliases or {}).get(head, head), *tail)
|
||||
)
|
||||
if canonical == "app.runtime.events.EventManager":
|
||||
return _Symbol("manager_instance", "injected_event_manager")
|
||||
return None
|
||||
|
||||
|
||||
def _function_parameter_symbols(
|
||||
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda,
|
||||
canonical_aliases: Mapping[str, str] | None = None,
|
||||
) -> dict[str, _Symbol]:
|
||||
"""收集函数参数上明确声明的 Event 发布端口。"""
|
||||
arguments = (
|
||||
*node.args.posonlyargs,
|
||||
*node.args.args,
|
||||
*node.args.kwonlyargs,
|
||||
)
|
||||
return {
|
||||
argument.arg: symbol
|
||||
for argument in arguments
|
||||
if (
|
||||
symbol := _event_port_symbol(
|
||||
argument.annotation,
|
||||
canonical_aliases,
|
||||
)
|
||||
) is not None
|
||||
}
|
||||
|
||||
|
||||
_InjectedFieldKey: TypeAlias = tuple[str, str]
|
||||
|
||||
|
||||
def _module_import_aliases(tree: ast.Module) -> dict[str, str]:
|
||||
"""收集解析类继承关系所需的模块级 import 别名。"""
|
||||
aliases: dict[str, str] = {}
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
for item in node.names:
|
||||
bound = item.asname or item.name.split(".", 1)[0]
|
||||
aliases[bound] = item.name if item.asname else bound
|
||||
elif isinstance(node, ast.ImportFrom) and not node.level and node.module:
|
||||
for item in node.names:
|
||||
if item.name != "*":
|
||||
aliases[item.asname or item.name] = f"{node.module}.{item.name}"
|
||||
return aliases
|
||||
|
||||
|
||||
def _iter_classes(
|
||||
statements: list[ast.stmt],
|
||||
prefix: tuple[str, ...] = (),
|
||||
) -> list[tuple[str, ast.ClassDef]]:
|
||||
"""按 lexical qualname 返回语句中的类定义,不进入函数 scope。"""
|
||||
classes: list[tuple[str, ast.ClassDef]] = []
|
||||
for statement in statements:
|
||||
if not isinstance(statement, ast.ClassDef):
|
||||
continue
|
||||
qualname = ".".join((*prefix, statement.name))
|
||||
classes.append((qualname, statement))
|
||||
classes.extend(_iter_classes(statement.body, (*prefix, statement.name)))
|
||||
return classes
|
||||
|
||||
|
||||
def _canonical_class_name(
|
||||
node: ast.expr,
|
||||
*,
|
||||
module_name: str,
|
||||
aliases: dict[str, str],
|
||||
) -> str:
|
||||
"""把有限 Name/Attribute 基类表达式还原成 canonical 类名。"""
|
||||
name = _expression_name(node)
|
||||
if not name:
|
||||
return ""
|
||||
head, *tail = name.split(".")
|
||||
if head in aliases:
|
||||
return ".".join((aliases[head], *tail))
|
||||
return f"{module_name}.{name}"
|
||||
|
||||
|
||||
def _discover_injected_event_fields(
|
||||
trees: dict[str, ast.Module],
|
||||
) -> dict[_InjectedFieldKey, dict[str, _Symbol]]:
|
||||
"""按 owning class 发现构造注入字段,并沿已知继承关系传播。"""
|
||||
classes = {
|
||||
f"{module_name}.{qualname}": (module_name, qualname, node)
|
||||
for module_name, tree in trees.items()
|
||||
for qualname, node in _iter_classes(tree.body)
|
||||
}
|
||||
aliases = {
|
||||
module_name: _module_import_aliases(tree)
|
||||
for module_name, tree in trees.items()
|
||||
}
|
||||
fields: dict[str, dict[str, _Symbol]] = {
|
||||
canonical: {} for canonical in classes
|
||||
}
|
||||
bases_by_class: dict[str, set[str]] = {
|
||||
canonical: set() for canonical in classes
|
||||
}
|
||||
descendants_by_class: dict[str, set[str]] = {
|
||||
canonical: set() for canonical in classes
|
||||
}
|
||||
for canonical, (module_name, _qualname, class_node) in classes.items():
|
||||
for base in class_node.bases:
|
||||
base_name = _canonical_class_name(
|
||||
base,
|
||||
module_name=module_name,
|
||||
aliases=aliases[module_name],
|
||||
)
|
||||
if base_name in classes:
|
||||
bases_by_class[canonical].add(base_name)
|
||||
descendants_by_class[base_name].add(canonical)
|
||||
|
||||
for canonical, (module_name, qualname, class_node) in classes.items():
|
||||
class_fields = fields[canonical]
|
||||
for constructor in (
|
||||
node
|
||||
for node in class_node.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "__init__"
|
||||
):
|
||||
parameters = _function_parameter_symbols(
|
||||
constructor,
|
||||
aliases[module_name],
|
||||
)
|
||||
for candidate in constructor.body:
|
||||
candidates = ast.walk(candidate) if not isinstance(
|
||||
candidate,
|
||||
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
|
||||
) else ()
|
||||
for assignment in candidates:
|
||||
if isinstance(assignment, ast.Assign):
|
||||
pairs = (
|
||||
(target, assignment.value)
|
||||
for target in assignment.targets
|
||||
)
|
||||
elif isinstance(assignment, ast.AnnAssign) and assignment.value:
|
||||
pairs = ((assignment.target, assignment.value),)
|
||||
else:
|
||||
continue
|
||||
for target, value in pairs:
|
||||
if not (
|
||||
isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "self"
|
||||
):
|
||||
continue
|
||||
symbol = (
|
||||
parameters.get(value.id)
|
||||
if isinstance(value, ast.Name)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
symbol is None
|
||||
and module_name == "app.chain"
|
||||
and qualname == "ChainBase"
|
||||
and target.attr == "eventmanager"
|
||||
and isinstance(value, ast.Attribute)
|
||||
and value.attr == "event_manager"
|
||||
):
|
||||
symbol = _Symbol(
|
||||
"manager_instance",
|
||||
"injected_event_manager",
|
||||
)
|
||||
if symbol is not None:
|
||||
class_fields[target.attr] = symbol
|
||||
|
||||
direct_fields = {
|
||||
canonical: dict(class_fields)
|
||||
for canonical, class_fields in fields.items()
|
||||
if class_fields
|
||||
}
|
||||
for owner, owner_fields in direct_fields.items():
|
||||
def reachable(
|
||||
starts: set[str],
|
||||
relations: dict[str, set[str]],
|
||||
) -> set[str]:
|
||||
"""返回给定继承方向上的传递闭包。"""
|
||||
pending = list(starts)
|
||||
reached: set[str] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in reached:
|
||||
continue
|
||||
reached.add(current)
|
||||
pending.extend(relations[current])
|
||||
return reached
|
||||
|
||||
descendants = reachable({owner}, descendants_by_class)
|
||||
targets = set(descendants)
|
||||
targets.update(reachable(descendants, bases_by_class))
|
||||
for target in targets:
|
||||
fields[target].update(owner_fields)
|
||||
|
||||
return {
|
||||
(module_name, qualname): fields[canonical]
|
||||
for canonical, (module_name, qualname, _node) in classes.items()
|
||||
if fields[canonical]
|
||||
}
|
||||
|
||||
|
||||
class _EventFactCollector(ast.NodeVisitor):
|
||||
"""以有限 lexical provenance 收集单个宿主模块的 Event 事实。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -246,18 +514,21 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
*,
|
||||
collect_facts: bool,
|
||||
module_final_scope: dict[str, _ScopeValue] | None = None,
|
||||
injected_fields: dict[_InjectedFieldKey, dict[str, _Symbol]] | None = None,
|
||||
) -> None:
|
||||
"""初始化模块、事件枚举、收集模式和最终模块绑定。"""
|
||||
"""初始化模块、事件枚举、收集模式及已证明的注入字段。"""
|
||||
self._module_name = module_name
|
||||
self._event_members = event_members
|
||||
self._collect_facts = collect_facts
|
||||
self._module_final_scope = module_final_scope or {}
|
||||
self._injected_fields = injected_fields or {}
|
||||
self._scopes: list[dict[str, _ScopeValue]] = [{}]
|
||||
self._scope_kinds = ["module"]
|
||||
self._function_final_scopes: list[dict[str, _ScopeValue]] = []
|
||||
self._qualnames: list[str] = []
|
||||
self.static: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
self.dynamic: list[dict[str, Any]] = []
|
||||
self._class_qualnames: list[str] = []
|
||||
self.producers: list[dict[str, Any]] = []
|
||||
self.consumers: list[dict[str, Any]] = []
|
||||
|
||||
def module_scope(self) -> dict[str, _ScopeValue]:
|
||||
"""返回按模块执行顺序收敛后的符号状态。"""
|
||||
@@ -304,22 +575,70 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
scope_kinds: list[str],
|
||||
) -> dict[str, _ScopeValue]:
|
||||
"""无事实副作用地计算一组语句执行后的最内层 scope。"""
|
||||
discovery = _EventConsumerCollector(
|
||||
discovery = _EventFactCollector(
|
||||
self._module_name,
|
||||
self._event_members,
|
||||
collect_facts=False,
|
||||
module_final_scope=self._module_final_scope,
|
||||
injected_fields=self._injected_fields,
|
||||
)
|
||||
discovery._scopes = [dict(scope) for scope in scopes]
|
||||
discovery._scope_kinds = list(scope_kinds)
|
||||
discovery._qualnames = list(self._qualnames)
|
||||
discovery._class_qualnames = list(self._class_qualnames)
|
||||
for statement in statements:
|
||||
discovery.visit(statement)
|
||||
return dict(discovery._scopes[-1])
|
||||
|
||||
def _function_initial_scope(
|
||||
self,
|
||||
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda,
|
||||
) -> dict[str, _ScopeValue]:
|
||||
"""构造函数调用期的局部符号,并保留明确的参数端口 provenance。"""
|
||||
scope: dict[str, _ScopeValue] = {
|
||||
name: None for name in _function_local_names(node)
|
||||
}
|
||||
canonical_aliases = {
|
||||
name: "app.runtime.events.EventManager"
|
||||
for name, value in self._module_final_scope.items()
|
||||
if isinstance(value, _Symbol) and value.kind == "manager_class"
|
||||
}
|
||||
if (
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "__init__"
|
||||
and self._class_qualnames
|
||||
):
|
||||
scope.update(_function_parameter_symbols(node, canonical_aliases))
|
||||
positional = (*node.args.posonlyargs, *node.args.args)
|
||||
if (
|
||||
self._module_name == "app.runtime.events"
|
||||
and self._qualnames == ["EventManager"]
|
||||
and positional
|
||||
and positional[0].arg == "self"
|
||||
):
|
||||
scope["self"] = _Symbol("publisher_instance", "event_manager_self")
|
||||
elif (
|
||||
self._class_qualnames
|
||||
and positional
|
||||
and not any(
|
||||
_expression_name(decorator) in {"classmethod", "staticmethod"}
|
||||
for decorator in getattr(node, "decorator_list", ())
|
||||
)
|
||||
and (
|
||||
self._module_name,
|
||||
self._class_qualnames[-1],
|
||||
) in self._injected_fields
|
||||
):
|
||||
scope[positional[0].arg] = _Symbol(
|
||||
"injected_owner",
|
||||
self._class_qualnames[-1],
|
||||
)
|
||||
return scope
|
||||
|
||||
def _symbol_for_canonical(self, canonical: str) -> _ScopeValue:
|
||||
"""把有限 canonical 路径转换为 collector symbol。"""
|
||||
if canonical == "app.runtime.events.eventmanager":
|
||||
return _Symbol("manager_instance")
|
||||
return _Symbol("manager_instance", "canonical_singleton")
|
||||
if canonical == "app.runtime.events.EventManager":
|
||||
return _Symbol("manager_class")
|
||||
if canonical == "typing.TYPE_CHECKING":
|
||||
@@ -356,11 +675,29 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
return self._lookup(node.id)
|
||||
if isinstance(node, ast.Attribute):
|
||||
parent = self._resolve(node.value)
|
||||
if isinstance(parent, _Symbol) and parent.kind == "injected_owner":
|
||||
fields = self._injected_fields.get(
|
||||
(self._module_name, parent.value),
|
||||
{},
|
||||
)
|
||||
if node.attr in fields:
|
||||
return fields[node.attr]
|
||||
if isinstance(parent, _Symbol) and parent.kind == "module":
|
||||
return self._symbol_for_canonical(f"{parent.value}.{node.attr}")
|
||||
if isinstance(parent, _Symbol) and parent.kind == "manager_class":
|
||||
if node.attr == "get_existing_instance":
|
||||
return _Symbol("manager_factory")
|
||||
if isinstance(parent, _Symbol) and parent.kind in {
|
||||
"manager_instance",
|
||||
"publisher_instance",
|
||||
}:
|
||||
methods = (
|
||||
_PRODUCER_METHODS | _EVENT_MANAGER_METHODS
|
||||
if parent.kind == "manager_instance"
|
||||
else _PRODUCER_METHODS
|
||||
)
|
||||
if node.attr in methods:
|
||||
return _BoundEventMethod(node.attr, parent.value)
|
||||
if isinstance(parent, _EventSelection) and parent.kind == "enum":
|
||||
enum_name = parent.events[0].split(".", 1)[0] if parent.events else ""
|
||||
if node.attr in self._event_members.get(enum_name, ()):
|
||||
@@ -368,6 +705,7 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
events=(f"{enum_name}.{node.attr}",),
|
||||
kind="member",
|
||||
)
|
||||
return _EventSelection((), "member", invalid=True)
|
||||
return None
|
||||
if isinstance(node, ast.List):
|
||||
return self._resolve_event_selection(
|
||||
@@ -375,18 +713,30 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
allow_enum=True,
|
||||
allow_list=True,
|
||||
)
|
||||
if isinstance(node, ast.IfExp):
|
||||
return self._resolve_event_selection(
|
||||
node,
|
||||
allow_enum=False,
|
||||
allow_list=False,
|
||||
)
|
||||
if isinstance(node, ast.Call):
|
||||
target = self._resolve(node.func)
|
||||
if isinstance(target, _Symbol) and target.kind in {
|
||||
"manager_class",
|
||||
"manager_factory",
|
||||
}:
|
||||
return _Symbol("manager_instance")
|
||||
receiver_kind = (
|
||||
"constructed_manager"
|
||||
if target.kind == "manager_class"
|
||||
else "existing_manager"
|
||||
)
|
||||
return _Symbol("manager_instance", receiver_kind)
|
||||
registration = self._registration(node)
|
||||
if registration and registration.method == "register":
|
||||
return _DecoratorFactory(
|
||||
selection=registration.selection,
|
||||
priority=registration.priority,
|
||||
receiver_kind=registration.receiver_kind,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -413,6 +763,7 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
events=tuple(sorted({event for item in selections for event in item.events})),
|
||||
kind="list",
|
||||
dynamic=any(item.dynamic for item in selections),
|
||||
invalid=any(item.invalid for item in selections),
|
||||
)
|
||||
if isinstance(node, ast.IfExp):
|
||||
branches = (
|
||||
@@ -431,6 +782,7 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
events=tuple(sorted({event for item in branches for event in item.events})),
|
||||
kind="list" if allow_list else "member",
|
||||
dynamic=any(item.dynamic for item in branches),
|
||||
invalid=any(item.invalid for item in branches),
|
||||
)
|
||||
resolved = self._resolve(node)
|
||||
if isinstance(resolved, _EventSelection):
|
||||
@@ -468,14 +820,13 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
|
||||
def _registration(self, node: ast.Call) -> _Registration | None:
|
||||
"""仅解析 receiver 已证明为 canonical EventManager 实例的注册。"""
|
||||
if not isinstance(node.func, ast.Attribute):
|
||||
return None
|
||||
method = node.func.attr
|
||||
if method not in _EVENT_MANAGER_METHODS:
|
||||
return None
|
||||
receiver = self._resolve(node.func.value)
|
||||
if not isinstance(receiver, _Symbol) or receiver.kind != "manager_instance":
|
||||
bound_method = self._resolve(node.func)
|
||||
if not (
|
||||
isinstance(bound_method, _BoundEventMethod)
|
||||
and bound_method.method in _EVENT_MANAGER_METHODS
|
||||
):
|
||||
return None
|
||||
method = bound_method.method
|
||||
if method == "register":
|
||||
arguments = self._bind_call_arguments(
|
||||
node,
|
||||
@@ -504,6 +855,34 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
selection=selection,
|
||||
handler=_handler_identity(handler_node),
|
||||
priority=_priority_identity(arguments.get("priority")),
|
||||
receiver_kind=bound_method.receiver_kind,
|
||||
)
|
||||
|
||||
def _producer_call(
|
||||
self,
|
||||
node: ast.Call,
|
||||
) -> tuple[_BoundEventMethod, _EventSelection] | None:
|
||||
"""解析 receiver 与调用签名均可证明的 Event producer。"""
|
||||
bound_method = self._resolve(node.func)
|
||||
if not (
|
||||
isinstance(bound_method, _BoundEventMethod)
|
||||
and bound_method.method in _PRODUCER_METHODS
|
||||
):
|
||||
return None
|
||||
arguments = self._bind_call_arguments(
|
||||
node,
|
||||
("etype", "data", "priority"),
|
||||
frozenset({"etype"}),
|
||||
)
|
||||
if arguments is None:
|
||||
return None
|
||||
return (
|
||||
bound_method,
|
||||
self._resolve_event_selection(
|
||||
arguments["etype"],
|
||||
allow_enum=False,
|
||||
allow_list=False,
|
||||
),
|
||||
)
|
||||
|
||||
def _decorator_factory_application(
|
||||
@@ -523,39 +902,84 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
return None
|
||||
return factory, arguments["f"]
|
||||
|
||||
def _record(
|
||||
def _base_fact(
|
||||
self,
|
||||
node: ast.AST,
|
||||
selection: _EventSelection,
|
||||
*,
|
||||
method: str,
|
||||
receiver_kind: str,
|
||||
) -> dict[str, Any]:
|
||||
"""构造带 line-free fingerprint 的逐调用基础事实。"""
|
||||
fact = {
|
||||
"caller": self._module_name,
|
||||
"line": node.lineno,
|
||||
"qualname": ".".join(self._qualnames) or "<module>",
|
||||
"method": method,
|
||||
"receiver_kind": receiver_kind,
|
||||
"events": list(selection.events),
|
||||
"dynamic": selection.dynamic,
|
||||
"invalid": selection.invalid,
|
||||
}
|
||||
fact["fingerprint"] = fingerprint_event_fact(fact)
|
||||
return fact
|
||||
|
||||
def _record_consumer(
|
||||
self,
|
||||
node: ast.AST,
|
||||
selection: _EventSelection,
|
||||
*,
|
||||
method: str,
|
||||
receiver_kind: str,
|
||||
handler: str,
|
||||
priority: str,
|
||||
registration_kind: Literal["decorator", "listener"],
|
||||
) -> None:
|
||||
"""写入静态事件位置,并为未知余项写入一条 dynamic 位置。"""
|
||||
"""写入一条 handler、kind 和 priority 完整的 consumer 事实。"""
|
||||
if not self._collect_facts:
|
||||
return
|
||||
location = {
|
||||
"caller": self._module_name,
|
||||
"line": node.lineno,
|
||||
fact = self._base_fact(
|
||||
node,
|
||||
selection,
|
||||
method=method,
|
||||
receiver_kind=receiver_kind,
|
||||
)
|
||||
fact.update({
|
||||
"handler": handler,
|
||||
"priority": priority,
|
||||
"registration_kind": registration_kind,
|
||||
"identity": f"{registration_kind}|{handler}|{priority}",
|
||||
}
|
||||
for event in selection.events:
|
||||
self.static[event].append(dict(location))
|
||||
if selection.dynamic:
|
||||
self.dynamic.append(dict(location))
|
||||
})
|
||||
fact["fingerprint"] = fingerprint_event_fact(fact)
|
||||
self.consumers.append(fact)
|
||||
|
||||
def _record_producer(
|
||||
self,
|
||||
node: ast.Call,
|
||||
method: _BoundEventMethod,
|
||||
selection: _EventSelection,
|
||||
) -> None:
|
||||
"""写入一条 receiver 已证明的 producer 事实。"""
|
||||
if not self._collect_facts:
|
||||
return
|
||||
self.producers.append(
|
||||
self._base_fact(
|
||||
node,
|
||||
selection,
|
||||
method=method.method,
|
||||
receiver_kind=method.receiver_kind,
|
||||
)
|
||||
)
|
||||
|
||||
def _record_decorator(self, decorator: ast.expr, handler: str) -> bool:
|
||||
"""记录直接或简单赋值别名形式的 register decorator。"""
|
||||
factory = self._resolve(decorator)
|
||||
if not isinstance(factory, _DecoratorFactory):
|
||||
return False
|
||||
self._record(
|
||||
self._record_consumer(
|
||||
decorator,
|
||||
factory.selection,
|
||||
method="register",
|
||||
receiver_kind=factory.receiver_kind,
|
||||
handler=handler,
|
||||
priority=factory.priority,
|
||||
registration_kind="decorator",
|
||||
@@ -837,7 +1261,7 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
body_scopes = [
|
||||
dict(self._module_final_scope),
|
||||
*(dict(scope) for scope in self._function_final_scopes),
|
||||
{name: None for name in _function_local_names(node)},
|
||||
self._function_initial_scope(node),
|
||||
]
|
||||
body_kinds = [
|
||||
"module",
|
||||
@@ -867,12 +1291,14 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
for expression in (*node.bases, *(keyword.value for keyword in node.keywords)):
|
||||
self.visit(expression)
|
||||
self._qualnames.append(node.name)
|
||||
self._class_qualnames.append(".".join(self._qualnames))
|
||||
self._scopes.append({})
|
||||
self._scope_kinds.append("class")
|
||||
for statement in node.body:
|
||||
self.visit(statement)
|
||||
self._scope_kinds.pop()
|
||||
self._scopes.pop()
|
||||
self._class_qualnames.pop()
|
||||
self._qualnames.pop()
|
||||
self._set(node.name, None)
|
||||
|
||||
@@ -890,7 +1316,7 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
body_scopes = [
|
||||
dict(self._module_final_scope),
|
||||
*(dict(scope) for scope in self._function_final_scopes),
|
||||
{name: None for name in _function_local_names(node)},
|
||||
self._function_initial_scope(node),
|
||||
]
|
||||
body_kinds = [
|
||||
"module",
|
||||
@@ -911,73 +1337,86 @@ class _EventConsumerCollector(ast.NodeVisitor):
|
||||
self._scope_kinds = saved_kinds
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
"""记录直接 listener 调用或立即应用的 register decorator。"""
|
||||
"""记录 receiver 与调用签名均可证明的 producer/consumer。"""
|
||||
producer = self._producer_call(node)
|
||||
if producer is not None:
|
||||
method, selection = producer
|
||||
self._record_producer(node, method, selection)
|
||||
|
||||
factory_application = self._decorator_factory_application(node)
|
||||
if factory_application:
|
||||
factory, handler_node = factory_application
|
||||
self._record(
|
||||
self._record_consumer(
|
||||
node,
|
||||
factory.selection,
|
||||
method="register",
|
||||
receiver_kind=factory.receiver_kind,
|
||||
handler=_handler_identity(handler_node),
|
||||
priority=factory.priority,
|
||||
registration_kind="decorator",
|
||||
)
|
||||
else:
|
||||
registration = self._registration(node)
|
||||
if not registration or registration.method != "add_event_listener":
|
||||
self.generic_visit(node)
|
||||
return
|
||||
self._record(
|
||||
node,
|
||||
registration.selection,
|
||||
handler=registration.handler,
|
||||
priority=registration.priority,
|
||||
registration_kind="listener",
|
||||
)
|
||||
if registration and registration.method == "add_event_listener":
|
||||
self._record_consumer(
|
||||
node,
|
||||
registration.selection,
|
||||
method="add_event_listener",
|
||||
receiver_kind=registration.receiver_kind,
|
||||
handler=registration.handler,
|
||||
priority=registration.priority,
|
||||
registration_kind="listener",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def collect_event_consumers(
|
||||
def collect_event_facts(
|
||||
modules: dict[str, Path],
|
||||
event_members: dict[str, tuple[str, ...]],
|
||||
) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
收集宿主 EventManager 的静态和动态 consumer 位置
|
||||
收集宿主 EventManager 的逐调用 producer 与 consumer 事实。
|
||||
|
||||
只有可追溯到 ``app.runtime.events.eventmanager`` 或 canonical
|
||||
``EventManager`` 实例的注册才进入事实;未知 receiver 直接忽略。
|
||||
只有可追溯到 canonical EventManager、其内部 ``self`` 或明确注入事件端口的
|
||||
调用才进入事实。插件模块与未知同名 receiver 始终忽略;每条事实携带排除行号
|
||||
的字段敏感 SHA256,供基线稳定追踪。
|
||||
|
||||
:param modules: 宿主模块名到 Python 源码路径的映射
|
||||
:param event_members: EventType/ChainEventType 到公开成员名的映射
|
||||
:return: 静态事件位置映射与事件值未知的位置列表
|
||||
:return: ``producers`` 与 ``consumers`` 两组稳定排序的逐调用事实
|
||||
"""
|
||||
static: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
dynamic: list[dict[str, Any]] = []
|
||||
for module_name, path in sorted(modules.items()):
|
||||
if module_name == "app.plugins" or module_name.startswith("app.plugins."):
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
discovery = _EventConsumerCollector(
|
||||
trees = {
|
||||
module_name: ast.parse(
|
||||
path.read_text(encoding="utf-8-sig"),
|
||||
filename=str(path),
|
||||
)
|
||||
for module_name, path in sorted(modules.items())
|
||||
if module_name != "app.plugins"
|
||||
and not module_name.startswith("app.plugins.")
|
||||
}
|
||||
injected_fields = _discover_injected_event_fields(trees)
|
||||
producers: list[dict[str, Any]] = []
|
||||
consumers: list[dict[str, Any]] = []
|
||||
for module_name, tree in sorted(trees.items()):
|
||||
discovery = _EventFactCollector(
|
||||
module_name,
|
||||
event_members,
|
||||
collect_facts=False,
|
||||
injected_fields=injected_fields,
|
||||
)
|
||||
discovery.visit(tree)
|
||||
collector = _EventConsumerCollector(
|
||||
collector = _EventFactCollector(
|
||||
module_name,
|
||||
event_members,
|
||||
collect_facts=True,
|
||||
module_final_scope=discovery.module_scope(),
|
||||
injected_fields=injected_fields,
|
||||
)
|
||||
collector.visit(tree)
|
||||
for event, locations in collector.static.items():
|
||||
static[event].extend(locations)
|
||||
dynamic.extend(collector.dynamic)
|
||||
producers.extend(collector.producers)
|
||||
consumers.extend(collector.consumers)
|
||||
|
||||
return (
|
||||
{
|
||||
event: sorted(locations, key=_location_sort_key)
|
||||
for event, locations in sorted(static.items())
|
||||
},
|
||||
sorted(dynamic, key=_location_sort_key),
|
||||
)
|
||||
return {
|
||||
"producers": sorted(producers, key=_fact_sort_key),
|
||||
"consumers": sorted(consumers, key=_fact_sort_key),
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验人工审查的宿主 Event consumer 精确政策。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.architecture.event_facts import fingerprint_event_fact
|
||||
except ModuleNotFoundError:
|
||||
from event_facts import fingerprint_event_fact
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_EVENT_POLICY_PATH = (
|
||||
PROJECT_ROOT
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "architecture"
|
||||
/ "runtime-contract-policy.json"
|
||||
)
|
||||
EVENT_CONSUMER_POLICY_SCOPE = {
|
||||
"root": "app",
|
||||
"excluded": ["app.plugins"],
|
||||
"receiver_contract": "canonical_event_manager_only",
|
||||
}
|
||||
EVENT_CONSUMER_FACT_FIELDS = (
|
||||
"caller",
|
||||
"qualname",
|
||||
"method",
|
||||
"receiver_kind",
|
||||
"events",
|
||||
"dynamic",
|
||||
"invalid",
|
||||
"handler",
|
||||
"registration_kind",
|
||||
"priority",
|
||||
"fingerprint",
|
||||
)
|
||||
EVENT_CONSUMER_POLICY_FIELDS = (
|
||||
*EVENT_CONSUMER_FACT_FIELDS,
|
||||
"classification",
|
||||
"owner",
|
||||
"reason",
|
||||
)
|
||||
STATIC_CLASSIFICATION = "approved_static_registration"
|
||||
DYNAMIC_CLASSIFICATION = "approved_dynamic_exception"
|
||||
_FINGERPRINT_PATTERN = re.compile(r"[0-9a-f]{64}")
|
||||
_WILDCARD_CHARACTERS = frozenset("*?[")
|
||||
_PLACEHOLDER_REASONS = frozenset({"todo", "tbd"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EventPolicyViolation:
|
||||
"""描述一条可稳定排序和断言的 Event policy 违规。"""
|
||||
|
||||
code: str
|
||||
fingerprint: str | None
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EventPolicyCheck:
|
||||
"""汇总当前 consumer 事实、人工政策和全部校验结果。"""
|
||||
|
||||
actual_count: int
|
||||
reviewed_count: int
|
||||
static_count: int
|
||||
dynamic_count: int
|
||||
invalid_count: int
|
||||
violations: tuple[EventPolicyViolation, ...]
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""返回当前事实是否完整匹配人工政策。"""
|
||||
return not self.violations
|
||||
|
||||
|
||||
def _violation(
|
||||
code: str,
|
||||
detail: str,
|
||||
fingerprint: object = None,
|
||||
) -> EventPolicyViolation:
|
||||
"""构造字段类型安全的违规对象。"""
|
||||
return EventPolicyViolation(
|
||||
code=code,
|
||||
fingerprint=fingerprint if isinstance(fingerprint, str) else None,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _sort_violations(
|
||||
violations: list[EventPolicyViolation],
|
||||
) -> tuple[EventPolicyViolation, ...]:
|
||||
"""按错误码、指纹和说明输出确定顺序。"""
|
||||
return tuple(
|
||||
sorted(
|
||||
violations,
|
||||
key=lambda item: (
|
||||
item.code,
|
||||
item.fingerprint or "",
|
||||
item.detail,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _has_wildcard(value: object) -> bool:
|
||||
"""判断字符串或字符串列表是否包含 glob 通配符。"""
|
||||
if isinstance(value, str):
|
||||
return any(character in value for character in _WILDCARD_CHARACTERS)
|
||||
if isinstance(value, list):
|
||||
return any(_has_wildcard(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _fact_projection(value: Mapping[str, object]) -> dict[str, object]:
|
||||
"""提取 policy 与 collector 共用的完整 line-free consumer 字段。"""
|
||||
return {
|
||||
field: value[field]
|
||||
for field in EVENT_CONSUMER_FACT_FIELDS
|
||||
if field != "fingerprint" and field in value
|
||||
}
|
||||
|
||||
|
||||
def _validate_fact_shape(
|
||||
value: object,
|
||||
*,
|
||||
source: str,
|
||||
) -> list[EventPolicyViolation]:
|
||||
"""校验 collector fact 或 policy entry 的公共语义字段。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return [_violation("invalid_entry", f"{source} 必须是对象")]
|
||||
|
||||
violations: list[EventPolicyViolation] = []
|
||||
fingerprint = value.get("fingerprint")
|
||||
missing = sorted(set(EVENT_CONSUMER_FACT_FIELDS) - set(value))
|
||||
if missing:
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source} 缺少字段:{', '.join(missing)}",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
string_fields = (
|
||||
"caller",
|
||||
"qualname",
|
||||
"method",
|
||||
"receiver_kind",
|
||||
"handler",
|
||||
"registration_kind",
|
||||
"priority",
|
||||
)
|
||||
if any(
|
||||
not isinstance(value.get(field), str) or not str(value[field]).strip()
|
||||
for field in string_fields
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source} 的 consumer identity 字段必须是非空字符串",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
events = value.get("events")
|
||||
if (
|
||||
not isinstance(events, list)
|
||||
or any(not isinstance(event, str) or not event for event in events)
|
||||
or events != sorted(set(events))
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source}.events 必须是排序、去重的字符串列表",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
if not isinstance(value.get("dynamic"), bool) or not isinstance(
|
||||
value.get("invalid"), bool
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source}.dynamic/invalid 必须是布尔值",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
if not isinstance(fingerprint, str) or not _FINGERPRINT_PATTERN.fullmatch(
|
||||
fingerprint
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source}.fingerprint 必须是 64 位小写 SHA256",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
wildcard_fields = (
|
||||
"caller",
|
||||
"qualname",
|
||||
"method",
|
||||
"receiver_kind",
|
||||
"events",
|
||||
"handler",
|
||||
"registration_kind",
|
||||
"priority",
|
||||
)
|
||||
wildcard_names = [
|
||||
field for field in wildcard_fields if _has_wildcard(value.get(field))
|
||||
]
|
||||
if wildcard_names:
|
||||
violations.append(
|
||||
_violation(
|
||||
"wildcard_entry",
|
||||
f"{source} 禁止通配字段:{', '.join(wildcard_names)}",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
caller = value.get("caller")
|
||||
if isinstance(caller, str) and (
|
||||
caller == "app.plugins" or caller.startswith("app.plugins.")
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"plugin_scope_violation",
|
||||
f"{source} 不得包含插件副本:{caller}",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
if value.get("invalid") is True:
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_consumer",
|
||||
f"{source} 的事件选择包含非法成员,不得获得政策准入",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_entry(
|
||||
value: object,
|
||||
*,
|
||||
index: int,
|
||||
) -> list[EventPolicyViolation]:
|
||||
"""校验人工 entry 的精确字段、分类、owner 和理由。"""
|
||||
source = f"policy.entries[{index}]"
|
||||
violations = _validate_fact_shape(value, source=source)
|
||||
if not isinstance(value, Mapping):
|
||||
return violations
|
||||
|
||||
fingerprint = value.get("fingerprint")
|
||||
if set(value) != set(EVENT_CONSUMER_POLICY_FIELDS):
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_entry",
|
||||
f"{source} 必须精确包含规定字段",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
dynamic = value.get("dynamic")
|
||||
expected_classification = (
|
||||
DYNAMIC_CLASSIFICATION if dynamic is True else STATIC_CLASSIFICATION
|
||||
)
|
||||
if value.get("classification") != expected_classification:
|
||||
violations.append(
|
||||
_violation(
|
||||
"classification_mismatch",
|
||||
f"{source}.classification 应为 {expected_classification}",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
owner = value.get("owner")
|
||||
if (
|
||||
not isinstance(owner, str)
|
||||
or not owner.strip()
|
||||
or owner != value.get("caller")
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"owner_mismatch",
|
||||
f"{source}.owner 必须精确等于 caller",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
elif _has_wildcard(owner):
|
||||
violations.append(
|
||||
_violation(
|
||||
"wildcard_entry",
|
||||
f"{source}.owner 禁止通配符",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
reason = value.get("reason")
|
||||
if (
|
||||
not isinstance(reason, str)
|
||||
or not reason.strip()
|
||||
or reason.strip().lower() in _PLACEHOLDER_REASONS
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"empty_reason",
|
||||
f"{source}.reason 必须是非占位的具体理由",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _fingerprint_errors(
|
||||
value: Mapping[str, object],
|
||||
*,
|
||||
source: str,
|
||||
) -> list[EventPolicyViolation]:
|
||||
"""使用统一 Event fact 摘要函数验证 line-free identity。"""
|
||||
fingerprint = value.get("fingerprint")
|
||||
if not isinstance(fingerprint, str):
|
||||
return []
|
||||
expected = fingerprint_event_fact(_fact_projection(value))
|
||||
if fingerprint == expected:
|
||||
return []
|
||||
return [
|
||||
_violation(
|
||||
"fingerprint_mismatch",
|
||||
f"{source}.fingerprint 与完整 consumer identity 不匹配",
|
||||
fingerprint,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _duplicate_fingerprint_errors(
|
||||
values: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
code: str,
|
||||
source: str,
|
||||
) -> list[EventPolicyViolation]:
|
||||
"""拒绝会被集合比较吞掉的重复 consumer identity。"""
|
||||
fingerprints: list[str] = [
|
||||
fingerprint
|
||||
for value in values
|
||||
if isinstance((fingerprint := value.get("fingerprint")), str)
|
||||
]
|
||||
duplicates = sorted({item for item in fingerprints if fingerprints.count(item) > 1})
|
||||
return [
|
||||
_violation(code, f"{source} 存在重复 fingerprint", fingerprint)
|
||||
for fingerprint in duplicates
|
||||
]
|
||||
|
||||
|
||||
def validate_event_consumer_policy(
|
||||
policy: Mapping[str, object],
|
||||
facts: Sequence[Mapping[str, object]],
|
||||
) -> EventPolicyCheck:
|
||||
"""按 exact fingerprint set 校验当前 consumer 事实与人工政策。"""
|
||||
violations: list[EventPolicyViolation] = []
|
||||
actual_facts = list(facts)
|
||||
valid_actual: list[Mapping[str, object]] = []
|
||||
for index, fact in enumerate(actual_facts):
|
||||
fact_errors = _validate_fact_shape(fact, source=f"facts[{index}]")
|
||||
violations.extend(fact_errors)
|
||||
if not any(error.code == "invalid_entry" for error in fact_errors):
|
||||
violations.extend(_fingerprint_errors(fact, source=f"facts[{index}]"))
|
||||
valid_actual.append(fact)
|
||||
|
||||
if not isinstance(policy, Mapping) or set(policy) != {
|
||||
"schema_version",
|
||||
"scope",
|
||||
"event_consumers",
|
||||
}:
|
||||
violations.append(
|
||||
_violation("invalid_schema", "policy 顶层字段不符合 schema v1")
|
||||
)
|
||||
if isinstance(policy, Mapping) and policy.get("schema_version") != 1:
|
||||
violations.append(
|
||||
_violation("invalid_schema", "policy.schema_version 必须为 1")
|
||||
)
|
||||
if not isinstance(policy, Mapping) or policy.get("scope") != EVENT_CONSUMER_POLICY_SCOPE:
|
||||
violations.append(
|
||||
_violation("scope_mismatch", "policy.scope 必须精确匹配宿主扫描范围")
|
||||
)
|
||||
|
||||
policy_section = policy.get("event_consumers") if isinstance(policy, Mapping) else None
|
||||
if not isinstance(policy_section, Mapping) or set(policy_section) != {
|
||||
"match_mode",
|
||||
"entries",
|
||||
}:
|
||||
violations.append(
|
||||
_violation("invalid_schema", "policy.event_consumers 字段不完整")
|
||||
)
|
||||
entries: list[object] = []
|
||||
else:
|
||||
if policy_section.get("match_mode") != "exact_fingerprint_set":
|
||||
violations.append(
|
||||
_violation(
|
||||
"invalid_schema",
|
||||
"policy.event_consumers.match_mode 必须为 exact_fingerprint_set",
|
||||
)
|
||||
)
|
||||
raw_entries = policy_section.get("entries")
|
||||
if not isinstance(raw_entries, list):
|
||||
violations.append(
|
||||
_violation("invalid_schema", "policy.event_consumers.entries 必须是列表")
|
||||
)
|
||||
entries = []
|
||||
else:
|
||||
entries = raw_entries
|
||||
|
||||
valid_entries: list[Mapping[str, object]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
entry_errors = _validate_policy_entry(entry, index=index)
|
||||
violations.extend(entry_errors)
|
||||
if isinstance(entry, Mapping) and not any(
|
||||
error.code == "invalid_entry" for error in entry_errors
|
||||
):
|
||||
violations.extend(
|
||||
_fingerprint_errors(entry, source=f"policy.entries[{index}]")
|
||||
)
|
||||
valid_entries.append(entry)
|
||||
|
||||
violations.extend(
|
||||
_duplicate_fingerprint_errors(
|
||||
valid_actual,
|
||||
code="duplicate_fact_fingerprint",
|
||||
source="collector facts",
|
||||
)
|
||||
)
|
||||
violations.extend(
|
||||
_duplicate_fingerprint_errors(
|
||||
valid_entries,
|
||||
code="duplicate_policy_fingerprint",
|
||||
source="policy entries",
|
||||
)
|
||||
)
|
||||
|
||||
actual_by_fingerprint = {
|
||||
str(fact["fingerprint"]): fact
|
||||
for fact in valid_actual
|
||||
if isinstance(fact.get("fingerprint"), str)
|
||||
}
|
||||
policy_by_fingerprint = {
|
||||
str(entry["fingerprint"]): entry
|
||||
for entry in valid_entries
|
||||
if isinstance(entry.get("fingerprint"), str)
|
||||
}
|
||||
for fingerprint in sorted(actual_by_fingerprint.keys() - policy_by_fingerprint.keys()):
|
||||
violations.append(
|
||||
_violation(
|
||||
"unreviewed_fact",
|
||||
"当前 consumer 事实未经过人工政策审查",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
for fingerprint in sorted(policy_by_fingerprint.keys() - actual_by_fingerprint.keys()):
|
||||
violations.append(
|
||||
_violation(
|
||||
"stale_policy",
|
||||
"人工政策引用的 consumer 事实已经消失或被替换",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
for fingerprint in sorted(actual_by_fingerprint.keys() & policy_by_fingerprint.keys()):
|
||||
if _fact_projection(actual_by_fingerprint[fingerprint]) != _fact_projection(
|
||||
policy_by_fingerprint[fingerprint]
|
||||
):
|
||||
violations.append(
|
||||
_violation(
|
||||
"fingerprint_mismatch",
|
||||
"相同 fingerprint 对应的 consumer identity 不一致",
|
||||
fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
static_count = sum(fact.get("dynamic") is False for fact in actual_facts)
|
||||
dynamic_count = sum(fact.get("dynamic") is True for fact in actual_facts)
|
||||
invalid_count = sum(fact.get("invalid") is True for fact in actual_facts)
|
||||
return EventPolicyCheck(
|
||||
actual_count=len(actual_facts),
|
||||
reviewed_count=len(entries),
|
||||
static_count=static_count,
|
||||
dynamic_count=dynamic_count,
|
||||
invalid_count=invalid_count,
|
||||
violations=_sort_violations(violations),
|
||||
)
|
||||
|
||||
|
||||
def check_event_consumer_policy(
|
||||
facts: Sequence[Mapping[str, object]],
|
||||
policy_path: Path = DEFAULT_EVENT_POLICY_PATH,
|
||||
) -> EventPolicyCheck:
|
||||
"""读取人工 policy 文件并校验给定的当前 consumer facts。"""
|
||||
try:
|
||||
policy = json.loads(policy_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
facts_list = list(facts)
|
||||
return EventPolicyCheck(
|
||||
actual_count=len(facts_list),
|
||||
reviewed_count=0,
|
||||
static_count=sum(fact.get("dynamic") is False for fact in facts_list),
|
||||
dynamic_count=sum(fact.get("dynamic") is True for fact in facts_list),
|
||||
invalid_count=sum(fact.get("invalid") is True for fact in facts_list),
|
||||
violations=(
|
||||
_violation(
|
||||
"invalid_policy_file",
|
||||
f"无法读取 Event consumer policy:{error}",
|
||||
),
|
||||
),
|
||||
)
|
||||
if not isinstance(policy, Mapping):
|
||||
policy = {}
|
||||
return validate_event_consumer_policy(policy, facts)
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""解析只读 Event policy 检查参数。"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--policy",
|
||||
type=Path,
|
||||
default=DEFAULT_EVENT_POLICY_PATH,
|
||||
help="人工 Event consumer policy 路径",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""收集当前事实并只读检查人工政策,不提供任何写入入口。"""
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
from scripts.architecture.baseline import collect_current_event_facts
|
||||
except ImportError:
|
||||
from baseline import collect_current_event_facts
|
||||
|
||||
current = collect_current_event_facts()
|
||||
consumers: Any = current.get("consumers") if isinstance(current, Mapping) else None
|
||||
if not isinstance(consumers, list):
|
||||
print("当前 Event facts 缺少 consumers 列表", file=sys.stderr)
|
||||
return 1
|
||||
result = check_event_consumer_policy(consumers, args.policy)
|
||||
if result.ok:
|
||||
print(
|
||||
"Event consumer policy 通过:"
|
||||
f"{result.actual_count} 条事实,"
|
||||
f"{result.static_count} 条静态注册,"
|
||||
f"{result.dynamic_count} 条动态例外"
|
||||
)
|
||||
return 0
|
||||
for violation in result.violations:
|
||||
suffix = f" [{violation.fingerprint}]" if violation.fingerprint else ""
|
||||
print(f"{violation.code}{suffix}: {violation.detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user