ci: replace line budgets with structural complexity gates

This commit is contained in:
jxxghp
2026-09-30 07:33:14 +08:00
parent 4a2ef75706
commit 7e43ea9676
13 changed files with 738 additions and 376 deletions

View File

@@ -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

View File

@@ -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
View 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 继续由行为/契约测试保护,源码尺寸或复杂度分值不能代替这些测试。

View File

@@ -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/)

View File

@@ -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. 结论摘要

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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",
],
}

View File

@@ -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)

View File

@@ -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")