4"""Gate: a header under a ``src/`` directory shall be module-private.
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
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
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.
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.
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
32Exit 0 if every ``src/`` header is ``*_internal``, exit 1 (with a table)
36from __future__
import annotations
41from collections.abc
import Iterable
42from pathlib
import Path
44sys.path.insert(0, str(Path(__file__).resolve().parent))
46from lint_targets
import is_build_output_path
48REPO_ROOT = Path(__file__).resolve().parents[2]
50HEADER_SUFFIXES = (
".h",
".hpp",
".hh",
".hxx")
51SCAN_ROOTS = (
"libs",
"port",
"examples",
"tools",
"apps",
"tests")
54 "apps/shared_libs/third_party/",
60INTERNAL_STEM_SUFFIX =
"_internal"
63MIN_PRIVATE_HEADERS = 100
66def _is_excluded(path: Path) -> bool:
67 return is_build_output_path(path)
or any(frag
in str(path)
for frag
in EXCLUDE_FRAGMENTS)
70def _is_header(path: Path) -> bool:
71 return path.suffix
in HEADER_SUFFIXES
74def _governing_dir(path: Path) -> str |
None:
75 """Return the nearest ``inc``/``src`` ancestor component, or None.
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``.
82 for part
in reversed(path.parent.parts):
83 if part
in (
"inc",
"src"):
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"
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)
98def _rel(path: Path) -> str:
99 if path.is_relative_to(REPO_ROOT):
100 return str(path.relative_to(REPO_ROOT))
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)
111 if not path.is_absolute():
112 path = REPO_ROOT / path
114 for suffix
in HEADER_SUFFIXES:
115 out.extend(path.rglob(
"*" + suffix))
116 elif _is_header(path):
118 return [p
for p
in out
if not _is_excluded(p)]
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)]
127def _audit_targets(targets: Iterable[Path]) -> tuple[int, list[str]]:
128 """Return the private-header count and misplaced relative paths."""
130 offenders: list[str] = []
132 if not _under_src(path):
135 if not _is_internal(path):
136 offenders.append(_rel(path))
137 return scanned, sorted(offenders)
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
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:
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)]:
165 f
"mixed fixture scanned={scanned}, offenders={offenders!r}; expected bad only"
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")
182 for failure
in failures:
183 print(f
" [FAIL] {failure}", file=sys.stderr)
185 print(
"check_header_file_placement.py --selftest: PASS (fire, quiet, tests, exclusions)")
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:])
197def main(argv: list[str]) -> int:
198 """Fail any header under a ``src/`` directory not named ``*_internal.h``.
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.
205 Returns 1 listing each misplaced header, 0 when every src/ header is
206 module-private or when nothing in scope reached the filter.
208 args = _parse_args(argv)
211 print(
"--selftest does not accept paths", file=sys.stderr)
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)
219 scanned, offenders = _audit_targets(targets)
220 if not _whole_tree_census_ok(scanned, explicit_paths=bool(args.paths)):
222 "check_header_file_placement.py: whole-tree scan reached only "
223 f
"{scanned} private header(s), below floor {MIN_PRIVATE_HEADERS}",
230 f
"check_header_file_placement.py: {scanned} src/ header(s) scanned, "
231 "all module-private (*_internal.h)."
236 f
"check_header_file_placement.py: {len(offenders)} src/ header(s) are not *_internal.h:\n",
239 for path
in offenders:
240 print(f
" {path}", file=sys.stderr)
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.",
254if __name__ ==
"__main__":
255 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.