ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_script_references.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: every ``scripts/...`` path mentioned anywhere in the tree resolves.
5
6``scripts/`` is referenced from Just recipes, workflows, CMake listfiles, the
7git hooks, C comments, and ~90 Markdown documents. Those references are plain
8text: nothing type-checks them, so a ``git mv`` inside ``scripts/`` breaks them
9silently. The repository has already been bitten by exactly that -- a rename
10left cross-references pointing at nothing and ``ci-fast`` did not notice,
11because a dead path in a doc link or a hook comment produces no build error and
12no test failure. It surfaces later as a workflow step that cannot find its
13driver.
14
15This gate closes that class. It reads every first-party text file, extracts
16every token that looks like a path under ``scripts/``, and requires it to
17resolve on disk. Run it before a restructuring and it is green; run it after
18and every reference you forgot to update is a named, located failure.
19
20Reference forms understood
21--------------------------
22``scripts/ci.sh`` repo-relative -- resolved against the repo root
23``../../scripts/dev/flash.sh`` relative -- resolved against the citing file
24``scripts/checks/`` trailing slash -- must be a directory
25``scripts/hil/*.sh`` glob -- must match at least one path
26``scripts/{flash,debug}.sh`` brace / ``$VAR`` interpolation -- the longest
27 literal directory prefix must exist
28
29The glob rule is the interesting one: it is what keeps a doc that says
30"``scripts/hil/*.sh``" honest after those scripts move, instead of leaving a
31pattern that matches nothing and reads as if it still does.
32
33Scope
34-----
35Deliberately limited to the ``scripts/`` prefix. Widening it to every
36top-level directory was measured first: 12445 path-like tokens tree-wide, of
37which 1223 distinct ones do not resolve -- build artifacts (``tests/build/``),
38illustrative globs (``tests/test_*.c``), and third-party prose. A gate that
39starts 1223 findings in the red cannot be landed, and grandfathering them would
40make it a gate that enforces nothing. ``scripts/`` is both the tree being
41restructured and the one whose inbound references are load-bearing (a broken
42``scripts/`` path in a workflow is a broken CI job), so it is where the rule
43pays for itself today. Extending the same machinery outward is tracked
44separately.
45
46Per-line opt-out: append ``PATHREF-OK: <reason>`` to a line to suppress it
47(mirrors the ``MAGIC-OK`` / ``CITES-OK`` / ``AI-OK`` family). It exists for
48prose that deliberately names a path that does not exist -- a checker docstring
49illustrating a hypothetical path, or a historical note. The marker has to sit
50on the same line as the token it waives, which is why the example below carries
51it inline rather than in the prose above:
52
53 a checker docstring naming ``scripts/foo`` PATHREF-OK: hypothetical
54
55Run::
56
57 check_script_references.py # gate
58 check_script_references.py --selftest # prove it fires and stays quiet
59
60Exit 0 if every reference resolves, 1 on findings, 2 on tool error.
61"""
62
63from __future__ import annotations
64
65import re
66import subprocess
67import sys
68import tempfile
69from pathlib import Path
70
71sys.path.insert(0, str(Path(__file__).resolve().parent))
72sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
73
74from git_environment import isolated_git_environment, trusted_git_executable
75from lint_targets import is_build_output_path
76
77REPO_ROOT = Path(__file__).resolve().parents[2]
78
79# The path prefix this gate validates. See the "Scope" note in the module
80# docstring for why it is one prefix and not every top-level directory.
81ROOT_PREFIX = "scripts"
82
83# Per-line opt-out marker.
84OPT_OUT = "PATHREF-OK"
85
86# Vendored / generated / build trees. Their prose is the upstream maintainer's
87# call, and several vendor script trees (mbedtls, zephyr) legitimately mention
88# their OWN scripts/ paths, which do not exist here and never will.
89#
90# docs/sbom/upstream/ is the same fact one step removed: those manifests are
91# generated listings of UPSTREAM file paths (#548), and mbedtls and LevelX
92# each ship a `scripts/` directory of their own -- a row naming
93# `scripts/generate_errors.pl` # PATHREF-OK: upstream's path, not ours
94# records what upstream publishes rather than referring to anything here, and
95# unlike prose there is no edit that could make it resolve. Those rows are not
96# unchecked either: check_soup_upstream.py parses every one of them strictly.
97EXCLUDE_FRAGMENTS = (
98 "libs/third_party/",
99 "apps/shared_libs/third_party/",
100 "libs/ra8_fonts/",
101 "port/threadx/",
102 "tools/vela/generated/",
103 "docs/sbom/upstream/",
104)
105
106# Suffixes that are binary or generated payloads -- never scanned. Anything not
107# listed here is scanned and simply yields nothing if it holds no path text, so
108# this is an optimisation and a decode guard, not a scope decision.
109BINARY_SUFFIXES = frozenset(
110 {
111 ".pdf",
112 ".png",
113 ".jpg",
114 ".jpeg",
115 ".gif",
116 ".bmp",
117 ".ico",
118 ".webp",
119 ".bin",
120 ".hex",
121 ".elf",
122 ".o",
123 ".a",
124 ".so",
125 ".dylib",
126 ".map",
127 ".ttf",
128 ".otf",
129 ".woff",
130 ".woff2",
131 ".epub",
132 ".zip",
133 ".gz",
134 ".xz",
135 ".jar",
136 ".class",
137 ".pyc",
138 ".svg",
139 }
140)
141
142# A path token: an optional run of ``../`` segments, then ``scripts/``, then
143# path characters. The leading assertion keeps a ``myscripts/`` or
144# ``tools/scripts/`` prefix from matching, while still allowing a leading run
145# of parent-directory segments (whose match starts at the first dot, not at
146# the slash that follows it).
147_TOKEN_RE = re.compile(
148 r"(?:(?<=^)|(?<=[^A-Za-z0-9_/.-]))"
149 r"((?:\.\./)*" + re.escape(ROOT_PREFIX) + r"/[A-Za-z0-9_./*?{}$@,+\\-]+)"
150)
151
152# A sibling-directory reference built from segments rather than written as a
153# ``scripts/...`` path: ``. "$SCRIPT_DIR/../builders/select_host_compiler.sh"``.
154# _TOKEN_RE cannot see these -- there is no ``scripts/`` token anywhere in the
155# line -- and that blind spot took ``dev`` red once already. The incident: a
156# rename moved the builder directory, and the ``# shellcheck source=`` directive
157# one line above the sourcing line WAS rewritten, because it spells the path out
158# in full. The sourcing line itself kept its segment-built reference to the old
159# directory and failed only when the gate ran, in CI, after the merge.
160#
161# The variable is conventionally the citing script's own directory
162# (``SCRIPT_DIR``, ``SCRIPTDIR``, ``HERE``, ``DIR``), so the reference resolves
163# against the citing file's parent. That convention is the whole basis for
164# resolving these, so the variable name must match it -- an arbitrary
165# ``$SOMEWHERE_ELSE/../x`` is left alone rather than guessed at.
166_SIBLING_RE = re.compile(
167 r"\$\{?(SCRIPT_DIR|SCRIPTDIR|SCRIPT_ROOT|HERE|DIR)\}?/((?:\.\./)+[A-Za-z0-9_./-]+)"
168)
169
170# Trailing characters that are punctuation in the citing text, never part of a
171# filename. A trailing ``/`` is meaningful (directory) and is NOT stripped.
172_TRAILING_JUNK = ".,;:!?)`'\"|>"
173
174# Characters that mean the token is a shell / just interpolation rather than a
175# literal path.
176_INTERPOLATION_CHARS = ("$", "{", "}")
177
178# Characters that mean the token is a glob pattern.
179_GLOB_CHARS = ("*", "?")
180
181
182class Finding:
183 """One unresolved reference: where it was written and what it said."""
184
185 def __init__(self, rel_file: str, line_no: int, token: str, reason: str) -> None:
186 """Record one unresolvable script reference and why it failed."""
187 self.rel_file = rel_file
188 self.line_no = line_no
189 self.token = token
190 self.reason = reason
191
192 def __str__(self) -> str:
193 """Render as ``path:line: token -- reason`` -- editor-jumpable."""
194 return f"{self.rel_file}:{self.line_no}: {self.token} -- {self.reason}"
195
196
197def _git_ls_files(root: Path) -> list[str]:
198 """Tracked plus untracked-but-not-ignored paths under `root`.
199
200 A filesystem walk would sweep in git-excluded local trees (``recon/``,
201 ``.claude/worktrees/``) that CI can never see, so the enumeration follows
202 git's own view of the tree -- the same choice the sibling checkers make.
203 """
204 git_tool = trusted_git_executable()
205 proc = subprocess.run( # noqa: S603 -- fixed argv, resolved tool path
206 [git_tool, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
207 cwd=root,
208 capture_output=True,
209 text=True,
210 check=False,
211 )
212 if proc.returncode != 0:
213 sys.stderr.write(proc.stderr)
214 sys.stderr.write("check_script_references.py: FATAL -- `git ls-files` failed\n")
215 sys.exit(2)
216 return [rel for rel in proc.stdout.split("\0") if rel]
217
218
219def _is_excluded(rel: str) -> bool:
220 return is_build_output_path(rel) or any(frag in f"/{rel}" for frag in EXCLUDE_FRAGMENTS)
221
222
223def _scannable(rel: str) -> bool:
224 return not _is_excluded(rel) and Path(rel).suffix.lower() not in BINARY_SUFFIXES
225
226
227def _literal_prefix(token: str) -> str:
228 """The longest leading run of `token` segments free of glob / interpolation.
229
230 ``scripts/checks/{a,b}.py`` -> ``scripts/checks``. Used to salvage a
231 partial check from a token whose tail cannot be resolved literally: the
232 directory it lives in still has to exist, and that alone catches a moved
233 tree.
234 """
235 kept: list[str] = []
236 for segment in token.split("/"):
237 if any(ch in segment for ch in (*_INTERPOLATION_CHARS, *_GLOB_CHARS)):
238 break
239 kept.append(segment)
240 return "/".join(kept)
241
242
243def _resolve_base(root: Path, rel_file: str, token: str) -> tuple[Path, str]:
244 """Return the directory `token` is relative to, and the token minus ``../``.
245
246 A bare ``scripts/...`` is repo-relative by convention. A ``../../scripts/``
247 in a Markdown link is relative to the citing document, which is how the
248 qualification docs link to the tree.
249 """
250 if not token.startswith("../"):
251 return root, token
252 depth = 0
253 rest = token
254 while rest.startswith("../"):
255 depth += 1
256 rest = rest[3:]
257 base = (root / rel_file).parent
258 for _ in range(depth):
259 base = base.parent
260 return base, rest
261
262
263def _check_interpolated(base: Path, rest: str) -> str | None:
264 """A ``$VAR`` / brace token resolves as far as its literal prefix goes."""
265 prefix = _literal_prefix(rest)
266 if prefix and not (base / prefix).exists():
267 return f"interpolated path whose literal prefix {prefix!r} does not exist"
268 return None
269
270
271def _check_glob(base: Path, rest: str) -> str | None:
272 """A glob has to match something; one that matches nothing is a dead pattern."""
273 if not any(base.glob(rest.rstrip("/"))):
274 return "glob pattern matches nothing"
275 return None
276
277
278def _check_directory(base: Path, rest: str) -> str | None:
279 """A trailing slash asserts a directory, so a file of that name is still wrong."""
280 if not (base / rest.rstrip("/")).is_dir():
281 return "directory does not exist"
282 return None
283
284
285def _check_literal(base: Path, rest: str, token: str) -> str | None:
286 """The ordinary case: the path names something that must be on disk."""
287 if not (base / rest).exists():
288 return f"no such file (token {token!r})"
289 return None
290
291
292def _classify(base: Path, rest: str, token: str) -> str | None:
293 """Return a failure reason for an unresolved reference, or None if it is OK.
294
295 The four reference forms are checked by dedicated predicates so each one
296 reads as its own rule, and the order is significant: interpolation and
297 globs are recognised before the literal case, because a token carrying
298 either cannot be resolved as a plain path.
299 """
300 if any(ch in rest for ch in _INTERPOLATION_CHARS):
301 return _check_interpolated(base, rest)
302 if any(ch in rest for ch in _GLOB_CHARS):
303 return _check_glob(base, rest)
304 if rest.endswith("/"):
305 return _check_directory(base, rest)
306 return _check_literal(base, rest, token)
307
308
309def _unescape(token: str) -> str:
310 r"""Undo regex escaping, and cut the token where escaping stops meaning a dot.
311
312 ``check_ci_parity.py`` matches gate calls with a regex holding an escaped
313 ``.sh`` followed by ``\s+``. Left alone, the extracted token stops at the
314 first backslash and reads as a dangling directory reference; blindly
315 stripping every backslash instead welds the pattern's next atom onto the
316 filename. Both are noise on a correct file. So ``\.`` -- the only escape
317 that spells a character a real path can contain -- is unescaped, and the
318 token is cut at any other backslash, which is where the filename provably
319 ended.
320 """
321 token = token.replace("\\.", ".")
322 head, _, _ = token.partition("\\")
323 return head
324
325
326def _scan_text(rel_file: str, text: str, root: Path = REPO_ROOT) -> list[Finding]:
327 """Every unresolved ``scripts/...`` reference in one file's text."""
328 findings: list[Finding] = []
329 for line_no, line in enumerate(text.splitlines(), start=1):
330 if OPT_OUT in line:
331 continue
332 for match in _TOKEN_RE.finditer(line):
333 token = _unescape(match.group(1)).rstrip(_TRAILING_JUNK)
334 if not token or (token.endswith("/") and token.count("/") == 1):
335 continue # a bare "scripts/" mention, not a reference
336 base, rest = _resolve_base(root, rel_file, token)
337 reason = _classify(base, rest, token)
338 if reason is not None:
339 findings.append(Finding(rel_file, line_no, token, reason))
340 findings.extend(_scan_sibling_refs(rel_file, line, line_no, root))
341 return findings
342
343
344def _scan_sibling_refs(rel_file: str, line: str, line_no: int, root: Path) -> list[Finding]:
345 """Unresolved sibling-directory references in one line.
346
347 The shape matched is ``$SCRIPT_DIR/../somedir/file`` PATHREF-OK: shape.
348
349 Only files under ``scripts/`` are considered: the convention that the
350 variable holds the citing script's own directory is what makes the
351 reference resolvable, and it is a convention of this tree's scripts. A hit
352 elsewhere would be a guess.
353 """
354 if not rel_file.startswith(ROOT_PREFIX + "/"):
355 return []
356 findings: list[Finding] = []
357 citing_dir = (root / rel_file).parent
358 for match in _SIBLING_RE.finditer(line):
359 rest = match.group(2)
360 if any(ch in rest for ch in (*_INTERPOLATION_CHARS, *_GLOB_CHARS)):
361 continue
362 target = (citing_dir / rest).resolve()
363 if target.exists():
364 continue
365 try:
366 shown = target.relative_to(root.resolve()).as_posix()
367 except ValueError:
368 shown = target.as_posix()
369 findings.append(
370 Finding(
371 rel_file,
372 line_no,
373 f"${match.group(1)}/{rest}",
374 f"resolves to {shown}, which does not exist",
375 )
376 )
377 return findings
378
379
380def _scan_file(root: Path, rel: str) -> list[Finding]:
381 try:
382 text = (root / rel).read_text(encoding="utf-8", errors="replace")
383 except OSError:
384 return []
385 return _scan_text(rel, text, root)
386
387
388def scan_repo(root: Path = REPO_ROOT) -> list[Finding]:
389 """Every unresolved ``scripts/...`` reference in the first-party tree."""
390 findings: list[Finding] = []
391 for rel in _git_ls_files(root):
392 if _scannable(rel):
393 findings.extend(_scan_file(root, rel))
394 return findings
395
396
397# ---------------------------------------------------------------------------
398# Selftest -- asserts BOTH directions before the real scan is trusted.
399# ---------------------------------------------------------------------------
400
401# Selftest fixture bodies are composed from ROOT_PREFIX rather than written
402# as literal "scripts/..." text. A literal here would be a real reference in a
403# real tracked file, so every deliberately-dead fixture would have to carry a
404# PATHREF-OK -- fourteen opt-outs on the one file whose job is to make opt-outs
405# rare. Composing them keeps the fixture invisible to the scanner while the
406# assertion below still exercises the exact same code path.
407_P = ROOT_PREFIX
408
409# (file body, expected finding count, what the case proves)
410_SELFTEST_CASES: tuple[tuple[str, int, str], ...] = (
411 (f"see {_P}/ci.sh for the gates\n", 0, "a live repo-relative path is clean"),
412 (f"see {_P}/nope_missing.sh for gates\n", 1, "a dead path is reported"),
413 (f"run `{_P}/checks/check_shell.py`\n", 0, "a live nested path is clean"),
414 (f"run `{_P}/checks/gone.py`\n", 1, "a dead nested path is reported"),
415 (f"the {_P}/checks/ directory\n", 0, "a live directory reference is clean"),
416 (f"the {_P}/nowhere/ directory\n", 1, "a dead directory reference is reported"),
417 (f"all of {_P}/checks/check_*.py\n", 0, "a glob that matches is clean"),
418 (f"all of {_P}/checks/zzz_*.py\n", 1, "a glob that matches nothing is reported"),
419 (f"bash {_P}/checks/${{NAME}}.py\n", 0, "a live interpolated prefix is clean"),
420 (f"bash {_P}/gonedir/${{NAME}}.py\n", 1, "a dead interpolated prefix is reported"),
421 (f"{_P}/nope_missing.sh {OPT_OUT}: prose\n", 0, "the opt-out suppresses"),
422 (f"my{_P}/nope_missing.sh\n", 0, "a look-alike prefix is not a reference"),
423 (f"tools/{_P}/nope_missing.sh\n", 0, "a nested scripts/ dir is not this tree"),
424 (f"see {_P}/ci.sh.\n", 0, "trailing prose punctuation is stripped"),
425 (f're.compile(r"{_P}/ci\\.sh")\n', 0, "regex-escaped dots are unescaped"),
426 (
427 f're.compile(r"{_P}/ci\\.sh\\s+--gate")\n',
428 0,
429 "a token is cut at a non-dot escape, not welded to the next atom",
430 ),
431)
432
433
434def _selftest_body() -> int:
435 """Assert the detector fires on dead references and stays quiet on live ones.
436
437 A path checker that silently stopped matching would report a clean tree --
438 the failure mode that makes a gate worse than no gate. Both directions are
439 asserted here, and the gate body runs this before the real scan.
440 """
441 failures = 0
442 for body, expected, description in _SELFTEST_CASES:
443 got = len(_scan_text("docs/selftest_fixture.md", body))
444 if got != expected:
445 failures += 1
446 print(
447 f" FAIL {description}: expected {expected} finding(s), got {got}"
448 f" [{body.strip()}]",
449 file=sys.stderr,
450 )
451
452 # Relative-form resolution is file-position dependent, so it needs a real
453 # citing path rather than the shared fixture above.
454 rel_cases = (
455 ("docs/qualification/SDP.md", f"../../{_P}/ci.sh", 0),
456 ("docs/qualification/SDP.md", f"../../{_P}/nope_missing.sh", 1),
457 ("docs/qualification/SDP.md", f"../{_P}/ci.sh", 1),
458 )
459 for citing, token, expected in rel_cases:
460 got = len(_scan_text(citing, f"link to {token}\n"))
461 if got != expected:
462 failures += 1
463 print(
464 f" FAIL relative form {token} from {citing}: expected {expected}, got {got}",
465 file=sys.stderr,
466 )
467
468 # End-to-end: a real file in a real git tree must turn the whole gate red.
469 # The unit cases above exercise _scan_text; this proves the enumeration
470 # reaches a newly added file too, so a broken ls-files cannot report clean.
471 with tempfile.TemporaryDirectory() as tmp:
472 tmp_root = Path(tmp)
473 git_tool = trusted_git_executable()
474 subprocess.run( # noqa: S603 -- fixed argv, resolved tool path
475 [git_tool, "init", "-q"], cwd=tmp_root, check=True, capture_output=True
476 )
477 (tmp_root / _P).mkdir()
478 (tmp_root / _P / "live.sh").write_text("#!/bin/sh\n")
479 (tmp_root / "README.md").write_text(f"ok: {_P}/live.sh\n")
480 if scan_repo(tmp_root):
481 failures += 1
482 print(" FAIL end-to-end: a live tree reported findings", file=sys.stderr)
483 (tmp_root / "README.md").write_text(f"bad: {_P}/dead.sh\n")
484 if len(scan_repo(tmp_root)) != 1:
485 failures += 1
486 print(" FAIL end-to-end: a dead reference was not reported", file=sys.stderr)
487
488 if failures:
489 print(f"check_script_references.py: --selftest FAILED ({failures})", file=sys.stderr)
490 return 1
491 total = len(_SELFTEST_CASES) + len(rel_cases) + 2
492 print(f"check_script_references.py: --selftest OK ({total} cases, both directions)")
493 return 0
494
495
496def _selftest() -> int:
497 """Run path-reference fixtures without inheriting the caller's repository."""
498 with isolated_git_environment():
499 return _selftest_body()
500
501
502def main(argv: list[str]) -> int:
503 """Verify every ``scripts/`` path named anywhere in the tree still resolves.
504
505 A stale script reference fails only when someone runs it, which may be
506 months after the rename that broke it -- and in a justfile or workflow
507 that is a broken build for whoever is unlucky, not for whoever moved the
508 file. This turns that into a build-time error at the moment of the move.
509
510 Returns 1 listing each dangling reference, 0 when all resolve.
511 """
512 if "--selftest" in argv[1:]:
513 return _selftest()
514
515 findings = scan_repo()
516 if not findings:
517 print(f"check_script_references.py: every {ROOT_PREFIX}/ reference resolves.")
518 return 0
519
520 print(
521 f"check_script_references.py: {len(findings)} unresolved {ROOT_PREFIX}/ reference(s):\n",
522 file=sys.stderr,
523 )
524 for finding in sorted(findings, key=lambda f: (f.rel_file, f.line_no)):
525 print(f" {finding}", file=sys.stderr)
526 print(
527 f"\nUpdate the reference, or -- if the path is deliberately "
528 f"hypothetical prose -- append `{OPT_OUT}: <reason>` to the line.",
529 file=sys.stderr,
530 )
531 return 1
532
533
534if __name__ == "__main__":
535 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298