ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_obsolete_standards.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_obsolete_standards.py -- Reject references to obsolete safety standards.
5
6Per the project's IEC 61508 SIL 3 / DO-178C target (CLAUDE.md):
7
8- DO-178B was superseded by DO-178C in December 2011 and is no longer
9 recognized by FAA / EASA / Transport Canada. Any new doc, test
10 comment, or commit message that says "DO-178B" must be corrected to
11 DO-178C (or to IEC 61508 SIL 3 if industry-neutral).
12
13Two scan modes, and NEITHER is the default -- a bare invocation is an error.
14
15 check_obsolete_standards.py --all # the whole tracked tree (CI)
16 check_obsolete_standards.py --staged # the git index (the commit hook)
17 check_obsolete_standards.py --selftest # prove the detector both ways
18
19That is deliberate. This checker read ``git diff --cached`` unconditionally,
20and the ``pre-commit-checks`` CI gate invoked it with no arguments -- where
21nothing is ever staged. It therefore scanned ZERO files on every CI run and
22printed ``0 findings``, for as long as it had been wired there. The same
23defect was found and fixed in ``check_mcdc_block.py`` (#325) and
24``check_new_compound_has_mcdc.py`` (#355); this is the third instance, so the
25remedy is the one those adopted: make the mode explicit so a caller cannot
26silently get the vacuous one, and refuse an empty tree-wide scan outright.
27
28Whitelists the project's own self-documentation (CLAUDE.md note, this script,
29the migration memo) where the tokens appear in *explanatory* context.
30"""
31
32from __future__ import annotations
33
34import argparse
35import re
36import subprocess
37import sys
38from pathlib import Path
39
40sys.path.insert(0, str(Path(__file__).resolve().parent))
41
42from lint_targets import first_party_paths # needs the sys.path line above
43from selftest_assert import expect, report # needs the sys.path line above
44
45# Tokens that must not appear in new content. Match case-sensitively to
46# avoid false positives on unrelated identifiers.
47FORBIDDEN_PATTERNS = [
48 re.compile(r"\bDO-178B\b"),
49 re.compile(r"\bDO178B\b"),
50]
51
52# Files where the forbidden token may legitimately appear because they
53# are *the* file documenting that the token is forbidden. Add new
54# entries with care.
55WHITELIST = {
56 "CLAUDE.md",
57 "scripts/checks/check_obsolete_standards.py",
58 "scripts/git/pre-commit",
59 "docs/MCDC.md",
60 # Historical/explanatory reference: SQLite's test harness targeted
61 # DO-178B, framed in-text as the direct ancestor of this project's
62 # DO-178C target -- not a claim that anything here targets the
63 # superseded bar.
64 "PHILOSOPHIES.md",
65}
66
67
68# Suffixes the tree-wide sweep enumerates. Mirrors scannable() below, which
69# still filters the per-file decisions for both modes.
70SCAN_SUFFIXES: tuple[str, ...] = (
71 ".c",
72 ".h",
73 ".cpp",
74 ".hpp",
75 ".md",
76 ".py",
77 ".sh",
78 ".cmake",
79 ".yml",
80 ".yaml",
81 ".txt",
82)
83
84# A tree-wide sweep that matched almost nothing has not found a clean tree; it
85# has lost its file list. Measured at 2600+ tracked files in these suffixes.
86TREE_FLOOR = 500
87
88
89def staged_files() -> list[str]:
90 """Return the list of files staged for the in-progress commit."""
91 out = subprocess.run(
92 ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], # noqa: S607 # trusted: git is a fixed dev-tool name
93 check=True,
94 capture_output=True,
95 text=True,
96 ).stdout
97 return [line for line in out.splitlines() if line]
98
99
100def tracked_files() -> list[str]:
101 """Return every tracked first-party path this checker scans tree-wide.
102
103 Enumeration goes through ``lint_targets.first_party_paths`` -- the shared
104 derived-scope primitive -- rather than a hand-written directory list, so a
105 new top-level directory is covered the day it lands and cannot quietly
106 fall out of scope the way a hardcoded tuple does.
107 """
108 named = ["justfile", "Justfile", "CMakeLists.txt"]
109 paths = list(first_party_paths(SCAN_SUFFIXES))
110 paths.extend(path for path in first_party_paths(tuple(named)) if Path(path).name in set(named))
111 return sorted(set(paths))
112
113
114def scannable(path: Path) -> bool:
115 """Decide whether a staged path is subject to the obsolete-standard scan.
116
117 Three subtractions, in order of cost: a path that no longer exists (staged
118 as a deletion, so there is nothing to read), an explicitly whitelisted
119 file, and anything under a ``third_party`` directory -- vendored code may
120 cite whatever standard it was written against and is not ours to correct.
121
122 Suffix is matched case-insensitively, so a ``.MD`` or ``.YAML`` cannot
123 duck the scan on spelling alone.
124 """
125 if not path.is_file():
126 return False
127 if str(path) in WHITELIST:
128 return False
129 if any(part == "third_party" for part in path.parts):
130 return False
131 suffix = path.suffix.lower()
132 if suffix in {
133 ".c",
134 ".h",
135 ".cpp",
136 ".hpp",
137 ".md",
138 ".py",
139 ".sh",
140 ".cmake",
141 ".just",
142 ".yml",
143 ".yaml",
144 ".txt",
145 }:
146 return True
147 return path.name in {"justfile", "Justfile", "CMakeLists.txt"}
148
149
150def scan_text(text: str) -> list[tuple[int, str]]:
151 """Return ``(lineno, line)`` for each line citing an obsolete standard.
152
153 Only the first matching pattern per line is recorded, so a line naming two
154 obsolete standards is reported once -- the fix is to rewrite the line, and
155 a second finding on it would just be noise.
156 """
157 hits: list[tuple[int, str]] = []
158 for lineno, line in enumerate(text.splitlines(), start=1):
159 for pat in FORBIDDEN_PATTERNS:
160 if pat.search(line):
161 hits.append((lineno, line.rstrip()))
162 break
163 return hits
164
165
166def scan_paths(names: list[str]) -> list[tuple[str, int, str]]:
167 """Scan the given paths, returning every ``(path, lineno, line)`` finding."""
168 findings: list[tuple[str, int, str]] = []
169 for name in names:
170 path = Path(name)
171 if not scannable(path):
172 continue
173 try:
174 text = path.read_text(encoding="utf-8")
175 except (UnicodeDecodeError, OSError):
176 continue
177 findings.extend((name, lineno, line) for lineno, line in scan_text(text))
178 return findings
179
180
181def selftest() -> int:
182 """Prove the detector fires on a citation and stays quiet without one.
183
184 Also asserts the tree-wide enumeration is not empty. That last assertion
185 is the point of this selftest: the scan was silently reduced to zero files
186 for its whole life in CI, and a detector nobody has watched fire on a real
187 file list is indistinguishable from one that has stopped looking.
188
189 Returns:
190 0 when every assertion held in both directions, 1 otherwise.
191 """
192 failures: list[str] = []
193 for label, text, must_fire in (
194 ("a DO-178B citation", "/* Written to DO-178B Level B. */", True),
195 ("the hyphen-less DO178B spelling", "# targets DO178B objectives", True),
196 ("the current DO-178C citation", "/* Written to DO-178C Level B. */", False),
197 ("an unrelated line naming no standard", "int x = 178;", False),
198 ):
199 fired = bool(scan_text(text))
200 expectation = "must fire" if must_fire else "must stay quiet"
201 expect(fired == must_fire, f"{label} ({expectation})", failures)
202
203 tracked = tracked_files()
204 expect(
205 len(tracked) >= TREE_FLOOR,
206 f"tree-wide enumeration sees {len(tracked)} file(s) (floor {TREE_FLOOR})",
207 failures,
208 )
209 return report(failures)
210
211
212def main() -> int:
213 """Fail any file citing a superseded safety standard (e.g. DO-178B).
214
215 The scan mode is mandatory rather than defaulted. ``--all`` sweeps the
216 tracked tree and is what CI runs; ``--staged`` reads the git index and is
217 what the commit hook runs. A bare invocation used to mean "staged", which
218 made the CI gate scan nothing at all and report a clean tree forever.
219
220 Returns:
221 0 when the selected set is clean, 1 with each offending line quoted,
222 and 2 when no mode was given or a tree-wide sweep found too few files
223 to be believable.
224 """
225 parser = argparse.ArgumentParser(description="ban references to superseded safety standards")
226 mode = parser.add_mutually_exclusive_group()
227 mode.add_argument("--all", action="store_true", help="scan every tracked first-party file")
228 mode.add_argument("--staged", action="store_true", help="scan the git index (commit hook)")
229 parser.add_argument("--selftest", action="store_true", help="prove the detector both ways")
230 args = parser.parse_args()
231
232 if args.selftest:
233 return selftest()
234
235 if not (args.all or args.staged):
236 sys.stderr.write(
237 "check_obsolete_standards.py: pass --all (CI, the whole tracked tree) or\n"
238 " --staged (the pre-commit hook, the git index). There is no default:\n"
239 " this checker silently defaulted to --staged and so scanned nothing at\n"
240 " all in CI, where the index is always empty.\n"
241 )
242 return 2
243
244 names = tracked_files() if args.all else staged_files()
245 if args.all and len(names) < TREE_FLOOR:
246 sys.stderr.write(
247 f"check_obsolete_standards.py: FATAL -- the tree-wide sweep enumerated only\n"
248 f" {len(names)} file(s), below the floor of {TREE_FLOOR}. An empty or\n"
249 f" collapsed file list reports success because it saw nothing.\n"
250 )
251 return 2
252
253 findings = scan_paths(names)
254 if findings:
255 print("[FAIL] Obsolete standard reference detected (DO-178B was")
256 print(" superseded by DO-178C in December 2011). Use")
257 print(" DO-178C, IEC 61508 SIL 3, or ISO 26262 ASIL C/D")
258 print(" per CLAUDE.md. Offending lines:")
259 for name, lineno, line in findings:
260 print(f" {name}:{lineno}: {line}")
261 return 1
262
263 print(f"check_obsolete_standards.py: 0 findings across {len(names)} file(s).")
264 return 0
265
266
267if __name__ == "__main__":
268 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298