ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_source_layout.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"""Enforce repository source/include/test layout for first-party code."""
5
6from __future__ import annotations
7
8import argparse
9import shutil
10import subprocess
11import sys
12from pathlib import Path, PurePosixPath
13
14SCOPED_ROOTS = frozenset({"apps", "examples", "libs", "port", "tests", "tools"})
15IMPLEMENTATION_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".m"})
16HEADER_SUFFIXES = frozenset({".h", ".hh", ".hpp", ".hxx"})
17COMPONENT_HELPER_SUFFIXES = frozenset({".py", ".sh"})
18COMPONENT_ROOTS = frozenset({"apps", "examples"})
19EXCLUDED_PARTS = frozenset({"_deps", "build"})
20TOOL_VENDOR_ROOT_PARTS = 3
21TOOL_FILE_MIN_PARTS = 3
22EXCLUDED_PREFIXES = (
23 PurePosixPath("libs/ra8_fonts"),
24 PurePosixPath("tools/vela/generated"),
25)
26APP_OWNED_VENDORS = frozenset({"libwebp", "litehtml", "miniz", "stb", "xz_embedded"})
27APP_COMPRESS_FILES = frozenset(
28 {
29 PurePosixPath("apps/shared_libs/compress/inc/ra8_compress.h"),
30 PurePosixPath("apps/shared_libs/compress/inc/ra8_vfs_compress.h"),
31 PurePosixPath("apps/shared_libs/compress/src/ra8_compress.c"),
32 PurePosixPath("apps/shared_libs/compress/src/ra8_vfs_compress.c"),
33 }
34)
35LEGACY_COMPRESS_FILES = frozenset(
36 {
37 PurePosixPath("libs/ra8_io/inc/ra8_io_compress.h"),
38 PurePosixPath("libs/ra8_io/inc/ra8_io_vfs_compress.h"),
39 PurePosixPath("libs/ra8_io/src/ra8_io_compress.c"),
40 PurePosixPath("libs/ra8_io/src/ra8_io_vfs_compress.c"),
41 }
42)
43
44
45def is_vendor_path(path: PurePosixPath) -> bool:
46 """Return whether ``path`` has a supported vendored-component shape.
47
48 The SBOM gate separately requires every directory under these roots to be
49 registered. A tool-private root is deliberately narrow: only
50 ``tools/<tool>/third_party/<component>`` qualifies, so a generic tools
51 vendor dumping ground cannot silently appear.
52 """
53 parts = path.parts
54 return (
55 parts[:2] == ("libs", "third_party")
56 or parts[:3] == ("apps", "shared_libs", "third_party")
57 or (
58 len(parts) >= TOOL_VENDOR_ROOT_PARTS
59 and parts[0] == "tools"
60 and parts[2] == "third_party"
61 )
62 )
63
64
65def is_excluded(path: PurePosixPath) -> bool:
66 """Return whether a path is vendored, generated, or build output."""
67 if is_vendor_path(path) or any(path.is_relative_to(prefix) for prefix in EXCLUDED_PREFIXES):
68 return True
69 return any(part in EXCLUDED_PARTS or part.startswith("build-") for part in path.parts)
70
71
72def layout_error(path: PurePosixPath) -> str | None:
73 """Return the layout error for one repository-relative path, if any."""
74 if not path.parts or path.parts[0] not in SCOPED_ROOTS or is_excluded(path):
75 return None
76 suffix = path.suffix.lower()
77 directory_parts = path.parts[1:-1]
78 if suffix in IMPLEMENTATION_SUFFIXES and "src" not in directory_parts:
79 return "implementation file is not under src/"
80 if suffix in HEADER_SUFFIXES and not ({"inc", "src"} & set(directory_parts)):
81 return "header is not under inc/ or src/"
82 return None
83
84
85def tracked_paths(root: Path) -> list[PurePosixPath]:
86 """Return present tracked and untracked paths without ignored build output."""
87 git = shutil.which("git")
88 if git is None:
89 raise FileNotFoundError
90 result = subprocess.run( # noqa: S603 - resolved absolute git executable
91 [git, "ls-files", "--cached", "--others", "--exclude-standard"],
92 cwd=root,
93 check=True,
94 capture_output=True,
95 text=True,
96 )
97 paths: list[PurePosixPath] = []
98 for line in result.stdout.splitlines():
99 candidate = PurePosixPath(line)
100 if (root / candidate).is_file():
101 paths.append(candidate)
102 return paths
103
104
105def vendor_ownership_errors(existing: set[PurePosixPath]) -> list[str]:
106 """Reject app-owned vendors in the platform tree and removed vendor debris."""
107 errors: list[str] = []
108 for name in sorted(APP_OWNED_VENDORS):
109 platform = PurePosixPath("libs/third_party") / name
110 app = PurePosixPath("apps/shared_libs/third_party") / name
111 if platform in existing:
112 errors.append(f"{platform}: app-owned vendor must live at {app}")
113 if app not in existing:
114 errors.append(f"{app}: required app-owned vendor is missing")
115 return errors
116
117
118def compress_ownership_errors(existing: set[PurePosixPath]) -> list[str]:
119 """Reject the retired ra8_io compression seam and require its app module."""
120 errors = [
121 f"{path}: compression is app-owned; remove this legacy ra8_io path"
122 for path in sorted(LEGACY_COMPRESS_FILES & existing)
123 ]
124 errors.extend(
125 f"{path}: required app-owned compression module file is missing"
126 for path in sorted(APP_COMPRESS_FILES - existing)
127 )
128 return errors
129
130
131def is_python_test_module(path: PurePosixPath) -> bool:
132 """Return whether a Python filename identifies a test module."""
133 return path.stem.startswith("test_") or path.stem.endswith(("_test", "_selftest"))
134
135
136def python_tool_layout_errors(
137 existing: set[PurePosixPath],
138) -> list[tuple[PurePosixPath, str]]:
139 """Reject flat multi-module tools and regressions from an adopted src layout."""
140 root_modules: dict[PurePosixPath, list[PurePosixPath]] = {}
141 module_counts: dict[PurePosixPath, int] = {}
142 src_roots: set[PurePosixPath] = set()
143 errors: list[tuple[PurePosixPath, str]] = []
144 for path in sorted(existing):
145 if (
146 path.suffix != ".py"
147 or len(path.parts) < TOOL_FILE_MIN_PARTS
148 or path.parts[0] != "tools"
149 ):
150 continue
151 if is_excluded(path):
152 continue
153 tool_root = PurePosixPath(*path.parts[:2])
154 module_counts[tool_root] = module_counts.get(tool_root, 0) + 1
155 relative = path.relative_to(tool_root)
156 if len(relative.parts) == 1:
157 root_modules.setdefault(tool_root, []).append(path)
158 elif relative.parts[0] == "src":
159 src_roots.add(tool_root)
160 if is_python_test_module(path):
161 errors.append((path, "Python test module is not under tests/"))
162
163 for tool_root, modules in sorted(root_modules.items()):
164 if tool_root in src_roots:
165 reason = "Python module is at tool root after src/ layout was adopted"
166 elif module_counts[tool_root] > 1:
167 reason = "multi-module Python tool must place production modules under src/"
168 else:
169 continue
170 errors.extend((path, reason) for path in modules)
171 return errors
172
173
174def component_helper_layout_errors(
175 existing: set[PurePosixPath],
176) -> list[tuple[PurePosixPath, str]]:
177 """Reject executable helpers placed directly at app/example component roots.
178
179 A sibling CMakeLists.txt identifies a component root without encoding the
180 repository's varying app/example nesting depths. Build and generation
181 helpers belong under scripts/; verification gates belong under
182 tests/scripts/.
183 """
184 errors: list[tuple[PurePosixPath, str]] = []
185 for path in sorted(existing):
186 if (
187 not path.parts
188 or path.parts[0] not in COMPONENT_ROOTS
189 or path.suffix.lower() not in COMPONENT_HELPER_SUFFIXES
190 or is_excluded(path)
191 ):
192 continue
193 if path.parent / "CMakeLists.txt" in existing:
194 errors.append(
195 (
196 path,
197 "component helper is at the component root; use scripts/ or tests/scripts/",
198 )
199 )
200 return errors
201
202
203def _path_layout_selftest_cases() -> dict[str, bool]:
204 """Return pass/fail results for direct C-family path classification."""
205 expectations = {
206 "examples/board/app/main.c": True,
207 "tests/hal/test_driver.c": True,
208 "tools/widget/widget.h": True,
209 "apps/host/widget/src/main.c": False,
210 "apps/host/widget/src/widget_internal.h": False,
211 "libs/widget/inc/widget.h": False,
212 "tests/hal/src/test_driver.c": False,
213 "tests/hal/inc/test_fixture.h": False,
214 "libs/third_party/vendor.c": False,
215 "apps/shared_libs/third_party/vendor.c": False,
216 "tools/viewer/third_party/vendor/source.c": False,
217 "tools/viewer/src/source.c": False,
218 "apps/shared_libs/widget/widget.c": True,
219 "tools/vela/generated/model.h": False,
220 }
221 return {
222 name: (layout_error(PurePosixPath(name)) is not None) == should_fail
223 for name, should_fail in expectations.items()
224 }
225
226
227def _ownership_selftest_cases() -> dict[str, bool]:
228 """Return vendor and compression ownership selftest results."""
229 correct = {PurePosixPath("apps/shared_libs/third_party") / name for name in APP_OWNED_VENDORS}
230 return {
231 "correct ownership stays quiet": not vendor_ownership_errors(correct),
232 "wrong platform root fires": bool(
233 vendor_ownership_errors(
234 correct - {PurePosixPath("apps/shared_libs/third_party/miniz")}
235 | {PurePosixPath("libs/third_party/miniz")}
236 )
237 ),
238 "app compression seam stays quiet": not compress_ownership_errors(set(APP_COMPRESS_FILES)),
239 "legacy ra8_io compression seam fires": bool(
240 compress_ownership_errors(set(APP_COMPRESS_FILES) | set(LEGACY_COMPRESS_FILES))
241 ),
242 }
243
244
245def _python_tool_selftest_cases() -> dict[str, bool]:
246 """Return flat-versus-structured Python tool layout results."""
247 return {
248 "single-file Python tool stays quiet": not python_tool_layout_errors(
249 {PurePosixPath("tools/solo/runner.py")}
250 ),
251 "data-only tool stays quiet": not python_tool_layout_errors(
252 {PurePosixPath("tools/model/weights.bin")}
253 ),
254 "flat multi-module Python tool fires": bool(
255 python_tool_layout_errors(
256 {
257 PurePosixPath("tools/flat/main.py"),
258 PurePosixPath("tools/flat/helper.py"),
259 }
260 )
261 ),
262 "root module plus nested helper fires": bool(
263 python_tool_layout_errors(
264 {
265 PurePosixPath("tools/nested/main.py"),
266 PurePosixPath("tools/nested/lib/helper.py"),
267 }
268 )
269 ),
270 "structured Python tool stays quiet": not python_tool_layout_errors(
271 {
272 PurePosixPath("tools/structured/src/main.py"),
273 PurePosixPath("tools/structured/src/helper.py"),
274 PurePosixPath("tools/structured/tests/main_selftest.py"),
275 }
276 ),
277 "root module beside src fires": bool(
278 python_tool_layout_errors(
279 {
280 PurePosixPath("tools/structured/src/main.py"),
281 PurePosixPath("tools/structured/helper.py"),
282 }
283 )
284 ),
285 "test module under src fires": bool(
286 python_tool_layout_errors({PurePosixPath("tools/structured/src/main_selftest.py")})
287 ),
288 }
289
290
291def _component_helper_selftest_cases() -> dict[str, bool]:
292 """Return app/example executable-helper layout results."""
293 return {
294 "app-root Python helper fires": bool(
295 component_helper_layout_errors(
296 {
297 PurePosixPath("apps/board/demo/CMakeLists.txt"),
298 PurePosixPath("apps/board/demo/generate.py"),
299 }
300 )
301 ),
302 "example-root shell helper fires": bool(
303 component_helper_layout_errors(
304 {
305 PurePosixPath("examples/board/demo/CMakeLists.txt"),
306 PurePosixPath("examples/board/demo/build_payload.sh"),
307 }
308 )
309 ),
310 "component scripts helper stays quiet": not component_helper_layout_errors(
311 {
312 PurePosixPath("examples/board/demo/CMakeLists.txt"),
313 PurePosixPath("examples/board/demo/scripts/generate.py"),
314 }
315 ),
316 "test gate under tests/scripts stays quiet": not component_helper_layout_errors(
317 {
318 PurePosixPath("apps/board/demo/CMakeLists.txt"),
319 PurePosixPath("apps/board/demo/tests/scripts/emulator_gate.sh"),
320 }
321 ),
322 }
323
324
325def run_selftest() -> int:
326 """Prove both rejection and acceptance directions of the layout rule."""
327 path_cases = _path_layout_selftest_cases()
328 path_failed = [name for name, passed in path_cases.items() if not passed]
329 if path_failed:
330 print(
331 f"check_source_layout.py: selftest failed: {', '.join(path_failed)}",
332 file=sys.stderr,
333 )
334 return 1
335 policy_cases = {
336 **_ownership_selftest_cases(),
337 **_python_tool_selftest_cases(),
338 **_component_helper_selftest_cases(),
339 }
340 policy_failed = [name for name, passed in policy_cases.items() if not passed]
341 if policy_failed:
342 print(
343 f"check_source_layout.py: policy selftest failed: {', '.join(policy_failed)}",
344 file=sys.stderr,
345 )
346 return 1
347 print(f"check_source_layout.py: selftest passed ({len(path_cases) + len(policy_cases)} cases).")
348 return 0
349
350
351def check_tree(root: Path) -> int:
352 """Check every first-party C-family, Python tool, and helper layout."""
353 checked = 0
354 violations: list[tuple[PurePosixPath, str]] = []
355 paths = tracked_paths(root)
356 for path in paths:
357 if path.suffix.lower() not in IMPLEMENTATION_SUFFIXES | HEADER_SUFFIXES:
358 continue
359 if path.parts and path.parts[0] in SCOPED_ROOTS and not is_excluded(path):
360 checked += 1
361 error = layout_error(path)
362 if error is not None:
363 violations.append((path, error))
364 existing_paths = set(paths)
365 vendor_dirs = {
366 PurePosixPath(base, child.name)
367 for base in ("libs/third_party", "apps/shared_libs/third_party")
368 for child in (root / base).iterdir()
369 if child.is_dir()
370 }
371 ownership_errors = vendor_ownership_errors(vendor_dirs) + compress_ownership_errors(
372 existing_paths
373 )
374 python_violations = python_tool_layout_errors(existing_paths)
375 component_helper_violations = component_helper_layout_errors(existing_paths)
376 if violations or ownership_errors or python_violations or component_helper_violations:
377 print("Source layout violations:", file=sys.stderr)
378 for path, error in violations:
379 print(f" {path}: {error}", file=sys.stderr)
380 for error in ownership_errors:
381 print(f" {error}", file=sys.stderr)
382 for path, error in python_violations:
383 print(f" {path}: {error}", file=sys.stderr)
384 for path, error in component_helper_violations:
385 print(f" {path}: {error}", file=sys.stderr)
386 print(
387 "Move code to src/, public C headers to inc/, tests to tests/, "
388 "and component helpers to scripts/ or tests/scripts/.",
389 file=sys.stderr,
390 )
391 return 1
392 print(f"check_source_layout.py: {checked} first-party C-family file(s) follow layout.")
393 return 0
394
395
396def main() -> int:
397 """Parse arguments and run the requested validation mode."""
398 parser = argparse.ArgumentParser(description=__doc__)
399 parser.add_argument("--selftest", action="store_true")
400 args = parser.parse_args()
401 if args.selftest:
402 return run_selftest()
403 return check_tree(Path(__file__).resolve().parents[2])
404
405
406if __name__ == "__main__":
407 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298