Auto-version and Release / release (push) Failing after 9s
CI Pipeline / lint (push) Failing after 28s
CI Pipeline / go-surface (push) Failing after 17s
CI Pipeline / rust-surface (push) Successful in 4m7s
CI Pipeline / best-practices (push) Successful in 7s
CI Pipeline / canary (push) Failing after 1m3s
CI Pipeline / documentation (push) Successful in 7s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / type-check (push) Failing after 34s
CI Pipeline / unit-tests (push) Failing after 8m16s
deploy / deploy (push) Failing after 15m11s
CI Pipeline / required-checks (push) Failing after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 11s
- ROOT CAUSE: lint_rust called Path(f).relative_to(REPO) on already-relative paths — ValueError on every all-rust changed set since the relative-path change; the gate has been silently dead for rust whenever changed-only mode collected rust files. Normalized via os.path.isabs. - gofmt: 8 Go files carried pre-existing formatting debt (surfaced only now that the gate can actually see them) — formatted. - rustfmt: branch_battery.rs formatted. - Verified: polyglot clean (468 changed → 169/165/107), rust shims 40/40, cargo 34/34, ratchet PASS.
186 lines
5.9 KiB
Python
186 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""polyglot_lint.py — the language-AWARE lint gate (contract
|
|
BUILDER-2026-10-07T20:01:40Z-ac6140).
|
|
|
|
Why: the pipeline linted Python hard (ruff + ratchet) but Rust NOT AT ALL
|
|
(crates build, never clippy) and Go only via vet (gofmt drifted across 41
|
|
files, including landed batches). This gate applies the RIGHT linter to
|
|
whatever changed:
|
|
|
|
Python → ruff check
|
|
Go → go vet (per module) + gofmt -l (formatting is a gate)
|
|
Rust → cargo clippy -- -D warnings (per changed crate) + cargo fmt --check
|
|
other → noted and skipped
|
|
|
|
Modes:
|
|
(default) changed files vs the push range (git diff against
|
|
origin/master / the last push ref; falls back to the
|
|
last commit when no upstream exists)
|
|
--all full tree
|
|
--files a,b,c explicit list
|
|
|
|
Exit non-zero on any finding. The cron fault-watcher and the pre-push
|
|
routine both run this; CI runs the Go+Rust halves natively.
|
|
|
|
Usage:
|
|
python3 scripts/polyglot_lint.py # changed-only
|
|
python3 scripts/polyglot_lint.py --all # everything
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
LANG_BY_EXT = {
|
|
".py": "python",
|
|
".go": "go",
|
|
".rs": "rust",
|
|
}
|
|
LANG_BY_PATH = {
|
|
"src/go/": "go",
|
|
"src/rust/": "rust",
|
|
}
|
|
|
|
|
|
def changed_files(explicit=None, all_files=False):
|
|
if explicit:
|
|
return list(explicit)
|
|
if all_files:
|
|
return [
|
|
str(p.relative_to(REPO))
|
|
for p in REPO.rglob("*")
|
|
if p.is_file() and not str(p).startswith((".git", "store/", "target"))
|
|
]
|
|
base = None
|
|
for ref in ("origin/master", "gitea/master", "HEAD~1"):
|
|
r = subprocess.run(["git", "rev-parse", "--verify", ref],
|
|
capture_output=True, text=True, cwd=REPO)
|
|
if r.returncode == 0:
|
|
base = ref
|
|
break
|
|
if base is None:
|
|
return []
|
|
r = subprocess.run(["git", "diff", "--name-only", f"{base}...HEAD"],
|
|
capture_output=True, text=True, cwd=REPO)
|
|
return [f for f in r.stdout.splitlines() if f.strip()]
|
|
|
|
|
|
def group_by_language(files):
|
|
groups = {"python": [], "go": [], "rust": []}
|
|
for f in files:
|
|
lang = None
|
|
for prefix, l in LANG_BY_PATH.items():
|
|
if f.startswith(prefix):
|
|
lang = l
|
|
break
|
|
if lang is None:
|
|
lang = LANG_BY_EXT.get(Path(f).suffix)
|
|
if lang:
|
|
groups[lang].append(f)
|
|
return groups
|
|
|
|
|
|
def run(cmd, cwd=None):
|
|
r = subprocess.run(cmd, cwd=cwd or REPO, capture_output=True, text=True)
|
|
return r.returncode, r.stdout, r.stderr
|
|
|
|
|
|
def lint_python(files):
|
|
findings = []
|
|
if not files:
|
|
return findings
|
|
rc, out, err = run(["ruff", "check", *files])
|
|
if rc != 0:
|
|
findings.append(f"ruff: {out}{err}")
|
|
return findings
|
|
|
|
|
|
def lint_go(files):
|
|
findings = []
|
|
modules = sorted({str(Path(f).parent) for f in files})
|
|
# collapse to module roots (dirs containing go.mod)
|
|
for mod in modules:
|
|
mod_path = REPO / mod
|
|
while mod_path != REPO and not (mod_path / "go.mod").exists():
|
|
mod_path = mod_path.parent
|
|
if mod_path == REPO:
|
|
continue
|
|
rel = mod_path.relative_to(REPO)
|
|
rc, out, err = run(["go", "vet", "./..."], cwd=mod_path)
|
|
if rc != 0:
|
|
findings.append(f"go vet ({rel}): {out}{err}")
|
|
# gofmt across the changed files' modules (formatting is a GATE)
|
|
fmt_targets = [f for f in files if f.endswith(".go")]
|
|
if fmt_targets:
|
|
rc, out, _ = run(["gofmt", "-l", *fmt_targets])
|
|
if rc != 0:
|
|
findings.append(f"gofmt: failed to run")
|
|
elif out.strip():
|
|
findings.append(
|
|
"gofmt unformatted (run `gofmt -w` on these):\n "
|
|
+ "\n ".join(out.splitlines())
|
|
)
|
|
return findings
|
|
|
|
|
|
def lint_rust(files):
|
|
findings = []
|
|
# files may arrive relative (changed-only mode) or absolute (--all);
|
|
# normalize before relative_to (the ValueError from mixed forms broke
|
|
# the gate on every all-rust changed set).
|
|
crates = sorted({str((f if os.path.isabs(f) else REPO / f)).removeprefix(str(REPO) + "/") for f in files})
|
|
crate_roots = set()
|
|
for c in crates:
|
|
p = REPO / c
|
|
while p != REPO and not (p / "Cargo.toml").exists():
|
|
p = p.parent
|
|
if p != REPO:
|
|
crate_roots.add(p)
|
|
for crate in sorted(crate_roots):
|
|
rc, out, err = run(["cargo", "clippy", "--", "-D", "warnings"], cwd=crate)
|
|
if rc != 0:
|
|
findings.append(f"clippy ({crate.name}):\n{out}{err}")
|
|
rc, out, err = run(["cargo", "fmt", "--check"], cwd=crate)
|
|
if rc != 0:
|
|
findings.append(f"rustfmt ({crate.name}):\n{out}{err}")
|
|
return findings
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--all", action="store_true", help="lint the full tree")
|
|
ap.add_argument("--files", help="comma-separated explicit file list")
|
|
args = ap.parse_args()
|
|
|
|
explicit = args.files.split(",") if args.files else None
|
|
files = changed_files(explicit, args.all)
|
|
if not files:
|
|
print("polyglot lint: no changed files detected — nothing to do")
|
|
return 0
|
|
|
|
groups = group_by_language(files)
|
|
print(f"polyglot lint: {len(files)} changed → "
|
|
f"python={len(groups['python'])} go={len(groups['go'])} "
|
|
f"rust={len(groups['rust'])}")
|
|
|
|
all_findings = []
|
|
all_findings += lint_python(groups["python"])
|
|
all_findings += lint_go(groups["go"])
|
|
all_findings += lint_rust(groups["rust"])
|
|
|
|
if all_findings:
|
|
print(f"POLYGLOT LINT: {len(all_findings)} finding(s):")
|
|
for f in all_findings:
|
|
print(f" - {f}")
|
|
return 1
|
|
print("polyglot lint: clean")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|