ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_host_build_entrypoints.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"""Keep native Just builds on the C23 compiler and complete tool registry."""
5
6from __future__ import annotations
7
8import argparse
9import re
10import subprocess
11import sys
12import tempfile
13from pathlib import Path
14
15REPO_ROOT = Path(__file__).resolve().parents[2]
16COMPILED = frozenset({".c", ".cc", ".cpp", ".cxx", ".m", ".mm"})
17RECIPE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*)(?:\s+[^:]*)?:\s*$")
18RAW_CMAKE_CONFIGURE = re.compile(r"(?:^|\s)cmake\s+(?!--(?:build|install)\b)")
19RAW_COMPILER = re.compile(
20 r"(?:^|\s)(?:\$\{?cc\}?|\$cc|cc|gcc(?:-[0-9]+)?|clang(?:-[0-9]+)?)\s+"
21 r"[^\n]*(?:-std=|(?:^|\s)-c(?:\s|$)|(?:^|\s)-o(?:\s|$))"
22)
23
24
25def _recipe_bodies(text: str) -> list[tuple[str, str]]:
26 """Return recipe names and bodies from one Just module."""
27 bodies: list[tuple[str, str]] = []
28 name = ""
29 lines: list[str] = []
30 for line in text.splitlines():
31 match = RECIPE.match(line)
32 if match:
33 if name:
34 bodies.append((name, "\n".join(lines)))
35 name, lines = match.group(1).strip(), []
36 elif name and (line.startswith((" ", "\t")) or not line.strip()):
37 if not line.lstrip().startswith("#"):
38 lines.append(line)
39 if name:
40 bodies.append((name, "\n".join(lines)))
41 return bodies
42
43
44def _shell_commands(body: str) -> list[str]:
45 """Join shell continuation lines into configure-command units."""
46 commands: list[str] = []
47 current = ""
48 for raw in body.splitlines():
49 line = raw.strip()
50 current = f"{current} {line}".strip()
51 if current.endswith("\\"):
52 current = current[:-1].rstrip()
53 elif current:
54 commands.append(current)
55 current = ""
56 if current:
57 commands.append(current)
58 return commands
59
60
61def _recipe_errors(label: str, text: str) -> list[str]:
62 """Reject raw native CMake configure and compile-driver bodies."""
63 errors: list[str] = []
64 for name, body in _recipe_bodies(text):
65 errors.extend(
66 f"{label}: recipe {name}: raw native CMake bypasses host_cmake.sh"
67 for command in _shell_commands(body)
68 if RAW_CMAKE_CONFIGURE.search(command) and "CMAKE_TOOLCHAIN_FILE" not in command
69 )
70 if RAW_COMPILER.search(body):
71 errors.append(f"{label}: recipe {name}: raw host compiler invocation bypasses CMake")
72 return errors
73
74
75def _just_files(root: Path) -> list[Path]:
76 """Discover the root Just entry point and every module."""
77 return [root / "justfile", *sorted((root / "just").glob("*.just"))]
78
79
80def _compiled_tools(root: Path) -> set[str]:
81 """Discover tool roots containing authored compiled implementation."""
82 found: set[str] = set()
83 tools = root / "tools"
84 for path in tools.glob("*/src/**/*"):
85 if path.is_file() and path.suffix in COMPILED:
86 found.add(path.relative_to(tools).parts[0])
87 return found
88
89
90def _cmake_tools(root: Path) -> set[str]:
91 """Discover tool roots managed by the CMake dispatcher."""
92 return {path.parent.name for path in (root / "tools").glob("*/CMakeLists.txt")}
93
94
95def _standalone_cmake(text: str) -> bool:
96 """Whether a listfile declares itself as a top-level CMake project."""
97 return bool(re.search(r"^\s*project\s*\‍(", text, flags=re.MULTILINE))
98
99
100def _shared_dispatch_errors(root: Path) -> list[str]:
101 """Ensure shared consumer fragments cannot be configured vacuously."""
102 just_text = (root / "just" / "shared.just").read_text(encoding="utf-8")
103 dispatcher = root / "scripts" / "builders" / "build_shared_libs.sh"
104 errors = []
105 if 'build_shared_libs.sh "{{ lib }}"' not in just_text:
106 errors.append("just/shared.just does not delegate to the shared-library dispatcher")
107 text = dispatcher.read_text(encoding="utf-8") if dispatcher.is_file() else ""
108 errors.extend(
109 f"shared-library dispatcher lacks contract: {required}"
110 for required in ("is_standalone", "host_cmake.sh", "apps::shared::test")
111 if required not in text
112 )
113 return errors
114
115
116def _inventory_errors(root: Path) -> list[str]:
117 """Require every compiled tool to join the discovery-managed registry."""
118 missing = sorted(_compiled_tools(root) - _cmake_tools(root))
119 return [f"tools/{name}: compiled tool has no CMakeLists.txt" for name in missing]
120
121
122def _dispatcher_errors(root: Path, listed: set[str]) -> list[str]:
123 """Check discovery output and the Just/wrapper contracts."""
124 expected = _cmake_tools(root)
125 errors = [f"tool dispatcher omits {name}" for name in sorted(expected - listed)]
126 errors += [f"tool dispatcher invents {name}" for name in sorted(listed - expected)]
127 tools_just = (root / "just" / "tools.just").read_text(encoding="utf-8")
128 for required in (
129 'build tool="all":',
130 'clean tool="all":',
131 'build_host_tools.sh build "{{ tool }}"',
132 'build_host_tools.sh clean "{{ tool }}"',
133 ):
134 if required not in tools_just:
135 errors.append(f"just/tools.just lacks discovery contract: {required}")
136 wrapper = (root / "scripts" / "builders" / "host_cmake.sh").read_text(encoding="utf-8")
137 for required in (
138 "ra8_select_host_compiler",
139 "ra8_select_emulator_compiler",
140 "ra8_cmake_reset_if_incompatible",
141 "-DCMAKE_C_COMPILER=",
142 "-DCMAKE_CXX_COMPILER=",
143 ):
144 if required not in wrapper:
145 errors.append(f"host_cmake.sh lacks compiler/cache contract: {required}")
146 dispatcher = (root / "scripts" / "builders" / "build_host_tools.sh").read_text(encoding="utf-8")
147 for required in ("clean_one", '"$dir/cache_bench"', '"$dir/miniz_host.o"', '"$dir"/*.trace'):
148 if required not in dispatcher:
149 errors.append(f"tool dispatcher lacks legacy-clean contract: {required}")
150 return errors
151
152
153def _live_dispatch(root: Path) -> tuple[set[str], list[str]]:
154 """Run the read-only dispatcher list mode."""
155 script = root / "scripts" / "builders" / "build_host_tools.sh"
156 proc = subprocess.run( # noqa: S603 -- fixed repository script, list-only mode
157 [str(script), "list"], text=True, capture_output=True, check=False
158 )
159 if proc.returncode:
160 return set(), [f"tool dispatcher list failed: {proc.stderr.strip()}"]
161 return set(proc.stdout.splitlines()), []
162
163
164def _selftest() -> int:
165 """Prove raw builds and registry omissions fire while legal forms stay quiet."""
166 failures: list[str] = []
167 good = "build:\n bash scripts/builders/host_cmake.sh tools/x tools/x/build\n"
168 cross = """build:
169 cmake -S x -B b -DCMAKE_TOOLCHAIN_FILE=cmake/arm.cmake
170 cmake --build b
171"""
172 bad_cmake = "build:\n cmake -S tools/x -B tools/x/build\n"
173 mixed_cmake = """build:
174 cmake -S arm -B arm/build -DCMAKE_TOOLCHAIN_FILE=cmake/arm.cmake
175 cmake -S tools/x -B tools/x/build
176"""
177 bad_cc = "build:\n cc -std=gnu23 src/main.c -o tool\n"
178 if _recipe_errors("good", good) or _recipe_errors("cross", cross):
179 failures.append("wrapper or ARM-toolchain fixture was rejected")
180 if not _recipe_errors("bad-cmake", bad_cmake):
181 failures.append("raw native CMake fixture was accepted")
182 if not _recipe_errors("mixed-cmake", mixed_cmake):
183 failures.append("raw native CMake hidden beside an ARM configure was accepted")
184 if not _recipe_errors("bad-cc", bad_cc):
185 failures.append("raw compiler fixture was accepted")
186 if _standalone_cmake("target_sources(app PRIVATE src/x.c)\n"):
187 failures.append("consumer CMake fragment was classified as standalone")
188 if not _standalone_cmake("project(shared LANGUAGES C)\n"):
189 failures.append("standalone shared CMake project was classified as a fragment")
190 with tempfile.TemporaryDirectory() as tmp:
191 root = Path(tmp)
192 (root / "just").mkdir()
193 (root / "justfile").write_text("default:\n true\n")
194 (root / "just" / "future.just").write_text(bad_cmake)
195 discovered = {path.relative_to(root) for path in _just_files(root)}
196 if discovered != {Path("justfile"), Path("just/future.just")}:
197 failures.append("new Just module was omitted from discovery")
198 (root / "tools" / "native" / "src").mkdir(parents=True)
199 (root / "tools" / "native" / "src" / "main.c").write_text("int main(void){}\n")
200 if not _inventory_errors(root):
201 failures.append("compiled tool without CMake was accepted")
202 (root / "tools" / "native" / "CMakeLists.txt").write_text("project(native C)\n")
203 if _inventory_errors(root):
204 failures.append("compiled tool with CMake was rejected")
205 if not _dispatcher_errors_fixture({"native"}, set()):
206 failures.append("dispatcher omission was accepted")
207 if _dispatcher_errors_fixture({"native"}, {"native"}):
208 failures.append("complete dispatcher fixture was rejected")
209 if failures:
210 print("check_host_build_entrypoints.py --selftest FAILED:", file=sys.stderr)
211 print("\n".join(f" {failure}" for failure in failures), file=sys.stderr)
212 return 1
213 print("check_host_build_entrypoints.py --selftest: PASS (12 both-direction cases)")
214 return 0
215
216
217def _dispatcher_errors_fixture(expected: set[str], listed: set[str]) -> list[str]:
218 """Pure set comparison used to prove dispatcher coverage both ways."""
219 return [
220 *(f"missing {x}" for x in expected - listed),
221 *(f"extra {x}" for x in listed - expected),
222 ]
223
224
225def main() -> int:
226 """Run selftest or the live host-build surface audit."""
227 parser = argparse.ArgumentParser(description=__doc__)
228 parser.add_argument("--selftest", action="store_true")
229 args = parser.parse_args()
230 if args.selftest:
231 return _selftest()
232 errors: list[str] = []
233 just_files = _just_files(REPO_ROOT)
234 for path in just_files:
235 errors += _recipe_errors(str(path.relative_to(REPO_ROOT)), path.read_text(encoding="utf-8"))
236 errors += _inventory_errors(REPO_ROOT)
237 errors += _shared_dispatch_errors(REPO_ROOT)
238 listed, list_errors = _live_dispatch(REPO_ROOT)
239 errors += list_errors + _dispatcher_errors(REPO_ROOT, listed)
240 if errors:
241 print("check_host_build_entrypoints.py: host build contract violations:", file=sys.stderr)
242 print("\n".join(f" {error}" for error in errors), file=sys.stderr)
243 return 1
244 print(
245 f"check_host_build_entrypoints.py: clean ({len(just_files)} Just files, "
246 f"{len(listed)} compiled tools)"
247 )
248 return 0
249
250
251if __name__ == "__main__":
252 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298