fix(quality): close coverage ratchet

This commit is contained in:
jxxghp
2026-08-26 22:10:23 +08:00
parent 15ddbbbafe
commit 265d3c6d1a
8 changed files with 645 additions and 46 deletions
+3 -1
View File
@@ -140,7 +140,6 @@ jobs:
uv run --locked --no-sync python -m coverage report uv run --locked --no-sync python -m coverage report
uv run --locked --no-sync python -m coverage json uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python -m coverage xml uv run --locked --no-sync python -m coverage xml
uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
- name: Upload coverage report - name: Upload coverage report
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -150,3 +149,6 @@ jobs:
coverage.xml coverage.xml
coverage.json coverage.json
retention-days: 7 retention-days: 7
- name: Check coverage ratchet
run: uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
+14 -6
View File
@@ -198,8 +198,9 @@ uvx --from pip-audit==2.10.1 pip-audit \
`python tests/run.py` 在本地默认把排序后的测试文件按向上取整的连续区间切成 4 片, `python tests/run.py` 在本地默认把排序后的测试文件按向上取整的连续区间切成 4 片,
并启动 4 个独立 pytest 进程;GitHub Actions 使用同一入口的 `--shard N/TOTAL` 并启动 4 个独立 pytest 进程;GitHub Actions 使用同一入口的 `--shard N/TOTAL`
参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。覆盖率报告 参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。Coverage job
按需通过 `Unit Tests` workflow 的手动触发串行生成,不阻塞常规 PR / push 门禁。 会在 `v3` 的 PR / push 中串行运行同一全量入口,并检查 Application 与 Domain 的
已提交低水位;它不是只在手工触发时运行的建议性报告。
4. **运行架构与静态门禁**:主仓架构检查不依赖独立插件仓;官方插件兼容观察单独运行, 4. **运行架构与静态门禁**:主仓架构检查不依赖独立插件仓;官方插件兼容观察单独运行,
任何检查命令都不会写入 fixture。 任何检查命令都不会写入 fixture。
@@ -210,17 +211,24 @@ uvx --from pip-audit==2.10.1 pip-audit \
--check-plugins --plugin-repo ../MoviePilot-Plugins \ --check-plugins --plugin-repo ../MoviePilot-Plugins \
--report official-plugin-architecture-report.json --report official-plugin-architecture-report.json
uv run --locked --no-sync pylint app/ uv run --locked --no-sync pylint app/
uv run --locked --no-sync python scripts/architecture/ruff_ratchet.py
uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py
uv run --locked --no-sync python -m coverage erase
uv run --locked --no-sync python -m coverage run tests/run.py --serial
uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
``` ```
GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python
文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周 文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周
或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。 或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。
mypy 类型错误基线(`tests/fixtures/architecture/mypy-baseline.json`)只降不增: Ruff/Mypy/Coverage 基线只允许收紧:新增诊断、类型错误增长或覆盖率下降都会被拒绝;
新增文件、错误码或既有计数增长都会被 `scripts/architecture/mypy_ratchet.py` 拒绝; 已有债务下降或覆盖率提升但 fixture 尚未同步时,门禁也会要求用对应脚本的 `--write`
修复存量错误后用同脚本 `--write` 收紧基线,禁止为绕过门禁放宽基线。受治文件清单 显式固化新低水位。存在回退时 `--write` 会拒绝覆盖,不能用于放宽基线。Mypy 完整
仍由 `mypy.ini` 的 `files=` 维护并保持零错误。 ratchet 固定按 Linux/Python 3.14 分析;Coverage fixture 只接受 GitHub Actions 的
Ubuntu/Python 3.14、locked 依赖和串行全量测试工件,本机 macOS 报告仅用于诊断,
不得直接写入并提交。受治零错误文件仍由 `mypy.ini` 的 `files=` 维护。
### 7. 参考资源 ### 7. 参考资源
+25 -3
View File
@@ -84,8 +84,8 @@
| 批次 | 叶子目标 | 状态 | 当前证据/停止条件 | | 批次 | 叶子目标 | 状态 | 当前证据/停止条件 |
|---|---|---|---| |---|---|---|---|
| 0 | 历史任务清账、现行架构图、外部契约核对和宿主基线对齐 | 已推送 | `d234c7132`;远端同 SHAahead/behind `0/0`;架构契约 `71 passed` | | 0 | 历史任务清账、现行架构图、外部契约核对和宿主基线对齐 | 已推送 | `d234c7132`;远端同 SHAahead/behind `0/0`;架构契约 `71 passed` |
| 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已本地验证 | Mypy 完整低水位 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`Pylint `10.00/10` | | 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已推送 | `6062b0661`;远端同 SHAahead/behind `0/0`Mypy 11994、Ruff 976 |
| 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 待批次 1 | fixture 当前 Application/Domain 均为 0%;最近 CI 仅作参考,必须在最终代码快照本地重建 | | 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 已本地验证 | Ubuntu canonicalApplication `9292/11949`77.76%),Domain `3390/4278`79.24%);专项 `26 passed` |
| 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 待批次 2 | Timer 只 cancel 不 joinGateway 主线程可能在 heartbeat 仍执行时报告停止成功 | | 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 待批次 2 | Timer 只 cancel 不 joinGateway 主线程可能在 heartbeat 仍执行时报告停止成功 |
| Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 待前置批次 | 所有准入项已推送;全量测试和适用门禁通过;本地/远端 0/0 | | Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 待前置批次 | 所有准入项已推送;全量测试和适用门禁通过;本地/远端 0/0 |
@@ -134,7 +134,9 @@
* Ruff fixture 从 1623 收紧到 976Mypy 和 Ruff 默认路径复跑均通过; * Ruff fixture 从 1623 收紧到 976Mypy 和 Ruff 默认路径复跑均通过;
* 质量/CI/上下文/严格类型专项 `33 passed`,架构契约 `71 passed`,改动文件 Pylint `10.00/10` * 质量/CI/上下文/严格类型专项 `33 passed`,架构契约 `71 passed`,改动文件 Pylint `10.00/10`
待完成:提交推送并记录远端证据。 交付证据:提交 `6062b0661` 已推送到 `origin/v3``git ls-remote` 返回同一 SHA
`HEAD...origin/v3``0/0`。提交严格包含上述 10 个批次路径;并行任务在提交前推进的
`1f7fac2b2` 是其父提交,不在本批次 diff 中。
### 批次 2Coverage 低水位闭环 ### 批次 2Coverage 低水位闭环
@@ -145,6 +147,23 @@
零 statements、工具失败和低水位未固化均会失败;CI 命令存在性有契约测试;文档与 CI 一致; 零 statements、工具失败和低水位未固化均会失败;CI 命令存在性有契约测试;文档与 CI 一致;
批次独立提交推送。 批次独立提交推送。
本地验收结果:
* 脚本拒绝 malformed/零语句报告、非法或不完整基线、布尔/负数/越界计数和计数不一致的
百分比;唯一旧全零 fixture 只允许初始化一次,后续不能再借 `--write` 绕过回退;
* 回退使用整数交叉相乘比较真实比例,避免四舍五入隐藏下降;任一包下降会整体拒绝写入且
基线原始字节不变,等比例计数变化和覆盖提升都必须显式固化完整快照;
* GitHub Actions run `32977180133``15ddbbbaf` 上完成 Ubuntu/Python 3.14、locked
依赖和串行全量 Coverage,日志精确聚合为 Application `9292/11949`77.76%)、Domain
`3390/4278`(79.24%);本机报告得到相同计数后,旧全零 fixture 已初始化并复验通过;
* CI 在同一 job 内按 erase、serial run、report、JSON、XML 顺序生成报告,先上传 canonical
工件再执行只读 ratchetPR、push、手工触发、Python 版本、锁依赖和禁止绕过均有契约测试;
* 当前应用代码快照的本地串行全量结果为 `6379 passed, 6 skipped`;随后新增的边界测试由
最新专项覆盖,质量/CI 专项 `26 passed`Ruff 通过,Pylint `10.00/10`,宿主、Schema、
Coverage 和锁文件门禁均通过。
待完成:提交推送并记录远端证据。
### 批次 3QQ Gateway heartbeat owner ### 批次 3QQ Gateway heartbeat owner
阶段 62 的既有合同要求消息渠道只有在线程真实终止后才能返回成功,超时时 owner 和句柄 阶段 62 的既有合同要求消息渠道只有在线程真实终止后才能返回成功,超时时 owner 和句柄
@@ -195,6 +214,9 @@ git rev-list --left-right --count HEAD...origin/v3
| 2026-08-26 | 后台 owner 历史余项审计 | 仅 QQ heartbeat Timer 满足阶段 62 既有合同和两天准入条件,其余候选关闭或排除 | | 2026-08-26 | 后台 owner 历史余项审计 | 仅 QQ heartbeat Timer 满足阶段 62 既有合同和两天准入条件,其余候选关闭或排除 |
| 2026-08-26 | 批次 0 交付 | `d234c7132` 已推送;远端同 SHAahead/behind `0/0` | | 2026-08-26 | 批次 0 交付 | `d234c7132` 已推送;远端同 SHAahead/behind `0/0` |
| 2026-08-26 | 批次 1 本地验收 | Mypy 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`Pylint `10.00/10` | | 2026-08-26 | 批次 1 本地验收 | Mypy 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`Pylint `10.00/10` |
| 2026-08-26 | 批次 1 交付 | `6062b0661` 已推送;远端同 SHAahead/behind `0/0`;显式变更 10 个路径 |
| 2026-08-26 | 批次 2 canonical Coverage | run `32977180133` 与本机计数一致;Application 77.76%Domain 79.24%;零 fixture 已初始化 |
| 2026-08-26 | 批次 2 本地验收 | 串行全量 `6379 passed, 6 skipped`;最新专项 `26 passed`Coverage/宿主/Schema/锁文件门禁通过 |
## 七、本轮停止条件 ## 七、本轮停止条件
+1 -1
View File
@@ -149,5 +149,5 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
- **跨仓观察**`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。 - **跨仓观察**`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。
- **静态检查**`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。 - **静态检查**`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。
- **PR 本地验证**:提交前运行受影响测试和适用的静态检查。涉及依赖或锁文件、共享测试基建、数据库、启动链、跨模块生命周期、兼容层或大范围行为变化时,运行 `uv run --locked --no-sync python tests/run.py` 完成本地全量;需要断点、输出顺序或测试污染诊断时使用 `--serial`。所有测试都应确认受影响路径通过且 socket 探针无真实出站,验证说明准确标注执行范围。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更执行适用的文本、结构和 diff 检查,CI 继续运行全量门禁。 - **PR 本地验证**:提交前运行受影响测试和适用的静态检查。涉及依赖或锁文件、共享测试基建、数据库、启动链、跨模块生命周期、兼容层或大范围行为变化时,运行 `uv run --locked --no-sync python tests/run.py` 完成本地全量;需要断点、输出顺序或测试污染诊断时使用 `--serial`。所有测试都应确认受影响路径通过且 socket 探针无真实出站,验证说明准确标注执行范围。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更执行适用的文本、结构和 diff 检查,CI 继续运行全量门禁。
- 覆盖率不参与常规 PR / push 的合并门禁;需要覆盖率制品时手动触发 `Unit Tests` workflow,独立的 `Coverage Report` job 会通过 `tests/run.py --serial` 跑串行全量上传 JSON / XML 报告 - **覆盖率门禁**`Coverage Report` job 会在 `v3` 的 PR、push 和手工触发中通过 `tests/run.py --serial` 跑串行全量,先上传 JSON / XML 工件,再只读检查 Application 与 Domain 的 Ubuntu/Python 3.14 canonical 低水位。覆盖率下降会阻塞,提升或等比例快照变化也必须显式刷新 fixture;macOS 本地报告只用于诊断,不直接作为可提交基线
- 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`,pytest 与覆盖率工具位于默认 `dev` 依赖组。 - 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`,pytest 与覆盖率工具位于默认 `dev` 依赖组。
+183 -20
View File
@@ -1,9 +1,11 @@
"""对 Application 与 Domain 维护不可退化的行覆盖率阈值""" """对 Application 与 Domain 维护不可退化且及时固化的行覆盖率低水位"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import math
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,22 +16,55 @@ PACKAGE_PREFIXES = {
"application": "app/application/", "application": "app/application/",
"domain": "app/domain/", "domain": "app/domain/",
} }
LEGACY_ZERO_BASELINE = {
name: {"statements": 0, "covered_lines": 0, "percent": 0.0}
for name in PACKAGE_PREFIXES
}
def _non_negative_int(value: object, *, field: str) -> int:
"""读取 coverage 整数计数,拒绝 bool、字符串和负值。"""
if type(value) is not int or value < 0:
raise ValueError(f"{field} 必须是非负整数")
return value
def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int | float]]: def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int | float]]:
"""按治理包聚合 coverage.py JSON 中的语句和已覆盖行。""" """按治理包聚合 coverage.py JSON 中的语句和已覆盖行。"""
if not isinstance(report, Mapping):
raise ValueError("coverage JSON 顶层必须是对象")
files = report.get("files")
if not isinstance(files, Mapping):
raise ValueError("coverage JSON 缺少 files 对象")
result: dict[str, dict[str, int | float]] = {} result: dict[str, dict[str, int | float]] = {}
files = report.get("files", {})
for name, prefix in PACKAGE_PREFIXES.items(): for name, prefix in PACKAGE_PREFIXES.items():
statements = 0 statements = 0
covered = 0 covered = 0
for path, details in files.items(): for path, details in files.items():
if not isinstance(path, str):
raise ValueError("coverage files 键必须是字符串路径")
if not path.replace("\\", "/").startswith(prefix): if not path.replace("\\", "/").startswith(prefix):
continue continue
summary = details["summary"] if not isinstance(details, Mapping):
statements += int(summary["num_statements"]) raise ValueError(f"{path}: coverage 文件详情必须是对象")
covered += int(summary["covered_lines"]) summary = details.get("summary")
percent = round(covered * 100 / statements, 2) if statements else 100.0 if not isinstance(summary, Mapping):
raise ValueError(f"{path}: coverage 文件详情缺少 summary 对象")
file_statements = _non_negative_int(
summary.get("num_statements"),
field=f"{path}.num_statements",
)
file_covered = _non_negative_int(
summary.get("covered_lines"),
field=f"{path}.covered_lines",
)
if file_covered > file_statements:
raise ValueError(
f"{path}: 已覆盖行数越界 {file_covered}/{file_statements}"
)
statements += file_statements
covered += file_covered
percent = round(covered * 100 / statements, 2) if statements else 0.0
result[name] = { result[name] = {
"statements": statements, "statements": statements,
"covered_lines": covered, "covered_lines": covered,
@@ -38,18 +73,110 @@ def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int
return result return result
def validate_coverage(
current: object,
) -> list[str]:
"""拒绝缺包、额外包、零快照和不一致的派生百分比。"""
problems: list[str] = []
if not isinstance(current, Mapping):
return ["覆盖率快照必须是对象"]
expected_names = set(PACKAGE_PREFIXES)
actual_names = set(current)
for name in sorted(expected_names - actual_names):
problems.append(f"{name}: 覆盖率报告缺少治理包")
for name in sorted(actual_names - expected_names):
problems.append(f"{name}: 覆盖率快照包含未知治理包")
for name in PACKAGE_PREFIXES:
values = current.get(name)
if values is None:
continue
if not isinstance(values, Mapping):
problems.append(f"{name}: 覆盖率数据必须是对象")
continue
if set(values) != {"statements", "covered_lines", "percent"}:
problems.append(f"{name}: 覆盖率数据字段不完整或包含未知字段")
continue
try:
statements = _non_negative_int(
values.get("statements"),
field=f"{name}.statements",
)
covered = _non_negative_int(
values.get("covered_lines"),
field=f"{name}.covered_lines",
)
except ValueError as error:
problems.append(str(error))
continue
percent = values.get("percent")
if (
isinstance(percent, bool)
or not isinstance(percent, (int, float))
or not math.isfinite(float(percent))
):
problems.append(f"{name}.percent 必须是有限数值")
continue
if statements <= 0:
problems.append(f"{name}: 覆盖率报告语句数必须大于 0")
continue
if not 0 <= covered <= statements:
problems.append(
f"{name}: 已覆盖行数越界 {covered}/{statements}"
)
continue
if covered == 0:
problems.append(f"{name}: 已覆盖行数必须大于 0")
expected_percent = round(covered * 100 / statements, 2)
if float(percent) != expected_percent:
problems.append(
f"{name}: percent 与计数不一致 {float(percent):.2f}!={expected_percent:.2f}"
)
return problems
def is_legacy_zero_baseline(baseline: object) -> bool:
"""识别本批次之前唯一允许被初始化替换的全零 fixture。"""
return baseline == LEGACY_ZERO_BASELINE
def classify_coverage(
baseline: dict[str, dict[str, int | float]],
current: dict[str, dict[str, int | float]],
) -> tuple[list[str], list[str]]:
"""把覆盖率差异分为不可写入的回退和可固化的新低水位。"""
regressions: list[str] = []
stale: list[str] = []
for name in PACKAGE_PREFIXES:
expected_values = baseline.get(name, {})
actual_values = current[name]
expected = float(expected_values.get("percent", 0.0))
actual = float(actual_values["percent"])
expected_statements = int(expected_values.get("statements", 0))
expected_covered = int(expected_values.get("covered_lines", 0))
actual_statements = int(actual_values["statements"])
actual_covered = int(actual_values["covered_lines"])
if (
expected_statements > 0
and actual_covered * expected_statements
< expected_covered * actual_statements
):
regressions.append(
f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%"
)
elif actual_values != expected_values:
stale.append(
f"{name}: 覆盖率低水位未固化 {expected:.2f}%->{actual:.2f}%"
)
return regressions, stale
def compare_coverage( def compare_coverage(
baseline: dict[str, dict[str, int | float]], baseline: dict[str, dict[str, int | float]],
current: dict[str, dict[str, int | float]], current: dict[str, dict[str, int | float]],
) -> list[str]: ) -> list[str]:
"""返回覆盖率低于已提交阈值的问题""" """返回覆盖率回退和尚未固化的新低水位"""
problems = [] regressions, stale = classify_coverage(baseline, current)
for name in PACKAGE_PREFIXES: return [*regressions, *stale]
expected = float(baseline[name]["percent"])
actual = float(current[name]["percent"])
if actual < expected:
problems.append(f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%")
return problems
def main() -> int: def main() -> int:
@@ -59,25 +186,61 @@ def main() -> int:
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
args = parser.parse_args() args = parser.parse_args()
report = json.loads(args.report.read_text(encoding="utf-8")) try:
current = collect_package_coverage(report) report = json.loads(args.report.read_text(encoding="utf-8"))
current = collect_package_coverage(report)
except (OSError, ValueError, TypeError) as error:
print(f"Coverage 报告无效:{error}")
return 1
validation_problems = validate_coverage(current)
if validation_problems:
print("\n".join(validation_problems))
return 1
baseline_exists = args.baseline.exists()
if baseline_exists:
try:
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError) as error:
print(f"Coverage 基线无效:{error}")
return 1
if not is_legacy_zero_baseline(baseline):
baseline_problems = validate_coverage(baseline)
if baseline_problems:
print("Coverage 基线无效:")
print("\n".join(baseline_problems))
return 1
else:
baseline = {}
regressions, stale = classify_coverage(baseline, current)
if args.write: if args.write:
if baseline_exists and regressions:
print("\n".join(regressions))
print("拒绝写入:当前结果包含覆盖率回退,--write 只能固化持平或提升后的低水位。")
return 1
args.baseline.parent.mkdir(parents=True, exist_ok=True) args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text( args.baseline.write_text(
json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n", json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8", encoding="utf-8",
) )
print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}") display_path = (
args.baseline.relative_to(PROJECT_ROOT)
if args.baseline.is_relative_to(PROJECT_ROOT)
else args.baseline
)
print(f"已写入 {display_path}")
return 0 return 0
baseline = json.loads(args.baseline.read_text(encoding="utf-8")) problems = [*regressions, *stale]
problems = compare_coverage(baseline, current)
if problems: if problems:
print("\n".join(problems)) print("\n".join(problems))
if regressions:
print("先消除覆盖率回退;存在下降时禁止用 --write 覆盖基线。")
else:
print("提示:当前只有覆盖率持平快照变化或提升,可用 --write 固化新的低水位。")
return 1 return 1
summary = ", ".join( summary = ", ".join(
f"{name}={values['percent']:.2f}%" for name, values in current.items() f"{name}={values['percent']:.2f}%" for name, values in current.items()
) )
print(f"覆盖率 ratchet 通过({summary}") print(f"覆盖率 ratchet 通过(低水位已同步:{summary}")
return 0 return 0
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"application": { "application": {
"covered_lines": 0, "covered_lines": 9292,
"percent": 0.0, "percent": 77.76,
"statements": 0 "statements": 11949
}, },
"domain": { "domain": {
"covered_lines": 0, "covered_lines": 3390,
"percent": 0.0, "percent": 79.24,
"statements": 0 "statements": 4278
} }
} }
+52
View File
@@ -1,5 +1,6 @@
"""后端架构治理工作流的静态契约测试。""" """后端架构治理工作流的静态契约测试。"""
import configparser
from pathlib import Path from pathlib import Path
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -57,6 +58,57 @@ def test_official_plugin_observation_is_scheduled_and_never_writes_fixture():
) )
def test_coverage_job_runs_full_suite_and_read_only_ratchet() -> None:
"""PR 与推送的 Coverage job 必须串行采集全量报告并只读检查低水位。"""
workflow = _load_workflow("test.yml")
coverage_job = workflow["jobs"]["coverage"]
steps = coverage_job["steps"]
assert workflow["on"]["pull_request"]["branches"] == ["v3"]
assert workflow["on"]["push"]["branches"] == ["v3"]
assert "workflow_dispatch" in workflow["on"]
assert coverage_job["runs-on"] == "ubuntu-latest"
assert "if" not in coverage_job
setup_step = next(step for step in steps if step.get("name") == "Set up uv")
install_step = next(
step for step in steps if step.get("name") == "Install dependencies"
)
generate_step = next(
step for step in steps if step.get("name") == "Generate coverage reports"
)
upload_step = next(
step for step in steps if step.get("name") == "Upload coverage report"
)
ratchet_step = next(
step for step in steps if step.get("name") == "Check coverage ratchet"
)
assert setup_step["with"]["python-version"] == "3.14"
assert install_step["run"] == "uv sync --locked"
commands = generate_step["run"]
expected_commands = [
"python -m coverage erase",
"python -m coverage run tests/run.py --serial",
"python -m coverage report",
"python -m coverage json",
"python -m coverage xml",
]
positions = [commands.index(command) for command in expected_commands]
assert positions == sorted(positions)
assert ratchet_step["run"].endswith("scripts/architecture/coverage_ratchet.py")
assert steps.index(generate_step) < steps.index(upload_step) < steps.index(ratchet_step)
all_commands = _step_commands(workflow, "coverage")
assert "--write" not in all_commands
assert "|| true" not in all_commands
assert all(step.get("continue-on-error") is not True for step in steps)
assert all("always()" not in str(step.get("if", "")) for step in steps)
coverage_config = configparser.ConfigParser()
coverage_config.read(PROJECT_ROOT / ".coveragerc", encoding="utf-8")
assert coverage_config["run"]["source"].strip() == "app"
assert "app/plugins/*/*" in coverage_config["run"]["omit"].splitlines()
def test_pylint_workflow_runs_for_v3_pull_requests_and_pushes(): def test_pylint_workflow_runs_for_v3_pull_requests_and_pushes():
"""改动文件应硬门禁,而全仓存量问题只能生成建议性报告。""" """改动文件应硬门禁,而全仓存量问题只能生成建议性报告。"""
workflow = _load_workflow("pylint.yml") workflow = _load_workflow("pylint.yml")
+361 -9
View File
@@ -3,6 +3,7 @@
import json import json
import subprocess import subprocess
import sys import sys
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -10,6 +11,10 @@ import pytest
from scripts.architecture.coverage_ratchet import ( from scripts.architecture.coverage_ratchet import (
collect_package_coverage, collect_package_coverage,
compare_coverage, compare_coverage,
validate_coverage,
)
from scripts.architecture.coverage_ratchet import (
main as coverage_main,
) )
from scripts.architecture.ruff_ratchet import ( from scripts.architecture.ruff_ratchet import (
PROJECT_ROOT, PROJECT_ROOT,
@@ -94,7 +99,10 @@ def test_run_ruff_rejects_status_payload_mismatch(
run_ruff() run_ruff()
def test_ruff_write_refuses_to_legalize_regression(tmp_path, monkeypatch) -> None: def test_ruff_write_refuses_to_legalize_regression(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有基线出现增长时,--write 不得覆盖原文件。""" """已有基线出现增长时,--write 不得覆盖原文件。"""
baseline_path = tmp_path / "ruff-baseline.json" baseline_path = tmp_path / "ruff-baseline.json"
baseline_path.write_text("{}\n", encoding="utf-8") baseline_path.write_text("{}\n", encoding="utf-8")
@@ -114,7 +122,10 @@ def test_ruff_write_refuses_to_legalize_regression(tmp_path, monkeypatch) -> Non
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {} assert json.loads(baseline_path.read_text(encoding="utf-8")) == {}
def test_ruff_write_persists_reduced_low_watermark(tmp_path, monkeypatch) -> None: def test_ruff_write_persists_reduced_low_watermark(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""没有增长时,--write 应把下降后的 Ruff 快照固化。""" """没有增长时,--write 应把下降后的 Ruff 快照固化。"""
baseline_path = tmp_path / "ruff-baseline.json" baseline_path = tmp_path / "ruff-baseline.json"
baseline_path.write_text( baseline_path.write_text(
@@ -164,18 +175,359 @@ def test_coverage_ratchet_aggregates_governed_packages() -> None:
} }
def test_coverage_ratchet_rejects_only_regressions() -> None: def test_coverage_ratchet_rejects_regressions_and_stale_improvements() -> None:
"""覆盖率达到或超过阈值时通过,任一包下降时失败""" """覆盖率下降必须修复,提升也必须及时固化为新低水位"""
baseline = { baseline = {
"application": {"percent": 50.0}, "application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"percent": 75.0}, "domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
} }
assert compare_coverage( assert compare_coverage(
baseline, baseline,
{"application": {"percent": 50.0}, "domain": {"percent": 76.0}}, {
) == [] "application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 25, "covered_lines": 19, "percent": 76.0},
},
) == ["domain: 覆盖率低水位未固化 75.00%->76.00%"]
assert compare_coverage( assert compare_coverage(
baseline, baseline,
{"application": {"percent": 49.99}, "domain": {"percent": 75.0}}, {
"application": {
"statements": 10000,
"covered_lines": 4999,
"percent": 49.99,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
) == ["application: 行覆盖率下降 50.00%->49.99%"] ) == ["application: 行覆盖率下降 50.00%->49.99%"]
def test_coverage_ratchet_detects_regression_hidden_by_rounding() -> None:
"""显示百分比相同时,真实覆盖比例下降仍必须失败。"""
baseline = {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
current = {
"application": {
"statements": 100000,
"covered_lines": 49996,
"percent": 50.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
assert compare_coverage(baseline, current) == [
"application: 行覆盖率下降 50.00%->50.00%"
]
def test_coverage_ratchet_requires_equal_ratio_snapshot_to_be_persisted() -> None:
"""真实比例持平但计数变化时也必须固化完整快照。"""
baseline = {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
current = {
"application": {"statements": 20, "covered_lines": 10, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
assert compare_coverage(baseline, current) == [
"application: 覆盖率低水位未固化 50.00%->50.00%"
]
def test_coverage_ratchet_rejects_zero_statement_report() -> None:
"""没有采集到治理包时不得把零语句误判为满覆盖。"""
current = collect_package_coverage({"files": {}})
assert current == {
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
"domain": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}
assert validate_coverage(current) == [
"application: 覆盖率报告语句数必须大于 0",
"domain: 覆盖率报告语句数必须大于 0",
]
@pytest.mark.parametrize(
("snapshot", "message"),
[
(
{
"application": {
"statements": 10,
"covered_lines": 11,
"percent": 110.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"已覆盖行数越界",
),
(
{
"application": {
"statements": 10,
"covered_lines": 5,
"percent": 49.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"percent 与计数不一致",
),
(
{
"application": {
"statements": True,
"covered_lines": 1,
"percent": 100.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"必须是非负整数",
),
],
)
def test_coverage_snapshot_validation_is_fail_closed(
snapshot: object,
message: str,
) -> None:
"""非法计数、布尔计数和人工百分比必须被受控拒绝。"""
assert any(message in problem for problem in validate_coverage(snapshot))
def test_coverage_main_rejects_malformed_report(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""缺少 files 对象的 JSON 报告必须返回失败而不是抛出栈。"""
report_path = tmp_path / "coverage.json"
report_path.write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
["coverage_ratchet.py", "--report", str(report_path)],
)
assert coverage_main() == 1
def test_coverage_write_refuses_to_legalize_regression(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有阈值出现下降时,--write 不得覆盖原文件。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 14}
},
}
}),
encoding="utf-8",
)
original = {
"application": {"statements": 10, "covered_lines": 4, "percent": 40.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
original_bytes = json.dumps(original, indent=1) + "\n"
baseline_path.write_text(original_bytes, encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 1
assert baseline_path.read_text(encoding="utf-8") == original_bytes
def test_coverage_write_persists_improved_low_watermark(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""覆盖率持平或提升时,--write 应固化当前完整快照。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
baseline_path.write_text(
json.dumps({
"application": {"statements": 10, "covered_lines": 4, "percent": 40.0},
"domain": {"statements": 20, "covered_lines": 14, "percent": 70.0},
}),
encoding="utf-8",
)
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 0
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
def test_coverage_write_persists_equal_ratio_snapshot(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""等比例计数变化不是回退,显式写入后应通过精确快照检查。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 20, "covered_lines": 10}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
baseline_path.write_text(
json.dumps({
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}),
encoding="utf-8",
)
command = [
"coverage_ratchet.py",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
]
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 1
monkeypatch.setattr(sys, "argv", [*command, "--write"])
assert coverage_main() == 0
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 0
def test_coverage_legacy_zero_baseline_can_only_initialize_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""历史全零 fixture 只允许初始化,初始化后下降仍必须拒绝写入。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
baseline_path.write_text(
json.dumps({
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
"domain": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}),
encoding="utf-8",
)
report = {
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}
report_path.write_text(json.dumps(report), encoding="utf-8")
command = [
"coverage_ratchet.py",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
]
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 1
monkeypatch.setattr(sys, "argv", [*command, "--write"])
assert coverage_main() == 0
initialized_bytes = baseline_path.read_bytes()
report["files"]["app/domain/a.py"]["summary"]["covered_lines"] = 14
report_path.write_text(json.dumps(report), encoding="utf-8")
assert coverage_main() == 1
assert baseline_path.read_bytes() == initialized_bytes
def test_coverage_write_rejects_malformed_existing_baseline(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有 malformed 基线不能借 --write 被静默洗掉。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
original_bytes = json.dumps({
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}).encode()
baseline_path.write_bytes(original_bytes)
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 1
assert baseline_path.read_bytes() == original_bytes