Files
MoviePilot/tests/test_architecture_dependencies.py
jxxghp 6ca7b294a0 fix(download): 共享站点流控并保留频控错误原因 (#7246)
* fix(download): share site limits and preserve frequency errors

* test(startup): record shared site throttle module
2026-10-30 14:53:32 +08:00

3067 lines
118 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证模块归属、兼容导入路径及分层依赖的架构约束。
"""
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 == []