ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_just_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"""Validate literal, first-party ``just`` command references.
5
6The recipe surface comes from both ``just --summary`` and the recursive JSON
7dump. The former is the user-visible public recipe list; the latter supplies
8module entry points and aliases that the summary deliberately omits. Keeping
9the two views in agreement prevents this check from going green because one
10side of the comparison silently became empty.
11
12Authored documentation, Just help, scripts, and workflow/configuration YAML are
13scanned. Generated files and vendored dependencies are deliberately excluded.
14Arguments may be placeholders, but a recipe name itself must remain literal:
15dynamic recipe construction cannot be validated and has repeatedly hidden
16stale migration-era commands.
17"""
18
19from __future__ import annotations
20
21import argparse
22import json
23import re
24import shutil
25import subprocess
26import sys
27from collections.abc import Iterable
28from dataclasses import dataclass
29from pathlib import Path
30from typing import NoReturn
31
32REPO_ROOT = Path(__file__).resolve().parent.parent.parent
33SELF = Path(__file__).resolve()
34
35MIN_PUBLIC_RECIPES = 150
36MIN_MODULES = 20
37MIN_ALIASES = 10
38MIN_SCOPED_FILES = 500
39MIN_LITERAL_REFERENCES = 500
40
41AUTHORED_DOC_SUFFIXES = {".md", ".mdx", ".rst"}
42AUTOMATION_SUFFIXES = {".just", ".py", ".sh", ".yaml", ".yml"}
43EXCLUDED_PARTS = {
44 ".git",
45 ".venv",
46 "build",
47 "generated",
48 "node_modules",
49 "third_party",
50 "vendor",
51}
52
53RECIPE_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_-]*(?:::[A-Za-z0-9_][A-Za-z0-9_-]*)*")
54QUOTED_JUST_RE = re.compile(r"(['\"])just\1\s*,\s*(['\"])(?P<word>[^'\"]+)\2")
55DIRECT_RE = re.compile(r"^\s*(?:(?:[-*+>]\s+|\d+[.)]\s+)|(?:[-]\s+)?run:\s+|@|exec\s+)?just\b")
56HELP_ECHO_RE = re.compile(r"^\s*@echo\s+(['\"])(?P<text>.*)\1\s*$")
57
58OPTIONS_WITH_VALUE = {"-d", "-f", "--justfile", "--working-directory"}
59FLAG_OPTIONS = {
60 "--check",
61 "--dry-run",
62 "--fmt",
63 "--quiet",
64 "--unstable",
65 "--unsorted",
66}
67WORD_DELIMITERS = " \t\r\n`'\",;|&()[]"
68
69STANDALONE_SURFACES = {"infra/hil-cache.just": frozenset({"check", "apply"})}
70ROOT_JUSTFILES = frozenset({"justfile"})
71
72
73@dataclass(frozen=True)
74class Surface:
75 """Invocable names from the recursive Just module tree."""
76
77 recipes: frozenset[str]
78 aliases: frozenset[str]
79 modules: frozenset[str]
80
81 @property
82 def invocable(self) -> frozenset[str]:
83 """Return recipes, aliases, and modules with a default entry point."""
84 return self.recipes | self.aliases | self.modules
85
86
87@dataclass(frozen=True)
88class Reference:
89 """One command-shaped reference extracted from authored text."""
90
91 recipe: str
92 column: int
93 dynamic_suffix: bool = False
94 justfile: str | None = None
95
96
97class ReferenceCheckError(RuntimeError):
98 """Raised when the authoritative Just surface cannot be checked."""
99
100
101def _fail(message: str) -> NoReturn:
102 raise ReferenceCheckError(message)
103
104
105def _run_just(*args: str) -> str:
106 just_bin = shutil.which("just")
107 if just_bin is None:
108 _fail("just is required to validate command references")
109 proc = subprocess.run( # noqa: S603 -- fixed executable and arguments
110 [just_bin, *args],
111 cwd=REPO_ROOT,
112 capture_output=True,
113 text=True,
114 check=False,
115 )
116 if proc.returncode != 0:
117 detail = proc.stderr.strip() or proc.stdout.strip() or "unknown error"
118 _fail(f"just {' '.join(args)} failed: {detail}")
119 return proc.stdout
120
121
122def _surface_difference(
123 summary: frozenset[str], dump_recipes: frozenset[str]
124) -> tuple[list[str], list[str]]:
125 """Return names missing from summary and names absent from the dump."""
126 return sorted(dump_recipes - summary), sorted(summary - dump_recipes)
127
128
129def _surface_from_dump(dump: dict[str, object]) -> Surface:
130 """Build qualified names from a recursive dump on every supported Just.
131
132 Just 1.40 omits ``module_path`` from child dump nodes, while newer
133 releases populate it. The containing ``modules`` dictionary is the stable
134 source of each path segment, so traversal carries the qualified path
135 explicitly and treats newer metadata only as a consistency check.
136 """
137 recipes: set[str] = set()
138 aliases: set[str] = set()
139 modules: set[str] = set()
140
141 def walk(module: dict[str, object], module_path: str) -> None:
142 reported_path = module.get("module_path")
143 if reported_path is not None and str(reported_path) != module_path:
144 _fail(
145 f"invalid module path metadata: expected {module_path or '<root>'}, "
146 f"got {reported_path}"
147 )
148 prefix = f"{module_path}::" if module_path else ""
149 module_recipes = module.get("recipes")
150 module_aliases = module.get("aliases")
151 child_modules = module.get("modules")
152 if not isinstance(module_recipes, dict):
153 _fail(f"invalid recipe dump below {module_path or '<root>'}")
154 if not isinstance(module_aliases, dict) or not isinstance(child_modules, dict):
155 _fail(f"invalid alias/module dump below {module_path or '<root>'}")
156 for name, value in module_recipes.items():
157 if not isinstance(value, dict):
158 _fail(f"invalid recipe metadata for {prefix}{name}")
159 if not value.get("private", False):
160 recipes.add(f"{prefix}{name}")
161 for name in module_aliases:
162 aliases.add(f"{prefix}{name}")
163 for child_name, child in child_modules.items():
164 if not isinstance(child, dict):
165 _fail(f"invalid child module below {module_path or '<root>'}")
166 child_path = f"{module_path}::{child_name}" if module_path else str(child_name)
167 if child.get("first") is not None:
168 modules.add(child_path)
169 walk(child, child_path)
170
171 walk(dump, "")
172 return Surface(frozenset(recipes), frozenset(aliases), frozenset(modules))
173
174
175def load_surface() -> Surface:
176 """Load and cross-check the public recipe, alias, and module surfaces."""
177 summary = frozenset(_run_just("--summary").split())
178 raw_dump = json.loads(_run_just("--dump", "--dump-format", "json"))
179 if not isinstance(raw_dump, dict):
180 _fail("invalid root recipe dump")
181 surface = _surface_from_dump(raw_dump)
182
183 if summary != surface.recipes:
184 missing, extra = _surface_difference(summary, surface.recipes)
185 _fail(
186 f"just surface disagreement: missing from summary={missing}, absent from dump={extra}"
187 )
188 if len(summary) < MIN_PUBLIC_RECIPES:
189 _fail(f"public recipe census collapsed: {len(summary)} < {MIN_PUBLIC_RECIPES}")
190 if len(surface.modules) < MIN_MODULES:
191 _fail(f"module census collapsed: {len(surface.modules)} < {MIN_MODULES}")
192 if len(surface.aliases) < MIN_ALIASES:
193 _fail(f"alias census collapsed: {len(surface.aliases)} < {MIN_ALIASES}")
194 return Surface(summary, surface.aliases, surface.modules)
195
196
197def _is_excluded(path: Path) -> bool:
198 return any(part in EXCLUDED_PARTS or part.startswith("build-") for part in path.parts)
199
200
201def _is_authored_doc(path: Path) -> bool:
202 """Return whether a path has a supported documentation suffix, case-insensitively."""
203 return path.suffix.lower() in AUTHORED_DOC_SUFFIXES
204
205
206def scoped_files() -> list[Path]:
207 """Return first-party authored docs and automation files."""
208 git_bin = shutil.which("git") or "git"
209 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
210 [git_bin, "ls-files", "--cached", "--others", "--exclude-standard"],
211 cwd=REPO_ROOT,
212 capture_output=True,
213 text=True,
214 check=True,
215 )
216 paths: list[Path] = []
217 for raw in proc.stdout.splitlines():
218 rel = Path(raw)
219 if _is_excluded(rel):
220 continue
221 suffix = rel.suffix.lower()
222 if rel.name == "justfile" or _is_authored_doc(rel) or suffix in AUTOMATION_SUFFIXES:
223 path = REPO_ROOT / rel
224 if path.is_file():
225 paths.append(path)
226 if SELF.is_file() and SELF not in paths:
227 paths.append(SELF)
228 paths = sorted(set(paths))
229 if len(paths) < MIN_SCOPED_FILES:
230 _fail(f"reference scope collapsed: {len(paths)} < {MIN_SCOPED_FILES} files")
231 return paths
232
233
234def _logical_lines(text: str) -> Iterable[tuple[int, str]]:
235 """Join shell continuations while retaining the first physical line number."""
236 pending = ""
237 start = 1
238 for number, line in enumerate(text.splitlines(), 1):
239 if not pending:
240 start = number
241 if line.rstrip().endswith("\\"):
242 pending += line.rstrip()[:-1] + " "
243 continue
244 yield start, pending + line
245 pending = ""
246 if pending:
247 yield start, pending
248
249
250def _read_word(text: str, position: int) -> tuple[str, int]:
251 while position < len(text) and text[position].isspace():
252 position += 1
253 if position >= len(text):
254 return "", position
255 if text[position] in "'\"":
256 quote = text[position]
257 position += 1
258 start = position
259 while position < len(text):
260 if text[position] == quote and text[position - 1] != "\\":
261 return text[start:position], position + 1
262 position += 1
263 return text[start:], position
264 start = position
265 while position < len(text) and text[position] not in WORD_DELIMITERS:
266 position += 1
267 return text[start:position], position
268
269
270def _reference_after_just(line: str, position: int) -> Reference | None:
271 """Parse the fixed recipe word after a conventional ``just`` token."""
272 cursor = position
273 justfile = None
274 while True:
275 word_start = cursor
276 word, cursor = _read_word(line, cursor)
277 if not word:
278 return None
279 if word in OPTIONS_WITH_VALUE:
280 value, cursor = _read_word(line, cursor)
281 if word in {"-f", "--justfile"}:
282 justfile = value
283 continue
284 valued_option = next(
285 (option for option in OPTIONS_WITH_VALUE if word.startswith(f"{option}=")),
286 None,
287 )
288 if valued_option is not None:
289 if valued_option in {"-f", "--justfile"}:
290 justfile = word.partition("=")[2]
291 continue
292 if word in FLAG_OPTIONS:
293 continue
294 if word.startswith("-"):
295 return None
296 match = RECIPE_RE.match(word)
297 if match is None:
298 return None
299 recipe = match.group(0)
300 suffix = word[match.end() :]
301 dynamic = suffix.startswith(("::{", "::$"))
302 while word_start < len(line) and line[word_start].isspace():
303 word_start += 1
304 return Reference(recipe, word_start, dynamic, justfile)
305
306
307def _invocable_for_reference(reference: Reference, root_surface: Surface) -> frozenset[str]:
308 """Resolve a reference against its explicit, statically approved Justfile."""
309 if reference.justfile is None:
310 return root_surface.invocable
311 normalized = Path(reference.justfile).as_posix()
312 while normalized.startswith("./"):
313 normalized = normalized[2:]
314 if normalized in ROOT_JUSTFILES:
315 return root_surface.invocable
316 return STANDALONE_SURFACES.get(normalized, frozenset())
317
318
319def _strong_command_context(
320 path: Path, line: str, position: int, *, in_doc_code_fence: bool
321) -> bool:
322 prefix = line[:position]
323 suffix = path.suffix.lower()
324 if "#" in prefix and (suffix in {".sh", ".just"} or in_doc_code_fence):
325 return False
326 direct_automation = suffix in {".sh", ".just", ".yaml", ".yml"} and bool(DIRECT_RE.match(line))
327 direct_doc = in_doc_code_fence and bool(DIRECT_RE.match(line))
328 markdown_list = _is_authored_doc(path) and bool(
329 re.match(r"^\s*(?:[-*+>]\s+|\d+[.)]\s+)just\b", line)
330 )
331 # Odd delimiter-run parity means the command is inside `...`, ``...``, etc.
332 inline_code = _is_authored_doc(path) and len(re.findall(r"`+", prefix)) % 2 == 1
333 # User-facing echo/print strings frequently indent the displayed command.
334 display_match = re.search(r"(?:echo|printf|print)\b.*['\"](?P<label>[^'\"]*)$", prefix)
335 displayed_command = False
336 if display_match is not None:
337 label = display_match.group("label").strip().lower()
338 displayed_command = not label or bool(
339 re.search(r"(?:command|local|run|usage|use|via):$", label)
340 )
341 return direct_automation or direct_doc or markdown_list or inline_code or displayed_command
342
343
344def _array_command_context(line: str, position: int) -> bool:
345 """Distinguish an argv literal from unrelated string collections."""
346 prefix = line[:position]
347 return bool(
348 prefix.rstrip().endswith("[")
349 or re.search(r"subprocess\.[A-Za-z_]+\s*\‍([^\‍]\n]*$", prefix)
350 or re.search(r"(?:argv|cmd|command)[A-Za-z0-9_]*\s*=\s*[\‍[(][^\‍]\n]*$", prefix)
351 )
352
353
354def references_in_line(
355 path: Path, line: str, *, in_doc_code_fence: bool = False
356) -> list[Reference]:
357 """Extract command-shaped literal references, excluding natural-language 'just'."""
358 refs: list[Reference] = []
359 occupied: list[tuple[int, int]] = []
360 for match in QUOTED_JUST_RE.finditer(line):
361 if not _array_command_context(line, match.start()):
362 continue
363 word = match.group("word")
364 recipe_match = RECIPE_RE.match(word)
365 if recipe_match is None or word.startswith("-"):
366 continue
367 recipe = recipe_match.group(0)
368 suffix = word[recipe_match.end() :]
369 refs.append(Reference(recipe, match.start("word"), suffix.startswith(("::{", "::$"))))
370 occupied.append(match.span())
371
372 for match in re.finditer(r"(?<!\.)\bjust\b", line):
373 if any(start <= match.start() < end for start, end in occupied):
374 continue
375 if match.end() >= len(line) or not line[match.end()].isspace():
376 continue
377 ref = _reference_after_just(line, match.end())
378 if ref is None:
379 continue
380 if (
381 ref.dynamic_suffix
382 or "::" in ref.recipe
383 or _strong_command_context(
384 path, line, match.start(), in_doc_code_fence=in_doc_code_fence
385 )
386 ):
387 refs.append(ref)
388 return refs
389
390
391def duplicate_help_findings(path: Path, text: str) -> list[str]:
392 """Reject exact command duplicates within one Just help menu."""
393 seen: dict[str, int] = {}
394 findings: list[str] = []
395 for number, line in enumerate(text.splitlines(), 1):
396 match = HELP_ECHO_RE.match(line)
397 if match is None:
398 continue
399 help_text = match.group("text").replace('\\"', '"')
400 command_match = re.search(r"\bjust\s+(.+?)(?:\s{2,}|$)", help_text)
401 if command_match is None:
402 continue
403 command = "just " + " ".join(command_match.group(1).split())
404 if command in seen:
405 findings.append(
406 f"{path.relative_to(REPO_ROOT)}:{number}: duplicate help command "
407 f"{command!r} (first at line {seen[command]})"
408 )
409 else:
410 seen[command] = number
411 return findings
412
413
414def check_paths(paths: Iterable[Path], surface: Surface) -> tuple[list[str], int]:
415 """Check references and help duplication for the supplied files."""
416 findings: list[str] = []
417 reference_count = 0
418 for path in paths:
419 text = path.read_text(encoding="utf-8", errors="replace")
420 if path.name == "justfile" or path.suffix == ".just":
421 findings.extend(duplicate_help_findings(path, text))
422 in_doc_code_fence = False
423 for number, line in _logical_lines(text):
424 stripped = line.lstrip()
425 if _is_authored_doc(path) and stripped.startswith(("```", "~~~")):
426 in_doc_code_fence = not in_doc_code_fence
427 continue
428 for ref in references_in_line(path, line, in_doc_code_fence=in_doc_code_fence):
429 reference_count += 1
430 rel = path.relative_to(REPO_ROOT)
431 if ref.dynamic_suffix:
432 findings.append(
433 f"{rel}:{number}: dynamic Just recipe name after {ref.recipe!r}; "
434 "use a fixed recipe and pass the value as an argument"
435 )
436 elif ref.recipe not in _invocable_for_reference(ref, surface):
437 findings.append(
438 f"{rel}:{number}: unknown Just recipe/module/alias {ref.recipe!r}"
439 )
440 return findings, reference_count
441
442
443def _reference_selftest_failures() -> tuple[list[str], int]:
444 """Return failures for command extraction and surface resolution cases."""
445 surface = Surface(
446 frozenset({"apps::build", "quality::local::gate", "search"}),
447 frozenset({"apps::compile-commands"}),
448 frozenset({"apps", "apps::host"}),
449 )
450 path = REPO_ROOT / "fixture.md"
451 just_word = "just"
452 cases = (
453 (f"`{just_word} apps::build <app>`", [], "recipe with placeholder argument"),
454 (f"run `{just_word} apps::compile-commands`", [], "alias"),
455 (f"`{just_word} apps`", [], "module default"),
456 (f"it is {just_word} enough for now", [], "natural language"),
457 (f"`{just_word} apps::missing foo`", ["unknown"], "stale namespaced recipe"),
458 (f"run `{just_word} missing`", ["unknown"], "stale root recipe"),
459 (
460 'print(f"' + just_word + ' apps::host::{name}")',
461 ["dynamic"],
462 "dynamic recipe",
463 ),
464 (f"`{just_word} apps::host::<command>`", [], "documented module placeholder"),
465 (
466 f"`{just_word} --justfile ./justfile quality::local::gate lint-just`",
467 [],
468 "justfile option",
469 ),
470 (
471 f"`{just_word} --justfile infra/hil-cache.just check`",
472 [],
473 "approved standalone recipe",
474 ),
475 (
476 f"`{just_word} --justfile infra/hil-cache.just missing`",
477 ["unknown"],
478 "unknown standalone recipe",
479 ),
480 (
481 f'subprocess.run(["{just_word}", "apps::build", "blink"])',
482 [],
483 "argv command",
484 ),
485 )
486 failures: list[str] = []
487 for line, expected, label in cases:
488 refs = references_in_line(path, line)
489 found: list[str] = []
490 for ref in refs:
491 if ref.dynamic_suffix:
492 found.append("dynamic")
493 elif ref.recipe not in _invocable_for_reference(ref, surface):
494 found.append("unknown")
495 if found != expected:
496 failures.append(f"{label}: expected {expected}, got {found} from {refs}")
497 uppercase = REPO_ROOT / "fixture.MD"
498 uppercase_refs = references_in_line(uppercase, "`just missing`")
499 if len(uppercase_refs) != 1 or uppercase_refs[0].recipe != "missing":
500 failures.append(f"uppercase Markdown command escaped: {uppercase_refs}")
501 if not _is_authored_doc(uppercase) or _is_authored_doc(REPO_ROOT / "fixture.txt"):
502 failures.append("case-insensitive authored-document scope drifted")
503 return failures, len(cases) + 2
504
505
506def _help_selftest_failures() -> tuple[list[str], int]:
507 """Return failures for exact-duplicate help detection."""
508 duplicate = duplicate_help_findings(
509 REPO_ROOT / "fixture.just",
510 '@echo " just apps::build <app> Build one"\n@echo " just apps::build <app> Build it"\n',
511 )
512 overload = duplicate_help_findings(
513 REPO_ROOT / "fixture.just",
514 '@echo " just apps::build <app> Build one"\n'
515 '@echo " just apps::build <app> clean=1 Rebuild one"\n',
516 )
517 failures: list[str] = []
518 if len(duplicate) != 1:
519 failures.append(f"duplicate help: expected one finding, got {duplicate}")
520 if overload:
521 failures.append(f"distinct help forms were treated as duplicates: {overload}")
522
523 return failures, 2
524
525
526def _surface_selftest_failures() -> tuple[list[str], int]:
527 """Prove both comparison directions and old/new dump traversal."""
528 failures: list[str] = []
529 cases = (
530 (frozenset({"a"}), frozenset({"a"}), ([], []), "equal views"),
531 (frozenset({"a"}), frozenset({"a", "b"}), (["b"], []), "summary omission"),
532 (frozenset({"a", "b"}), frozenset({"a"}), ([], ["b"]), "dump omission"),
533 )
534 for summary, dump, expected, label in cases:
535 actual = _surface_difference(summary, dump)
536 if actual != expected:
537 failures.append(f"{label}: expected {expected}, got {actual}")
538
539 child: dict[str, object] = {
540 "recipes": {"gate": {"private": False}},
541 "aliases": {"check": {}},
542 "modules": {},
543 "first": "gate",
544 }
545 old_dump: dict[str, object] = {
546 "recipes": {"root": {"private": False}},
547 "aliases": {},
548 "modules": {"quality": child},
549 }
550 new_child = {**child, "module_path": "quality"}
551 new_dump = {**old_dump, "module_path": "", "modules": {"quality": new_child}}
552 expected_surface = Surface(
553 frozenset({"root", "quality::gate"}),
554 frozenset({"quality::check"}),
555 frozenset({"quality"}),
556 )
557 for dump, label in (
558 (old_dump, "Just 1.40 dump without module_path"),
559 (new_dump, "newer Just dump with module_path"),
560 ):
561 actual_surface = _surface_from_dump(dump)
562 if actual_surface != expected_surface:
563 failures.append(f"{label}: expected {expected_surface}, got {actual_surface}")
564
565 return failures, len(cases) + 2
566
567
568def selftest() -> int:
569 """Exercise positive, negative, placeholder, and duplicate cases."""
570 reference_failures, reference_cases = _reference_selftest_failures()
571 help_failures, help_cases = _help_selftest_failures()
572 surface_failures, surface_cases = _surface_selftest_failures()
573 failures = reference_failures + help_failures + surface_failures
574 if failures:
575 for failure in failures:
576 print(f"selftest: check_just_references.py FAIL: {failure}", file=sys.stderr)
577 return 1
578 print(
579 "selftest: check_just_references.py OK "
580 f"({reference_cases + help_cases + surface_cases} cases)"
581 )
582 return 0
583
584
585def main() -> int:
586 """Run the live reference audit or its detector selftest."""
587 parser = argparse.ArgumentParser(description=__doc__)
588 parser.add_argument("--selftest", action="store_true", help="run detector selftests")
589 args = parser.parse_args()
590 if args.selftest:
591 return selftest()
592
593 try:
594 surface = load_surface()
595 paths = scoped_files()
596 findings, reference_count = check_paths(paths, surface)
597 except (json.JSONDecodeError, OSError, ReferenceCheckError, subprocess.SubprocessError) as exc:
598 print(f"check-just-references: ERROR: {exc}", file=sys.stderr)
599 return 1
600 if reference_count < MIN_LITERAL_REFERENCES:
601 print(
602 "check-just-references: ERROR: literal-reference census collapsed: "
603 f"{reference_count} < {MIN_LITERAL_REFERENCES}",
604 file=sys.stderr,
605 )
606 return 1
607 if findings:
608 for finding in findings:
609 print(finding, file=sys.stderr)
610 print(
611 f"check-just-references: FAIL ({len(findings)} finding(s), "
612 f"{reference_count} references in {len(paths)} files)",
613 file=sys.stderr,
614 )
615 return 1
616 print(
617 f"Just references clean ({reference_count} references, {len(paths)} files, "
618 f"{len(surface.recipes)} recipes, {len(surface.modules)} modules, "
619 f"{len(surface.aliases)} aliases)"
620 )
621 return 0
622
623
624if __name__ == "__main__":
625 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298