diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..a724dfef89 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,151 @@ +name: Lint (ruff + ty) + +# Surface ruff and ty diagnostics as a diff vs the target branch. +# This check is advisory only ATM it always exits zero and never blocks merge. +# It posts a Markdown summary to the workflow run and, for pull requests, +# comments the same summary on the PR. + +on: + push: + branches: [main] + paths-ignore: + - "**/*.md" + - "docs/**" + - "website/**" + pull_request: + branches: [main] + paths-ignore: + - "**/*.md" + - "docs/**" + - "website/**" + +permissions: + contents: read + pull-requests: write # needed to post/update PR comments + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-diff: + name: ruff + ty diff + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # need full history for merge-base + worktree + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Install ruff + ty + run: | + uv tool install ruff + uv tool install ty + + - name: Determine base ref + id: base + run: | + # For PRs, diff against the merge base with the target branch. + # For pushes to main, diff against the previous commit on main. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD) + BASE_REF="origin/${{ github.base_ref }}" + else + BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) + BASE_REF="HEAD~1" + fi + echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT" + echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT" + echo "Base SHA: ${BASE_SHA}" + echo "Base ref: ${BASE_REF}" + + - name: Run ruff + ty on HEAD + run: | + mkdir -p .lint-reports/head + ruff check --output-format json --exit-zero \ + > .lint-reports/head/ruff.json || true + ty check --output-format gitlab --exit-zero \ + > .lint-reports/head/ty.json || true + echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes" + echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes" + + - name: Run ruff + ty on base (via git worktree) + run: | + mkdir -p .lint-reports/base + # Use a worktree so we don't clobber the main checkout. If the basex + # SHA is identical to HEAD (e.g. first commit), skip and leave the + # base reports empty — the diff script handles missing files. + HEAD_SHA=$(git rev-parse HEAD) + BASE_SHA="${{ steps.base.outputs.sha }}" + if [ "$BASE_SHA" = "$HEAD_SHA" ]; then + echo "Base SHA == HEAD SHA, skipping base scan." + echo '[]' > .lint-reports/base/ruff.json + echo '[]' > .lint-reports/base/ty.json + else + git worktree add --detach /tmp/lint-base "$BASE_SHA" + ( + cd /tmp/lint-base + ruff check --output-format json --exit-zero \ + > "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true + ty check --output-format gitlab --exit-zero \ + > "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true + ) + git worktree remove --force /tmp/lint-base + fi + echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes" + echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes" + + - name: Generate diff summary + run: | + python scripts/lint_diff.py \ + --base-ruff .lint-reports/base/ruff.json \ + --head-ruff .lint-reports/head/ruff.json \ + --base-ty .lint-reports/base/ty.json \ + --head-ty .lint-reports/head/ty.json \ + --base-ref "${{ steps.base.outputs.ref }}" \ + --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \ + --output .lint-reports/summary.md + cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload reports as artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lint-reports + path: .lint-reports/ + retention-days: 14 + + - name: Post / update PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('.lint-reports/summary.md', 'utf8'); + const marker = ''; + const fullBody = marker + '\n' + body; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: fullBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: fullBody, + }); + } diff --git a/scripts/lint_diff.py b/scripts/lint_diff.py new file mode 100755 index 0000000000..a84156fc8e --- /dev/null +++ b/scripts/lint_diff.py @@ -0,0 +1,207 @@ +#!/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()) + 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("
First entries\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("
\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())