Files
orisys-sdk/tools/lint_gate.py
T
ykcheungab cde832eaca tools: delegate documentation checks to the workspace doc gate
`tools/lint_gate.py` now also runs `D:\orisys_ws\scripts\doc_lint.py`, the
workspace's single documentation gate, alongside Ruff:

- changed Markdown by default, the whole corpus under --all
- `--files` names Python files explicitly, so it skips the doc gate
- `--no-docs` opts out
- the docs verdict is reported even when Ruff is missing, so a missing
  linter cannot hide it (the exit code still signals the environment problem)

The doc rules live in exactly one place on purpose: this file is duplicated
across three repositories and had already drifted, so adding a fourth copy of
lint logic would repeat the mistake. Failing checks are the documentation
budget, inventory membership, a declared kind, unresolved mojibake, paragraph
walls, and duplicate explanations across documents. Emphasis density is
advisory only - measured as a continuum from 12% to 48% across the corpus, so
no threshold separates signal from noise.

Pre-existing oversized documents are recorded as budget-exempt with a written
reason in D:\orisys_ws\docs\inventory.json, and pre-existing findings are
baselined there, so this gate is red only for new regression.

With Ruff unavailable in the current environment the doc half still reports,
which is how it was verified.
2026-09-30 16:12:30 +08:00

252 lines
11 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():
return {"status": "unavailable", "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 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:
return json.loads(result.stdout)
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)
# 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())