4"""Linter for repository justfiles.
6Formatting (``just --fmt``) is enforced by the format gate (``format_tree.sh``),
10from __future__
import annotations
18from pathlib
import Path
20REPO_ROOT = Path(__file__).resolve().parent.parent.parent
21FIRMWARE_BUILD_RE = re.compile(
22 r'^build app="" build_type="([A-Za-z0-9]+)":$',
25BARE_NESTED_JUST_RE = re.compile(
r"(?:^\s*@?|\b(?:then|do|else)\s+|(?:&&|\|\||;)\s*)just(?=\s|$)")
26CI_SH_CALL_RE = re.compile(
r"^\s*@?/bin/bash\s+-p\s+scripts/ci\.sh(?P<args>(?:\s+.*)?)$")
27CI_SH_SWITCHES = frozenset({
"--container",
"--fast",
"--list-gates",
"--native",
"--rebuild"})
28CI_SH_VALUE_OPTIONS = frozenset({
"--gate",
"--selftest-abort"})
29NATIVE_FAST_RECIPE_RE = re.compile(
30 r"^native_fast:[ \t]*\n(?P<body>(?:[ \t]+[^\n]*(?:\n|$))*)",
33NATIVE_FAST_COMMAND =
"/bin/bash -p scripts/ci.sh --native --fast"
36def check_firmware_build_default(text: str) -> list[str]:
37 """Keep the migrated per-app build default byte-for-behaviour compatible."""
38 match = FIRMWARE_BUILD_RE.search(text)
40 return [
"just/apps.just: firmware build recipe/default is missing"]
41 if match.group(1) !=
"RelWithDebInfo":
43 "just/apps.just: apps::build must default to RelWithDebInfo "
44 "(the historical per-app contract)"
49def check_nested_just_invocations(text: str, rel: str) -> list[str]:
50 """Require same-environment recursion to preserve the invoking Just path.
52 A recipe may be entered through an absolute executable while that
53 executable's directory is absent from PATH, as on a noninteractive SSH
54 session. ``just_executable()`` preserves the known-good executable. A bare
55 ``just`` passed through ``devcontainer_run.sh`` is intentionally excluded:
56 it runs in the container's namespace, where the host executable path is
57 invalid and the image owns PATH.
59 findings: list[str] = []
60 for number, line
in enumerate(text.splitlines(), start=1):
61 if "scripts/ci/devcontainer_run.sh" in line:
63 if BARE_NESTED_JUST_RE.search(line)
is not None:
65 f
"{rel}:{number}: nested Just call must use "
66 '"{{ just_executable() }}" instead of PATH lookup'
71def check_ci_driver_invocations(text: str, rel: str) -> list[str]:
72 """Reject stale or malformed ``scripts/ci.sh`` options in Just recipes."""
73 findings: list[str] = []
74 for number, line
in enumerate(text.splitlines(), start=1):
75 match = CI_SH_CALL_RE.fullmatch(line)
79 args = shlex.split(match.group(
"args"))
80 except ValueError
as exc:
81 findings.append(f
"{rel}:{number}: cannot parse scripts/ci.sh arguments: {exc}")
84 while index < len(args):
86 if option
in CI_SH_SWITCHES:
89 if option
in CI_SH_VALUE_OPTIONS:
90 if index + 1 >= len(args)
or args[index + 1].startswith(
"--"):
91 findings.append(f
"{rel}:{number}: {option} requires one value")
96 f
"{rel}:{number}: unsupported scripts/ci.sh option or argument {option!r}"
102def check_ci_native_fast_contract(text: str, rel: str) -> list[str]:
103 """Pin the public native-fast recipe to the CI driver's real argv."""
104 matches = list(NATIVE_FAST_RECIPE_RE.finditer(text))
105 if len(matches) != 1:
106 return [f
"{rel}: expected exactly one native_fast recipe, found {len(matches)}"]
107 body = [line.strip()
for line
in matches[0].group(
"body").splitlines()
if line.strip()]
108 if body != [NATIVE_FAST_COMMAND]:
109 return [f
"{rel}: native_fast must contain only `{NATIVE_FAST_COMMAND}`; found {body!r}"]
113def find_justfiles() -> list[Path]:
114 """Return all tracked justfiles in the repository."""
115 git_bin = shutil.which(
"git")
or "git"
116 proc = subprocess.run(
122 "--exclude-standard",
132 paths: list[Path] = []
133 for raw_line
in proc.stdout.splitlines():
134 line = raw_line.strip()
135 if line
and (REPO_ROOT / line).is_file():
136 paths.append(REPO_ROOT / line)
140def check_file(path: Path) -> list[str]:
141 """Check a justfile for structural defects (nesting, driver calls, contracts)."""
142 findings: list[str] = []
143 if not path.is_file():
144 return [f
"{path}: file not found"]
146 rel = path.relative_to(REPO_ROOT).as_posix()
147 text = path.read_text(encoding=
"utf-8")
148 findings.extend(check_nested_just_invocations(text, rel))
149 findings.extend(check_ci_driver_invocations(text, rel))
151 if path.resolve() == (REPO_ROOT /
"just/apps.just").resolve():
152 findings.extend(check_firmware_build_default(text))
153 if rel ==
"just/ci.just":
154 findings.extend(check_ci_native_fast_contract(text, rel))
158def _selftest_build_default() -> tuple[int, str | None]:
159 """Exercise the firmware-build default in both directions."""
161 (
'build app="" build_type="RelWithDebInfo":\n',
False,
"historical default passes"),
162 (
'build app="" build_type="Debug":\n',
True,
"Debug default fires"),
163 (
'build app="":\n',
True,
"missing selectable default fires"),
165 for text, expected, label
in build_cases:
166 if bool(check_firmware_build_default(text)) != expected:
167 return len(build_cases), label
168 return len(build_cases),
None
171def _selftest_nested_just() -> tuple[int, str | None]:
172 """Exercise nested Just invocation policy in both directions."""
174 (
" just quality::run\n",
True,
"direct nested lookup fires"),
175 (
" @just hooks\n",
True,
"quiet nested lookup fires"),
176 (
" if ok; then just tests::build; fi\n",
True,
"shell-chain lookup fires"),
178 ' "{{ just_executable() }}" quality::run\n',
180 "invoking executable stays quiet",
183 " bash scripts/ci/devcontainer_run.sh -- just quality::local::check\n",
185 "container-owned lookup stays quiet",
187 (
' @echo "Run just quality::run"\n',
False,
"help prose stays quiet"),
189 for text, expected, label
in nested_cases:
190 if bool(check_nested_just_invocations(text,
"fixture.just")) != expected:
191 return len(nested_cases), label
192 return len(nested_cases),
None
195def _selftest_ci_driver() -> tuple[int, str | None]:
196 """Exercise generic CI-driver option validation in both directions."""
199 " /bin/bash -p scripts/ci.sh --native --fast\n",
201 "separate native and fast switches stay valid",
204 " /bin/bash -p scripts/ci.sh --gate work-harness\n",
206 "gate value stays valid",
209 " /bin/bash -p scripts/ci.sh --native-fast\n",
211 "invented combined switch fires",
214 " /bin/bash -p scripts/ci.sh --gate\n",
216 "missing gate value fires",
219 for text, expected, label
in ci_driver_cases:
220 if bool(check_ci_driver_invocations(text,
"fixture.just")) != expected:
221 return len(ci_driver_cases), label
222 return len(ci_driver_cases),
None
225def _selftest_native_fast() -> tuple[int, str | None]:
226 """Exercise the exact native-fast recipe contract in both directions."""
227 native_fast_cases = (
229 f
"native_fast:\n {NATIVE_FAST_COMMAND}\n\nalias native-fast := native_fast\n",
231 "exact native-fast recipe stays valid",
234 "native_fast:\n bash scripts/ci.sh --native-fast\n",
236 "historical stale recipe fires",
239 "native_fast_renamed:\n /bin/bash -p scripts/ci.sh --native --fast\n",
241 "missing native-fast recipe fires",
244 for text, expected, label
in native_fast_cases:
245 if bool(check_ci_native_fast_contract(text,
"just/ci.just")) != expected:
246 return len(native_fast_cases), label
247 return len(native_fast_cases),
None
250def selftest() -> int:
251 """Run internal selftest."""
254 _selftest_build_default,
255 _selftest_nested_just,
257 _selftest_native_fast,
259 count, failure = run_cases()
261 if failure
is not None:
262 print(f
"selftest: check_justfiles.py FAIL: {failure}", file=sys.stderr)
264 print(f
"selftest: check_justfiles.py OK ({total} both-direction cases)")
269 """Check justfiles in the repository."""
270 parser = argparse.ArgumentParser(description=
"Check justfiles in the repository")
271 parser.add_argument(
"--list-files", action=
"store_true", help=
"List all scanned justfiles")
272 parser.add_argument(
"--check", action=
"store_true", help=
"Check justfiles (structural)")
273 parser.add_argument(
"--selftest", action=
"store_true", help=
"Run internal selftest")
274 parser.add_argument(
"paths", nargs=
"*", help=
"Optional specific paths to check")
275 args = parser.parse_args()
280 files = [Path(p).resolve()
for p
in args.paths]
if args.paths
else find_justfiles()
284 print(f.relative_to(REPO_ROOT))
287 findings: list[str] = []
289 findings.extend(check_file(f))
292 for finding
in findings:
293 sys.stderr.write(f
"{finding}\n")
296 print(f
"Justfiles clean ({len(files)} files)")
300if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.