d914b81242
added agent doc and coding rules
264 lines
12 KiB
Python
264 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Merge gate for lint on a change — the narrow rule set a change must pass.
|
|
|
|
This is deliberately *not* the repo's full `ruff.toml` selection. The policy, decided from the
|
|
measured findings rather than from taste:
|
|
|
|
* ``BLE001`` (blind except) is **allowed**. A broad ``except Exception`` is the right choice
|
|
around device I/O, GL teardown and camera probing, and this codebase already records that
|
|
choice inline as ``# noqa: BLE001 - <reason>``. Gating it would produce ~100 findings whose
|
|
only correct fix is "add a reasoned suppression".
|
|
* ``S110``/``S112`` (a handler that is only ``pass``/``continue``) and ``E722`` (bare
|
|
``except:``) **are** gated: the complaint is not the breadth of the catch, it is that the
|
|
failure becomes invisible. Silence is acceptable only with a reasoned suppression or a
|
|
handler that reports/re-raises.
|
|
* ``RUF100`` (unused suppression) is reported for triage but is configured ``unfixable``: a
|
|
machine must never delete a suppression whose comment records why it exists.
|
|
|
|
Run over the files a change touches, never the whole tree, so an AI-authored diff is not
|
|
drowned in pre-existing style churn.
|
|
|
|
Usage::
|
|
|
|
tools/lint_gate.py # files changed vs HEAD (staged + unstaged)
|
|
tools/lint_gate.py --base origin/main # files changed vs a ref
|
|
tools/lint_gate.py --files a.py b.py # explicit files
|
|
tools/lint_gate.py --all # gate rules over the whole repo
|
|
tools/lint_gate.py --json # machine-readable result on stdout
|
|
|
|
Exit codes: 0 clean, 1 findings, 2 a usage or environment problem (e.g. ruff missing).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
#: The gate. Explicit, so a rule added to `ruff.toml` cannot silently start failing builds.
|
|
#: `E4`/`E7` are deliberately absent: `E402` fires on the intentional "set the environment before
|
|
#: importing Qt" pattern in scripts, and `E701`/`E702` are style, which `ruff format` handles.
|
|
GATE_SELECT = "E9,E722,F,S110,S112,B023,PLW1510"
|
|
|
|
#: Rules we report but never let a tool auto-fix (RUF100 is `unfixable` in ruff.toml too).
|
|
REPORT_ONLY = "RUF100"
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def find_ruff() -> str:
|
|
"""Locate ruff: $RUFF, then PATH, then the interpreter environment."""
|
|
for candidate in (os.environ.get("RUFF"), shutil.which("ruff")):
|
|
if candidate and Path(candidate).is_file():
|
|
return candidate
|
|
beside = Path(sys.executable).parent / ("ruff.exe" if os.name == "nt" else "ruff")
|
|
if beside.is_file():
|
|
return str(beside)
|
|
print(
|
|
"lint_gate: ruff not found. Set $RUFF, install it beside the interpreter, or put it on "
|
|
"PATH. The pin matters: findings differ between ruff versions, so the gate names its "
|
|
"version below when it runs.",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(2)
|
|
|
|
|
|
def git(*args: str) -> list[str]:
|
|
result = subprocess.run(
|
|
["git", "-C", str(REPO), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"lint_gate: git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def changed_python_files(base: str | None) -> list[str]:
|
|
"""The .py files this change touches: working tree, index, and new files."""
|
|
paths: set[str] = set()
|
|
if base:
|
|
paths.update(git("diff", "--name-only", "--diff-filter=ACMR", f"{base}...HEAD"))
|
|
paths.update(git("diff", "--name-only", "--diff-filter=ACMR"))
|
|
paths.update(git("diff", "--name-only", "--cached", "--diff-filter=ACMR"))
|
|
paths.update(git("ls-files", "--others", "--exclude-standard"))
|
|
return sorted(p for p in paths if p.endswith(".py") and (REPO / p).is_file())
|
|
|
|
|
|
def run_ruff(ruff: str, args: list[str]) -> tuple[list[dict], str]:
|
|
version = subprocess.run(
|
|
[ruff, "--version"], capture_output=True, text=True, check=False
|
|
).stdout.strip()
|
|
result = subprocess.run(
|
|
[ruff, "check", "--no-cache", "--output-format", "json", *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode not in (0, 1):
|
|
print(f"lint_gate: ruff failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
try:
|
|
findings = json.loads(result.stdout or "[]")
|
|
except json.JSONDecodeError:
|
|
print(f"lint_gate: could not parse ruff output: {result.stdout[:400]}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
return findings, version
|
|
|
|
|
|
def describe(finding: dict) -> str:
|
|
code = finding.get("code") or "?"
|
|
location = finding.get("location") or {}
|
|
path = str(finding.get("filename", "")).replace(str(REPO) + os.sep, "")
|
|
return (
|
|
f"{path}:{location.get('row', '?')}:{location.get('column', '?')}: {code}: "
|
|
f"{finding.get('message', '')}"
|
|
)
|
|
|
|
|
|
def run_doc_gate(force_all: bool) -> dict:
|
|
"""Run the workspace documentation gate and return its JSON payload.
|
|
|
|
Delegates to the single authoritative script in the workspace root. Deliberately not a
|
|
fourth copy of any lint logic in this repo: this file is already duplicated across three
|
|
repositories and has drifted, so the doc rules live in exactly one place.
|
|
"""
|
|
script = REPO.parent / "scripts" / "doc_lint.py"
|
|
if not script.is_file():
|
|
print(f"lint_gate: required documentation checker missing: {script}", file=sys.stderr)
|
|
return {"status": "error", "findings": [], "baselined": []}
|
|
cmd = [sys.executable, str(script), "--json"]
|
|
if force_all:
|
|
cmd.append("--all")
|
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
|
|
if result.returncode not in (0, 1):
|
|
print(f"lint_gate: documentation checker failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
|
return {"status": "error", "findings": [], "baselined": []}
|
|
if not result.stdout.strip():
|
|
print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr)
|
|
return {"status": "error", "findings": [], "baselined": []}
|
|
try:
|
|
payload = json.loads(result.stdout)
|
|
expected = "clean" if result.returncode == 0 else "findings"
|
|
if not isinstance(payload, dict) or payload.get("status") != expected:
|
|
return {"status": "error", "findings": [], "baselined": []}
|
|
return payload
|
|
except json.JSONDecodeError:
|
|
print("lint_gate: could not parse doc gate output", file=sys.stderr)
|
|
return {"status": "error", "findings": [], "baselined": []}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--base", default=None, help="compare against this ref (e.g. origin/main)")
|
|
parser.add_argument("--files", nargs="*", default=None, help="explicit files to check")
|
|
parser.add_argument("--all", action="store_true", help="run the gate rules over the whole repo")
|
|
parser.add_argument("--json", action="store_true", help="machine-readable result on stdout")
|
|
parser.add_argument("--no-docs", action="store_true",
|
|
help="skip the workspace documentation gate")
|
|
args = parser.parse_args(argv)
|
|
|
|
# Documentation: changed Markdown by default, the whole corpus under --all. `--files` names
|
|
# Python files explicitly, so it disables the doc gate rather than misreading them as docs.
|
|
docs: dict = {"status": "skipped", "findings": [], "baselined": []}
|
|
if not args.no_docs and args.files is None:
|
|
docs = run_doc_gate(force_all=args.all)
|
|
if docs.get("status") not in ("clean", "findings"):
|
|
if args.json:
|
|
print(json.dumps({"status": "error", "docs": docs, "findings": []}))
|
|
return 2
|
|
|
|
# Report the documentation verdict even when Ruff is unavailable: a missing linter must not
|
|
# hide the docs result, and the exit code still signals the environment problem.
|
|
try:
|
|
ruff = find_ruff()
|
|
except SystemExit as exc:
|
|
doc_findings = docs.get("findings") or []
|
|
if doc_findings:
|
|
print(f"docs: {len(doc_findings)} finding(s) (see docs/AUTHORING.md):", file=sys.stderr)
|
|
for finding in doc_findings:
|
|
print(f" {finding['file']}: {finding['check']}: {finding['message']}",
|
|
file=sys.stderr)
|
|
else:
|
|
print(f"docs: clean ({docs.get('status')})", file=sys.stderr)
|
|
return int(exc.code or 2)
|
|
|
|
if args.all:
|
|
targets = [str(REPO)]
|
|
else:
|
|
targets = args.files or changed_python_files(args.base)
|
|
if not targets:
|
|
print("lint_gate: no changed Python files; nothing to check.")
|
|
if args.json:
|
|
print(json.dumps({"findings": [], "checked": [], "docs": docs,
|
|
"status": "findings" if docs.get("findings") else "clean"}))
|
|
return 1 if docs.get("findings") else 0
|
|
|
|
findings, version = run_ruff(ruff, ["--select", GATE_SELECT, *targets])
|
|
report_only, _ = run_ruff(ruff, ["--select", REPORT_ONLY, *targets])
|
|
|
|
if args.json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ruff": version,
|
|
"select": GATE_SELECT,
|
|
"checked": targets,
|
|
"findings": [
|
|
{
|
|
"file": str(f.get("filename", "")).replace(str(REPO) + os.sep, ""),
|
|
"row": (f.get("location") or {}).get("row"),
|
|
"column": (f.get("location") or {}).get("column"),
|
|
"code": f.get("code"),
|
|
"message": f.get("message"),
|
|
"fixable": bool((f.get("fix") or {}).get("applicability") == "safe"),
|
|
}
|
|
for f in findings
|
|
],
|
|
"unused_suppressions": [describe(f) for f in report_only],
|
|
"docs": docs,
|
|
"status": "findings" if (findings or docs.get("findings")) else "clean",
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1 if (findings or docs.get("findings")) else 0
|
|
|
|
print(f"lint_gate: {version}")
|
|
print(f" select {GATE_SELECT} (BLE001 is allowed by policy; see ruff.toml)")
|
|
print(f" checked {len(targets)} target(s)")
|
|
if findings:
|
|
print(f"\n{len(findings)} finding(s):")
|
|
for finding in sorted(findings, key=describe):
|
|
hint = " [safe fix: ruff check --fix]" if (finding.get("fix") or {}).get("applicability") == "safe" else ""
|
|
print(f" {describe(finding)}{hint}")
|
|
else:
|
|
print(" no findings")
|
|
if report_only:
|
|
print(f"\n{len(report_only)} unused suppression(s) to triage by hand (never auto-removed):")
|
|
for finding in report_only:
|
|
print(f" {describe(finding)}")
|
|
|
|
doc_findings = docs.get("findings") or []
|
|
if docs.get("status") == "unavailable":
|
|
print("\ndocs: workspace doc gate not found; skipped")
|
|
elif doc_findings:
|
|
print(f"\ndocs: {len(doc_findings)} finding(s) (see docs/AUTHORING.md):")
|
|
for finding in doc_findings:
|
|
print(f" {finding['file']}: {finding['check']}: {finding['message']}")
|
|
else:
|
|
print(f"\ndocs: clean ({docs.get('status')})")
|
|
|
|
print(f"\nstatus: {'FINDINGS' if (findings or doc_findings) else 'clean'}")
|
|
return 1 if (findings or doc_findings) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|