diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c22d74e7..9a9b4bd14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,10 +109,19 @@ jobs: - name: Check governed Python types run: uv run --locked --no-sync mypy --config-file mypy.ini - - name: Check complexity ratchet - run: | + - name: Check structural complexity ratchet + run: >- uv run --locked --no-sync python scripts/architecture/complexity.py - uv run --locked --no-sync python scripts/architecture/complexity.py --v2 + --report complexity-report.json + + - name: Upload complexity and source size report + if: always() + uses: actions/upload-artifact@v7 + with: + name: complexity-report + path: complexity-report.json + if-no-files-found: warn + retention-days: 7 - name: Check native concurrency ownership run: uv run --locked --no-sync python scripts/architecture/concurrency.py diff --git a/AGENTS.md b/AGENTS.md index 9b639520a..a9d6c363b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,6 @@ uv run --locked --no-sync pytest \ uv run --locked --no-sync python scripts/architecture/baseline.py --check-host uv run --locked --no-sync mypy --config-file mypy.ini uv run --locked --no-sync python scripts/architecture/complexity.py -uv run --locked --no-sync python scripts/architecture/complexity.py --v2 uv run --locked --no-sync python scripts/architecture/concurrency.py uv run --locked --no-sync python scripts/architecture/async_blocking.py uv run --locked --no-sync python scripts/architecture/task_ownership.py @@ -174,7 +173,7 @@ uv run --locked --no-sync python scripts/startup/performance.py --check --repeat Apply the following acceptance rules: * **No baseline laundering:** A failed baseline or ratchet is evidence to inspect, not permission to regenerate fixtures. Never use `--write-host`, `--write-plugins`, or any ratchet `--write` merely to make a gate pass. Fix added dependencies, cycles, layer violations, direct egress, service locators, blocking calls, unmanaged tasks, type/lint growth, complexity growth, or concurrency growth in the implementation. -* **Reviewed baseline updates only:** Update a fixture only when the task intentionally changes the governed contract or the tool reports a genuine lower debt watermark. First prove the change complies with architecture policy, inspect the semantic diff, update coupled documentation/tests, run the non-writing check again, and include the fixture diff in the same commit. Complexity, concurrency, async-blocking, and host snapshot writers can overwrite regressions mechanically, so their ability to write is not approval to do so. +* **Reviewed baseline updates only:** Update a fixture only when the task intentionally changes the governed contract or the tool reports a genuine lower debt watermark. First prove the change complies with architecture policy, inspect the semantic diff, update coupled documentation/tests, run the non-writing check again, and include the fixture diff in the same commit. Complexity uses per-function C901/PLR1702 metrics across host Python sources; physical line counts are advisory only. Its writer rejects growth and its check requires reduced debt to be recorded. Concurrency, async-blocking, and host snapshot writers can overwrite regressions mechanically, so their ability to write is not approval to do so. * **Diagnose snapshot failures:** When `baseline.py --check-host` fails unexpectedly, rerun it with `--diagnostics` and inspect the affected JSON under `tests/fixtures/architecture/`. Do not infer that a changed snapshot is acceptable from test success alone. * **Test the behavior:** Run focused pytest coverage for every changed behavior. Run `uv run --locked --no-sync python tests/run.py` before commit for dependency/lock changes, shared test infrastructure, database or startup paths, cross-module lifecycle, compatibility layers, or broad behavior changes. Do not weaken, skip, or delete tests to satisfy a gate without proving equivalent coverage. * **Check changed Python files:** For PR preparation, take the deduplicated union of `git diff --name-only --diff-filter=ACMRT ...HEAD -- '*.py'`, `git diff --cached --name-only --diff-filter=ACMRT -- '*.py'`, and `git diff --name-only --diff-filter=ACMRT -- '*.py'`, plus intended new files from `git ls-files --others --exclude-standard -- '*.py'`. Resolve `` to the actual target base ref (normally `upstream/v3`). Keep staged and unstaged comparisons separate: they can cancel against HEAD while the index still contains a broken change. Exclude unrelated local files from the planned commit. diff --git a/docs/code-quality.md b/docs/code-quality.md new file mode 100644 index 000000000..e2943d271 --- /dev/null +++ b/docs/code-quality.md @@ -0,0 +1,90 @@ +# 代码门禁:结构指标与扩围建议 + +2026-09-30 对 `v3@b4175a4f88421cf3515eeb651b8a4febe836642f` 的门禁进行审计,并在其上调整 +复杂度与严格类型检查。提交前衔接到最新 `v3@4a2ef7570659d587c2ebbddf02177443237734dc`, +初始结构基线按该版本重新采集;宿主业务源码与该版本完全一致。以下区分已落地规则与后续建议。 + +## 行数为什么不再阻断 + +旧 `complexity.py` v1 对公共 API 入口设置 80 行预算,对 Application/Chain 公共入口设置 +150 行预算;v2 对 API、Application、Chain、Scheduler 的函数、类、文件设置 +150/500/1000 行预算。函数和类使用 AST `end_lineno - lineno + 1`,会把 docstring、 +注释、空行和参数换行计入。它衡量源码尺寸,并不衡量分支复杂度;压缩格式能够降低数值, +补充契约说明却可能触发回退。同一长函数还可能同时消耗函数、类和文件三份预算。 + +源码尺寸仍适合寻找职责混杂热点,但不适合作为逐行禁止增长的合并条件。拆分应以稳定职责、 +可测试行为和清晰依赖为依据,不能为了满足行数而删除说明或搬运代码。 + +## 当前复杂度合同 + +`scripts/architecture/complexity.py` 使用 `uv.lock` 锁定的 Ruff,独立执行以下两条规则: + +| 指标 | 新增函数上限 | 存量处理 | +| --- | --- | --- | +| C901:McCabe 圈复杂度 | 15 | 超限项按函数登记,只能保持或下降 | +| PLR1702:嵌套控制块深度 | 5 | 同函数多个告警取最大深度,只能保持或下降 | +| 方法/类/文件源码尺寸 | 无硬限制 | 超过 150/500/1000 行出现在观察报告 | + +范围为所有 `app/**/*.py`,只排除 `app/plugins/**` 运行时插件副本;包含 Agent、Runtime、 +Modules、Adapters、Startup、Domain、DB、Workflow、私有方法、dunder 方法和嵌套函数。 +局部 Ruff 配置、`noqa` 与 `.gitignore` 不会隐藏这些指标。 + +基线身份使用“规则 + 相对文件路径 + 完整词法函数名”,不保存物理行号;同词法名的重复定义 +按源码顺序附加序号,避免 property 或条件重定义覆盖预算。移动或重命名超限函数会成为新项, +需要在职责迁移中单独审查,不能自动挪用旧额度。不同函数的增减也不能互相抵消。 + +首次从行数策略迁移到结构策略的快照为 C901 138 个函数、PLR1702 39 个函数;同一函数可能 +同时出现在两类中。这是本次迁移的历史证据,不要求后续数字保持不变。源码观察报告有 +62 个长函数、123 个大类和 66 个大文件。没有为建立新基线修改业务源码。 + +当前 `tests/fixtures/architecture/complexity-baseline.json` 是指标事实源,并记录策略版本 +和阈值;旧 `complexity-v2-baseline.json` 与 `--v2` 已退役。门禁行为为: + +- 新增超限或既有超限增长:检查失败,`--write` 也拒绝覆盖已有基线。 +- 指标降低、回到阈值内或函数删除:先提示低水位未固化,审查后 `--write` 收紧基线。 +- 语法错误、工具错误、不完整输出、无法映射的诊断、缺失源码或基线:失败,不能当作零债务。 +- `--report` 输出结构指标和源码尺寸。CI 上传 `complexity-report`,失败时也保留已生成报告。 + +```bash +uv run --locked --no-sync python scripts/architecture/complexity.py --report /tmp/moviepilot-complexity-report.json +uv run --locked --no-sync python scripts/architecture/complexity.py --write +uv run --locked --no-sync python scripts/architecture/complexity.py +``` + +`--write` 不是接受新债务的审批入口。首次初始化、算法升级、阈值调整和职责迁移需要审查 +源码、指标 diff 和相关测试;不得删除已有基线后重建来绕过增长检查。 + +算法口径跟随锁定的 Ruff,未自行实现另一套复杂度定义。 +[C901 的配置](https://docs.astral.sh/ruff/settings/#lint_mccabe_max-complexity) 衡量控制流分支, +不是运行时耗时、内存复杂度或完整认知复杂度。 +[PLR1702](https://docs.astral.sh/ruff/rules/too-many-nested-blocks/) 仍属 preview,只有此独立 +检查启用 preview;升级 Ruff 时应跑真实 Ruff 回归用例并审查指标 diff,不自动刷新基线。 +短路布尔表达式、推导式、跨函数调用链和职责耦合仍需行为测试与架构审查。 + +## 类型检查的本次扩围 + +`mypy.ini` 的严格范围从 42 个文件扩大到 45 个,新增: + +- `app/runtime/tasks.py`:后台任务登记、提交和关闭。 +- `app/scheduler/registry.py`:调度器代际、所有权与句柄。 +- `app/startup/lifecycle/components.py`:组件启动与关闭。 + +三个文件及完整严格清单实测通过,不增加忽略规则或类型债务。全宿主 mypy ratchet 继续保留。 +严格清单当前仍采用 `follow_imports = skip` 和 `ignore_missing_imports = True`,不能等同于 +传递依赖全部严格。Agent session/lifecycle 与 Scheduler lifecycle 的扩围需要一起处理父类、 +回调和返回值类型;全仓错误计数为零并不保证单独纳入 strict frontier 后也为零。 + +## 后续补充顺序 + +| 优先级 | 现有缺口 | 建议落点与验收 | +| --- | --- | --- | +| P0 | GitHub `v3` 查询结果 `protected=false`,生效分支规则为空 | 增加固定名称的 CI 汇总检查,再配置 required checks;汇总要正确处理成功复用、取消和失败,不能把所有 skipped 当成功。远端规则属于仓库策略,当前改动未更改它 | +| P1 | 覆盖率只阻断 Application/Domain 的聚合行覆盖率低于 80%;虽采集分支覆盖率,但没有分支下限 | 先为 transfer、download、scheduler、tasks、Agent 生命周期等关键文件组建立 Ubuntu 全量 CI 分支基线,再加入变更行覆盖率;避免用整个包的高覆盖率掩盖关键路径。macOS 报告只作诊断 | +| P1 | strict frontier 对部分父类和传递依赖仍视为 Any | 按“父类/Port/DTO → lifecycle → 调用者”扩到 Agent session、Scheduler lifecycle、插件管理器;先消除真实类型问题,再纳入,不增添 ignore | +| P1 | 漏洞审计位于正式版/Beta 发布链路,依赖兼容 PR 工作流只做平台安装验证 | 在依赖变更 PR 对同一 locked 运行时依赖做 pip-audit;不要复制一份依赖清单,也无需每个普通源码 PR 重跑多架构镜像扫描 | +| P2 | 常规 Ruff 仅启用 E4/E7/E9/F/I | 优先试运行 B006(可变默认参数)、B023(循环变量闭包捕获)、B018(无效表达式);审查误报后按范围纳入。审计时 app 中 B006 为 0、B023 为 5、B018 为 1,不自动把新诊断认定为已证实的 bug | +| P2 | 部分其它 ratchet 写入器仍能直接覆盖增长,Ruff/mypy 按文件计数可能发生同规则问题抵消 | 统一已有基线的写保护;对重点规则考虑稳定符号/诊断指纹。先补“无法洗入回退”的行为用例,避免只测 fixture 字符串 | + +依赖方向、循环依赖、直接出站、异步阻塞、后台任务所有权、原生并发和 service locator +已经有门禁,应保留并按实际漏报补强,不重复添加同义规则。鉴权、数据持久化、幂等、取消、 +崩溃恢复和插件 ABI 继续由行为/契约测试保护,源码尺寸或复杂度分值不能代替这些测试。 diff --git a/docs/development-setup.md b/docs/development-setup.md index b7d321f91..e23ac8947 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -243,6 +243,23 @@ Ruff/Mypy 基线只允许收紧,不接受新增诊断或类型错误增长; 跳过测试或产物规避超时。Coverage 只接受 GitHub Actions 的 Ubuntu/Python 3.14、locked 依赖和全量测试工件,本机 macOS 报告仅供诊断,不得写入并提交 canonical baseline。 +### 6.1 结构复杂度门禁 + +复杂度按全宿主函数的 Ruff C901(上限 15)与 PLR1702(嵌套上限 5)检查,排除 +`app/plugins/**`。方法、类和文件行数只生成观察报告,注释、空行和格式换行不再消耗硬预算。 +原来的 `--v2` 行数检查已退役。阈值以内允许正常开发;存量超限函数不能新增或继续增长。 + +```bash +uv run --locked --no-sync python scripts/architecture/complexity.py --report /tmp/moviepilot-complexity-report.json +# 仅在消除全部回退、确认真实下降后固化低水位 +uv run --locked --no-sync python scripts/architecture/complexity.py --write +uv run --locked --no-sync python scripts/architecture/complexity.py +``` + +`--write` 拒绝接受已有基线上的增长;减少或删除热点后必须固化低水位。 +CI 上传 `complexity-report` 工件,门禁失败时也保留已生成的指标。指标含义、限制与后续扩围见 +[代码门禁说明](code-quality.md)。 + ### 7. 参考资源 - [uv 官方文档](https://docs.astral.sh/uv/) diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index b75d977e3..ee7d22ac0 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -20,11 +20,13 @@ | RES-003 | `DELIVERED`(R3) | Outbox after_commit 失败只有内存 pending 标记,重启不可恢复 | `9d06f91bb`:持久 intent、唯一 handler、claim/fencing、重启回放、幂等和失败观测完整闭环 | | RES-004 | `DELIVERED`(R4) | Startup initializer 与插件市场存在多套 Transport/Adapter/Manager 构造 | `7f5b8b469` 至 `046b0b305`:构造回收到 composition,兼容门面消费同一 owner,canonical 无重复正式实现 | | RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、固定 80% 覆盖率门禁与规则一致;Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` | -R2 门禁现已完整覆盖私有、dunder、任意控制流嵌套方法、类、文件与 -`app/scheduler/`;`concurrency.py` 扫描完整宿主源码,按 canonical import/alias、 -TaskGroup、可证明的 loop/executor 来源和词法 owner 聚合数量。新增 owner、数量增长以及 -复杂度与静态质量事实下降后未刷新低水位都会阻断 CI;覆盖率只要求 Application 与 Domain -达到固定 80%,行号移动和普通同名方法不会制造噪音。 +R2 原复杂度 v1/v2 实际使用源码行跨度,只覆盖 API、Application、Chain 和 Scheduler, +且未强制固化下降后的低水位。2026-09-30 门禁调整将其替换为全宿主函数的 C901 圈复杂度 +(15)与 PLR1702 嵌套深度(5),源码尺寸降为观察报告;增长禁止写入,下降必须固化。 +范围、初始基线与后续建议见 [代码门禁说明](../code-quality.md)。 +`concurrency.py` 扫描完整宿主源码,按 canonical import/alias、TaskGroup、可证明的 +loop/executor 来源和词法 owner 聚合数量。新增 owner、数量增长以及静态质量事实下降后 +未刷新低水位都会阻断 CI;覆盖率只要求 Application 与 Domain 达到固定 80%。 ## 1. 结论摘要 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index a00073b90..a67926800 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -250,11 +250,15 @@ 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` -uses complete AST child traversal to ratchet private/dunder/nested methods, -class/file hotspots, and the `app/scheduler/` package, including owners nested -under `Match`, `TryStar`, and other control-flow nodes. Its generated baseline -is an evidence ledger, not permission to add another oversized owner. +surface rather than only public methods. `scripts/architecture/complexity.py` +checks Ruff C901 (McCabe complexity, limit 15) and PLR1702 (nested blocks, limit 5) +throughout `app/**/*.py`, excluding only runtime plugin copies in `app/plugins/**`. +Complete AST traversal maps diagnostics to stable private/dunder/nested function +owners, including definitions under `Match` and `TryStar`. Existing over-limit +functions may only decrease; growth is rejected even by `--write`, and reductions +must be recorded before the check passes. `--report` also records method/class/file +line-span hotspots for review, but physical line counts never block CI. The former +v1/v2 line-budget gates are retired; see [code quality](../code-quality.md). `scripts/architecture/concurrency.py` resolves canonical imports and aliases for native Thread, Timer, thread/process executors, Process, TaskGroup, event-loop task submission, asyncio task/thread helpers, and executor hand-off calls. It diff --git a/mypy.ini b/mypy.ini index 3466e52c7..023c067ca 100644 --- a/mypy.ini +++ b/mypy.ini @@ -16,6 +16,7 @@ files = app/runtime/log.py, app/runtime/observability/__init__.py, app/runtime/thread.py, + app/runtime/tasks.py, app/runtime/event/contracts.py, app/runtime/event/errors.py, app/runtime/extensions/module/contracts.py, @@ -42,6 +43,8 @@ files = app/db/uow.py, app/startup/composition/context.py, app/startup/composition/configuration.py, + app/startup/lifecycle/components.py, + app/scheduler/registry.py, app/db/adapters/outbox.py, app/db/adapters/chain.py, app/db/adapters/download.py, diff --git a/scripts/architecture/complexity.py b/scripts/architecture/complexity.py index 95e1c53d8..0cb8c50b0 100644 --- a/scripts/architecture/complexity.py +++ b/scripts/architecture/complexity.py @@ -1,57 +1,40 @@ -"""为 API/Application/Chain 公共入口维护只降不增的行数预算。""" +"""按函数维护圈复杂度与嵌套深度低水位;源码行数仅用于热点报告。""" from __future__ import annotations import argparse import ast import json -from dataclasses import dataclass +import re +import subprocess +import sys +from collections import Counter +from collections.abc import Iterable from pathlib import Path -from typing import Iterable +from typing import Any 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" +THRESHOLDS = {"C901": 15, "PLR1702": 5} +SCHEMA_VERSION = 3 +_METRIC = re.compile(r"\((\d+) > (\d+)\)$") -@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 _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 _iter_python_files(root: Path) -> list[Path]: + """完整扫描宿主 Python 源码,插件副本由插件仓治理。""" + paths = [ + path for path in sorted((root / "app").rglob("*.py")) + if not path.relative_to(root).as_posix().startswith("app/plugins/") + ] + if not paths: + raise ValueError("未找到宿主 Python 源码,拒绝生成空基线") + return paths def _walk_owned_nodes( - nodes: Iterable[ast.AST], - prefix: str = "", + nodes: Iterable[ast.AST], prefix: str = "", ) -> Iterable[tuple[str, ast.AST]]: - """通过完整 AST 子节点遍历收集类、方法和任意控制流中的嵌套 owner。""" + """遍历任意控制流下的类和函数,保留私有、dunder 与嵌套词法归属。""" for node in nodes: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): qualname = f"{prefix}.{node.name}" if prefix else node.name @@ -61,164 +44,180 @@ def _walk_owned_nodes( yield from _walk_owned_nodes(ast.iter_child_nodes(node), prefix) -def _walk_functions( - nodes: Iterable[ast.AST], -) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: - """收集含 dunder、Match、TryStar 在内的全部函数与方法 owner。""" - for qualname, node in _walk_owned_nodes(nodes): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - yield qualname, node +def _owned_nodes(tree: ast.Module) -> Iterable[tuple[str, ast.AST]]: + """为条件重定义和 property 同名定义分配序号,避免相互覆盖预算。""" + nodes = list(_walk_owned_nodes(tree.body)) + totals = Counter(name for name, _ in nodes) + seen: Counter[str] = Counter() + for name, node in nodes: + seen[name] += 1 + yield (f"{name}#{seen[name]}" if totals[name] > 1 else name), node -def _walk_classes( - nodes: Iterable[ast.AST], -) -> Iterable[tuple[str, ast.ClassDef]]: - """收集任意控制流和函数内部的类,并保留完整嵌套 owner。""" - for qualname, node in _walk_owned_nodes(nodes): - if isinstance(node, ast.ClassDef): - yield qualname, node - - -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, method_node in _walk_functions(tree.body): - line_count = (method_node.end_lineno or method_node.lineno) - method_node.lineno + 1 - if line_count > 150: - report["method"][f"{relative}:{qualname}"] = line_count - for qualname, class_node in _walk_classes(tree.body): - line_count = (class_node.end_lineno or class_node.lineno) - class_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"} - 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 run_ruff(root: Path) -> list[dict[str, Any]]: + """使用锁定的 Ruff 计算指标;配置、noqa 与 gitignore 不能隐藏新增复杂度。""" + result = subprocess.run( + [ + sys.executable, "-m", "ruff", "check", "app", + "--isolated", "--no-cache", "--no-respect-gitignore", "--ignore-noqa", + "--preview", "--target-version", "py314", "--select", ",".join(THRESHOLDS), + "--exclude", "app/plugins", "--config", 'include = ["*.py"]', + "--config", f"lint.mccabe.max-complexity={THRESHOLDS['C901']}", + "--config", f"lint.pylint.max-nested-blocks={THRESHOLDS['PLR1702']}", + "--output-format", "json", + ], + cwd=root, capture_output=True, text=True, check=False, + ) + if result.returncode not in {0, 1} or result.stderr.strip(): + raise ValueError(f"Ruff 执行失败:{result.stderr or result.stdout}") + diagnostics = json.loads(result.stdout) + if not isinstance(diagnostics, list) or bool(diagnostics) != (result.returncode == 1): + raise ValueError("Ruff 退出状态与诊断列表不一致") + return diagnostics 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 + """把 Ruff 诊断映射为规则、文件和词法 owner,不使用易变行号作为基线身份。""" + root = root.resolve() + owners = {} + for path in _iter_python_files(root): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owners[path.relative_to(root).as_posix()] = [ + (node.lineno, node.end_lineno, name) for name, node in _owned_nodes(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + report: dict[str, dict[str, int]] = {code: {} for code in THRESHOLDS} + for diagnostic in run_ruff(root): + code = diagnostic.get("code") + if code not in THRESHOLDS: + raise ValueError(f"Ruff 返回非预期诊断:{diagnostic}") + match = _METRIC.search(diagnostic["message"]) + if not match or int(match[2]) != THRESHOLDS[code] or int(match[1]) <= THRESHOLDS[code]: + raise ValueError(f"无法解析 Ruff 复杂度:{diagnostic}") + path = Path(diagnostic["filename"]).resolve().relative_to(root).as_posix() + row = diagnostic["location"]["row"] + candidates = [ + (start, name) for start, end, name in owners.get(path, []) + if start <= row <= end and (code != "C901" or start == row) + ] + if not candidates: + raise ValueError(f"Ruff 诊断无法定位函数:{diagnostic}") + owner = max(candidates)[1] + key = f"{path}:{owner}" + if code == "C901" and key in report[code]: + raise ValueError(f"Ruff 返回重复函数诊断:{code} {key}") + # PLR1702 按控制块报告,同一函数可能有多处;取最大深度保留逐函数预算。 + report[code][key] = max(report[code].get(key, 0), int(match[1])) 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 collect_source_size(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int]]: + """记录方法超过 150、类超过 500、文件超过 1000 行的观察项,不参与阻断。""" + report: dict[str, dict[str, int]] = {"method": {}, "class": {}, "file": {}} + for path in _iter_python_files(root): + relative = path.relative_to(root).as_posix() + source = path.read_text(encoding="utf-8") + if len(source.splitlines()) > 1000: + report["file"][relative] = len(source.splitlines()) + for name, node in _owned_nodes(ast.parse(source, filename=str(path))): + category, limit = ("class", 500) if isinstance(node, ast.ClassDef) else ("method", 150) + count = node.end_lineno - node.lineno + 1 + if count > limit: + report[category][f"{relative}:{name}"] = count + return report -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 baseline_payload(metrics: dict[str, dict[str, int]]) -> dict[str, Any]: + """把指标与算法版本、阈值一起持久化,避免静默改变门禁口径。""" + return {"schema_version": SCHEMA_VERSION, "thresholds": THRESHOLDS, "metrics": metrics} + + +def read_baseline(path: Path) -> dict[str, dict[str, int]]: + """严格校验基线格式,旧行数基线与策略变更必须显式迁移。""" + payload = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(payload, dict) + or set(payload) != {"schema_version", "thresholds", "metrics"} + or payload["schema_version"] != SCHEMA_VERSION + or payload["thresholds"] != THRESHOLDS + ): + raise ValueError("复杂度基线版本或阈值不匹配,必须审查指标迁移") + metrics = payload["metrics"] + if not isinstance(metrics, dict) or set(metrics) != set(THRESHOLDS): + raise ValueError("复杂度基线缺少规则或包含未知规则") + for code, entries in metrics.items(): + if not isinstance(entries, dict): + raise ValueError(f"复杂度基线 {code} 必须是函数指标对象") + for owner, value in entries.items(): + path, separator, name = owner.partition(":") + if ( + not separator or not name or not path.startswith("app/") + or not path.endswith(".py") or ".." in Path(path).parts + or path.startswith("app/plugins/") + or type(value) is not int or value <= THRESHOLDS[code] + ): + raise ValueError(f"复杂度基线存在无效指标:{code} {owner}={value}") + return metrics + + +def classify_complexity( + baseline: dict[str, dict[str, int]], current: dict[str, dict[str, int]], +) -> tuple[list[str], list[str]]: + """分别返回逐函数新增/增长与待固化下降,函数之间不能抵消债务。""" + regressions: list[str] = [] + stale: list[str] = [] + for code, threshold in THRESHOLDS.items(): + previous, latest = baseline.get(code, {}), current.get(code, {}) + for owner in sorted(previous.keys() | latest.keys()): + old, new = previous.get(owner, threshold), latest.get(owner, threshold) + if new > old: + regressions.append(f"{code}: 新增超限或复杂度增长 {owner} {old}->{new}") + elif new < old: + value = str(new) if owner in latest else f"<={threshold} 或已删除" + stale.append(f"{code}: 低水位未固化 {owner} {old}->{value}") + return regressions, stale + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + """以稳定排序写入可审查的 JSON 基线或观察报告。""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") 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("--write", action="store_true", help="首次建账或固化下降后的低水位") parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + parser.add_argument("--report", type=Path, help="输出结构指标与不阻断的源码行数热点") args = parser.parse_args() - if args.v2: - baseline_path = args.baseline if args.baseline != DEFAULT_BASELINE else V2_BASELINE - current_v2 = collect_complexity_v2() + try: + if args.report and args.report.resolve() == args.baseline.resolve(): + raise ValueError("报告路径不能覆盖复杂度基线") + current = collect_complexity() + if args.report: + _write_json(args.report, {**baseline_payload(current), "source_size": collect_source_size()}) + exists = args.baseline.exists() + baseline = read_baseline(args.baseline) if exists else {} + regressions, stale = classify_complexity(baseline, current) 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)}") + if exists and regressions: + print("\n".join(regressions)) + print("拒绝写入:--write 只能固化下降后的低水位,不能接受复杂度增长。") + return 1 + _write_json(args.baseline, baseline_payload(current)) + print(f"已写入 {args.baseline}") 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)) + if not exists: + raise ValueError(f"缺少复杂度基线:{args.baseline}") + if regressions or stale: + print("\n".join([*regressions, *stale])) + print("先消除新增超限与增长;仅存在下降时使用 --write 固化低水位。") return 1 - print("复杂度 v2 ratchet 通过") - return 0 - 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)) + except (OSError, ValueError, SyntaxError, KeyError, TypeError) as error: + print(f"复杂度检查失败:{error}") return 1 - print("复杂度 ratchet 通过") + print("结构复杂度 ratchet 通过(C901 <= 15、PLR1702 <= 5,存量逐函数只降不增)") return 0 diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index a137f3e08..98fb8586a 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -1,5 +1,190 @@ { - "api_endpoint": {}, - "application_public": {}, - "chain_public": {} + "metrics": { + "C901": { + "app/adapters/external/cookiecloud.py:CookieCloudHelper.download": 20, + "app/adapters/external/plugin/client.py:PluginMarketTransport.__normalize_plugin_index_entry": 18, + "app/adapters/external/plugin/client.py:PluginMarketTransport.get_local_plugin_candidate": 23, + "app/adapters/network/http.py:AsyncRequestUtils.get_stream": 20, + "app/adapters/network/http.py:RequestUtils.get_decoded_xml_content": 18, + "app/adapters/system/plugin/health.py:PluginRuntimeHealth.__async_install_packages_with_fallback": 24, + "app/adapters/system/plugin/health.py:PluginRuntimeHealth.install_packages_with_fallback": 27, + "app/agent/callback/__init__.py:StreamingHandler._classify_tool_call": 27, + "app/agent/lifecycle.py:AgentLifecycleOwner.close": 21, + "app/agent/llm/helper.py:_patch_gemini_thought_signature": 16, + "app/agent/llm/runtime.py:_ProviderRuntime.resolve_runtime": 17, + "app/agent/middleware/memory.py:query_memory_files": 28, + "app/agent/orchestrator.py:MoviePilotAgent._execute_agent": 22, + "app/agent/policy/mcp.py:build_api_mcp_input_schema": 17, + "app/agent/policy/sanitizer.py:sanitize_for_host": 45, + "app/agent/session.py:AgentSessionOwner._process_message_internal": 23, + "app/agent/session.py:AgentSessionOwner._session_worker": 22, + "app/agent/tools/base.py:MoviePilotTool._arun": 23, + "app/agent/tools/impl/agent_task.py:AgentTaskTool._update_task": 16, + "app/agent/tools/impl/apply_patch.py:parse_patch": 23, + "app/agent/tools/impl/browse_webpage.py:BrowseWebpageTool._do_action": 20, + "app/agent/tools/impl/service.py:_run_database_script": 20, + "app/api/endpoints/media.py:_scrape_impl": 16, + "app/api/endpoints/transfer.py:_execute_manual_transfer": 28, + "app/api/protocol.py:extract_text_and_images": 19, + "app/application/classification/projection.py:_project_condition": 16, + "app/application/download/organization.py:organize_existing_source": 22, + "app/application/formatting.py:EpisodeFormatRuleHelper._auto_recommend": 25, + "app/application/messaging/agent.py:apply_web_agent_display_event": 20, + "app/application/messaging/skill.py:SkillInteractionHandler._handle_callback_interaction": 23, + "app/application/messaging/skill.py:SkillInteractionHandler._handle_text_interaction": 27, + "app/application/messaging/webagent/stream.py:_build_web_agent_event_generator": 16, + "app/application/plugin/admission.py:admit_plugin_install": 22, + "app/application/plugin/identity.py:PluginIdentity.__post_init__": 26, + "app/application/plugin/install.py:PluginInstallCommand.__execute_locked": 28, + "app/application/plugin/source.py:select_plugin_candidate": 19, + "app/application/rss.py:RssHelper._parse_impl": 37, + "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 45, + "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 43, + "app/application/torrent/download.py:TorrentHelper.download_torrent": 25, + "app/application/torrent/download.py:TorrentHelper.filter_torrent": 24, + "app/application/torrent/download.py:TorrentHelper.match_torrent": 16, + "app/application/transfer/models.py:TransferPlanCheckpoint.__post_init__": 18, + "app/chain/_messaging.py:_notification_route_steps": 17, + "app/chain/download/batch.py:DownloadBatchOwner._execute_batch_download": 79, + "app/chain/download/existence.py:DownloadExistenceOwner.get_no_exists_info": 23, + "app/chain/download/subtitle.py:DownloadSubtitleOwner._save_subtitle_response": 16, + "app/chain/media/album.py:_album_context_with_resource": 20, + "app/chain/message.py:MessageChain._download_attachments_to_data_urls": 18, + "app/chain/message.py:MessageChain._download_message_file_bytes": 19, + "app/chain/message.py:MessageChain._handle_ai_message": 19, + "app/chain/message.py:MessageChain._transcribe_audio_refs": 19, + "app/chain/scraping.py:ScrapingChain._get_target_fileitem_and_path": 16, + "app/chain/scraping.py:ScrapingChain.scrape_metadata_event": 17, + "app/chain/scraping.py:ScrapingChain.scrape_music_metadata": 17, + "app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__get_subscribe_no_exits": 16, + "app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__record_subscribe_download_facts": 19, + "app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 47, + "app/chain/subscribe/match.py:SubscribeMatchOwner._run_match": 18, + "app/chain/subscribe/query.py:SubscribeQueryOwner.follow": 19, + "app/chain/subscribe/query.py:SubscribeQueryOwner.subscribe_files_info": 24, + "app/chain/subscribe/refresh.py:SubscribeRefreshOwner.backfill_existing_episodes": 16, + "app/chain/subscribe/search.py:_SubscribeSearchQueueCoordinator._execute_inline_search": 17, + "app/chain/transfer/execution.py:TransferExecutionOwner._TransferChain__perform_transfer": 31, + "app/chain/transfer/filter.py:FileFilterMixin._restore_music_download_context": 24, + "app/chain/transfer/music.py:_release_groups": 16, + "app/chain/transfer/music.py:prepare_music_batch_context": 18, + "app/chain/transfer/queue.py:TransferQueueOwner._TransferChain__replay_pending": 26, + "app/chain/transfer/queue.py:TransferQueueOwner._TransferChain__start_transfer": 21, + "app/chain/transfer/queue.py:TransferQueueOwner.process": 21, + "app/chain/transfer/request.py:_TransferCandidatePlanner._plan_file_items": 24, + "app/chain/transfer/retry.py:FailedRetryMixin._re_transfer": 21, + "app/chain/transfer/settlement.py:TransferSettlementOwner._TransferChain__default_callback": 18, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._build_transfer_tasks": 32, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._collect_transfer_candidates": 16, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._execute_transfer_tasks": 18, + "app/chain/workflow.py:WorkflowExecutor.evaluate_target_state": 16, + "app/domain/classification/validation.py:ClassificationPolicyValidator._validate_condition_value": 18, + "app/domain/meta/metaanime.py:MetaAnime.__init__": 46, + "app/domain/meta/metaanime.py:MetaAnime.__prepare_title": 16, + "app/domain/meta/metabase.py:MetaBase.init_subtitle": 41, + "app/domain/meta/metamusic.py:MetaMusic._parse_music_scene_tokens": 19, + "app/domain/meta/metamusic.py:MetaMusic.apply_path_context": 16, + "app/domain/meta/metamusic.py:MetaMusic.parse_resource": 20, + "app/domain/meta/metavideo.py:MetaVideo.__init__": 18, + "app/domain/meta/metavideo.py:MetaVideo.__init_episode": 17, + "app/domain/meta/metavideo.py:MetaVideo.__init_name": 31, + "app/domain/metainfo.py:_find_metainfo_python": 29, + "app/domain/music.py:parse_music_cue": 21, + "app/domain/projection/anilist.py:project": 17, + "app/domain/projection/douban.py:project": 25, + "app/modules/discord/discord.py:Discord._build_embed": 26, + "app/modules/discord/discord.py:Discord._resolve_channel": 20, + "app/modules/emby/emby.py:Emby.get_tv_episodes": 18, + "app/modules/emby/emby.py:Emby.get_webhook_message": 18, + "app/modules/filemanager/storages/alipan.py:AliPan.upload": 16, + "app/modules/filemanager/storages/u115.py:U115Pan.upload": 33, + "app/modules/filemanager/transhandler.py:TransHandler.__resolve_overwrite": 16, + "app/modules/filemanager/transhandler.py:TransHandler.__transfer_command": 40, + "app/modules/filemanager/transhandler.py:TransHandler.execute_transfer_plan": 21, + "app/modules/filter/__init__.py:FilterModule.__match_rule": 24, + "app/modules/imdb/__init__.py:ImdbModule._to_media_info": 17, + "app/modules/indexer/parser/nexus_php.py:NexusPhpSiteUserInfo._parse_user_torrent_seeding_info": 16, + "app/modules/indexer/spider/__init__.py:SiteSpider.__get_search_url": 24, + "app/modules/jellyfin/jellyfin.py:Jellyfin.get_tv_episodes": 18, + "app/modules/qbittorrent/__init__.py:QbittorrentModule.download": 24, + "app/modules/qbittorrent/__init__.py:QbittorrentModule.list_torrents": 20, + "app/modules/qqbot/gateway.py:run_gateway": 42, + "app/modules/qqbot/qqbot.py:QQBot.send_msg": 19, + "app/modules/rtorrent/__init__.py:RtorrentModule.download": 19, + "app/modules/rtorrent/__init__.py:RtorrentModule.list_torrents": 20, + "app/modules/slack/__init__.py:SlackModule.message_parser": 16, + "app/modules/slack/slack.py:Slack.send_medias_msg": 18, + "app/modules/telegram/telegram.py:Telegram.__init__": 17, + "app/modules/telegram/telegram.py:Telegram.send_msg": 20, + "app/modules/transmission/__init__.py:TransmissionModule.download": 19, + "app/modules/transmission/__init__.py:TransmissionModule.list_torrents": 26, + "app/modules/trimemedia/api.py:Api.request": 16, + "app/modules/ugreen/ugreen.py:Ugreen.get_tv_episodes": 21, + "app/modules/wechat/__init__.py:WechatModule.message_parser": 20, + "app/modules/wechatclawbot/wechatclawbot.py:ILinkClient._parse_incoming": 34, + "app/modules/zspace/zspace.py:ZSpace.get_items": 16, + "app/modules/zspace/zspace.py:ZSpace.get_tv_episodes": 18, + "app/modules/zspace/zspace.py:ZSpace.get_webhook_message": 17, + "app/monitor/monitor.py:Monitor.__initialize_monitors": 25, + "app/runtime/cache.py:cached": 34, + "app/runtime/cache.py:cached.decorator": 33, + "app/runtime/config.py:Settings.generic_type_converter": 26, + "app/runtime/extensions/plugin/monitor.py:PluginChangeMonitor._process_changes": 19, + "app/runtime/extensions/plugin/runtime.py:build_plugin_runtime": 19, + "app/runtime/tasks.py:TaskRegistry.submit_threadsafe": 25, + "app/scheduler/bridge.py:SchedulerBridgeOwner._submit_cross_thread": 16, + "app/scheduler/execution.py:SchedulerExecutionOwner.start": 21, + "app/startup/composition/outbox.py:build_outbox_handlers": 16, + "app/workflow/__init__.py:WorkflowManager._check_complex_condition": 16, + "app/workflow/actions/add_download.py:AddDownloadAction.execute": 16, + "app/workflow/actions/fetch_torrents.py:FetchTorrentsAction.execute": 16, + "app/workflow/actions/transfer_file.py:TransferFileAction.execute": 20 + }, + "PLR1702": { + "app/adapters/network/http.py:AsyncRequestUtils.get_stream": 6, + "app/adapters/network/http.py:get_caller": 6, + "app/adapters/system/host.py:SystemUtils.exits_files._search_files": 6, + "app/adapters/system/host.py:SystemUtils.list_files._scan_directory": 6, + "app/adapters/system/plugin/health.py:PluginRuntimeHealth.__async_install_packages_with_fallback": 6, + "app/adapters/system/plugin/health.py:PluginRuntimeHealth.install_packages_with_fallback": 7, + "app/agent/callback/__init__.py:StreamingHandler._flush": 6, + "app/agent/llm/helper.py:_patch_gemini_thought_signature": 6, + "app/agent/policy/sanitizer.py:sanitize_for_host": 6, + "app/api/endpoints/transfer.py:_execute_manual_transfer": 6, + "app/application/rss.py:RssHelper._parse_impl": 7, + "app/application/torrent/download.py:TorrentHelper.download_torrent": 6, + "app/chain/_messaging.py:_notification_route_steps": 6, + "app/chain/download/batch.py:DownloadBatchOwner._execute_batch_download": 8, + "app/chain/scraping.py:ScrapingChain._download_and_save_image": 7, + "app/chain/scraping.py:ScrapingChain.scrape_metadata_event": 6, + "app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 7, + "app/chain/subscribe/match.py:SubscribeMatchOwner._run_match": 6, + "app/chain/subscribe/query.py:SubscribeQueryOwner.subscribe_files_info": 6, + "app/chain/transfer/queue.py:TransferQueueOwner._TransferChain__start_transfer": 6, + "app/chain/transfer/workflow.py:TransferWorkflowOwner._build_transfer_tasks": 8, + "app/domain/meta/metaanime.py:MetaAnime.__init__": 7, + "app/modules/discord/discord.py:Discord._build_embed": 7, + "app/modules/emby/emby.py:Emby.get_movies": 6, + "app/modules/filemanager/storages/alipan.py:AliPan.download": 6, + "app/modules/indexer/spider/__init__.py:SiteSpider.__get_search_url": 7, + "app/modules/jellyfin/jellyfin.py:Jellyfin.get_movies": 6, + "app/modules/qbittorrent/__init__.py:QbittorrentModule.download": 7, + "app/modules/rtorrent/__init__.py:RtorrentModule.download": 6, + "app/modules/rtorrent/rtorrent.py:Rtorrent.delete_torrents": 6, + "app/modules/slack/__init__.py:SlackModule.send_direct_message": 6, + "app/modules/slack/slack.py:Slack.send_medias_msg": 6, + "app/modules/transmission/__init__.py:TransmissionModule.download": 7, + "app/modules/wechat/wechat.py:WeChat.__split_content": 6, + "app/modules/zspace/zspace.py:ZSpace.get_items": 6, + "app/modules/zspace/zspace.py:ZSpace.get_movies": 6, + "app/modules/zspace/zspace.py:ZSpace.get_user": 6, + "app/scheduler/reconcile.py:SchedulerReconcileOwner.remove_plugin_job": 6, + "app/workflow/actions/add_download.py:AddDownloadAction.execute": 7 + } + }, + "schema_version": 3, + "thresholds": { + "C901": 15, + "PLR1702": 5 + } } diff --git a/tests/fixtures/architecture/complexity-v2-baseline.json b/tests/fixtures/architecture/complexity-v2-baseline.json deleted file mode 100644 index ad2bf0da1..000000000 --- a/tests/fixtures/architecture/complexity-v2-baseline.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "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": 743, - "app/application/rss.py:RssHelper": 531, - "app/application/security/url.py:SecurityUtils": 771, - "app/application/torrent/download.py:TorrentHelper": 738, - "app/application/transfer/jobs.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": 895, - "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": 1152, - "app/application/formatting.py": 1523, - "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": 2068, - "app/chain/site.py": 1187, - "app/chain/transfer/queue.py": 1631, - "app/chain/transfer/workflow.py": 1106, - "app/chain/workflow.py": 1393 - }, - "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/plugin/install.py:PluginInstallCommand.__execute_locked": 279, - "app/application/rss.py:RssHelper._parse_impl": 213, - "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 241, - "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 214, - "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/test_architecture_ci.py b/tests/test_architecture_ci.py index caac867b1..c41c3f6d5 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -66,6 +66,22 @@ def test_unit_test_workflow_has_independent_host_architecture_gate(): assert "scripts/architecture/ruff_ratchet.py --write" not in commands assert "scripts/architecture/mypy_ratchet.py --write" not in commands assert "scripts/architecture/service_locator.py" in commands + assert "scripts/architecture/complexity.py" in commands + assert "complexity.py --v2" not in commands + assert "--report complexity-report.json" in commands + complexity_step = next( + step for step in workflow["jobs"]["architecture-ratchets"]["steps"] + if step.get("name") == "Check structural complexity ratchet" + ) + assert "--write" not in complexity_step["run"] + assert "|| true" not in complexity_step["run"] + assert not complexity_step.get("continue-on-error") + report_step = next( + step for step in workflow["jobs"]["architecture-ratchets"]["steps"] + if step.get("name") == "Upload complexity and source size report" + ) + assert report_step["if"] == "always()" + assert report_step["with"]["path"] == "complexity-report.json" assert "scripts/startup/performance.py --check --repeat 3" in commands # 覆盖率分片跳过的文件必须恰好由门禁运行,否则会有测试在 CI 中无人执行 gate_test_files = { @@ -246,6 +262,7 @@ def test_upload_artifact_actions_share_node24_major(): "test.yml": [ "actions/upload-artifact@v7", "actions/upload-artifact@v7", + "actions/upload-artifact@v7", ], } diff --git a/tests/test_complexity_gate.py b/tests/test_complexity_gate.py index 8ba35c088..e90a16a2b 100644 --- a/tests/test_complexity_gate.py +++ b/tests/test_complexity_gate.py @@ -1,122 +1,234 @@ -"""复杂度只降不增 ratchet 测试。""" +"""结构复杂度门禁、行数观察与基线写保护的回归测试。""" +import json +import subprocess +import sys from pathlib import Path +from textwrap import indent -from scripts.architecture.complexity import ( - collect_complexity_v2, - compare_complexity, - compare_complexity_v2, -) +import pytest + +from scripts.architecture import complexity -def _assignments(indent: str, count: int) -> str: - """生成足够长的临时语句体,精确触发复杂度阈值。""" - return "\n".join(f"{indent}value_{index} = {index}" for index in range(count)) +def _write_source(root: Path, relative: str, source: str) -> Path: + """写入隔离源码树,避免对真实宿主文件做变异。""" + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + return path -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": {}, +def _branches(count: int, name: str = "dispatch") -> str: + """构造分支数可控的函数,以真实 Ruff 验证阈值边界。""" + return f"def {name}(value):\n" + "".join( + f" if value == {index}:\n return {index}\n" for index in range(count) + ) + + +def _nested(depth: int) -> str: + """构造独立于物理行数的深层条件控制流。""" + return "def nested(value):\n" + "".join( + f"{' ' * level}if value:\n" for level in range(1, depth + 1) + ) + f"{' ' * (depth + 1)}return value\n" + + +def test_structural_metrics_cover_all_host_packages_and_ignore_only_plugins(tmp_path: Path) -> None: + """原来未受治的宿主目录同样拦截短而多分支的函数;插件副本除外。""" + packages = ( + "adapters", "agent", "api/endpoints", "application", "chain", "db", "domain", + "foundation", "modules", "runtime", "scheduler", "startup", "workflow", "sdk", + "runtime/compat", "testing", + ) + for package in packages: + _write_source(tmp_path, f"app/{package}/sample.py", _branches(15, "__private")) + _write_source(tmp_path, "app/plugins/sample/__init__.py", "invalid syntax !") + metrics = complexity.collect_complexity(tmp_path) + assert metrics["C901"] == { + f"app/{package}/sample.py:__private": 16 for package in packages } - current = { - "api_endpoint": {"app/api/endpoints/a.py:large": 90}, - "application_public": {}, - "chain_public": {}, + assert metrics["PLR1702"] == {} + + +def test_complexity_limits_are_inclusive_and_nested_depth_is_independent(tmp_path: Path) -> None: + """圈复杂度 15、嵌套 5 层允许,跨过任一阈值必须报告。""" + path = _write_source(tmp_path, "app/sample.py", _branches(14) + _nested(5)) + assert complexity.collect_complexity(tmp_path) == {"C901": {}, "PLR1702": {}} + path.write_text(_branches(15) + _nested(6), encoding="utf-8") + assert complexity.collect_complexity(tmp_path) == { + "C901": {"app/sample.py:dispatch": 16}, + "PLR1702": {"app/sample.py:nested": 6}, } - assert compare_complexity(baseline, current) == [] + +def test_formatting_comments_and_docstrings_only_affect_advisory_size(tmp_path: Path) -> None: + """增加文档、空行或换行不消耗结构预算,但仍可观察源码规模变化。""" + path = _write_source(tmp_path, "app/sample.py", _branches(15)) + before = complexity.collect_complexity(tmp_path) + formatted = _branches(15).replace("(value):", "(\n value,\n):").replace( + " if value == 0:", + ' """文档\n' + " 说明\n" * 160 + ' """\n\n # 分支语义未变\n if value == 0:', + ) + path.write_text("\n\n" + formatted, encoding="utf-8") + assert complexity.collect_complexity(tmp_path) == before + assert complexity.collect_source_size(tmp_path)["method"]["app/sample.py:dispatch"] > 150 + path.write_text("def linear():\n" + " value = 1\n" * 1100, encoding="utf-8") + assert complexity.collect_complexity(tmp_path) == {"C901": {}, "PLR1702": {}} + assert complexity.collect_source_size(tmp_path)["file"] == {"app/sample.py": 1101} -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": {}, +def test_nested_owners_and_redefinitions_do_not_share_budgets(tmp_path: Path) -> None: + """Match、TryStar 内嵌套函数与同名重定义必须保留独立身份。""" + source = ( + "class Runner:\n" + indent(_branches(15, "__hidden"), " ") + + "\ndef outer(value):\n match value:\n case 1:\n" + + indent(_branches(15, "matched"), " ") + + "\ndef guarded():\n try:\n pass\n except* ValueError:\n" + + indent(_branches(15, "recovered"), " ") + + "\ndef local():\n class Inner:\n" + + indent(_branches(15, "method"), " ") + + "\nif enabled:\n" + indent(_branches(15, "repeated"), " ") + + "else:\n" + indent(_branches(16, "repeated"), " ") + ) + _write_source(tmp_path, "app/sample.py", source) + metrics = complexity.collect_complexity(tmp_path)["C901"] + for owner in ("Runner.__hidden", "outer.matched", "guarded.recovered", "local.Inner.method"): + assert metrics[f"app/sample.py:{owner}"] == 16 + assert metrics["app/sample.py:repeated#1"] == 16 + assert metrics["app/sample.py:repeated#2"] == 17 + + +def test_noqa_gitignore_and_local_ruff_config_cannot_hide_metrics(tmp_path: Path) -> None: + """noqa、忽略文件或局部 Ruff 排除项不能绕过宿主复杂度门禁。""" + _write_source(tmp_path, "app/sample.py", _branches(15).replace( + "def dispatch(value):", "def dispatch(value): # noqa: C901", + )) + (tmp_path / ".git").mkdir() + (tmp_path / ".gitignore").write_text("app/\n", encoding="utf-8") + (tmp_path / "ruff.toml").write_text('exclude = ["app"]\n', encoding="utf-8") + assert complexity.collect_complexity(tmp_path)["C901"] == {"app/sample.py:dispatch": 16} + + +def test_multiple_deep_blocks_use_the_functions_maximum_depth(tmp_path: Path) -> None: + """同函数多个深层块取最大深度,嵌套函数的指标归属自己。""" + source = _nested(6) + _nested(7).split("\n", 1)[1] + source += "\ndef outer():\n" + indent(_nested(8), " ") + _write_source(tmp_path, "app/sample.py", source) + assert complexity.collect_complexity(tmp_path)["PLR1702"] == { + "app/sample.py:nested": 7, "app/sample.py:outer.nested": 8, } - problems = compare_complexity(baseline, current) - assert any("既有超限增长" in problem for problem in problems) - assert any("新增超限" in problem for problem in problems) +def test_growth_cannot_be_offset_by_reduction_or_renaming() -> None: + """同文件中删除旧热点不能抵消另一个函数变复杂或搬走后的新热点。""" + baseline = {"C901": {"app/a.py:old": 20, "app/a.py:growing": 18}, "PLR1702": {}} + current = {"C901": {"app/b.py:moved": 20, "app/a.py:growing": 19}, "PLR1702": {}} + regressions, stale = complexity.classify_complexity(baseline, current) + assert len(regressions) == 2 + assert any("growing 18->19" in item for item in regressions) + assert any("moved 15->20" in item for item in regressions) + assert len(stale) == 1 and "old" in stale[0] -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) - - -def test_complexity_v2_walks_dunder_match_trystar_and_nested_class_owners( - tmp_path: Path, +@pytest.mark.parametrize("current", [{"C901": {"app/a.py:f": 16}, "PLR1702": {}}, {"C901": {}, "PLR1702": {}}]) +def test_cli_requires_and_writes_lower_watermark( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, current: dict, ) -> None: - """完整 AST 遍历必须覆盖 dunder、Match、TryStar 和函数内嵌套类。""" - scheduler = tmp_path / "app/scheduler" - scheduler.mkdir(parents=True) - (scheduler / "dunder.py").write_text( - "class Runner:\n" - " def __hidden(self):\n" - f"{_assignments(' ', 151)}\n", - encoding="utf-8", - ) - (scheduler / "control_flow.py").write_text( - "def dispatch(value):\n" - " match value:\n" - " case 1:\n" - " def matched():\n" - f"{_assignments(' ', 151)}\n" - " return matched()\n\n" - "def guarded():\n" - " try:\n" - " raise ExceptionGroup('failure', [ValueError()])\n" - " except* ValueError:\n" - " def recovered():\n" - f"{_assignments(' ', 151)}\n" - " return recovered()\n", - encoding="utf-8", - ) - nested_classes = ( - "class Outer:\n" - " def first(self):\n" - " class Local:\n" - f"{_assignments(' ', 501)}\n\n" - " def second(self):\n" - " class Local:\n" - f"{_assignments(' ', 501)}\n" - ) - (scheduler / "nested_classes.py").write_text(nested_classes, encoding="utf-8") - (scheduler / "conditional.py").write_text( - "if enabled:\n" - " class Conditional:\n" - f"{_assignments(' ', 501)}\n", - encoding="utf-8", - ) + """降低到阈值内或删除热点后必须收紧基线,后续检查才能通过。""" + baseline = tmp_path / "baseline.json" + baseline.write_text(json.dumps(complexity.baseline_payload({"C901": {"app/a.py:f": 20}, "PLR1702": {}}))) + monkeypatch.setattr(complexity, "collect_complexity", lambda: current) + monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline)]) + assert complexity.main() == 1 + monkeypatch.setattr(sys, "argv", [*sys.argv, "--write"]) + assert complexity.main() == 0 + assert complexity.read_baseline(baseline) == current + monkeypatch.setattr(sys, "argv", sys.argv[:-1]) + assert complexity.main() == 0 - report = collect_complexity_v2(tmp_path) - assert "app/scheduler/dunder.py:Runner.__hidden" in report["method"] - assert "app/scheduler/control_flow.py:dispatch.matched" in report["method"] - assert "app/scheduler/control_flow.py:guarded.recovered" in report["method"] - assert "app/scheduler/conditional.py:Conditional" in report["class"] - assert "app/scheduler/nested_classes.py:Outer.first.Local" in report["class"] - assert "app/scheduler/nested_classes.py:Outer.second.Local" in report["class"] +def test_cli_refuses_to_overwrite_growth_and_keeps_diagnostic_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """失败时保存审查报告,但 write 绝不能把回退洗入已有基线。""" + baseline, report = tmp_path / "baseline.json", tmp_path / "report.json" + original = json.dumps(complexity.baseline_payload({"C901": {}, "PLR1702": {}})) + baseline.write_text(original, encoding="utf-8") + current = {"C901": {"app/a.py:f": 16}, "PLR1702": {}} + monkeypatch.setattr(complexity, "collect_complexity", lambda: current) + monkeypatch.setattr(complexity, "collect_source_size", lambda: {"file": {"app/a.py": 2000}}) + monkeypatch.setattr(sys, "argv", ["complexity.py", "--write", "--baseline", str(baseline), "--report", str(report)]) + assert complexity.main() == 1 + assert baseline.read_text(encoding="utf-8") == original + assert json.loads(report.read_text(encoding="utf-8"))["metrics"] == current + monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline), "--report", str(baseline)]) + assert complexity.main() == 1 + assert baseline.read_text(encoding="utf-8") == original + + +def test_cli_missing_baseline_requires_explicit_initialization(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """缺失基线不能默认为成功,首次建账必须显式执行 write。""" + baseline = tmp_path / "new.json" + monkeypatch.setattr(complexity, "collect_complexity", lambda: {"C901": {}, "PLR1702": {}}) + monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline)]) + assert complexity.main() == 1 + monkeypatch.setattr(sys, "argv", [*sys.argv, "--write"]) + assert complexity.main() == 0 + assert complexity.read_baseline(baseline) == {"C901": {}, "PLR1702": {}} + + +@pytest.mark.parametrize("payload", [ + {"api_endpoint": {}, "application_public": {}, "chain_public": {}}, + {"schema_version": 3, "thresholds": {"C901": 20, "PLR1702": 5}, "metrics": {}}, + complexity.baseline_payload({"C901": {"app/a.py:f": True}, "PLR1702": {}}), + complexity.baseline_payload({"C901": {"app/plugins/a.py:f": 16}, "PLR1702": {}}), + complexity.baseline_payload({"C901": {"app/a.py:f": 15}, "PLR1702": {}}), + complexity.baseline_payload({"C901": {}}), +]) +def test_baseline_rejects_legacy_malformed_or_changed_policy(tmp_path: Path, payload: dict) -> None: + """格式、范围和阈值异常必须失败,不能静默接受旧口径。""" + path = tmp_path / "baseline.json" + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError): + complexity.read_baseline(path) + + +@pytest.mark.parametrize(("status", "stdout", "stderr"), [ + (2, "", "tool failure"), (0, "", ""), (0, "{}", ""), + (1, "[]", ""), (0, '[{"code": "C901"}]', ""), (0, "[]", "warning"), +]) +def test_ruff_failures_never_become_zero_debt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: int, stdout: str, stderr: str, +) -> None: + """工具异常、输出丢失和退出状态不一致均必须阻断。""" + result = subprocess.CompletedProcess([], status, stdout, stderr) + monkeypatch.setattr(complexity.subprocess, "run", lambda *args, **kwargs: result) + with pytest.raises(ValueError): + complexity.run_ruff(tmp_path) + + +@pytest.mark.parametrize("changes", [ + {"code": "invalid-syntax"}, {"message": "unrecognized metric"}, + {"message": "metric (16 > 10)"}, {"location": {"row": 999}}, +]) +def test_unrecognized_diagnostics_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, changes: dict, +) -> None: + """Ruff 升级改变输出或无法定位 owner 时不能漏记债务。""" + path = _write_source(tmp_path, "app/sample.py", _branches(15)) + diagnostic = { + "code": "C901", "message": "metric (16 > 15)", + "filename": str(path), "location": {"row": 1}, **changes, + } + monkeypatch.setattr(complexity, "run_ruff", lambda root: [diagnostic]) + with pytest.raises(ValueError): + complexity.collect_complexity(tmp_path) + + +def test_missing_or_invalid_source_cannot_shrink_baseline(tmp_path: Path) -> None: + """扫描根消失或源码损坏不能被当成复杂度下降。""" + with pytest.raises(ValueError, match="未找到"): + complexity.collect_complexity(tmp_path) + _write_source(tmp_path, "app/sample.py", "invalid syntax !") + with pytest.raises(SyntaxError): + complexity.collect_complexity(tmp_path) diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py index b388e8a4a..5e70d6a08 100644 --- a/tests/test_type_gate.py +++ b/tests/test_type_gate.py @@ -36,6 +36,11 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None: assert "app/api/context.py" in governed_files assert "app/db/base.py" in governed_files assert "app/db/uow.py" in governed_files + assert { + "app/runtime/tasks.py", + "app/startup/lifecycle/components.py", + "app/scheduler/registry.py", + } <= governed_files assert len(governed_files) >= 37 assert any(path.startswith("app/domain/") for path in governed_files) assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")