4"""Validate literal, first-party ``just`` command references.
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.
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.
19from __future__
import annotations
27from collections.abc
import Iterable
28from dataclasses
import dataclass
29from pathlib
import Path
30from typing
import NoReturn
32REPO_ROOT = Path(__file__).resolve().parent.parent.parent
33SELF = Path(__file__).resolve()
35MIN_PUBLIC_RECIPES = 150
39MIN_LITERAL_REFERENCES = 500
41AUTHORED_DOC_SUFFIXES = {
".md",
".mdx",
".rst"}
42AUTOMATION_SUFFIXES = {
".just",
".py",
".sh",
".yaml",
".yml"}
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*$")
58OPTIONS_WITH_VALUE = {
"-d",
"-f",
"--justfile",
"--working-directory"}
67WORD_DELIMITERS =
" \t\r\n`'\",;|&()[]"
69STANDALONE_SURFACES = {
"infra/hil-cache.just": frozenset({
"check",
"apply"})}
70ROOT_JUSTFILES = frozenset({
"justfile"})
73@dataclass(frozen=True)
75 """Invocable names from the recursive Just module tree."""
77 recipes: frozenset[str]
78 aliases: frozenset[str]
79 modules: frozenset[str]
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
87@dataclass(frozen=True)
89 """One command-shaped reference extracted from authored text."""
93 dynamic_suffix: bool =
False
94 justfile: str |
None =
None
97class ReferenceCheckError(RuntimeError):
98 """Raised when the authoritative Just surface cannot be checked."""
101def _fail(message: str) -> NoReturn:
102 raise ReferenceCheckError(message)
105def _run_just(*args: str) -> str:
106 just_bin = shutil.which(
"just")
108 _fail(
"just is required to validate command references")
109 proc = subprocess.run(
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}")
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)
129def _surface_from_dump(dump: dict[str, object]) -> Surface:
130 """Build qualified names from a recursive dump on every supported Just.
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.
137 recipes: set[str] = set()
138 aliases: set[str] = set()
139 modules: set[str] = set()
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:
145 f
"invalid module path metadata: expected {module_path or '<root>'}, "
146 f
"got {reported_path}"
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)
172 return Surface(frozenset(recipes), frozenset(aliases), frozenset(modules))
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)
183 if summary != surface.recipes:
184 missing, extra = _surface_difference(summary, surface.recipes)
186 f
"just surface disagreement: missing from summary={missing}, absent from dump={extra}"
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)
197def _is_excluded(path: Path) -> bool:
198 return any(part
in EXCLUDED_PARTS
or part.startswith(
"build-")
for part
in path.parts)
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
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(
210 [git_bin,
"ls-files",
"--cached",
"--others",
"--exclude-standard"],
216 paths: list[Path] = []
217 for raw
in proc.stdout.splitlines():
219 if _is_excluded(rel):
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
226 if SELF.is_file()
and SELF
not in paths:
228 paths = sorted(set(paths))
229 if len(paths) < MIN_SCOPED_FILES:
230 _fail(f
"reference scope collapsed: {len(paths)} < {MIN_SCOPED_FILES} files")
234def _logical_lines(text: str) -> Iterable[tuple[int, str]]:
235 """Join shell continuations while retaining the first physical line number."""
238 for number, line
in enumerate(text.splitlines(), 1):
241 if line.rstrip().endswith(
"\\"):
242 pending += line.rstrip()[:-1] +
" "
244 yield start, pending + line
250def _read_word(text: str, position: int) -> tuple[str, int]:
251 while position < len(text)
and text[position].isspace():
253 if position >= len(text):
255 if text[position]
in "'\"":
256 quote = text[position]
259 while position < len(text):
260 if text[position] == quote
and text[position - 1] !=
"\\":
261 return text[start:position], position + 1
263 return text[start:], position
265 while position < len(text)
and text[position]
not in WORD_DELIMITERS:
267 return text[start:position], position
270def _reference_after_just(line: str, position: int) -> Reference |
None:
271 """Parse the fixed recipe word after a conventional ``just`` token."""
276 word, cursor = _read_word(line, cursor)
279 if word
in OPTIONS_WITH_VALUE:
280 value, cursor = _read_word(line, cursor)
281 if word
in {
"-f",
"--justfile"}:
284 valued_option = next(
285 (option
for option
in OPTIONS_WITH_VALUE
if word.startswith(f
"{option}=")),
288 if valued_option
is not None:
289 if valued_option
in {
"-f",
"--justfile"}:
290 justfile = word.partition(
"=")[2]
292 if word
in FLAG_OPTIONS:
294 if word.startswith(
"-"):
296 match = RECIPE_RE.match(word)
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():
304 return Reference(recipe, word_start, dynamic, justfile)
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())
319def _strong_command_context(
320 path: Path, line: str, position: int, *, in_doc_code_fence: bool
322 prefix = line[:position]
323 suffix = path.suffix.lower()
324 if "#" in prefix
and (suffix
in {
".sh",
".just"}
or in_doc_code_fence):
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)
332 inline_code = _is_authored_doc(path)
and len(re.findall(
r"`+", prefix)) % 2 == 1
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)
341 return direct_automation
or direct_doc
or markdown_list
or inline_code
or displayed_command
344def _array_command_context(line: str, position: int) -> bool:
345 """Distinguish an argv literal from unrelated string collections."""
346 prefix = line[:position]
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)
354def references_in_line(
355 path: Path, line: str, *, in_doc_code_fence: bool =
False
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()):
363 word = match.group(
"word")
364 recipe_match = RECIPE_RE.match(word)
365 if recipe_match
is None or word.startswith(
"-"):
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())
372 for match
in re.finditer(
r"(?<!\.)\bjust\b", line):
373 if any(start <= match.start() < end
for start, end
in occupied):
375 if match.end() >= len(line)
or not line[match.end()].isspace():
377 ref = _reference_after_just(line, match.end())
382 or "::" in ref.recipe
383 or _strong_command_context(
384 path, line, match.start(), in_doc_code_fence=in_doc_code_fence
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)
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:
403 command =
"just " +
" ".join(command_match.group(1).split())
406 f
"{path.relative_to(REPO_ROOT)}:{number}: duplicate help command "
407 f
"{command!r} (first at line {seen[command]})"
410 seen[command] = number
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] = []
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
428 for ref
in references_in_line(path, line, in_doc_code_fence=in_doc_code_fence):
430 rel = path.relative_to(REPO_ROOT)
431 if ref.dynamic_suffix:
433 f
"{rel}:{number}: dynamic Just recipe name after {ref.recipe!r}; "
434 "use a fixed recipe and pass the value as an argument"
436 elif ref.recipe
not in _invocable_for_reference(ref, surface):
438 f
"{rel}:{number}: unknown Just recipe/module/alias {ref.recipe!r}"
440 return findings, reference_count
443def _reference_selftest_failures() -> tuple[list[str], int]:
444 """Return failures for command extraction and surface resolution cases."""
446 frozenset({
"apps::build",
"quality::local::gate",
"search"}),
447 frozenset({
"apps::compile-commands"}),
448 frozenset({
"apps",
"apps::host"}),
450 path = REPO_ROOT /
"fixture.md"
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"),
460 'print(f"' + just_word +
' apps::host::{name}")',
464 (f
"`{just_word} apps::host::<command>`", [],
"documented module placeholder"),
466 f
"`{just_word} --justfile ./justfile quality::local::gate lint-just`",
471 f
"`{just_word} --justfile infra/hil-cache.just check`",
473 "approved standalone recipe",
476 f
"`{just_word} --justfile infra/hil-cache.just missing`",
478 "unknown standalone recipe",
481 f
'subprocess.run(["{just_word}", "apps::build", "blink"])',
486 failures: list[str] = []
487 for line, expected, label
in cases:
488 refs = references_in_line(path, line)
489 found: list[str] = []
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
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',
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',
517 failures: list[str] = []
518 if len(duplicate) != 1:
519 failures.append(f
"duplicate help: expected one finding, got {duplicate}")
521 failures.append(f
"distinct help forms were treated as duplicates: {overload}")
526def _surface_selftest_failures() -> tuple[list[str], int]:
527 """Prove both comparison directions and old/new dump traversal."""
528 failures: list[str] = []
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"),
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}")
539 child: dict[str, object] = {
540 "recipes": {
"gate": {
"private":
False}},
541 "aliases": {
"check": {}},
545 old_dump: dict[str, object] = {
546 "recipes": {
"root": {
"private":
False}},
548 "modules": {
"quality": child},
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"}),
558 (old_dump,
"Just 1.40 dump without module_path"),
559 (new_dump,
"newer Just dump with module_path"),
561 actual_surface = _surface_from_dump(dump)
562 if actual_surface != expected_surface:
563 failures.append(f
"{label}: expected {expected_surface}, got {actual_surface}")
565 return failures, len(cases) + 2
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
575 for failure
in failures:
576 print(f
"selftest: check_just_references.py FAIL: {failure}", file=sys.stderr)
579 "selftest: check_just_references.py OK "
580 f
"({reference_cases + help_cases + surface_cases} cases)"
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()
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)
600 if reference_count < MIN_LITERAL_REFERENCES:
602 "check-just-references: ERROR: literal-reference census collapsed: "
603 f
"{reference_count} < {MIN_LITERAL_REFERENCES}",
608 for finding
in findings:
609 print(finding, file=sys.stderr)
611 f
"check-just-references: FAIL ({len(findings)} finding(s), "
612 f
"{reference_count} references in {len(paths)} files)",
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)"
624if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.