Files
MoviePilot/scripts/architecture/coverage_ratchet.py
Aqr-K d51320103c fix: make local setup and bundled skill operations recoverable (#7150)
* fix: make local setup and bundled skill operations recoverable

* test: verify user updates with real transaction runners
2026-10-23 06:49:13 +08:00

435 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
检查除官方插件外全部 app Python 文件的覆盖率低水位。
"""
from __future__ import annotations
import argparse
import json
import math
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_REPORT = PROJECT_ROOT / "coverage.json"
DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/coverage-baseline.json"
# 第 2 版覆盖率快照以逐文件计数为准,同时保留包级和全量摘要。
SCHEMA_VERSION = 2
SCOPE_ROOT = "app"
EXCLUDED_PREFIXES = ("app/plugins/",)
# 新受治理文件必须覆盖至少 80% 的可执行语句。
NEW_FILE_COVERAGE_PERCENT = 80.0
_METRIC_FIELDS = {"statements", "covered_lines", "percent"}
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 _normalise_path(path: str) -> str:
"""
将 coverage.py 路径中的反斜杠统一为 POSIX 分隔符。
"""
return path.replace("\\", "/")
def _is_governed_path(path: str) -> bool:
"""
判断路径是否属于 app 且不在官方插件目录。
"""
normalised = _normalise_path(path)
return (
normalised.startswith("app/")
and not any(
normalised == prefix.rstrip("/") or normalised.startswith(prefix)
for prefix in EXCLUDED_PREFIXES
)
and normalised.endswith(".py")
)
def _metric(statements: int, covered: int) -> dict[str, int | float]:
"""
根据 coverage.py 计数构造稳定的逐文件覆盖率记录。
"""
percent = round(covered * 100 / statements, 2) if statements else 0.0
return {"statements": statements, "covered_lines": covered, "percent": percent}
def _validate_metric(
values: object, *, name: str, allow_zero: bool = True
) -> list[str]:
"""
校验一条覆盖率记录的字段、计数边界和派生百分比。
"""
problems: list[str] = []
if not isinstance(values, Mapping):
return [f"{name}: 覆盖率数据必须是对象"]
if set(values) != _METRIC_FIELDS:
return [f"{name}: 覆盖率数据字段不完整或包含未知字段"]
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:
return [str(error)]
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 必须是有限数值")
return problems
if not 0 <= covered <= statements:
problems.append(f"{name}: 已覆盖行数越界 {covered}/{statements}")
if not allow_zero and statements <= 0:
problems.append(f"{name}: 覆盖率报告语句数必须大于 0")
expected_percent = round(covered * 100 / statements, 2) if statements else 0.0
if float(percent) != expected_percent:
problems.append(
f"{name}: percent 与计数不一致 {float(percent):.2f}!={expected_percent:.2f}"
)
return problems
def _aggregate(
metrics: Mapping[str, Mapping[str, int | float]],
) -> dict[str, int | float]:
"""
按真实语句数聚合逐文件覆盖率,避免平均百分比失真。
"""
statements = sum(int(values["statements"]) for values in metrics.values())
covered = sum(int(values["covered_lines"]) for values in metrics.values())
return _metric(statements, covered)
def _package_name(path: str) -> str:
"""
返回 app 文件对应的一级包名。
"""
relative = path.removeprefix("app/")
return relative.split("/", 1)[0] if "/" in relative else "__root__"
def _snapshot_from_files(
files: dict[str, dict[str, int | float]],
) -> dict[str, Any]:
"""
从逐文件记录构造带包级和全量摘要的快照。
"""
package_files: dict[str, dict[str, dict[str, int | float]]] = {}
for path, values in files.items():
package_files.setdefault(_package_name(path), {})[path] = values
packages = {
name: _aggregate(files_for_package)
for name, files_for_package in sorted(package_files.items())
}
return {
"schema_version": SCHEMA_VERSION,
"scope": {"root": SCOPE_ROOT, "excluded": ["app/plugins"]},
"files": dict(sorted(files.items())),
"packages": packages,
"total": _aggregate(files),
}
def collect_package_coverage(report: dict[str, Any]) -> dict[str, Any]:
"""
收集全部受治理文件,并附带一级包与全量聚合摘要。
:param report: coverage.py 导出的 JSON 报告,必须含有效的 files 对象
:return: 按文件、一级包和全部源码汇总的覆盖率快照
:raises ValueError: 报告结构、文件路径或计数不符合要求
"""
if not isinstance(report, Mapping):
raise ValueError("coverage JSON 顶层必须是对象")
files = report.get("files")
if not isinstance(files, Mapping):
raise ValueError("coverage JSON 缺少 files 对象")
collected: dict[str, dict[str, int | float]] = {}
for raw_path, details in files.items():
if not isinstance(raw_path, str):
raise ValueError("coverage files 键必须是字符串路径")
path = _normalise_path(raw_path)
if not _is_governed_path(path):
continue
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 对象")
statements = _non_negative_int(
summary.get("num_statements"), field=f"{path}.num_statements"
)
covered = _non_negative_int(
summary.get("covered_lines"), field=f"{path}.covered_lines"
)
if covered > statements:
raise ValueError(f"{path}: 已覆盖行数越界 {covered}/{statements}")
collected[path] = _metric(statements, covered)
return _snapshot_from_files(collected)
def validate_coverage(current: object, *, require_data: bool = False) -> list[str]:
"""
拒绝超出治理范围、字段不完整或计数与百分比不一致的快照。
:param current: 待检查的覆盖率快照
:param require_data: 是否拒绝空快照或没有可执行语句的数据
:return: 格式与计数错误列表;空列表表示有效
"""
problems: list[str] = []
if not isinstance(current, Mapping):
return ["覆盖率快照必须是对象"]
if current.get("schema_version") != SCHEMA_VERSION:
problems.append(f"覆盖率快照 schema_version 必须是 {SCHEMA_VERSION}")
if current.get("scope") != {"root": SCOPE_ROOT, "excluded": ["app/plugins"]}:
problems.append("覆盖率快照 scope 必须覆盖 app 并排除 app/plugins")
files = current.get("files")
if not isinstance(files, Mapping):
problems.append("覆盖率快照缺少 files 对象")
return problems
if require_data and not files:
problems.append("覆盖率报告缺少受治理文件")
for path, values in files.items():
if not isinstance(path, str):
problems.append("覆盖率快照 files 键必须是字符串路径")
continue
if not _is_governed_path(path):
problems.append(f"{path}: 路径不在覆盖率治理范围")
continue
problems.extend(_validate_metric(values, name=path))
packages = current.get("packages")
if not isinstance(packages, Mapping):
problems.append("覆盖率快照缺少 packages 对象")
else:
for name, values in packages.items():
if not isinstance(name, str):
problems.append("packages 键必须是字符串")
continue
problems.extend(_validate_metric(values, name=f"packages.{name}"))
total = current.get("total")
problems.extend(_validate_metric(total, name="total"))
if require_data and isinstance(total, Mapping) and total.get("statements") == 0:
problems.append("覆盖率报告语句数必须大于 0")
if isinstance(files, Mapping) and isinstance(packages, Mapping) and isinstance(total, Mapping):
expected_packages: dict[str, dict[str, Mapping[str, int | float]]] = {}
for path, values in files.items():
if not isinstance(path, str) or not isinstance(values, Mapping):
continue
expected_packages.setdefault(_package_name(path), {})[path] = values
for name in sorted(set(packages) - set(expected_packages)):
problems.append(f"packages.{name}: 包级摘要没有对应文件")
for name, values in expected_packages.items():
if packages.get(name) != _aggregate(values):
problems.append(f"packages.{name}: 聚合结果与 files 不一致")
if dict(total) != _aggregate(files):
problems.append("total: 聚合结果与 files 不一致")
return problems
def _file_ratio_is_lower(
previous: Mapping[str, int | float], current: Mapping[str, int | float]
) -> bool:
"""
使用真实计数比较两个文件的覆盖率,避免四舍五入误判。
"""
previous_statements = int(previous["statements"])
current_statements = int(current["statements"])
if previous_statements == 0:
return False
if current_statements == 0:
return True
return int(current["covered_lines"]) * previous_statements < int(
previous["covered_lines"]
) * current_statements
def _fewer_statements_without_new_misses(
previous: Mapping[str, int | float], current: Mapping[str, int | float]
) -> bool:
"""
允许语句减少且未覆盖行数不增加造成的比例下降。
"""
previous_statements = int(previous["statements"])
current_statements = int(current["statements"])
return (
current_statements < previous_statements
and current_statements - int(current["covered_lines"])
<= previous_statements - int(previous["covered_lines"])
)
def classify_coverage(
baseline: Mapping[str, Any], current: Mapping[str, Any]
) -> tuple[list[str], list[str]]:
"""
拒绝存量文件新增未覆盖行造成的回退,并要求新文件达到 80%。
:param baseline: 已确认的逐文件覆盖率低水位
:param current: 当前逐文件覆盖率快照
:return: 回退问题与可提升低水位的条目两个列表
"""
regressions: list[str] = []
improvements: list[str] = []
baseline_files = baseline.get("files", {})
current_files = current.get("files", {})
if not isinstance(baseline_files, Mapping) or not isinstance(current_files, Mapping):
return ["Coverage 基线或当前报告缺少 files 对象"], []
for path, values in current_files.items():
previous = baseline_files.get(path)
if previous is None:
statements = int(values["statements"])
covered = int(values["covered_lines"])
if covered * 100 < NEW_FILE_COVERAGE_PERCENT * statements:
regressions.append(
f"{path}: 新文件覆盖率低于 {NEW_FILE_COVERAGE_PERCENT:.0f}%"
f"->{float(values['percent']):.2f}%"
)
else:
improvements.append(f"{path}: 新文件达到覆盖率门槛")
elif _file_ratio_is_lower(previous, values) and not _fewer_statements_without_new_misses(
previous, values
):
regressions.append(
f"{path}: 存量覆盖率下降 "
f"{float(previous['percent']):.2f}%->{float(values['percent']):.2f}%"
)
elif _file_ratio_is_lower(values, previous):
improvements.append(f"{path}: 覆盖率上升")
return regressions, improvements
def compare_coverage(
baseline: Mapping[str, Any], current: Mapping[str, Any]
) -> list[str]:
"""
返回存量覆盖率回退或新文件低于 80% 的问题。
"""
regressions, _ = classify_coverage(baseline, current)
return regressions
def _merge_baseline(
baseline: Mapping[str, Any], current: Mapping[str, Any]
) -> dict[str, Any]:
"""
使用同一份当前计数生成逐文件和汇总低水位;调用方已拒绝覆盖率回退。
"""
regressions = compare_coverage(baseline, current)
if regressions:
raise ValueError("拒绝写入覆盖率回退:" + "; ".join(regressions))
return _snapshot_from_files(current["files"])
def main() -> int:
"""
检查 coverage JSON,或在无回退时显式刷新逐文件低水位。
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--write", action="store_true", help="写入当前覆盖率低水位")
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
args = parser.parse_args()
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
if args.report.resolve() == DEFAULT_REPORT.resolve():
source_paths = {
path.relative_to(PROJECT_ROOT).as_posix()
for path in (PROJECT_ROOT / SCOPE_ROOT).rglob("*.py")
if path.relative_to(PROJECT_ROOT).parts[:2] != ("app", "plugins")
}
missing_source_paths = source_paths - set(current["files"])
stale_report_paths = set(current["files"]) - source_paths
if os.environ.get("CI", "").lower() == "true" and (
missing_source_paths or stale_report_paths
):
print(
"Coverage 报告未完整覆盖当前 app 源码,"
"CI 只接受 canonical 全量报告。"
)
return 1
if args.write and (missing_source_paths or stale_report_paths):
print(
"拒绝写入:默认 coverage 报告未完整覆盖当前 app 源码,"
"请使用 canonical 全量报告。"
)
return 1
current_files = {
path: values
for path, values in current["files"].items()
if path in source_paths
}
current = _snapshot_from_files(current_files)
validation_problems = validate_coverage(current, require_data=True)
if validation_problems:
print("\n".join(validation_problems))
return 1
if args.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
baseline_problems = validate_coverage(baseline)
if baseline_problems:
print("Coverage 基线无效:")
print("\n".join(baseline_problems))
return 1
else:
baseline = {
"schema_version": SCHEMA_VERSION,
"scope": {"root": SCOPE_ROOT, "excluded": ["app/plugins"]},
"files": {},
}
regressions, _ = classify_coverage(baseline, current)
if regressions:
print("\n".join(regressions))
if args.write:
print("拒绝写入:覆盖率低于既有低水位或新文件 80% 门槛。")
return 1
if args.write:
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(
json.dumps(
_merge_baseline(baseline, current), ensure_ascii=False, indent=2, sort_keys=True
)
+ "\n",
encoding="utf-8",
)
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
total = current["total"]
print(
"覆盖率 ratchet 通过(全 app 排除 plugins:"
f"{total['percent']:.2f}%,文件 {len(current['files'])} 个)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())