Files
MoviePilot/docs/code-quality.md
jxxghp b12c112fe1 chore(types): gate mypy debt as exceptions and require zero new files
mypy 门禁此前只表达“只降不增”,没有把存量债务和新增文件分开:拆包迁移按目录前缀
继承额度,同包后来新增的文件可以吸收旧文件剩余额度;本地缺 types-* 桩包时,
import-untyped 与派生的 no-any-return 会被计成债务增长。

调整后口径分三层:mypy.ini 严格清单必须零错误;基线登记文件是存量例外,按文件 +
错误码设上限;基线未登记的新文件必须零诊断。

- 迁移登记改为 Migration(sources, targets),额度只转给逐一登记的接收文件。
- classify_counts 返回 RatchetDiff,新增文件、存量增长、待固化低水位分组输出。
- 缺少 types-* 时直接报环境错误并要求 uv sync --locked,不写入基线。
- 删除 7 个已消耗的迁移组,其旧路径已不在基线中。

验证:tests/test_mypy_gate.py 与 tests/test_architecture_ci.py 26 passed;
changed-file Pylint 10.00/10;Ruff ratchet、结构复杂度、mypy 严格清单与
mypy ratchet(2881 项存量)通过。
2026-10-18 13:46:37 +08:00

11 KiB
Raw Blame History

代码门禁:结构指标与扩围建议

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,失败时也保留已生成报告。
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 的配置 衡量控制流分支, 不是运行时耗时、内存复杂度或完整认知复杂度。 PLR1702 仍属 preview,只有此独立 检查启用 preview;升级 Ruff 时应跑真实 Ruff 回归用例并审查指标 diff,不自动刷新基线。 短路布尔表达式、推导式、跨函数调用链和职责耦合仍需行为测试与架构审查。

类型检查门禁口径

v3 的 mypy 债务分三层,口径不同,不能互相抵扣:

层 判定 事实源
严格清单 mypy.ini 的 files 已清零,出现任何诊断即失败 mypy.ini
存量例外 基线登记的文件按「文件 + 错误码」设上限,涨一条即失败 tests/fixtures/architecture/mypy-baseline.json
新增文件 基线未登记的文件必须零诊断,出现任何错误即失败 同上

scripts/architecture/mypy_ratchet.py 负责后两层:以 Linux 平台全量扫描 app(排除 app/plugins),未登记文件按「新增文件必须清零类型错误」失败,例外文件只允许数量下降。 债务真实下降后用 --write 固化低水位;存在新增文件或增长时 --write 拒绝写入, 所以基线是逐文件、逐错误码的上限快照,不是允许失败的白名单。

单体文件拆成包时,旧路径额度只能转移给 MYPY_MIGRATIONS 中逐一登记的接收文件;同包内 后来新增的文件不在名单里,仍按新增文件清零判定,避免新文件吸收旧文件额度。迁移按错误码 汇总比较,不据此推断旧源码已经删除。

缺少 pyproject.toml 声明的 types-* 桩包时,mypy 会把带类型的第三方库降级为 Any, 同时产生 import-untyped 和派生的 no-any-return。门禁此时直接报环境错误并要求 uv sync --locked,不允许把环境缺失写进基线。本地复现门禁前先确认虚拟环境与 uv.lock 一致。

按文件 + 错误码计数仍无法识别同一文件内同错误码的一换一替换,属于已知残余,需要评审确认; 稳定诊断指纹是后续方向。验收命令:

uv run --locked --no-sync mypy --config-file mypy.ini
uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py
uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py --write

类型检查的本次扩围

mypy.ini 的严格范围从 42 个文件扩大到 45 个,新增:

  • app/runtime/tasks.py:后台任务登记、提交和关闭。
  • app/scheduler/registry.py:调度器代际、所有权与句柄。
  • app/startup/lifecycle/components.py:组件启动与关闭。

三个文件及完整严格清单实测通过,不增加忽略规则或类型债务。全宿主 mypy ratchet 继续保留。 严格清单对项目内部的 app.* 统一采用 follow_imports = silent,并将此策略应用于 内部 .pyi,读取真实接口;FastAPI 和 Click 同样读取供应方已声明的类型, 其它外部依赖沿用 follow_imports = skip,保留 ignore_missing_imports = True。silent 只影响导入的非目标文件,files 中的目标仍然 严格检查;全宿主 Linux 分析显式扫描整个 app,继续报告未清零文件的全部历史债务。 此前将已有完整分析零错误的文件统一纳入严格清单,保留之前的全部目标,新增 551 个 受保护文件。扩围前后对完整宿主逐文件、逐错误码核对,导入策略没有隐藏任何既有错误; 订阅请求仓储补齐真实查询接口、API 移除恒等 cast 后,另消除 8 项原有类型错误。 门禁扩围表示已清零代码受到保护,不表示同时修复了这些文件的业务实现。

FastAPI/Click 合同治理移除了 107 条已失效的类型忽略,修正 OpenAPI 状态码键和 真实协程返回类型,并从定义方导入 FastAPI 的同一对象。全宿主诊断下降 417 项, 逐文件、逐错误码没有增长;新增清零的 app/factory.py 进入严格清单。仍有历史 债务的文件继续受完整分析的低水位约束,不因装饰器类型恢复而宣称整文件已清零。

批量清理类型债务时,仍逐文件确认生产文件在完整宿主分析中清零,才纳入 mypy.ini 严格清单。受影响的行为、兼容、Pylint 与适用门禁通过后,按本轮授权统一提交并通过 同一个 PR 交付,核对最终提交的远端必需 CI。验收同时检查完整严格清单和全宿主 Linux 分析,确认其他文件及错误码无增长; 只把经完整扫描确认的真实下降写入类型基线,不以忽略诊断或放宽类型合同接受回退。 严格文件清单以 mypy.ini 为准,已记录的逐文件、逐错误码债务以 tests/fixtures/architecture/mypy-baseline.json 为准。动态插件与 Agent 工具合同、 Pydantic 可空输入、事件载荷、工作流上下文、文件系统代理及外部类型桩等问题, 在确定行为和兼容合同前不宣称清零或纳入严格清单。

事件运行时的契约、注册、绑定、调度、错误处理和 payload 快照六个实现模块均已纳入 严格范围,完整宿主分析中 app/runtime/event/ 目录的类型错误已清零。

后续补充顺序

优先级 现有缺口 建议落点与验收
P0 GitHub v3 查询结果 protected=false,生效分支规则为空 增加固定名称的 CI 汇总检查,再配置 required checks;汇总要正确处理成功复用、取消和失败,不能把所有 skipped 当成功。远端规则属于仓库策略,当前改动未更改它
P1 覆盖率曾只阻断 Application/Domain 的聚合行覆盖率 已扩展到除 app/plugins 外全部 app 文件;存量逐文件低水位只增不减,新文件至少 80%,仍不设置分支下限;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 继续由行为/契约测试保护,源码尺寸或复杂度分值不能代替这些测试。