From dd246fe045e563042ebe300bc251ade754e5f235 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 21 Aug 2026 22:23:37 +0800 Subject: [PATCH] ci: enforce complexity reduction ratchet --- .github/workflows/test.yml | 3 + .../backend-architecture-next-stage.md | 10 ++ scripts/architecture/complexity.py | 120 ++++++++++++++++++ .../architecture/complexity-baseline.json | 36 ++++++ tests/test_complexity_gate.py | 41 ++++++ 5 files changed, 210 insertions(+) create mode 100644 scripts/architecture/complexity.py create mode 100644 tests/fixtures/architecture/complexity-baseline.json create mode 100644 tests/test_complexity_gate.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0a13cb71..37c9a3822 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,9 @@ jobs: - name: Check governed Python types 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 + pytest: runs-on: ubuntu-latest name: Unit Tests (${{ matrix.shard }}) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 0c0646715..77a1bdbb5 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -811,6 +811,16 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op- 优先拆分对象:`do_transfer`、`batch_download`、`SubscribeChain.match`、`web_agent_stream`、`Scheduler.init`。先提取 phase object/DTO/port,再缩短入口;不创建一批互相读写同一个大 dict 的私有函数来“达标”。 +**实施记录(2026-08-21)**: + +- 新增 `scripts/architecture/complexity.py`,通过 AST 只统计 API HTTP endpoint、Application public method + 和 Chain public use-case,预算分别为 80/150/150 行;嵌套 helper 不会被机械重复计数。 +- `complexity-baseline.json` 只保存当前超限入口和行数,不把达标方法写成永久快照。check 允许缩短、达标或删除, + 精确拒绝既有超限增长和任何新增超限;CI architecture job 每次执行。 +- 当前债务清单明确包含 `web_agent_stream`、`batch_download`、`SubscribeChain.match`、`do_transfer`; + `Scheduler.init` 已在 ARCH-252 通过 JobSpec/catalog 拆分退出超限清单,调度专项测试是该代表性拆分的回归证据。 +- 单元测试覆盖删除/缩短放行和增长/新增拒绝,当前仓库 baseline check 通过。 + #### ARCH-272:异步阻塞检测 **目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。 diff --git a/scripts/architecture/complexity.py b/scripts/architecture/complexity.py new file mode 100644 index 000000000..fdbb23523 --- /dev/null +++ b/scripts/architecture/complexity.py @@ -0,0 +1,120 @@ +"""为 API/Application/Chain 公共入口维护只降不增的行数预算。""" + +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/complexity-baseline.json" + + +@dataclass(frozen=True, slots=True) +class ComplexityRule: + """描述目录、入口选择器与最大源代码行数。""" + + name: str + root: str + budget: int + endpoint_only: bool = False + + +RULES = ( + ComplexityRule("api_endpoint", "app/api/endpoints", 80, endpoint_only=True), + ComplexityRule("application_public", "app/application", 150), + ComplexityRule("chain_public", "app/chain", 150), +) + + +def _is_endpoint(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """识别带常见 HTTP method decorator 的 API endpoint。""" + methods = {"get", "post", "put", "patch", "delete", "options", "head"} + for decorator in node.decorator_list: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + if isinstance(target, ast.Attribute) and target.attr.lower() in methods: + return True + return False + + +def _public_entries( + tree: ast.Module, endpoint_only: bool +) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: + """产出顶层函数和类直接拥有的公共方法,不把嵌套 helper 重复计数。""" + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not node.name.startswith("_") and ( + not endpoint_only or _is_endpoint(node) + ): + yield node.name, node + elif isinstance(node, ast.ClassDef): + for method in node.body: + if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not method.name.startswith("_") and ( + not endpoint_only or _is_endpoint(method) + ): + yield f"{node.name}.{method.name}", method + + +def collect_complexity(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int]]: + """收集每条规则下当前超过预算的入口及其精确源代码行数。""" + report: dict[str, dict[str, int]] = {} + for rule in RULES: + debt: dict[str, int] = {} + for path in sorted((root / rule.root).rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + relative = path.relative_to(root).as_posix() + for qualname, node in _public_entries(tree, rule.endpoint_only): + line_count = (node.end_lineno or node.lineno) - node.lineno + 1 + if line_count > rule.budget: + debt[f"{relative}:{qualname}"] = line_count + report[rule.name] = debt + return report + + +def compare_complexity( + baseline: dict[str, dict[str, int]], current: dict[str, dict[str, int]] +) -> list[str]: + """返回新增超限或既有超限增长问题;删除和缩短均合法。""" + problems = [] + for rule in RULES: + previous = baseline.get(rule.name, {}) + for entry, line_count in current.get(rule.name, {}).items(): + if entry not in previous: + problems.append(f"{rule.name}: 新增超限 {entry}={line_count}>{rule.budget}") + elif line_count > previous[entry]: + problems.append( + f"{rule.name}: 既有超限增长 {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("--baseline", type=Path, default=DEFAULT_BASELINE) + args = parser.parse_args() + current = collect_complexity() + 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_complexity(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-baseline.json b/tests/fixtures/architecture/complexity-baseline.json new file mode 100644 index 000000000..b357500e6 --- /dev/null +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -0,0 +1,36 @@ +{ + "api_endpoint": { + "app/api/endpoints/agent.py:web_agent_stream": 346, + "app/api/endpoints/download.py:add": 92, + "app/api/endpoints/mcp.py:mcp_jsonrpc": 83, + "app/api/endpoints/media.py:scrape": 105, + "app/api/endpoints/openai.py:chat_completions": 107, + "app/api/endpoints/openai.py:responses": 105, + "app/api/endpoints/system.py:get_logging": 111, + "app/api/endpoints/system.py:nettest": 87, + "app/api/endpoints/torrent.py:reidentify_cache": 147 + }, + "application_public": { + "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/rss.py:RssHelper.parse": 206, + "app/application/security/cookie.py:CookieHelper.get_site_cookie_ua": 221 + }, + "chain_public": { + "app/chain/download.py:DownloadChain.batch_download": 572, + "app/chain/download.py:DownloadChain.download_single": 276, + "app/chain/download.py:DownloadChain.get_no_exists_info": 152, + "app/chain/mediaserver.py:MediaServerChain.sync": 292, + "app/chain/site.py:SiteChain.sync_cookies": 180, + "app/chain/subscribe.py:SubscribeChain.add": 183, + "app/chain/subscribe.py:SubscribeChain.async_add": 186, + "app/chain/subscribe.py:SubscribeChain.match": 417, + "app/chain/subscribe.py:SubscribeChain.search": 249, + "app/chain/subscribe.py:SubscribeChain.subscribe_files_info": 199, + "app/chain/torrents.py:TorrentsChain.refresh": 235, + "app/chain/transfer.py:TransferChain.do_transfer": 885, + "app/chain/transfer.py:TransferChain.process": 154 + } +} diff --git a/tests/test_complexity_gate.py b/tests/test_complexity_gate.py new file mode 100644 index 000000000..198f841eb --- /dev/null +++ b/tests/test_complexity_gate.py @@ -0,0 +1,41 @@ +"""复杂度只降不增 ratchet 测试。""" + +from scripts.architecture.complexity import compare_complexity + + +def test_complexity_ratchet_allows_removal_and_reduction() -> None: + """既有超限入口被删除或缩短必须允许合并。""" + baseline = { + "api_endpoint": {"app/api/endpoints/a.py:large": 100}, + "application_public": {"app/application/a.py:Service.run": 180}, + "chain_public": {}, + } + current = { + "api_endpoint": {"app/api/endpoints/a.py:large": 90}, + "application_public": {}, + "chain_public": {}, + } + + assert compare_complexity(baseline, current) == [] + + +def test_complexity_ratchet_rejects_growth_and_new_oversize() -> None: + """既有入口增长和新增超预算入口必须同时给出精确诊断。""" + baseline = { + "api_endpoint": {"app/api/endpoints/a.py:large": 100}, + "application_public": {}, + "chain_public": {}, + } + current = { + "api_endpoint": { + "app/api/endpoints/a.py:large": 101, + "app/api/endpoints/b.py:new_endpoint": 81, + }, + "application_public": {}, + "chain_public": {}, + } + + problems = compare_complexity(baseline, current) + + assert any("既有超限增长" in problem for problem in problems) + assert any("新增超限" in problem for problem in problems)