ci(release): enforce changed-scope Ruff gate
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s

This commit is contained in:
2026-09-13 15:41:35 +09:00
parent 1010d257ad
commit 4520fbbfbd
5 changed files with 147 additions and 89 deletions
+109 -54
View File
@@ -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