diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5294a5fcb..47bc9b5b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,7 +63,12 @@ jobs: run: uv run --locked --no-sync mypy --config-file mypy.ini - name: Check complexity ratchet - run: uv run --locked --no-sync python scripts/architecture/complexity.py + run: | + uv run --locked --no-sync python scripts/architecture/complexity.py + uv run --locked --no-sync python scripts/architecture/complexity.py --v2 + + - name: Check native concurrency ownership + run: uv run --locked --no-sync python scripts/architecture/concurrency.py - name: Check async blocking ratchet run: uv run --locked --no-sync python scripts/architecture/async_blocking.py diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 574373da0..02d4d0bb2 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -20,6 +20,10 @@ | RES-003 | `PLANNED`(R3) | Outbox after_commit 失败只有内存 pending 标记,重启不可恢复 | 持久 intent、唯一 handler、claim/fencing、重启回放、幂等和失败观测完整闭环 | | RES-004 | `PLANNED`(R4) | Startup initializer 与插件市场存在多套 Transport/Adapter/Manager 构造 | 所有构造回收到 composition root,插件兼容只经 SDK/Compat,canonical 不保留重复正式入口 | | RES-005 | `PLANNED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 文档与事实源一致;专项、锁定全量、Pylint、架构/兼容门禁和 GitHub CI 闭环,远端 `0/0` | +R2 已落地门禁基础:`complexity.py --v2` 覆盖私有/嵌套方法、类、文件和 +`app/scheduler/`;`concurrency.py` 覆盖宿主原生并发原语并冻结调用点与词法 owner。 +当前基线只允许删除或收缩,新增调用点和 owner 漂移均阻断 CI;现有历史热点仍须在后续 +叶子中按真实所有权逐项收口,不得以生成基线伪装为债务清零。 ## 1. 结论摘要 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 0a17531b5..32b4f6032 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -216,6 +216,18 @@ only the historical `Scheduler` and `SchedulerChain` identities. New plugins use the narrow `app.sdk.scheduler` facade; internal Scheduler owners must not be re-exported from the package root, SDK or Compat. +Complexity and concurrency governance covers the complete canonical execution +surface rather than only public methods. `scripts/architecture/complexity.py --v2` +ratchets private and nested methods, class/file hotspots, and the +`app/scheduler/` package; its generated baseline is an evidence ledger, not a +permission to add another oversized owner. `scripts/architecture/concurrency.py` +enumerates native Thread, Timer, thread/process executors, Process, +asyncio task/thread helpers, and executor hand-off calls across host roots. Each +fact must have a stable lexical owner and remain in the baseline with no new +call site or owner drift. `app/plugins/**`, `app/sdk/**`, and +`app/runtime/compat/**` are excluded because their compatibility/runtime +contracts are governed by their own exact manifests. + `app.schemas` and the `app.db` package root are compatibility facades, not implementation dependency hubs. Host code imports concrete schema submodules; the schema root resolves its generated export manifest lazily for plugins and legacy callers. diff --git a/scripts/architecture/complexity.py b/scripts/architecture/complexity.py index fdbb23523..ec506a3f1 100644 --- a/scripts/architecture/complexity.py +++ b/scripts/architecture/complexity.py @@ -11,6 +11,7 @@ from typing import Iterable PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/complexity-baseline.json" +V2_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/complexity-v2-baseline.json" @dataclass(frozen=True, slots=True) @@ -30,6 +31,85 @@ RULES = ( ) +def _iter_python_files(root: Path, roots: tuple[str, ...]) -> Iterable[Path]: + """遍历 canonical 宿主源码,明确排除运行时插件副本。""" + for scan_root in roots: + path = root / scan_root + if path.is_file() and path.suffix == ".py": + yield path + continue + if not path.is_dir(): + continue + for candidate in sorted(path.rglob("*.py")): + relative = candidate.relative_to(root).as_posix() + if relative == "app/plugins" or relative.startswith("app/plugins/"): + continue + yield candidate + + +def _walk_functions( + nodes: Iterable[ast.AST], + prefix: str = "", +) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: + """递归收集函数、方法和嵌套编排器,避免只检查公开入口。""" + for node in nodes: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + qualname = f"{prefix}.{node.name}" if prefix else node.name + if not node.name.startswith("__"): + yield qualname, node + yield from _walk_functions(node.body, qualname) + elif isinstance(node, ast.ClassDef): + qualname = f"{prefix}.{node.name}" if prefix else node.name + yield from _walk_functions(node.body, qualname) + elif isinstance(node, ast.If): + yield from _walk_functions(node.body, prefix) + yield from _walk_functions(node.orelse, prefix) + elif isinstance(node, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith)): + yield from _walk_functions(node.body, prefix) + yield from _walk_functions(node.orelse, prefix) if isinstance(node, (ast.For, ast.AsyncFor)) else () + elif isinstance(node, ast.Try): + yield from _walk_functions(node.body, prefix) + yield from _walk_functions(node.orelse, prefix) + yield from _walk_functions(node.finalbody, prefix) + for handler in node.handlers: + yield from _walk_functions(handler.body, prefix) + + +def _walk_classes( + nodes: Iterable[ast.AST], + prefix: str = "", +) -> Iterable[tuple[str, ast.ClassDef]]: + """递归收集类体长度,覆盖 Scheduler 和私有 owner。""" + for node in nodes: + if isinstance(node, ast.ClassDef): + qualname = f"{prefix}.{node.name}" if prefix else node.name + yield qualname, node + yield from _walk_classes(node.body, qualname) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield from _walk_classes(node.body, prefix) + + +def collect_complexity_v2(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int]]: + """收集私有方法、类、文件和 Scheduler 的可审计复杂度事实。""" + roots = ("app/api/endpoints", "app/application", "app/chain", "app/scheduler") + report: dict[str, dict[str, int]] = {"method": {}, "class": {}, "file": {}} + for path in _iter_python_files(root, roots): + relative = path.relative_to(root).as_posix() + source_lines = path.read_text(encoding="utf-8").splitlines() + if len(source_lines) > 1000: + report["file"][relative] = len(source_lines) + tree = ast.parse("\n".join(source_lines), filename=str(path)) + for qualname, node in _walk_functions(tree.body): + line_count = (node.end_lineno or node.lineno) - node.lineno + 1 + if line_count > 150: + report["method"][f"{relative}:{qualname}"] = line_count + for qualname, node in _walk_classes(tree.body): + line_count = (node.end_lineno or node.lineno) - node.lineno + 1 + if line_count > 500: + report["class"][f"{relative}:{qualname}"] = line_count + return report + + def _is_endpoint(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: """识别带常见 HTTP method decorator 的 API endpoint。""" methods = {"get", "post", "put", "patch", "delete", "options", "head"} @@ -92,12 +172,48 @@ def compare_complexity( return problems +def compare_complexity_v2( + baseline: dict[str, dict[str, int]], current: dict[str, dict[str, int]] +) -> list[str]: + """返回 v2 私有方法、类和文件事实的新增或增长问题。""" + problems: list[str] = [] + for category in baseline.keys() | current.keys(): + previous = baseline.get(category, {}) + for entry, line_count in current.get(category, {}).items(): + if entry not in previous: + problems.append(f"{category}: 新增超限 {entry}={line_count}") + elif line_count > previous[entry]: + problems.append( + f"{category}: 既有超限增长 {entry}={line_count}>{previous[entry]}" + ) + return problems + + def main() -> int: """执行复杂度 baseline check 或显式 write。""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--write", action="store_true", help="写入当前超限基线") + parser.add_argument("--v2", action="store_true", help="检查私有方法、类、文件和 Scheduler") parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) args = parser.parse_args() + if args.v2: + baseline_path = args.baseline if args.baseline != DEFAULT_BASELINE else V2_BASELINE + current_v2 = collect_complexity_v2() + if args.write: + baseline_path.parent.mkdir(parents=True, exist_ok=True) + baseline_path.write_text( + json.dumps(current_v2, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"已写入 {baseline_path.relative_to(PROJECT_ROOT)}") + return 0 + baseline_v2 = json.loads(baseline_path.read_text(encoding="utf-8")) + problems_v2 = compare_complexity_v2(baseline_v2, current_v2) + if problems_v2: + print("\n".join(problems_v2)) + return 1 + print("复杂度 v2 ratchet 通过") + return 0 current = collect_complexity() if args.write: args.baseline.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/architecture/concurrency.py b/scripts/architecture/concurrency.py new file mode 100644 index 000000000..ef5163cf3 --- /dev/null +++ b/scripts/architecture/concurrency.py @@ -0,0 +1,200 @@ +"""枚举宿主原生并发原语并维护稳定 owner 事实。""" + +from __future__ import annotations + +import argparse +import ast +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/concurrency-baseline.json" +SCAN_ROOTS = ( + "app/adapters", + "app/api", + "app/agent", + "app/application", + "app/chain", + "app/command.py", + "app/db", + "app/domain", + "app/foundation", + "app/main.py", + "app/monitor", + "app/modules", + "app/runtime", + "app/scheduler", + "app/startup", + "app/workflow", +) + +_CALL_SUFFIXES = frozenset({ + "create_task", "ensure_future", "to_thread", "run_in_executor", "run_in_threadpool", +}) +_CONSTRUCTOR_NAMES = frozenset({ + "Thread", "Timer", "ThreadPoolExecutor", "ProcessPoolExecutor", "Process", +}) + + +@dataclass(frozen=True, slots=True) +class ConcurrencyFact: + """描述一个原生并发调用及其静态 owner。""" + + path: str + line: int + target: str + owner: str + + @property + def key(self) -> str: + """返回跨运行稳定的事实键。""" + return f"{self.path}:{self.line}:{self.target}" + + def as_dict(self) -> dict[str, str]: + """转换为基线文件中的稳定记录。""" + return {"owner": self.owner, "target": self.target} + + +def _iter_python_files(root: Path, scan_roots: Iterable[str]) -> Iterable[Path]: + """遍历宿主源码并排除插件副本、SDK 和兼容实现。""" + for scan_root in scan_roots: + path = root / scan_root + if path.is_file(): + candidates = [path] + elif path.is_dir(): + candidates = sorted(path.rglob("*.py")) + else: + candidates = [] + for candidate in candidates: + relative = candidate.relative_to(root).as_posix() + if any( + relative == excluded or relative.startswith(f"{excluded}/") + for excluded in ("app/plugins", "app/sdk", "app/runtime/compat") + ): + continue + yield candidate + + +def _target_name(node: ast.Call) -> str | None: + """识别约定的原生并发 API,避免按普通方法名误报。""" + if not isinstance(node.func, (ast.Name, ast.Attribute)): + return None + target = ast.unparse(node.func) + terminal = target.rsplit(".", 1)[-1] + if terminal in _CALL_SUFFIXES or terminal in _CONSTRUCTOR_NAMES: + return terminal + return None + + +class _ConcurrencyVisitor(ast.NodeVisitor): + """收集函数、类和模块级并发调用的词法 owner。""" + + def __init__(self, relative_path: str) -> None: + """初始化文件位置和 owner 栈。""" + self._relative_path = relative_path + self._owners: list[str] = [] + self.facts: list[ConcurrencyFact] = [] + + @property + def _owner(self) -> str: + """返回最具体的可定位 owner。""" + return ".".join(self._owners) if self._owners else self._relative_path + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + """把类名纳入 owner 路径后继续扫描方法体。""" + self._owners.append(node.name) + self.generic_visit(node) + self._owners.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """把同步函数纳入 owner 路径后扫描其嵌套并发。""" + self._owners.append(node.name) + self.generic_visit(node) + self._owners.pop() + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """把异步函数纳入 owner 路径后扫描其嵌套并发。""" + self._owners.append(node.name) + self.generic_visit(node) + self._owners.pop() + + def visit_Call(self, node: ast.Call) -> None: + """记录已识别原语并继续访问参数中的嵌套调用。""" + target = _target_name(node) + if target: + self.facts.append( + ConcurrencyFact( + path=self._relative_path, + line=node.lineno, + target=target, + owner=self._owner, + ) + ) + self.generic_visit(node) + + +def collect_concurrency( + root: Path = PROJECT_ROOT, + *, + scan_roots: Iterable[str] = SCAN_ROOTS, +) -> dict[str, dict[str, str]]: + """收集全部宿主原生并发调用及静态 owner。""" + facts: dict[str, dict[str, str]] = {} + for path in _iter_python_files(root, scan_roots): + relative = path.relative_to(root).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + visitor = _ConcurrencyVisitor(relative) + visitor.visit(tree) + for fact in visitor.facts: + facts[fact.key] = fact.as_dict() + return dict(sorted(facts.items())) + + +def compare_concurrency( + baseline: dict[str, dict[str, str]], + current: dict[str, dict[str, str]], +) -> list[str]: + """拒绝新增原语、owner 丢失或事实 owner 漂移。""" + problems: list[str] = [] + for key, record in current.items(): + if not record.get("owner"): + problems.append(f"并发事实缺少 owner:{key}") + continue + previous = baseline.get(key) + if previous is None: + problems.append(f"新增原生并发事实:{key}") + elif previous.get("owner") != record.get("owner"): + problems.append( + f"并发 owner 漂移:{key}={record.get('owner')}(基线为 {previous.get('owner')})" + ) + return problems + + +def main() -> int: + """执行原生并发事实 ratchet。""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="写入当前并发事实基线") + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + args = parser.parse_args() + current = collect_concurrency() + if args.write: + args.baseline.parent.mkdir(parents=True, exist_ok=True) + args.baseline.write_text( + json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}") + return 0 + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + problems = compare_concurrency(baseline, current) + if problems: + print("\n".join(problems)) + return 1 + print("原生并发事实 ratchet 通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/architecture/complexity-v2-baseline.json b/tests/fixtures/architecture/complexity-v2-baseline.json new file mode 100644 index 000000000..578e1f72c --- /dev/null +++ b/tests/fixtures/architecture/complexity-v2-baseline.json @@ -0,0 +1,79 @@ +{ + "class": { + "app/application/formatting.py:EpisodeFormatRuleHelper": 1280, + "app/application/messaging/site.py:SiteInteractionHandler": 579, + "app/application/messaging/skill.py:SkillInteractionHandler": 1101, + "app/application/messaging/subscribe.py:SubscribeInteractionHandler": 694, + "app/application/plugin/install.py:PluginInstallCommand": 739, + "app/application/rss.py:RssHelper": 531, + "app/application/security/url.py:SecurityUtils": 771, + "app/application/torrent/download.py:TorrentHelper": 738, + "app/application/transfer/workflow.py:JobManager": 754, + "app/chain/_recognition.py:RecognitionMixin": 570, + "app/chain/base.py:ChainBase": 1161, + "app/chain/download/batch.py:DownloadBatchOwner": 515, + "app/chain/download/subtitle.py:DownloadSubtitleOwner": 538, + "app/chain/interaction.py:MediaInteractionChain": 1540, + "app/chain/mediaserver.py:MediaServerChain": 568, + "app/chain/message.py:MessageChain": 1876, + "app/chain/recommend.py:RecommendChain": 742, + "app/chain/scraping.py:ScrapingChain": 1802, + "app/chain/search/media.py:SearchMediaOwner": 522, + "app/chain/search/provider.py:SearchProviderOwner": 662, + "app/chain/search/subtitle.py:SearchSubtitleOwner": 528, + "app/chain/site.py:SiteChain": 1003, + "app/chain/subscribe/create.py:SubscribeCreateOwner": 646, + "app/chain/subscribe/match.py:SubscribeMatchOwner": 571, + "app/chain/subscribe/refresh.py:SubscribeRefreshOwner": 760, + "app/chain/torrents.py:TorrentsChain": 792, + "app/chain/transfer/plan.py:TransferPlanningOwner": 909, + "app/chain/transfer/queue.py:TransferQueueOwner": 1572, + "app/chain/transfer/settlement.py:TransferSettlementOwner": 648, + "app/chain/transfer/workflow.py:TransferWorkflowOwner": 1061, + "app/chain/workflow.py:WorkflowExecutor": 1143 + }, + "file": { + "app/api/endpoints/system.py": 1438, + "app/application/formatting.py": 1523, + "app/application/history.py": 1505, + "app/application/messaging/agent.py": 2195, + "app/application/messaging/message.py": 1314, + "app/application/messaging/skill.py": 1300, + "app/application/security/url.py": 1079, + "app/application/transfer/workflow.py": 1957, + "app/chain/base.py": 1201, + "app/chain/interaction.py": 1571, + "app/chain/message.py": 1984, + "app/chain/scraping.py": 2065, + "app/chain/site.py": 1187, + "app/chain/transfer/queue.py": 1631, + "app/chain/transfer/workflow.py": 1106, + "app/chain/workflow.py": 1391 + }, + "method": { + "app/api/endpoints/transfer.py:_execute_manual_transfer": 296, + "app/application/formatting.py:EpisodeFormatRuleHelper._auto_recommend": 174, + "app/application/messaging/site.py:SiteInteractionHandler._handle_text_interaction": 227, + "app/application/messaging/skill.py:SkillInteractionHandler._handle_callback_interaction": 158, + "app/application/messaging/skill.py:SkillInteractionHandler._handle_text_interaction": 296, + "app/application/messaging/subscribe.py:SubscribeInteractionHandler._handle_text_interaction": 205, + "app/application/network.py:NetworkTestService._build_rules": 206, + "app/application/rss.py:RssHelper._parse_impl": 213, + "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 241, + "app/chain/download/batch.py:DownloadBatchOwner._execute_batch_download": 478, + "app/chain/download/submission.py:DownloadSubmissionOwner._execute_download_single": 176, + "app/chain/message.py:MessageChain._handle_ai_message": 202, + "app/chain/message.py:MessageChain._handle_message_core": 151, + "app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 377, + "app/chain/transfer/execution.py:TransferExecutionOwner._TransferChain__perform_transfer": 209, + "app/chain/transfer/queue.py:TransferQueueOwner._TransferChain__replay_pending": 171, + "app/chain/transfer/queue.py:TransferQueueOwner._TransferChain__start_transfer": 180, + "app/chain/transfer/request.py:_TransferCandidatePlanner._plan_file_items": 173, + "app/chain/transfer/settlement.py:TransferSettlementOwner._TransferChain__default_callback": 261, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._build_transfer_tasks": 283, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._execute_transfer_tasks": 172, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._run_transfer_workflow": 232, + "app/scheduler/catalog.py:SchedulerCatalogOwner._initialize_catalog": 336, + "app/scheduler/execution.py:SchedulerExecutionOwner.start": 153 + } +} diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json new file mode 100644 index 000000000..7856a369f --- /dev/null +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -0,0 +1,622 @@ +{ + "app/adapters/external/plugin/client.py:1372:to_thread": { + "owner": "PluginMarketTransport.async_get_plugin_system_version_check_message", + "target": "to_thread" + }, + "app/adapters/external/server.py:1005:to_thread": { + "owner": "MoviePilotServerHelper.sub_reg_async", + "target": "to_thread" + }, + "app/adapters/external/server.py:1019:to_thread": { + "owner": "MoviePilotServerHelper.sub_done_async", + "target": "to_thread" + }, + "app/adapters/network/browser.py:461:ThreadPoolExecutor": { + "owner": "BrowserSessionHelper._get_session_executor", + "target": "ThreadPoolExecutor" + }, + "app/adapters/network/http.py:224:create_task": { + "owner": "_get_shared_async_transport", + "target": "create_task" + }, + "app/adapters/system/fsproxy.py:399:Thread": { + "owner": "FileSystemProxy._read_line_windows", + "target": "Thread" + }, + "app/adapters/system/host.py:1012:Process": { + "owner": "SystemUtils.memory_usage", + "target": "Process" + }, + "app/adapters/system/host.py:255:Process": { + "owner": "SystemUtils._process_tree", + "target": "Process" + }, + "app/adapters/system/host.py:329:create_task": { + "owner": "SystemUtils.execute_with_subprocess_async", + "target": "create_task" + }, + "app/adapters/system/host.py:950:Process": { + "owner": "SystemUtils.dashboard_system_info", + "target": "Process" + }, + "app/adapters/system/plugin/dependency.py:414:to_thread": { + "owner": "PluginDependencyInstaller.async_find_missing", + "target": "to_thread" + }, + "app/adapters/system/plugin/health.py:403:create_task": { + "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", + "target": "create_task" + }, + "app/adapters/system/plugin/health.py:404:to_thread": { + "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", + "target": "to_thread" + }, + "app/adapters/system/plugin/health.py:417:to_thread": { + "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file.cleanup_created_file", + "target": "to_thread" + }, + "app/adapters/system/plugin/health.py:419:create_task": { + "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", + "target": "create_task" + }, + "app/adapters/system/plugin/package.py:2130:to_thread": { + "owner": "PluginPackageManager.__async_install_from_release", + "target": "to_thread" + }, + "app/agent/callback/__init__.py:239:create_task": { + "owner": "StreamingHandler._start_streaming", + "target": "create_task" + }, + "app/agent/callback/__init__.py:271:run_in_threadpool": { + "owner": "StreamingHandler._stop_streaming", + "target": "run_in_threadpool" + }, + "app/agent/callback/__init__.py:606:run_in_threadpool": { + "owner": "StreamingHandler._flush", + "target": "run_in_threadpool" + }, + "app/agent/callback/__init__.py:653:run_in_threadpool": { + "owner": "StreamingHandler._flush", + "target": "run_in_threadpool" + }, + "app/agent/callback/__init__.py:691:run_in_threadpool": { + "owner": "StreamingHandler._flush", + "target": "run_in_threadpool" + }, + "app/agent/capabilities/adapter.py:117:to_thread": { + "owner": "AgentServiceAdapter.materialize", + "target": "to_thread" + }, + "app/agent/lifecycle.py:104:create_task": { + "owner": "AgentLifecycleOwner.close", + "target": "create_task" + }, + "app/agent/lifecycle.py:33:create_task": { + "owner": "AgentLifecycleOwner.initialize", + "target": "create_task" + }, + "app/agent/llm/discovery.py:195:to_thread": { + "owner": "_ProviderDiscovery._load_models_dev_from_disk", + "target": "to_thread" + }, + "app/agent/llm/discovery.py:223:to_thread": { + "owner": "_ProviderDiscovery._write_models_dev_to_disk", + "target": "to_thread" + }, + "app/agent/llm/discovery.py:684:to_thread": { + "owner": "_ProviderDiscovery._list_models_from_bedrock", + "target": "to_thread" + }, + "app/agent/llm/discovery.py:691:to_thread": { + "owner": "_ProviderDiscovery._list_models_from_bedrock", + "target": "to_thread" + }, + "app/agent/mcp.py:215:create_task": { + "owner": "_StdioMcpSession.__aenter__", + "target": "create_task" + }, + "app/agent/memory/__init__.py:64:create_task": { + "owner": "MemoryManager.initialize", + "target": "create_task" + }, + "app/agent/middleware/subagents.py:1091:create_task": { + "owner": "SubAgentTaskControlMiddleware._run_pipeline", + "target": "create_task" + }, + "app/agent/middleware/subagents.py:841:create_task": { + "owner": "SubAgentTaskControlMiddleware._start_tasks", + "target": "create_task" + }, + "app/agent/orchestrator.py:1050:run_in_threadpool": { + "owner": "MoviePilotAgent._deliver_private_channel_message", + "target": "run_in_threadpool" + }, + "app/agent/session.py:374:create_task": { + "owner": "AgentSessionOwner.process_message", + "target": "create_task" + }, + "app/agent/session.py:655:create_task": { + "owner": "AgentSessionOwner._stop_current_task_locked", + "target": "create_task" + }, + "app/agent/session.py:71:run_in_threadpool": { + "owner": "_async_start_processing_status", + "target": "run_in_threadpool" + }, + "app/agent/session.py:755:create_task": { + "owner": "AgentSessionOwner._schedule_deferred_session_cleanup", + "target": "create_task" + }, + "app/agent/session.py:83:run_in_threadpool": { + "owner": "_async_finish_processing_status", + "target": "run_in_threadpool" + }, + "app/agent/tools/base.py:199:ThreadPoolExecutor": { + "owner": "_submit_blocking_call", + "target": "ThreadPoolExecutor" + }, + "app/agent/tools/impl/_terminal_session.py:257:create_task": { + "owner": "_TerminalSessionManager.start", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:302:create_task": { + "owner": "_TerminalSessionManager._start_pty_session", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:303:create_task": { + "owner": "_TerminalSessionManager._start_pty_session", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:326:create_task": { + "owner": "_TerminalSessionManager._start_pipe_session", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:330:create_task": { + "owner": "_TerminalSessionManager._start_pipe_session", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:332:create_task": { + "owner": "_TerminalSessionManager._start_pipe_session", + "target": "create_task" + }, + "app/agent/tools/impl/_terminal_session.py:372:to_thread": { + "owner": "_TerminalSessionManager._wait_pty_process", + "target": "to_thread" + }, + "app/agent/tools/impl/_terminal_session.py:477:to_thread": { + "owner": "_TerminalSessionManager.write", + "target": "to_thread" + }, + "app/agent/tools/impl/execute_command.py:475:create_task": { + "owner": "ExecuteCommandTool._run_once", + "target": "create_task" + }, + "app/agent/tools/impl/execute_command.py:477:create_task": { + "owner": "ExecuteCommandTool._run_once", + "target": "create_task" + }, + "app/agent/tools/impl/execute_command.py:478:create_task": { + "owner": "ExecuteCommandTool._run_once", + "target": "create_task" + }, + "app/api/endpoints/dashboard.py:183:run_in_threadpool": { + "owner": "schedule", + "target": "run_in_threadpool" + }, + "app/api/endpoints/dashboard.py:214:run_in_threadpool": { + "owner": "schedule2", + "target": "run_in_threadpool" + }, + "app/api/endpoints/history.py:111:to_thread": { + "owner": "_request_batch_durable_retries", + "target": "to_thread" + }, + "app/api/endpoints/history.py:447:to_thread": { + "owner": "ai_redo_transfer_history", + "target": "to_thread" + }, + "app/api/endpoints/search.py:218:create_task": { + "owner": "_iter_batched_search_events", + "target": "create_task" + }, + "app/application/agenttask.py:286:create_task": { + "owner": "AgentTaskExecutionService.claim", + "target": "create_task" + }, + "app/application/agenttask.py:293:create_task": { + "owner": "AgentTaskExecutionService.claim", + "target": "create_task" + }, + "app/application/agenttask.py:359:create_task": { + "owner": "AgentTaskExecutionService._finalize", + "target": "create_task" + }, + "app/application/messaging/agent.py:1023:run_in_threadpool": { + "owner": "get_web_agent_upload_dir", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1047:run_in_threadpool": { + "owner": "save_web_agent_upload", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1263:run_in_threadpool": { + "owner": "prepare_web_agent_audio_attachment_path_async", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1271:run_in_threadpool": { + "owner": "prepare_web_agent_audio_attachment_path_async", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1278:run_in_threadpool": { + "owner": "prepare_web_agent_audio_attachment_path_async", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1289:run_in_threadpool": { + "owner": "prepare_web_agent_audio_attachment_path_async.cleanup_conversion_output", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1311:run_in_threadpool": { + "owner": "prepare_web_agent_audio_attachment_path_async", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1417:to_thread": { + "owner": "transcribe_web_agent_audio_input", + "target": "to_thread" + }, + "app/application/messaging/agent.py:1772:run_in_threadpool": { + "owner": "collect_web_agent_traditional_events", + "target": "run_in_threadpool" + }, + "app/application/messaging/agent.py:1802:to_thread": { + "owner": "collect_web_agent_traditional_events", + "target": "to_thread" + }, + "app/application/messaging/agent.py:1875:create_task": { + "owner": "_build_traditional_web_agent_stream.event_generator", + "target": "create_task" + }, + "app/application/messaging/agent.py:715:create_task": { + "owner": "WebAgentEventPublisher.__init__", + "target": "create_task" + }, + "app/application/messaging/message.py:892:Thread": { + "owner": "MessageQueueManager.start", + "target": "Thread" + }, + "app/application/plugin/catalog.py:195:ThreadPoolExecutor": { + "owner": "PluginCatalogService.collect", + "target": "ThreadPoolExecutor" + }, + "app/application/plugin/catalog.py:255:create_task": { + "owner": "PluginCatalogService.async_collect", + "target": "create_task" + }, + "app/application/plugin/catalog.py:260:create_task": { + "owner": "PluginCatalogService.async_collect", + "target": "create_task" + }, + "app/application/plugin/install.py:661:create_task": { + "owner": "PluginInstallCommand.__resolve_journal_created", + "target": "create_task" + }, + "app/application/plugin/install.py:696:create_task": { + "owner": "PluginInstallCommand.__resolve_commit_outcome", + "target": "create_task" + }, + "app/application/plugin/install.py:851:create_task": { + "owner": "PluginInstallCommand.__rollback_cancelled", + "target": "create_task" + }, + "app/application/plugin/install.py:871:ensure_future": { + "owner": "PluginInstallCommand.__await_side_effect", + "target": "ensure_future" + }, + "app/application/plugin/inventory.py:116:create_task": { + "owner": "PluginCandidateInventoryReader.async_load", + "target": "create_task" + }, + "app/application/plugin/inventory.py:131:to_thread": { + "owner": "PluginCandidateInventoryReader.async_load", + "target": "to_thread" + }, + "app/application/plugin/inventory.py:181:to_thread": { + "owner": "PluginCandidateInventoryReader._async_read_market_generation", + "target": "to_thread" + }, + "app/chain/_recognition.py:527:run_in_threadpool": { + "owner": "RecognitionMixin._async_run_recognition_steps", + "target": "run_in_threadpool" + }, + "app/chain/media/album.py:180:run_in_threadpool": { + "owner": "MediaAlbumOwner._async_match_music_album_directory", + "target": "run_in_threadpool" + }, + "app/chain/media/album.py:216:run_in_threadpool": { + "owner": "MediaAlbumOwner.async_recognize_music_album_directory", + "target": "run_in_threadpool" + }, + "app/chain/media/album.py:218:run_in_threadpool": { + "owner": "MediaAlbumOwner.async_recognize_music_album_directory", + "target": "run_in_threadpool" + }, + "app/chain/media/path.py:381:run_in_threadpool": { + "owner": "MediaPathOwner._async_music_album_dir_fallback", + "target": "run_in_threadpool" + }, + "app/chain/media/path.py:427:run_in_threadpool": { + "owner": "MediaPathOwner.async_recognize_music_by_path", + "target": "run_in_threadpool" + }, + "app/chain/search/media.py:734:run_in_threadpool": { + "owner": "SearchMediaOwner._run_media_process_async", + "target": "run_in_threadpool" + }, + "app/chain/search/media.py:936:run_in_threadpool": { + "owner": "SearchMediaOwner.async_process_stream", + "target": "run_in_threadpool" + }, + "app/chain/search/music.py:236:run_in_threadpool": { + "owner": "SearchMusicOwner._async_process_music", + "target": "run_in_threadpool" + }, + "app/chain/search/music.py:282:run_in_threadpool": { + "owner": "SearchMusicOwner._async_process_music_stream", + "target": "run_in_threadpool" + }, + "app/chain/search/subtitle.py:429:run_in_threadpool": { + "owner": "SearchSubtitleOwner._async_search_subtitles_for_media", + "target": "run_in_threadpool" + }, + "app/chain/search/subtitle.py:526:run_in_threadpool": { + "owner": "SearchSubtitleOwner._async_search_subtitles_for_media_stream", + "target": "run_in_threadpool" + }, + "app/chain/search/title.py:248:run_in_threadpool": { + "owner": "SearchTitleOwner._run_title_search_async", + "target": "run_in_threadpool" + }, + "app/chain/search/title.py:382:run_in_threadpool": { + "owner": "SearchTitleOwner.async_search_by_title_stream", + "target": "run_in_threadpool" + }, + "app/chain/transfer/queue.py:149:Thread": { + "owner": "TransferQueueOwner._TransferChain__init", + "target": "Thread" + }, + "app/chain/transfer/queue.py:317:to_thread": { + "owner": "TransferQueueOwner.close", + "target": "to_thread" + }, + "app/chain/transfer/queue.py:405:Thread": { + "owner": "TransferQueueOwner._TransferChain__ensure_recovery_scheduler", + "target": "Thread" + }, + "app/chain/transfer/queue.py:469:Thread": { + "owner": "TransferQueueOwner._TransferChain__start_lease_heartbeat_owner_locked", + "target": "Thread" + }, + "app/chain/transfer/queue.py:717:Thread": { + "owner": "TransferQueueOwner._TransferChain__start_lease_release_owner_locked", + "target": "Thread" + }, + "app/db/worker.py:82:ThreadPoolExecutor": { + "owner": "DatabaseWorker.start", + "target": "ThreadPoolExecutor" + }, + "app/main.py:179:Thread": { + "owner": "start_tray", + "target": "Thread" + }, + "app/modules/acoustid/__init__.py:148:run_in_threadpool": { + "owner": "AcoustIdModule.async_identify_music_by_fingerprint", + "target": "run_in_threadpool" + }, + "app/modules/discord/discord.py:220:create_task": { + "owner": "Discord._start.runner", + "target": "create_task" + }, + "app/modules/discord/discord.py:248:Thread": { + "owner": "Discord._start", + "target": "Thread" + }, + "app/modules/discord/discord.py:731:create_task": { + "owner": "Discord._start_typing_task", + "target": "create_task" + }, + "app/modules/discord/discord.py:924:run_in_threadpool": { + "owner": "Discord._send_file", + "target": "run_in_threadpool" + }, + "app/modules/feishu/feishu.py:215:Thread": { + "owner": "Feishu._start_ws_client", + "target": "Thread" + }, + "app/modules/indexer/spider/__init__.py:400:run_in_threadpool": { + "owner": "SiteSpider.async_get_torrents", + "target": "run_in_threadpool" + }, + "app/modules/qqbot/gateway.py:134:Thread": { + "owner": "run_gateway.start_heartbeat", + "target": "Thread" + }, + "app/modules/qqbot/qqbot.py:144:Thread": { + "owner": "QQBot._start_gateway", + "target": "Thread" + }, + "app/modules/telegram/telegram.py:221:Thread": { + "owner": "Telegram.__init__", + "target": "Thread" + }, + "app/modules/telegram/telegram.py:544:Thread": { + "owner": "Telegram._start_typing_task", + "target": "Thread" + }, + "app/modules/thetvdb/__init__.py:291:run_in_threadpool": { + "owner": "TheTvDbModule._async_load_auxiliary_candidates", + "target": "run_in_threadpool" + }, + "app/modules/wechat/wechatbot.py:122:Thread": { + "owner": "WeChatBot._start_gateway", + "target": "Thread" + }, + "app/modules/wechat/wechatbot.py:124:Thread": { + "owner": "WeChatBot._start_gateway", + "target": "Thread" + }, + "app/modules/wechatclawbot/wechatclawbot.py:1732:Thread": { + "owner": "WechatClawBot._start_polling", + "target": "Thread" + }, + "app/monitor/monitor.py:1111:Thread": { + "owner": "Monitor.__request_scheduler_stop", + "target": "Thread" + }, + "app/monitor/monitor.py:735:Thread": { + "owner": "Monitor.__start_compensation", + "target": "Thread" + }, + "app/monitor/recovery.py:168:Thread": { + "owner": "RecoveryExecutor._start", + "target": "Thread" + }, + "app/monitor/watcher.py:126:Thread": { + "owner": "LocalDirectoryWatcher.start", + "target": "Thread" + }, + "app/runtime/capabilities/runtime.py:1514:to_thread": { + "owner": "CapabilityRuntime.shutdown_async", + "target": "to_thread" + }, + "app/runtime/coalesce.py:171:create_task": { + "owner": "EventCoalescer._on_flush_timer", + "target": "create_task" + }, + "app/runtime/debounce.py:136:Timer": { + "owner": "Debouncer._call_leading", + "target": "Timer" + }, + "app/runtime/debounce.py:161:Timer": { + "owner": "Debouncer._call_trailing", + "target": "Timer" + }, + "app/runtime/debounce.py:221:create_task": { + "owner": "AsyncDebouncer._call_leading", + "target": "create_task" + }, + "app/runtime/debounce.py:241:create_task": { + "owner": "AsyncDebouncer._call_trailing", + "target": "create_task" + }, + "app/runtime/event/dispatch.py:260:run_in_threadpool": { + "owner": "EventDispatcher.invoke_async", + "target": "run_in_threadpool" + }, + "app/runtime/event/dispatch.py:292:run_in_threadpool": { + "owner": "EventDispatcher.invoke_async_strict", + "target": "run_in_threadpool" + }, + "app/runtime/events.py:229:Thread": { + "owner": "EventManager.start", + "target": "Thread" + }, + "app/runtime/events.py:262:to_thread": { + "owner": "EventManager.stop_async", + "target": "to_thread" + }, + "app/runtime/execution.py:153:create_task": { + "owner": "run_in_threadpool_to_completion", + "target": "create_task" + }, + "app/runtime/execution.py:153:run_in_threadpool": { + "owner": "run_in_threadpool_to_completion", + "target": "run_in_threadpool" + }, + "app/runtime/extensions/plugin/monitor.py:55:Thread": { + "owner": "PluginMonitorController.start", + "target": "Thread" + }, + "app/runtime/extensions/plugin/sync.py:108:ThreadPoolExecutor": { + "owner": "PluginSyncService.sync", + "target": "ThreadPoolExecutor" + }, + "app/runtime/extensions/resource.py:129:to_thread": { + "owner": "AsyncManagedResourceAdapter.materialize", + "target": "to_thread" + }, + "app/runtime/gc.py:78:Process": { + "owner": "get_memory_usage", + "target": "Process" + }, + "app/runtime/log.py:143:Thread": { + "owner": "NonBlockingFileHandler.__init__", + "target": "Thread" + }, + "app/runtime/log.py:303:Thread": { + "owner": "NonBlockingFileHandler._close_handlers_bounded", + "target": "Thread" + }, + "app/runtime/resources.py:133:to_thread": { + "owner": "acquire_managed_resource_async", + "target": "to_thread" + }, + "app/runtime/resources.py:189:to_thread": { + "owner": "stop_managed_resource_async", + "target": "to_thread" + }, + "app/runtime/state.py:335:Thread": { + "owner": "SystemHelper._start_graceful_shutdown_monitor", + "target": "Thread" + }, + "app/runtime/state.py:99:Process": { + "owner": "SystemHelper._is_local_cli_managed", + "target": "Process" + }, + "app/runtime/tasks.py:63:create_task": { + "owner": "TaskRegistry.create", + "target": "create_task" + }, + "app/runtime/tasks.py:80:to_thread": { + "owner": "TaskRegistry.create_sync", + "target": "to_thread" + }, + "app/scheduler/bridge.py:186:create_task": { + "owner": "SchedulerBridgeOwner._submit_cross_thread.start_on_target_loop", + "target": "create_task" + }, + "app/scheduler/bridge.py:248:create_task": { + "owner": "SchedulerBridgeOwner._submit_to_loop", + "target": "create_task" + }, + "app/scheduler/catalog.py:187:ThreadPoolExecutor": { + "owner": "SchedulerCatalogOwner._initialize_catalog", + "target": "ThreadPoolExecutor" + }, + "app/scheduler/execution.py:224:create_task": { + "owner": "SchedulerExecutionOwner.start.__start_coro", + "target": "create_task" + }, + "app/scheduler/execution.py:301:Process": { + "owner": "SchedulerExecutionOwner.start", + "target": "Process" + }, + "app/scheduler/lifecycle.py:126:to_thread": { + "owner": "SchedulerLifecycleOwner.stop_async", + "target": "to_thread" + }, + "app/scheduler/lifecycle.py:29:to_thread": { + "owner": "SchedulerLifecycleOwner.on_config_changed", + "target": "to_thread" + }, + "app/startup/composition/subscription.py:93:to_thread": { + "owner": "async_rule_group_mutation_scope", + "target": "to_thread" + }, + "app/startup/lifecycle/__init__.py:161:create_task": { + "owner": "run_shutdown_step", + "target": "create_task" + }, + "app/startup/lifecycle/__init__.py:365:create_task": { + "owner": "schedule_plugin_settlement", + "target": "create_task" + } +} diff --git a/tests/test_complexity_gate.py b/tests/test_complexity_gate.py index 198f841eb..00b376294 100644 --- a/tests/test_complexity_gate.py +++ b/tests/test_complexity_gate.py @@ -1,6 +1,6 @@ """复杂度只降不增 ratchet 测试。""" -from scripts.architecture.complexity import compare_complexity +from scripts.architecture.complexity import compare_complexity, compare_complexity_v2 def test_complexity_ratchet_allows_removal_and_reduction() -> None: @@ -39,3 +39,18 @@ def test_complexity_ratchet_rejects_growth_and_new_oversize() -> None: assert any("既有超限增长" in problem for problem in problems) assert any("新增超限" in problem for problem in problems) + + +def test_complexity_v2_ratchet_checks_categories_outside_public_rules() -> None: + """v2 基线必须实际比较私有方法、类和文件类别。""" + baseline = {"method": {"app/scheduler/a.py:Runner.run": 160}, "class": {}, "file": {}} + current = { + "method": {"app/scheduler/a.py:Runner.run": 161}, + "class": {"app/scheduler/a.py:Runner": 600}, + "file": {"app/scheduler/a.py": 1200}, + } + + problems = compare_complexity_v2(baseline, current) + assert any("method: 既有超限增长" in problem for problem in problems) + assert any("class: 新增超限" in problem for problem in problems) + assert any("file: 新增超限" in problem for problem in problems) diff --git a/tests/test_concurrency_gate.py b/tests/test_concurrency_gate.py new file mode 100644 index 000000000..57348cbc7 --- /dev/null +++ b/tests/test_concurrency_gate.py @@ -0,0 +1,62 @@ +"""原生并发事实门禁回归测试。""" + +import textwrap +from pathlib import Path + +from scripts.architecture.concurrency import collect_concurrency, compare_concurrency + + +def test_concurrency_gate_tracks_nested_owners_and_known_primitives(tmp_path: Path) -> None: + """嵌套函数、类方法和常见原生并发入口都必须被枚举。""" + source_path = tmp_path / "app/scheduler/sample.py" + source_path.parent.mkdir(parents=True) + source_path.write_text( + textwrap.dedent( + """ + import asyncio + import threading + from concurrent.futures import ThreadPoolExecutor + + class Runner: + async def run(self): + asyncio.create_task(self.child()) + threading.Timer(1, self.child).start() + with ThreadPoolExecutor() as executor: + executor.submit(self.child) + + async def child(self): + await asyncio.to_thread(lambda: None) + """ + ), + encoding="utf-8", + ) + + facts = collect_concurrency(tmp_path, scan_roots=("app/scheduler",)) + assert facts["app/scheduler/sample.py:8:create_task"]["owner"] == "Runner.run" + assert facts["app/scheduler/sample.py:9:Timer"]["owner"] == "Runner.run" + assert facts["app/scheduler/sample.py:10:ThreadPoolExecutor"]["owner"] == "Runner.run" + assert facts["app/scheduler/sample.py:14:to_thread"]["owner"] == "Runner.child" + + +def test_concurrency_ratchet_rejects_new_facts_and_owner_drift() -> None: + """新增并发调用和 owner 漂移必须阻断 ratchet。""" + baseline = { + "app/runtime/tasks.py:10:create_task": { + "owner": "Runtime.run", + "target": "create_task", + }, + } + current = { + "app/runtime/tasks.py:10:create_task": { + "owner": "Runtime.changed", + "target": "create_task", + }, + "app/api/system.py:20:Thread": { + "owner": "system", + "target": "Thread", + }, + } + + problems = compare_concurrency(baseline, current) + assert any("owner 漂移" in problem for problem in problems) + assert any("新增原生并发事实" in problem for problem in problems)