ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_core_layering.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 non-negotiable first-party library architecture boundaries.
5
6The checker protects four independent invariants:
7
8* ``ra8_core`` is the foundation and cannot include another library;
9* a library cannot include another library's ``*_internal.h`` contract;
10* Domain code cannot include HAL or board-device contracts;
11* reusable libraries cannot include hosted-OS headers. POSIX, Windows, and
12 similar dependencies belong under ``port/`` and bind through a portable
13 interface instead of leaking into ``libs/``.
14
15The second rule is what keeps implementation seams real. A wrapper backend in
16another module must implement a public, platform-neutral backend contract; it
17cannot compile only because a broad include path exposes a sibling's ``src/``.
18The fourth rule intentionally allows ISO C headers such as ``string.h`` and
19``stdlib.h``. It rejects only hosted I/O, filesystem, threading, socket, and
20process headers whose presence makes a reusable library OS-specific.
21
22Run::
23
24 check_core_layering.py # scan every first-party library
25 check_core_layering.py path/to/file.c # scan listed library files
26 check_core_layering.py --selftest # prove every rule fires/stays quiet
27
28Exit 0 on a clean scan, 1 on a boundary violation, and 2 when a full sweep or
29the header-ownership oracle collapses below its non-vacuity floor.
30"""
31
32from __future__ import annotations
33
34import re
35import sys
36from collections.abc import Iterable
37from dataclasses import dataclass
38from pathlib import Path
39
40sys.path.insert(0, str(Path(__file__).resolve().parent))
41
42from lint_targets import is_build_output_path
43from selftest_assert import expect, report
44
45REPO_ROOT = Path(__file__).resolve().parents[2]
46LIBS_ROOT = REPO_ROOT / "libs"
47
48FOUNDATION_LIB = "ra8_core"
49SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp")
50INCLUDE_RE = re.compile(r'#\s*include\s*([<"])([^">]+)[">]')
51EXCLUDE_FRAGMENTS = ("/third_party/", "/ra8_fonts/")
52INTERNAL_HEADER_SUFFIX = "_internal.h"
53DOMAIN_TAG = "[Ring 4 / Domain]"
54
55# Hosted implementation details that never belong in a reusable library.
56# ISO C headers remain legal; the port adapters themselves live outside libs/.
57HOSTED_HEADERS = frozenset(
58 {
59 "arpa/inet.h",
60 "dirent.h",
61 "dlfcn.h",
62 "fcntl.h",
63 "glob.h",
64 "mach/mach.h",
65 "netdb.h",
66 "poll.h",
67 "pthread.h",
68 "pwd.h",
69 "semaphore.h",
70 "spawn.h",
71 "stdio.h",
72 "sys/mman.h",
73 "sys/socket.h",
74 "sys/stat.h",
75 "sys/statvfs.h",
76 "sys/syscall.h",
77 "sys/types.h",
78 "sys/wait.h",
79 "syslog.h",
80 "unistd.h",
81 "windows.h",
82 "winsock2.h",
83 }
84)
85HOSTED_HEADER_PREFIXES = ("arpa/", "mach/", "netinet/", "sys/", "windows/")
86
87
88def _is_device_module(module: str) -> bool:
89 """Whether ``module`` owns a hardware- or board-specific contract."""
90 return module == "ra8_hal" or module.startswith("ra8_board_")
91
92
93# A library header is ``libs/<module>/<inc|src>/...``.
94_MIN_LIB_PATH_PARTS = 2
95
96# Measured 2026-08-14: more than 850 first-party C/C++ files under libs/ after
97# excluding SOUP/generated trees. A floor makes an empty or narrowed scan fatal.
98FILE_FLOOR = 700
99
100# Measured 2026-08-14: more than 425 distinct first-party library header names.
101# Every ownership decision depends on this oracle, so it has its own floor.
102OWNER_FLOOR = 340
103
104# Program name plus the one accepted selftest option.
105SELFTEST_ARG_COUNT = 2
106
107
108def _excluded(path: Path) -> bool:
109 """Whether ``path`` is build output, SOUP, or generated font data."""
110 text = str(path).replace("\\", "/")
111 return is_build_output_path(text) or any(fragment in text for fragment in EXCLUDE_FRAGMENTS)
112
113
114def _header_owners() -> dict[str, set[str]]:
115 """Map each first-party library header basename to its owning module(s)."""
116 owners: dict[str, set[str]] = {}
117 for header in LIBS_ROOT.rglob("*.h"):
118 if _excluded(header):
119 continue
120 rel = header.relative_to(LIBS_ROOT).parts
121 if len(rel) < _MIN_LIB_PATH_PARTS or rel[1] not in ("inc", "src"):
122 continue
123 owners.setdefault(header.name, set()).add(rel[0])
124 return owners
125
126
127def _is_source(path: Path) -> bool:
128 """Whether ``path`` has a first-party C/C++ source/header suffix."""
129 return path.suffix in SOURCE_SUFFIXES
130
131
132def _rel(path: Path) -> str:
133 """Return a stable repository-relative display path where possible."""
134 if path.is_relative_to(REPO_ROOT):
135 return str(path.relative_to(REPO_ROOT))
136 return str(path)
137
138
139def _module_for(path: Path) -> str | None:
140 """Return the immediate module beneath ``libs/``, or None outside it."""
141 if not path.is_relative_to(LIBS_ROOT):
142 return None
143 rel = path.relative_to(LIBS_ROOT).parts
144 return rel[0] if rel else None
145
146
147def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
148 """Expand explicit paths, or enumerate every first-party library file."""
149 args = list(arg_paths)
150 if args:
151 candidates: list[Path] = []
152 for raw in args:
153 path = Path(raw)
154 if not path.is_absolute():
155 path = REPO_ROOT / path
156 if path.is_dir():
157 for suffix in SOURCE_SUFFIXES:
158 candidates.extend(path.rglob("*" + suffix))
159 elif _is_source(path):
160 candidates.append(path)
161 else:
162 candidates = []
163 for suffix in SOURCE_SUFFIXES:
164 candidates.extend(LIBS_ROOT.rglob("*" + suffix))
165 return sorted(
166 path for path in candidates if path.is_relative_to(LIBS_ROOT) and not _excluded(path)
167 )
168
169
170def _is_hosted_header(name: str) -> bool:
171 """Whether an angle-bracket include names a hosted-OS implementation API."""
172 folded = name.lower()
173 return folded in HOSTED_HEADERS or folded.startswith(HOSTED_HEADER_PREFIXES)
174
175
176def _scan_text(
177 text: str,
178 rel_path: str,
179 module: str,
180 owners: dict[str, set[str]],
181) -> list[tuple[str, str, int, str, tuple[str, ...]]]:
182 """Return architecture findings from one module-owned translation unit."""
183 findings: list[tuple[str, str, int, str, tuple[str, ...]]] = []
184 is_domain = DOMAIN_TAG in text
185 for lineno, line in enumerate(text.splitlines(), 1):
186 match = INCLUDE_RE.search(line)
187 if match is None:
188 continue
189 opener, included = match.groups()
190 name = Path(included).name
191 modules = owners.get(name, set())
192 other_owners = modules - {module}
193
194 if module == FOUNDATION_LIB and other_owners and FOUNDATION_LIB not in modules:
195 findings.append(
196 ("CORE_UPWARD", rel_path, lineno, included, tuple(sorted(other_owners)))
197 )
198
199 if name.endswith(INTERNAL_HEADER_SUFFIX) and other_owners and module not in modules:
200 findings.append(
201 ("CROSS_INTERNAL", rel_path, lineno, included, tuple(sorted(other_owners)))
202 )
203
204 device_owners = tuple(sorted(owner for owner in other_owners if _is_device_module(owner)))
205 if is_domain and device_owners:
206 findings.append(("DOMAIN_DEVICE", rel_path, lineno, included, device_owners))
207
208 if opener == "<" and _is_hosted_header(included):
209 findings.append(("HOSTED_LIB", rel_path, lineno, included, ()))
210 return findings
211
212
213def _scan_targets(
214 targets: Iterable[Path], owners: dict[str, set[str]]
215) -> list[tuple[str, str, int, str, tuple[str, ...]]]:
216 """Read and scan every target, reporting unreadable files as findings."""
217 findings: list[tuple[str, str, int, str, tuple[str, ...]]] = []
218 for path in targets:
219 module = _module_for(path)
220 if module is None:
221 continue
222 try:
223 source = path.read_text(encoding="utf-8", errors="replace")
224 except OSError:
225 findings.append(("READ_ERROR", _rel(path), 0, "unreadable source", ()))
226 continue
227 findings.extend(_scan_text(source, _rel(path), module, owners))
228 return findings
229
230
231@dataclass(frozen=True)
232class _SelftestCase:
233 """One synthetic source and its expected self-test label."""
234
235 source: str
236 rel_path: str
237 module: str
238 label: str
239
240
241def _expect_rule(
242 rule: str, case: _SelftestCase, owners: dict[str, set[str]], failures: list[str]
243) -> None:
244 """Require one synthetic include to trigger the selected rule."""
245 findings = _scan_text(case.source, case.rel_path, case.module, owners)
246 expect(any(finding[0] == rule for finding in findings), case.label, failures)
247
248
249def _expect_portable(
250 case: _SelftestCase,
251 owners: dict[str, set[str]],
252 failures: list[str],
253) -> None:
254 """Require one synthetic portable include set to remain finding-free."""
255 expect(not _scan_text(case.source, case.rel_path, case.module, owners), case.label, failures)
256
257
258def _selftest_violations(owners: dict[str, set[str]], failures: list[str]) -> None:
259 """Prove each forbidden dependency class is detected."""
260 _expect_rule(
261 "CORE_UPWARD",
262 _SelftestCase(
263 '#include "ra8_gpio.h"\n',
264 "libs/ra8_core/x.c",
265 "ra8_core",
266 "ra8_core upward dependency fires",
267 ),
268 owners,
269 failures,
270 )
271 _expect_rule(
272 "CROSS_INTERNAL",
273 _SelftestCase(
274 '#include "ra8_io_private_internal.h"\n',
275 "libs/ra8_ftl/x.c",
276 "ra8_ftl",
277 "cross-library internal header fires",
278 ),
279 owners,
280 failures,
281 )
282 _expect_rule(
283 "DOMAIN_DEVICE",
284 _SelftestCase(
285 '[Ring 4 / Domain]\n#include "ra8_gpio.h"\n',
286 "apps/shared_libs/book/x.c",
287 "book",
288 "Domain dependency on a device contract fires",
289 ),
290 owners,
291 failures,
292 )
293 _expect_rule(
294 "HOSTED_LIB",
295 _SelftestCase(
296 "#include <dirent.h>\n",
297 "apps/shared_libs/book/x.c",
298 "book",
299 "hosted filesystem header in a library fires",
300 ),
301 owners,
302 failures,
303 )
304
305
306def _selftest_portable(owners: dict[str, set[str]], failures: list[str]) -> None:
307 """Prove portable and same-module dependencies remain accepted."""
308 _expect_portable(
309 _SelftestCase(
310 '#include <string.h>\n#include "ra8_err.h"\n',
311 "apps/shared_libs/book/x.c",
312 "book",
313 "portable ISO C and public lower-layer includes stay quiet",
314 ),
315 owners,
316 failures,
317 )
318 _expect_portable(
319 _SelftestCase(
320 '#include "ra8_io_private_internal.h"\n',
321 "libs/ra8_io/x.c",
322 "ra8_io",
323 "same-module internal header stays quiet",
324 ),
325 owners,
326 failures,
327 )
328 _expect_portable(
329 _SelftestCase(
330 '[Ring 4 / Domain]\n#include "ra8_err.h"\n',
331 "apps/shared_libs/book/x.c",
332 "book",
333 "Domain dependency on a portable lower contract stays quiet",
334 ),
335 owners,
336 failures,
337 )
338
339
340def _selftest() -> int:
341 """Prove all four rules fire and portable/same-module includes stay quiet."""
342 print("check_core_layering.py --selftest")
343 failures: list[str] = []
344 owners = {
345 "ra8_err.h": {"ra8_core"},
346 "ra8_gpio.h": {"ra8_hal"},
347 "ra8_io_private_internal.h": {"ra8_io"},
348 }
349 _selftest_violations(owners, failures)
350 _selftest_portable(owners, failures)
351 return report(failures)
352
353
354def _report(findings: list[tuple[str, str, int, str, tuple[str, ...]]]) -> None:
355 """Print actionable diagnostics grouped by stable rule key."""
356 print(f"check_core_layering.py: {len(findings)} architecture violation(s):\n", file=sys.stderr)
357 for rule, path, lineno, included, owners in findings:
358 location = f"{path}:{lineno}" if lineno > 0 else path
359 suffix = f" (owned by {', '.join(owners)})" if owners else ""
360 print(f" [{rule}] {location} includes {included}{suffix}", file=sys.stderr)
361 print(
362 "\nCORE_UPWARD: move the dependency above ra8_core or move a genuinely\n"
363 "shared contract into the foundation. CROSS_INTERNAL: publish a narrow\n"
364 "backend/adapter contract under the owner library's inc/ directory.\n"
365 "DOMAIN_DEVICE: invert the device dependency behind a portable interface\n"
366 "and bind it in the application composition root.\n"
367 "HOSTED_LIB: move the OS operation under port/ and inject it through a\n"
368 "platform-neutral interface. Do not add an include path or local shim\n"
369 "that hides the same dependency.",
370 file=sys.stderr,
371 )
372
373
374def main(argv: list[str]) -> int:
375 """Scan first-party libraries, or run the detector's bidirectional selftest."""
376 if len(argv) == SELFTEST_ARG_COUNT and argv[1] == "--selftest":
377 return _selftest()
378 if "--selftest" in argv[1:]:
379 print("check_core_layering.py: --selftest accepts no paths", file=sys.stderr)
380 return 2
381
382 paths = argv[1:]
383 targets = _enumerate_targets(paths)
384 if not paths and len(targets) < FILE_FLOOR:
385 print(
386 f"check_core_layering.py: FATAL -- only {len(targets)} library file(s) "
387 f"in scope, floor is {FILE_FLOOR}. A collapsed sweep is not clean.",
388 file=sys.stderr,
389 )
390 return 2
391 if not targets:
392 print("check_core_layering.py: no library files to scan", file=sys.stderr)
393 return 0
394
395 owners = _header_owners()
396 if len(owners) < OWNER_FLOOR:
397 print(
398 f"check_core_layering.py: FATAL -- header ownership map holds only "
399 f"{len(owners)} header(s), floor is {OWNER_FLOOR}. A collapsed oracle "
400 "cannot establish layering.",
401 file=sys.stderr,
402 )
403 return 2
404
405 findings = _scan_targets(targets, owners)
406 if findings:
407 _report(findings)
408 else:
409 print(
410 f"check_core_layering.py: {len(targets)} library file(s) scanned; "
411 "foundation, module privacy, Domain/device, and hosted-API boundaries are clean."
412 )
413 return int(bool(findings))
414
415
416if __name__ == "__main__":
417 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298