diff --git a/tools/lint_gate.py b/tools/lint_gate.py index e1109b4..c40c8c4 100644 --- a/tools/lint_gate.py +++ b/tools/lint_gate.py @@ -122,15 +122,60 @@ def describe(finding: dict) -> str: ) +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) - ruff = find_ruff() + # 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)] @@ -139,8 +184,9 @@ def main(argv: list[str] | None = None) -> int: if not targets: print("lint_gate: no changed Python files; nothing to check.") if args.json: - print(json.dumps({"findings": [], "checked": [], "status": "clean"})) - return 0 + 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]) @@ -164,12 +210,13 @@ def main(argv: list[str] | None = None) -> int: for f in findings ], "unused_suppressions": [describe(f) for f in report_only], - "status": "findings" if findings else "clean", + "docs": docs, + "status": "findings" if (findings or docs.get("findings")) else "clean", }, indent=2, ) ) - return 1 if findings else 0 + 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)") @@ -185,8 +232,19 @@ def main(argv: list[str] | None = None) -> int: 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)}") - print(f"\nstatus: {'FINDINGS' if findings else 'clean'}") - return 1 if findings else 0 + + 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__":