ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_ruff.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: ruff lint for every first-party Python file in the tree.
5
6Formatting (``ruff format``) is enforced by the format gate
7(``format_tree.sh``), never here.
8
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
16files that do exist.
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.
22
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.
30
31Non-vacuity
32-----------
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.
40
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.
44
45Run::
46
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
50
51Exit 0 if clean, exit 1 on findings, exit 2 on ruff error.
52"""
53
54from __future__ import annotations
55
56import json
57import os
58import shutil
59import subprocess
60import sys
61import tempfile
62from pathlib import Path
63
64REPO_ROOT = Path(__file__).resolve().parents[2]
65
66# Floor on the number of files ruff reports it will check. The tree has 96
67# first-party Python files today. This is not a target to keep in sync file by
68# file -- it is a trip-wire for a scope that collapses wholesale. Lower it
69# deliberately, with a reason, if first-party Python genuinely shrinks.
70FILE_FLOOR = 80
71
72
73def _find_ruff() -> str | None:
74 env = os.environ.get("RUFF")
75 if env and Path(env).exists():
76 return env
77 return shutil.which("ruff")
78
79
80def _git_ls_files(*pathspec: str) -> list[str]:
81 """Return tracked or untracked/non-ignored paths matching `pathspec`."""
82 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
83 [ # noqa: S607 -- trusted: fixed git argv
84 "git",
85 "ls-files",
86 "-z",
87 "--cached",
88 "--others",
89 "--exclude-standard",
90 "--",
91 *pathspec,
92 ],
93 cwd=REPO_ROOT,
94 capture_output=True,
95 text=True,
96 check=False,
97 )
98 if proc.returncode != 0:
99 sys.stderr.write(proc.stderr)
100 sys.stderr.write("check_ruff.py: FATAL -- `git ls-files` failed\n")
101 sys.exit(2)
102 return [p for p in proc.stdout.split("\0") if p]
103
104
105def _has_python_shebang(rel: str) -> bool:
106 """True when `rel` starts with a `#!...python...` line."""
107 try:
108 with (REPO_ROOT / rel).open("rb") as handle:
109 first = handle.readline(200)
110 except OSError:
111 return False
112 return first.startswith(b"#!") and b"python" in first
113
114
115def _present_files(files: list[str], root: Path = REPO_ROOT) -> list[str]:
116 """Return candidate paths that still exist in the worktree.
117
118 Args:
119 files: Repository-relative paths reported by the index.
120 root: Worktree root, overridden by the selftest fixture.
121
122 Returns:
123 Paths that are regular files in the candidate worktree.
124 """
125 return [rel for rel in files if (root / rel).is_file()]
126
127
128def _tracked_python_files() -> list[str]:
129 """Every candidate-worktree Python file, by extension OR by shebang.
130
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.
134
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
141 unlinted.
142 """
143 # The cached half of `git ls-files` still returns a path deleted in an
144 # uncommitted migration. Ruff reports that as E902. A local pre-commit gate
145 # must judge the candidate worktree, including new files, rather than
146 # require staging merely to learn whether the candidate is clean.
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))
151
152
153def _checked_files(ruff: str, files: list[str]) -> list[str]:
154 """Ask ruff which of `files` survive the configured exclusions."""
155 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
156 [ruff, "check", "--show-files", "--force-exclude", *files],
157 cwd=REPO_ROOT,
158 capture_output=True,
159 text=True,
160 check=False,
161 )
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")
165 sys.exit(2)
166 return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
167
168
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))
173 return filename
174
175
176def _run_check(ruff: str, files: list[str]) -> dict[str, dict[str, int]]:
177 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
178 [ruff, "check", "--force-exclude", "--output-format=json", *files],
179 cwd=REPO_ROOT,
180 capture_output=True,
181 text=True,
182 check=False,
183 )
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")
187 sys.exit(2)
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
194 return findings
195
196
197def _report(lint: dict[str, dict[str, int]]) -> None:
198 if lint:
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")
204
205
206# ---------------------------------------------------------------------------
207# Selftest
208#
209# Both fixtures are fed to ruff on stdin under the REAL pyproject.toml, so what
210# is proven is the shipped configuration, not a copy of it. Nothing is written
211# into the tree -- a deliberately non-conforming fixture stored as a real file
212# would be picked up by the gate's own scan and fail it.
213# ---------------------------------------------------------------------------
214
215# A generated 60-statement body: PLR0915 (too-many-statements) is called out by
216# name in #360, and a fixture that only *looks* long would not reach the limit.
217_MANY_STATEMENTS = "\n".join(f" v{i} = {i}" for i in range(60))
218
219BAD_FIXTURE = f'''"""Module docstring so the fixture fails on the rules under test only."""
220
221import subprocess
222import os
223import sys
224import pdb
225import datetime
226
227
228def shadowing(list, id):
229 """Shadow two builtins and read a naive timestamp."""
230 _ = list, id
231 return datetime.datetime.now()
232
233
234def CamelCaseName(items=[]):
235 """Do a thing.
236
237 Args:
238 items: things.
239
240 Returns:
241 The count.
242 """
243 unused_local = os.path.join("a", "b")
244 with open("f") as handle:
245 data = handle.read()
246 if len(data) > 1337:
247 result = 1
248 else:
249 result = 2
250 try:
251 subprocess.run("ls", shell=True, check=False)
252 except Exception:
253 pass
254 return result
255
256
257def long_body():
258 """Trip the statement-count limit."""
259{_MANY_STATEMENTS}
260 return v0
261
262
263# result = long_body()
264'''
265
266GOOD_FIXTURE = '''"""Legal-but-tricky fixture: this must stay completely silent.
267
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.
271"""
272
273from __future__ import annotations
274
275import shutil
276import subprocess
277from pathlib import Path
278
279TIMEOUT_SECONDS = 30
280
281
282def read_manifest(root: Path) -> dict[str, str]:
283 """Return the manifest under `root` as a mapping.
284
285 Args:
286 root: Directory expected to contain `manifest.txt`.
287
288 Returns:
289 Mapping of key to value; empty when the manifest is absent.
290 """
291 manifest = root / "manifest.txt"
292 if not manifest.is_file():
293 return {}
294 entries = {}
295 for line in manifest.read_text(encoding="utf-8").splitlines():
296 key, _, value = line.partition("=")
297 if key:
298 entries[key.strip()] = value.strip()
299 return entries
300
301
302def git_head(root: Path) -> str:
303 """Return the short HEAD sha of the repository at `root`.
304
305 Args:
306 root: Repository working tree.
307
308 Returns:
309 The abbreviated commit hash.
310
311 Raises:
312 RuntimeError: When git is absent or exits non-zero.
313 """
314 git = shutil.which("git")
315 if git is None:
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"],
320 cwd=root,
321 capture_output=True,
322 text=True,
323 check=False,
324 timeout=TIMEOUT_SECONDS,
325 )
326 if proc.returncode != 0:
327 message = f"git rev-parse failed in {root}"
328 raise RuntimeError(message)
329 return proc.stdout.strip()
330'''
331
332# Rule families the project claims to enforce, each pinned to a code the bad
333# fixture provably triggers. Keyed by family so a dropped `select` entry names
334# itself in the failure text instead of surfacing as a bare missing code.
335EXPECTED_CODES: dict[str, str] = {
336 "F (pyflakes)": "F401",
337 "N (pep8-naming)": "N802",
338 "I (isort)": "I001",
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",
350}
351
352
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( # noqa: S603 -- fixed argv, trusted tool path
356 [ruff, "check", "--no-cache", "--output-format=json", "--stdin-filename", filename, "-"],
357 cwd=REPO_ROOT,
358 input=source,
359 capture_output=True,
360 text=True,
361 check=False,
362 )
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")
366 sys.exit(2)
367 return {item.get("code") or "SYNTAX" for item in json.loads(proc.stdout or "[]")}
368
369
370# Virtual filenames handed to `ruff --stdin-filename`. Ruff resolves per-file
371# configuration against the name, so it has to look like a first-party .py
372# path; nothing is ever created on disk at either location.
373BAD_FIXTURE_NAME = "scripts/checks/ruff_selftest_bad.py" # PATHREF-OK: virtual
374GOOD_FIXTURE_NAME = "scripts/checks/ruff_selftest_good.py" # PATHREF-OK: virtual
375
376
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] = []
380
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")
385
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))
388
389 # An unsquashed migration leaves deleted paths in the index. Prove the
390 # scope keeps the neighbouring live file and drops only the absent one.
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")
397
398 if failures:
399 sys.stderr.write("check_ruff.py --selftest: FAILED\n")
400 sys.stderr.write("\n".join(failures) + "\n")
401 sys.stderr.write(
402 "\nThe configured rule set is not enforcing what it advertises.\n"
403 "Check `select` in pyproject.toml before trusting a clean run.\n"
404 )
405 return 1
406
407 print(
408 f"check_ruff.py --selftest: OK "
409 f"({len(EXPECTED_CODES)} rule families fire, good fixture silent, "
410 "deleted worktree path excluded)."
411 )
412 return 0
413
414
415def main(argv: list[str]) -> int:
416 """Run ruff's lint check over every tracked first-party Python file.
417
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.
423
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.
427
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.
430 """
431 args = argv[1:]
432 ruff = _find_ruff()
433 if not ruff:
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")
437 sys.exit(1)
438 print(msg + " -- skipping (install ruff to enforce locally).")
439 sys.exit(0)
440
441 if "--selftest" in args:
442 return selftest(ruff)
443
444 tracked = _tracked_python_files()
445 files = _checked_files(ruff, tracked) if tracked else []
446
447 # Scope introspection for check_lint_coverage.py and the format gate:
448 # report exactly the files this gate would lint, after ruff's own exclusions.
449 # The coverage gate asks every checker this rather than restating its scope,
450 # so the two cannot disagree about what is covered.
451 # Repo-relative: ruff reports absolute paths, and every other checker's
452 # list mode speaks repo-relative paths.
453 if "--list-files" in args:
454 print("\n".join(sorted(_rel(f) for f in files)))
455 return 0
456
457 if len(files) < FILE_FLOOR:
458 sys.stderr.write(
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"
462 )
463 return 2
464
465 lint = _run_check(ruff, tracked)
466 if not lint:
467 print(f"check_ruff.py: clean ({len(files)} files, no lint findings).")
468 return 0
469 _report(lint)
470 return 1
471
472
473if __name__ == "__main__":
474 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298