mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
ci: replace line budgets with structural complexity gates
This commit is contained in:
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 <pr-base>...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 `<pr-base>` 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.
|
||||
|
||||
90
docs/code-quality.md
Normal file
90
docs/code-quality.md
Normal file
@@ -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 继续由行为/契约测试保护,源码尺寸或复杂度分值不能代替这些测试。
|
||||
@@ -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/)
|
||||
|
||||
@@ -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. 结论摘要
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
3
mypy.ini
3
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
191
tests/fixtures/architecture/complexity-baseline.json
vendored
191
tests/fixtures/architecture/complexity-baseline.json
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user