ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_justfiles.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"""Linter for repository justfiles.
5
6Formatting (``just --fmt``) is enforced by the format gate (``format_tree.sh``),
7never here.
8"""
9
10from __future__ import annotations
11
12import argparse
13import re
14import shlex
15import shutil
16import subprocess
17import sys
18from pathlib import Path
19
20REPO_ROOT = Path(__file__).resolve().parent.parent.parent
21FIRMWARE_BUILD_RE = re.compile(
22 r'^build app="" build_type="([A-Za-z0-9]+)":$',
23 re.MULTILINE,
24)
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|$))*)",
31 re.MULTILINE,
32)
33NATIVE_FAST_COMMAND = "/bin/bash -p scripts/ci.sh --native --fast"
34
35
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)
39 if match is None:
40 return ["just/apps.just: firmware build recipe/default is missing"]
41 if match.group(1) != "RelWithDebInfo":
42 return [
43 "just/apps.just: apps::build must default to RelWithDebInfo "
44 "(the historical per-app contract)"
45 ]
46 return []
47
48
49def check_nested_just_invocations(text: str, rel: str) -> list[str]:
50 """Require same-environment recursion to preserve the invoking Just path.
51
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.
58 """
59 findings: list[str] = []
60 for number, line in enumerate(text.splitlines(), start=1):
61 if "scripts/ci/devcontainer_run.sh" in line:
62 continue
63 if BARE_NESTED_JUST_RE.search(line) is not None:
64 findings.append(
65 f"{rel}:{number}: nested Just call must use "
66 '"{{ just_executable() }}" instead of PATH lookup'
67 )
68 return findings
69
70
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)
76 if match is None:
77 continue
78 try:
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}")
82 continue
83 index = 0
84 while index < len(args):
85 option = args[index]
86 if option in CI_SH_SWITCHES:
87 index += 1
88 continue
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")
92 break
93 index += 2
94 continue
95 findings.append(
96 f"{rel}:{number}: unsupported scripts/ci.sh option or argument {option!r}"
97 )
98 break
99 return findings
100
101
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}"]
110 return []
111
112
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( # noqa: S603 -- resolved Git executable and fixed argv
117 [
118 git_bin,
119 "ls-files",
120 "--cached",
121 "--others",
122 "--exclude-standard",
123 "justfile",
124 "*.just",
125 "just/*.just",
126 ],
127 cwd=REPO_ROOT,
128 capture_output=True,
129 text=True,
130 check=True,
131 )
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)
137 return sorted(paths)
138
139
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"]
145
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))
150
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))
155 return findings
156
157
158def _selftest_build_default() -> tuple[int, str | None]:
159 """Exercise the firmware-build default in both directions."""
160 build_cases = (
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"),
164 )
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
169
170
171def _selftest_nested_just() -> tuple[int, str | None]:
172 """Exercise nested Just invocation policy in both directions."""
173 nested_cases = (
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"),
177 (
178 ' "{{ just_executable() }}" quality::run\n',
179 False,
180 "invoking executable stays quiet",
181 ),
182 (
183 " bash scripts/ci/devcontainer_run.sh -- just quality::local::check\n",
184 False,
185 "container-owned lookup stays quiet",
186 ),
187 (' @echo "Run just quality::run"\n', False, "help prose stays quiet"),
188 )
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
193
194
195def _selftest_ci_driver() -> tuple[int, str | None]:
196 """Exercise generic CI-driver option validation in both directions."""
197 ci_driver_cases = (
198 (
199 " /bin/bash -p scripts/ci.sh --native --fast\n",
200 False,
201 "separate native and fast switches stay valid",
202 ),
203 (
204 " /bin/bash -p scripts/ci.sh --gate work-harness\n",
205 False,
206 "gate value stays valid",
207 ),
208 (
209 " /bin/bash -p scripts/ci.sh --native-fast\n",
210 True,
211 "invented combined switch fires",
212 ),
213 (
214 " /bin/bash -p scripts/ci.sh --gate\n",
215 True,
216 "missing gate value fires",
217 ),
218 )
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
223
224
225def _selftest_native_fast() -> tuple[int, str | None]:
226 """Exercise the exact native-fast recipe contract in both directions."""
227 native_fast_cases = (
228 (
229 f"native_fast:\n {NATIVE_FAST_COMMAND}\n\nalias native-fast := native_fast\n",
230 False,
231 "exact native-fast recipe stays valid",
232 ),
233 (
234 "native_fast:\n bash scripts/ci.sh --native-fast\n",
235 True,
236 "historical stale recipe fires",
237 ),
238 (
239 "native_fast_renamed:\n /bin/bash -p scripts/ci.sh --native --fast\n",
240 True,
241 "missing native-fast recipe fires",
242 ),
243 )
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
248
249
250def selftest() -> int:
251 """Run internal selftest."""
252 total = 0
253 for run_cases in (
254 _selftest_build_default,
255 _selftest_nested_just,
256 _selftest_ci_driver,
257 _selftest_native_fast,
258 ):
259 count, failure = run_cases()
260 total += count
261 if failure is not None:
262 print(f"selftest: check_justfiles.py FAIL: {failure}", file=sys.stderr)
263 return 1
264 print(f"selftest: check_justfiles.py OK ({total} both-direction cases)")
265 return 0
266
267
268def main() -> int:
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()
276
277 if args.selftest:
278 return selftest()
279
280 files = [Path(p).resolve() for p in args.paths] if args.paths else find_justfiles()
281
282 if args.list_files:
283 for f in files:
284 print(f.relative_to(REPO_ROOT))
285 return 0
286
287 findings: list[str] = []
288 for f in files:
289 findings.extend(check_file(f))
290
291 if findings:
292 for finding in findings:
293 sys.stderr.write(f"{finding}\n")
294 return 1
295
296 print(f"Justfiles clean ({len(files)} files)")
297 return 0
298
299
300if __name__ == "__main__":
301 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298