ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_lint_coverage.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"""Prove that EVERY code file in this repository is linted and formatted.
5
6WHY THIS EXISTS
7---------------
8"Is everything linted?" was, until this gate, answerable only by opening each
9checker and reading its scan list by hand. That audit was performed five times
10and was wrong five times -- #296, #332, #358, #359, #360. Every one of those
11was the same defect in a different checker: a hardcoded root list that stopped
12matching the tree, so the checker reported a clean run over a subset while
13files outside it sat unchecked for months. A gate that scans nothing reports
14success, and success is indistinguishable from having done the work.
15
16This gate answers the question mechanically:
17
18 1. Enumerate every file from ``git ls-files`` -- never a directory list.
19 A hardcoded directory list is the exact defect being killed here, so this
20 gate must not contain one.
21 2. Classify each file by exact name, then extension, then shebang.
22 3. Ask each checker, in its own "list what you would scan" mode, which files
23 it claims. The gate does NOT restate any checker's scope: a second copy of
24 the coverage map is a new instance of the original bug.
25 4. Assert every CODE file is claimed by at least one linter and at least one
26 formatter.
27 5. FAIL on any file whose type has no classification rule at all.
28
29Point 5 is the one that earns the gate its keep. The day someone commits a
30``.rs``, a ``.ts`` or a ``.proto``, this goes red and somebody has to decide
31how that language is checked -- rather than it entering the tree silently and
32being discovered by the sixth hand audit.
33
34LAYOUT AGNOSTICISM IS A DESIGN REQUIREMENT
35------------------------------------------
36Checkers are located by BASENAME through ``git ls-files``, not by a hardcoded
37path. ``scripts/`` was reorganised into subdirectories in #359 and every
38checker this gate resolves changed directory; the basename lookup carried that
39move without a single edit. A gate that hardcoded full paths would have broken
40on it and -- far worse -- could then have been "fixed" by dropping the
41provider, silently shrinking coverage. By name, a MOVE is invisible and a
42DELETION is loud.
43
44USAGE
45-----
46 check_lint_coverage.py --selftest # assert the gate itself still fires
47 check_lint_coverage.py # the real check
48 check_lint_coverage.py --matrix # print the coverage matrix and exit 0
49"""
50
51from __future__ import annotations
52
53import argparse
54import shutil
55import subprocess
56import sys
57import tempfile
58from dataclasses import dataclass
59from pathlib import Path
60
61sys.path.insert(0, str(Path(__file__).resolve().parent))
62
63from lint_coverage_rules import (
64 CLASSES,
65 EXT_CLASS,
66 FORMAT,
67 KNOWN_GAPS,
68 LINT,
69 NAME_CLASS,
70 PATH_CLASS,
71 SHEBANG_CLASS,
72 GapCtx,
73 exemption_reason,
74 validate_tables,
75)
76from selftest_assert import expect, report
77
78REPO_ROOT = Path(
79 subprocess.run(
80 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 -- trusted: fixed git argv
81 capture_output=True,
82 text=True,
83 check=True,
84 ).stdout.strip()
85)
86
87# A tree this size cannot legitimately collapse to a handful of files. If the
88# enumeration returns less than this, something broke (a bad cwd, a failed git)
89# and reporting "all covered" would be a lie. Same trip-wire as check_ruff.py.
90FILE_FLOOR = 2000
91
92# How many offending paths to print before truncating the list.
93MAX_SHOWN = 40
94
95# A checker basename must resolve to exactly one tracked path; zero means it
96# was deleted, more than one means the name is ambiguous. Both are failures.
97EXACTLY_ONE = 1
98
99# The selftest fixture below plants exactly three exempt paths.
100EXPECTED_FIXTURE_EXEMPT = 3
101
102# An uncovered file yields one pair per role: lint and format.
103BOTH_ROLES = 2
104
105
106# ---------------------------------------------------------------------------
107# Provider descriptors
108# ---------------------------------------------------------------------------
109@dataclass(frozen=True)
110class Provider:
111 """A checker, the roles it fills, and how to ask it what it scans.
112
113 ``script`` is a BASENAME resolved through git at run time -- see the module
114 docstring on layout agnosticism.
115 """
116
117 name: str
118 roles: tuple[str, ...]
119 classes: tuple[str, ...]
120 script: str
121 args: tuple[str, ...]
122 runner: str = "python3"
123
124
125PROVIDERS: tuple[Provider, ...] = (
126 Provider("clang-tidy", (LINT,), ("c-family",), "clang_tidy.sh", ("--list-files",), "bash"),
127 Provider("clang-format", (FORMAT,), ("c-family",), "format_code.sh", ("--list-files",), "bash"),
128 Provider("ruff", (LINT,), ("python",), "check_ruff.py", ("--list-files",)),
129 Provider(
130 "ruff-format", (FORMAT,), ("python",), "format_tree.sh", ("--list-files", "python"), "bash"
131 ),
132 Provider("vet+staticcheck", (LINT,), ("golang",), "check_go.py", ("--list-files",)),
133 Provider("gofmt", (FORMAT,), ("golang",), "format_tree.sh", ("--list-files", "go"), "bash"),
134 Provider("shellcheck", (LINT,), ("shell",), "check_shell.py", ("--list-files",)),
135 Provider("shfmt", (FORMAT,), ("shell",), "format_tree.sh", ("--list-files", "shell"), "bash"),
136 Provider("cmake-lint", (LINT,), ("cmake",), "lint_targets.py", ("cmake",)),
137 Provider(
138 "cmake-format", (FORMAT,), ("cmake",), "format_tree.sh", ("--list-files", "cmake"), "bash"
139 ),
140 Provider("check_justfiles", (LINT,), ("just",), "check_justfiles.py", ("--list-files",)),
141 Provider("just-fmt", (FORMAT,), ("just",), "format_tree.sh", ("--list-files", "just"), "bash"),
142 Provider("yamllint+actionlint", (LINT, FORMAT), ("yaml",), "lint_targets.py", ("yaml",)),
143 Provider(
144 "check_linker_scripts",
145 (LINT, FORMAT),
146 ("linker-script",),
147 "check_linker_scripts.py",
148 ("--list-files",),
149 ),
150 Provider("check_asm", (LINT, FORMAT), ("asm",), "check_asm.py", ("--list-files",)),
151 Provider(
152 "hadolint+zsh",
153 (LINT, FORMAT),
154 ("dockerfile", "zsh"),
155 "check_devcontainer.py",
156 ("--list-files",),
157 ),
158 Provider(
159 "fleet-ansible-template",
160 (LINT, FORMAT),
161 ("ansible-systemd-template",),
162 "check_fleet_declaration.py",
163 ("--list-files",),
164 ),
165)
166
167
168# ---------------------------------------------------------------------------
169# Enumeration and classification
170# ---------------------------------------------------------------------------
171def git_files() -> list[str]:
172 """Every tracked or untracked-but-not-ignored path, repo-relative."""
173 proc = subprocess.run(
174 [ # noqa: S607 -- trusted: fixed git argv
175 "git",
176 "ls-files",
177 "-z",
178 "--cached",
179 "--others",
180 "--exclude-standard",
181 ],
182 cwd=REPO_ROOT,
183 capture_output=True,
184 text=True,
185 check=False,
186 )
187 if proc.returncode != 0:
188 sys.stderr.write(proc.stderr)
189 sys.stderr.write("check_lint_coverage.py: FATAL -- `git ls-files` failed\n")
190 sys.exit(2)
191 return sorted(_present_worktree_files(proc.stdout.split("\0")))
192
193
194def _present_worktree_files(paths: list[str], root: Path = REPO_ROOT) -> list[str]:
195 """Retain live candidate files and drop deleted paths left in the index."""
196 return [path for path in paths if path and (root / path).is_file()]
197
198
199def read_shebang(rel: str) -> str:
200 """First line of `rel` if it is a shebang, else the empty string."""
201 try:
202 with (REPO_ROOT / rel).open("rb") as handle:
203 first = handle.readline(200)
204 except OSError:
205 return ""
206 if not first.startswith(b"#!"):
207 return ""
208 return first.decode("utf-8", errors="replace").strip()
209
210
211def classify(rel: str) -> str | None:
212 """Return the class name for `rel`, or None when nothing claims it.
213
214 Order is exact path, exact name, extension, then shebang. Exact path keeps
215 one reproducible generated file from exempting every future file with the
216 same extension. Name beats extension so ``CMakeLists.txt`` is cmake rather
217 than text; shebang comes last so it only rescues files the tables genuinely
218 miss -- which is how an extensionless ``scripts/git/pre-commit`` is
219 recognised as shell.
220 """
221 if rel in PATH_CLASS:
222 return PATH_CLASS[rel]
223 name = rel.rsplit("/", 1)[-1]
224 if name in NAME_CLASS:
225 return NAME_CLASS[name]
226 suffix = ""
227 if "." in name[1:]:
228 suffix = "." + name.rsplit(".", 1)[-1]
229 if suffix.lower() in EXT_CLASS:
230 return EXT_CLASS[suffix.lower()]
231 line = read_shebang(rel)
232 if line:
233 for token, cls in SHEBANG_CLASS:
234 if token in line:
235 return cls
236 return None
237
238
239# ---------------------------------------------------------------------------
240# Asking the checkers what they scan
241# ---------------------------------------------------------------------------
242def resolve_script(basename: str, tracked: list[str]) -> str | None:
243 """Locate a checker by basename anywhere in the tree. None if absent."""
244 hits = [p for p in tracked if p.rsplit("/", 1)[-1] == basename]
245 if len(hits) != EXACTLY_ONE:
246 return None
247 return hits[0]
248
249
250def provider_files(prov: Provider, tracked: list[str]) -> tuple[set[str], str | None]:
251 """Run `prov` in list mode. Returns (files, error). Never swallows failure."""
252 path = resolve_script(prov.script, tracked)
253 if path is None:
254 return set(), f"cannot locate {prov.script} (moved, deleted or ambiguous)"
255 runner = shutil.which(prov.runner) or prov.runner
256 proc = subprocess.run( # noqa: S603 -- argv built from the fixed table above
257 [runner, path, *prov.args],
258 cwd=REPO_ROOT,
259 capture_output=True,
260 text=True,
261 check=False,
262 )
263 if proc.returncode != 0:
264 detail = proc.stderr.strip().splitlines()
265 tail = detail[-1] if detail else f"exit {proc.returncode}"
266 return set(), f"{prov.script} --list-files failed: {tail}"
267 files = {ln.strip() for ln in proc.stdout.splitlines() if ln.strip()}
268 foreign_code = sorted(
269 rel
270 for rel in files
271 if (cls := classify(rel)) is not None
272 and CLASSES[cls].kind == "code"
273 and cls not in prov.classes
274 )
275 if foreign_code:
276 return set(), (
277 f"{prov.script} --list-files claimed {foreign_code[0]!r} outside "
278 f"its declared classes {prov.classes!r}"
279 )
280 return files, None
281
282
283# ---------------------------------------------------------------------------
284# The pure evaluation core -- shared by the real run and by --selftest.
285# ---------------------------------------------------------------------------
286class Report:
287 """Outcome of one evaluation."""
288
289 def __init__(self) -> None:
290 """Start an empty report with every failure bucket distinct.
291
292 The buckets are kept separate rather than merged into one findings
293 list because they fail for different reasons and carry different
294 remedies -- an unclassified file type needs a rule, an uncovered file
295 needs a checker, and gap growth needs the gap closed.
296 """
297 self.unclassified: list[str] = []
298 self.uncovered: list[tuple[str, str, str]] = []
299 self.gap_growth: list[str] = []
300 self.gap_sizes: dict[str, int] = {}
301 self.counts: dict[str, int] = {}
302 self.exempt: int = 0
303
304 @property
305 def ok(self) -> bool:
306 """Whether the report is clean across every failing bucket.
307
308 Note ``gap_sizes`` and ``exempt`` are deliberately NOT consulted: a
309 recorded gap of unchanged size is the accepted state, and only its
310 GROWTH is a failure.
311 """
312 return not (self.unclassified or self.uncovered or self.gap_growth)
313
314
315def _read_text(rel: str) -> str:
316 """File contents for a gap predicate, empty when unreadable or binary."""
317 try:
318 return (REPO_ROOT / rel).read_text(errors="replace")
319 except OSError:
320 return ""
321
322
323def _bucket_gaps(raw: list[tuple[str, str, str]], report: Report) -> list[tuple[str, str, str]]:
324 """Split uncovered pairs into recorded gaps and genuine violations.
325
326 Also runs the ratchet: a gap that has grown past its recorded count is a
327 failure, because "carried deliberately while it is closed" and "quietly
328 becoming permanent" must not look the same.
329 """
330 hits: dict[str, set[str]] = {gap.name: set() for gap in KNOWN_GAPS}
331 violations: list[tuple[str, str, str]] = []
332 text_cache: dict[str, str] = {}
333 for rel, cls, role in raw:
334 if rel not in text_cache:
335 text_cache[rel] = _read_text(rel)
336 ctx = GapCtx(rel=rel, cls=cls, text=text_cache[rel])
337 for gap in KNOWN_GAPS:
338 if gap.match(ctx):
339 hits[gap.name].add(rel)
340 break
341 else:
342 violations.append((rel, cls, role))
343
344 for gap in KNOWN_GAPS:
345 got = len(hits[gap.name])
346 report.gap_sizes[gap.name] = got
347 if got > gap.count:
348 report.gap_growth.append(
349 f"known gap {gap.name!r} ({gap.issue}) grew from {gap.count} to {got} "
350 "file(s). Close it -- raising the recorded count needs a stated reason."
351 )
352 return violations
353
354
355def _provider_claims_class(prov: Provider, rel: str) -> bool:
356 """Whether a provider may satisfy coverage for this path's code class."""
357 cls = classify(rel)
358 return cls is not None and cls in prov.classes
359
360
361def evaluate(files: list[str], claimed: dict[str, set[str]]) -> Report:
362 """Decide coverage for `files` given each provider's claimed set.
363
364 `claimed` maps provider name -> the set of paths that provider scans. The
365 real run fills it from the checkers themselves; --selftest fills it by
366 hand, which is what makes both directions assertable without touching the
367 working tree.
368 """
369 report = Report()
370 lint_by: dict[str, set[str]] = {}
371 fmt_by: dict[str, set[str]] = {}
372 for prov in PROVIDERS:
373 # A semantic checker cannot accidentally become a universal linter by
374 # returning dependency/ownership files outside its declared class.
375 got = {rel for rel in claimed.get(prov.name, set()) if _provider_claims_class(prov, rel)}
376 if LINT in prov.roles:
377 lint_by[prov.name] = got
378 if FORMAT in prov.roles:
379 fmt_by[prov.name] = got
380
381 all_lint = set().union(*lint_by.values()) if lint_by else set()
382 all_fmt = set().union(*fmt_by.values()) if fmt_by else set()
383
384 raw: list[tuple[str, str, str]] = []
385 for rel in files:
386 if exemption_reason(rel) is not None:
387 report.exempt += 1
388 continue
389 cls = classify(rel)
390 if cls is None:
391 report.unclassified.append(rel)
392 continue
393 report.counts[cls] = report.counts.get(cls, 0) + 1
394 if CLASSES[cls].kind != "code":
395 continue
396 for role, pool in ((LINT, all_lint), (FORMAT, all_fmt)):
397 if rel not in pool:
398 raw.append((rel, cls, role))
399
400 report.uncovered = _bucket_gaps(raw, report)
401 return report
402
403
404# ---------------------------------------------------------------------------
405# Reporting
406# ---------------------------------------------------------------------------
407def print_matrix(report: Report, claimed: dict[str, set[str]]) -> None:
408 """Print the class-by-provider coverage matrix.
409
410 The human-readable answer to "which checker claims this file type?", which
411 is the question that goes unasked until a whole language turns out to have
412 had no checker at all.
413 """
414 by_class: dict[str, list[str]] = {}
415 for prov in PROVIDERS:
416 for cls in prov.classes:
417 by_class.setdefault(cls, []).append(prov.name)
418 print(f"{'CLASS':<18}{'COUNT':>7} {'KIND':<6} PROVIDERS")
419 print("-" * 78)
420 for cls in sorted(report.counts):
421 spec = CLASSES[cls]
422 provs = ", ".join(by_class.get(cls, [])) or "-- none --"
423 n = report.counts[cls]
424 print(f"{cls:<18}{n:>7} {spec.kind:<6} {provs}")
425 print("-" * 78)
426 total = sum(report.counts.values())
427 print(f"{'total classified':<18}{total:>7}")
428 print(f"{'exempt':<18}{report.exempt:>7}")
429 for prov in PROVIDERS:
430 print(f" scanned by {prov.name:<22} {len(claimed.get(prov.name, set())):>6} file(s)")
431 if report.gap_sizes:
432 print("\nRECORDED GAPS -- code with no checker, held flat by the ratchet:")
433 for gap in KNOWN_GAPS:
434 got = report.gap_sizes.get(gap.name, 0)
435 print(f" {gap.name:<28}{got:>4}/{gap.count:<4} {gap.issue} {gap.reason[:60]}")
436
437
438def print_failures(report: Report) -> None:
439 """Print each failing bucket to stderr, capped per bucket.
440
441 Capped because a newly-added file type can produce hundreds of identical
442 findings, and the first few plus a count communicate the same thing
443 without burying the other buckets.
444 """
445 if report.unclassified:
446 print("\nUNCLASSIFIED FILE TYPES -- no rule says how these are checked:", file=sys.stderr)
447 for rel in report.unclassified[:MAX_SHOWN]:
448 print(f" {rel}", file=sys.stderr)
449 extra = len(report.unclassified) - MAX_SHOWN
450 if extra > 0:
451 print(f" ... and {extra} more", file=sys.stderr)
452 print(
453 " Add the type to PATH_CLASS/EXT_CLASS/NAME_CLASS in lint_coverage_rules.py and,\n"
454 " if it is code, wire a linter and a formatter for it.",
455 file=sys.stderr,
456 )
457 if report.uncovered:
458 print(
459 f"\nUNCOVERED CODE FILES -- {len(report.uncovered)} file/role pair(s) "
460 "that no checker claims:",
461 file=sys.stderr,
462 )
463 noun = {LINT: "linter", FORMAT: "formatter"}
464 for rel, cls, role in report.uncovered[:MAX_SHOWN]:
465 print(f" {rel} [{cls}] has no {noun[role]}", file=sys.stderr)
466 extra = len(report.uncovered) - MAX_SHOWN
467 if extra > 0:
468 print(f" ... and {extra} more", file=sys.stderr)
469 for msg in report.gap_growth:
470 print(f"\nGAP RATCHET: {msg}", file=sys.stderr)
471
472
473# ---------------------------------------------------------------------------
474# Selftest -- both directions, run BEFORE the real check.
475# ---------------------------------------------------------------------------
476def _fixture() -> tuple[list[str], dict[str, set[str]]]:
477 """A miniature repo that is fully covered, used as the quiet baseline."""
478 files = [
479 "libs/ra8_core/src/ra8_err.c",
480 "libs/ra8_core/inc/ra8_err.h",
481 "scripts/checks/check_thing.py", # PATHREF-OK: synthetic fixture
482 "scripts/git/pre-commit",
483 "CMakeLists.txt",
484 "examples/app/linker_script.ld",
485 "examples/app/boot.S",
486 ".devcontainer/Dockerfile",
487 ".devcontainer/zshrc",
488 ".github/workflows/firmware.yml",
489 "infra/ansible/roles/dev_box/templates/ra8-hil-runner.service.j2",
490 "README.md",
491 "apps/shared_libs/third_party/miniz/miniz.c",
492 "apps/board/stand_alone/ereader/content/library/book.epub",
493 "docs/reference/ra8d2-datasheet.pdf",
494 ]
495 claimed = {
496 "clang-tidy": {"libs/ra8_core/src/ra8_err.c", "libs/ra8_core/inc/ra8_err.h"},
497 "clang-format": {"libs/ra8_core/src/ra8_err.c", "libs/ra8_core/inc/ra8_err.h"},
498 "ruff": {"scripts/checks/check_thing.py"}, # PATHREF-OK: synthetic
499 "ruff-format": {"scripts/checks/check_thing.py"}, # PATHREF-OK: synthetic
500 "shellcheck": {"scripts/git/pre-commit"},
501 "shfmt": {"scripts/git/pre-commit"},
502 "cmake-lint": {"CMakeLists.txt"},
503 "cmake-format": {"CMakeLists.txt"},
504 "yamllint+actionlint": {".github/workflows/firmware.yml"},
505 "check_linker_scripts": {"examples/app/linker_script.ld"},
506 "check_asm": {"examples/app/boot.S"},
507 "hadolint+zsh": {".devcontainer/Dockerfile", ".devcontainer/zshrc"},
508 "fleet-ansible-template": {
509 "infra/ansible/roles/dev_box/templates/ra8-hil-runner.service.j2"
510 },
511 }
512 return files, claimed
513
514
515def _assert_quiet(files: list[str], claimed: dict[str, set[str]], failures: list[str]) -> None:
516 """Assert the model stays silent on trees that are genuinely covered.
517
518 Split out along the QUIET / MUST-FIRE boundary this suite already drew in
519 comments: the two directions share only the fixture, and a reader checking
520 "does a covered tree pass?" should not have to step over the fires cases.
521 """
522 base = evaluate(files, claimed)
523 expect(base.ok, "a fully-covered tree passes", failures)
524 expect(
525 base.exempt == EXPECTED_FIXTURE_EXEMPT,
526 f"exempt paths counted, not flagged (got {base.exempt})",
527 failures,
528 )
529
530 plus = [*files, "libs/ra8_core/src/ra8_new.c"]
531 claimed2 = {k: set(v) for k, v in claimed.items()}
532 claimed2["clang-tidy"].add("libs/ra8_core/src/ra8_new.c")
533 claimed2["clang-format"].add("libs/ra8_core/src/ra8_new.c")
534 expect(
535 evaluate(plus, claimed2).ok,
536 "a new file of a covered type in a covered dir stays quiet",
537 failures,
538 )
539
540
541def _assert_fires(files: list[str], claimed: dict[str, set[str]], failures: list[str]) -> None:
542 """Assert the model fires on each distinct way coverage can be lost."""
543 rust = evaluate([*files, "tools/agent/src/main.rs"], claimed)
544 expect(
545 rust.unclassified == ["tools/agent/src/main.rs"],
546 "an unclassified file type (.rs) fires",
547 failures,
548 )
549
550 orphan = evaluate([*files, "newdir/thing.c"], claimed)
551 expect(
552 sorted({r for r, _, _ in orphan.uncovered}) == ["newdir/thing.c"]
553 and len(orphan.uncovered) == BOTH_ROLES,
554 "a code file in a directory no checker enumerates fires (lint AND format)",
555 failures,
556 )
557
558 narrowed = {k: set(v) for k, v in claimed.items()}
559 narrowed["clang-format"].discard("libs/ra8_core/inc/ra8_err.h")
560 drop = evaluate(files, narrowed)
561 expect(
562 drop.uncovered == [("libs/ra8_core/inc/ra8_err.h", "c-family", FORMAT)],
563 "narrowing a checker's scan list fires on the file that dropped out",
564 failures,
565 )
566
567 missing_py = {k: set(v) for k, v in claimed.items()}
568 missing_py["ruff"] = set()
569 missing_py["ruff-format"] = set()
570 expect(
571 len(evaluate(files, missing_py).uncovered) == BOTH_ROLES,
572 "losing python lint and format ownership fires both roles",
573 failures,
574 )
575 missing_template = {k: set(v) for k, v in claimed.items()}
576 missing_template["fleet-ansible-template"] = set()
577 expect(
578 len(evaluate(files, missing_template).uncovered) == BOTH_ROLES,
579 "the HIL systemd template needs both semantic lint and format ownership",
580 failures,
581 )
582 leaked_census = {k: set(v) for k, v in claimed.items()}
583 leaked_census["ruff"] = set()
584 leaked_census["ruff-format"] = set()
585 leaked_census["fleet-ansible-template"].add(
586 "scripts/checks/check_thing.py" # PATHREF-OK: synthetic lint-coverage fixture
587 )
588 expect(
589 len(evaluate(files, leaked_census).uncovered) == BOTH_ROLES,
590 "ownership-census files cannot inflate another class's lint/format coverage",
591 failures,
592 )
593
594
595def _assert_exact_classifications(failures: list[str]) -> None:
596 """Prove reviewed generated inputs do not exempt future files by suffix."""
597 expect(
598 classify("coprocessor/esp32c6/patches/0001-custom-rpc-sync-response-hook.patch")
599 == "validated-input",
600 "the pinned ESP32-C6 patch is an exact validated input",
601 failures,
602 )
603 expect(
604 classify("scripts/checks/patches/cppcheck-2.13/misra_9-c23-empty-initializer.patch")
605 == "validated-input",
606 "the pinned cppcheck MISRA patch is an exact selftested input",
607 failures,
608 )
609 expect(
610 classify("docs/sbom/patches/stb/0001-harden-font-parser-bounds.patch") == "validated-input"
611 and classify("docs/sbom/patches/stb/series") == "validated-input",
612 "the reviewed SOUP patch and series are exact replay-gated inputs",
613 failures,
614 )
615 expect(
616 classify("libs/ra8_c6link/proto/ra8_media_download.proto") == "validated-input",
617 "the pinned protobuf schema is an exact validated input",
618 failures,
619 )
620 expect(
621 classify("libs/ra8_c6link/src/ra8_media_download.pb-c.c") == "generated-source",
622 "the pinned protobuf-C output is exact generated source",
623 failures,
624 )
625 expect(
626 classify("infra/ansible/roles/dev_box/templates/ra8-hil-runner.service.j2")
627 == "ansible-systemd-template",
628 "the exact managed HIL systemd template has semantic ownership",
629 failures,
630 )
631 expect(
632 classify("infra/ansible/roles/dev_box/templates/ra8-hil-privileged-policy.json.j2")
633 == "validated-input"
634 and classify("scripts/hil/lib/ra8-hil-privileged.sha256") == "validated-input",
635 "the privilege checker owns its exact policy template and identity manifest",
636 failures,
637 )
638 _assert_future_classifications(failures)
639
640
641def _assert_future_classifications(failures: list[str]) -> None:
642 """Prove lookalike paths cannot inherit an exact reviewed classification."""
643 expect(
644 classify("coprocessor/esp32c6/patches/another.patch") is None,
645 "a future upstream patch remains unclassified",
646 failures,
647 )
648 expect(
649 classify(
650 "scripts/checks/patches/cppcheck-2.13/future.patch" # PATHREF-OK: synthetic fixture
651 )
652 is None,
653 "a future cppcheck patch remains unclassified",
654 failures,
655 )
656 expect(
657 # PATHREF-OK: synthetic lint-coverage fixture
658 classify("infra/ansible/roles/other/templates/sudoers.j2") is None,
659 "a future Jinja template remains unclassified",
660 failures,
661 )
662 expect(
663 classify("docs/sbom/patches/future/series") is None,
664 "a future patch series remains unclassified",
665 failures,
666 )
667 expect(
668 classify("libs/new/proto/another.proto") is None,
669 "a future protobuf schema remains unclassified",
670 failures,
671 )
672 expect(
673 classify("libs/new/src/another.pb-c.c") == "c-family",
674 "a future generated-looking C file remains first-party C",
675 failures,
676 )
677 expect(
678 classify("infra/ansible/roles/dev_box/templates/future.service.j2") is None,
679 "a future Jinja template remains unclassified until it has a validator",
680 failures,
681 )
682 expect(
683 classify("infra/ansible/roles/dev_box/templates/future-policy.json.j2") is None
684 and classify(
685 "scripts/hil/lib/future.sha256" # PATHREF-OK: absent-manifest fixture
686 )
687 is None,
688 "future policy and digest inputs remain unclassified without an exact checker",
689 failures,
690 )
691
692
693def _assert_ratchet(files: list[str], claimed: dict[str, set[str]], failures: list[str]) -> None:
694 """Assert the recorded-gap ratchet holds, and that closed gaps really closed.
695
696 Separate from the plain must-fire cases because these test a different
697 mechanism: not "is this file covered?" but "has a gap we agreed to tolerate
698 grown, and did the gaps we claim to have closed actually close?".
699 """
700 # 3 unclaimed .m files exceed the recorded 2 of objc-needs-macos-runner
701 # (#370); one does not.
702 many = [f"tools/ra8_x/src/v{n}.m" for n in range(3)]
703 grew = evaluate([*files, *many], claimed)
704 expect(bool(grew.gap_growth), "a known gap that grows fires the ratchet", failures)
705 expect(
706 not evaluate([*files, many[0]], claimed).gap_growth,
707 "a known gap at or under its recorded count stays quiet",
708 failures,
709 )
710 # C++ is no longer a recorded gap: #370's C++ half is closed by the C++
711 # pass in clang_tidy.sh, so an unclaimed .cpp is now a plain violation.
712 # This asserts that half really was closed rather than merely deleted from
713 # the table -- the same assertion shape #371 left behind for .S below.
714 orphan_cxx = evaluate([*files, "libs/ra8_x/src/orphan.cpp"], claimed)
715 expect(
716 sorted({r for r, _, _ in orphan_cxx.uncovered}) == ["libs/ra8_x/src/orphan.cpp"]
717 and not orphan_cxx.gap_growth,
718 "an unclaimed .cpp is a violation now, not a recorded gap",
719 failures,
720 )
721 # A .S file is no longer a recorded gap: #371 gave assembly a checker, so an
722 # unclaimed one is now a plain violation. This asserts the gap really was
723 # closed rather than merely deleted from the table.
724 orphan_asm = evaluate([*files, "newdir/boot.S"], claimed)
725 expect(
726 sorted({r for r, _, _ in orphan_asm.uncovered}) == ["newdir/boot.S"]
727 and not orphan_asm.gap_growth,
728 "an unclaimed .S is a violation now, not a recorded gap",
729 failures,
730 )
731
732
733def selftest() -> int:
734 """Prove the coverage model both fires and stays quiet, against fixtures.
735
736 Validates the classification tables first: a rule keyed on a class no
737 provider claims, or a provider claiming a class that does not exist,
738 makes every later answer meaningless.
739
740 Returns 0 when both directions hold, 1 otherwise.
741 """
742 print("check_lint_coverage.py --selftest")
743 failures: list[str] = []
744
745 problems = validate_tables()
746 expect(not problems, f"classification tables self-consistent ({problems})", failures)
747
748 with tempfile.TemporaryDirectory() as tmp:
749 root = Path(tmp)
750 (root / "present.py").touch()
751 expect(
752 _present_worktree_files(["present.py", "deleted.py"], root) == ["present.py"],
753 "candidate inventory keeps live files and drops deleted index paths",
754 failures,
755 )
756
757 files, claimed = _fixture()
758 _assert_exact_classifications(failures)
759 _assert_quiet(files, claimed, failures)
760 _assert_fires(files, claimed, failures)
761 _assert_ratchet(files, claimed, failures)
762
763 return report(failures)
764
765
766def run_check(show_matrix: bool) -> int:
767 """Verify every tracked code file is claimed by at least one checker.
768
769 Enforces a FILE FLOOR before anything else and exits 2 below it. That is
770 the load-bearing part: if the enumeration collapses, every file is
771 trivially covered and the gate reports perfect coverage precisely because
772 it saw nothing -- the exact failure mode it exists to detect in others.
773
774 Returns 0 when every file is covered, 1 on a coverage failure, 2 when the
775 enumeration is too small to trust.
776 """
777 tracked = git_files()
778 if len(tracked) < FILE_FLOOR:
779 sys.stderr.write(
780 f"check_lint_coverage.py: FATAL -- only {len(tracked)} file(s) enumerated, "
781 f"floor is {FILE_FLOOR}.\n"
782 " A collapsed enumeration reports full coverage because it saw nothing.\n"
783 )
784 return 2
785
786 claimed: dict[str, set[str]] = {}
787 errors: list[str] = []
788 for prov in PROVIDERS:
789 got, err = provider_files(prov, tracked)
790 if err:
791 errors.append(f"{prov.name}: {err}")
792 claimed[prov.name] = got
793 if errors:
794 sys.stderr.write("check_lint_coverage.py: FATAL -- provider enumeration failed.\n")
795 for err in errors:
796 sys.stderr.write(f" {err}\n")
797 sys.stderr.write(
798 " A provider that cannot report its scope leaves coverage unknown;\n"
799 " unknown is a failure, never a pass.\n"
800 )
801 return 2
802
803 report = evaluate(tracked, claimed)
804 if show_matrix or report.ok:
805 print_matrix(report, claimed)
806 if report.ok:
807 held = sum(report.gap_sizes.values())
808 if held:
809 print(
810 f"\ncheck_lint_coverage.py: every code file is linted and formatted, "
811 f"except {held} file(s) in the recorded gaps above -- each tracked by "
812 "an issue and held flat by the ratchet."
813 )
814 else:
815 print("\ncheck_lint_coverage.py: every code file is linted and formatted.")
816 return 0
817 print_failures(report)
818 return 1
819
820
821def main(argv: list[str]) -> int:
822 """Run the lint-coverage gate, its selftest, or print the coverage matrix."""
823 ap = argparse.ArgumentParser(description=__doc__)
824 ap.add_argument("--selftest", action="store_true", help="assert both directions")
825 ap.add_argument("--matrix", action="store_true", help="print the coverage matrix")
826 args = ap.parse_args(argv[1:])
827 if args.selftest:
828 return selftest()
829 return run_check(args.matrix)
830
831
832if __name__ == "__main__":
833 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298