|
|
| """Diff ruff + ty diagnostic reports between two git refs.
|
|
|
| Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
|
| comments. Compares issues by a stable key (file, rule, line) so line-only
|
| shifts from unrelated edits are treated as the same issue.
|
|
|
| Usage:
|
| lint_diff.py \\
|
| --base-ruff base/ruff.json --head-ruff head/ruff.json \\
|
| --base-ty base/ty.json --head-ty head/ty.json \\
|
| [--base-ref origin/main] [--head-ref HEAD]
|
|
|
| Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
|
| case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
|
| config yet, or a tool crashed).
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import argparse
|
| import json
|
| import os
|
| import sys
|
| from collections import Counter
|
| from pathlib import Path
|
|
|
|
|
| def _load_json(path: Path | None) -> list[dict]:
|
| if path is None or not path.exists() or path.stat().st_size == 0:
|
| return []
|
| try:
|
| data = json.loads(path.read_text())
|
| except json.JSONDecodeError as exc:
|
| print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
| return []
|
| if not isinstance(data, list):
|
| return []
|
| return data
|
|
|
|
|
| def _normalize_ruff(entries: list[dict]) -> list[dict]:
|
| """Ruff JSON: {code, filename, location.row, message}."""
|
| out: list[dict] = []
|
| for e in entries:
|
| code = e.get("code") or "unknown"
|
|
|
| filename = e.get("filename", "")
|
| try:
|
| filename = os.path.relpath(filename)
|
| except ValueError:
|
| pass
|
| line = (e.get("location") or {}).get("row", 0)
|
| out.append(
|
| {
|
| "tool": "ruff",
|
| "rule": code,
|
| "path": filename,
|
| "line": line,
|
| "message": e.get("message", ""),
|
| }
|
| )
|
| return out
|
|
|
|
|
| def _normalize_ty(entries: list[dict]) -> list[dict]:
|
| """ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
|
| out: list[dict] = []
|
| for e in entries:
|
| loc = e.get("location") or {}
|
| begin = (loc.get("positions") or {}).get("begin") or {}
|
| out.append(
|
| {
|
| "tool": "ty",
|
| "rule": e.get("check_name", "unknown"),
|
| "path": loc.get("path", ""),
|
| "line": begin.get("line", 0),
|
| "message": e.get("description", ""),
|
| }
|
| )
|
| return out
|
|
|
|
|
| def _key(d: dict) -> tuple[str, str, str]:
|
| """Stable diagnostic identity across commits: (path, rule, message)."""
|
|
|
|
|
| return (d["path"], d["rule"], d["message"])
|
|
|
|
|
| def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
| base_map = {_key(d): d for d in base}
|
| head_map = {_key(d): d for d in head}
|
| base_keys = set(base_map)
|
| head_keys = set(head_map)
|
| new_keys = head_keys - base_keys
|
| fixed_keys = base_keys - head_keys
|
| unchanged_keys = base_keys & head_keys
|
|
|
| return (
|
| [head_map[k] for k in new_keys],
|
| [base_map[k] for k in fixed_keys],
|
| [head_map[k] for k in unchanged_keys],
|
| )
|
|
|
|
|
| def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
|
| return Counter(e["rule"] for e in entries).most_common()
|
|
|
|
|
| def _section(title: str, entries: list[dict], limit: int = 25) -> str:
|
| if not entries:
|
| return f"**{title}:** none\n"
|
| lines = [f"**{title} ({len(entries)}):**\n"]
|
|
|
| counts = _rule_counts(entries)
|
| lines.append("| Rule | Count |")
|
| lines.append("| --- | ---: |")
|
| for rule, count in counts[:15]:
|
| lines.append(f"| `{rule}` | {count} |")
|
| if len(counts) > 15:
|
| lines.append(f"| _+{len(counts) - 15} more rules_ | |")
|
| lines.append("")
|
| lines.append("<details><summary>First entries</summary>\n")
|
| lines.append("```")
|
| for e in entries[:limit]:
|
| lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
|
| if len(entries) > limit:
|
| lines.append(f"... and {len(entries) - limit} more")
|
| lines.append("```")
|
| lines.append("</details>\n")
|
| return "\n".join(lines)
|
|
|
|
|
| def _tool_report(
|
| tool_name: str,
|
| base: list[dict],
|
| head: list[dict],
|
| base_available: bool,
|
| ) -> str:
|
| new, fixed, unchanged = _diff(base, head)
|
| delta = len(head) - len(base)
|
| delta_str = f"+{delta}" if delta > 0 else str(delta)
|
| emoji = "π" if delta > 0 else ("β
" if delta < 0 else "β")
|
|
|
| lines = [f"## {tool_name}\n"]
|
| if not base_available:
|
| lines.append(
|
| "_Base report unavailable (likely main has no config for this tool yet); "
|
| "treating all head diagnostics as new._\n"
|
| )
|
| lines.append(
|
| f"**Total:** {len(head)} on HEAD, {len(base)} on base "
|
| f"({emoji} {delta_str})\n"
|
| )
|
| lines.append(_section("π New issues", new))
|
| lines.append(_section("β
Fixed issues", fixed))
|
| lines.append(
|
| f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
|
| )
|
| return "\n".join(lines)
|
|
|
|
|
| def main() -> int:
|
| ap = argparse.ArgumentParser()
|
| ap.add_argument("--base-ruff", type=Path, required=True)
|
| ap.add_argument("--head-ruff", type=Path, required=True)
|
| ap.add_argument("--base-ty", type=Path, required=True)
|
| ap.add_argument("--head-ty", type=Path, required=True)
|
| ap.add_argument("--base-ref", default="base")
|
| ap.add_argument("--head-ref", default="HEAD")
|
| ap.add_argument(
|
| "--output", type=Path, help="Write summary to this file instead of stdout"
|
| )
|
| args = ap.parse_args()
|
|
|
| base_ruff_raw = _load_json(args.base_ruff)
|
| head_ruff_raw = _load_json(args.head_ruff)
|
| base_ty_raw = _load_json(args.base_ty)
|
| head_ty_raw = _load_json(args.head_ty)
|
|
|
| base_ruff = _normalize_ruff(base_ruff_raw)
|
| head_ruff = _normalize_ruff(head_ruff_raw)
|
| base_ty = _normalize_ty(base_ty_raw)
|
| head_ty = _normalize_ty(head_ty_raw)
|
|
|
| base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
|
| base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
|
|
|
| buf: list[str] = []
|
| buf.append(f"# π Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
|
| buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
|
| buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
|
| buf.append(
|
| "_Diagnostics are surfaced as warnings β this check never fails the build._\n"
|
| )
|
|
|
| summary = "\n".join(buf)
|
| if args.output:
|
| args.output.write_text(summary)
|
| else:
|
| print(summary)
|
| return 0
|
|
|
|
|
| if __name__ == "__main__":
|
| raise SystemExit(main())
|
|
|