ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_wave_references.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4r"""check_no_wave_references.py -- ban session-bookkeeping "Wave N" references.
5
6Rationale: comments and commit messages that cite "Wave 70 fixed FRDY" or
7"see Wave 43b" leak internal session bookkeeping into the source tree.
8Future readers do not care WHEN a fix was found, only WHY. Reference the
9function or symbol or HUM section instead.
10
11What this gate flags (case-insensitive):
12 - "Wave 70", "wave 3", "WAVE-12", "wave_43b" -- a "wave" token immediately
13 followed (after at most one [\\s_-]) by a digit. Optional trailing
14 letter(s) for sub-numbering ("wave 43b").
15 - "(Wave 12)" / "[wave-7]" -- same pattern wrapped in punctuation.
16
17What this gate does NOT flag (legitimate domain usage):
18 - "waveform", "wave shape", "saw wave", "sine wave" (no digit follows).
19 - Identifiers like "k_ra8_pdg_wave_saw", "wave_select", "wave_table".
20 - Renesas / MIPI / vendor symbols that happen to contain "wave".
21
22Per-line opt-out: append "WAVE-OK: <reason>" on the offending line. Reserve
23for unavoidable upstream-symbol citations (e.g. a Renesas register name
24that literally encodes the wording).
25
26Scope is DERIVED from ``git ls-files`` via ``lint_targets.first_party_paths``
27rather than a hardcoded root list, so a new top-level directory (``tools/``,
28``coprocessor/``, ``infra/`` and ``just/`` were the ones the old list silently
29omitted, #549) is covered the day it lands. ``--selftest`` proves the detector
30fires and stays quiet, and that the derived scope clears its floor.
31
32Exit code:
33 0 -- gate clean
34 1 -- violations exist
35 2 -- the scope collapsed below FILE_FLOOR (a scan of almost nothing)
36
37@copyright Copyright (c) 2026 Brighton Sikarskie
38SPDX-License-Identifier: MIT
39"""
40
41from __future__ import annotations
42
43import re
44import sys
45from pathlib import Path
46
47sys.path.insert(0, str(Path(__file__).resolve().parent))
48
49from lint_targets import first_party_paths
50from selftest_assert import expect, report
51
52SCAN_EXTS: frozenset[str] = frozenset(
53 {
54 ".c",
55 ".h",
56 ".cpp",
57 ".hpp",
58 ".cc",
59 ".cmake",
60 ".md",
61 ".yml",
62 ".yaml",
63 ".sh",
64 ".py",
65 ".txt",
66 ".mk",
67 ".just",
68 }
69)
70
71SCAN_BASENAMES: frozenset[str] = frozenset(
72 {"justfile", "Dockerfile", "CMakeLists.txt", "GNUmakefile", "Justfile"}
73)
74
75# Vendored / generated docs content that is not ours to police: committed
76# datasheets and register maps under docs/reference/, and any generated Doxygen
77# output (docs/**/doxygen, docs/**/html) that is tracked. first_party_paths
78# already drops third_party/ and build output; these three are the docs-side
79# equivalents the old SKIP_DIR_NAMES carried. Matched by path component, the
80# same way the previous walk skipped them.
81DOCS_VENDOR_DIRS: frozenset[str] = frozenset({"reference", "doxygen", "html"})
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: 3416 first-party files in the derived scope. Same
87# trip-wire as check_ruff.py.
88FILE_FLOOR = 2500
89
90# Maximum number of violations to print before truncating output.
91MAX_FINDINGS_SHOWN = 50
92# Maximum line length (chars) for a snippet printed in the report.
93SNIPPET_MAX_LEN = 120
94# Truncated snippet suffix consumes 3 chars ("..."), so trim to this length.
95SNIPPET_TRIM_LEN = SNIPPET_MAX_LEN - 3
96
97# "Wave 70", "wave-3", "WAVE_43b", "(Wave 12)" -- token + optional sep +
98# digit(s) + optional letter(s).
99WAVE_RE: re.Pattern[str] = re.compile(r"\b[Ww]ave[\s_\-]?\d+[A-Za-z]?\b")
100
101OPTOUT_RE: re.Pattern[str] = re.compile(r"WAVE-OK\s*:")
102
103SELF_EXEMPT_FILES: frozenset[str] = frozenset(
104 {
105 "scripts/checks/check_no_wave_references.py",
106 "scripts/fix/fix_wave_references.py",
107 "docs/STYLE_GUIDE.md",
108 "CLAUDE.md",
109 }
110)
111
112
113def iter_source_files(root: Path) -> list[Path]:
114 """Every in-scope first-party file, derived from git rather than a root list.
115
116 Enumeration goes through ``lint_targets.first_party_paths`` -- the shared
117 derived-scope primitive -- so ``tools/``, ``coprocessor/``, ``infra/`` and
118 ``just/`` (the roots a hardcoded list silently dropped, #549) are covered. The
119 only subtractions on top of what that primitive already exempts are the
120 docs-side vendored/generated directories in ``DOCS_VENDOR_DIRS``.
121 """
122 rels = set(first_party_paths(tuple(SCAN_EXTS)))
123 for name in SCAN_BASENAMES:
124 rels |= {rel for rel in first_party_paths((name,)) if Path(rel).name == name}
125 out: list[Path] = []
126 for rel in sorted(rels):
127 if set(Path(rel).parts) & DOCS_VENDOR_DIRS:
128 continue
129 out.append(root / rel)
130 return out
131
132
133def scan_file(path: Path, root: Path) -> list[tuple[Path, int, str]]:
134 """Report every "Wave N" session reference in one file.
135
136 Self-exempt files are skipped whole: this checker and the policy doc must
137 spell the banned pattern to describe it, and tagging every such line
138 individually would bury them.
139 """
140 rel = path.relative_to(root)
141 if str(rel) in SELF_EXEMPT_FILES:
142 return []
143 try:
144 text = path.read_text(encoding="utf-8")
145 except (OSError, UnicodeDecodeError):
146 return []
147 out: list[tuple[Path, int, str]] = []
148 for lineno, line in enumerate(text.splitlines(), start=1):
149 if OPTOUT_RE.search(line):
150 continue
151 if WAVE_RE.search(line):
152 out.append((rel, lineno, line.rstrip()))
153 return out
154
155
156def selftest() -> int:
157 """Prove the detector fires on a "Wave N" tag, stays quiet otherwise, and scans real files.
158
159 Both directions plus a scope probe: the numbered-session pattern must FIRE,
160 the legitimate domain uses (``waveform``, ``sine wave``, ``wave_table``)
161 must stay QUIET, the derived scope must clear ``FILE_FLOOR``, and it must
162 reach the roots a hardcoded list had dropped (``infra/``, ``just/``) -- a
163 clean run over a scope that never sees those roots proves nothing.
164
165 Returns:
166 0 when every assertion held in both directions, 1 otherwise.
167 """
168 failures: list[str] = []
169 for text, must_fire, label in (
170 ("fixed in Wave 70", True, 'MUST FIRE: "Wave 70"'),
171 ("see wave-43b for context", True, 'MUST FIRE: "wave-43b"'),
172 ("the sine wave is smooth", False, 'MUST NOT FIRE: "sine wave"'),
173 ("k_ra8_pdg_wave_saw selects the waveform", False, "MUST NOT FIRE: wave_saw / waveform"),
174 ("wave_table[0] holds the sample", False, "MUST NOT FIRE: wave_table identifier"),
175 ):
176 fired = bool(WAVE_RE.search(text))
177 expect(fired == must_fire, label, failures)
178
179 root = Path(__file__).resolve().parents[2]
180 files = iter_source_files(root)
181 rels = {str(p.relative_to(root)) for p in files if p.is_relative_to(root)}
182 expect(
183 len(files) >= FILE_FLOOR,
184 f"derived scope sees {len(files)} file(s) (floor {FILE_FLOOR})",
185 failures,
186 )
187 for root_name in ("infra", "just"):
188 expect(
189 any(rel.startswith(root_name + "/") for rel in rels),
190 f"the derived scope reaches {root_name}/ (previously omitted)",
191 failures,
192 )
193 return report(failures)
194
195
196def main() -> int:
197 """Ban session-bookkeeping "Wave N" references from the tree.
198
199 These leak an internal working chronology into source that outlives it: a
200 future reader cannot resolve "see Wave 43b" to anything, whereas the
201 function, symbol or HUM section the fix touched stays findable.
202
203 Returns 1 listing each reference, 0 when the tree is clean, 2 when the
204 derived scope collapsed below FILE_FLOOR.
205 """
206 if "--selftest" in sys.argv[1:]:
207 return selftest()
208 root = Path(__file__).resolve().parents[2]
209 files = iter_source_files(root)
210 if len(files) < FILE_FLOOR:
211 sys.stderr.write(
212 f"check_no_wave_references.py: FATAL -- only {len(files)} file(s) in scope, "
213 f"floor is {FILE_FLOOR}. A collapsed scope reports a clean tree because it "
214 "scanned nothing.\n"
215 )
216 return 2
217 findings: list[tuple[Path, int, str]] = []
218 for f in files:
219 findings.extend(scan_file(f, root))
220
221 if not findings:
222 print("no-wave-refs: 0 violations -- gate clean.")
223 return 0
224
225 print(f"no-wave-refs: {len(findings)} violations found.")
226 for rel, lineno, line in findings[:MAX_FINDINGS_SHOWN]:
227 snippet = line if len(line) <= SNIPPET_MAX_LEN else line[:SNIPPET_TRIM_LEN] + "..."
228 print(f" {rel}:{lineno} {snippet}")
229 if len(findings) > MAX_FINDINGS_SHOWN:
230 print(f" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)")
231 print()
232 print('Per-line opt-out: append "WAVE-OK: <reason>" on the offending line.')
233 print("Auto-fix helper: scripts/fix/fix_wave_references.py --apply")
234 return 1
235
236
237if __name__ == "__main__":
238 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298