ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_inclusive_terminology.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"""check_inclusive_terminology.py -- inclusive-terminology gate for ra8-firmware.
5
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.
10
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).
15
16Exit code:
17 0 -- no violations (gate clean), or warn-only mode is on
18 1 -- violations exist (only when WARN_ONLY_MODE is False)
19
20The script is intentionally fast (pure-Python regex scan, no libclang) so
21the pre-commit hook stays interactive.
22
23@copyright Copyright (c) 2026 Brighton Sikarskie
24SPDX-License-Identifier: MIT
25"""
26
27from __future__ import annotations
28
29import re
30import sys
31from pathlib import Path
32
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34
35from lint_targets import first_party_paths
36from selftest_assert import expect, report
37
38# --------------------------------------------------------------------------
39# Configuration
40# --------------------------------------------------------------------------
41
42# + sweep brought first-party source to 0 violations and the gate is
43# now strict (False). If a future agent reintroduces a violation the gate
44# fails the commit; either rewrite the wording (preferred for our own
45# prose and symbols) or, only where an upstream string must appear
46# verbatim (a Renesas HUM section title, a literal FSP / USBX API symbol),
47# annotate that single line with `LEGACY-OK: <reason>`. There is no
48# whole-file escape hatch -- every line stands on its own.
49WARN_ONLY_MODE: bool = False
50
51# Directories whose CONTENT is vendored or generated and not ours to police:
52# docs/reference/ holds committed vendor PDFs and register maps (HUM register
53# names literally spell MASTEREN and the like), and docs/**/doxygen,
54# docs/**/html are generated Doxygen output. first_party_paths already drops
55# third_party/ and build output; these three are the docs-side equivalents the
56# old SKIP_DIR_NAMES carried. Matched by path component, as the walk did.
57DOCS_VENDOR_DIRS: frozenset[str] = frozenset({"reference", "doxygen", "html"})
58
59# File extensions we scan. Anything else is binary / not-our-source.
60SCAN_EXTS: frozenset[str] = frozenset(
61 {
62 ".c",
63 ".h",
64 ".cpp",
65 ".hpp",
66 ".cc",
67 ".cmake",
68 ".mk",
69 ".just",
70 ".md",
71 ".yml",
72 ".yaml",
73 ".sh",
74 ".py",
75 ".txt",
76 ".csv", # docs/MCDC_GAPS.csv and other generated/authored tables
77 }
78)
79
80# Filenames (no extension) we still want to scan.
81SCAN_BASENAMES: frozenset[str] = frozenset({"justfile", "Justfile", "Dockerfile", "CMakeLists.txt"})
82
83# A tree this size cannot legitimately collapse to a handful of files. A scan
84# that enumerates almost nothing reports a clean tree because it read almost
85# nothing -- the exact failure the gate-honesty epic (#190) exists to prevent.
86# Measured 2026-08-02: 3415 first-party files in the derived scope. Same
87# trip-wire as check_ruff.py.
88FILE_FLOOR = 2500
89
90# Prose patterns: word-boundary matches that catch the legacy vocabulary
91# wherever it appears as a standalone word -- prose comments ("master
92# enable"), HUM section titles, "Slave Select", the bare "SS" pin name.
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")), # SPI Slave Select pin abbreviation -- use CS
100)
101
102# Identifier-embedded detection.
103#
104# Python's `\b` does not fire between an underscore and a letter (`_` is a
105# word character), so the prose patterns above miss the legacy words when
106# they are welded into a snake_case / SCREAMING_CASE symbol
107# (`internal_spcr_master`, `make_master_i2s_cfg`,
108# ...). Instead of enumerating our own prefixes, flag ANY identifier that
109# carries `master`/`slave` as a leading component and is NOT part of a
110# vendored upstream namespace -- those APIs are referenced verbatim in our
111# glue code and cannot be renamed.
112IDENT_RE: re.Pattern[str] = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
113# `master`/`slave` as a component: at the identifier start or just after an
114# underscore (so `MASTEREN`, `ra8_spi_master_init`, `_slave` all match, while
115# camelCase `offsetFromMaster` and substrings like `enslave` do not).
116IDENT_TERM_RE: re.Pattern[str] = re.compile(r"(?:^|_)(?:master|slave)", re.IGNORECASE)
117# Upstream namespaces whose symbols legitimately appear in first-party glue
118# and are cited verbatim: Express Logic USBX / ThreadX / NetX / FileX /
119# LevelX, Renesas FSP (r_iic_* / r_sce_*), Mbed TLS, NimBLE / Mynewt, and
120# Espressif esp-hosted. Matched at the identifier head.
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)",
125 re.IGNORECASE,
126)
127# Hardware register-bit names that spell a legacy token verbatim (Renesas
128# MIPI D-PHY DPHYMDC.MASTEREN); these are silicon names, not our symbols.
129HW_TOKENS: frozenset[str] = frozenset({"MASTEREN"})
130
131# Per-line opt-out marker.
132LEGACY_OK_RE: re.Pattern[str] = re.compile(r"LEGACY-OK\s*:")
133
134# Languages in which a trailing backslash joins physical lines into ONE
135# logical line. The opt-out is documented as per-line, and an author annotates
136# the line they see -- but a formatter is free to wrap a long `#define` after
137# the macro name, stranding the annotation on the continuation while the
138# offending identifier stays on the head. The marker then silences nothing and
139# the gate fails on a line its author believed was annotated. That is exactly
140# what happened to `H_HOST_RESTART_NO_COMMUNICATION_WITH_SLAVE_TIMEOUT_MS`,
141# whose LEGACY-OK went inert the moment its reason grew past the column limit
142# and clang-format wrapped the definition. Honouring the marker anywhere in
143# the continued run keeps the annotation attached to the construct instead of
144# to the formatter's line breaks. It cannot reach any other logical line, so
145# nothing else becomes exemptible. Restricted to the suffixes where a trailing
146# backslash genuinely continues a line: in YAML, Markdown and CSV it is
147# ordinary text, and joining there would let an unrelated neighbouring line
148# exempt a real violation.
149CONTINUATION_EXTS: frozenset[str] = frozenset(
150 {".c", ".h", ".cpp", ".hpp", ".cc", ".sh", ".mk", ".py"}
151)
152CONTINUATION_BASENAMES: frozenset[str] = frozenset({"Dockerfile", "Makefile"})
153
154
155def joins_lines(path: Path) -> bool:
156 """Whether a trailing backslash continues a line in this file's language.
157
158 Args:
159 path: The scanned file, classified by suffix then by bare filename.
160
161 Returns:
162 True when a trailing backslash is a line continuation there.
163 """
164 return path.suffix in CONTINUATION_EXTS or path.name in CONTINUATION_BASENAMES
165
166
167def exempt_lines(lines: list[str], *, join: bool) -> frozenset[int]:
168 """One-based line numbers whose LOGICAL line carries a LEGACY-OK opt-out.
169
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.
174
175 Args:
176 lines: The file's physical lines, in order, without line endings.
177 join: Whether a trailing backslash continues a line in this language.
178
179 Returns:
180 The exempt one-based line numbers.
181 """
182 exempt: set[int] = set()
183 run: list[int] = []
184 annotated = False
185 for lineno, line in enumerate(lines, start=1):
186 run.append(lineno)
187 annotated = annotated or bool(LEGACY_OK_RE.search(line))
188 if join and line.endswith("\\"):
189 continue
190 if annotated:
191 exempt.update(run)
192 run = []
193 annotated = False
194 if annotated:
195 exempt.update(run)
196 return frozenset(exempt)
197
198
199def identifier_violation(line: str) -> str | None:
200 """The offending identifier when a line names a symbol with legacy terminology.
201
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.
205
206 Returns None when the line is clean.
207
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.
211 """
212 for m in IDENT_RE.finditer(line):
213 tok = m.group(0)
214 if not IDENT_TERM_RE.search(tok):
215 continue
216 if tok in HW_TOKENS:
217 continue
218 if VENDOR_IDENT_RE.match(tok):
219 continue
220 return tok
221 return None
222
223
224# Output display limits.
225MAX_SNIPPET_LEN = 120
226SNIPPET_TRUNCATE_LEN = 117
227MAX_FINDINGS_SHOWN = 50
228
229# Self-exempt: a short, closed list of files that MUST spell the banned
230# vocabulary to do their job. There is NO vendor-file escape hatch here --
231# a file dominated by upstream symbols annotates each such line with
232# `LEGACY-OK: <reason>` instead of exempting the whole file.
233#
234# Only two kinds of file qualify:
235# 1. The detection scripts, which hold the banned terms as regex literals.
236# 2. The policy documents that DEFINE the terminology standard by quoting
237# the words they ban (CLAUDE.md, docs/STYLE_GUIDE.md, and the
238# style-reviewer subagent whose Terminology Standard section instructs
239# "Use Controller/Peripheral instead of master/slave").
240SELF_EXEMPT_FILES: frozenset[str] = frozenset(
241 {
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",
246 "CLAUDE.md",
247 ".claude/agents/style-reviewer.md",
248 }
249)
250
251
252# --------------------------------------------------------------------------
253# Implementation
254# --------------------------------------------------------------------------
255
256
257def iter_source_files(root: Path) -> list[Path]:
258 """Every in-scope first-party file, derived from git rather than a root list.
259
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``.
266 """
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}
270 out: list[Path] = []
271 for rel in sorted(rels):
272 if set(Path(rel).parts) & DOCS_VENDOR_DIRS:
273 continue
274 out.append(root / rel)
275 return out
276
277
278def scan_file(path: Path, root: Path) -> list[tuple[Path, int, str, str]]:
279 """Report every legacy-terminology identifier in one file.
280
281 Self-exempt files -- this checker, the terminology policy -- are skipped
282 whole, since they must name the banned terms to define them.
283 """
284 rel = path.relative_to(root)
285 rel_str = str(rel)
286 if rel_str in SELF_EXEMPT_FILES:
287 return []
288 try:
289 text = path.read_text(encoding="utf-8")
290 except (OSError, UnicodeDecodeError):
291 return []
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):
296 if lineno in exempt:
297 continue
298 matched = False
299 for term, regex in PATTERNS:
300 if not regex.search(line):
301 continue
302 out.append((rel, lineno, term, line.rstrip()))
303 matched = True
304 break
305 if matched:
306 continue
307 ident = identifier_violation(line)
308 if ident is not None:
309 out.append((rel, lineno, f"symbol:{ident}", line.rstrip()))
310 return out
311
312
313def _assert_term_detection(failures: list[str]) -> None:
314 """Assert legacy terms fire and their legitimate look-alikes stay quiet.
315
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.
319
320 Args:
321 failures: Accumulator every ``expect`` appends its message to.
322 """
323 # Prose / identifier detection, driven through scan_line-equivalent logic.
324 fire_lines = (
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"),
328 )
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)
333
334 quiet_lines = (
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"),
338 )
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)
343
344
345def _assert_continuation_exemption(failures: list[str]) -> None:
346 """Assert a LEGACY-OK marker attaches to its construct and no further.
347
348 Args:
349 failures: Accumulator every ``expect`` appends its message to.
350 """
351 # Continued logical lines. A backslash-wrapped `#define` puts the offending
352 # identifier on the head and lets the formatter strand the annotation on the
353 # continuation; the opt-out must still attach to the construct. Asserted in
354 # both directions, and the over-exemption direction is asserted twice: a
355 # marker must not leak across a completed logical line, nor into a language
356 # where a trailing backslash is ordinary text rather than a continuation.
357 wrapped = [
358 "#define SPI_MASTER_TIMEOUT_MS \\",
359 " (-1)",
360 ]
361 annotated_wrapped = [
362 "#define SPI_MASTER_TIMEOUT_MS \\",
363 " (-1) /* LEGACY-OK: upstream vendor macro name */",
364 ]
365 unwrapped = [
366 "#define SPI_MASTER_TIMEOUT_MS (-1)",
367 "/* LEGACY-OK: annotates the next construct, not the previous one */",
368 ]
369 expect(
370 exempt_lines(wrapped, join=True) == frozenset(),
371 "MUST FIRE: a continued macro with no LEGACY-OK anywhere in the run",
372 failures,
373 )
374 expect(
375 exempt_lines(annotated_wrapped, join=True) == frozenset({1, 2}),
376 "MUST NOT FIRE: LEGACY-OK on the continuation exempts the macro head",
377 failures,
378 )
379 expect(
380 exempt_lines(unwrapped, join=True) == frozenset({2}),
381 "MUST FIRE: LEGACY-OK does not reach back over a completed logical line",
382 failures,
383 )
384 expect(
385 exempt_lines(annotated_wrapped, join=False) == frozenset({2}),
386 "MUST FIRE: a trailing backslash joins nothing where it is ordinary text",
387 failures,
388 )
389 expect(
390 joins_lines(Path("a.h")) and not joins_lines(Path("a.yml")),
391 "continuation languages are classified by suffix",
392 failures,
393 )
394
395
396def selftest() -> int:
397 """Prove the detector fires on legacy terms, spares the legitimate ones, and scans real files.
398
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.
405
406 Returns:
407 0 when every assertion held in both directions, 1 otherwise.
408 """
409 failures: list[str] = []
410 _assert_term_detection(failures)
411 _assert_continuation_exemption(failures)
412
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)}
416 expect(
417 len(files) >= FILE_FLOOR,
418 f"derived scope sees {len(files)} file(s) (floor {FILE_FLOOR})",
419 failures,
420 )
421 for root_name in ("infra", "just"):
422 expect(
423 any(rel.startswith(root_name + "/") for rel in rels),
424 f"the derived scope reaches {root_name}/ (previously omitted)",
425 failures,
426 )
427 return report(failures)
428
429
430def main() -> int:
431 """Enforce OSHWA-inclusive terminology on first-party identifiers.
432
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.
437
438 Returns 1 listing each offending symbol, 0 when the tree is clean, 2 when
439 the derived scope collapsed below FILE_FLOOR.
440 """
441 if "--selftest" in sys.argv[1:]:
442 return selftest()
443 root = Path(__file__).resolve().parents[2]
444 files = iter_source_files(root)
445 if len(files) < FILE_FLOOR:
446 sys.stderr.write(
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 "
449 "nothing.\n"
450 )
451 return 2
452 findings: list[tuple[Path, int, str, str]] = []
453 for f in files:
454 findings.extend(scan_file(f, root))
455
456 if not findings:
457 print("inclusive-terminology: 0 violations -- gate clean.")
458 return 0
459
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)")
466 print()
467 print("Per-line opt-out: append `LEGACY-OK: <reason>` on the offending line.")
468 print("See CLAUDE.md 'Terminology Standard' for the policy.")
469
470 if WARN_ONLY_MODE:
471 print("WARN_ONLY_MODE=True -- not failing the gate.")
472 return 0
473 return 1
474
475
476if __name__ == "__main__":
477 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298