ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
tier_layers.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The repository's tier model: what the layers are, and what each may reach.
4
5``check_tier_imports.py`` is the SCANNER -- it lexes C and CMake and reports
6what it finds. This module is the MODEL it scans against: where each layer's
7files live, which region each layer may not reach into, which populations the
8non-vacuity floors are asserted on, and how the exclusive header-basename
9census that judges a bare include is derived.
10
11Keeping the two apart is what makes the boundary describable in one place. The
12whole layout knowledge is four strings -- ``apps/``, ``apps/shared_libs/`` and the
13two form categories -- and two ``Layer`` rows built out of them. A product
14moving between categories, or a new category arriving, is a change HERE and
15nowhere else; the scanner never learns a path.
16
17The tiers, and the one direction the arrow points:
18
19* PLATFORM -- ``libs/``, ``port/``, ``tools/``. May not reach into
20 ``apps/`` at all, in any category.
21* PRODUCTS -- ``apps/``. ``apps/shared_libs/`` is portable product-tier code and
22 sits BELOW the form categories ``apps/host/`` and ``apps/board/``; it may not
23 reach up into either. The forms consume
24 shared, and the platform, freely.
25* CONSUMERS -- ``examples/`` and ``tests/``. Outside every rule by design: they
26 exist to demonstrate and to compile the other two.
27
28This module holds no ``main`` and no ``--selftest`` of its own. It is a model,
29not a detector: every predicate and every floor in it is proved in both
30directions by ``check_tier_imports.py --selftest``, which is the entry point
31the ``tier-imports`` gate drives.
32"""
33
34from __future__ import annotations
35
36import re
37import sys
38from collections.abc import Iterable
39from pathlib import Path
40from typing import NamedTuple
41
42sys.path.insert(0, str(Path(__file__).resolve().parent))
43
44from lint_targets import is_build_output_path
45
46REPO_ROOT = Path(__file__).resolve().parents[2]
47
48# The products tier and its FORM categories. These four strings are the whole
49# layout knowledge this gate has; everything else is derived from them, so a
50# product moving between categories needs no change here.
51PRODUCTS_ROOT = "apps/"
52
53SHARED_CATEGORY = "apps/shared_libs/"
54
55FORM_CATEGORIES = ("apps/board/", "apps/host/")
56
57# Consumers of every tier, exempt by design. Named so the exemption is a stated
58# part of the rule rather than an absence someone has to notice.
59EXEMPT_CONSUMER_ROOTS = ("examples/", "tests/")
60
61
62class Layer(NamedTuple):
63 """One ruled layer: where its files live, and what it may not reach into."""
64
65 name: str
66 c_roots: tuple[str, ...]
67 cmake_roots: tuple[str, ...]
68 cmake_files: tuple[str, ...]
69 forbidden: tuple[str, ...]
70
71
72# The rule set. Order is presentation order only; the layers do not overlap.
73#
74# The platform's CMake half additionally covers cmake/ -- the shared platform
75# CMake modules, which are platform infrastructure even though they compile
76# nothing themselves -- and the root listfile, the tree's top orchestrator.
77PLATFORM_LAYER_NAME = "platform"
78
79SHARED_LAYER_NAME = "product-shared"
80
81LAYERS = (
82 Layer(
83 name=PLATFORM_LAYER_NAME,
84 c_roots=("libs/", "port/", "tools/"),
85 cmake_roots=("libs/", "port/", "tools/", "cmake/"),
86 cmake_files=("CMakeLists.txt",),
87 forbidden=FORM_CATEGORIES,
88 ),
89 Layer(
90 name=SHARED_LAYER_NAME,
91 c_roots=(SHARED_CATEGORY,),
92 cmake_roots=(SHARED_CATEGORY,),
93 cmake_files=(),
94 forbidden=FORM_CATEGORIES,
95 ),
96)
97
98PLATFORM_C_ROOTS = LAYERS[0].c_roots
99
100# The exact listfiles permitted to name a forbidden region, and only in an
101# add_subdirectory() call. An explicit tuple, never a pattern: a wildcard here
102# ("any top-level listfile", "anything called CMakeLists.txt") would re-open the
103# boundary the moment the tree grew another orchestrator.
104ORCHESTRATION_EXEMPT = frozenset({"CMakeLists.txt"})
105
106C_SUFFIXES = (".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".inc", ".m", ".mm")
107
108HEADER_SUFFIXES = (".h", ".hh", ".hpp", ".hxx", ".inc")
109
110LISTFILE_BASENAME = "CMakeLists.txt"
111
112LISTFILE_SUFFIX = ".cmake"
113
114# Vendored SOUP and generated font tables are not hand-authored first-party
115# code and are exempt from every house rule, this one included.
116EXCLUDED_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/", "libs/ra8_fonts/")
117
118# Non-vacuity floors. A checker whose scope collapsed to zero files is also
119# perfectly quiet, so a shrunken census is FATAL rather than clean. The
120# PLATFORM and PRODUCTS populations are counted SEPARATELY, so either census
121# collapsing fails loudly on its own terms rather than being hidden inside a
122# total. Measured 2026-08-17 (tracked + untracked-not-ignored, SOUP and fonts
123# excluded): libs 948, port 98, tools 214 C-family files; 73 platform
124# listfiles; 143 C-family files and 52 headers under apps/. The libs/ figure
125# absorbed the 10 files of the dissolved src/ root (#724), so the platform
126# total is unchanged at 1260 and the per-root floors below still sum to 1070.
127#
128# Deliberately NOT floored: apps/shared_libs/ and the individual form categories. A
129# product is allowed to live entirely in one of them while another is a
130# placeholder, and a floor there would fail the gate for a legal layout.
131C_ROOT_FILE_FLOORS = {"libs/": 750, "port/": 80, "tools/": 180}
132
133C_TOTAL_FILE_FLOOR = 1050
134
135CMAKE_TOTAL_FILE_FLOOR = 60
136
137APPS_C_FILE_FLOOR = 100
138
139APPS_HEADER_FLOOR = 20
140
141
142def normalize_rel(rel: str) -> str:
143 """Return `rel` as a forward-slash repo-relative path with no `./` prefix.
144
145 Args:
146 rel: A path as it arrives from git, argv, or a selftest fixture.
147
148 Returns:
149 The same path in the one spelling every predicate here compares.
150 """
151 text = rel.replace("\\", "/")
152 while text.startswith("./"):
153 text = text[2:]
154 return text.lstrip("/")
155
156
157def region_parts(prefix: str) -> tuple[str, ...]:
158 """Split a region prefix such as ``apps/board/`` into components.
159
160 Args:
161 prefix: A trailing-slash region prefix.
162
163 Returns:
164 Its path components, which is the form the include matcher compares.
165 """
166 return tuple(part for part in prefix.split("/") if part)
167
168
169def _cmake_region_re(prefix: str) -> re.Pattern[str]:
170 """Build the CMake path matcher for one forbidden region.
171
172 The lookbehind rejects an identifier character, a dot or a dash immediately
173 before, so ``myapps/``, ``foo_apps/`` and ``ra8-apps/`` are not this root.
174 ``${FW_ROOT}/apps/...`` matches: the character before is a slash.
175
176 Args:
177 prefix: A trailing-slash region prefix.
178
179 Returns:
180 The compiled pattern.
181 """
182 return re.compile(r"(?<![A-Za-z0-9_.-])" + re.escape(prefix))
183
184
185CMAKE_REGION_RES = {
186 prefix: _cmake_region_re(prefix) for layer in LAYERS for prefix in layer.forbidden
187}
188
189
190def is_excluded(norm: str) -> bool:
191 """Whether a normalized path is vendored, generated, or build output."""
192 return norm.startswith(EXCLUDED_PREFIXES) or is_build_output_path(norm)
193
194
195def layer_for_c(rel: str) -> Layer | None:
196 """Return the ruled layer owning C-family file `rel`, or None.
197
198 Args:
199 rel: Repo-relative path.
200
201 Returns:
202 The owning ``Layer`` for hand-authored C/C++ inside a ruled layer;
203 None for the product FORMS, the exempt consumers, vendored SOUP,
204 generated font tables, and anything inside a build tree.
205 """
206 norm = normalize_rel(rel)
207 if not norm.lower().endswith(C_SUFFIXES) or is_excluded(norm):
208 return None
209 return next((layer for layer in LAYERS if norm.startswith(layer.c_roots)), None)
210
211
212def layer_for_cmake(rel: str) -> Layer | None:
213 """Return the ruled layer owning CMake listfile `rel`, or None.
214
215 Args:
216 rel: Repo-relative path.
217
218 Returns:
219 The owning ``Layer`` for a ``CMakeLists.txt`` / ``*.cmake`` inside a
220 ruled layer, or for an exact orchestrator listfile; None otherwise.
221 """
222 norm = normalize_rel(rel)
223 exact = next((layer for layer in LAYERS if norm in layer.cmake_files), None)
224 if exact is not None:
225 return exact
226 is_listfile = norm.endswith(("/" + LISTFILE_BASENAME, LISTFILE_SUFFIX))
227 if not is_listfile or is_excluded(norm):
228 return None
229 return next((layer for layer in LAYERS if norm.startswith(layer.cmake_roots)), None)
230
231
232def exclusive_basenames(rels: Iterable[str], region: tuple[str, ...]) -> frozenset[str]:
233 """Return header basenames that exist inside `region` and nowhere else.
234
235 Args:
236 rels: Every repo-relative path in the working tree.
237 region: Region prefixes forming the forbidden area.
238
239 Returns:
240 The unambiguous basenames -- the only ones a bare include can be judged
241 on without guessing at the build's include-directory ordering.
242 """
243 inside: set[str] = set()
244 outside: set[str] = set()
245 for rel in rels:
246 norm = normalize_rel(rel)
247 if not norm.lower().endswith(HEADER_SUFFIXES) or is_build_output_path(norm):
248 continue
249 name = norm.rsplit("/", 1)[-1]
250 if norm.startswith(region):
251 inside.add(name)
252 else:
253 outside.add(name)
254 return frozenset(inside - outside)
255
256
257class Census(NamedTuple):
258 """The measured populations every non-vacuity floor is asserted against."""
259
260 c_counts: dict[str, int]
261 cmake_count: int
262 apps_c_count: int
263 apps_header_count: int
264
265
266def census_floor_errors(census: Census) -> list[str]:
267 """Describe every non-vacuity floor the current census fails.
268
269 Args:
270 census: The measured platform and products populations.
271
272 Returns:
273 One message per violated floor; empty when the scan is honest.
274 """
275 errors: list[str] = []
276 for root, floor in C_ROOT_FILE_FLOORS.items():
277 actual = census.c_counts.get(root, 0)
278 if actual < floor:
279 errors.append(f"{root} enumerated {actual} C-family file(s); floor is {floor}")
280 total = sum(census.c_counts.values())
281 if total < C_TOTAL_FILE_FLOOR:
282 errors.append(f"platform C scope enumerated {total} file(s); floor is {C_TOTAL_FILE_FLOOR}")
283 if census.cmake_count < CMAKE_TOTAL_FILE_FLOOR:
284 errors.append(
285 f"platform CMake scope enumerated {census.cmake_count} listfile(s); "
286 f"floor is {CMAKE_TOTAL_FILE_FLOOR}"
287 )
288 if census.apps_c_count < APPS_C_FILE_FLOOR:
289 errors.append(
290 f"products tier enumerated {census.apps_c_count} C-family file(s); "
291 f"floor is {APPS_C_FILE_FLOOR}"
292 )
293 if census.apps_header_count < APPS_HEADER_FLOOR:
294 errors.append(
295 f"products tier enumerated {census.apps_header_count} header(s); "
296 f"floor is {APPS_HEADER_FLOOR} -- the bare-name rule would be vacuous"
297 )
298 return errors
299
300
301def measure(rels: Iterable[str]) -> Census:
302 """Count the platform and products populations the floors are asserted on.
303
304 Args:
305 rels: Every repo-relative path in the working tree.
306
307 Returns:
308 The measured census.
309 """
310 c_counts = dict.fromkeys(PLATFORM_C_ROOTS, 0)
311 cmake_count = 0
312 apps_c_count = 0
313 apps_header_count = 0
314 for rel in rels:
315 norm = normalize_rel(rel)
316 if is_excluded(norm):
317 continue
318 is_c = norm.lower().endswith(C_SUFFIXES)
319 if is_c:
320 root = next((root for root in PLATFORM_C_ROOTS if norm.startswith(root)), None)
321 if root is not None:
322 c_counts[root] += 1
323 cmake_layer = layer_for_cmake(norm)
324 if cmake_layer is not None and cmake_layer.name == PLATFORM_LAYER_NAME:
325 cmake_count += 1
326 if norm.startswith(PRODUCTS_ROOT):
327 apps_c_count += int(is_c)
328 apps_header_count += int(norm.lower().endswith(HEADER_SUFFIXES))
329 return Census(c_counts, cmake_count, apps_c_count, apps_header_count)
330
331
332def build_exclusive(rels: Iterable[str]) -> dict[str, frozenset[str]]:
333 """Build each layer's exclusive-basename census.
334
335 Args:
336 rels: Every repo-relative path in the working tree.
337
338 Returns:
339 Layer name -> the basenames a bare include can be judged on.
340 """
341 materialised = list(rels)
342 return {layer.name: exclusive_basenames(materialised, layer.forbidden) for layer in LAYERS}