ci(release): enforce changed-scope Ruff gate
This commit is contained in:
+109
-54
@@ -1,71 +1,126 @@
|
||||
"""Enforce the repository's v0.2.1 Ruff debt as a decreasing baseline."""
|
||||
"""Reject Ruff findings introduced since a stable release tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BASELINE_VERSION = "v0.2.1"
|
||||
BASELINE_TOTAL = 576
|
||||
BASELINE_BY_RULE = {
|
||||
"B008": 117,
|
||||
"B017": 1,
|
||||
"B018": 1,
|
||||
"BLE001": 19,
|
||||
"C408": 7,
|
||||
"DTZ901": 1,
|
||||
"F401": 65,
|
||||
"F541": 6,
|
||||
"F811": 16,
|
||||
"F841": 20,
|
||||
"FURB157": 2,
|
||||
"I001": 90,
|
||||
"RET501": 2,
|
||||
"RUF015": 2,
|
||||
"RUF059": 8,
|
||||
"RUF100": 1,
|
||||
"SIM102": 1,
|
||||
"SIM103": 1,
|
||||
"SIM114": 2,
|
||||
"SIM115": 8,
|
||||
"SIM117": 13,
|
||||
"TRY004": 1,
|
||||
"TRY203": 9,
|
||||
"UP017": 166,
|
||||
"UP035": 5,
|
||||
"UP037": 4,
|
||||
"UP045": 8,
|
||||
}
|
||||
|
||||
|
||||
def baseline_regressions(diagnostics: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return rule-count or total-count increases above the frozen baseline."""
|
||||
counts = Counter(item["code"] for item in diagnostics)
|
||||
regressions = [
|
||||
f"{code}: {count} > {BASELINE_BY_RULE.get(code, 0)}"
|
||||
for code, count in sorted(counts.items())
|
||||
if count > BASELINE_BY_RULE.get(code, 0)
|
||||
]
|
||||
if len(diagnostics) > BASELINE_TOTAL:
|
||||
regressions.append(f"total: {len(diagnostics)} > {BASELINE_TOTAL}")
|
||||
return regressions
|
||||
def _run(command: list[str], *, cwd: Path, accepted: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
|
||||
if result.returncode not in accepted:
|
||||
raise RuntimeError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def changed_python_paths(repo: Path, base: str) -> list[str]:
|
||||
result = _run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
f"{base}...HEAD",
|
||||
"--",
|
||||
"*.py",
|
||||
],
|
||||
cwd=repo,
|
||||
)
|
||||
return sorted(path for path in result.stdout.split("\0") if path)
|
||||
|
||||
|
||||
def _source_line(root: Path, relative_path: str, row: int) -> str:
|
||||
lines = (root / relative_path).read_text(encoding="utf-8").splitlines()
|
||||
return lines[row - 1].strip() if 0 < row <= len(lines) else ""
|
||||
|
||||
|
||||
def normalized_findings(
|
||||
diagnostics: list[dict[str, object]], root: Path
|
||||
) -> Counter[tuple[str, str, str, str]]:
|
||||
findings: Counter[tuple[str, str, str, str]] = Counter()
|
||||
for diagnostic in diagnostics:
|
||||
filename = Path(str(diagnostic["filename"])).resolve()
|
||||
relative_path = filename.relative_to(root.resolve()).as_posix()
|
||||
location = diagnostic["location"]
|
||||
if not isinstance(location, dict):
|
||||
raise TypeError("Ruff diagnostic location must be an object")
|
||||
row = int(location["row"])
|
||||
finding = (
|
||||
relative_path,
|
||||
str(diagnostic["code"]),
|
||||
str(diagnostic["message"]),
|
||||
_source_line(root, relative_path, row),
|
||||
)
|
||||
findings[finding] += 1
|
||||
return findings
|
||||
|
||||
|
||||
def _ruff_diagnostics(root: Path, paths: list[str]) -> list[dict[str, object]]:
|
||||
if not paths:
|
||||
return []
|
||||
ruff = shutil.which("ruff")
|
||||
if ruff is None:
|
||||
raise RuntimeError("ruff executable is not available")
|
||||
result = _run(
|
||||
[ruff, "check", *paths, "--output-format=json"],
|
||||
cwd=root,
|
||||
accepted=(0, 1),
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
if not isinstance(payload, list):
|
||||
raise TypeError("Ruff JSON output must be a list")
|
||||
return payload
|
||||
|
||||
|
||||
def introduced_findings(repo: Path, base: str) -> tuple[list[str], Counter[tuple[str, str, str, str]]]:
|
||||
paths = changed_python_paths(repo, base)
|
||||
if not paths:
|
||||
return paths, Counter()
|
||||
|
||||
current = normalized_findings(_ruff_diagnostics(repo, paths), repo)
|
||||
with tempfile.TemporaryDirectory(prefix="portfoliohub-ruff-base-") as parent:
|
||||
baseline_root = Path(parent) / "tree"
|
||||
_run(["git", "worktree", "add", "--detach", str(baseline_root), base], cwd=repo)
|
||||
try:
|
||||
baseline_paths = [path for path in paths if (baseline_root / path).is_file()]
|
||||
baseline = normalized_findings(
|
||||
_ruff_diagnostics(baseline_root, baseline_paths), baseline_root
|
||||
)
|
||||
finally:
|
||||
_run(
|
||||
["git", "worktree", "remove", "--force", str(baseline_root)],
|
||||
cwd=repo,
|
||||
)
|
||||
return paths, current - baseline
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("diagnostics", type=Path)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True, help="stable comparison tag")
|
||||
args = parser.parse_args()
|
||||
diagnostics = json.loads(args.diagnostics.read_text(encoding="utf-8"))
|
||||
regressions = baseline_regressions(diagnostics)
|
||||
if regressions:
|
||||
print(f"Ruff debt increased above {BASELINE_VERSION}:")
|
||||
for regression in regressions:
|
||||
print(f"- {regression}")
|
||||
|
||||
repo = Path(
|
||||
_run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd()).stdout.strip()
|
||||
)
|
||||
ruff_version = _run([shutil.which("ruff") or "ruff", "--version"], cwd=repo).stdout.strip()
|
||||
paths, introduced = introduced_findings(repo, args.base)
|
||||
print(f"base={args.base} head=HEAD selector=ACMR-python files={len(paths)} tool={ruff_version}")
|
||||
for path in paths:
|
||||
print(f"selected={path}")
|
||||
if introduced:
|
||||
for (path, code, message, source), count in sorted(introduced.items()):
|
||||
print(f"NEW {count}x {path}: {code} {message} | {source}")
|
||||
print(f"introduced_findings={sum(introduced.values())}")
|
||||
return 1
|
||||
print(f"Ruff debt ratchet passed: {len(diagnostics)} <= {BASELINE_TOTAL} ({BASELINE_VERSION})")
|
||||
print("introduced_findings=0")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user