From b9ad5220ae87fbf415150871a9cf812d97264f9f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 21 Aug 2026 21:25:35 +0800 Subject: [PATCH] refactor: define module contract v2 --- app/runtime/extensions/module/contracts.py | 110 ++++-- app/runtime/extensions/module/dispatcher.py | 25 +- .../backend-architecture-next-stage.md | 11 + scripts/architecture/baseline.py | 17 + .../runtime-contract-baseline.json | 332 ++++++++++++++++++ tests/test_module_method_contracts.py | 41 ++- 6 files changed, 511 insertions(+), 25 deletions(-) diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index 1d62cd910..5bcbc518e 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -2,22 +2,54 @@ from __future__ import annotations +import inspect +from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum +from typing import Any, Protocol class ModuleResultAggregation(StrEnum): """描述多模块结果沿调用链的兼容聚合方式。""" LEGACY = "legacy" + FIRST_NON_EMPTY = "first_non_empty" + ORDERED_LIST_MERGE = "ordered_list_merge" + + +class ModuleExecutionMode(StrEnum): + """描述 provider 可以采用的执行形态。""" + + SYNC_OR_ASYNC = "sync_or_async" + + +class ModuleErrorPolicy(StrEnum): + """描述单个 provider 失败后的兼容处理策略。""" + + ISOLATE_PROVIDER = "isolate_provider" + + +class ModuleCapability(Protocol): + """宿主和新插件可用于声明动态能力的最小 Protocol。""" + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """执行模块能力并返回契约声明的结果。""" @dataclass(frozen=True, slots=True) class ModuleMethodContract: - """记录一个模块方法族的调用模式和结果规则。""" + """记录模块方法的输入、结果、执行与兼容错误协议。""" family: str aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY + version: int = 1 + input_contract: str = "legacy_args" + result_contract: str = "Any" + required_parameters: tuple[str, ...] = () + execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC + timeout_policy: str = "caller_budget" + error_policy: ModuleErrorPolicy = ModuleErrorPolicy.ISOLATE_PROVIDER + public_to_plugins: bool = True supports_sync: bool = True supports_async: bool = True plugin_short_circuit: bool = True @@ -28,28 +60,34 @@ _DEFAULT_CONTRACT = ModuleMethodContract(family="legacy") # 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力; # 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。 _METHOD_CONTRACTS = { - "recognize_media": ModuleMethodContract(family="media-recognition"), - "search_medias": ModuleMethodContract(family="media-recognition"), - "obtain_images": ModuleMethodContract(family="media-recognition"), - "media_category": ModuleMethodContract(family="media-recognition"), - "mediaserver_items": ModuleMethodContract(family="media-server"), - "mediaserver_iteminfo": ModuleMethodContract(family="media-server"), - "mediaserver_play_url": ModuleMethodContract(family="media-server"), - "mediaserver_tv_episodes": ModuleMethodContract(family="media-server"), - "download_file": ModuleMethodContract(family="storage"), - "upload_file": ModuleMethodContract(family="storage"), - "list_files": ModuleMethodContract(family="storage"), - "get_file_item": ModuleMethodContract(family="storage"), - "get_folder": ModuleMethodContract(family="storage"), - "get_parent_item": ModuleMethodContract(family="storage"), - "rename_file": ModuleMethodContract(family="storage"), - "storage_manage": ModuleMethodContract(family="storage"), - "snapshot_storage": ModuleMethodContract(family="storage"), - "send_message": ModuleMethodContract(family="messaging"), - "finalize_message": ModuleMethodContract(family="messaging"), - "register_commands": ModuleMethodContract(family="messaging"), - "scheduler_job": ModuleMethodContract(family="scheduling"), - "webhook_parser": ModuleMethodContract(family="integration"), + "recognize_media": ModuleMethodContract( + family="media-recognition", input_contract="MediaRecognitionRequest", + result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, + ), + "search_medias": ModuleMethodContract( + family="media-recognition", input_contract="MediaSearchRequest", + result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, + ), + "obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None"), + "media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"), + "mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE), + "mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE), + "download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE), + "get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"), + "scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"), + "webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), } _PREFIX_CONTRACTS = ( @@ -80,3 +118,29 @@ def get_module_method_contract(method: str) -> ModuleMethodContract: def is_explicit_module_method(method: str) -> bool: """判断方法是否已进入首批显式能力族清单。""" return get_module_method_contract(method) is not _DEFAULT_CONTRACT + + +def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple[str, ...]: + """诊断显式能力的基础签名;兼容阶段只返回问题,不拒绝 provider。""" + contract = get_module_method_contract(method) + if contract is _DEFAULT_CONTRACT: + return () + try: + parameters = inspect.signature(callback).parameters + except (TypeError, ValueError): + return ("signature-unavailable",) + missing = tuple( + name + for name in contract.required_parameters + if name not in parameters + and not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters.values() + ) + ) + return tuple(f"missing-parameter:{name}" for name in missing) + + +def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]: + """返回显式方法清单的副本,供架构基线和 SDK 文档使用。""" + return dict(_METHOD_CONTRACTS) diff --git a/app/runtime/extensions/module/dispatcher.py b/app/runtime/extensions/module/dispatcher.py index b1aff963c..ce6c1a2f6 100644 --- a/app/runtime/extensions/module/dispatcher.py +++ b/app/runtime/extensions/module/dispatcher.py @@ -9,7 +9,10 @@ from typing import Any, Protocol from app.foundation.reflection import ObjectUtils from app.runtime.execution import run_in_threadpool from app.runtime.log import logger -from app.runtime.extensions.module.contracts import get_module_method_contract +from app.runtime.extensions.module.contracts import ( + diagnose_module_callable, + get_module_method_contract, +) from app.schemas.exception import RateLimitExceededException @@ -108,6 +111,7 @@ class ModuleInvocationDispatcher: func = module_dict.get(method) if not func: continue + self._diagnose_callable(method, func, f"插件 {plugin_id}") logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): result = func(*args, **kwargs) @@ -154,6 +158,7 @@ class ModuleInvocationDispatcher: func = module_dict.get(method) if not func: continue + self._diagnose_callable(method, func, f"插件 {plugin_id}") logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): result = await self._async_call(func, *args, **kwargs) @@ -199,6 +204,7 @@ class ModuleInvocationDispatcher: module_name = self._module_name(module, module_id) try: func = getattr(module, method) + self._diagnose_callable(method, func, f"宿主模块 {module_id}") if self.is_valid_empty(result): result = func(*args, **kwargs) elif ObjectUtils.check_signature(func, result): @@ -245,6 +251,7 @@ class ModuleInvocationDispatcher: module_name = self._module_name(module, module_id) try: func = getattr(module, method) + self._diagnose_callable(method, func, f"宿主模块 {module_id}") if self.is_valid_empty(result): result = await self._async_call(func, *args, **kwargs) elif ObjectUtils.check_signature(func, result): @@ -273,6 +280,22 @@ class ModuleInvocationDispatcher: ) return result + @staticmethod + def _diagnose_callable( + method: str, + callback: Callable[..., Any], + owner: str, + ) -> None: + """记录 Contract V2 签名偏差,兼容阶段不阻断旧插件执行。""" + problems = diagnose_module_callable(method, callback) + if problems: + logger.warning( + "%s 的模块方法 %s 与契约不一致:%s;当前仅诊断", + owner, + method, + ", ".join(problems), + ) + async def _async_call( self, func: Callable[..., Any], diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index f484fa213..087b14c83 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -553,6 +553,17 @@ ModuleMethodSpec( **量化目标**:legacy 方法数从 96 开始只降不升;新增宿主调用必须先有显式 spec。 +**实施记录(2026-08-21)**: + +- `ModuleMethodContract` 已升级为 V2,显式记录 version、input/result contract、aggregation、 + execution、timeout、error、plugin visibility 与基础签名要求;首批 22 个识别、搜索、媒体服务器、 + 存储、消息和调度/集成能力完成登记。 +- `run_module()` 与插件优先、短路、列表顺序合并、空值和异常隔离算法保持不变。Dispatcher 在真实 + provider 调用边界执行基础签名诊断;旧插件不匹配只写可读 warning,不拒绝加载或执行,未知自定义 + 方法继续使用 legacy contract。 +- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查; + `ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。 + #### ARCH-241:Event Contract Registry **目标**:为每个 EventType/ChainEventType 明确 payload、可见范围、投递和可靠性,不改变旧装饰器 API。 diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 9ab4b4960..93e38786e 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -801,12 +801,29 @@ def collect_runtime_baseline() -> dict[str, Any]: return { "schema_version": 2, "run_module": collect_run_module_contracts(), + "module_method_specs": collect_module_method_specs(), "events": collect_event_contracts(), "sdk_exports": collect_sdk_exports(), "compat_manifest": collect_compat_manifest(), } +def collect_module_method_specs() -> dict[str, Any]: + """加载无运行资源副作用的 Module Contract V2 清单。""" + path = APP_ROOT / "runtime" / "extensions" / "module" / "contracts.py" + spec = importlib.util.spec_from_file_location("architecture_module_contracts", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"无法加载模块契约清单:{path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + contracts = module.list_explicit_module_contracts() + return json_compatible(contracts) + finally: + sys.modules.pop(spec.name, None) + + def collect_runtime_diagnostics() -> dict[str, Any]: """生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。""" return { diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index a32a523fc..df4b8cb63 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -1766,6 +1766,338 @@ }, "producer_count": 66 }, + "module_method_specs": { + "download_file": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageDownloadRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "FileItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "finalize_message": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "messaging", + "input_contract": "MessageFinalizeRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "Message | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "get_file_item": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageItemRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "FileItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "get_folder": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageFolderRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "FileItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "get_parent_item": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageParentRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "FileItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "list_files": { + "aggregation": "ordered_list_merge", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageListRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "list[FileItem]", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "media_category": { + "aggregation": "legacy", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-recognition", + "input_contract": "MediaCategoryRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "CategoryConfig | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "mediaserver_iteminfo": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-server", + "input_contract": "MediaServerItemRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "MediaServerItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "mediaserver_items": { + "aggregation": "ordered_list_merge", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-server", + "input_contract": "MediaServerItemsRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "list[MediaServerItem]", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "mediaserver_play_url": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-server", + "input_contract": "MediaServerPlayRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "str | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "mediaserver_tv_episodes": { + "aggregation": "ordered_list_merge", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-server", + "input_contract": "MediaServerEpisodesRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "list[MediaServerPlayItem]", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "obtain_images": { + "aggregation": "legacy", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-recognition", + "input_contract": "MediaInfo", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "MediaInfo | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "recognize_media": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-recognition", + "input_contract": "MediaRecognitionRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "MediaInfo | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "register_commands": { + "aggregation": "legacy", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "messaging", + "input_contract": "CommandRegistrationRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "rename_file": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageRenameRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "bool | FileItem", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "scheduler_job": { + "aggregation": "legacy", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "scheduling", + "input_contract": "SchedulerJobRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "search_medias": { + "aggregation": "ordered_list_merge", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "media-recognition", + "input_contract": "MediaSearchRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "list[MediaInfo]", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "send_message": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "messaging", + "input_contract": "MessageSendRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "Message | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "snapshot_storage": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageSnapshotRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "dict[str, dict] | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "storage_manage": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageManageRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "Any", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "upload_file": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "storage", + "input_contract": "StorageUploadRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "FileItem | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, + "webhook_parser": { + "aggregation": "first_non_empty", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "integration", + "input_contract": "WebhookRequest", + "plugin_short_circuit": true, + "public_to_plugins": true, + "required_parameters": [], + "result_contract": "WebhookEventInfo | None", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + } + }, "run_module": { "call_count": 260, "dynamic_call_count": 0, diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index 8e9a41103..0da6de09a 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -4,9 +4,13 @@ import json from pathlib import Path from app.runtime.extensions.module.contracts import ( + ModuleErrorPolicy, + ModuleExecutionMode, ModuleResultAggregation, + diagnose_module_callable, get_module_method_contract, is_explicit_module_method, + list_explicit_module_contracts, ) @@ -23,7 +27,7 @@ def test_all_scanned_module_methods_resolve_a_contract() -> None: assert methods for method in methods: contract = get_module_method_contract(method) - assert contract.aggregation is ModuleResultAggregation.LEGACY + assert isinstance(contract.aggregation, ModuleResultAggregation) assert contract.plugin_short_circuit is True @@ -53,3 +57,38 @@ def test_unknown_plugin_method_keeps_legacy_compatibility() -> None: assert contract.family == "legacy" assert contract.supports_sync is True assert contract.supports_async is True + + +def test_contract_v2_freezes_at_least_twenty_high_value_methods() -> None: + """首批能力必须具备可生成文档和诊断的完整 V2 字段。""" + contracts = list_explicit_module_contracts() + + assert len(contracts) >= 20 + for contract in contracts.values(): + assert contract.version == 1 + assert contract.input_contract != "legacy_args" + assert contract.result_contract + assert contract.execution is ModuleExecutionMode.SYNC_OR_ASYNC + assert contract.timeout_policy == "caller_budget" + assert contract.error_policy is ModuleErrorPolicy.ISOLATE_PROVIDER + assert contract.public_to_plugins is True + + +def test_signature_diagnostics_do_not_reject_legacy_callable() -> None: + """无法检查的旧插件 callable 只产生诊断,仍由 dispatcher 决定是否执行。""" + class _OpaqueCallable: + """模拟 inspect 无法解析签名的第三方 callable。""" + + @property + def __signature__(self): + """模拟扩展对象不提供 Python signature。""" + raise ValueError("opaque") + + def __call__(self): + """保留可调用行为。""" + return "ok" + + assert diagnose_module_callable("recognize_media", _OpaqueCallable()) == ( + "signature-unavailable", + ) + assert _OpaqueCallable()() == "ok"