Files
hermes-agent/scripts/lint_diff.py
ethernet 6e294f543d fix: read source files with encoding=utf-8-sig
Read text files with the encoding utf-8-sig so a BOM at the start of a
file does not cause a Unicode decode error (Windows editors add BOMs).

Reconstructed from ethie/pm commits 48a32b135b + 013219e814 onto the
current upstream/main base: only the utf-8 -> utf-8-sig transforms were
carried (370 exact line pairs across 205 files); pm-rename hunks that
rode in the original commit were left to the pm-store commit, and
utf8sig hunks entangled with content changes ride their owning commit.

Rebuilt on ethie/pm-clean off ac6c8028e0 (upstream/main).
2026-08-29 21:23:00 -04:00

208 lines
6.9 KiB
Python
Executable File
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.

#!/usr/bin/env python3
"""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(encoding="utf-8-sig"))
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"
# ruff emits absolute paths; relativize to repo root if possible
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)."""
# Intentionally omit line so unrelated edits above an issue don't flag it
# as "new". Same file + same rule + same message = same issue.
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 entries for new (current line numbers), base entries for fixed
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"]
# Group by rule for readability
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, encoding="utf-8")
else:
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())