""" 验证模块归属、兼容导入路径及分层依赖的架构约束。 """ import ast import json from functools import lru_cache from pathlib import Path from app.runtime.compat.manifest import MODULE_ALIASES, SYMBOL_ALIASES from scripts.architecture.baseline import ( discover_modules as _discover_modules, ) from scripts.architecture.baseline import ( resolve_imports as _resolve_imports, ) from scripts.architecture.baseline import ( strongly_connected_components as _strongly_connected_components, ) from tests.architecture_cache import ( current_event_facts as _collect_current_event_facts, ) from tests.architecture_cache import parse_module as _parse_source from tests.architecture_cache import walk_module as _walk_module PROJECT_ROOT = Path(__file__).parents[1] APP_ROOT = PROJECT_ROOT / "app" DEPENDENCY_POLICY_PATH = PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json" LEGACY_ROOTS = ("app.core", "app.helper", "app.utils") LEGACY_MODULES = {"app.log"} IMPLEMENTATION_ROOTS = ( "app.agent.skills", "app.adapters", "app.application", "app.db.adapters", "app.domain", "app.foundation", "app.runtime", ) CYCLE_ROOTS = (*IMPLEMENTATION_ROOTS, "app.runtime.compat", "app.sdk") RETIRED_CANONICAL_ROOTS = ( "compat", "extensions", "infrastructure", "integrations", "messaging", "platform", "security", "services", ) RETIRED_CANONICAL_FILES = ( "app/infrastructure/package_installer.py", "app/infrastructure/resource_updater.py", "app/infrastructure/rust_accel.py", "app/messaging/agent_bridge.py", "app/platform/config_reload.py", "app/platform/rate_limit.py", "app/platform/thread_pool.py", "app/services/filter_rules.py", "app/services/transfer_history.py", "app/extensions/module_loader.py", "app/extensions/plugin_market.py", "app/extensions/plugin_repository.py", "app/infrastructure/http.py", "app/integrations/rss.py", "app/security/two_factor.py", "app/infrastructure/gc.py", "app/infrastructure/web.py", "app/security/url_safety.py", "app/domain/mediaserver.py", "app/domain/nfo.py", "app/domain/string.py", "app/log.py", "app/foundation/diagnostics.py", "app/infrastructure/log.py", "app/startup/diagnostics_initializer.py", "app/startup/log_initializer.py", "app/messaging/notification.py", "app/messaging/webpush.py", "app/foundation/jieba.py", "app/foundation/module.py", "app/foundation/object.py", "app/foundation/structures.py", "app/foundation/zhconv.py", "app/runtime/runtime.py", "app/runtime/dependencies.py", "app/runtime/native_dependencies.py", "app/agent/runtime_loader.py", "app/agent/llm/server_tools.py", "app/agent/middleware/patch_tool_calls.py", "app/agent/middleware/runtime_config.py", "app/agent/middleware/tool_selection.py", "app/agent/policy/secret_fields.py", "app/agent/prompt/transfer_redo.py", "app/api/openai_utils.py", "app/api/router_specs.py", "app/adapters/external/wechat_crypt.py", "app/modules/_base/media_auxiliary.py", "app/modules/indexer/parser/ipt_project.py", "app/modules/musicbrainz/music_cache.py", "app/modules/themoviedb/tmdb_cache.py", "app/modules/thetvdb/tvdb_v4_official.py", "app/runtime/compat/resource_imports.py", "app/runtime/extensions/host_module_adapter.py", "app/runtime/extensions/module_manager.py", "app/runtime/extensions/plugin_manager.py", "app/runtime/extensions/service_config.py", "app/testing/network_guard.py", "app/chain/media.py", "app/adapters/network/rss.py", "app/adapters/network/sites.pyi", "app/application/plugins.py", "app/application/subscribe.py", "app/application/torrent.py", "app/application/torrent_cache.py", "app/application/chain/durable_events.py", "app/application/transfer.py", "app/application/transfer_execution.py", "app/db/adapters/transfer.py", "app/db/adapters/transfer_execution.py", "app/runtime/extensions/managed_resource_adapter.py", "app/runtime/managed_resources.py", "app/startup/agent_initializer.py", "app/startup/cache_initializer.py", "app/startup/chain_events.py", "app/startup/command_initializer.py", "app/startup/configuration.py", "app/startup/context.py", "app/startup/database.py", "app/startup/database_initializer.py", "app/startup/domain_initializer.py", "app/startup/download_failure.py", "app/startup/managed_resources_initializer.py", "app/startup/initializers/managed_resources.py", "app/startup/modules_initializer.py", "app/startup/monitor_initializer.py", "app/startup/outbox.py", "app/startup/plugins_initializer.py", "app/startup/routers_initializer.py", "app/startup/scheduler_initializer.py", "app/startup/site.py", "app/startup/subscription.py", "app/startup/transaction.py", "app/startup/transfer_initializer.py", "app/startup/workflow.py", "app/startup/workflow_initializer.py", ) HOST_MODULE_PACKAGE_EXPORTS = { "filemanager": {"FileManagerModule"}, "qqbot": {"QQBotModule"}, "telegram": {"TelegramModule"}, "trimemedia": {"TrimeMediaModule"}, "ugreen": {"UgreenModule"}, } PLUGIN_COMPONENT_ROOTS = ( "app/adapters/external/plugin", "app/adapters/system/plugin", "app/application/plugin", "app/runtime/extensions/plugin", ) PLUGIN_LEGACY_ABI_NAMES = { "MoviePilotServerHelper", "PluginHelper", "PluginManager", } FORBIDDEN_IMPORT_PREFIXES = { "app.foundation": ( "app.adapters", "app.application", "app.db", "app.domain", "app.runtime", "app.sdk", ), "app.domain": ( "app.adapters", "app.application", "app.db", "app.runtime", "app.sdk", ), "app.adapters": ( "app.application", "app.runtime.compat", "app.runtime.extensions", "app.sdk", ), "app.runtime": ( "app.adapters", "app.application", "app.sdk", ), "app.application": ( "app.runtime.extensions", "app.runtime.compat", "app.sdk", ), "app.api": ( "app.runtime.extensions.plugin.manager", "app.runtime.extensions.module.manager", "app.scheduler", ), "app.agent": ( "app.runtime.extensions.plugin.manager", "app.runtime.extensions.module.manager", ), "app.chain": ( "app.runtime.extensions.plugin.manager", "app.runtime.extensions.module.manager", "app.runtime.extensions.module.dispatcher", ), "app.workflow": ( "app.runtime.extensions.plugin.manager", "app.runtime.extensions.module.manager", ), } def _legacy_imports(path: Path) -> set[str]: """ 提取源码中的静态和常量动态旧路径导入。 """ tree = _parse_source(path) imports: set[str] = set() for node in _walk_module(tree): candidates: list[str] = [] if isinstance(node, ast.Import): candidates.extend(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module: candidates.append(node.module) elif isinstance(node, ast.Call) and node.args: argument = node.args[0] if isinstance(argument, ast.Constant) and isinstance(argument.value, str): candidates.append(argument.value) imports.update( candidate for candidate in candidates if candidate in LEGACY_MODULES or candidate.startswith(LEGACY_ROOTS) ) return imports def _attribute_parts(node: ast.Attribute) -> list[str]: """ 将静态属性访问还原为从根名称开始的完整路径片段。 """ parts = [node.attr] value = node.value while isinstance(value, ast.Attribute): parts.append(value.attr) value = value.value if not isinstance(value, ast.Name): return [] parts.append(value.id) return list(reversed(parts)) def _compat_symbol_references(tree: ast.AST) -> set[tuple[int, str]]: """ 收集显式导入或静态属性访问命中的兼容符号。 """ compatibility_symbols = { (module_name, symbol_name) for module_name, symbols in SYMBOL_ALIASES.items() for symbol_name in symbols } module_bindings: dict[str, str] = {} references: set[tuple[int, str]] = set() for node in _walk_module(tree): if isinstance(node, ast.Import): for alias in node.names: binding = alias.asname or alias.name.split(".", maxsplit=1)[0] module_bindings[binding] = alias.name if alias.asname else binding elif isinstance(node, ast.ImportFrom) and node.module: for alias in node.names: if (node.module, alias.name) in compatibility_symbols: references.add((node.lineno, f"{node.module}.{alias.name}")) continue binding = alias.asname or alias.name module_bindings[binding] = f"{node.module}.{alias.name}" for node in _walk_module(tree): if not isinstance(node, ast.Attribute): continue parts = _attribute_parts(node) if len(parts) < 2: continue resolved_root = module_bindings.get(parts[0], parts[0]) resolved = [*resolved_root.split("."), *parts[1:]] module_name = ".".join(resolved[:-1]) symbol_name = resolved[-1] if (module_name, symbol_name) in compatibility_symbols: references.add((node.lineno, f"{module_name}.{symbol_name}")) return references def _class_annotations(path: Path, class_name: str) -> dict[str, str]: """ 返回指定类的源码级字段注解。 """ tree = _parse_source(path) class_node = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name) return { node.target.id: ast.unparse(node.annotation) for node in class_node.body if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) } def test_legacy_roots_contain_no_python_sources(): """ 旧目录只能作为运行时虚拟包存在,仓库中不得重新出现源码。 """ leftovers = sorted( str(path.relative_to(PROJECT_ROOT)) for root_name in ("core", "helper", "utils") for path in (APP_ROOT / root_name).rglob("*.py") ) assert leftovers == [] def test_legacy_source_directories_do_not_exist(): """ core/helper/utils 物理目录应完全退役,旧导入只由虚拟兼容包解析。 """ leftovers = [root_name for root_name in ("core", "helper", "utils") if (APP_ROOT / root_name).exists()] assert leftovers == [] def test_retired_canonical_filenames_do_not_return(): """ 能力包应使用包内语境明确的短文件名,避免再次出现冗余角色后缀。 """ leftovers = [relative_path for relative_path in RETIRED_CANONICAL_FILES if (PROJECT_ROOT / relative_path).exists()] assert leftovers == [] def test_agent_manager_uses_focused_owner_modules_and_precise_facade(): """ AgentManager 必须保持薄门面,编排器不得重新聚合 Manager 实现。 """ owner_paths = { "manager": APP_ROOT / "agent" / "manager.py", "session": APP_ROOT / "agent" / "session.py", "lifecycle": APP_ROOT / "agent" / "lifecycle.py", "tasks": APP_ROOT / "agent" / "tasks.py", "orchestrator": APP_ROOT / "agent" / "orchestrator.py", } assert all(path.is_file() for path in owner_paths.values()) manager_tree = ast.parse(owner_paths["manager"].read_text(encoding="utf-8-sig")) manager_class = next( node for node in manager_tree.body if isinstance(node, ast.ClassDef) and node.name == "AgentManager" ) manager_methods = { node.name for node in manager_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } assert manager_methods == {"__init__"} assert [ast.unparse(base) for base in manager_class.bases] == ["AgentTaskOwner"] orchestrator_tree = ast.parse(owner_paths["orchestrator"].read_text(encoding="utf-8-sig")) orchestrator_classes = {node.name for node in orchestrator_tree.body if isinstance(node, ast.ClassDef)} orchestrator_assignments = { target.id for node in orchestrator_tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) } assert "AgentManager" not in orchestrator_classes assert "_MessageTask" not in orchestrator_classes assert "agent_manager" not in orchestrator_assignments startup_source = (APP_ROOT / "startup" / "initializers" / "agent.py").read_text(encoding="utf-8-sig") assert "from app.agent.manager import AgentManager" in startup_source assert "from app.agent.orchestrator import AgentManager" not in startup_source def test_agent_graph_resources_have_one_owner() -> None: """ 图资源只归 graph 所有,执行对象不得重新建立平行资源集合或私有兼容导出。 """ graph_path = APP_ROOT / "agent" / "graph.py" assert graph_path.is_file() graph_tree = _parse_source(graph_path) assert {node.name for node in graph_tree.body if isinstance(node, ast.ClassDef)} == { "GraphResources", "CompiledAgentBundle", "AgentGraphCache", } orchestrator_path = APP_ROOT / "agent" / "orchestrator.py" orchestrator = orchestrator_path.read_text(encoding="utf-8") for retired in ("_compiled_agent_bundle", "_retired_llm_models", "self._subagent_middlewares", "def _cache_agent", "def _release_llm_models", "class _CompiledAgentBundle"): assert retired not in orchestrator assert "from app.agent.graph import AgentGraphCache, CompiledAgentBundle" in orchestrator assert not any( isinstance(node, ast.ImportFrom) and node.module == "app.agent.orchestrator" for node in ast.walk(graph_tree) ) assert "app/agent/graph.py" in (PROJECT_ROOT / "docs/rules/05-architecture.md").read_text(encoding="utf-8") def test_mcp_schema_projection_has_one_pure_owner() -> None: """ MCP schema 投影使用单词 owner,且不引入运行时或端点依赖。 """ owner = APP_ROOT / "agent" / "tools" / "projection.py" assert owner.is_file() assert not (owner.parent / "schema_budget.py").exists() imports = [ node.module or "" for node in ast.walk(_parse_source(owner)) if isinstance(node, ast.ImportFrom) ] assert not any(name == "app" or name.startswith("app.") for name in imports) def test_runtime_dependencies_use_same_named_single_word_package() -> None: """ 运行依赖能力必须使用同名包,且宿主直接依赖单一职责子模块。 """ package = APP_ROOT / "runtime" / "dependencies" assert {path.name for path in package.glob("*.py")} == { "__init__.py", "native.py", "profile.py", } init_tree = ast.parse( (package / "__init__.py").read_text(encoding="utf-8"), filename=str(package / "__init__.py"), ) assert ast.get_docstring(init_tree) assert len(init_tree.body) == 1 violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins": continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app.runtime.dependencies": violations.append(f"{relative}:{node.lineno}:from-package-root") elif isinstance(node, ast.Import): violations.extend( f"{relative}:{node.lineno}:package-root" for alias in node.names if alias.name == "app.runtime.dependencies" ) assert violations == [] def test_domain_classification_is_a_pure_direct_import_package() -> None: """ 分类领域包根仅承载文档,且实现不得依赖配置、持久化或具体来源。 """ package = APP_ROOT / "domain" / "classification" assert {path.name for path in package.glob("*.py")} == { "__init__.py", "conditions.py", "evaluator.py", "facts.py", "fields.py", "sources.py", "validation.py", "vocabulary.py", } init_path = package / "__init__.py" init_tree = ast.parse(init_path.read_text(encoding="utf-8"), filename=str(init_path)) assert ast.get_docstring(init_tree) assert len(init_tree.body) == 1 forbidden_prefixes = ( "app.adapters", "app.application", "app.core.config", "app.db", "app.modules", "app.runtime", "app.sdk", ) violations: list[str] = [] for path in package.glob("*.py"): tree = _parse_source(path) for node in _walk_module(tree): imported_modules: list[str] = [] if isinstance(node, ast.ImportFrom) and node.module: imported_modules.append(node.module) elif isinstance(node, ast.Import): imported_modules.extend(alias.name for alias in node.names) violations.extend( f"{path.name}:{node.lineno}:{module}" for module in imported_modules if module.startswith(forbidden_prefixes) ) assert violations == [] root_imports: list[str] = [] for path in APP_ROOT.rglob("*.py"): if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app.domain.classification": root_imports.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}") elif isinstance(node, ast.Import): root_imports.extend( f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}" for alias in node.names if alias.name == "app.domain.classification" ) assert root_imports == [] def test_application_classification_uses_same_named_package() -> None: """ 分类应用能力必须归入同名包,包根不得重复导出内部实现。 """ package = APP_ROOT / "application" / "classification" assert {path.name for path in package.glob("*.py")} == { "__init__.py", "analysis.py", "catalog.py", "configuration.py", "contract.py", "enrichment.py", "execution.py", "legacy.py", "migration.py", "compiler.py", "projection.py", "reference.py", "runtime.py", } init_path = package / "__init__.py" init_tree = ast.parse(init_path.read_text(encoding="utf-8"), filename=str(init_path)) assert ast.get_docstring(init_tree) assert len(init_tree.body) == 1 def test_site_application_owners_use_single_word_module_names() -> None: """ 站点流控等用例必须留在站点包中,新增子模块保持单词命名。 """ package = APP_ROOT / "application" / "site" assert (package / "throttle.py").is_file() assert all(path.stem.isalpha() for path in package.glob("*.py") if path.stem != "__init__") assert not (APP_ROOT / "application" / "site_throttle.py").exists() def test_application_torrent_uses_same_named_single_word_package() -> None: """ 种子应用能力必须归入同名包,包根不得重复导出内部实现。 """ package = APP_ROOT / "application" / "torrent" assert {path.name for path in package.glob("*.py")} == { "__init__.py", "cache.py", "download.py", } init_tree = ast.parse( (package / "__init__.py").read_text(encoding="utf-8"), filename=str(package / "__init__.py"), ) assert ast.get_docstring(init_tree) assert len(init_tree.body) == 1 root_imports: list[str] = [] for root in (APP_ROOT, PROJECT_ROOT / "database"): for path in root.rglob("*.py"): if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app.application.torrent": root_imports.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}") assert root_imports == [] def test_host_module_package_roots_only_export_capability_entrypoints() -> None: """ 宿主模块包根只公开 capability entrypoint,内部 owner 必须直达子模块。 """ base_path = APP_ROOT / "modules" / "_base" / "__init__.py" base_tree = ast.parse(base_path.read_text(encoding="utf-8"), filename=str(base_path)) assert ast.get_docstring(base_tree) assert len(base_tree.body) == 1 root_imports: list[str] = [] for path in (APP_ROOT / "modules").rglob("*.py"): if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app.modules._base": root_imports.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}") assert root_imports == [] for package_name, expected_exports in HOST_MODULE_PACKAGE_EXPORTS.items(): path = APP_ROOT / "modules" / package_name / "__init__.py" tree = _parse_source(path) assignments = { target.id: ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) and target.id in {"_EXPORTS", "__all__"} } assert set(assignments["_EXPORTS"]) == expected_exports assert set(assignments["__all__"]) == expected_exports def test_domain_media_projection_uses_single_word_owner_package() -> None: """ 媒体来源投影必须由单词 owner 完整承接,canonical 模型只保留薄委托。 """ package = APP_ROOT / "domain" / "projection" assert {path.name for path in package.glob("*.py")} == { "__init__.py", "anilist.py", "bangumi.py", "douban.py", "mapping.py", "tmdb.py", } init_tree = ast.parse( (package / "__init__.py").read_text(encoding="utf-8"), filename=str(package / "__init__.py"), ) assert ast.get_docstring(init_tree) assert len(init_tree.body) == 1 context_path = APP_ROOT / "domain" / "context.py" context_tree = ast.parse( context_path.read_text(encoding="utf-8-sig"), filename=str(context_path), ) media_class = next( node for node in context_tree.body if isinstance(node, ast.ClassDef) and node.name == "MediaInfo" ) methods = { node.name: node for node in media_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } for name in ( "set_tmdb_info", "set_douban_info", "set_bangumi_info", "set_anilist_info", ): method = methods[name] executable = [ node for node in method.body if not ( isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) ) ] assert len(executable) == 1 assert isinstance(executable[0], ast.Expr) assert isinstance(executable[0].value, ast.Call) schema_source = (APP_ROOT / "schemas" / "context.py").read_text(encoding="utf-8-sig") assert "app.domain.projection" not in schema_source assert not any(name in schema_source for name in methods if name.startswith("set_") and name.endswith("_info")) forbidden_calls: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins" or path == context_path: continue tree = _parse_source(path) for node in _walk_module(tree): if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): continue if node.value.id == "MediaInfo" and node.attr in { "get_bangumi_media_type", "get_anilist_media_type", "_anilist_date", "_anilist_chinese_title", }: forbidden_calls.append(f"{relative}:{node.lineno}:{node.attr}") assert forbidden_calls == [] def test_workflow_query_contract_returns_only_typed_snapshots(): """ 工作流正式查询端口不得退化为 Any 或 ORM 返回值。 """ path = APP_ROOT / "application" / "workflow.py" tree = _parse_source(path) query_classes = { node.name: node for node in tree.body if isinstance(node, ast.ClassDef) and node.name in {"WorkflowQueryRepository", "WorkflowQueryService"} } methods = [ node for query_class in query_classes.values() for node in query_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("__") and node.returns is not None ] assert set(query_classes) == {"WorkflowQueryRepository", "WorkflowQueryService"} assert methods for method in methods: annotation = ast.unparse(method.returns) assert "Any" not in annotation if method.name in {"count", "async_count"}: assert annotation == "int" else: assert "WorkflowSnapshot" in annotation def test_workflow_query_consumers_do_not_reach_raw_oper(): """ API、Agent、共享服务和运行时管理器只消费统一快照查询服务。 """ consumer_paths = ( "app/api/dependencies/workflow.py", "app/application/server/share.py", "app/workflow/__init__.py", ) violations = {} for relative_path in consumer_paths: source = (PROJECT_ROOT / relative_path).read_text(encoding="utf-8") forbidden = { name for name in ( "WorkflowOper", "get_agent_workflow_port", "get_chain_workflow_port", ) if name in source } if forbidden: violations[relative_path] = sorted(forbidden) assert violations == {} def test_workflow_query_adapter_owns_projection_sessions(): """ 唯一查询适配器必须在自有同步和异步 Session 内投影快照。 """ path = APP_ROOT / "db" / "adapters" / "workflow.py" source = path.read_text(encoding="utf-8") assert "class TransactionalWorkflowQueryRepository" in source assert "session.close()" in source assert "async with self._async_session() as session" in source assert "_project_workflow(record)" in source def test_workflow_execution_chain_uses_single_application_owned_port(): """ 工作流 Chain 写端必须只使用 Application owner 的唯一配置入口。 """ contract_path = APP_ROOT / "application" / "workflow.py" contract_tree = ast.parse( contract_path.read_text(encoding="utf-8"), filename=str(contract_path), ) contract = next( node for node in contract_tree.body if isinstance(node, ast.ClassDef) and node.name == "WorkflowExecutionPort" ) methods = { node.name: ast.unparse(node.returns) for node in contract.body if isinstance(node, ast.FunctionDef) and node.returns is not None } assert methods == { "start": "bool", "success": "bool", "fail": "bool", "step": "bool", "reset": "bool", } chain_source = (APP_ROOT / "chain" / "workflow.py").read_text(encoding="utf-8") startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8") assert chain_source.count("get_configured_workflow_execution()") == 1 assert "get_chain_workflow_port" not in chain_source assert "configure_workflow_execution(TransactionalWorkflowExecutionService(SessionFactory))" in composition_source assert "configure_workflow_execution_composition()" in startup_source assert "workflow=lambda:" not in startup_source def test_chain_runtime_context_owns_typed_repository_instances(): """ Chain 数据能力必须作为明确类型的实例字段进入运行时上下文。 """ annotations = _class_annotations( APP_ROOT / "application" / "chain" / "context.py", "ChainRuntimeContext", ) expected = { "site_repository": "SiteRepository", "subscription_repository": "SubscriptionRepository", "download_history_repository": "DownloadHistoryRepository", "transfer_history_repository": "TransferHistoryRepository", "transfer_admission_repository": "TransferAdmissionRepository", "transfer_execution_repository": "TransferExecutionRepository", "media_server_repository": "MediaServerRepository", "download_failure_repository": "DownloadFailureRepository", "user_repository": "ChainUserRepository", } assert {name: annotations[name] for name in expected} == expected chain_base_source = (APP_ROOT / "chain" / "base.py").read_text(encoding="utf-8-sig") for field_name in expected: assert f"self.{field_name} = context.{field_name}" in chain_base_source download_source = "\n".join( path.read_text(encoding="utf-8") for path in sorted((APP_ROOT / "chain" / "download").glob("*.py")) ) mediaserver_source = (APP_ROOT / "chain" / "mediaserver.py").read_text(encoding="utf-8") application_source = (APP_ROOT / "application" / "mediaserver.py").read_text(encoding="utf-8") startup_source = (APP_ROOT / "startup" / "composition" / "chain.py").read_text(encoding="utf-8") assert "DownloadFailure = Any" not in download_source assert "dboper" not in mediaserver_source assert "async def async_get_item_id(" in application_source assert "TransactionalMediaServerRepository(SessionFactory)" in startup_source def test_startup_composes_typed_chain_and_agent_data_contexts(): """ 组合根必须一次性构造 Chain 与 Agent 的类型化数据上下文。 """ context_specs = { "ChainRuntimeContext": ( APP_ROOT / "application" / "chain" / "context.py", APP_ROOT / "startup" / "composition" / "chain.py", { "site_repository", "subscription_repository", "subscription_mutation_scope", "sync_subscription_mutation_scope", "subscription_delete_scope", "sync_subscription_delete_scope", "subscription_completion_scope", "download_history_repository", "transfer_history_repository", "transfer_admission_repository", "transfer_execution_repository", "media_server_repository", "download_failure_repository", "user_repository", }, ), "AgentDataContext": ( APP_ROOT / "application" / "agent.py", APP_ROOT / "startup" / "composition" / "agent.py", { "chat", "chat_persistence", "tasks", "users", "sites", "subscriptions", "subscription_mutation_scope", "subscription_delete_scope", "subscription_history", "transfer_history", "transfer_execution", "download_history", "plugin_data", }, ), } for class_name, (context_path, composition_path, expected_fields) in context_specs.items(): annotations = _class_annotations(context_path, class_name) assert expected_fields <= annotations.keys() assert all( "Any" not in annotations[field_name] and "Oper" not in annotations[field_name] and "Callable" not in annotations[field_name] for field_name in expected_fields ) composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) constructors = [ node for node in ast.walk(composition_tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == class_name ] assert len(constructors) == 1 values = {keyword.arg: keyword.value for keyword in constructors[0].keywords if keyword.arg is not None} assert expected_fields <= values.keys() assert all(not isinstance(values[field_name], ast.Lambda) for field_name in expected_fields) def test_download_history_ports_are_typed_detached_and_canonically_injected(): """ 下载历史宿主调用面只能消费冻结快照和显式事务 adapter。 """ history_path = APP_ROOT / "application" / "history" / "__init__.py" history_tree = ast.parse( history_path.read_text(encoding="utf-8"), filename=str(history_path), ) classes = {node.name: node for node in history_tree.body if isinstance(node, ast.ClassDef)} for class_name in ( "DownloadHistorySnapshot", "DownloadFileSnapshot", "DownloadHistoryWrite", "DownloadFileWrite", ): decorator = next( item for item in classes[class_name].decorator_list if isinstance(item, ast.Call) and isinstance(item.func, ast.Name) and item.func.id == "dataclass" ) keywords = {item.arg: ast.literal_eval(item.value) for item in decorator.keywords} assert keywords == {"frozen": True, "slots": True} for class_name in ("DownloadHistoryQueryPort", "DownloadHistoryWritePort"): annotations = [ ast.unparse(node.returns) for node in classes[class_name].body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None ] assert annotations assert all("Any" not in annotation for annotation in annotations) chain_annotations = _class_annotations( APP_ROOT / "application" / "chain" / "context.py", "ChainRuntimeContext", ) agent_annotations = _class_annotations( APP_ROOT / "application" / "agent.py", "AgentDataContext", ) assert chain_annotations["download_history_repository"] == "DownloadHistoryRepository" assert agent_annotations["download_history"] == "DownloadHistoryRepository" consumer_paths = ( *sorted((APP_ROOT / "chain" / "download").glob("*.py")), *sorted((APP_ROOT / "chain" / "transfer").glob("*.py")), APP_ROOT / "application" / "transfer" / "workflow.py", ) for path in consumer_paths: source = path.read_text(encoding="utf-8") assert "app.db.oper.downloadhistory" not in source assert "DownloadHistory = Any" not in source assert "DownloadFiles = Any" not in source startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") assert "TransactionalDownloadHistoryRepository(" not in startup_source assert "TransactionalDownloadHistoryRepository(" in runtime_source assert "SessionDownloadHistoryRepository" not in startup_source assert "SessionDownloadHistoryRepository" in runtime_source assert "DownloadHistoryOper" not in startup_source adapter_source = (APP_ROOT / "db" / "adapters" / "history" / "download.py").read_text(encoding="utf-8") assert "class TransactionalDownloadHistoryRepository" in adapter_source assert "class SessionDownloadHistoryRepository" in adapter_source assert "_project_history" in adapter_source assert "SqlAlchemyUnitOfWork" in adapter_source legacy_source = (APP_ROOT / "sdk" / "_legacy" / "transfer.py").read_text(encoding="utf-8") assert "download_history: Optional[Any]" in legacy_source def test_user_configuration_uses_typed_transactional_adapter(): """ 用户配置宿主入口只消费类型化端口,旧 Oper 写入口仅承担兼容 ABI。 """ application_path = APP_ROOT / "application" / "security" / "userconfig.py" application_source = application_path.read_text(encoding="utf-8") application_tree = ast.parse(application_source, filename=str(application_path)) repository = next( node for node in application_tree.body if isinstance(node, ast.ClassDef) and node.name == "UserConfigurationRepository" ) returns = { node.name: ast.unparse(node.returns) for node in repository.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None } assert returns == { "get": "JsonData", "set": "None", "publish_rename": "None", "publish_delete": "None", } assert "Any" not in application_source composition_source = (APP_ROOT / "startup" / "composition" / "configuration.py").read_text(encoding="utf-8") assert "TransactionalUserConfigurationRepository(SessionFactory)" in composition_source assert "UserConfigOper" not in composition_source adapter_path = APP_ROOT / "db" / "adapters" / "configuration.py" adapter_source = adapter_path.read_text(encoding="utf-8") assert "class TransactionalUserConfigurationRepository" in adapter_source assert "SqlAlchemyUnitOfWork" in adapter_source assert "Any" not in adapter_source oper_source = (APP_ROOT / "db" / "oper" / "userconfig.py").read_text(encoding="utf-8") assert "def stage_set(" in oper_source assert "def set(" in oper_source assert "Any" not in oper_source def test_modules_initializer_delegates_configuration_and_database_composition(): """ 模块初始化器只编排配置与数据库组合 API,不再内联其构造细节。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") configuration_source = (APP_ROOT / "startup" / "composition" / "configuration.py").read_text(encoding="utf-8") database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8") runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") for call in ( "start_database_runtime()", "stop_database_runtime", "compose_configuration(", "compose_database_services(", "publish_database_services(", "publish_configuration(", "reset_database_services", "reset_configuration", "configure_database()", ): assert call in initializer_source for detail in ( "DatabaseWorker()", "TransactionalWriteRunner(", "PluginPersistenceService(", "SqlAlchemyDataQueryAdapter(", "RuntimeConfiguration(", "RuntimeSettingsService(", "TransactionalUserConfigurationRepository(", ): assert detail not in initializer_source assert "stop_database_worker" not in initializer_source assert "RuntimeConfiguration(" in configuration_source assert "RuntimeSettingsService(" in configuration_source assert "TransactionalUserConfigurationRepository(" in configuration_source assert "DatabaseWorker()" in database_source assert "TransactionalWriteRunner(" in database_source assert "PluginPersistenceService(" in database_source assert "SqlAlchemyDataQueryAdapter(" in database_source assert "reset_transaction_runners()" in database_source assert "configure_api_data_runtime" not in initializer_source assert "configure_api_data_runtime" not in database_source assert "configure_api_data_runtime" in runtime_source def test_modules_initializer_delegates_network_composition(): """ 模块初始化器只调用网络组合 API,不再内联具体 Adapter 组装。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") network_source = (APP_ROOT / "startup" / "composition" / "network.py").read_text(encoding="utf-8") assert "configure_application_network_ports()" in initializer_source for detail in ( "_NetworkTestTransportAdapter", "_ImageTransportAdapter", "_InternalAddressAdapter", "_MessageIngressAdapter", "NetworkTestService(", "configure_image_ports(", "configure_message_ingress_port(", ): assert detail not in initializer_source assert detail in network_source def test_modules_initializer_delegates_outbox_composition(): """ 模块初始化器只发布 Outbox dispatcher,不再内联 handler 与存储装配。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_source = (APP_ROOT / "startup" / "composition" / "outbox.py").read_text(encoding="utf-8") package_source = (APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8") assert "configure_outbox_dispatcher(build_outbox_dispatcher, submit=ThreadHelper().submit)" in initializer_source for retired_name in ("_build_outbox_handlers", "_build_outbox_dispatcher"): assert retired_name not in initializer_source for detail in ( "def build_outbox_handlers()", "def build_outbox_dispatcher()", "validate_durable_event_handlers(handlers)", "SqlAlchemyOutboxDispatchStore(SessionFactory)", ): assert detail in composition_source assert "build_outbox_handlers" not in package_source assert "build_outbox_dispatcher" not in package_source def test_modules_initializer_delegates_server_service_composition(): """ 中心服务对象图只由单词型 composition owner 构造,initializer 仅保留调用顺序。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_path = APP_ROOT / "startup" / "composition" / "server.py" composition_source = composition_path.read_text(encoding="utf-8") architecture_source = (PROJECT_ROOT / "docs" / "rules" / "05-architecture.md").read_text(encoding="utf-8") package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) assert composition_path.is_file() assert not list(composition_path.parent.glob("server_*.py")) assert "from app.startup.composition.server import configure_server_services" in (initializer_source) assert "configure_server_services(workflow_query, runtime_dependencies.subscription)" in initializer_source assert "`startup/composition/server.py` owns MoviePilot Server" in (architecture_source) for detail in ( "ServerReportService(", "ServerSharingService(", "configure_server_application_services(", ): assert detail not in initializer_source assert detail in composition_source assert not any( isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body ) def test_modules_initializer_delegates_agent_composition(): """ 模块初始化器只编排 Agent composition,不再内联数据与任务构造。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") agent_source = (APP_ROOT / "startup" / "composition" / "agent.py").read_text(encoding="utf-8") runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") assert "compose_agent(" in initializer_source assert "publish_agent_services(" in initializer_source assert initializer_source.index("await start_database_runtime()") < initializer_source.index("compose_agent(") for detail in ( "AgentDataContext(", "AgentChatPersistenceService(", "AgentChatService(", "TransactionalAgentTaskRepository(", "AgentTaskExecutionService(", "configure_agent_chat_service(", "configure_agent_chat_persistence(", "configure_agent_task_execution(", ): assert detail not in initializer_source assert detail in agent_source assert "AgentChatRuntime(" not in initializer_source assert "AgentChatRuntime(" not in agent_source assert "AgentChatRuntime(" in runtime_source assert "runtime.worker.snapshot().capacity" in agent_source assert "startup.initializers" not in agent_source def test_runtime_composition_is_the_sole_host_and_domain_runtime_owner(): """ HostRuntime、领域 Runtime 与 ApiData 投影只能由单词型 runtime owner 构造。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") runtime_path = APP_ROOT / "startup" / "composition" / "runtime.py" runtime_source = runtime_path.read_text(encoding="utf-8") database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8") package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) assert runtime_path.is_file() assert not list(runtime_path.parent.glob("runtime_*.py")) assert "compose_runtime_dependencies()" in initializer_source assert "compose_runtime(" in initializer_source assert "RuntimeInputs(" in initializer_source assert "publish_runtime(runtime_composition)" in initializer_source assert "HostRuntime(" not in initializer_source assert "ApiDataPorts(" not in initializer_source assert "ApiDataPorts(" not in database_source for detail in ( "class RuntimeDependencies:", "class RuntimeInputs:", "class RuntimeComposition:", "HostRuntime(", "AgentChatRuntime(", "AuthenticationRuntime(", "PersistenceRuntime(", "MessagingRuntime(", "HistoryRuntime(", "SiteRuntime(", "SubscriptionRuntime(", "WorkflowRuntime(", "ApiDataPorts(", ): assert detail in runtime_source constructors = [] for path in APP_ROOT.rglob("*.py"): if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) if any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "HostRuntime" for node in _walk_module(tree) ): constructors.append(path.relative_to(APP_ROOT).as_posix()) assert constructors == ["startup/composition/runtime.py"] context_tree = ast.parse((APP_ROOT / "startup" / "composition" / "context.py").read_text(encoding="utf-8")) host_runtime = next( node for node in context_tree.body if isinstance(node, ast.ClassDef) and node.name == "HostRuntime" ) task_field = next( node for node in host_runtime.body if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "tasks" ) assert task_field.value is None assert ( _class_annotations( APP_ROOT / "startup" / "composition" / "context.py", "AuthenticationRuntime", )["passkey_repository"] == "RepositoryFactory" ) assert not any( isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body ) def test_modules_initializer_delegates_chain_composition(): """ Chain 上下文、壁纸与旧整理命令只由单词型 composition owner 装配。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_path = APP_ROOT / "startup" / "composition" / "chain.py" composition_source = composition_path.read_text(encoding="utf-8") composition_tree = ast.parse(composition_source, filename=str(composition_path)) package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) architecture_source = (PROJECT_ROOT / "docs" / "rules" / "05-architecture.md").read_text(encoding="utf-8") runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") assert composition_path.is_file() assert not list(composition_path.parent.glob("chain_*.py")) assert "from app.startup.composition.chain import (" in initializer_source assert initializer_source.index("configure_wallpaper_services()") < ( initializer_source.index("configure_chain_runtime_context(") ) for retired_name in ( "_execute_legacy_transfer_command", "_build_chain_runtime_context", ): assert retired_name not in initializer_source for detail in ( "ChainRuntimeContext(", "TransactionalTransferAdmissionRepository(SessionFactory)", "TransactionalChainDurableEventWriter(SessionFactory)", "def execute_legacy_transfer_command(", "def build_chain_runtime_context(", "def configure_wallpaper_services(", "def configure_chain_runtime_context(", ): assert detail not in initializer_source assert detail in composition_source assert "TransactionalTransferExecutionRepository(" not in initializer_source assert runtime_source.count("TransactionalTransferExecutionRepository(") == 1 assert "dependencies=runtime_dependencies" in initializer_source assert "transfer_execution_repository=dependencies.transfer_execution" in composition_source assert "TransactionalTransferExecutionRepository" not in composition_source transfer_function = next( node for node in composition_tree.body if isinstance(node, ast.FunctionDef) and node.name == "execute_legacy_transfer_command" ) assert any( isinstance(node, ast.ImportFrom) and node.module == "app.chain.transfer.facade" for node in ast.walk(transfer_function) ) assert not any( isinstance(node, ast.ImportFrom) and node.module == "app.chain.transfer.facade" for node in composition_tree.body ) assert "`startup/composition/chain.py` owns" in architecture_source assert not any( isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body ) def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): """ 工作流旧写入口只能存在于 SDK Legacy facade。 """ oper_path = APP_ROOT / "db" / "oper" / "workflow.py" oper_tree = ast.parse( oper_path.read_text(encoding="utf-8"), filename=str(oper_path), ) oper_class = next(node for node in oper_tree.body if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper") method_names = {node.name for node in oper_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} assert {"start", "success", "fail", "step", "reset"}.isdisjoint(method_names) assert "legacy" not in oper_path.read_text(encoding="utf-8").lower() package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text(encoding="utf-8") assert '"WorkflowOper"' not in package_source def test_agent_data_ports_do_not_duplicate_workflow_query_capability(): """ Agent 显式数据上下文不得重复拥有工作流查询能力。 """ annotations = _class_annotations( APP_ROOT / "application" / "agent.py", "AgentDataContext", ) assert "workflow" not in annotations assert "workflow_query" not in annotations def test_host_uses_canonical_workflow_manager_name(): """ 宿主代码不得继续定义或导入旧 WorkFlowManager 拼写。 """ violations = [] for path in APP_ROOT.rglob("*.py"): relative_path = path.relative_to(APP_ROOT) if relative_path.parts[:2] == ("runtime", "compat"): continue if "WorkFlowManager" in path.read_text(encoding="utf-8"): violations.append(str(path.relative_to(PROJECT_ROOT))) assert violations == [] def test_plugin_route_refresh_is_imported_from_its_application_owner(): """ 插件路由刷新只有 Application 一个拥有者,端点之间不得互相取用它。 """ owner = "app.application.plugin.routes" violations: dict[str, str] = {} for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins": continue tree = _parse_source(path) for node in _walk_module(tree): if not isinstance(node, ast.ImportFrom) or node.level: continue if node.module == owner: continue if any(alias.name == "register_plugin_api" for alias in node.names): violations[str(relative)] = node.module or "" assert violations == {} def test_startup_root_contains_only_composition_packages(): """ 组合根顶层只保留稳定分区,禁止再次堆叠扁平实现文件。 """ startup_root = APP_ROOT / "startup" root_modules = sorted(path.name for path in startup_root.glob("*.py")) python_packages = sorted(path.name for path in startup_root.iterdir() if path.is_dir() and any(path.rglob("*.py"))) assert root_modules == ["__init__.py"] assert python_packages == ["composition", "initializers", "lifecycle"] def test_retired_canonical_roots_contain_no_python_sources(): """ 已收敛的新增目录不得再次以顶级 Python 包形式出现。 """ leftovers = sorted( str(path.relative_to(PROJECT_ROOT)) for root_name in RETIRED_CANONICAL_ROOTS for path in (APP_ROOT / root_name).rglob("*.py") ) assert leftovers == [] def test_host_code_does_not_import_legacy_roots(): """ 除插件和兼容层外,宿主代码必须使用 canonical 路径。 """ violations: dict[str, set[str]] = {} for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins" or relative.parts[:2] == ( "runtime", "compat", ): continue imports = _legacy_imports(path) if imports: violations[str(relative)] = imports assert violations == {} def test_compat_symbol_scanner_covers_static_import_shapes() -> None: """ 兼容符号扫描必须覆盖显式导入、模块别名和完整属性链。 """ tree = ast.parse( """ from app.schemas import TransferTask import app.schemas as schema_alias schema_alias.TransferQueue import app.schemas app.schemas.TransferTask from app.application import transfer as transfer_package transfer_package.TransferQueue """ ) assert _compat_symbol_references(tree) == { (2, "app.schemas.TransferTask"), (4, "app.schemas.TransferQueue"), (6, "app.schemas.TransferTask"), (8, "app.application.transfer.TransferQueue"), } def test_host_code_does_not_use_compat_symbol_aliases() -> None: """ 宿主必须导入 canonical 符号,不得反向消费插件兼容覆盖。 """ violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if ( relative.parts[0] == "plugins" or relative.parts[:2] == ("runtime", "compat") or relative.parts[:2] == ("sdk", "_legacy") ): continue tree = _parse_source(path) violations.extend( f"{relative.as_posix()}:{line}:{symbol}" for line, symbol in sorted(_compat_symbol_references(tree)) ) assert violations == [] def test_host_code_uses_explicit_runtime_facade_getters(): """ 宿主消费者必须显式调用 getter,不得把兼容 Facade 当作新代码入口。 """ forbidden_imports = { "app.application.module": {"ModuleManager"}, "app.application.scheduling": {"Scheduler"}, } violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins" or relative.parts[:2] == ( "runtime", "compat", ): continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.Assign) and isinstance(node.value, ast.Name): target_names = {target.id for target in node.targets if isinstance(target, ast.Name)} if "SystemConfigOper" in target_names and node.value.id == "get_configured_system_config": violations.append(f"{relative.as_posix()}:{node.lineno}:SystemConfigOper") continue if not isinstance(node, ast.ImportFrom) or not node.module: continue forbidden_names = forbidden_imports.get(node.module, set()) for alias in node.names: class_shaped_plugin_getter = ( node.module == "app.application.plugin.runtime" and alias.name == "get_plugin_manager" and alias.asname is not None ) class_shaped_config_getter = ( node.module == "app.application.configuration" and alias.name == "get_configured_system_config" and alias.asname is not None ) if alias.name in forbidden_names or class_shaped_plugin_getter or class_shaped_config_getter: imported_name = alias.asname or alias.name violations.append(f"{relative.as_posix()}:{node.lineno}:{imported_name}") assert violations == [] def test_user_chain_and_agent_ports_are_typed_and_orm_free(): """ 用户 Chain、Agent 与宿主模块只能消费 Application 用户端口。 """ chain_annotations = _class_annotations( APP_ROOT / "application" / "chain" / "context.py", "ChainRuntimeContext", ) agent_annotations = _class_annotations( APP_ROOT / "application" / "agent.py", "AgentDataContext", ) assert chain_annotations["user_repository"] == "ChainUserRepository" assert agent_annotations["users"] == "ChainUserRepository" production_paths = [ APP_ROOT / "chain" / "user.py", APP_ROOT / "chain" / "interaction.py", APP_ROOT / "chain" / "_messaging.py", APP_ROOT / "agent" / "orchestrator.py", APP_ROOT / "modules" / "feishu" / "feishu.py", ] violations: list[str] = [] for path in production_paths: tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ClassDef) and node.name == "UserOper": violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:class") if isinstance(node, ast.ImportFrom) and node.module == "app.db.oper.user": violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:import") assert violations == [] def test_startup_injects_user_adapter_instead_of_raw_oper(): """ 启动组合根不得把无会话 UserOper 注入宿主查询调用面。 """ initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8-sig") database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8-sig") chain_source = (APP_ROOT / "startup" / "composition" / "chain.py").read_text(encoding="utf-8-sig") runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8-sig") security_source = (APP_ROOT / "startup" / "composition" / "security.py").read_text(encoding="utf-8-sig") source = initializer_source + database_source + chain_source + runtime_source + security_source assert "TransactionalUserRepository" in source assert "SqlAlchemyUserRepository" in source assert "build_transactional_user_repository" not in initializer_source assert "build_transactional_user_repository" in chain_source assert "from app.db.oper.user import UserOper" not in source assert "user=lambda: UserOper()" not in source def test_transfer_pending_oper_import_is_confined_to_database_boundary(): """ 宿主仅允许事务适配器和兼容导出直接导入整理待处理 Oper。 """ allowed_paths = { "app/db/adapters/transfer/admission.py", "app/db/adapters/chain.py", "app/db/adapters/transfer/execution.py", "app/db/oper/__init__.py", } violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(PROJECT_ROOT).as_posix() if relative.startswith("app/plugins/") or relative in allowed_paths: continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.Import): if any(alias.name == "app.db.oper.transferpending" for alias in node.names): violations.append(f"{relative}:{node.lineno}") elif isinstance(node, ast.ImportFrom) and ( node.module == "app.db.oper.transferpending" or ( node.module == "app.db.oper" and any(alias.name in {"transferpending", "TransferPendingOper"} for alias in node.names) ) ): violations.append(f"{relative}:{node.lineno}") assert violations == [] def test_startup_injects_transactional_transfer_admission_repository(): """ 启动组合根必须向 Chain 注入事务型整理准入仓储。 """ path = APP_ROOT / "startup" / "composition" / "chain.py" tree = _parse_source(path) imports_repository = any( isinstance(node, ast.ImportFrom) and node.module == "app.db.adapters.transfer.admission" and any(alias.name == "TransactionalTransferAdmissionRepository" for alias in node.names) for node in _walk_module(tree) ) chain_context_calls = [ node for node in _walk_module(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "ChainRuntimeContext" ] assert imports_repository is True assert len(chain_context_calls) == 1 keywords = {keyword.arg: keyword.value for keyword in chain_context_calls[0].keywords if keyword.arg is not None} admission = keywords["transfer_admission_repository"] assert isinstance(admission, ast.Call) assert isinstance(admission.func, ast.Name) assert admission.func.id == "TransactionalTransferAdmissionRepository" def test_transfer_admission_chain_context_field_is_typed(): """ 整理准入仓储必须以明确 Protocol 注入 Chain 上下文。 """ annotations = _class_annotations( APP_ROOT / "application" / "chain" / "context.py", "ChainRuntimeContext", ) assert annotations["transfer_admission_repository"] == "TransferAdmissionRepository" def test_scheduler_does_not_depend_on_database_implementation(): """ Scheduler 只能消费应用端口,不得重新直连 app.db 实现。 """ dependencies = set().union( *( module_dependencies for module_name, module_dependencies in _build_module_graph().items() if module_name == "app.scheduler" or module_name.startswith("app.scheduler.") ) ) assert { dependency for dependency in dependencies if dependency == "app.db" or dependency.startswith("app.db.") } == set() def test_monitor_dispatcher_uses_explicit_history_port_getter(): """ 监控分发器不得把兼容 TransferHistoryPort 伪装成数据库 Oper。 """ path = APP_ROOT / "monitor" / "dispatcher.py" tree = _parse_source(path) violations = [ f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}" for node in _walk_module(tree) if isinstance(node, ast.ImportFrom) and node.module == "app.application.history" and any(alias.name == "TransferHistoryPort" for alias in node.names) ] assert violations == [] def test_canonical_service_config_consumers_use_application_directory(): """ Chain、API、Scheduler 与 Agent 不得绕过命名应用目录读取服务配置。 """ paths = [ APP_ROOT / "chain" / "_messaging.py", APP_ROOT / "chain" / "mediaserver.py", APP_ROOT / "api" / "endpoints" / "message.py", *(APP_ROOT / "scheduler").glob("*.py"), APP_ROOT / "agent" / "llm" / "capability.py", APP_ROOT / "agent" / "tools" / "base.py", APP_ROOT / "api" / "endpoints" / "mediaserver.py", ] violations: list[str] = [] for path in paths: tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app.runtime.extensions.service": violations.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}") if ( path.name == "mediaserver.py" and isinstance(node, ast.ImportFrom) and node.module == "app.application.mediaserver" and any(alias.name == "MediaServerHelper" for alias in node.names) ): violations.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}:MediaServerHelper") assert violations == [] def test_plugin_components_do_not_reexport_legacy_abi_names(): """ 插件组件只在 manager owner 定义 PluginManager,不得复制旧 ABI。 """ violations: list[str] = [] manager_path = APP_ROOT / "runtime" / "extensions" / "plugin" / "manager.py" for root in PLUGIN_COMPONENT_ROOTS: for path in (PROJECT_ROOT / root).rglob("*.py"): tree = _parse_source(path) for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if node.name == "__getattr__" or node.name in PLUGIN_LEGACY_ABI_NAMES: violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}") elif isinstance(node, ast.ClassDef): is_canonical_manager = path == manager_path and node.name == "PluginManager" if not is_canonical_manager and ( node.name in PLUGIN_LEGACY_ABI_NAMES or node.name.endswith("Oper") ): violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}") elif isinstance(node, ast.ImportFrom): for alias in node.names: is_legacy_name = alias.name in PLUGIN_LEGACY_ABI_NAMES or alias.name.endswith("Oper") is_private = bool(alias.asname and alias.asname.startswith("_")) if is_legacy_name and not is_private: violations.append(f"{path.relative_to(PROJECT_ROOT)}:{alias.name}") elif isinstance(node, (ast.Assign, ast.AnnAssign)): targets = node.targets if isinstance(node, ast.Assign) else [node.target] names = {target.id for target in targets if isinstance(target, ast.Name)} forbidden = { name for name in names if name == "__all__" or name in PLUGIN_LEGACY_ABI_NAMES or name.endswith("Oper") } violations.extend(f"{path.relative_to(PROJECT_ROOT)}:{name}" for name in sorted(forbidden)) assert violations == [] def test_host_code_uses_precise_schema_modules(): """ 宿主不得重新依赖 schema 聚合入口或星号导出。 """ violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] in {"plugins", "schemas"}: continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module == "app": if any(alias.name == "schemas" for alias in node.names): violations.append(str(relative)) break if isinstance(node, ast.ImportFrom) and node.module == "app.schemas": violations.append(str(relative)) break assert violations == [] def test_database_internals_do_not_import_db_facades(): """ DB 子模块必须依赖具体实现文件,不得回流到包级兼容入口。 """ violations: list[str] = [] for path in (APP_ROOT / "db").rglob("*.py"): if path.name == "__init__.py": continue tree = _parse_source(path) if any( isinstance(node, ast.ImportFrom) and node.module in {"app.db", "app.db.models"} for node in _walk_module(tree) ): violations.append(str(path.relative_to(PROJECT_ROOT))) assert violations == [] def test_database_opers_use_dboper_transaction_dispatchers(): """ Oper 不得绕过 DbOper 的统一 Session 类型分派直接调用事务 runner。 """ runner_names = {"run_sync_transaction", "run_async_transaction"} violations: list[str] = [] for path in (APP_ROOT / "db" / "oper").rglob("*.py"): tree = _parse_source(path) for node in _walk_module(tree): if not isinstance(node, ast.ImportFrom) or node.module != "app.db.uow": continue imported = {alias.name for alias in node.names} & runner_names if imported: relative = path.relative_to(PROJECT_ROOT).as_posix() violations.append(f"{relative}:{node.lineno}:{','.join(sorted(imported))}") assert violations == [] def test_models_and_base_require_explicit_database_sessions(): """ Model/Base 不得装饰事务,且所有 db 参数必须由调用方显式传入。 """ decorator_names = { "db_query", "db_update", "async_db_query", "async_db_update", "legacy_db_query", "legacy_db_update", "legacy_async_db_query", "legacy_async_db_update", } violations: list[str] = [] paths = [APP_ROOT / "db" / "base.py"] paths.extend((APP_ROOT / "db" / "models").rglob("*.py")) for path in paths: tree = _parse_source(path) relative = str(path.relative_to(PROJECT_ROOT)) nodes = list(_walk_module(tree)) for node in nodes: if isinstance(node, ast.ImportFrom) and node.module == "app.db.decorators": violations.append(f"{relative}:{node.lineno}:decorator-import") if path.name == "base.py": base_class = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Base") nodes = list(ast.walk(base_class)) for node in nodes: if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue for decorator in node.decorator_list: name = ( decorator.id if isinstance(decorator, ast.Name) else decorator.attr if isinstance(decorator, ast.Attribute) else None ) if name in decorator_names: violations.append(f"{relative}:{node.lineno}:@{name}") arguments = [*node.args.posonlyargs, *node.args.args] defaults = [None] * (len(arguments) - len(node.args.defaults)) + list(node.args.defaults) for argument, default in zip(arguments, defaults): if argument.arg != "db": continue annotation = ast.unparse(argument.annotation) if argument.annotation else "" if default is not None or "None" in annotation: violations.append(f"{relative}:{node.lineno}:{node.name}:optional-db") assert violations == [] def test_plugin_sdk_does_not_import_or_export_host_models(): """ 插件 SDK 不得暴露 ORM Model,只有精确旧 ABI 门面可在内部访问。 """ internal_compat_imports = { ("app/sdk/_legacy/transferpending.py", "app.db.models.transferpending"), } violations: list[str] = [] for path in (APP_ROOT / "sdk").rglob("*.py"): relative = path.relative_to(PROJECT_ROOT).as_posix() tree = _parse_source(path) for node in _walk_module(tree): if ( isinstance(node, ast.ImportFrom) and node.module and (node.module == "app.db.models" or node.module.startswith("app.db.models.")) and (relative, node.module) not in internal_compat_imports ): violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.module}") assert violations == [] def test_entry_layers_do_not_import_database_implementations(): """ API、应用、编排、Agent、监控、模块和 Runtime 只能经端口访问持久化。 """ graph = _build_module_graph() layer_roots = ( "app.api", "app.application", "app.agent", "app.chain", "app.monitor", "app.modules", "app.runtime", "app.workflow", "app.adapters", ) violations = { source: sorted(dependency for dependency in dependencies if dependency.startswith("app.db")) for source, dependencies in graph.items() if source.startswith(layer_roots) and any(dependency.startswith("app.db") for dependency in dependencies) } assert violations == {} def test_migrated_modules_are_not_in_import_cycles(): """ 任何 canonical 迁移模块都不得进入完整应用依赖图的环。 """ modules = _discover_modules() known_modules = set(modules) graph = {name: _resolve_imports(name, path, known_modules) for name, path in modules.items()} relevant_cycles = [ sorted(component) for component in _strongly_connected_components(graph) if any(name.startswith(CYCLE_ROOTS) for name in component) ] assert relevant_cycles == [] def test_canonical_layers_do_not_depend_on_sdk_or_compat(): """ canonical 实现层不得反向依赖面向插件的 SDK 或兼容层。 """ violations: dict[str, set[str]] = {} modules = _discover_modules() known_modules = set(modules) for module_name, path in modules.items(): if not module_name.startswith(IMPLEMENTATION_ROOTS): continue if module_name.startswith("app.runtime.compat"): continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = { dependency for dependency in dependencies if dependency.startswith(("app.sdk", "app.runtime.compat")) } if forbidden: violations[module_name] = forbidden assert violations == {} def test_capability_packages_do_not_import_forbidden_upper_layers(): """ 新能力包只能依赖明确允许的下层或同层协作包。 """ modules = _discover_modules() known_modules = set(modules) violations: dict[str, set[str]] = {} for module_name, path in modules.items(): source_root = next( (root for root in FORBIDDEN_IMPORT_PREFIXES if module_name == root or module_name.startswith(f"{root}.")), None, ) if not source_root: continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = { dependency for dependency in dependencies if dependency.startswith(FORBIDDEN_IMPORT_PREFIXES[source_root]) } if forbidden: violations[module_name] = forbidden assert violations == {} def test_application_does_not_import_transport_frameworks(): """ 应用层不得依赖 FastAPI、Starlette 或宿主 HTTP 适配器。 """ violations: dict[str, set[str]] = {} for path in (APP_ROOT / "application").rglob("*.py"): tree = _parse_source(path) forbidden: set[str] = set() for node in _walk_module(tree): candidates: list[str] = [] if isinstance(node, ast.Import): candidates.extend(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module: candidates.append(node.module) forbidden.update( candidate for candidate in candidates if candidate.startswith(("fastapi", "starlette", "app.api", "app.adapters.web")) ) if forbidden: violations[str(path.relative_to(PROJECT_ROOT))] = forbidden assert violations == {} def test_site_domain_uses_foundation_dom_boundary(): """ 站点领域规则应依赖 DOM 原语,不得重新耦合聚合字符串工具。 """ modules = _discover_modules() dependencies = _resolve_imports( "app.domain.site", modules["app.domain.site"], set(modules), ) assert "app.foundation.dom" in dependencies assert "app.domain.string" not in dependencies def test_host_code_does_not_use_string_utils_facade(): """ 聚合 StringUtils 只服务插件兼容,宿主实现必须使用拆分后的能力。 """ violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] in {"plugins", "sdk"}: continue tree = _parse_source(path) if any(isinstance(node, ast.Name) and node.id == "StringUtils" for node in _walk_module(tree)): violations.append(str(relative)) assert violations == [] def test_host_code_does_not_import_chain_plugin_abi_roots() -> None: """ Subscribe/Transfer 包根只服务插件 ABI,宿主必须直接导入规范 Facade。 """ compatibility_roots = {"app.chain.subscribe", "app.chain.transfer"} violations: list[str] = [] for path in APP_ROOT.rglob("*.py"): relative = path.relative_to(APP_ROOT) if relative.parts[0] == "plugins": continue tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.ImportFrom) and node.module in compatibility_roots: violations.append(f"{relative}:{node.lineno}:{node.module}") elif isinstance(node, ast.Import): violations.extend( f"{relative}:{node.lineno}:{alias.name}" for alias in node.names if alias.name in compatibility_roots ) assert violations == [] def test_runtime_log_is_a_dependency_leaf(): """ 底层可引用运行时日志,但日志模块本身不得反向导入应用模块。 """ modules = _discover_modules() dependencies = _resolve_imports( "app.runtime.log", modules["app.runtime.log"], set(modules), ) assert {dependency for dependency in dependencies if dependency.startswith("app.")} == set() def test_foundation_does_not_emit_runtime_logs(): """ 基础机制不打印或初始化日志系统,运行期诊断由上层调用方负责。 """ violations: list[str] = [] for path in (APP_ROOT / "foundation").rglob("*.py"): tree = _parse_source(path) for node in _walk_module(tree): if isinstance(node, ast.Import) and any(alias.name == "logging" for alias in node.names): violations.append(str(path.relative_to(PROJECT_ROOT))) break if isinstance(node, ast.ImportFrom) and node.module in { "logging", "app.runtime.log", }: violations.append(str(path.relative_to(PROJECT_ROOT))) break if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "print": violations.append(str(path.relative_to(PROJECT_ROOT))) break assert violations == [] def test_cache_contract_does_not_import_concrete_adapters(): """ 运行时缓存契约和内存机制不得反向导入具体缓存适配器。 """ modules = _discover_modules() dependencies = _resolve_imports( "app.runtime.cache", modules["app.runtime.cache"], set(modules), ) assert {dependency for dependency in dependencies if dependency.startswith("app.adapters.cache")} == set() def test_passkey_application_does_not_select_cache_backend(): """ PassKey 用例只消费原子缓存端口,不得识别 Redis 或后端类型。 """ modules = _discover_modules() path = modules["app.application.security.passkey"] dependencies = _resolve_imports( "app.application.security.passkey", path, set(modules), ) source = path.read_text(encoding="utf-8-sig") assert {dependency for dependency in dependencies if dependency.startswith("app.adapters.cache")} == set() assert "RedisHelper" not in source assert ".is_redis(" not in source def test_startup_explicitly_configures_passkey_challenge_cache(): """ PassKey challenge 缓存必须由启动组合根显式装配。 """ path = APP_ROOT / "startup" / "composition" / "security.py" tree = _parse_source(path) configured = any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "configure_passkey_challenge_cache" for node in _walk_module(tree) ) assert configured is True def test_resource_adapter_does_not_restart_process(): """ 资源下载安装适配器不得反向调用进程重启能力。 """ modules = _discover_modules() dependencies = _resolve_imports( "app.adapters.system.resource", modules["app.adapters.system.resource"], set(modules), ) assert "app.runtime.state" not in dependencies def test_modules_do_not_import_other_modules_or_chain(): """ 模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。 `app.modules._base` 是模块共享样板基类包(模块发现会跳过),不视为业务模块。 """ modules = _discover_modules() known_modules = set(modules) violations: dict[str, set[str]] = {} for module_name, path in modules.items(): if not module_name.startswith("app.modules."): continue own_package = module_name.split(".")[2] dependencies = _resolve_imports(module_name, path, known_modules) forbidden = { dependency for dependency in dependencies if dependency.startswith("app.chain") or (dependency.startswith("app.modules.") and dependency.split(".")[2] not in (own_package, "_base")) } if forbidden: violations[module_name] = forbidden assert violations == {} def test_entrypoints_do_not_import_module_internals(): """ 入口层不得穿透导入具体模块实现,应经由链层或应用服务。 """ modules = _discover_modules() known_modules = set(modules) entrypoint_roots = ("app.api", "app.agent", "app.monitor", "app.workflow", "app.doctor") violations: dict[str, set[str]] = {} for module_name, path in modules.items(): if not module_name.startswith(entrypoint_roots): continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = {dependency for dependency in dependencies if dependency.startswith("app.modules.")} if forbidden: violations[module_name] = forbidden assert violations == {} def test_chain_does_not_import_downloader_sdks(): """ 链层不得引入下载器后端协议类型,避免后端细节泄漏到编排层。 """ forbidden_sdks = {"qbittorrentapi", "transmission_rpc"} violations: list[str] = [] for path in (APP_ROOT / "chain").rglob("*.py"): tree = _parse_source(path) for node in _walk_module(tree): names: list[str] = [] if isinstance(node, ast.Import): names.extend(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: names.append(node.module) if any(name.split(".")[0] in forbidden_sdks for name in names): violations.append(str(path.relative_to(PROJECT_ROOT))) break assert violations == [] def test_chain_does_not_import_module_internals(): """ 链层与模块只能通过 run_module 方法名契约互联,禁止直接导入模块实现。 模块内容必须封闭在模块内部,链层不显式指定具体模块的类、异常或常量, 这样模块才是可插拔的。 """ modules = _discover_modules() known_modules = set(modules) violations: dict[str, set[str]] = {} for module_name, path in modules.items(): if not module_name.startswith("app.chain"): continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = {dependency for dependency in dependencies if dependency.startswith("app.modules.")} if forbidden: violations[module_name] = forbidden assert violations == {} @lru_cache(maxsize=1) def _build_module_graph() -> dict[str, set[str]]: """ 构建非插件模块的完整静态依赖图,供既有包治理断言复用。 纯静态 AST 分析无副作用,结果可安全缓存;多断言共享一次解析。 """ modules = _discover_modules() known_modules = set(modules) return {name: _resolve_imports(name, path, known_modules) for name, path in modules.items()} def _load_dependency_policy() -> dict: """ 读取人工审查的依赖语义 policy;生成基线不得改写该文件。 """ return json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8")) def _scc_policy_violations( graph: dict[str, set[str]], entries: list[dict], ) -> tuple[list[list[str]], list[list[str]]]: """ 返回未审查 SCC 与已经失效但未清理的 policy SCC。 """ actual = {tuple(component) for component in _strongly_connected_components(graph)} reviewed = {tuple(entry["modules"]) for entry in entries} return ( [list(component) for component in sorted(actual - reviewed)], [list(component) for component in sorted(reviewed - actual)], ) def test_complete_host_sccs_match_reviewed_policy() -> None: """ 完整宿主图的每个 SCC 都必须精确匹配人工 policy,且不得保留陈旧项。 """ policy = _load_dependency_policy() entries = policy["allowed_sccs"] assert policy["schema_version"] == 3 assert policy["scope"] == { "dependency_kind": "static_runtime_imports", "excluded_roots": ["app/plugins"], "include_parent_package_initialization": True, "root": "app", } assert {entry["classification"] for entry in entries} == {"contained_vendor"} assert len({entry["id"] for entry in entries}) == len(entries) assert all(entry["modules"] == sorted(set(entry["modules"])) for entry in entries) assert all(entry["reason"] and entry["tracking"] for entry in entries) all_members = [member for entry in entries for member in entry["modules"]] assert len(all_members) == len(set(all_members)) vendor = next(entry for entry in entries if entry["classification"] == "contained_vendor") assert vendor["id"] == "themoviedb-vendored-package" assert vendor["tracking"] == "replace-or-upgrade-vendored-package" assert len(vendor["modules"]) == 29 assert all(member.startswith("app.modules.themoviedb") for member in vendor["modules"]) unreviewed, stale = _scc_policy_violations(_build_module_graph(), entries) assert unreviewed == [] assert stale == [] def test_scc_policy_rejects_unreviewed_cycles_in_every_host_root() -> None: """ API、Chain、Module 与 Startup 内的新环都必须落入未审查集合。 """ for root in ("app.api", "app.chain", "app.modules", "app.startup"): first = f"{root}.first" second = f"{root}.second" unreviewed, stale = _scc_policy_violations( {first: {second}, second: {first}}, [], ) assert unreviewed == [[first, second]] assert stale == [] def test_scc_policy_rejects_changed_or_stale_membership() -> None: """ 已知 SCC 缩小、扩大或消失后必须同步审查 policy,不能静默放行。 """ entries = [{"modules": ["app.chain.first", "app.chain.second", "app.chain.third"]}] unreviewed, stale = _scc_policy_violations( { "app.chain.first": {"app.chain.second"}, "app.chain.second": {"app.chain.first"}, "app.chain.third": set(), }, entries, ) assert unreviewed == [["app.chain.first", "app.chain.second"]] assert stale == [["app.chain.first", "app.chain.second", "app.chain.third"]] def test_contained_vendor_scc_may_have_one_way_outbound_dependency() -> None: """ vendor SCC 的普通单向向下依赖不应把包外模块误纳入 containment。 """ first = "app.modules.themoviedb.first" second = "app.modules.themoviedb.second" domain = "app.domain.media" entries = [{"modules": [first, second]}] unreviewed, stale = _scc_policy_violations( {first: {second, domain}, second: {first}, domain: set()}, entries, ) assert unreviewed == [] assert stale == [] def test_host_dependency_graph_excludes_plugin_copies() -> None: """ `app/plugins/**` 是运行时副本,不能进入宿主模块或 SCC 图。 """ assert all(not module.startswith("app.plugins") for module in _discover_modules()) def test_chain_does_not_import_agent_implementation(): """ 编排层不得反向依赖 Agent 实现,跨域编排经 application 门面。 """ violations: dict[str, set[str]] = {} for module_name, dependencies in _build_module_graph().items(): if not module_name.startswith("app.chain"): continue forbidden = {dependency for dependency in dependencies if dependency.startswith("app.agent")} if forbidden: violations[module_name] = forbidden assert violations == {} def test_agent_application_facade_does_not_import_agent_implementation(): """ Agent application 门面只能接收组合根注入,不能反向解析具体实现。 """ dependencies = _build_module_graph()["app.application.agent"] assert {dependency for dependency in dependencies if dependency.startswith("app.agent")} == set() def test_host_consumers_get_agent_manager_through_application_facade(): """ 宿主消费者不得绕过 Application 门面直接定位 Agent manager。 """ forbidden = {"get_agent_manager", "get_running_agent_manager"} violations: dict[str, set[str]] = {} for module_name, path in _discover_modules().items(): if module_name.startswith(("app.agent", "app.startup")): continue tree = _parse_source(path) imported = { alias.name for node in _walk_module(tree) if isinstance(node, ast.ImportFrom) and node.module == "app.agent.loader" for alias in node.names if alias.name in forbidden } if imported: violations[module_name] = imported assert violations == {} def test_agent_package_roots_do_not_duplicate_implementation_exports(): """ Agent 与 LLM 包根不得实现动态转发,旧符号只能由精确 Compat 路由承接。 """ for relative_path in ("app/agent/__init__.py", "app/agent/llm/__init__.py"): path = PROJECT_ROOT / relative_path tree = _parse_source(path) assert all(isinstance(node, ast.Expr) for node in tree.body), relative_path assert set(SYMBOL_ALIASES["app.agent"]) == { "AgentChain", "AgentManager", "HEARTBEAT_SESSION_PREFIX", "MoviePilotAgent", "ReplyMode", "UNSUPPORTED_IMAGE_INPUT_MESSAGE", "agent_manager", } assert set(SYMBOL_ALIASES["app.agent.llm"]) == {"LLMHelper"} helper_alias = MODULE_ALIASES["app.helper.llm"] assert helper_alias.target == "app.agent.llm.helper" assert helper_alias.replacement == "app.agent.llm.helper" assert not helper_alias.is_package def test_host_code_imports_agent_and_llm_symbols_from_owner_modules(): """ 宿主不得消费 Agent 包根兼容符号,避免包根再次成为第二公开面。 """ violations: dict[str, set[str]] = {} for module_name, path in _discover_modules().items(): tree = _parse_source(path) imported = { f"{node.module}.{alias.name}" for node in _walk_module(tree) if isinstance(node, ast.ImportFrom) and node.module in {"app.agent", "app.agent.llm"} for alias in node.names } if imported: violations[module_name] = imported assert violations == {} def test_host_consumers_resolve_llm_provider_runtime_through_gateway(): """ 宿主不得绕过 gateway 或 LLM 公共导出穿透 provider 实现。 """ violations: dict[str, set[str]] = {} graph = _build_module_graph() for module_name, path in _discover_modules().items(): if module_name.startswith(("app.agent", "app.startup")): continue tree = _parse_source(path) imported = { alias.name for node in _walk_module(tree) if isinstance(node, ast.ImportFrom) and node.module in {"app.agent.llm", "app.agent.llm.provider"} for alias in node.names if alias.name == "LLMProviderManager" } forbidden = set(imported) if "app.agent.llm.provider" in graph[module_name]: forbidden.add("app.agent.llm.provider") if forbidden: violations[module_name] = forbidden assert violations == {} def test_host_consumers_use_agent_audio_capability_application_port(): """ 宿主消费者不得绕过 Application 门面直连 Agent 音频实现。 """ violations: dict[str, set[str]] = {} for module_name, path in _discover_modules().items(): if module_name.startswith(("app.agent", "app.startup")): continue tree = _parse_source(path) imported = { alias.name for node in _walk_module(tree) if isinstance(node, ast.ImportFrom) and node.module in {"app.agent.llm", "app.agent.llm.capability"} for alias in node.names if alias.name == "AgentCapabilityManager" } if imported: violations[module_name] = imported assert violations == {} def test_application_services_do_not_resolve_event_manager_singleton(): """ Application 服务必须接收事件端口,不得自行定位进程级事件单例。 """ violations = { module_name: dependencies & {"app.runtime.events"} for module_name, dependencies in _build_module_graph().items() if module_name.startswith("app.application") and "app.runtime.events" in dependencies } assert violations == {} def test_http_endpoints_do_not_register_process_event_listeners(): """ HTTP 端点不得拥有进程级事件监听器,监听装配必须留在 startup。 """ facts = _collect_current_event_facts() callers = {item["caller"] for item in facts["consumers"]} assert {caller for caller in callers if caller.startswith("app.api")} == set() def test_agent_tools_do_not_import_entrypoint_internals(): """ Agent 工具不得穿透导入 HTTP 端点、调度器与命令注册表内部实现。 工具对进程级状态的读写必须收敛到 application 门面, 否则 agent 层与入口层互相穿透会形成不可测试的循环。 """ violations: dict[str, set[str]] = {} for module_name, dependencies in _build_module_graph().items(): if not module_name.startswith("app.agent.tools"): continue forbidden = { dependency for dependency in dependencies if dependency.startswith(("app.api", "app.scheduler", "app.command")) } if forbidden: violations[module_name] = forbidden assert violations == {} def test_modules_read_deployment_settings_through_runtime_port(): """ 宿主 Module 不得绕过 runtime 配置端口直接依赖 Settings 实例。 """ violations: list[str] = [] for path in (APP_ROOT / "modules").rglob("*.py"): tree = _parse_source(path) for node in _walk_module(tree): if not isinstance(node, ast.ImportFrom) or node.module != "app.runtime.config": continue if any(alias.name == "settings" for alias in node.names): violations.append(path.relative_to(PROJECT_ROOT).as_posix()) break assert violations == [] def test_runtime_implementation_does_not_use_legacy_settings_proxy(): """ runtime 实现只能使用只读配置端口,不得重新引入迁移期代理对象。 """ violations: list[str] = [] for path in (APP_ROOT / "runtime").rglob("*.py"): if path == APP_ROOT / "runtime" / "settings.py": continue tree = _parse_source(path) for node in _walk_module(tree): if not isinstance(node, ast.ImportFrom): continue if node.module != "app.runtime.settings": continue if any(alias.name.lower().endswith("compat") for alias in node.names): violations.append(path.relative_to(PROJECT_ROOT).as_posix()) break assert violations == [] def test_deprecated_settings_proxy_imports_are_zero(): """ 宿主代码不得导入已删除的 Settings 兼容代理。 """ limits = { "adapters": 0, "agent": 0, "application": 0, "cli.py": 0, "doctor": 0, "factory.py": 0, "main.py": 0, "modules": 0, "startup": 0, } counts: dict[str, int] = {} for path in APP_ROOT.rglob("*.py"): if path == APP_ROOT / "runtime" / "settings.py": continue if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) imports_compat = any( isinstance(node, ast.ImportFrom) and node.module == "app.runtime.settings" and any(alias.name.lower().endswith("compat") for alias in node.names) for node in _walk_module(tree) ) if not imports_compat: continue relative = path.relative_to(APP_ROOT) group = relative.parts[0] if len(relative.parts) > 1 else relative.as_posix() counts[group] = counts.get(group, 0) + 1 unexpected = set(counts) - set(limits) exceeded = {group: count for group, count in counts.items() if group in limits and count > limits[group]} assert unexpected == set() assert exceeded == {} def test_global_settings_imports_stay_within_compatibility_baseline(): """ 真实 Settings 对象只能保留在已知迁移点和插件 SDK,不得产生新宿主调用。 """ allowed = { "app/sdk/config.py", "app/startup/initializers/modules.py", # 共用测试引导是插件仓单测的组合根,与 tests/conftest.py 一样按测试 settings 登记运行时配置快照。 "app/testing/bootstrap.py", } imports: set[str] = set() for path in APP_ROOT.rglob("*.py"): if path.is_relative_to(APP_ROOT / "plugins"): continue tree = _parse_source(path) if any( isinstance(node, ast.ImportFrom) and node.module == "app.runtime.config" and any(alias.name == "settings" for alias in node.names) for node in _walk_module(tree) ): imports.add(path.relative_to(PROJECT_ROOT).as_posix()) assert imports <= allowed def test_api_does_not_import_factory(): """ 装配器(factory)只允许 app.main 使用,HTTP 端点不得回引。 """ violations: dict[str, set[str]] = {} for module_name, dependencies in _build_module_graph().items(): if not module_name.startswith("app.api"): continue forbidden = {dependency for dependency in dependencies if dependency.startswith("app.factory")} if forbidden: violations[module_name] = forbidden assert violations == {} def test_web_agent_non_transport_state_stays_in_application(): """ Agent 端点不得重新拥有附件、音频、投影或会话写入实现。 """ endpoint_path = APP_ROOT / "api" / "endpoints" / "agent.py" application_path = APP_ROOT / "application" / "messaging" / "agent.py" endpoint_tree = ast.parse( endpoint_path.read_text(encoding="utf-8-sig"), filename=str(endpoint_path), ) application_tree = ast.parse( application_path.read_text(encoding="utf-8-sig"), filename=str(application_path), ) endpoint_owners = { node.name for node in endpoint_tree.body if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) } application_owners = { node.name for node in application_tree.body if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) } required = { "WebAgentEventPublisher", "apply_web_agent_display_event", "build_web_agent_command_items", "build_web_agent_input_attachments", "build_web_agent_message_events_async", "build_web_agent_session_id_async", "build_web_agent_stream", "collect_web_agent_traditional_events", "get_web_agent_registered_file", "prepare_web_agent_audio_attachment_path_async", "save_web_agent_display_snapshot", "transcribe_web_agent_audio_input", } forbidden_endpoint_imports = { "app.application.commands", "app.application.messaging.router", "app.chain.message", "app.runtime.execution", "app.runtime.stop", } endpoint_imports = { node.module for node in ast.walk(endpoint_tree) if isinstance(node, ast.ImportFrom) and node.module } assert required <= application_owners assert required.isdisjoint(endpoint_owners) assert endpoint_imports.isdisjoint(forbidden_endpoint_imports) def test_plugin_market_use_cases_stay_in_application(): """ Plugin 端点不得重新组合市场 Adapter 或拥有目录投影实现。 """ endpoint_path = APP_ROOT / "api" / "endpoints" / "plugin.py" endpoint_tree = ast.parse( endpoint_path.read_text(encoding="utf-8-sig"), filename=str(endpoint_path), ) forbidden_imports = { "app.adapters.external.market", "app.adapters.external.server", } imported = {node.module for node in ast.walk(endpoint_tree) if isinstance(node, ast.ImportFrom) and node.module} endpoint_owners = { node.name for node in endpoint_tree.body if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) } assert forbidden_imports.isdisjoint(imported) assert { "_get_market_plugin_from_repo", "_get_plugin_history_detail", "_installed_plugins_with_declared_metadata", "_prepare_update_candidates", }.isdisjoint(endpoint_owners) def test_system_nettest_is_owned_by_application_service(): """ System API 不得重新拥有网络目标目录、安全准入或具体传输组装。 """ endpoint_path = APP_ROOT / "api" / "endpoints" / "system.py" endpoint_tree = ast.parse( endpoint_path.read_text(encoding="utf-8-sig"), filename=str(endpoint_path), ) retired_endpoint_symbols = { "_build_nettest_rules", "_close_nettest_response", "_get_nettest_rule", "_is_allowed_nettest_redirect", "_match_nettest_prefix", "_validate_nettest_url", } endpoint_functions = { node.name for node in endpoint_tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } assert endpoint_functions.isdisjoint(retired_endpoint_symbols) assert not any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "NetworkTestService" for node in ast.walk(endpoint_tree) ) application_path = APP_ROOT / "application" / "network.py" application_tree = ast.parse( application_path.read_text(encoding="utf-8-sig"), filename=str(application_path), ) concrete_adapter_imports = { node.module for node in ast.walk(application_tree) if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("app.adapters") } concrete_adapter_imports.update( alias.name for node in ast.walk(application_tree) if isinstance(node, ast.Import) for alias in node.names if alias.name.startswith("app.adapters") ) assert concrete_adapter_imports == set() def test_plugin_market_construction_is_owned_by_startup_composition(): """ 插件 initializer 只能消费组合根创建的市场依赖,禁止隐式构造第二套 owner。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "plugins.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) initializer_imports = { alias.name for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) for alias in node.names } assert "compose_plugin_market" in initializer_imports forbidden_names = { "PluginMarketTransport", "PluginPackageSourceClient", "PluginPackageManager", "PluginRuntimeHealth", "PluginDependencyInstaller", } assert forbidden_names.isdisjoint(initializer_imports) assert not any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in forbidden_names for node in ast.walk(initializer_tree) ) composition_path = APP_ROOT / "startup" / "composition" / "plugin.py" composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) composition_calls = [ node.func.id for node in ast.walk(composition_tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in forbidden_names ] assert composition_calls.count("PluginMarketTransport") == 1 assert composition_calls.count("PluginPackageSourceClient") == 1 assert composition_calls.count("PluginPackageManager") == 1 assert composition_calls.count("PluginRuntimeHealth") == 1 assert composition_calls.count("PluginDependencyInstaller") == 1 def test_plugin_market_transport_is_process_shared_across_compat_and_composition(): """ 兼容门面和组合根不得物化两套插件市场传输 owner。 """ from app.adapters.external.market import _plugin_market_transport from app.adapters.external.plugin.client import PluginMarketTransport assert PluginMarketTransport.get_existing_instance() is _plugin_market_transport def test_plugin_runtime_owners_are_shared_with_compat_facade(): """ 兼容门面必须消费组合根发布的健康检查与依赖安装 owner。 """ from app.adapters.external.market import ( _plugin_dependency_owner, _plugin_runtime_health_owner, configure_plugin_runtime_owners, reset_plugin_runtime_owners, ) health = object() dependency = object() configure_plugin_runtime_owners( # 只验证依赖所有者的对象身份,用占位对象隔离真实资源构造。 health=health, # type: ignore[arg-type] # 只验证依赖所有者的对象身份,用占位对象隔离真实资源构造。 dependency=dependency, # type: ignore[arg-type] ) try: assert _plugin_runtime_health_owner() is health assert _plugin_dependency_owner() is dependency finally: reset_plugin_runtime_owners() def test_domain_initializer_delegates_construction_to_composition(): """ 领域 initializer 只负责生命周期调用,不得直接构造 Adapter 或领域服务。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "domain.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == {"app.startup.composition.domain"} calls = [ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)] assert calls == ["compose_domain_dependencies"] composition_path = APP_ROOT / "startup" / "composition" / "domain.py" composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) composition_imports = { node.module for node in ast.walk(composition_tree) if isinstance(node, ast.ImportFrom) and node.module } assert "app.adapters.network.resolver" in composition_imports assert "app.adapters.system.host" in composition_imports assert "app.application.recognition" in composition_imports def test_network_initializer_delegates_construction_to_composition(): """ 网络 initializer 只保留生命周期入口,不得重新拥有具体技术适配器。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "network.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == {"app.startup.composition.network"} calls = {ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)} assert calls == { "configure_chain_network_composition", "reset_chain_network_composition", } composition_path = APP_ROOT / "startup" / "composition" / "network.py" composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) composition_imports = { node.module for node in ast.walk(composition_tree) if isinstance(node, ast.ImportFrom) and node.module } assert "app.adapters.network.http" in composition_imports assert "app.adapters.system.host" in composition_imports assert "app.chain.download.ports" in composition_imports def test_site_initializer_delegates_construction_to_composition(): """ 站点 initializer 只保留生命周期入口,不得重新拥有具体技术适配器。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "site.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == {"app.startup.composition.site"} calls = {ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)} assert calls == { "configure_site_access_composition", "reset_site_access_composition", } composition_path = APP_ROOT / "startup" / "composition" / "site.py" composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) composition_imports = { node.module for node in ast.walk(composition_tree) if isinstance(node, ast.ImportFrom) and node.module } assert "app.adapters.external.cookiecloud" in composition_imports assert "app.adapters.network.browser" in composition_imports assert "app.application.rss" in composition_imports def test_chain_initializer_delegates_construction_to_composition(): """ Chain initializer 只保留生命周期入口,不得重新拥有外部技术适配器。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "chain.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == {"app.startup.composition.chain"} calls = {ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)} assert calls == { "configure_chain_port_composition", "reset_chain_port_composition", } composition_path = APP_ROOT / "startup" / "composition" / "chain.py" composition_tree = ast.parse( composition_path.read_text(encoding="utf-8-sig"), filename=str(composition_path), ) composition_imports = { node.module for node in ast.walk(composition_tree) if isinstance(node, ast.ImportFrom) and node.module } assert "app.adapters.external.server" in composition_imports assert "app.adapters.system.host" in composition_imports assert "app.chain._recognition" in composition_imports def test_cache_initializer_delegates_construction_to_composition(): """ 缓存 initializer 只保留启动入口,不得直接登记具体缓存 Adapter。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "cache.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == {"app.startup.composition.cache"} calls = [ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)] assert calls == ["configure_cache_composition"] def test_resource_initializer_delegates_construction_to_composition(): """ 资源 initializer 只保留生命周期入口,不得构造托管资源 Adapter。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "resources.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert imported_modules == { "app.runtime.capabilities.runtime", "app.startup.composition.resource", } constructors = {ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)} assert constructors == { "configure_managed_resource_composition", "stop_managed_resource_composition", "reset_managed_resource_composition", } def test_modules_initializer_consumes_doh_and_workflow_composition(): """ 模块 initializer 不得直接构造 DoH 或事务型工作流执行 Adapter。 """ initializer_path = APP_ROOT / "startup" / "initializers" / "modules.py" initializer_tree = ast.parse( initializer_path.read_text(encoding="utf-8-sig"), filename=str(initializer_path), ) imported_modules = { node.module for node in ast.walk(initializer_tree) if isinstance(node, ast.ImportFrom) and node.module } assert "app.adapters.network.doh" not in imported_modules assert "app.db.adapters.workflow" not in imported_modules calls = {ast.unparse(node.func) for node in ast.walk(initializer_tree) if isinstance(node, ast.Call)} assert "DohHelper" not in calls assert "TransactionalWorkflowExecutionService" not in calls assert "configure_doh_composition" in calls assert "configure_workflow_execution_composition" in calls def test_system_api_business_orchestration_is_owned_by_application_service(): """ System API 只保留传输映射,不得重新拥有日志、设置或更新实现。 """ endpoint_path = APP_ROOT / "api" / "endpoints" / "system.py" endpoint_tree = ast.parse( endpoint_path.read_text(encoding="utf-8-sig"), filename=str(endpoint_path), ) imported = {node.module for node in ast.walk(endpoint_tree) if isinstance(node, ast.ImportFrom) and node.module} direct_adapters = { module for module in imported if module.startswith("app.adapters") and module != "app.adapters.web.security.access" } endpoint_functions = { node.name for node in endpoint_tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } retired_helpers = { "_collect_named_log_files", "_validate_database_backup_config", "_validate_llm_server_tool_config", "_is_allowed_plugin_market_wiki_url", } assert not direct_adapters assert endpoint_functions.isdisjoint(retired_helpers) assert not imported.intersection( { "app.application.plugin.runtime", "app.runtime.events", "app.runtime.state", } ) application_path = APP_ROOT / "application" / "system.py" application_tree = ast.parse( application_path.read_text(encoding="utf-8-sig"), filename=str(application_path), ) assert not any( isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("app.adapters") for node in ast.walk(application_tree) ) composition_source = (APP_ROOT / "startup" / "composition" / "system.py").read_text(encoding="utf-8-sig") assert "def compose_system_service(" in composition_source assert "system=compose_system_service(" in (APP_ROOT / "startup" / "composition" / "runtime.py").read_text( encoding="utf-8-sig" ) PROCESS_LEVEL_ROOTS = ( "app.api", "app.chain", "app.agent", "app.scheduler", "app.command", "app.monitor", "app.startup", "app.factory", ) def test_process_level_packages_are_not_mutually_cyclic(): """ 进程级根包之间不得形成跨包强连通分量。 允许的环只存在于:单一包内部(modules 模块内、db 内、schemas 包内、 agent 子域内、doctor 内)。跨根包的环意味着入口层、编排层与 Agent 层 互相穿透,破坏可插拔性与可测试性。 """ graph = _build_module_graph() components = _strongly_connected_components(graph) violations: list[list[str]] = [] for component in components: roots = {name.split(".")[1] for name in component if name.startswith("app.") and name.count(".") >= 1} involved = {root for root in roots if f"app.{root}" in PROCESS_LEVEL_ROOTS} if len(involved) > 1: violations.append(sorted(component)) assert violations == []