4"""Gate: every ``scripts/...`` path mentioned anywhere in the tree resolves.
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
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.
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
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.
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
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:
53 a checker docstring naming ``scripts/foo`` PATHREF-OK: hypothetical
57 check_script_references.py # gate
58 check_script_references.py --selftest # prove it fires and stays quiet
60Exit 0 if every reference resolves, 1 on findings, 2 on tool error.
63from __future__
import annotations
69from pathlib
import Path
71sys.path.insert(0, str(Path(__file__).resolve().parent))
72sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
74from git_environment
import isolated_git_environment, trusted_git_executable
75from lint_targets
import is_build_output_path
77REPO_ROOT = Path(__file__).resolve().parents[2]
81ROOT_PREFIX =
"scripts"
99 "apps/shared_libs/third_party/",
102 "tools/vela/generated/",
103 "docs/sbom/upstream/",
109BINARY_SUFFIXES = frozenset(
147_TOKEN_RE = re.compile(
148 r"(?:(?<=^)|(?<=[^A-Za-z0-9_/.-]))"
149 r"((?:\.\./)*" + re.escape(ROOT_PREFIX) +
r"/[A-Za-z0-9_./*?{}$@,+\\-]+)"
166_SIBLING_RE = re.compile(
167 r"\$\{?(SCRIPT_DIR|SCRIPTDIR|SCRIPT_ROOT|HERE|DIR)\}?/((?:\.\./)+[A-Za-z0-9_./-]+)"
172_TRAILING_JUNK =
".,;:!?)`'\"|>"
176_INTERPOLATION_CHARS = (
"$",
"{",
"}")
179_GLOB_CHARS = (
"*",
"?")
183 """One unresolved reference: where it was written and what it said."""
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
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}"
197def _git_ls_files(root: Path) -> list[str]:
198 """Tracked plus untracked-but-not-ignored paths under `root`.
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.
204 git_tool = trusted_git_executable()
205 proc = subprocess.run(
206 [git_tool,
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard"],
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")
216 return [rel
for rel
in proc.stdout.split(
"\0")
if rel]
219def _is_excluded(rel: str) -> bool:
220 return is_build_output_path(rel)
or any(frag
in f
"/{rel}" for frag
in EXCLUDE_FRAGMENTS)
223def _scannable(rel: str) -> bool:
224 return not _is_excluded(rel)
and Path(rel).suffix.lower()
not in BINARY_SUFFIXES
227def _literal_prefix(token: str) -> str:
228 """The longest leading run of `token` segments free of glob / interpolation.
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
236 for segment
in token.split(
"/"):
237 if any(ch
in segment
for ch
in (*_INTERPOLATION_CHARS, *_GLOB_CHARS)):
240 return "/".join(kept)
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 ``../``.
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.
250 if not token.startswith(
"../"):
254 while rest.startswith(
"../"):
257 base = (root / rel_file).parent
258 for _
in range(depth):
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"
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"
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"
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})"
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.
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.
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)
309def _unescape(token: str) -> str:
310 r"""Undo regex escaping, and cut the token where escaping stops meaning a dot.
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
321 token = token.replace(
"\\.",
".")
322 head, _, _ = token.partition(
"\\")
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):
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):
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))
344def _scan_sibling_refs(rel_file: str, line: str, line_no: int, root: Path) -> list[Finding]:
345 """Unresolved sibling-directory references in one line.
347 The shape matched is ``$SCRIPT_DIR/../somedir/file`` PATHREF-OK: shape.
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.
354 if not rel_file.startswith(ROOT_PREFIX +
"/"):
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)):
362 target = (citing_dir / rest).resolve()
366 shown = target.relative_to(root.resolve()).as_posix()
368 shown = target.as_posix()
373 f
"${match.group(1)}/{rest}",
374 f
"resolves to {shown}, which does not exist",
380def _scan_file(root: Path, rel: str) -> list[Finding]:
382 text = (root / rel).read_text(encoding=
"utf-8", errors=
"replace")
385 return _scan_text(rel, text, root)
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):
393 findings.extend(_scan_file(root, rel))
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"),
427 f
're.compile(r"{_P}/ci\\.sh\\s+--gate")\n',
429 "a token is cut at a non-dot escape, not welded to the next atom",
434def _selftest_body() -> int:
435 """Assert the detector fires on dead references and stays quiet on live ones.
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.
442 for body, expected, description
in _SELFTEST_CASES:
443 got = len(_scan_text(
"docs/selftest_fixture.md", body))
447 f
" FAIL {description}: expected {expected} finding(s), got {got}"
448 f
" [{body.strip()}]",
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),
459 for citing, token, expected
in rel_cases:
460 got = len(_scan_text(citing, f
"link to {token}\n"))
464 f
" FAIL relative form {token} from {citing}: expected {expected}, got {got}",
471 with tempfile.TemporaryDirectory()
as tmp:
473 git_tool = trusted_git_executable()
475 [git_tool,
"init",
"-q"], cwd=tmp_root, check=
True, capture_output=
True
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):
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:
486 print(
" FAIL end-to-end: a dead reference was not reported", file=sys.stderr)
489 print(f
"check_script_references.py: --selftest FAILED ({failures})", file=sys.stderr)
491 total = len(_SELFTEST_CASES) + len(rel_cases) + 2
492 print(f
"check_script_references.py: --selftest OK ({total} cases, both directions)")
496def _selftest() -> int:
497 """Run path-reference fixtures without inheriting the caller's repository."""
498 with isolated_git_environment():
499 return _selftest_body()
502def main(argv: list[str]) -> int:
503 """Verify every ``scripts/`` path named anywhere in the tree still resolves.
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.
510 Returns 1 listing each dangling reference, 0 when all resolve.
512 if "--selftest" in argv[1:]:
515 findings = scan_repo()
517 print(f
"check_script_references.py: every {ROOT_PREFIX}/ reference resolves.")
521 f
"check_script_references.py: {len(findings)} unresolved {ROOT_PREFIX}/ reference(s):\n",
524 for finding
in sorted(findings, key=
lambda f: (f.rel_file, f.line_no)):
525 print(f
" {finding}", file=sys.stderr)
527 f
"\nUpdate the reference, or -- if the path is deliberately "
528 f
"hypothetical prose -- append `{OPT_OUT}: <reason>` to the line.",
534if __name__ ==
"__main__":
535 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.