From 265d3c6d1a620791f8c1fce92c69d62fffae0d1d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 26 Aug 2026 22:10:23 +0800 Subject: [PATCH] fix(quality): close coverage ratchet --- .github/workflows/test.yml | 4 +- docs/development-setup.md | 20 +- docs/refactor/backend-architecture-review.md | 28 +- docs/testing.md | 2 +- scripts/architecture/coverage_ratchet.py | 203 +++++++++- .../architecture/coverage-baseline.json | 12 +- tests/test_architecture_ci.py | 52 +++ tests/test_quality_ratchets.py | 370 +++++++++++++++++- 8 files changed, 645 insertions(+), 46 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edef137d1..13c7000d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,6 @@ jobs: 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 xml - uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py - name: Upload coverage report uses: actions/upload-artifact@v7 @@ -150,3 +149,6 @@ jobs: coverage.xml coverage.json retention-days: 7 + + - name: Check coverage ratchet + run: uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py diff --git a/docs/development-setup.md b/docs/development-setup.md index 961ffafe9..fb5cf4975 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -198,8 +198,9 @@ uvx --from pip-audit==2.10.1 pip-audit \ `python tests/run.py` 在本地默认把排序后的测试文件按向上取整的连续区间切成 4 片, 并启动 4 个独立 pytest 进程;GitHub Actions 使用同一入口的 `--shard N/TOTAL` - 参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。覆盖率报告 - 按需通过 `Unit Tests` workflow 的手动触发串行生成,不阻塞常规 PR / push 门禁。 + 参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。Coverage job + 会在 `v3` 的 PR / push 中串行运行同一全量入口,并检查 Application 与 Domain 的 + 已提交低水位;它不是只在手工触发时运行的建议性报告。 4. **运行架构与静态门禁**:主仓架构检查不依赖独立插件仓;官方插件兼容观察单独运行, 任何检查命令都不会写入 fixture。 @@ -210,17 +211,24 @@ uvx --from pip-audit==2.10.1 pip-audit \ --check-plugins --plugin-repo ../MoviePilot-Plugins \ --report official-plugin-architecture-report.json 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 -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 文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周 或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。 - mypy 类型错误基线(`tests/fixtures/architecture/mypy-baseline.json`)只降不增: - 新增文件、错误码或既有计数增长都会被 `scripts/architecture/mypy_ratchet.py` 拒绝; - 修复存量错误后用同脚本 `--write` 收紧基线,禁止为绕过门禁放宽基线。受治文件清单 - 仍由 `mypy.ini` 的 `files=` 维护并保持零错误。 + Ruff/Mypy/Coverage 基线只允许收紧:新增诊断、类型错误增长或覆盖率下降都会被拒绝; + 已有债务下降或覆盖率提升但 fixture 尚未同步时,门禁也会要求用对应脚本的 `--write` + 显式固化新低水位。存在回退时 `--write` 会拒绝覆盖,不能用于放宽基线。Mypy 完整 + ratchet 固定按 Linux/Python 3.14 分析;Coverage fixture 只接受 GitHub Actions 的 + Ubuntu/Python 3.14、locked 依赖和串行全量测试工件,本机 macOS 报告仅用于诊断, + 不得直接写入并提交。受治零错误文件仍由 `mypy.ini` 的 `files=` 维护。 ### 7. 参考资源 diff --git a/docs/refactor/backend-architecture-review.md b/docs/refactor/backend-architecture-review.md index a9eed92f0..355d4a702 100644 --- a/docs/refactor/backend-architecture-review.md +++ b/docs/refactor/backend-architecture-review.md @@ -84,8 +84,8 @@ | 批次 | 叶子目标 | 状态 | 当前证据/停止条件 | |---|---|---|---| | 0 | 历史任务清账、现行架构图、外部契约核对和宿主基线对齐 | 已推送 | `d234c7132`;远端同 SHA;ahead/behind `0/0`;架构契约 `71 passed` | -| 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已本地验证 | Mypy 完整低水位 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`;Pylint `10.00/10` | -| 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 待批次 1 | fixture 当前 Application/Domain 均为 0%;最近 CI 仅作参考,必须在最终代码快照本地重建 | +| 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已推送 | `6062b0661`;远端同 SHA;ahead/behind `0/0`;Mypy 11994、Ruff 976 | +| 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 已本地验证 | Ubuntu canonical:Application `9292/11949`(77.76%),Domain `3390/4278`(79.24%);专项 `26 passed` | | 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 待批次 2 | Timer 只 cancel 不 join,Gateway 主线程可能在 heartbeat 仍执行时报告停止成功 | | Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 待前置批次 | 所有准入项已推送;全量测试和适用门禁通过;本地/远端 0/0 | @@ -134,7 +134,9 @@ * Ruff fixture 从 1623 收紧到 976,Mypy 和 Ruff 默认路径复跑均通过; * 质量/CI/上下文/严格类型专项 `33 passed`,架构契约 `71 passed`,改动文件 Pylint `10.00/10`。 -待完成:提交推送并记录远端证据。 +交付证据:提交 `6062b0661` 已推送到 `origin/v3`;`git ls-remote` 返回同一 SHA, +`HEAD...origin/v3` 为 `0/0`。提交严格包含上述 10 个批次路径;并行任务在提交前推进的 +`1f7fac2b2` 是其父提交,不在本批次 diff 中。 ### 批次 2:Coverage 低水位闭环 @@ -145,6 +147,23 @@ 零 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 + 工件再执行只读 ratchet;PR、push、手工触发、Python 版本、锁依赖和禁止绕过均有契约测试; +* 当前应用代码快照的本地串行全量结果为 `6379 passed, 6 skipped`;随后新增的边界测试由 + 最新专项覆盖,质量/CI 专项 `26 passed`,Ruff 通过,Pylint `10.00/10`,宿主、Schema、 + Coverage 和锁文件门禁均通过。 + +待完成:提交推送并记录远端证据。 + ### 批次 3:QQ Gateway heartbeat 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 | 批次 0 交付 | `d234c7132` 已推送;远端同 SHA;ahead/behind `0/0` | | 2026-08-26 | 批次 1 本地验收 | Mypy 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`;Pylint `10.00/10` | +| 2026-08-26 | 批次 1 交付 | `6062b0661` 已推送;远端同 SHA;ahead/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/锁文件门禁通过 | ## 七、本轮停止条件 diff --git a/docs/testing.md b/docs/testing.md index 32d5abf81..7c65c8b78 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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/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 / 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` 依赖组。 diff --git a/scripts/architecture/coverage_ratchet.py b/scripts/architecture/coverage_ratchet.py index 64f2b246c..51e077e9f 100644 --- a/scripts/architecture/coverage_ratchet.py +++ b/scripts/architecture/coverage_ratchet.py @@ -1,9 +1,11 @@ -"""对 Application 与 Domain 维护不可退化的行覆盖率阈值。""" +"""对 Application 与 Domain 维护不可退化且及时固化的行覆盖率低水位。""" from __future__ import annotations import argparse import json +import math +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -14,22 +16,55 @@ PACKAGE_PREFIXES = { "application": "app/application/", "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]]: """按治理包聚合 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]] = {} - files = report.get("files", {}) for name, prefix in PACKAGE_PREFIXES.items(): statements = 0 covered = 0 for path, details in files.items(): + if not isinstance(path, str): + raise ValueError("coverage files 键必须是字符串路径") if not path.replace("\\", "/").startswith(prefix): continue - summary = details["summary"] - statements += int(summary["num_statements"]) - covered += int(summary["covered_lines"]) - percent = round(covered * 100 / statements, 2) if statements else 100.0 + if not isinstance(details, Mapping): + raise ValueError(f"{path}: coverage 文件详情必须是对象") + summary = details.get("summary") + 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] = { "statements": statements, "covered_lines": covered, @@ -38,18 +73,110 @@ def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int 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( baseline: dict[str, dict[str, int | float]], current: dict[str, dict[str, int | float]], ) -> list[str]: - """返回包覆盖率低于已提交阈值的问题。""" - problems = [] - for name in PACKAGE_PREFIXES: - expected = float(baseline[name]["percent"]) - actual = float(current[name]["percent"]) - if actual < expected: - problems.append(f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%") - return problems + """返回覆盖率回退和尚未固化的新低水位。""" + regressions, stale = classify_coverage(baseline, current) + return [*regressions, *stale] def main() -> int: @@ -59,25 +186,61 @@ def main() -> int: parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) args = parser.parse_args() - report = json.loads(args.report.read_text(encoding="utf-8")) - current = collect_package_coverage(report) + try: + 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 baseline_exists and regressions: + print("\n".join(regressions)) + print("拒绝写入:当前结果包含覆盖率回退,--write 只能固化持平或提升后的低水位。") + return 1 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)}") + 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 - baseline = json.loads(args.baseline.read_text(encoding="utf-8")) - problems = compare_coverage(baseline, current) + problems = [*regressions, *stale] if problems: print("\n".join(problems)) + if regressions: + print("先消除覆盖率回退;存在下降时禁止用 --write 覆盖基线。") + else: + print("提示:当前只有覆盖率持平快照变化或提升,可用 --write 固化新的低水位。") return 1 summary = ", ".join( f"{name}={values['percent']:.2f}%" for name, values in current.items() ) - print(f"覆盖率 ratchet 通过({summary})") + print(f"覆盖率 ratchet 通过(低水位已同步:{summary})") return 0 diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 3e80cc0d7..736a61d73 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,12 +1,12 @@ { "application": { - "covered_lines": 0, - "percent": 0.0, - "statements": 0 + "covered_lines": 9292, + "percent": 77.76, + "statements": 11949 }, "domain": { - "covered_lines": 0, - "percent": 0.0, - "statements": 0 + "covered_lines": 3390, + "percent": 79.24, + "statements": 4278 } } diff --git a/tests/test_architecture_ci.py b/tests/test_architecture_ci.py index eb8b31715..ae2ffc17c 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -1,5 +1,6 @@ """后端架构治理工作流的静态契约测试。""" +import configparser from pathlib import Path 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(): """改动文件应硬门禁,而全仓存量问题只能生成建议性报告。""" workflow = _load_workflow("pylint.yml") diff --git a/tests/test_quality_ratchets.py b/tests/test_quality_ratchets.py index 7cc0e2edb..dc166a73c 100644 --- a/tests/test_quality_ratchets.py +++ b/tests/test_quality_ratchets.py @@ -3,6 +3,7 @@ import json import subprocess import sys +from pathlib import Path from unittest.mock import patch import pytest @@ -10,6 +11,10 @@ import pytest from scripts.architecture.coverage_ratchet import ( collect_package_coverage, compare_coverage, + validate_coverage, +) +from scripts.architecture.coverage_ratchet import ( + main as coverage_main, ) from scripts.architecture.ruff_ratchet import ( PROJECT_ROOT, @@ -94,7 +99,10 @@ def test_run_ruff_rejects_status_payload_mismatch( 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 不得覆盖原文件。""" baseline_path = tmp_path / "ruff-baseline.json" 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")) == {} -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 快照固化。""" baseline_path = tmp_path / "ruff-baseline.json" 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 = { - "application": {"percent": 50.0}, - "domain": {"percent": 75.0}, + "application": {"statements": 10, "covered_lines": 5, "percent": 50.0}, + "domain": {"statements": 20, "covered_lines": 15, "percent": 75.0}, } assert compare_coverage( 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( 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%"] + + +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