refactor: separate semantic architecture baselines

This commit is contained in:
jxxghp
2026-08-21 19:28:31 +08:00
parent 7bc3ea831f
commit 37c442b042
6 changed files with 872 additions and 630 deletions
+229 -24
View File
@@ -250,8 +250,11 @@ def collect_dependency_baseline() -> dict[str, Any]:
}
def collect_run_module_contracts() -> dict[str, Any]:
"""收集字符串模块调度方法及其同步、异步调用位置。"""
def _collect_run_module_locations() -> tuple[
dict[str, list[dict[str, Any]]],
list[dict[str, Any]],
]:
"""扫描模块调度调用及其当前源码位置。"""
calls: dict[str, list[dict[str, Any]]] = defaultdict(list)
dynamic_calls: list[dict[str, Any]] = []
for module_name, path in discover_modules().items():
@@ -274,18 +277,53 @@ def collect_run_module_contracts() -> dict[str, Any]:
calls[node.args[0].value].append(location)
else:
dynamic_calls.append(location)
return calls, dynamic_calls
def _aggregate_locations(
locations: list[dict[str, Any]],
fields: tuple[str, ...],
) -> list[dict[str, Any]]:
"""按稳定字段聚合调用位置,排除行号但保留调用次数。"""
counts: dict[tuple[Any, ...], int] = defaultdict(int)
for location in locations:
counts[tuple(location[field] for field in fields)] += 1
return [
{
**dict(zip(fields, values)),
"count": count,
}
for values, count in sorted(counts.items())
]
def collect_run_module_contracts() -> dict[str, Any]:
"""收集不受源码行号变化影响的模块调度语义契约。"""
calls, dynamic_calls = _collect_run_module_locations()
stable_calls = {
method: sorted(
locations,
key=lambda item: (item["caller"], item["line"], item["mode"]),
)
method: _aggregate_locations(locations, ("caller", "mode"))
for method, locations in sorted(calls.items())
}
return {
"method_count": len(stable_calls),
"call_count": sum(len(locations) for locations in stable_calls.values()),
"call_count": sum(len(locations) for locations in calls.values()),
"dynamic_call_count": len(dynamic_calls),
"methods": stable_calls,
"dynamic_calls": _aggregate_locations(dynamic_calls, ("caller", "mode")),
}
def collect_run_module_diagnostics() -> dict[str, Any]:
"""收集模块调度调用的当前源码位置,仅用于人工诊断。"""
calls, dynamic_calls = _collect_run_module_locations()
return {
"methods": {
method: sorted(
locations,
key=lambda item: (item["caller"], item["line"], item["mode"]),
)
for method, locations in sorted(calls.items())
},
"dynamic_calls": sorted(
dynamic_calls,
key=lambda item: (item["caller"], item["line"], item["mode"]),
@@ -330,8 +368,14 @@ def _event_enum_members(enum_name: str) -> tuple[str, ...]:
)
def collect_event_contracts() -> dict[str, Any]:
"""收集宿主事件枚举的生产者、消费者和动态调用位置。"""
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]],
]:
"""扫描事件枚举及其生产、消费位置,供语义和诊断视图复用。"""
event_members = _event_enum_members("EventType")
chain_event_members = _event_enum_members("ChainEventType")
@@ -390,28 +434,70 @@ def collect_event_contracts() -> dict[str, Any]:
*(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 collect_event_contracts() -> dict[str, Any]:
"""收集不受源码行号变化影响的宿主事件语义契约。"""
(
enum_names,
producers,
consumers,
dynamic_producers,
dynamic_consumers,
) = _collect_event_locations()
contracts = {
name: {
"producers": sorted(
"producers": _aggregate_locations(
producers.get(name, []),
key=lambda item: (item["caller"], item["line"]),
("caller",),
),
"consumers": sorted(
"consumers": _aggregate_locations(
consumers.get(name, []),
key=lambda item: (item["caller"], item["line"]),
("caller",),
),
}
for name in sorted(enum_names)
}
return {
"event_count": len(contracts),
"producer_count": sum(
len(item["producers"]) for item in contracts.values()
),
"consumer_count": sum(
len(item["consumers"]) for item in contracts.values()
),
"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",)),
}
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"]),
@@ -503,7 +589,7 @@ def collect_compat_manifest() -> dict[str, Any]:
def collect_runtime_baseline() -> dict[str, Any]:
"""生成模块调度、SDK 和兼容层公开契约基线。"""
return {
"schema_version": 1,
"schema_version": 2,
"run_module": collect_run_module_contracts(),
"events": collect_event_contracts(),
"sdk_exports": collect_sdk_exports(),
@@ -511,6 +597,14 @@ def collect_runtime_baseline() -> dict[str, Any]:
}
def collect_runtime_diagnostics() -> dict[str, Any]:
"""生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。"""
return {
"run_module": collect_run_module_diagnostics(),
"events": collect_event_diagnostics(),
}
def git_head(repository: Path) -> str:
"""读取外部插件仓当前提交,失败时返回可诊断占位值。"""
result = subprocess.run(
@@ -633,11 +727,13 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
if hook in defined_names:
hook_files[hook].add(relative)
return {
"schema_version": 2,
"source": {
"schema_version": 3,
"scope": {
"repository": "MoviePilot-Plugins",
"head": git_head(plugin_repo),
"roots": [root.name for root in roots],
},
"provenance": {
"head": git_head(plugin_repo),
"python_file_count": len(paths),
"source_sha256": digest.hexdigest(),
},
@@ -669,12 +765,103 @@ def write_json(path: Path, value: dict[str, Any]) -> None:
def _display_path(path: Path) -> Path:
"""优先返回仓库相对路径,便于 CLI 输出稳定可读。"""
try:
return path.relative_to(PROJECT_ROOT)
except ValueError:
return path
def _migrate_runtime_baseline(value: dict[str, Any]) -> dict[str, Any]:
"""把包含源码行号的 v1 运行契约转换为稳定语义结构。"""
if value.get("schema_version") != 1:
return value
run_module = value["run_module"]
events = value["events"]
return {
"schema_version": 2,
"run_module": {
**{
key: run_module[key]
for key in ("method_count", "call_count", "dynamic_call_count")
},
"methods": {
method: _aggregate_locations(locations, ("caller", "mode"))
for method, locations in run_module["methods"].items()
},
"dynamic_calls": _aggregate_locations(
run_module["dynamic_calls"],
("caller", "mode"),
),
},
"events": {
**{
key: events[key]
for key in ("event_count", "producer_count", "consumer_count")
},
"events": {
event_name: {
"producers": _aggregate_locations(
contract["producers"],
("caller",),
),
"consumers": _aggregate_locations(
contract["consumers"],
("caller",),
),
}
for event_name, contract in events["events"].items()
},
"dynamic_producers": _aggregate_locations(
events["dynamic_producers"],
("caller",),
),
"dynamic_consumers": _aggregate_locations(
events["dynamic_consumers"],
("caller",),
),
},
"sdk_exports": value["sdk_exports"],
"compat_manifest": value["compat_manifest"],
}
def _migrate_plugin_baseline(value: dict[str, Any]) -> dict[str, Any]:
"""把来源信息混排的 v2 插件基线转换为 scope/provenance 结构。"""
if value.get("schema_version") != 2:
return value
source = value["source"]
return {
"schema_version": 3,
"scope": {
"repository": source["repository"],
"roots": source["roots"],
},
"provenance": {
"head": source["head"],
"python_file_count": source["python_file_count"],
"source_sha256": source["source_sha256"],
},
"imports": value["imports"],
"hooks": value["hooks"],
"api_routes": value["api_routes"],
}
def semantic_baseline(path: Path, value: dict[str, Any]) -> dict[str, Any]:
"""返回参与门禁比较的稳定语义视图,并兼容读取旧 fixture。"""
if path.name == RUNTIME_BASELINE_PATH.name:
return _migrate_runtime_baseline(value)
if path.name == PLUGIN_BASELINE_PATH.name:
migrated = _migrate_plugin_baseline(value)
return {
key: item
for key, item in migrated.items()
if key != "provenance"
}
return value
def check_json(
path: Path,
actual: dict[str, Any],
@@ -683,7 +870,14 @@ def check_json(
) -> bool:
"""比较当前扫描结果和已提交基线并输出限定范围的更新提示。"""
expected = json.loads(path.read_text(encoding="utf-8"))
if expected == actual:
expected_semantic = semantic_baseline(path, expected)
actual_semantic = semantic_baseline(path, actual)
if expected_semantic == actual_semantic:
if expected != actual:
print(
f"基线语义未变化,但 schema/provenance 已变化:{_display_path(path)}",
file=sys.stderr,
)
return True
print(
f"架构基线已变化:{_display_path(path)}"
@@ -713,6 +907,11 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
type=Path,
help="官方插件操作所需的独立 MoviePilot-Plugins 仓路径",
)
parser.add_argument(
"--diagnostics",
action="store_true",
help="随宿主检查打印当前运行契约源码位置",
)
args = parser.parse_args(argv)
if args.check or args.write:
if not args.scope:
@@ -724,11 +923,15 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
file=sys.stderr,
)
setattr(args, f"{'check' if args.check else 'write'}_{args.scope}", True)
elif args.scope:
parser.error("--scope 只能与旧 --check/--write 一起使用")
plugin_action = args.check_plugins or args.write_plugins
if plugin_action and not args.plugin_repo:
parser.error("插件基线操作必须指定 --plugin-repo")
if not plugin_action and args.plugin_repo:
parser.error("--plugin-repo 只能用于插件基线操作")
if args.diagnostics and not args.check_host:
parser.error("--diagnostics 只能与 --check-host 一起使用")
return args
@@ -763,6 +966,8 @@ def main(argv: Optional[list[str]] = None) -> int:
check_json(path, baseline, write_hint=write_hint)
for path, baseline in baselines
]
if args.diagnostics:
print(json.dumps(collect_runtime_diagnostics(), ensure_ascii=False, indent=2))
return 0 if all(checks) else 1
+1
View File
@@ -322,6 +322,7 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
def _display_path(path: Path) -> Path:
"""优先返回仓库相对路径,便于 CLI 输出稳定可读。"""
try:
return path.relative_to(PROJECT_ROOT)
except ValueError:
+161 -63
View File
@@ -1111,48 +1111,59 @@
"response_model": null
}
],
"plugins.v2/chatgpt/__init__.py": [
"plugins.v2/courseorganizer/__init__.py": [
{
"auth": "'bear'",
"endpoint": "get_cache_stats",
"endpoint_return": "dict",
"endpoint": "get_review_route",
"endpoint_return": "Any",
"methods": [
"GET"
],
"path": "/cache_stats",
"path": "/review",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "clear_cache",
"endpoint_return": "dict",
"endpoint": "save_review_route",
"endpoint_return": "Any",
"methods": [
"GET"
"POST"
],
"path": "/clear_cache",
"path": "/review",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "clear_usage_stats",
"endpoint_return": "dict",
"endpoint": "refresh_review_route",
"endpoint_return": "Any",
"methods": [
"GET"
"POST"
],
"path": "/clear_usage_stats",
"path": "/review/refresh",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "get_usage_stats",
"endpoint_return": "dict",
"endpoint": "associate_tmdb_route",
"endpoint_return": "Any",
"methods": [
"GET"
"POST"
],
"path": "/usage_stats",
"path": "/review/tmdb/associate",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "search_tmdb_route",
"endpoint_return": "Any",
"methods": [
"POST"
],
"path": "/review/tmdb/search",
"response_class": null,
"response_model": null
}
@@ -2501,6 +2512,52 @@
"response_model": null
}
],
"plugins.v3/chatgpt/__init__.py": [
{
"auth": "'bear'",
"endpoint": "get_cache_stats",
"endpoint_return": "dict",
"methods": [
"GET"
],
"path": "/cache_stats",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "clear_cache",
"endpoint_return": "dict",
"methods": [
"GET"
],
"path": "/clear_cache",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "clear_usage_stats",
"endpoint_return": "dict",
"methods": [
"GET"
],
"path": "/clear_usage_stats",
"response_class": null,
"response_model": null
},
{
"auth": "'bear'",
"endpoint": "get_usage_stats",
"endpoint_return": "dict",
"methods": [
"GET"
],
"path": "/usage_stats",
"response_class": null,
"response_model": null
}
],
"plugins.v3/doubanrank/__init__.py": [
{
"auth": null,
@@ -2638,7 +2695,7 @@
]
},
"get_api": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2656,6 +2713,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -2708,6 +2766,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -2728,7 +2787,7 @@
"files": []
},
"get_command": {
"file_count": 79,
"file_count": 81,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2745,6 +2804,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -2796,6 +2856,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -2827,7 +2888,7 @@
]
},
"get_form": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2845,6 +2906,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -2897,6 +2959,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -2923,7 +2986,7 @@
]
},
"get_page": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2941,6 +3004,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -2993,6 +3057,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -3009,13 +3074,14 @@
]
},
"get_render_mode": {
"file_count": 11,
"file_count": 12,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
"plugins.v2/airecognizerenhancer/__init__.py",
"plugins.v2/brushflow/__init__.py",
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/fullscreenposterwall/__init__.py",
"plugins.v2/hrblocker/__init__.py",
"plugins.v2/oidcauth/__init__.py",
@@ -3025,7 +3091,7 @@
]
},
"get_service": {
"file_count": 47,
"file_count": 48,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/autoauction/__init__.py",
@@ -3035,6 +3101,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -3081,7 +3148,7 @@
"files": []
},
"get_state": {
"file_count": 82,
"file_count": 85,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3100,6 +3167,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -3152,6 +3220,8 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/chatgpt/openai.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -3168,7 +3238,7 @@
]
},
"init_plugin": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3186,6 +3256,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -3238,6 +3309,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -3254,7 +3326,7 @@
]
},
"stop_service": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3272,6 +3344,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -3324,6 +3397,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -3348,11 +3422,12 @@
]
},
"app.agent.llm": {
"file_count": 3,
"file_count": 4,
"files": [
"plugins.v2/airecognizerenhancer/__init__.py",
"plugins.v2/chatgpt/openai.py",
"plugins.v2/qbuploadlimiter/__init__.py"
"plugins.v2/courseorganizer/providers.py",
"plugins.v2/qbuploadlimiter/__init__.py",
"plugins.v3/chatgpt/openai.py"
]
},
"app.agent.llm.helper": {
@@ -3415,10 +3490,11 @@
]
},
"app.chain.media": {
"file_count": 9,
"file_count": 10,
"files": [
"plugins.v2/agentresourceofficer/services/hdhive_openapi.py",
"plugins.v2/airecognizerenhancer/__init__.py",
"plugins.v2/courseorganizer/providers.py",
"plugins.v2/doubanrank/__init__.py",
"plugins.v2/doubansync/__init__.py",
"plugins.v2/lexiannot/__init__.py",
@@ -3548,7 +3624,7 @@
]
},
"app.core.config": {
"file_count": 70,
"file_count": 73,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agentresourceofficer/services/hdhive_openapi.py",
@@ -3584,6 +3660,7 @@
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/contractcheck/siteuserinfo/__init__.py",
"plugins.v2/courseorganizer/providers.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -3594,6 +3671,7 @@
"plugins.v2/ffmpegthumb/__init__.py",
"plugins.v2/fullscreenposterwall/__init__.py",
"plugins.v2/imdbsource/__init__.py",
"plugins.v2/imdbsource/imdbapi.py",
"plugins.v2/imdbsource/imdbhelper.py",
"plugins.v2/invitessignin/__init__.py",
"plugins.v2/iyuuautoseed/__init__.py",
@@ -3619,7 +3697,8 @@
"plugins.v2/tvdbdiscover/__init__.py",
"plugins.v2/tvfirstwatch/__init__.py",
"plugins.v2/updatewechatip/__init__.py",
"plugins.v2/wechatclawbot/__init__.py"
"plugins.v2/wechatclawbot/__init__.py",
"plugins.v3/chatgpt/__init__.py"
]
},
"app.core.context": {
@@ -3645,7 +3724,7 @@
]
},
"app.core.event": {
"file_count": 40,
"file_count": 41,
"files": [
"plugins.v2/agenttokens/__init__.py",
"plugins.v2/airecognizerenhancer/__init__.py",
@@ -3686,7 +3765,8 @@
"plugins.v2/tvfirstwatch/__init__.py",
"plugins.v2/updatewechatip/__init__.py",
"plugins.v2/wechatclawbot/__init__.py",
"plugins.v2/wxpusher/__init__.py"
"plugins.v2/wxpusher/__init__.py",
"plugins.v3/chatgpt/__init__.py"
]
},
"app.core.meta": {
@@ -3880,8 +3960,9 @@
]
},
"app.db.user_oper": {
"file_count": 2,
"file_count": 3,
"files": [
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/doubansync/__init__.py",
"plugins.v2/oidcauth/__init__.py"
]
@@ -4036,7 +4117,7 @@
]
},
"app.log": {
"file_count": 97,
"file_count": 99,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agentresourceofficer/feishu_channel.py",
@@ -4082,6 +4163,7 @@
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/contractcheck/siteuserinfo/__init__.py",
"plugins.v2/contractcheck/siteuserinfo/nexus_php.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -4134,7 +4216,8 @@
"plugins.v2/updatewechatip/__init__.py",
"plugins.v2/wechatclawbot/__init__.py",
"plugins.v2/wechatclawbot/ilink/client.py",
"plugins.v2/wxpusher/__init__.py"
"plugins.v2/wxpusher/__init__.py",
"plugins.v3/chatgpt/__init__.py"
]
},
"app.modules.bangumi": {
@@ -4210,7 +4293,7 @@
]
},
"app.plugins": {
"file_count": 81,
"file_count": 83,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -4228,6 +4311,7 @@
"plugins.v2/clashruleprovider/__init__.py",
"plugins.v2/cleaninvalidseed/__init__.py",
"plugins.v2/contractcheck/__init__.py",
"plugins.v2/courseorganizer/__init__.py",
"plugins.v2/crossseed/__init__.py",
"plugins.v2/dailysummary/__init__.py",
"plugins.v2/doubanrank/__init__.py",
@@ -4280,6 +4364,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
@@ -4322,9 +4407,10 @@
]
},
"app.plugins.chatgpt.openai": {
"file_count": 1,
"file_count": 2,
"files": [
"plugins.v2/chatgpt/__init__.py"
"plugins.v2/chatgpt/__init__.py",
"plugins.v3/chatgpt/__init__.py"
]
},
"app.plugins.ffmpegthumb.ffmpeg_helper": {
@@ -4334,24 +4420,21 @@
]
},
"app.plugins.imdbsource.imdbhelper": {
"file_count": 2,
"file_count": 1,
"files": [
"plugins.v2/imdbsource/__init__.py",
"plugins.v3/imdbsource/__init__.py"
"plugins.v2/imdbsource/__init__.py"
]
},
"app.plugins.imdbsource.officialapi": {
"file_count": 2,
"file_count": 1,
"files": [
"plugins.v2/imdbsource/__init__.py",
"plugins.v3/imdbsource/__init__.py"
"plugins.v2/imdbsource/__init__.py"
]
},
"app.plugins.imdbsource.schema": {
"file_count": 2,
"file_count": 1,
"files": [
"plugins.v2/imdbsource/__init__.py",
"plugins.v3/imdbsource/__init__.py"
"plugins.v2/imdbsource/__init__.py"
]
},
"app.plugins.iyuuautoseed.iyuu_helper": {
@@ -4385,7 +4468,7 @@
]
},
"app.schemas": {
"file_count": 48,
"file_count": 50,
"files": [
"plugins.v2/autoclean/__init__.py",
"plugins.v2/blurayremux/__init__.py",
@@ -4426,9 +4509,11 @@
"plugins.v2/wechatclawbot/__init__.py",
"plugins.v3/autoclean/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubanrank/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/schema/imdbapi.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
@@ -4437,14 +4522,26 @@
"plugins.v3/tvdbdiscover/__init__.py"
]
},
"app.schemas.context": {
"file_count": 1,
"files": [
"plugins.v3/imdbsource/schema/imdbapi.py"
]
},
"app.schemas.event": {
"file_count": 1,
"files": [
"plugins.v2/hrblocker/__init__.py"
]
},
"app.schemas.exception": {
"file_count": 1,
"files": [
"plugins.v3/imdbsource/officialapi.py"
]
},
"app.schemas.types": {
"file_count": 65,
"file_count": 66,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -4500,6 +4597,7 @@
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
"plugins.v3/brushflow/__init__.py",
"plugins.v3/chatgpt/__init__.py",
"plugins.v3/doubansync/__init__.py",
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/episodegroupmeta/__init__.py",
@@ -4522,9 +4620,8 @@
]
},
"app.sdk.cache": {
"file_count": 5,
"file_count": 4,
"files": [
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/imdbapi.py",
"plugins.v3/imdbsource/imdbhelper.py",
"plugins.v3/imdbsource/officialapi.py",
@@ -4532,7 +4629,7 @@
]
},
"app.sdk.config": {
"file_count": 20,
"file_count": 21,
"files": [
"plugins.v3/agentresourceofficer/__init__.py",
"plugins.v3/agentresourceofficer/services/hdhive_openapi.py",
@@ -4546,6 +4643,7 @@
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/episodegroupmeta/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/imdbapi.py",
"plugins.v3/imdbsource/imdbhelper.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
@@ -4573,7 +4671,7 @@
]
},
"app.sdk.logging": {
"file_count": 24,
"file_count": 23,
"files": [
"plugins.v3/agentresourceofficer/__init__.py",
"plugins.v3/agentresourceofficer/feishu_channel.py",
@@ -4588,7 +4686,6 @@
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/episodegroupmeta/__init__.py",
"plugins.v3/historytov2/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/imdbapi.py",
"plugins.v3/imdbsource/imdbhelper.py",
"plugins.v3/imdbsource/officialapi.py",
@@ -4622,7 +4719,7 @@
]
},
"app.sdk.network": {
"file_count": 17,
"file_count": 16,
"files": [
"plugins.v3/animeupscale/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
@@ -4632,7 +4729,6 @@
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/episodegroupmeta/__init__.py",
"plugins.v3/historytov2/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/imdbapi.py",
"plugins.v3/imdbsource/imdbhelper.py",
"plugins.v3/imdbsource/officialapi.py",
@@ -4839,15 +4935,17 @@
]
}
},
"schema_version": 2,
"source": {
"head": "ddb41dbcbbea21196154a7f6d5fdba3aa34a5e4a",
"python_file_count": 231,
"provenance": {
"head": "217c8d25ffe6ff0b3f6352c4278fd6896def442e",
"python_file_count": 237,
"source_sha256": "c3796565ff3fd49a8ff3f1e1373d9afab44fd7b98371e44f662c32739fe53d15"
},
"schema_version": 3,
"scope": {
"repository": "MoviePilot-Plugins",
"roots": [
"plugins.v2",
"plugins.v3"
],
"source_sha256": "63663538bd7f5b7c6a9f318ab58ad35d8c5ab68bd7d798d07a5dcdf600f062ad"
]
}
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -71,6 +71,133 @@ def test_architecture_plugin_action_requires_repository(capsys):
assert "必须指定 --plugin-repo" in capsys.readouterr().err
def test_architecture_diagnostics_only_support_host_check(capsys):
"""源码位置诊断不得与写操作或插件扫描混用。"""
with pytest.raises(SystemExit) as error:
architecture_baseline.parse_args(["--write-host", "--diagnostics"])
assert error.value.code == 2
assert "只能与 --check-host" in capsys.readouterr().err
def test_runtime_semantics_ignore_line_changes_but_keep_call_count(tmp_path: Path):
"""旧 fixture 的行号变化不影响门禁,重复调用次数仍属于语义。"""
baseline_path = tmp_path / "runtime-contract-baseline.json"
old_value = {
"schema_version": 1,
"run_module": {
"method_count": 1,
"call_count": 2,
"dynamic_call_count": 0,
"methods": {
"search": [
{"caller": "app.chain.search", "line": 10, "mode": "sync"},
{"caller": "app.chain.search", "line": 11, "mode": "sync"},
]
},
"dynamic_calls": [],
},
"events": {
"event_count": 1,
"producer_count": 1,
"consumer_count": 0,
"events": {
"EventType.NoticeMessage": {
"producers": [{"caller": "app.chain.message", "line": 20}],
"consumers": [],
}
},
"dynamic_producers": [],
"dynamic_consumers": [],
},
"sdk_exports": {},
"compat_manifest": {},
}
moved_value = json.loads(json.dumps(old_value))
moved_value["run_module"]["methods"]["search"][0]["line"] = 1000
moved_value["run_module"]["methods"]["search"][1]["line"] = 1001
moved_value["events"]["events"]["EventType.NoticeMessage"]["producers"][0][
"line"
] = 2000
old_semantic = architecture_baseline.semantic_baseline(baseline_path, old_value)
moved_semantic = architecture_baseline.semantic_baseline(
baseline_path,
moved_value,
)
assert old_semantic == moved_semantic
assert old_semantic["run_module"]["methods"]["search"] == [
{"caller": "app.chain.search", "mode": "sync", "count": 2}
]
def test_plugin_provenance_does_not_participate_in_semantic_gate(tmp_path: Path):
"""插件仓 revision 和源码摘要变化不应伪装成 ABI 变化。"""
baseline_path = tmp_path / "official-plugin-baseline.json"
first = {
"schema_version": 3,
"scope": {"repository": "MoviePilot-Plugins", "roots": ["plugins.v3"]},
"provenance": {
"head": "a" * 40,
"python_file_count": 1,
"source_sha256": "a" * 64,
},
"imports": {},
"hooks": {},
"api_routes": {},
}
second = json.loads(json.dumps(first))
second["provenance"] = {
"head": "b" * 40,
"python_file_count": 2,
"source_sha256": "b" * 64,
}
assert architecture_baseline.semantic_baseline(
baseline_path,
first,
) == architecture_baseline.semantic_baseline(baseline_path, second)
def test_plugin_v2_fixture_migrates_before_semantic_comparison(tmp_path: Path):
"""旧插件 fixture 应在内存中迁移,不能因 schema 排列变化误报 ABI。"""
baseline_path = tmp_path / "official-plugin-baseline.json"
old_value = {
"schema_version": 2,
"source": {
"repository": "MoviePilot-Plugins",
"head": "a" * 40,
"roots": ["plugins.v2", "plugins.v3"],
"python_file_count": 1,
"source_sha256": "a" * 64,
},
"imports": {"app.sdk.logging": {"file_count": 1, "files": ["x.py"]}},
"hooks": {},
"api_routes": {},
}
new_value = {
"schema_version": 3,
"scope": {
"repository": "MoviePilot-Plugins",
"roots": ["plugins.v2", "plugins.v3"],
},
"provenance": {
"head": "b" * 40,
"python_file_count": 2,
"source_sha256": "b" * 64,
},
"imports": old_value["imports"],
"hooks": {},
"api_routes": {},
}
assert architecture_baseline.semantic_baseline(
baseline_path,
old_value,
) == architecture_baseline.semantic_baseline(baseline_path, new_value)
def test_architecture_write_host_only_updates_host_files(
tmp_path: Path,
monkeypatch,
+13 -3
View File
@@ -41,9 +41,10 @@ def test_official_plugin_baseline_records_external_source():
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["source"]["repository"] == "MoviePilot-Plugins"
assert len(baseline["source"]["head"]) == 40
assert baseline["source"]["roots"] == ["plugins.v2", "plugins.v3"]
assert baseline["schema_version"] == 3
assert baseline["scope"]["repository"] == "MoviePilot-Plugins"
assert baseline["scope"]["roots"] == ["plugins.v2", "plugins.v3"]
assert len(baseline["provenance"]["head"]) == 40
assert all(
not path.startswith("app/plugins/")
for contract in (*baseline["imports"].values(), *baseline["hooks"].values())
@@ -95,6 +96,15 @@ def test_startup_performance_baseline_records_all_cold_import_targets():
assert contract["loaded_module_count"] > 0
def test_runtime_contract_baseline_excludes_diagnostic_line_numbers():
"""运行契约 fixture 只保存稳定语义,源码位置必须按需诊断。"""
baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 2
assert '"line"' not in json.dumps(baseline)
def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():
"""非功能基线必须同时记录正常/安全模式和隔离资源增量。"""
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"