ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
tidy_ratchet.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"""tidy_ratchet.py -- clang-tidy ratchet gate (compare vs committed baseline).
5
6WHY THIS EXISTS
7---------------
8#369 and #370 brought 435 firmware C files and 12 first-party C++/Objective-C
9files into clang-tidy's scope for the first time. They had never been analysed,
10so they arrive carrying pre-existing findings. Two bad options were available
11and both were rejected:
12
13* leave the new surface OUT of the gate -- which is the status quo the two
14 issues exist to end, and the reason nobody knew what was in there;
15* switch the offending checks off -- which converts a measured debt into a
16 permanent blind spot, and is exactly the "gate that silently does nothing"
17 pattern this tree keeps finding.
18
19So the new surface is IN the gate, every finding is counted, and this ratchet
20holds the line: it is the same shape as `misra_ratchet.py`, which this tree
21already uses for precisely this situation.
22
23* NEW findings (any per-file-per-check count above the baseline, or any
24 file/check pair absent from the baseline) FAIL.
25* Shrinkage PASSES with a notice to re-baseline, which locks the progress in
26 so the debt can never quietly grow back.
27
28Closing the debt means the baseline reaching zero rows and being deleted --
29not being regenerated larger. `--update` refuses to grow a bucket for exactly
30that reason; a genuine increase has to be justified by a human editing the
31file, which leaves a reviewable diff.
32
33BASELINE NORMALISATION -- per-file-per-check COUNTS, not raw finding lines.
34Raw clang-tidy findings carry line numbers, which churn on every unrelated edit
35above them, and message text, which embeds identifiers. A `(file, check) ->
36count` map is invariant under both, still trips the moment a file gains another
37violation of a check, and stays small and diffable.
38
39USAGE
40 python3 scripts/checks/tidy_ratchet.py --selftest # assert it fires
41 python3 scripts/checks/tidy_ratchet.py --check LOGFILE # the gate
42 python3 scripts/checks/tidy_ratchet.py --update LOGFILE # re-baseline
43
44Copyright (c) 2026 Brighton Sikarskie
45SPDX-License-Identifier: MIT
46"""
47
48from __future__ import annotations
49
50import argparse
51import re
52import sys
53from collections import Counter
54from pathlib import Path
55
56REPO_ROOT = Path(__file__).resolve().parents[2]
57BASELINE_FILE = REPO_ROOT / ".github" / "tidy-baseline.txt"
58
59MAX_DETAIL_LINES = 10
60"""Cap on offending buckets echoed before the report truncates."""
61
62BASELINE_COLUMNS = 3
63"""Column count of one baseline row: file, check, count."""
64
65DIAGNOSTIC_ERROR_CHECK = "clang-diagnostic-error"
66"""The clang "check" that is really a PARSE failure, not a lint finding."""
67
68MAX_DIAGNOSTIC_ERROR_RATE = 0.20
69"""Refuse to ratchet when more than this fraction of findings are parse errors.
70
71A clang-diagnostic-error means clang-tidy could not PARSE a translation unit
72(a missing header, an unusable compile command) rather than that it found a
73real defect. When those dominate a run, nothing was actually analysed -- the
74#387 case, where an unusable arm-none-eabi-gcc stripped the firmware pass of
75every system include and turned ~100 TUs into "'string.h' file not found". The
76healthy baseline runs at 37 of 415 findings (9%) with the toolchain correct, so
770.20 clears real runs with margin while a mass parse failure (which pushes the
78rate past 25%) trips it.
79"""
80
81DIAGNOSTIC_ERROR_FLOOR = 60
82"""...and only once at least this many parse errors are present, so a tiny run
83whose handful of findings happen to be parse errors does not trip. Above the
84healthy baseline's 37, below the ~137 a broken firmware pass produces."""
85
86# One clang-tidy diagnostic line:
87# /abs/path/file.c:12:34: warning: text [check-name,-warnings-as-errors]
88FINDING_RE = re.compile(
89 r"^(?P<path>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+):\s+"
90 r"(?:warning|error):\s+.*\‍[(?P<checks>[A-Za-z0-9_.,-]+)\‍]\s*$"
91)
92
93# clang-tidy appends this to every check name when WarningsAsErrors is on; it
94# is not a check and must not become a baseline key.
95NOT_A_CHECK = "-warnings-as-errors"
96
97
98def parse_log(text: str) -> Counter:
99 """Return a {(repo-relative file, check): count} Counter for `text`.
100
101 A finding may list several aliases for one diagnostic
102 (`bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp`). The FIRST is
103 used as the canonical key so an alias-set reordering between clang-tidy
104 versions cannot silently rewrite every baseline row.
105 """
106 counts: Counter = Counter()
107 seen: set[tuple[str, int, int, str]] = set()
108 for raw in text.splitlines():
109 m = FINDING_RE.match(raw.strip())
110 if not m:
111 continue
112 names = [c for c in m.group("checks").split(",") if c and c != NOT_A_CHECK]
113 if not names:
114 continue
115 check = names[0]
116 path = m.group("path")
117 try:
118 rel = str(Path(path).resolve().relative_to(REPO_ROOT))
119 except ValueError:
120 rel = path
121 # clang-tidy repeats a header diagnostic once per TU that includes it.
122 key = (rel, int(m.group("line")), int(m.group("col")), check)
123 if key in seen:
124 continue
125 seen.add(key)
126 counts[(rel, check)] += 1
127 return counts
128
129
130def load_baseline() -> Counter:
131 """Read the committed baseline into a Counter. Missing file means empty."""
132 counts: Counter = Counter()
133 if not BASELINE_FILE.is_file():
134 return counts
135 for raw in BASELINE_FILE.read_text(encoding="ascii").splitlines():
136 line = raw.strip()
137 if not line or line.startswith("#"):
138 continue
139 parts = line.split("\t")
140 if len(parts) != BASELINE_COLUMNS:
141 continue
142 counts[(parts[0], parts[1])] = int(parts[2])
143 return counts
144
145
146def write_baseline(counts: Counter) -> None:
147 """Write `counts` out in the committed, sorted, diffable form."""
148 lines = [
149 "# clang-tidy ratchet baseline -- per-file-per-check finding counts.",
150 "# Consumed by scripts/checks/tidy_ratchet.py --check (CI gate: tidy).",
151 "#",
152 "# These are PRE-EXISTING findings on the surface that #369 and #370",
153 "# brought into clang-tidy's scope for the first time. The gate fails on",
154 "# any INCREASE, so the debt is frozen and can only be burned down.",
155 "#",
156 "# Regenerate after burning findings down:",
157 "# bash scripts/checks/clang_tidy.sh --check > /tmp/tidy.log 2>&1",
158 "# python3 scripts/checks/tidy_ratchet.py --update /tmp/tidy.log",
159 "#",
160 "# Closing this out means this file reaching zero rows and being",
161 "# DELETED -- never regenerated larger.",
162 "#",
163 "# file<TAB>check<TAB>count",
164 ]
165 for (path, check), n in sorted(counts.items()):
166 if n:
167 lines.append(f"{path}\t{check}\t{n}")
168 BASELINE_FILE.write_text("\n".join(lines) + "\n", encoding="ascii")
169
170
171def diagnostic_error_reason(current: Counter) -> str | None:
172 """Return a refusal reason if the run is dominated by parse errors, else None.
173
174 Guards both directions of the ratchet (#387): a run whose findings are
175 mostly clang-diagnostic-error has not analysed its code, it has failed to
176 compile it. Writing that as the baseline freezes ~100 files of parse errors
177 as the accepted state, and comparing against it hides every real finding in
178 those files. So refuse to ``--update`` or ``--check`` such a run and name
179 the likely cause instead.
180
181 Trips only when BOTH the absolute count clears ``DIAGNOSTIC_ERROR_FLOOR``
182 and the fraction clears ``MAX_DIAGNOSTIC_ERROR_RATE`` -- a healthy run
183 carrying a few baselined parse errors must not be mistaken for a broken one.
184 """
185 total = sum(current.values())
186 if total == 0:
187 return None
188 diag = sum(n for (_path, check), n in current.items() if check == DIAGNOSTIC_ERROR_CHECK)
189 rate = diag / total
190 if diag < DIAGNOSTIC_ERROR_FLOOR or rate <= MAX_DIAGNOSTIC_ERROR_RATE:
191 return None
192 return (
193 f"{diag} of {total} finding(s) ({rate:.0%}) are {DIAGNOSTIC_ERROR_CHECK} "
194 f"-- over the {MAX_DIAGNOSTIC_ERROR_RATE:.0%} ceiling.\n"
195 " clang-tidy could not PARSE most of its input. The usual cause is an\n"
196 " unusable cross-compiler stripping the firmware pass of its system\n"
197 " includes (#387): run the tidy gate with the pinned 13.3\n"
198 " arm-none-eabi-gcc on PATH. A baseline built from a broken parse is\n"
199 " worse than no baseline, so this run is refused for ratcheting."
200 )
201
202
203def report(current: Counter, baseline: Counter) -> int:
204 """Compare and print. Returns the process exit code."""
205 grown = []
206 for key, n in sorted(current.items()):
207 was = baseline.get(key, 0)
208 if n > was:
209 grown.append((key, was, n))
210
211 total_now = sum(current.values())
212 total_was = sum(baseline.values())
213
214 if grown:
215 print(file=sys.stderr)
216 print("clang-tidy ratchet: NEW findings above the committed baseline", file=sys.stderr)
217 print(file=sys.stderr)
218 for (path, check), was, now in grown[:MAX_DETAIL_LINES]:
219 print(f" {path}\n {check}: {was} -> {now}", file=sys.stderr)
220 if len(grown) > MAX_DETAIL_LINES:
221 print(f" ... and {len(grown) - MAX_DETAIL_LINES} more bucket(s)", file=sys.stderr)
222 print(file=sys.stderr)
223 print(
224 "Fix them. The baseline is a burn-down of debt that predates the\n"
225 "scope widening, not a place to record new debt.",
226 file=sys.stderr,
227 )
228 return 1
229
230 print(f"clang-tidy ratchet: {total_now} finding(s), baseline {total_was} -- no growth.")
231 if total_now < total_was:
232 burned = total_was - total_now
233 print(f" {burned} finding(s) burned down. Re-baseline to lock it in:")
234 print(" python3 scripts/checks/tidy_ratchet.py --update <log>")
235 return 0
236
237
238def _selftest_parse() -> list[str]:
239 """Assertions about log parsing."""
240 failures: list[str] = []
241
242 sample = (
243 "/repo/examples/a/main.c:12:3: warning: x is bad "
244 "[readability-magic-numbers,-warnings-as-errors]\n"
245 "/repo/examples/a/main.c:14:5: error: y is worse "
246 "[bugprone-reserved-identifier,cert-dcl37-c]\n"
247 "some unrelated build noise\n"
248 " 12 | static uint32_t s_pass = 0U;\n"
249 )
250 counts = parse_log(sample)
251 if sum(counts.values()) != 2: # noqa: PLR2004 -- the fixture plants exactly 2
252 failures.append(f"parse_log() found {sum(counts.values())} finding(s), expected 2")
253 checks = {check for _, check in counts}
254 if "bugprone-reserved-identifier" not in checks:
255 failures.append("parse_log() did not canonicalise an alias set to its first name")
256 if NOT_A_CHECK in checks:
257 failures.append("parse_log() treated -warnings-as-errors as a check name")
258
259 # A repeated header diagnostic (same file/line/col/check) counts ONCE.
260 dupe = parse_log(
261 "/repo/libs/x.h:9:1: warning: z [misc-x,-warnings-as-errors]\n"
262 "/repo/libs/x.h:9:1: warning: z [misc-x,-warnings-as-errors]\n"
263 )
264 if sum(dupe.values()) != 1:
265 failures.append(f"parse_log() counted a duplicated header finding {sum(dupe.values())}x")
266
267 return failures
268
269
270def _selftest_ratchet() -> list[str]:
271 """Assertions about the growth verdict and baseline round-tripping."""
272 failures: list[str] = []
273
274 # The gate must FAIL on growth ...
275 base = Counter({("f.c", "misc-x"): 1})
276 if report(Counter({("f.c", "misc-x"): 2}), base) == 0:
277 failures.append("report() passed a bucket that GREW above the baseline")
278 # ... and on a bucket the baseline has never seen ...
279 if report(Counter({("new.c", "misc-x"): 1}), base) == 0:
280 failures.append("report() passed a finding in a file absent from the baseline")
281 # ... and PASS when equal or shrinking.
282 if report(Counter({("f.c", "misc-x"): 1}), base) != 0:
283 failures.append("report() failed an unchanged bucket")
284 if report(Counter(), base) != 0:
285 failures.append("report() failed a fully burned-down baseline")
286
287 # Round-trip: what is written must read back identically, or a re-baseline
288 # would silently reshape the debt it claims to be freezing.
289 original = BASELINE_FILE.read_text(encoding="ascii") if BASELINE_FILE.is_file() else None
290 try:
291 fixture = Counter({("a/b.c", "misc-x"): 3, ("a/c.c", "readability-y"): 1})
292 write_baseline(fixture)
293 if load_baseline() != fixture:
294 failures.append("write_baseline()/load_baseline() did not round-trip")
295 finally:
296 if original is None:
297 BASELINE_FILE.unlink(missing_ok=True)
298 else:
299 BASELINE_FILE.write_text(original, encoding="ascii")
300
301 return failures
302
303
304def _selftest_diag_guard() -> list[str]:
305 """Assertions about the parse-error guard (#387), in both directions."""
306 failures: list[str] = []
307
308 # A run dominated by parse errors must be refused for ratcheting ...
309 broken = Counter({(f"examples/tu{i}.c", DIAGNOSTIC_ERROR_CHECK): 1 for i in range(100)})
310 if diagnostic_error_reason(broken) is None:
311 failures.append("diagnostic_error_reason() accepted a run that is 100% parse errors")
312
313 # ... while a healthy mix carrying a few baselined parse errors is accepted.
314 healthy: Counter = Counter(
315 {(f"libs/f{i}.c", "readability-magic-numbers"): 1 for i in range(378)}
316 )
317 healthy.update({(f"examples/m{i}.c", DIAGNOSTIC_ERROR_CHECK): 1 for i in range(37)})
318 if diagnostic_error_reason(healthy) is not None:
319 failures.append("diagnostic_error_reason() rejected the healthy 37/415 baseline mix")
320
321 # A tiny run whose few findings are all parse errors must not trip (below
322 # the absolute floor): the signature is a MASS failure, not a stray error.
323 tiny = Counter({(f"x{i}.c", DIAGNOSTIC_ERROR_CHECK): 1 for i in range(5)})
324 if diagnostic_error_reason(tiny) is not None:
325 failures.append("diagnostic_error_reason() tripped on a handful of parse errors")
326
327 return failures
328
329
330def selftest() -> int:
331 """Assert the parser and the ratchet fire, in BOTH directions."""
332 failures = _selftest_parse() + _selftest_ratchet() + _selftest_diag_guard()
333
334 if failures:
335 print("SELFTEST FAILED:", file=sys.stderr)
336 for problem in failures:
337 print(f" - {problem}", file=sys.stderr)
338 return 1
339 print("selftest: clang-tidy ratchet parse + growth detection OK")
340 return 0
341
342
343def main() -> int:
344 """Ratchet clang-tidy findings against the committed baseline.
345
346 A ratchet, not a floor: ``--check`` fails when the count RISES, and
347 ``--update`` lowers the baseline once findings are fixed. The baseline can
348 therefore only move downward, which is what stops a large legacy count
349 from being permanently accepted.
350
351 ``--selftest`` asserts the comparison still fires, so a ratchet that
352 stopped detecting growth cannot pass as clean.
353
354 Returns 0 when the count is at or below the baseline, 1 when it grew.
355 """
356 parser = argparse.ArgumentParser(description=__doc__)
357 parser.add_argument("log", nargs="?", help="clang-tidy output to compare")
358 parser.add_argument("--check", action="store_true", help="gate against the baseline")
359 parser.add_argument("--update", action="store_true", help="rewrite the baseline")
360 parser.add_argument("--selftest", action="store_true", help="assert this gate still fires")
361 args = parser.parse_args()
362
363 if args.selftest:
364 return selftest()
365 if not args.log:
366 parser.error("a clang-tidy log path is required unless --selftest is given")
367
368 text = Path(args.log).read_text(encoding="utf-8", errors="replace")
369 current = parse_log(text)
370
371 # Refuse to ratchet a run that failed to parse its input, in EITHER
372 # direction, before comparing or writing anything (#387). A baseline built
373 # from a broken toolchain would freeze ~100 files of parse errors forever.
374 broken = diagnostic_error_reason(current)
375 if broken:
376 verb = "--update" if args.update else "--check"
377 print(f"refusing to {verb}: {broken}", file=sys.stderr)
378 return 1
379
380 if args.update:
381 baseline = load_baseline()
382 # Seeding the very first baseline necessarily "grows" every bucket from
383 # nothing, so the no-growth rule applies only once a baseline exists.
384 seeding = not BASELINE_FILE.is_file()
385 grew = [] if seeding else [k for k, n in current.items() if n > baseline.get(k, 0)]
386 if grew:
387 print(
388 f"refusing to --update: {len(grew)} bucket(s) would GROW. "
389 "The baseline is a burn-down; fix the new findings instead.",
390 file=sys.stderr,
391 )
392 for key in grew[:MAX_DETAIL_LINES]:
393 print(f" {key[0]} {key[1]}", file=sys.stderr)
394 return 1
395 write_baseline(current)
396 print(f"baseline updated: {sum(current.values())} finding(s) recorded")
397 return 0
398
399 return report(current, load_baseline())
400
401
402if __name__ == "__main__":
403 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298