ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_header_file_placement.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: a header under a ``src/`` directory shall be module-private.
5
6The repository splits every module into a public ``inc/`` directory (the
7contract other translation units consume) and a private ``src/`` directory
8(the implementation). A header that lives under ``src/`` is therefore, by
9construction, module-private -- and must announce that with an ``_internal``
10suffix (``*_internal.h``). A non-``_internal`` header under ``src/`` is one of
11two mistakes:
12
13 * it is actually a public interface that was filed in the wrong place and
14 belongs in the module's ``inc/`` directory, or
15 * it is genuinely private but was never given the ``_internal`` name that
16 marks it as such.
17
18Either way it is a defect. This gate walks every first-party ``.h``/``.hpp``
19under a ``src/`` directory and fails on any that do not end in ``_internal``.
20Vendored trees (``libs/third_party/``) and generated font tables
21(``libs/ra8_fonts/``) are out of scope, matching every other repo gate.
22
23There is deliberately NO in-file waiver marker: the fix is to move the header
24to ``inc/`` or rename it ``*_internal.h``, never to annotate an exception.
25
26Run::
27
28 check_header_file_placement.py # scan the whole tree
29 check_header_file_placement.py path/to/file.h ... # scan listed files
30 check_header_file_placement.py --selftest # prove both directions
31
32Exit 0 if every ``src/`` header is ``*_internal``, exit 1 (with a table)
33otherwise.
34"""
35
36from __future__ import annotations
37
38import argparse
39import sys
40import tempfile
41from collections.abc import Iterable
42from pathlib import Path
43
44sys.path.insert(0, str(Path(__file__).resolve().parent))
45
46from lint_targets import is_build_output_path
47
48REPO_ROOT = Path(__file__).resolve().parents[2]
49
50HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx")
51SCAN_ROOTS = ("libs", "port", "examples", "tools", "apps", "tests")
52EXCLUDE_FRAGMENTS = (
53 "libs/third_party/",
54 "apps/shared_libs/third_party/",
55 # Generated font tables are data artifacts, not hand-authored module interfaces.
56 "libs/ra8_fonts/",
57)
58
59# The suffix that marks a src/ header as intentionally module-private.
60INTERNAL_STEM_SUFFIX = "_internal"
61
62# A whole-tree pass below this measured population has lost scope and must fail.
63MIN_PRIVATE_HEADERS = 100
64
65
66def _is_excluded(path: Path) -> bool:
67 return is_build_output_path(path) or any(frag in str(path) for frag in EXCLUDE_FRAGMENTS)
68
69
70def _is_header(path: Path) -> bool:
71 return path.suffix in HEADER_SUFFIXES
72
73
74def _governing_dir(path: Path) -> str | None:
75 """Return the nearest ``inc``/``src`` ancestor component, or None.
76
77 A module may nest an ``inc`` inside a ``src`` tree (e.g.
78 ``libs/ra8_secure_app/inc/key_vault.h``): the *closest* such component to the
79 file decides whether it is public (``inc``) or private (``src``), so a
80 higher ``src`` does not condemn a header that sits in a deeper ``inc``.
81 """
82 for part in reversed(path.parent.parts):
83 if part in ("inc", "src"):
84 return part
85 return None
86
87
88def _under_src(path: Path) -> bool:
89 """True if the header's nearest inc/src ancestor is a private ``src``."""
90 return _governing_dir(path) == "src"
91
92
93def _is_internal(path: Path) -> bool:
94 """True if the header's stem ends in the ``_internal`` marker."""
95 return path.stem.endswith(INTERNAL_STEM_SUFFIX)
96
97
98def _rel(path: Path) -> str:
99 if path.is_relative_to(REPO_ROOT):
100 return str(path.relative_to(REPO_ROOT))
101 return str(path)
102
103
104def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
105 """Resolve the list of headers to scan from CLI arguments."""
106 args = list(arg_paths)
107 if args:
108 out: list[Path] = []
109 for raw in args:
110 path = Path(raw)
111 if not path.is_absolute():
112 path = REPO_ROOT / path
113 if path.is_dir():
114 for suffix in HEADER_SUFFIXES:
115 out.extend(path.rglob("*" + suffix))
116 elif _is_header(path):
117 out.append(path)
118 return [p for p in out if not _is_excluded(p)]
119
120 out = []
121 for root in SCAN_ROOTS:
122 for suffix in HEADER_SUFFIXES:
123 out.extend((REPO_ROOT / root).rglob("*" + suffix))
124 return [p for p in out if not _is_excluded(p)]
125
126
127def _audit_targets(targets: Iterable[Path]) -> tuple[int, list[str]]:
128 """Return the private-header count and misplaced relative paths."""
129 scanned = 0
130 offenders: list[str] = []
131 for path in targets:
132 if not _under_src(path):
133 continue
134 scanned += 1
135 if not _is_internal(path):
136 offenders.append(_rel(path))
137 return scanned, sorted(offenders)
138
139
140def _whole_tree_census_ok(scanned: int, *, explicit_paths: bool) -> bool:
141 """Return whether the private-header census is non-vacuous for this mode."""
142 return explicit_paths or scanned >= MIN_PRIVATE_HEADERS
143
144
145def selftest() -> int:
146 """Prove private/public placement fires and stays quiet without scope leaks."""
147 failures: list[str] = []
148 with tempfile.TemporaryDirectory(prefix="ra8-header-placement-") as temp:
149 root = Path(temp)
150 good = root / "tests/module/src/widget_internal.h"
151 bad = root / "tests/module/src/widget.h"
152 nested_public = root / "tests/module/src/sub/inc/public.h"
153 vendor = root / "libs/third_party/vendor/src/public.h"
154 app_vendor = root / "apps/shared_libs/third_party/vendor/src/public.h"
155 generated_font = root / "libs/ra8_fonts/src/generated.h"
156 build = root / "CMakeFiles/src/generated.h"
157 for path in (good, bad, nested_public, vendor, app_vendor, generated_font, build):
158 path.parent.mkdir(parents=True, exist_ok=True)
159 path.write_text("#pragma once\n", encoding="ascii")
160 targets = _enumerate_targets([str(root)])
161 scanned, offenders = _audit_targets(targets)
162 expected_scanned = len((good, bad))
163 if scanned != expected_scanned or offenders != [_rel(bad)]:
164 failures.append(
165 f"mixed fixture scanned={scanned}, offenders={offenders!r}; expected bad only"
166 )
167 quiet_scanned, quiet = _audit_targets([good, nested_public])
168 if quiet_scanned != 1 or quiet:
169 failures.append("private _internal.h or nearest nested inc/ did not stay quiet")
170 excluded = {vendor, app_vendor, generated_font, build}
171 if excluded & set(targets):
172 failures.append("vendor, generated-font, or build exclusion leaked into the scan")
173 if not _is_excluded(Path("tests/module/build/src/generated.h")):
174 failures.append("tests/ build-tree output is not excluded")
175 if "tests" not in SCAN_ROOTS:
176 failures.append("tests/ is absent from the repository-wide scan roots")
177 if _whole_tree_census_ok(MIN_PRIVATE_HEADERS - 1, explicit_paths=False):
178 failures.append("collapsed whole-tree private-header census did not fail")
179 if not _whole_tree_census_ok(0, explicit_paths=True):
180 failures.append("explicit-file mode incorrectly requires the whole-tree floor")
181 if failures:
182 for failure in failures:
183 print(f" [FAIL] {failure}", file=sys.stderr)
184 return 1
185 print("check_header_file_placement.py --selftest: PASS (fire, quiet, tests, exclusions)")
186 return 0
187
188
189def _parse_args(argv: list[str]) -> argparse.Namespace:
190 """Parse the CLI so misspelled options fail instead of becoming paths."""
191 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
192 parser.add_argument("--selftest", action="store_true")
193 parser.add_argument("paths", nargs="*")
194 return parser.parse_args(argv[1:])
195
196
197def main(argv: list[str]) -> int:
198 """Fail any header under a ``src/`` directory not named ``*_internal.h``.
199
200 The scanned count reported on success is the number of headers actually
201 UNDER a src/ directory, not the number handed in: everything else is
202 filtered out first, so passing the whole staged file list is normal and
203 the printed total will legitimately be far smaller than argv.
204
205 Returns 1 listing each misplaced header, 0 when every src/ header is
206 module-private or when nothing in scope reached the filter.
207 """
208 args = _parse_args(argv)
209 if args.selftest:
210 if args.paths:
211 print("--selftest does not accept paths", file=sys.stderr)
212 return 2
213 return selftest()
214 targets = _enumerate_targets(args.paths)
215 if not targets and args.paths:
216 print("check_header_file_placement.py: no headers to scan", file=sys.stderr)
217 return 0
218
219 scanned, offenders = _audit_targets(targets)
220 if not _whole_tree_census_ok(scanned, explicit_paths=bool(args.paths)):
221 print(
222 "check_header_file_placement.py: whole-tree scan reached only "
223 f"{scanned} private header(s), below floor {MIN_PRIVATE_HEADERS}",
224 file=sys.stderr,
225 )
226 return 1
227
228 if not offenders:
229 print(
230 f"check_header_file_placement.py: {scanned} src/ header(s) scanned, "
231 "all module-private (*_internal.h)."
232 )
233 return 0
234
235 print(
236 f"check_header_file_placement.py: {len(offenders)} src/ header(s) are not *_internal.h:\n",
237 file=sys.stderr,
238 )
239 for path in offenders:
240 print(f" {path}", file=sys.stderr)
241 print(
242 "\nA header under a src/ directory is module-private and must say so.\n"
243 "For each offender, decide which it is and fix at the root:\n"
244 " - public interface (consumed outside the module) -> move it to the\n"
245 " module's inc/ directory;\n"
246 " - genuinely module-private -> rename it '*_internal.h'.\n"
247 "Update every #include of the header in the same change. There is no\n"
248 "waiver marker -- placement is the contract.",
249 file=sys.stderr,
250 )
251 return 1
252
253
254if __name__ == "__main__":
255 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298