4"""check_inclusive_terminology.py -- inclusive-terminology gate for ra8-firmware.
6Bans the legacy master/slave/MOSI/MISO/SS vocabulary from FIRST-PARTY source
7under libs/, src/, examples/, tests/, port/, scripts/, docs/, and the top-
8level CMake / justfile / workflow files. CLAUDE.md "Terminology Standard"
9mandates Controller/Peripheral, COPI/CIPO, CS/Chip Select, Primary/Main.
11Per-line opt-out: append a `LEGACY-OK: <reason>` annotation on the offending
12line. Reserved for unavoidable upstream-symbol citations (e.g. the literal
13spelling of a Renesas HUM register-bit name where the symbol must appear
14verbatim in the source comment).
17 0 -- no violations (gate clean), or warn-only mode is on
18 1 -- violations exist (only when WARN_ONLY_MODE is False)
20The script is intentionally fast (pure-Python regex scan, no libclang) so
21the pre-commit hook stays interactive.
23@copyright Copyright (c) 2026 Brighton Sikarskie
24SPDX-License-Identifier: MIT
27from __future__
import annotations
31from pathlib
import Path
33sys.path.insert(0, str(Path(__file__).resolve().parent))
35from lint_targets
import first_party_paths
36from selftest_assert
import expect, report
49WARN_ONLY_MODE: bool =
False
57DOCS_VENDOR_DIRS: frozenset[str] = frozenset({
"reference",
"doxygen",
"html"})
60SCAN_EXTS: frozenset[str] = frozenset(
81SCAN_BASENAMES: frozenset[str] = frozenset({
"justfile",
"Justfile",
"Dockerfile",
"CMakeLists.txt"})
93PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
94 (
"master", re.compile(
r"\bmaster(s|ed|ing|ship)?\b", re.IGNORECASE)),
95 (
"slave", re.compile(
r"\bslave(s|d)?\b", re.IGNORECASE)),
96 (
"mosi", re.compile(
r"\bMOSI\b")),
97 (
"miso", re.compile(
r"\bMISO\b")),
98 (
"slave_select", re.compile(
r"\bSlave[ _-]Select\b", re.IGNORECASE)),
99 (
"ss_pin", re.compile(
r"\bSS\b")),
112IDENT_RE: re.Pattern[str] = re.compile(
r"[A-Za-z_][A-Za-z0-9_]*")
116IDENT_TERM_RE: re.Pattern[str] = re.compile(
r"(?:^|_)(?:master|slave)", re.IGNORECASE)
121VENDOR_IDENT_RE: re.Pattern[str] = re.compile(
122 r"^(?:_?ux_|r_iic|r_sce|_?nx_|ble_|mynewt|mbedtls|tls_|pre_?master"
123 r"|premaster|resumption_master|_?lx_|_?tx_|_?gx_|_?fx_|netx|threadx"
124 r"|usbx|levelx|esp_hosted|dcd_sim_slave|hcd_sim_host)",
129HW_TOKENS: frozenset[str] = frozenset({
"MASTEREN"})
132LEGACY_OK_RE: re.Pattern[str] = re.compile(
r"LEGACY-OK\s*:")
149CONTINUATION_EXTS: frozenset[str] = frozenset(
150 {
".c",
".h",
".cpp",
".hpp",
".cc",
".sh",
".mk",
".py"}
152CONTINUATION_BASENAMES: frozenset[str] = frozenset({
"Dockerfile",
"Makefile"})
155def joins_lines(path: Path) -> bool:
156 """Whether a trailing backslash continues a line in this file's language.
159 path: The scanned file, classified by suffix then by bare filename.
162 True when a trailing backslash is a line continuation there.
164 return path.suffix
in CONTINUATION_EXTS
or path.name
in CONTINUATION_BASENAMES
167def exempt_lines(lines: list[str], *, join: bool) -> frozenset[int]:
168 """One-based line numbers whose LOGICAL line carries a LEGACY-OK opt-out.
170 With ``join`` false this is exactly the set of physical lines carrying the
171 marker, which is the historical behaviour. With ``join`` true a run of
172 backslash-continued physical lines shares one verdict, so an annotation on
173 any line of the run exempts that run -- and only that run.
176 lines: The file's physical lines, in order, without line endings.
177 join: Whether a trailing backslash continues a line in this language.
180 The exempt one-based line numbers.
182 exempt: set[int] = set()
185 for lineno, line
in enumerate(lines, start=1):
187 annotated = annotated
or bool(LEGACY_OK_RE.search(line))
188 if join
and line.endswith(
"\\"):
196 return frozenset(exempt)
199def identifier_violation(line: str) -> str |
None:
200 """The offending identifier when a line names a symbol with legacy terminology.
202 Checks IDENTIFIERS, not prose: a comment discussing the legacy term --
203 often required when mapping a vendor document onto our names -- is fine,
204 while a symbol carrying it is not, because the symbol propagates.
206 Returns None when the line is clean.
208 Vendor-namespace identifiers and hardware register-bit names are
209 skipped: they are upstream contracts spelled verbatim, not symbols
210 this project is free to rename.
212 for m
in IDENT_RE.finditer(line):
214 if not IDENT_TERM_RE.search(tok):
218 if VENDOR_IDENT_RE.match(tok):
226SNIPPET_TRUNCATE_LEN = 117
227MAX_FINDINGS_SHOWN = 50
240SELF_EXEMPT_FILES: frozenset[str] = frozenset(
242 "scripts/checks/check_inclusive_terminology.py",
243 "scripts/checks/check_inclusive_terminology_commits.py",
244 "scripts/fix/fix_inclusive_terminology.py",
245 "docs/STYLE_GUIDE.md",
247 ".claude/agents/style-reviewer.md",
257def iter_source_files(root: Path) -> list[Path]:
258 """Every in-scope first-party file, derived from git rather than a root list.
260 Enumeration goes through ``lint_targets.first_party_paths`` -- the shared
261 derived-scope primitive -- so ``infra/`` and ``just/`` (the roots a hardcoded
262 list silently dropped, #549) are covered, and any future top-level
263 directory is in scope the day it lands. The only subtractions on top of what
264 that primitive already exempts (third_party/, generated fonts, build output)
265 are the docs-side vendored/generated directories in ``DOCS_VENDOR_DIRS``.
267 rels = set(first_party_paths(tuple(SCAN_EXTS)))
268 for name
in SCAN_BASENAMES:
269 rels |= {rel
for rel
in first_party_paths((name,))
if Path(rel).name == name}
271 for rel
in sorted(rels):
272 if set(Path(rel).parts) & DOCS_VENDOR_DIRS:
274 out.append(root / rel)
278def scan_file(path: Path, root: Path) -> list[tuple[Path, int, str, str]]:
279 """Report every legacy-terminology identifier in one file.
281 Self-exempt files -- this checker, the terminology policy -- are skipped
282 whole, since they must name the banned terms to define them.
284 rel = path.relative_to(root)
286 if rel_str
in SELF_EXEMPT_FILES:
289 text = path.read_text(encoding=
"utf-8")
290 except (OSError, UnicodeDecodeError):
292 out: list[tuple[Path, int, str, str]] = []
293 lines = text.splitlines()
294 exempt = exempt_lines(lines, join=joins_lines(path))
295 for lineno, line
in enumerate(lines, start=1):
299 for term, regex
in PATTERNS:
300 if not regex.search(line):
302 out.append((rel, lineno, term, line.rstrip()))
307 ident = identifier_violation(line)
308 if ident
is not None:
309 out.append((rel, lineno, f
"symbol:{ident}", line.rstrip()))
313def _assert_term_detection(failures: list[str]) ->
None:
314 """Assert legacy terms fire and their legitimate look-alikes stay quiet.
316 The quiet direction is the load-bearing one: a vendored ``ux_`` symbol
317 and a silicon register-bit name both contain a legacy token and must
318 NOT be reported, or the gate becomes noise the tree learns to ignore.
321 failures: Accumulator every ``expect`` appends its message to.
325 (
"a legacy identifier",
"ra8_err_t ra8_spi_master_init(void);"),
326 (
"the bare MOSI token",
"// route MOSI to the header"),
327 (
'the "Slave Select" phrase',
"assert the Slave Select line"),
329 for label, line
in fire_lines:
330 prose = any(regex.search(line)
for _, regex
in PATTERNS)
331 ident = identifier_violation(line)
is not None
332 expect(prose
or ident, f
"MUST FIRE: {label}", failures)
335 (
"an inclusive rewrite",
"ra8_err_t ra8_spi_controller_init(void);"),
336 (
"a vendored ux_ symbol",
"ux_device_class_storage_master_read();"),
337 (
"the MASTEREN register bit",
"DPHYMDC.MASTEREN = 1U; // silicon name"),
339 for label, line
in quiet_lines:
340 prose = any(regex.search(line)
for _, regex
in PATTERNS)
341 ident = identifier_violation(line)
is not None
342 expect(
not (prose
or ident), f
"MUST NOT FIRE: {label}", failures)
345def _assert_continuation_exemption(failures: list[str]) ->
None:
346 """Assert a LEGACY-OK marker attaches to its construct and no further.
349 failures: Accumulator every ``expect`` appends its message to.
358 "#define SPI_MASTER_TIMEOUT_MS \\",
361 annotated_wrapped = [
362 "#define SPI_MASTER_TIMEOUT_MS \\",
363 " (-1) /* LEGACY-OK: upstream vendor macro name */",
366 "#define SPI_MASTER_TIMEOUT_MS (-1)",
367 "/* LEGACY-OK: annotates the next construct, not the previous one */",
370 exempt_lines(wrapped, join=
True) == frozenset(),
371 "MUST FIRE: a continued macro with no LEGACY-OK anywhere in the run",
375 exempt_lines(annotated_wrapped, join=
True) == frozenset({1, 2}),
376 "MUST NOT FIRE: LEGACY-OK on the continuation exempts the macro head",
380 exempt_lines(unwrapped, join=
True) == frozenset({2}),
381 "MUST FIRE: LEGACY-OK does not reach back over a completed logical line",
385 exempt_lines(annotated_wrapped, join=
False) == frozenset({2}),
386 "MUST FIRE: a trailing backslash joins nothing where it is ordinary text",
390 joins_lines(Path(
"a.h"))
and not joins_lines(Path(
"a.yml")),
391 "continuation languages are classified by suffix",
396def selftest() -> int:
397 """Prove the detector fires on legacy terms, spares the legitimate ones, and scans real files.
399 Both directions plus a scope probe: a legacy identifier and the bare prose
400 tokens must FIRE, while an inclusive rewrite, a vendored-namespace symbol
401 and a hardware register-bit name must stay QUIET; the derived scope must
402 clear ``FILE_FLOOR`` and reach the roots a hardcoded list had dropped
403 (``infra/``, ``just/``). A clean run over a scope that never sees those roots,
404 or one whose detector had stopped matching, proves nothing.
407 0 when every assertion held in both directions, 1 otherwise.
409 failures: list[str] = []
410 _assert_term_detection(failures)
411 _assert_continuation_exemption(failures)
413 root = Path(__file__).resolve().parents[2]
414 files = iter_source_files(root)
415 rels = {str(p.relative_to(root))
for p
in files
if p.is_relative_to(root)}
417 len(files) >= FILE_FLOOR,
418 f
"derived scope sees {len(files)} file(s) (floor {FILE_FLOOR})",
421 for root_name
in (
"infra",
"just"):
423 any(rel.startswith(root_name +
"/")
for rel
in rels),
424 f
"the derived scope reaches {root_name}/ (previously omitted)",
427 return report(failures)
431 """Enforce OSHWA-inclusive terminology on first-party identifiers.
433 Scoped to identifiers rather than all text on purpose: vendor manuals and
434 external APIs still use the legacy terms, and comments mapping our names
435 onto theirs are required elsewhere in this tree. The rule governs what
436 this codebase NAMES, not what it may mention.
438 Returns 1 listing each offending symbol, 0 when the tree is clean, 2 when
439 the derived scope collapsed below FILE_FLOOR.
441 if "--selftest" in sys.argv[1:]:
443 root = Path(__file__).resolve().parents[2]
444 files = iter_source_files(root)
445 if len(files) < FILE_FLOOR:
447 f
"inclusive-terminology: FATAL -- only {len(files)} file(s) in scope, floor "
448 f
"is {FILE_FLOOR}. A collapsed scope reports a clean tree because it scanned "
452 findings: list[tuple[Path, int, str, str]] = []
454 findings.extend(scan_file(f, root))
457 print(
"inclusive-terminology: 0 violations -- gate clean.")
460 print(f
"inclusive-terminology: {len(findings)} violations found.")
461 for rel, lineno, term, line
in findings[:MAX_FINDINGS_SHOWN]:
462 snippet = line
if len(line) <= MAX_SNIPPET_LEN
else line[:SNIPPET_TRUNCATE_LEN] +
"..."
463 print(f
" {rel}:{lineno} [{term}] {snippet}")
464 if len(findings) > MAX_FINDINGS_SHOWN:
465 print(f
" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)")
467 print(
"Per-line opt-out: append `LEGACY-OK: <reason>` on the offending line.")
468 print(
"See CLAUDE.md 'Terminology Standard' for the policy.")
471 print(
"WARN_ONLY_MODE=True -- not failing the gate.")
476if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.