4"""Gate: ruff lint for every first-party Python file in the tree.
6Formatting (``ruff format``) is enforced by the format gate
7(``format_tree.sh``), never here.
9Scope is derived, not hardcoded
10-------------------------------
11``git ls-files`` enumerates every tracked or untracked-but-not-ignored, present
12Python file -- by ``*.py`` suffix and by ``#!...python`` shebang, so an
13extensionless executable script cannot sit outside the gate. Paths deleted in
14the worktree are ignored: they will not exist in the candidate commit, and
15handing them to ruff produces an ``E902`` I/O error that masks findings in
17``--force-exclude`` makes ruff honour the
18``extend-exclude`` list in pyproject.toml (vendored SOUP, generated data,
19build trees) even though the paths are handed to it explicitly. Explicit
20paths bypass exclusions without that flag, which would drag
21``libs/third_party`` into the gate.
23The previous revision hardcoded ``TARGETS = ("scripts", "tools", "tests")``.
24That left seven first-party files -- host tooling and the HIL fixture
25generators under ``examples/`` -- unlinted and unformatted for the life of the
26gate, hiding 33 lint findings and 3 unformatted files. It is the same
27hardcoded-scan-list defect as #358 / #332 / #296, so the list is derived here
28rather than grown by three more entries: a new root is covered the day it is
29added, with no allowlist to forget.
33The dominant defect class in this tree is a gate that looks active and
34enforces nothing. This one is checkable in both directions: ``--selftest``
35feeds ruff a deliberately non-conforming file and asserts that every rule
36family the project claims to enforce actually fires, then feeds it a
37legal-but-tricky file and asserts silence. An emptied ``select`` list, a
38config ruff stopped reading, or a rule family quietly dropped all turn the
39must-fire half red instead of turning the tree green.
41The file-count floor guards the other half of the same failure: a scope that
42silently collapses (a broken ``git ls-files``, a runaway ``extend-exclude``)
43reports a clean tree because it checked almost nothing.
47 check_ruff.py # gate (fail on any finding)
48 check_ruff.py --require # fail (not skip) if ruff is absent
49 check_ruff.py --selftest # prove the configured rule set is non-vacuous
51Exit 0 if clean, exit 1 on findings, exit 2 on ruff error.
54from __future__
import annotations
62from pathlib
import Path
64REPO_ROOT = Path(__file__).resolve().parents[2]
73def _find_ruff() -> str | None:
74 env = os.environ.get(
"RUFF")
75 if env
and Path(env).exists():
77 return shutil.which(
"ruff")
80def _git_ls_files(*pathspec: str) -> list[str]:
81 """Return tracked or untracked/non-ignored paths matching `pathspec`."""
82 proc = subprocess.run(
98 if proc.returncode != 0:
99 sys.stderr.write(proc.stderr)
100 sys.stderr.write(
"check_ruff.py: FATAL -- `git ls-files` failed\n")
102 return [p
for p
in proc.stdout.split(
"\0")
if p]
105def _has_python_shebang(rel: str) -> bool:
106 """True when `rel` starts with a `#!...python...` line."""
108 with (REPO_ROOT / rel).open(
"rb")
as handle:
109 first = handle.readline(200)
112 return first.startswith(b
"#!")
and b
"python" in first
115def _present_files(files: list[str], root: Path = REPO_ROOT) -> list[str]:
116 """Return candidate paths that still exist in the worktree.
119 files: Repository-relative paths reported by the index.
120 root: Worktree root, overridden by the selftest fixture.
123 Paths that are regular files in the candidate worktree.
125 return [rel
for rel
in files
if (root / rel).is_file()]
128def _tracked_python_files() -> list[str]:
129 """Every candidate-worktree Python file, by extension OR by shebang.
131 Exclusions are ruff's job (``--force-exclude`` + ``extend-exclude``); this
132 only decides what is *offered*, so a file cannot escape the gate by living
133 in a directory nobody remembered to list.
135 The shebang sweep exists because ``*.py`` alone is a scope that can be
136 escaped by accident: an executable ``scripts/foo`` PATHREF-OK: placeholder
137 with a python shebang and no extension is a Python file this project's
138 rules apply to, and a
139 glob-only list would never see it. There are none today -- which is
140 exactly when to close the hole, rather than after one appears and sits
147 by_extension = _present_files(_git_ls_files(
"*.py"))
148 known = set(by_extension)
149 by_shebang = [rel
for rel
in _git_ls_files()
if rel
not in known
and _has_python_shebang(rel)]
150 return sorted(known.union(by_shebang))
153def _checked_files(ruff: str, files: list[str]) -> list[str]:
154 """Ask ruff which of `files` survive the configured exclusions."""
155 proc = subprocess.run(
156 [ruff,
"check",
"--show-files",
"--force-exclude", *files],
162 if proc.returncode != 0:
163 sys.stderr.write(proc.stderr)
164 sys.stderr.write(f
"ruff check --show-files failed (exit {proc.returncode})\n")
166 return [line.strip()
for line
in proc.stdout.splitlines()
if line.strip()]
169def _rel(filename: str) -> str:
170 path = Path(filename)
171 if path.is_relative_to(REPO_ROOT):
172 return str(path.relative_to(REPO_ROOT))
176def _run_check(ruff: str, files: list[str]) -> dict[str, dict[str, int]]:
177 proc = subprocess.run(
178 [ruff,
"check",
"--force-exclude",
"--output-format=json", *files],
184 if proc.returncode
not in (0, 1):
185 sys.stderr.write(proc.stderr)
186 sys.stderr.write(f
"ruff check failed (exit {proc.returncode})\n")
188 findings: dict[str, dict[str, int]] = {}
189 for item
in json.loads(proc.stdout
or "[]"):
190 rel = _rel(item[
"filename"])
191 code = item.get(
"code")
or "SYNTAX"
192 findings.setdefault(rel, {})
193 findings[rel][code] = findings[rel].get(code, 0) + 1
197def _report(lint: dict[str, dict[str, int]]) ->
None:
199 sys.stderr.write(
"check_ruff.py: ruff lint finding(s):\n")
200 for relfile
in sorted(lint):
201 for code, count
in sorted(lint[relfile].items()):
202 sys.stderr.write(f
" {relfile}: {code} x{count}\n")
203 sys.stderr.write(
"\nFix the finding.\n")
217_MANY_STATEMENTS =
"\n".join(f
" v{i} = {i}" for i
in range(60))
219BAD_FIXTURE = f
'''"""Module docstring so the fixture fails on the rules under test only."""
228def shadowing(list, id):
229 """Shadow two builtins and read a naive timestamp."""
231 return datetime.datetime.now()
234def CamelCaseName(items=[]):
243 unused_local = os.path.join("a", "b")
244 with open("f") as handle:
251 subprocess.run("ls", shell=True, check=False)
258 """Trip the statement-count limit."""
263# result = long_body()
266GOOD_FIXTURE =
'''"""Legal-but-tricky fixture: this must stay completely silent.
268Everything here is a construct a careless rule set flags but the project
269genuinely uses: a sorted import block, pathlib over os.path, a narrow except
270that re-raises with a named error, and a documented subprocess call.
273from __future__ import annotations
277from pathlib import Path
282def read_manifest(root: Path) -> dict[str, str]:
283 """Return the manifest under `root` as a mapping.
286 root: Directory expected to contain `manifest.txt`.
289 Mapping of key to value; empty when the manifest is absent.
291 manifest = root / "manifest.txt"
292 if not manifest.is_file():
295 for line in manifest.read_text(encoding="utf-8").splitlines():
296 key, _, value = line.partition("=")
298 entries[key.strip()] = value.strip()
302def git_head(root: Path) -> str:
303 """Return the short HEAD sha of the repository at `root`.
306 root: Repository working tree.
309 The abbreviated commit hash.
312 RuntimeError: When git is absent or exits non-zero.
314 git = shutil.which("git")
316 message = "git is not on PATH"
317 raise RuntimeError(message)
318 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
319 [git, "rev-parse", "--short", "HEAD"],
324 timeout=TIMEOUT_SECONDS,
326 if proc.returncode != 0:
327 message = f"git rev-parse failed in {root}"
328 raise RuntimeError(message)
329 return proc.stdout.strip()
335EXPECTED_CODES: dict[str, str] = {
336 "F (pyflakes)":
"F401",
337 "N (pep8-naming)":
"N802",
339 "B (bugbear)":
"B006",
340 "PTH (use-pathlib)":
"PTH123",
341 "PL (pylint: magic value)":
"PLR2004",
342 "PL (pylint: too-many-statements)":
"PLR0915",
343 "SIM (simplify)":
"SIM108",
344 "S (bandit)":
"S602",
345 "BLE (blind-except)":
"BLE001",
346 "A (builtin shadowing)":
"A002",
347 "DTZ (naive datetime)":
"DTZ005",
348 "T10 (debugger left in)":
"T100",
349 "F (unused local)":
"F841",
353def _lint_stdin(ruff: str, source: str, filename: str) -> set[str]:
354 """Return the set of rule codes ruff reports for `source`."""
355 proc = subprocess.run(
356 [ruff,
"check",
"--no-cache",
"--output-format=json",
"--stdin-filename", filename,
"-"],
363 if proc.returncode
not in (0, 1):
364 sys.stderr.write(proc.stderr)
365 sys.stderr.write(f
"ruff check (stdin) failed (exit {proc.returncode})\n")
367 return {item.get(
"code")
or "SYNTAX" for item
in json.loads(proc.stdout
or "[]")}
373BAD_FIXTURE_NAME =
"scripts/checks/ruff_selftest_bad.py"
374GOOD_FIXTURE_NAME =
"scripts/checks/ruff_selftest_good.py"
377def selftest(ruff: str) -> int:
378 """Prove the configured rule set fires where it must and is quiet where it must."""
379 failures: list[str] = []
381 fired = _lint_stdin(ruff, BAD_FIXTURE, BAD_FIXTURE_NAME)
382 for family, code
in sorted(EXPECTED_CODES.items()):
383 if code
not in fired:
384 failures.append(f
" must-fire: {family} did not report {code} on the bad fixture")
386 quiet = _lint_stdin(ruff, GOOD_FIXTURE, GOOD_FIXTURE_NAME)
387 failures.extend(f
" must-stay-quiet: good fixture reported {code}" for code
in sorted(quiet))
391 with tempfile.TemporaryDirectory()
as tmp:
392 fixture_root = Path(tmp)
393 (fixture_root /
"present.py").touch()
394 present = _present_files([
"present.py",
"deleted.py"], fixture_root)
395 if present != [
"present.py"]:
396 failures.append(
" worktree scope did not exclude exactly the deleted fixture")
399 sys.stderr.write(
"check_ruff.py --selftest: FAILED\n")
400 sys.stderr.write(
"\n".join(failures) +
"\n")
402 "\nThe configured rule set is not enforcing what it advertises.\n"
403 "Check `select` in pyproject.toml before trusting a clean run.\n"
408 f
"check_ruff.py --selftest: OK "
409 f
"({len(EXPECTED_CODES)} rule families fire, good fixture silent, "
410 "deleted worktree path excluded)."
415def main(argv: list[str]) -> int:
416 """Run ruff's lint check over every tracked first-party Python file.
418 A missing ruff is handled two ways ON PURPOSE. Bare, it prints a notice
419 and exits 0, so a contributor without ruff installed is not blocked by a
420 local hook. Under ``--require``, ``--selftest`` or ``--list-files`` it
421 exits 1 instead -- CI passes ``--require`` precisely so that an absent
422 linter fails the build rather than skipping the gate silently.
424 ``--list-files`` exists for check_lint_coverage.py, which asks each checker
425 to report its own post-exclusion scope rather than restating it. That is
426 what keeps the two from disagreeing about which files are covered.
428 Returns 0 when lint is clean, 1 on any finding, on a
429 failing selftest, or on a missing ruff in a mode that requires it.
434 msg =
"check_ruff.py: ruff not found"
435 if "--require" in args
or "--selftest" in args
or "--list-files" in args:
436 sys.stderr.write(msg +
" -- required by --require/--selftest/--list-files\n")
438 print(msg +
" -- skipping (install ruff to enforce locally).")
441 if "--selftest" in args:
442 return selftest(ruff)
444 tracked = _tracked_python_files()
445 files = _checked_files(ruff, tracked)
if tracked
else []
453 if "--list-files" in args:
454 print(
"\n".join(sorted(_rel(f)
for f
in files)))
457 if len(files) < FILE_FLOOR:
459 f
"check_ruff.py: FATAL -- only {len(files)} Python file(s) in scope, "
460 f
"floor is {FILE_FLOOR}.\n"
461 " A collapsed scope reports a clean tree because it checked nothing.\n"
465 lint = _run_check(ruff, tracked)
467 print(f
"check_ruff.py: clean ({len(files)} files, no lint findings).")
473if __name__ ==
"__main__":
474 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.