4"""tidy_ratchet.py -- clang-tidy ratchet gate (compare vs committed baseline).
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:
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.
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.
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.
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.
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.
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
44Copyright (c) 2026 Brighton Sikarskie
45SPDX-License-Identifier: MIT
48from __future__
import annotations
53from collections
import Counter
54from pathlib
import Path
56REPO_ROOT = Path(__file__).resolve().parents[2]
57BASELINE_FILE = REPO_ROOT /
".github" /
"tidy-baseline.txt"
60"""Cap on offending buckets echoed before the report truncates."""
63"""Column count of one baseline row: file, check, count."""
65DIAGNOSTIC_ERROR_CHECK =
"clang-diagnostic-error"
66"""The clang "check" that is really a PARSE failure, not a lint finding."""
68MAX_DIAGNOSTIC_ERROR_RATE = 0.20
69"""Refuse to ratchet when more than this fraction of findings are parse errors.
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.
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."""
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*$"
95NOT_A_CHECK =
"-warnings-as-errors"
98def parse_log(text: str) -> Counter:
99 """Return a {(repo-relative file, check): count} Counter for `text`.
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.
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())
112 names = [c
for c
in m.group(
"checks").split(
",")
if c
and c != NOT_A_CHECK]
116 path = m.group(
"path")
118 rel = str(Path(path).resolve().relative_to(REPO_ROOT))
122 key = (rel, int(m.group(
"line")), int(m.group(
"col")), check)
126 counts[(rel, check)] += 1
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():
135 for raw
in BASELINE_FILE.read_text(encoding=
"ascii").splitlines():
137 if not line
or line.startswith(
"#"):
139 parts = line.split(
"\t")
140 if len(parts) != BASELINE_COLUMNS:
142 counts[(parts[0], parts[1])] = int(parts[2])
146def write_baseline(counts: Counter) ->
None:
147 """Write `counts` out in the committed, sorted, diffable form."""
149 "# clang-tidy ratchet baseline -- per-file-per-check finding counts.",
150 "# Consumed by scripts/checks/tidy_ratchet.py --check (CI gate: tidy).",
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.",
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",
160 "# Closing this out means this file reaching zero rows and being",
161 "# DELETED -- never regenerated larger.",
163 "# file<TAB>check<TAB>count",
165 for (path, check), n
in sorted(counts.items()):
167 lines.append(f
"{path}\t{check}\t{n}")
168 BASELINE_FILE.write_text(
"\n".join(lines) +
"\n", encoding=
"ascii")
171def diagnostic_error_reason(current: Counter) -> str |
None:
172 """Return a refusal reason if the run is dominated by parse errors, else None.
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.
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.
185 total = sum(current.values())
188 diag = sum(n
for (_path, check), n
in current.items()
if check == DIAGNOSTIC_ERROR_CHECK)
190 if diag < DIAGNOSTIC_ERROR_FLOOR
or rate <= MAX_DIAGNOSTIC_ERROR_RATE:
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."
203def report(current: Counter, baseline: Counter) -> int:
204 """Compare and print. Returns the process exit code."""
206 for key, n
in sorted(current.items()):
207 was = baseline.get(key, 0)
209 grown.append((key, was, n))
211 total_now = sum(current.values())
212 total_was = sum(baseline.values())
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)
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.",
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>")
238def _selftest_parse() -> list[str]:
239 """Assertions about log parsing."""
240 failures: list[str] = []
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"
250 counts = parse_log(sample)
251 if sum(counts.values()) != 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")
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"
264 if sum(dupe.values()) != 1:
265 failures.append(f
"parse_log() counted a duplicated header finding {sum(dupe.values())}x")
270def _selftest_ratchet() -> list[str]:
271 """Assertions about the growth verdict and baseline round-tripping."""
272 failures: list[str] = []
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")
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")
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")
289 original = BASELINE_FILE.read_text(encoding=
"ascii")
if BASELINE_FILE.is_file()
else None
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")
297 BASELINE_FILE.unlink(missing_ok=
True)
299 BASELINE_FILE.write_text(original, encoding=
"ascii")
304def _selftest_diag_guard() -> list[str]:
305 """Assertions about the parse-error guard (#387), in both directions."""
306 failures: list[str] = []
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")
314 healthy: Counter = Counter(
315 {(f
"libs/f{i}.c",
"readability-magic-numbers"): 1
for i
in range(378)}
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")
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")
330def selftest() -> int:
331 """Assert the parser and the ratchet fire, in BOTH directions."""
332 failures = _selftest_parse() + _selftest_ratchet() + _selftest_diag_guard()
335 print(
"SELFTEST FAILED:", file=sys.stderr)
336 for problem
in failures:
337 print(f
" - {problem}", file=sys.stderr)
339 print(
"selftest: clang-tidy ratchet parse + growth detection OK")
344 """Ratchet clang-tidy findings against the committed baseline.
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.
351 ``--selftest`` asserts the comparison still fires, so a ratchet that
352 stopped detecting growth cannot pass as clean.
354 Returns 0 when the count is at or below the baseline, 1 when it grew.
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()
366 parser.error(
"a clang-tidy log path is required unless --selftest is given")
368 text = Path(args.log).read_text(encoding=
"utf-8", errors=
"replace")
369 current = parse_log(text)
374 broken = diagnostic_error_reason(current)
376 verb =
"--update" if args.update
else "--check"
377 print(f
"refusing to {verb}: {broken}", file=sys.stderr)
381 baseline = load_baseline()
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)]
388 f
"refusing to --update: {len(grew)} bucket(s) would GROW. "
389 "The baseline is a burn-down; fix the new findings instead.",
392 for key
in grew[:MAX_DETAIL_LINES]:
393 print(f
" {key[0]} {key[1]}", file=sys.stderr)
395 write_baseline(current)
396 print(f
"baseline updated: {sum(current.values())} finding(s) recorded")
399 return report(current, load_baseline())
402if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.