ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_tier_imports.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"""Gate: the tier dependency arrow points one way, and nothing may reverse it.
5
6The tree has three tiers (#718), and the products tier has an internal layer of
7its own:
8
9* PLATFORM -- ``libs/``, ``port/``, ``tools/``. General-purpose,
10 reusable, knowing nothing about any one product.
11* PRODUCTS -- ``apps/``, split by FORM. ``apps/host/`` holds hosted products,
12 ``apps/board/`` holds firmware products, and ``apps/shared_libs/`` holds the
13 portable product-tier code both forms consume.
14* CONSUMERS -- ``examples/`` and ``tests/``, which exist to demonstrate and to
15 compile the other two and therefore legitimately name any path in the tree.
16
17Two rules follow from that shape, and this gate is both of them:
18
19**Rule 1 -- the platform never imports a product.** Nothing under a platform
20root may ``#include`` a header from ``apps/`` or name an ``apps/`` path in a
21CMake source list or include directory. Any category: a platform file reaching
22into ``apps/shared_libs/`` is the same defect as one reaching into
23``apps/board/``. The moment it does, the platform has stopped being
24general-purpose and the tier boundary is a comment rather than a fact.
25
26**Rule 2 -- shared product code never imports a product FORM.**
27``apps/shared_libs/`` sits below ``apps/host/`` and ``apps/board/``:
28the forms consume shared freely, and shared must not reach back up. Shared code
29that knows which form is compiling it is not shared, it is a copy of one form
30with a switch in it.
31
32Both rules are the same shape -- a layer, and the region it may not reach into
33-- so both are expressed as one ``Layer`` row and scanned by one scanner. That
34model lives in ``tier_layers.py``: the layers, their roots, the populations the
35non-vacuity floors are asserted on, and the exclusive-basename census. This
36file is the scanner and the gate entry point. The split is the reason the rule
37set is keyed on CATEGORY DIRECTORY NAMES rather than on a file list -- a
38product moving between categories is a change to the model and to nothing here,
39so the gate keeps working across a layout change.
40
41``tier_layers.py`` carries no selftest of its own by design: it is a model, not
42a detector, and every predicate and floor in it is proved in both directions
43below, through the same entry point CI drives.
44
45TWO HALVES, BECAUSE THERE ARE TWO WAYS IN
46-----------------------------------------
47
48* **Includes.** A C/C++ file must not ``#include`` a header from a forbidden
49 region.
50* **CMake.** A listfile must not name a forbidden region's path in a source
51 list, an include directory, or anywhere else -- compiling another layer's
52 translation unit into your target is the same coupling in another language.
53
54PRECISION VERSUS RECALL
55-----------------------
56
57Calibrated for ZERO false positives on the current tree, because a boundary
58gate that cries wolf gets bypassed and then the boundary is gone again. Three
59deliberate choices buy that:
60
611. **Comments are stripped first, in both languages.** A naive ``grep`` for
62 ``apps/`` over the platform listfiles reports three hits today
63 (``tools/rabook_imagepack/CMakeLists.txt``, ``cmake/ra8_app/sources.cmake``,
64 ``cmake/ra8_webp_vendor.cmake``) and all three are PROSE -- comments
65 explaining which producers exist and which two files once faked a WebP
66 symbol. Those are exactly the false positives that would sink the gate, so
67 ``#`` and ``#[[ ]]`` comments in CMake, and ``//`` and block comments in C,
68 are removed before matching.
692. **The include directive is anchored to the start of its line.** A C string
70 literal containing the text of an include (a code generator emitting C)
71 cannot start a line with ``#``, so the anchor costs no real recall and
72 removes a whole false-positive class without needing a full C lexer.
733. **The bare-name rule is keyed on an EXCLUSIVE basename census.** A file that
74 writes ``#include "mdl_cache.h"`` names no directory, yet it can only ever
75 resolve to a product header. The gate therefore builds a repo-wide
76 header-basename census first and flags a bare include only when its basename
77 exists inside the forbidden region and NOWHERE else in the tree. Measured
78 2026-08-17 for Rule 1: 52 headers under ``apps/``, and the intersection with
79 the basenames of every other header in the repository -- first-party,
80 vendored SOUP and generated alike -- is EMPTY, so every one of the 52 is
81 unambiguous.
82
83The recall this gives up is stated rather than hidden:
84
85* A bare include whose basename ALSO exists outside the forbidden region is not
86 flagged. Under every include-directory ordering this tree's builds use, such
87 an include resolves to the legal copy, so flagging it would be a guess about
88 the build rather than a fact about the source.
89* Include search paths are not replayed per translation unit. Doing that would
90 make the gate depend on a configured compile database, and therefore on a
91 build succeeding, which is precisely how a checker ends up silently scoped to
92 whatever last configured.
93* Rule 2's bare-name half is only as strong as the forms' exclusive census, and
94 that census is legitimately allowed to be EMPTY -- ``apps/shared_libs/`` may hold
95 every header while a form is a single ``main.c``. Rule 2's literal-path and
96 CMake halves do not depend on the census and are always live, which is why
97 the non-vacuity floors below apply to the tier populations rather than to
98 this one derived set.
99
100What is caught with no ambiguity at all is the literal form: any include whose
101path carries the forbidden region's DIRECTORY components -- ``apps/...``,
102``../../apps/...``, ``apps/board/...`` -- fires regardless of the census.
103No platform path in this tree contains a component named ``apps`` (measured:
104zero), so that rule cannot misfire either.
105
106THE CMAKE EXEMPTION IS ENUMERATED, NOT WILDCARDED
107-------------------------------------------------
108
109Orchestration has to be able to name a product: something must eventually
110``add_subdirectory()`` one. That permission is granted to an explicit list of
111exact paths (``ORCHESTRATION_EXEMPT``), never to a pattern, and even inside
112those files it is narrow: only an ``add_subdirectory`` line may name a
113forbidden region. A source list or an include directory in an exempt file still
114fails, because "this file is allowed to know a product exists" is a different
115claim from "this file is allowed to compile one".
116
117Run::
118
119 check_tier_imports.py --all # the gate: sweep every ruled layer
120 check_tier_imports.py FILE ... # scan named files
121 check_tier_imports.py --selftest # prove it in both directions
122
123Exit 0 when clean, 1 on a tier violation, 2 when the scan itself collapsed.
124"""
125
126from __future__ import annotations
127
128import argparse
129import re
130import subprocess
131import sys
132import tempfile
133from collections.abc import Iterable
134from pathlib import Path
135
136sys.path.insert(0, str(Path(__file__).resolve().parent))
137
138from selftest_assert import expect, report
139from tier_layers import (
140 APPS_C_FILE_FLOOR,
141 APPS_HEADER_FLOOR,
142 C_ROOT_FILE_FLOORS,
143 CMAKE_REGION_RES,
144 CMAKE_TOTAL_FILE_FLOOR,
145 EXEMPT_CONSUMER_ROOTS,
146 FORM_CATEGORIES,
147 ORCHESTRATION_EXEMPT,
148 PLATFORM_C_ROOTS,
149 PLATFORM_LAYER_NAME,
150 PRODUCTS_ROOT,
151 REPO_ROOT,
152 SHARED_CATEGORY,
153 SHARED_LAYER_NAME,
154 Census,
155 Layer,
156 build_exclusive,
157 census_floor_errors,
158 layer_for_c,
159 layer_for_cmake,
160 measure,
161 normalize_rel,
162 region_parts,
163)
164
165# A C preprocessor include, anchored to the start of its (comment-stripped)
166# line. See "PRECISION VERSUS RECALL" for why the anchor is load-bearing.
167INCLUDE_RE = re.compile(r'^\s*#\s*include\s*([<"])([^>"]+)[>"]')
168
169ADD_SUBDIRECTORY_RE = re.compile(r"\badd_subdirectory\s*\‍(")
170
171KIND_PATH = "PATH"
172KIND_HEADER = "HEADER"
173KIND_CMAKE = "CMAKE"
174
175EXIT_OK = 0
176EXIT_VIOLATION = 1
177EXIT_VACUOUS = 2
178
179# (layer, kind, rel, line number, what was named, the offending source line)
180Finding = tuple[str, str, str, int, str, str]
181
182# (path on disk, repo-relative display path)
183Target = tuple[Path, str]
184
185
186def strip_c_comments(text: str) -> list[str]:
187 """Return `text`'s lines with `//` and `/* */` comment content removed.
188
189 String literals are left intact -- unlike the shared ``blank_noncode``
190 lexer, which blanks them and would erase the very ``"header.h"`` operand
191 this gate reads.
192
193 Args:
194 text: Whole C/C++ translation unit or header.
195
196 Returns:
197 One string per input line, comment bytes dropped, so line numbers still
198 index the original file.
199 """
200 lines: list[str] = []
201 in_block = False
202 for raw in text.splitlines():
203 kept: list[str] = []
204 index = 0
205 while index < len(raw):
206 pair = raw[index : index + 2]
207 if in_block:
208 in_block = pair != "*/"
209 index += 1 if in_block else 2
210 elif pair == "/*":
211 in_block = True
212 index += 2
213 elif pair == "//":
214 break
215 else:
216 kept.append(raw[index])
217 index += 1
218 lines.append("".join(kept))
219 return lines
220
221
222def _first_hash_outside_quotes(line: str) -> int:
223 """Return the index of the first `#` not inside a double-quoted string.
224
225 Args:
226 line: One CMake source line, bracket comments already removed.
227
228 Returns:
229 The index, or -1 when the line carries no line comment.
230 """
231 in_quote = False
232 escaped = False
233 for index, char in enumerate(line):
234 if escaped:
235 escaped = False
236 elif char == "\\":
237 escaped = True
238 elif char == '"':
239 in_quote = not in_quote
240 elif char == "#" and not in_quote:
241 return index
242 return -1
243
244
245def strip_cmake_comments(text: str) -> list[str]:
246 """Return `text`'s lines with CMake line and bracket comments removed.
247
248 Args:
249 text: Whole ``CMakeLists.txt`` or ``*.cmake`` file.
250
251 Returns:
252 One string per input line, so line numbers still index the original.
253 """
254 lines: list[str] = []
255 in_bracket = False
256 for raw in text.splitlines():
257 line = raw
258 if in_bracket:
259 end = line.find("]]")
260 if end < 0:
261 lines.append("")
262 continue
263 line = line[end + 2 :]
264 in_bracket = False
265 start = line.find("#[[")
266 if start >= 0:
267 end = line.find("]]", start + 3)
268 if end < 0:
269 in_bracket = True
270 line = line[:start]
271 else:
272 line = line[:start] + line[end + 2 :]
273 hash_index = _first_hash_outside_quotes(line)
274 lines.append(line if hash_index < 0 else line[:hash_index])
275 return lines
276
277
278def classify_include(
279 target: str, forbidden: tuple[str, ...], exclusive: frozenset[str]
280) -> tuple[str, str] | None:
281 """Classify one include operand against a layer's forbidden regions.
282
283 Args:
284 target: The text between the quotes or angle brackets.
285 forbidden: Region prefixes the including layer may not reach into.
286 exclusive: Header basenames that exist inside those regions and nowhere
287 else in the repository.
288
289 Returns:
290 ``(kind, what was named)`` for a violation, or None when the include is
291 legal. ``PATH`` means the operand spells the region's directory
292 components; ``HEADER`` means its basename can only be a header from it.
293 """
294 parts = [part for part in target.replace("\\", "/").split("/") if part not in ("", ".")]
295 while parts and parts[0] == "..":
296 parts.pop(0)
297 if not parts:
298 return None
299 for prefix in forbidden:
300 region = region_parts(prefix)
301 span = len(region)
302 for start in range(len(parts) - span):
303 if tuple(parts[start : start + span]) == region:
304 return (KIND_PATH, prefix + "/".join(parts[start + span :]))
305 if parts[-1] in exclusive:
306 return (KIND_HEADER, parts[-1])
307 return None
308
309
310def scan_c_text(text: str, rel: str, layer: Layer, exclusive: frozenset[str]) -> list[Finding]:
311 """Return every cross-layer include in one C-family file.
312
313 Args:
314 text: File contents.
315 rel: Repo-relative display path.
316 layer: The ruled layer that owns the file.
317 exclusive: Exclusive header basenames for that layer's forbidden
318 regions.
319
320 Returns:
321 One finding per offending ``#include`` line.
322 """
323 findings: list[Finding] = []
324 for lineno, line in enumerate(strip_c_comments(text), 1):
325 match = INCLUDE_RE.match(line)
326 if match is None:
327 continue
328 verdict = classify_include(match.group(2).strip(), layer.forbidden, exclusive)
329 if verdict is not None:
330 kind, named = verdict
331 findings.append((layer.name, kind, rel, lineno, named, line.strip()))
332 return findings
333
334
335def scan_cmake_text(text: str, rel: str, layer: Layer) -> list[Finding]:
336 """Return every cross-layer path reference in one CMake listfile.
337
338 Args:
339 text: File contents.
340 rel: Repo-relative display path; decides orchestration exemption.
341 layer: The ruled layer that owns the listfile.
342
343 Returns:
344 One finding per offending line. In an ``ORCHESTRATION_EXEMPT`` listfile
345 an ``add_subdirectory`` line is allowed and everything else still
346 fires.
347 """
348 exempt = normalize_rel(rel) in ORCHESTRATION_EXEMPT
349 findings: list[Finding] = []
350 for lineno, line in enumerate(strip_cmake_comments(text), 1):
351 for prefix in layer.forbidden:
352 match = CMAKE_REGION_RES[prefix].search(line)
353 if match is None:
354 continue
355 if exempt and ADD_SUBDIRECTORY_RE.search(line):
356 continue
357 named = line[match.start() :].split()[0].rstrip(")\"'")
358 findings.append((layer.name, KIND_CMAKE, rel, lineno, named, line.strip()))
359 break
360 return findings
361
362
363def _git_working_tree() -> list[str]:
364 """Enumerate tracked and newly-added (non-ignored) paths.
365
366 Returns:
367 Repo-relative paths, so a brand-new file is judged the moment it is
368 written rather than the moment it is committed.
369
370 Raises:
371 SystemExit: When git cannot enumerate the working tree; a gate that
372 cannot see the tree must fail, never report clean.
373 """
374 proc = subprocess.run(
375 ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], # noqa: S607 -- fixed repository Git census
376 cwd=REPO_ROOT,
377 capture_output=True,
378 text=True,
379 check=False,
380 )
381 if proc.returncode != 0:
382 sys.stderr.write(proc.stderr)
383 sys.stderr.write("check_tier_imports.py: FATAL -- git working-tree enumeration failed\n")
384 raise SystemExit(EXIT_VACUOUS)
385 return [rel for rel in proc.stdout.split("\0") if rel]
386
387
388def _working_scope() -> tuple[list[Target], list[Target], dict[str, frozenset[str]]]:
389 """Enumerate every ruled layer and prove the enumeration is not vacuous.
390
391 Returns:
392 The C-family targets, the CMake targets, and each layer's exclusive
393 header-basename census.
394
395 Raises:
396 SystemExit: When any non-vacuity floor is violated.
397 """
398 rels = _git_working_tree()
399 errors = census_floor_errors(measure(rels))
400 if errors:
401 sys.stderr.write("check_tier_imports.py: FATAL -- " + "; ".join(errors) + "\n")
402 raise SystemExit(EXIT_VACUOUS)
403 ordered = sorted(rels)
404 c_targets = [
405 (REPO_ROOT / rel, normalize_rel(rel)) for rel in ordered if layer_for_c(rel) is not None
406 ]
407 cmake_targets = [
408 (REPO_ROOT / rel, normalize_rel(rel)) for rel in ordered if layer_for_cmake(rel) is not None
409 ]
410 return (
411 [target for target in c_targets if target[0].is_file()],
412 [target for target in cmake_targets if target[0].is_file()],
413 build_exclusive(rels),
414 )
415
416
417def _explicit_scope(raw_paths: Iterable[str]) -> tuple[list[Target], list[Target]]:
418 """Filter caller-named files through the same layer-ownership policy.
419
420 Args:
421 raw_paths: Paths from argv.
422
423 Returns:
424 The in-scope C-family targets and CMake targets.
425 """
426 c_targets: list[Target] = []
427 cmake_targets: list[Target] = []
428 for raw in raw_paths:
429 path = Path(raw)
430 absolute = path if path.is_absolute() else REPO_ROOT / path
431 try:
432 rel = absolute.resolve().relative_to(REPO_ROOT).as_posix()
433 except ValueError:
434 continue
435 if not absolute.is_file():
436 continue
437 if layer_for_c(rel) is not None:
438 c_targets.append((absolute, rel))
439 elif layer_for_cmake(rel) is not None:
440 cmake_targets.append((absolute, rel))
441 return sorted(set(c_targets)), sorted(set(cmake_targets))
442
443
444def scan_targets(
445 c_targets: Iterable[Target],
446 cmake_targets: Iterable[Target],
447 exclusive: dict[str, frozenset[str]],
448) -> tuple[int, list[Finding]]:
449 """Scan both halves of every ruled layer.
450
451 This is the one scanning entry point ``--all``, an explicit file list and
452 ``--selftest`` all drive, so the selftest cannot prove a code path CI does
453 not run. Layer ownership is decided HERE, so handing it a file from an
454 unruled layer (a product form, an example, a test) is a no-op -- which is
455 what makes "a form including a shared header stays quiet" a property of the
456 scanner rather than of the caller's filtering.
457
458 Args:
459 c_targets: Candidate C-family files.
460 cmake_targets: Candidate CMake listfiles.
461 exclusive: Per-layer exclusive header-basename census.
462
463 Returns:
464 The number of files actually scanned and every finding, in scan order.
465 """
466 findings: list[Finding] = []
467 scanned = 0
468 for path, rel in c_targets:
469 layer = layer_for_c(rel)
470 if layer is None:
471 continue
472 text = path.read_text(encoding="utf-8", errors="replace")
473 findings.extend(scan_c_text(text, rel, layer, exclusive.get(layer.name, frozenset())))
474 scanned += 1
475 for path, rel in cmake_targets:
476 layer = layer_for_cmake(rel)
477 if layer is None:
478 continue
479 text = path.read_text(encoding="utf-8", errors="replace")
480 findings.extend(scan_cmake_text(text, rel, layer))
481 scanned += 1
482 return scanned, findings
483
484
485def _write(root: Path, rel: str, text: str) -> Target:
486 """Materialise one selftest fixture and return it as a scan target.
487
488 Args:
489 root: Temporary directory standing in for the repository root.
490 rel: Repo-relative path the fixture pretends to occupy.
491 text: File contents.
492
493 Returns:
494 The ``(path, rel)`` pair ``scan_targets`` consumes.
495 """
496 path = root / rel
497 path.parent.mkdir(parents=True, exist_ok=True)
498 path.write_text(text, encoding="utf-8")
499 return (path, rel)
500
501
502# Rule 1 -- a platform file reaching into any products category.
503_FIRE_C_RULE1 = {
504 "libs/ra8_tier/src/literal.c": '#include "apps/host/mdl/inc/mdl_cli.h"\n',
505 "libs/ra8_tier/src/shared.c": '#include "apps/board/ereader/inc/ereader.h"\n',
506 "libs/ra8_tier/src/relative.c": '#include "../../../apps/board/thing/inc/mdl_cache.h"\n',
507 "libs/ra8_tier/src/bare.c": '#include "mdl_cli.h"\n',
508 "tools/tier_tool/src/angle.c": "#include <mdl_cli.h>\n",
509 "port/posix/src/nested.c": '#include "apps/host/mdl/inc/mdl_cli.h"\n',
510 "libs/ra8_secure_app/src/deep.c": '#include "apps/board/dl/inc/dl.h"\n',
511}
512
513# Rule 2 -- shared product code reaching up into a product FORM.
514_FIRE_C_RULE2 = {
515 "apps/shared_libs/mdl/src/up_standalone.c": ('#include "apps/host/mdl/inc/mdl_cli.h"\n'),
516 "apps/shared_libs/mdl/src/up_threadx.c": (
517 '#include "apps/board/threadx_modules/downloader/inc/dl_module.h"\n'
518 ),
519 "apps/shared_libs/mdl/src/up_bare.c": '#include "mdl_cli.h"\n',
520}
521
522_FIRE_CMAKE = {
523 "libs/ra8_tier/CMakeLists.txt": (
524 "target_sources(ra8_tier PRIVATE\n ${FW_ROOT}/apps/host/mdl/src/mdl_cli.c)\n"
525 ),
526 "cmake/tier.cmake": ("target_include_directories(t PRIVATE ${FW_ROOT}/apps/host/mdl/inc)\n"),
527 # An exempt orchestrator may add_subdirectory a product -- but COMPILING one
528 # is a different claim, and it still fires.
529 "CMakeLists.txt": "target_sources(all PRIVATE apps/host/mdl/src/main.c)\n",
530 "apps/shared_libs/mdl/CMakeLists.txt": (
531 "target_sources(mdl_core PRIVATE ${FW_ROOT}/apps/host/mdl/src/mdl_cli.c)\n"
532 ),
533}
534
535
536def _selftest_must_fire(
537 root: Path, exclusive: dict[str, frozenset[str]], failures: list[str]
538) -> None:
539 """Prove every violation shape, for both rules, reaches the real scanner.
540
541 Args:
542 root: Temporary fixture root.
543 exclusive: Census the fixtures are written against.
544 failures: Accumulator from ``selftest_assert``.
545 """
546 for label, fixtures in (("rule 1", _FIRE_C_RULE1), ("rule 2", _FIRE_C_RULE2)):
547 for rel, text in fixtures.items():
548 _, found = scan_targets([_write(root, rel, text)], [], exclusive)
549 expect(len(found) == 1, f"{label} include fires: {rel}", failures)
550 for rel, text in _FIRE_CMAKE.items():
551 _, found = scan_targets([], [_write(root, rel, text)], exclusive)
552 expect(len(found) == 1, f"listfile fires: {rel}", failures)
553
554
555_QUIET_C = {
556 "libs/ra8_tier/src/legal.c": (
557 '#include "ra8_attributes.h"\n'
558 '#include "ra8_check.h"\n'
559 "#include <stdint.h>\n"
560 '/* #include "apps/host/mdl/inc/mdl_cache.h" -- not compiled */\n'
561 '// #include "mdl_cache.h"\n'
562 'static const char *k_help = "#include \\"mdl_cache.h\\"";\n'
563 'static const char *k_path = "apps/host/mdl";\n'
564 ),
565 "libs/ra8_tier/src/lookalike.c": '#include "myapps/thing.h"\n#include "ra8_apps_registry.h"\n',
566 # Shared consuming the platform and its own category is the whole point.
567 "apps/shared_libs/mdl/src/legal.c": (
568 '#include "ra8_check.h"\n'
569 '#include "apps/shared_libs/mdl/inc/mdl_cache.h"\n'
570 '#include "mdl_cache.h"\n'
571 "#include <stdint.h>\n"
572 ),
573 # A product FORM consuming shared -- the arrow's legal direction. It is not
574 # a ruled layer at all, which scan_targets decides for itself.
575 "apps/host/mdl/src/main.c": (
576 '#include "apps/shared_libs/mdl/inc/mdl_cache.h"\n#include "mdl_cache.h"\n'
577 ),
578 "apps/board/threadx_modules/downloader/src/mod.c": (
579 '#include "apps/shared_libs/mdl/inc/mdl_cache.h"\n'
580 ),
581}
582
583_QUIET_CMAKE = {
584 "tools/rabook_imagepack/CMakeLists.txt": (
585 "# The single-unit counterpart to the batch producers "
586 "(apps/host/mdl, ...)\n"
587 "add_executable(rabook_imagepack src/main.c)\n"
588 ),
589 "cmake/ra8_webp_vendor.cmake": (
590 "#[[ tools/rabook_imagepack and apps/host/mdl each faked\n"
591 " jof_priv_webp_transcode() ]]\n"
592 "add_library(ra8_webp STATIC ${RA8_WEBP_SRCS})\n"
593 ),
594 "CMakeLists.txt": "add_subdirectory(apps/host/mdl)\n",
595 "libs/ra8_tier/CMakeLists.txt": "target_sources(ra8_tier PRIVATE src/tier.c)\n",
596 "apps/shared_libs/mdl/CMakeLists.txt": (
597 "target_sources(mdl_core PRIVATE src/mdl_hash.c)\n"
598 "target_include_directories(mdl_core PUBLIC ${FW_ROOT}/apps/shared_libs/mdl/inc)\n"
599 ),
600 "apps/host/mdl/CMakeLists.txt": (
601 "target_sources(mdl PRIVATE ${FW_ROOT}/apps/shared_libs/mdl/src/mdl_hash.c)\n"
602 ),
603}
604
605
606def _selftest_must_stay_quiet(
607 root: Path, exclusive: dict[str, frozenset[str]], failures: list[str]
608) -> None:
609 """Prove legal code, legal orchestration and prose produce no finding.
610
611 Args:
612 root: Temporary fixture root.
613 exclusive: Census the fixtures are written against.
614 failures: Accumulator from ``selftest_assert``.
615 """
616 for rel, text in _QUIET_C.items():
617 _, found = scan_targets([_write(root, rel, text)], [], exclusive)
618 expect(not found, f"legal source stays quiet: {rel}", failures)
619 for rel, text in _QUIET_CMAKE.items():
620 _, found = scan_targets([], [_write(root, rel, text)], exclusive)
621 expect(not found, f"legal listfile stays quiet: {rel}", failures)
622
623
624def _selftest_scope(failures: list[str]) -> None:
625 """Prove the layer partition: ruled layers in, forms and consumers out."""
626 for root in PLATFORM_C_ROOTS:
627 layer = layer_for_c(f"{root}mod/src/thing.c")
628 expect(
629 layer is not None and layer.name == PLATFORM_LAYER_NAME, f"{root} is platform", failures
630 )
631 shared = layer_for_c("apps/shared_libs/mdl/src/core.c")
632 expect(
633 shared is not None and shared.name == SHARED_LAYER_NAME,
634 "apps/shared_libs is the product-shared layer",
635 failures,
636 )
637 for category in FORM_CATEGORIES:
638 expect(
639 layer_for_c(f"{category}thing/src/main.c") is None,
640 f"{category} is a form and consumes freely",
641 failures,
642 )
643 for root in EXEMPT_CONSUMER_ROOTS:
644 expect(layer_for_c(f"{root}app/main.c") is None, f"{root} is an exempt consumer", failures)
645 expect(
646 layer_for_cmake(f"{root}cmake/unit_tests.cmake") is None,
647 f"{root} listfiles are exempt consumers",
648 failures,
649 )
650 expect(
651 layer_for_c("apps/shared_libs/third_party/miniz/miniz.c") is None,
652 "vendored SOUP is out",
653 failures,
654 )
655 expect(
656 layer_for_c("tools/foo/build/CMakeFiles/probe.c") is None, "build output is out", failures
657 )
658 expect(layer_for_cmake("CMakeLists.txt") is not None, "the root listfile is scanned", failures)
659 expect(
660 layer_for_cmake("cmake/ra8_add_app.cmake") is not None,
661 "the shared cmake modules are scanned",
662 failures,
663 )
664 expect(
665 "cmake/ra8_add_app.cmake" not in ORCHESTRATION_EXEMPT,
666 "the exemption does not cover the shared cmake modules",
667 failures,
668 )
669
670
671def _selftest_census(failures: list[str]) -> None:
672 """Prove the exclusive-basename census and its ambiguity rule."""
673 rels = [
674 "apps/shared_libs/mdl/inc/mdl_cache.h",
675 "apps/host/mdl/inc/mdl_cli.h",
676 "apps/host/mdl/inc/shared_name.h",
677 "apps/shared_libs/mdl/inc/shared_name.h",
678 "libs/ra8_core/inc/ra8_check.h",
679 "apps/shared_libs/mdl/build/CMakeFiles/generated.h",
680 ]
681 census = build_exclusive(rels)
682 expect(
683 census[PLATFORM_LAYER_NAME] == {"mdl_cli.h"},
684 "rule 1's census is every form-exclusive header basename",
685 failures,
686 )
687 expect(
688 census[SHARED_LAYER_NAME] == {"mdl_cli.h"},
689 "rule 2's census is form-exclusive only -- a shared sibling is not in it",
690 failures,
691 )
692 expect(
693 "generated.h" not in census[PLATFORM_LAYER_NAME],
694 "build output never enters the census",
695 failures,
696 )
697 expect(
698 build_exclusive([r for r in rels if not r.startswith(SHARED_CATEGORY)])[SHARED_LAYER_NAME]
699 == {"mdl_cli.h", "shared_name.h"},
700 "an EMPTY apps/shared_libs is a legal layout the census still handles",
701 failures,
702 )
703 expect(
704 classify_include("apps/x/y.h", (PRODUCTS_ROOT,), frozenset())[0] == KIND_PATH,
705 "the literal path rule needs no census",
706 failures,
707 )
708 expect(
709 classify_include("shared_name.h", FORM_CATEGORIES, census[SHARED_LAYER_NAME]) is None,
710 "an ambiguous bare include is not a finding",
711 failures,
712 )
713 expect(
714 classify_include("apps/shared_libs/x.h", FORM_CATEGORIES, frozenset()) is None,
715 "shared is not forbidden to itself",
716 failures,
717 )
718
719
720_MEASURED_C_COUNTS = {"libs/": 938, "port/": 98, "src/": 16, "tools/": 214}
721_MEASURED_CMAKE = 74
722_MEASURED_APPS_C = 137
723_MEASURED_APPS_HEADERS = 52
724
725
726def _selftest_floors(failures: list[str]) -> None:
727 """Prove every non-vacuity floor bites, and a healthy census does not."""
728
729 def census(
730 *,
731 c_counts: dict[str, int] | None = None,
732 cmake_count: int = _MEASURED_CMAKE,
733 apps_c_count: int = _MEASURED_APPS_C,
734 apps_header_count: int = _MEASURED_APPS_HEADERS,
735 ) -> Census:
736 """Build a Census from the measured tree with one value perturbed."""
737 return Census(
738 c_counts=dict(_MEASURED_C_COUNTS) if c_counts is None else c_counts,
739 cmake_count=cmake_count,
740 apps_c_count=apps_c_count,
741 apps_header_count=apps_header_count,
742 )
743
744 expect(not census_floor_errors(census()), "the measured census clears every floor", failures)
745 narrowed = dict(_MEASURED_C_COUNTS)
746 narrowed["port/"] = C_ROOT_FILE_FLOORS["port/"] - 1
747 expect(
748 bool(census_floor_errors(census(c_counts=narrowed))),
749 "a narrowed platform root fails",
750 failures,
751 )
752 # Every root exactly ON its floor, so only the aggregate can object.
753 expect(
754 bool(census_floor_errors(census(c_counts=dict(C_ROOT_FILE_FLOORS)))),
755 "the aggregate platform C floor fails",
756 failures,
757 )
758 expect(
759 bool(census_floor_errors(census(cmake_count=CMAKE_TOTAL_FILE_FLOOR - 1))),
760 "a collapsed platform listfile scan fails",
761 failures,
762 )
763 expect(
764 bool(census_floor_errors(census(apps_c_count=APPS_C_FILE_FLOOR - 1))),
765 "a collapsed PRODUCTS census fails independently of the platform one",
766 failures,
767 )
768 expect(
769 bool(census_floor_errors(census(apps_header_count=APPS_HEADER_FLOOR - 1))),
770 "a collapsed products header census fails, so bare-name cannot go vacuous",
771 failures,
772 )
773
774
775def selftest() -> int:
776 """Prove both rules fire, stay quiet, partition the layers, and are not vacuous.
777
778 Returns:
779 0 when every assertion held, 1 otherwise.
780 """
781 print("check_tier_imports.py --selftest")
782 failures: list[str] = []
783 exclusive = {
784 PLATFORM_LAYER_NAME: frozenset({"mdl_cache.h", "mdl_cli.h", "dl_module.h"}),
785 SHARED_LAYER_NAME: frozenset({"mdl_cli.h", "dl_module.h"}),
786 }
787 with tempfile.TemporaryDirectory() as tmp:
788 root = Path(tmp)
789 _selftest_must_fire(root, exclusive, failures)
790 _selftest_must_stay_quiet(root, exclusive, failures)
791 _selftest_scope(failures)
792 _selftest_census(failures)
793 _selftest_floors(failures)
794 return report(failures)
795
796
797def _report(scanned: int, findings: list[Finding]) -> int:
798 """Print the zero-baseline verdict and return its exit status.
799
800 Args:
801 scanned: Files read.
802 findings: Every tier violation.
803
804 Returns:
805 0 when clean, 1 when a layer reached into a region above it.
806 """
807 if not findings:
808 print(f"check_tier_imports.py: {scanned} file(s) across the ruled layers, 0 violations.")
809 return EXIT_OK
810 sys.stderr.write(
811 "check_tier_imports.py: tier boundary violated -- the platform must not import "
812 "apps/, and apps/shared_libs/ must not import a product form. Move the shared "
813 "code down (libs/ or apps/shared_libs/) or invert the dependency:\n"
814 )
815 for layer, kind, rel, lineno, named, source in findings:
816 sys.stderr.write(f" {rel}:{lineno}: [{layer}/{kind}] {named}\n {source}\n")
817 sys.stderr.write(f"\n{len(findings)} finding(s); baseline is zero.\n")
818 return EXIT_VIOLATION
819
820
821def main(argv: list[str]) -> int:
822 """Dispatch the selftest, the full layer sweep, or an explicit scan.
823
824 Args:
825 argv: Process argv.
826
827 Returns:
828 0 clean, 1 violation, 2 usage or collapsed scan.
829 """
830 parser = argparse.ArgumentParser(description="tier-import boundary gate")
831 parser.add_argument("--all", action="store_true", help="sweep every ruled layer")
832 parser.add_argument("--selftest", action="store_true", help="prove the gate both ways")
833 parser.add_argument("files", nargs="*", help="explicit files")
834 args = parser.parse_args(argv[1:])
835 if args.selftest:
836 if args.all or args.files:
837 parser.error("--selftest accepts no other arguments")
838 return selftest()
839 if args.all and args.files:
840 parser.error("--all accepts no explicit files")
841 if not args.all and not args.files:
842 parser.error("provide --all or at least one file")
843 if args.all:
844 c_targets, cmake_targets, exclusive = _working_scope()
845 else:
846 c_targets, cmake_targets = _explicit_scope(args.files)
847 exclusive = build_exclusive(_git_working_tree())
848 try:
849 scanned, findings = scan_targets(c_targets, cmake_targets, exclusive)
850 except OSError as exc:
851 sys.stderr.write(f"check_tier_imports.py: FATAL -- {exc}\n")
852 return EXIT_VACUOUS
853 return _report(scanned, findings)
854
855
856if __name__ == "__main__":
857 raise SystemExit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298