3"""The repository's tier model: what the layers are, and what each may reach.
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.
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.
17The tiers, and the one direction the arrow points:
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.
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.
34from __future__
import annotations
38from collections.abc
import Iterable
39from pathlib
import Path
40from typing
import NamedTuple
42sys.path.insert(0, str(Path(__file__).resolve().parent))
44from lint_targets
import is_build_output_path
46REPO_ROOT = Path(__file__).resolve().parents[2]
51PRODUCTS_ROOT =
"apps/"
53SHARED_CATEGORY =
"apps/shared_libs/"
55FORM_CATEGORIES = (
"apps/board/",
"apps/host/")
59EXEMPT_CONSUMER_ROOTS = (
"examples/",
"tests/")
62class Layer(NamedTuple):
63 """One ruled layer: where its files live, and what it may not reach into."""
66 c_roots: tuple[str, ...]
67 cmake_roots: tuple[str, ...]
68 cmake_files: tuple[str, ...]
69 forbidden: tuple[str, ...]
77PLATFORM_LAYER_NAME =
"platform"
79SHARED_LAYER_NAME =
"product-shared"
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,
90 name=SHARED_LAYER_NAME,
91 c_roots=(SHARED_CATEGORY,),
92 cmake_roots=(SHARED_CATEGORY,),
94 forbidden=FORM_CATEGORIES,
98PLATFORM_C_ROOTS = LAYERS[0].c_roots
104ORCHESTRATION_EXEMPT = frozenset({
"CMakeLists.txt"})
106C_SUFFIXES = (
".c",
".h",
".cc",
".cpp",
".cxx",
".hh",
".hpp",
".hxx",
".inc",
".m",
".mm")
108HEADER_SUFFIXES = (
".h",
".hh",
".hpp",
".hxx",
".inc")
110LISTFILE_BASENAME =
"CMakeLists.txt"
112LISTFILE_SUFFIX =
".cmake"
116EXCLUDED_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/",
"libs/ra8_fonts/")
131C_ROOT_FILE_FLOORS = {
"libs/": 750,
"port/": 80,
"tools/": 180}
133C_TOTAL_FILE_FLOOR = 1050
135CMAKE_TOTAL_FILE_FLOOR = 60
137APPS_C_FILE_FLOOR = 100
139APPS_HEADER_FLOOR = 20
142def normalize_rel(rel: str) -> str:
143 """Return `rel` as a forward-slash repo-relative path with no `./` prefix.
146 rel: A path as it arrives from git, argv, or a selftest fixture.
149 The same path in the one spelling every predicate here compares.
151 text = rel.replace(
"\\",
"/")
152 while text.startswith(
"./"):
154 return text.lstrip(
"/")
157def region_parts(prefix: str) -> tuple[str, ...]:
158 """Split a region prefix such as ``apps/board/`` into components.
161 prefix: A trailing-slash region prefix.
164 Its path components, which is the form the include matcher compares.
166 return tuple(part
for part
in prefix.split(
"/")
if part)
169def _cmake_region_re(prefix: str) -> re.Pattern[str]:
170 """Build the CMake path matcher for one forbidden region.
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.
177 prefix: A trailing-slash region prefix.
180 The compiled pattern.
182 return re.compile(
r"(?<![A-Za-z0-9_.-])" + re.escape(prefix))
186 prefix: _cmake_region_re(prefix)
for layer
in LAYERS
for prefix
in layer.forbidden
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)
195def layer_for_c(rel: str) -> Layer |
None:
196 """Return the ruled layer owning C-family file `rel`, or None.
199 rel: Repo-relative path.
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.
206 norm = normalize_rel(rel)
207 if not norm.lower().endswith(C_SUFFIXES)
or is_excluded(norm):
209 return next((layer
for layer
in LAYERS
if norm.startswith(layer.c_roots)),
None)
212def layer_for_cmake(rel: str) -> Layer |
None:
213 """Return the ruled layer owning CMake listfile `rel`, or None.
216 rel: Repo-relative path.
219 The owning ``Layer`` for a ``CMakeLists.txt`` / ``*.cmake`` inside a
220 ruled layer, or for an exact orchestrator listfile; None otherwise.
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:
226 is_listfile = norm.endswith((
"/" + LISTFILE_BASENAME, LISTFILE_SUFFIX))
227 if not is_listfile
or is_excluded(norm):
229 return next((layer
for layer
in LAYERS
if norm.startswith(layer.cmake_roots)),
None)
232def exclusive_basenames(rels: Iterable[str], region: tuple[str, ...]) -> frozenset[str]:
233 """Return header basenames that exist inside `region` and nowhere else.
236 rels: Every repo-relative path in the working tree.
237 region: Region prefixes forming the forbidden area.
240 The unambiguous basenames -- the only ones a bare include can be judged
241 on without guessing at the build's include-directory ordering.
243 inside: set[str] = set()
244 outside: set[str] = set()
246 norm = normalize_rel(rel)
247 if not norm.lower().endswith(HEADER_SUFFIXES)
or is_build_output_path(norm):
249 name = norm.rsplit(
"/", 1)[-1]
250 if norm.startswith(region):
254 return frozenset(inside - outside)
257class Census(NamedTuple):
258 """The measured populations every non-vacuity floor is asserted against."""
260 c_counts: dict[str, int]
263 apps_header_count: int
266def census_floor_errors(census: Census) -> list[str]:
267 """Describe every non-vacuity floor the current census fails.
270 census: The measured platform and products populations.
273 One message per violated floor; empty when the scan is honest.
275 errors: list[str] = []
276 for root, floor
in C_ROOT_FILE_FLOORS.items():
277 actual = census.c_counts.get(root, 0)
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:
285 f
"platform CMake scope enumerated {census.cmake_count} listfile(s); "
286 f
"floor is {CMAKE_TOTAL_FILE_FLOOR}"
288 if census.apps_c_count < APPS_C_FILE_FLOOR:
290 f
"products tier enumerated {census.apps_c_count} C-family file(s); "
291 f
"floor is {APPS_C_FILE_FLOOR}"
293 if census.apps_header_count < APPS_HEADER_FLOOR:
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"
301def measure(rels: Iterable[str]) -> Census:
302 """Count the platform and products populations the floors are asserted on.
305 rels: Every repo-relative path in the working tree.
310 c_counts = dict.fromkeys(PLATFORM_C_ROOTS, 0)
313 apps_header_count = 0
315 norm = normalize_rel(rel)
316 if is_excluded(norm):
318 is_c = norm.lower().endswith(C_SUFFIXES)
320 root = next((root
for root
in PLATFORM_C_ROOTS
if norm.startswith(root)),
None)
323 cmake_layer = layer_for_cmake(norm)
324 if cmake_layer
is not None and cmake_layer.name == PLATFORM_LAYER_NAME:
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)
332def build_exclusive(rels: Iterable[str]) -> dict[str, frozenset[str]]:
333 """Build each layer's exclusive-basename census.
336 rels: Every repo-relative path in the working tree.
339 Layer name -> the basenames a bare include can be judged on.
341 materialised = list(rels)
342 return {layer.name: exclusive_basenames(materialised, layer.forbidden)
for layer
in LAYERS}