mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-01 13:37:24 +08:00
fix(quality): close coverage ratchet
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. 参考资源
|
||||
|
||||
|
||||
@@ -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/锁文件门禁通过 |
|
||||
|
||||
## 七、本轮停止条件
|
||||
|
||||
|
||||
+1
-1
@@ -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` 依赖组。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+6
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user