ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_line_citations.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_line_citations.py -- Reject in-tree source citations with line numbers.
5
6Per CLAUDE.md "Code Style / Comment citations":
7
8 Comments must NOT reference files in this repo by line number
9 (e.g. `libs/foo.c:776`). Line numbers go stale on the next reformat.
10 Reference the function / symbol name instead.
11
12 External / vendor citations (HUM, FSP, RFC, datasheet) remain
13 MANDATORY for any HAL register access, ISR, or driver path.
14
15Scope (derived, not a hardcoded root list -- #358):
16 Scans C / C++ source comments in every first-party C file (via
17 lint_targets, so tools/ -- which the old SCAN_ROOTS tuple
18 silently omitted -- is covered). Flags tokens matching
19 `<file>.<ext>:<line>` inside `// ...` or `/* ... */` comments.
20
21 Also scans every first-party Markdown (`.md`) / plain-text (`.txt`)
22 doc, wherever it lives (tools/mcp, examples/**/README.md, .claude/ agent
23 prompts, the repo root), not just docs/. The same regex applies; in docs
24 the whole line is treated as the "comment" (no comment-span extraction).
25
26Exemptions:
27 * docs/reference/* paths (HUM PDFs etc).
28 * libs/third_party/* and apps/shared_libs/third_party/* (SOUP -- not our
29 citations to manage).
30 * Any line containing `CITES-OK: <reason>` (reason text required).
31 * CHANGELOG-style "moved from <file>:NNN to ..." historical notes.
32 * `// SPDX-License-Identifier:` headers and `#include` directives.
33 * In docs: lines inside a Markdown-valid fenced block whose exact first
34 info-string identifier is a known tool (`cppcheck`, `clang`, `clang-tidy`,
35 `llvm-cov`, `gdb`, `objdump`, `readelf`) -- these are tool transcripts,
36 not citations.
37 * In docs: inline-code spans (backticked) whose first token is one
38 of the same tool names.
39"""
40
41from __future__ import annotations
42
43import subprocess
44import sys
45import tempfile
46from collections.abc import Callable
47from pathlib import Path
48
49sys.path.insert(0, str(Path(__file__).resolve().parent))
50
51from line_citation_lex import (
52 CITATION_RE,
53 all_tracked_files,
54 find_comment_spans,
55 find_mcdc_reason_spans,
56 is_exempt,
57 line_of_offset,
58)
59from line_citation_lex import is_in_scope as _lex_in_scope
60from lint_targets import is_build_output_path
61from markdown_reference_policy import FENCE_RE
62from selftest_assert import expect, report
63
64# Strict: cleanup wave landed; gate now blocks any new line-citation
65# violation. See docs/CITATION_POLICY.md for the rule and the
66# `// CITES-OK: <reason>` per-line opt-out.
67WARN_ONLY_MODE = False
68
69# Snippet display limit for violation output lines.
70MAX_SNIPPET_LEN = 120
71SNIPPET_TRUNCATE_LEN = 117
72EXPECTED_POISON_FINDINGS = 2
73EXPECTED_DOC_POISON_FINDINGS = 3
74EXPECTED_FENCE_POISON_FINDINGS = 4
75
76EXCLUDE_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/")
77# Vendored / generated doc trees. Not first-party prose, so out of scope --
78# named and reasoned rather than left to a positive root allowlist (#358).
79DOC_EXCLUDE_PREFIXES = ("docs/doxygen_theme/", "libs/ra8_fonts/")
80DOC_EXTS = (".md", ".txt")
81TOOL_OUTPUT_TOKENS = (
82 "cppcheck",
83 "clang-tidy",
84 "clang",
85 "llvm-cov",
86 "gdb",
87 "objdump",
88 "readelf",
89)
90
91
92def _fence_is_tool_output(info: str) -> bool:
93 """Accept only a recognized tool as the fence's exact first identifier."""
94 identifiers = info.split(maxsplit=1)
95 if not identifiers:
96 return False
97 return identifiers[0].casefold() in TOOL_OUTPUT_TOKENS
98
99
100def staged_files() -> list[str]:
101 """Paths added, copied, modified or renamed in the index.
102
103 Deletions are filtered out: a removed file has no citation left to check,
104 and reading its blob would fail.
105 """
106 out = subprocess.run(
107 ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], # noqa: S607 # trusted: fixed git argv
108 check=True,
109 capture_output=True,
110 text=True,
111 ).stdout
112 return [line for line in out.splitlines() if line]
113
114
115def is_in_scope(path: str) -> bool:
116 """Whether a source path is subject to the ban, with this tool's exclusions."""
117 return _lex_in_scope(path, EXCLUDE_PREFIXES)
118
119
120def is_doc_in_scope(path: str) -> bool:
121 """Any first-party Markdown / plain-text doc, derived from the tree.
122
123 Widened from the old "docs/ or repo-root only" rule (#358): tools/mcp,
124 examples/**/README.md, .claude/ agent prompts and every other tracked doc
125 are now scanned, so a stale ``file.c:99`` citation cannot hide in one.
126 Vendored SOUP, generated doc trees and build output are the only
127 subtractions.
128 """
129 if not path.lower().endswith(DOC_EXTS):
130 return False
131 if path.startswith(EXCLUDE_PREFIXES) or path.startswith(DOC_EXCLUDE_PREFIXES):
132 return False
133 return not is_build_output_path(path)
134
135
136def _line_is_tool_exempt(line: str, column: int) -> bool:
137 """Return whether ``column`` sits in an inline tool-transcript span."""
138 cursor = 0
139 while cursor < len(line):
140 if line[cursor] != "`":
141 cursor += 1
142 continue
143 end_run = cursor
144 while end_run < len(line) and line[end_run] == "`":
145 end_run += 1
146 marker = line[cursor:end_run]
147 close = line.find(marker, end_run)
148 if close < 0:
149 cursor = end_run
150 continue
151 if end_run <= column < close:
152 content = line[end_run:close].lstrip()
153 return any(
154 content == tok or content.startswith(tok + " ") for tok in TOOL_OUTPUT_TOKENS
155 )
156 cursor = close + len(marker)
157 return False
158
159
160def scan_doc_file(path: Path) -> list[tuple[int, str, str]]:
161 """Scan a markdown or plain-text doc for stale-prone line citations.
162
163 Returns a list of ``(line_no, matched_text, snippet)`` violations.
164
165 Docs need far more exemptions than source does, because a doc legitimately
166 QUOTES tool output that contains ``file:line`` -- which is a transcript,
167 not a citation the reader is meant to follow.
168
169 Exemptions:
170 * Markdown-valid fenced blocks whose first info-string identifier is an
171 exact known tool name.
172 * Inline-code spans whose first token is a known tool (tool transcript).
173 * `CITES-OK: <reason>` opt-out.
174 * `moved from <file>:NN to ...` historical notes.
175 * Anchor links / heading IDs of the form `(file:NN)` are still
176 considered violations -- that is exactly what we want to ban.
177 """
178 try:
179 text = path.read_text(encoding="utf-8", errors="replace")
180 except OSError:
181 return []
182 violations: list[tuple[int, str, str]] = []
183 in_fence = False
184 fence_is_tool = False
185 fence_marker = ""
186 for line_no, raw in enumerate(text.splitlines(), start=1):
187 fence_match = FENCE_RE.match(raw)
188 if not in_fence and fence_match is not None:
189 in_fence = True
190 fence_marker = fence_match.group(1)
191 info = raw[fence_match.end(1) :].strip()
192 fence_is_tool = _fence_is_tool_output(info)
193 continue
194 if in_fence and fence_match is not None:
195 marker = fence_match.group(1)
196 trailing = raw[fence_match.end(1) :].strip()
197 if marker[0] == fence_marker[0] and len(marker) >= len(fence_marker) and not trailing:
198 in_fence = False
199 fence_is_tool = False
200 fence_marker = ""
201 continue
202 if in_fence and fence_is_tool:
203 continue
204 for m in CITATION_RE.finditer(raw):
205 matched = m.group(0)
206 if is_exempt(matched, raw, m.start()):
207 continue
208 if _line_is_tool_exempt(raw, m.start()):
209 continue
210 snippet = raw.strip()
211 if len(snippet) > MAX_SNIPPET_LEN:
212 snippet = snippet[:SNIPPET_TRUNCATE_LEN] + "..."
213 violations.append((line_no, matched, snippet))
214 return violations
215
216
217def line_text(text: str, line_no: int) -> str:
218 """The text of a 1-based line, or "" when the number is out of range.
219
220 Returns empty rather than raising so a finding reported at a line past
221 EOF (possible on a truncated read) still prints instead of aborting the
222 sweep.
223 """
224 lines = text.splitlines()
225 if 1 <= line_no <= len(lines):
226 return lines[line_no - 1]
227 return ""
228
229
230def scan_file(path: Path) -> list[tuple[int, str, str]]:
231 """Return list of (line_no, matched_text, comment_snippet) violations."""
232 try:
233 text = path.read_text(encoding="utf-8", errors="replace")
234 except OSError:
235 return []
236 violations: list[tuple[int, str, str]] = []
237 seen: set[tuple[int, str]] = set()
238 # Two citation-bearing regions: C/C++ comments, and the string reason of an
239 # RA8_MCDC_DEACTIVATED(...) annotation. The reason is a string literal that
240 # find_comment_spans skips, but docs/ANNOTATIONS.md promises this gate scans
241 # it -- a deactivation reason anchored to a line number is DO-178C evidence
242 # that rots silently (#547). Both regions share one exemption cascade, so a
243 # CITES-OK on the line excuses either; dedup keeps a reason that happens to
244 # sit inside a doc comment from being reported twice.
245 spans = find_comment_spans(text) + find_mcdc_reason_spans(text)
246 for start, end in spans:
247 region = text[start:end]
248 # Per-line check: walk each match, validate against the
249 # specific physical line it sits on (so CITES-OK: on the same
250 # line excuses it).
251 for m in CITATION_RE.finditer(region):
252 abs_off = start + m.start()
253 line_no = line_of_offset(text, abs_off)
254 matched = m.group(0)
255 line = line_text(text, line_no)
256 line_start = text.rfind("\n", 0, abs_off) + 1
257 if is_exempt(matched, line, abs_off - line_start):
258 continue
259 if (line_no, matched) in seen:
260 continue
261 seen.add((line_no, matched))
262 snippet = line.strip()
263 if len(snippet) > MAX_SNIPPET_LEN:
264 snippet = snippet[:SNIPPET_TRUNCATE_LEN] + "..."
265 violations.append((line_no, matched, snippet))
266 return violations
267
268
269def _files_to_scan() -> list[str]:
270 """The file set for this run: staged paths, else the whole tree.
271
272 Falling back to the whole tree when nothing is staged is deliberate -- a
273 hook invocation with an empty index must not report a clean tree having
274 looked at no files.
275 """
276 if "--all" in sys.argv:
277 return all_tracked_files()
278 staged = staged_files()
279 return staged or all_tracked_files()
280
281
282def _report_violations(
283 repo_root: Path,
284 paths: list[str],
285 scan: Callable[[Path], list[tuple[int, str, str]]],
286 per_file_counts: dict[str, int],
287) -> int:
288 """Print every violation ``scan`` finds under ``paths``; return the count.
289
290 Takes the scanner as a parameter because source files and documentation
291 are lexed differently but reported identically -- two copies of the
292 reporting loop is how the two report formats drift apart.
293 """
294 total = 0
295 for f in paths:
296 path = repo_root / f
297 if not path.is_file():
298 continue
299 viols = scan(path)
300 if not viols:
301 continue
302 per_file_counts[f] = len(viols)
303 for line_no, matched, snippet in viols:
304 print(f"{f}:{line_no}: line-citation found ('{matched}'): {snippet}")
305 print(" fix: replace with function/symbol name, or add `// CITES-OK: <reason>`")
306 total += 1
307 return total
308
309
310def _selftest_scope(failures: list[str]) -> None:
311 """Assert derived scope: tools source/docs in, both SOUP roots out (#358)."""
312 expect(
313 is_in_scope("tools/ra8_emulator/src/main.c"),
314 "tools/ C is in scope (SCAN_ROOTS omitted it before #358)",
315 failures,
316 )
317 expect(is_doc_in_scope("tools/mcp/README.md"), "tools/ docs are in scope", failures)
318 expect(is_doc_in_scope("docs/reference/README.md"), "reference Markdown is in scope", failures)
319 expect(is_doc_in_scope("docs/UPPER.MD"), "uppercase Markdown is in scope", failures)
320 expect(
321 not is_in_scope("apps/shared_libs/third_party/miniz/miniz.c"),
322 "vendored SOUP stays out of scope",
323 failures,
324 )
325
326
327# ---------------------------------------------------------------------------
328# Selftest -- both directions, for source AND docs, plus scope assertions under
329# tools/ (source and docs), silently omitted until #358.
330# ---------------------------------------------------------------------------
331def _selftest_mcdc_reason_cases(tmp: Path, failures: list[str]) -> None:
332 """Assert both directions of the RA8_MCDC_DEACTIVATED reason scan (#547).
333
334 Extracted from :func:`selftest` so that function stays under the NASA Rule 4
335 line cap; the assertions are unchanged. The macro's reason is a string
336 literal, outside comment spans, so this proves a file:line inside one fires
337 and that a symbol-only or CITES-OK reason stays quiet -- the enforcement
338 docs/ANNOTATIONS.md promises.
339
340 Args:
341 tmp: A writable temporary directory for the fixture files.
342 failures: The accumulator each assertion records into.
343 """
344 bad_mcdc = tmp / "bad_mcdc.c"
345 bad_mcdc.write_text(
346 'RA8_MCDC_DEACTIVATED("guard justified in libs/foo.c:123")\n'
347 "static inline bool internal_guard(const void* p);\n",
348 encoding="utf-8",
349 )
350 expect(
351 bool(scan_file(bad_mcdc)),
352 "a file:line inside an RA8_MCDC_DEACTIVATED reason fires (#547)",
353 failures,
354 )
355 good_mcdc = tmp / "good_mcdc.c"
356 good_mcdc.write_text(
357 'RA8_MCDC_DEACTIVATED("guard: ra8_pin_validator_check asserts non-null")\n'
358 'RA8_MCDC_DEACTIVATED("legacy libs/foo.c:1 CITES-OK: historical note")\n',
359 encoding="utf-8",
360 )
361 expect(
362 not scan_file(good_mcdc),
363 "a symbol-only reason and a CITES-OK reason stay quiet (source)",
364 failures,
365 )
366
367
368def _selftest_fence_tool_cases(tmp: Path, failures: list[str]) -> None:
369 """Prove fenced tool exemptions bind to the exact first identifier."""
370 poison = tmp / "poison-fences.md"
371 poison.write_text(
372 "```notcppcheckthing\nlibs/live.c:999999\n```\n"
373 "```python title=cppcheck\nlibs/live.c:999999\n```\n"
374 " ```cppcheck\nlibs/live.c:999999\n ```\n"
375 "\t```cppcheck\nlibs/live.c:999999\n\t```\n",
376 encoding="utf-8",
377 )
378 expect(
379 len(scan_doc_file(poison)) == EXPECTED_FENCE_POISON_FINDINGS,
380 "fence substrings and unrelated metadata do not exempt citations",
381 failures,
382 )
383 legitimate = tmp / "tool-fences.md"
384 legitimate.write_text(
385 "```cppcheck\nlibs/live.c:999999\n```\n"
386 "```clang-tidy linenums=1\nlibs/live.c:999999\n```\n"
387 "````cppcheck\nlibs/live.c:999999\n```\nlibs/live.c:999999\n````\n",
388 encoding="utf-8",
389 )
390 expect(
391 not scan_doc_file(legitimate),
392 "exact tool fence identifiers exempt their transcripts",
393 failures,
394 )
395
396
397def selftest() -> int:
398 """Prove a file:line citation fires, legal forms stay quiet, and scope holds."""
399 print("check_line_citations.py --selftest")
400 failures: list[str] = []
401 with tempfile.TemporaryDirectory() as tmp:
402 bad_src = Path(tmp) / "bad.c"
403 bad_src.write_text("/* see libs/foo.c:123 for the layout */\n", encoding="utf-8")
404 expect(bool(scan_file(bad_src)), "a file:line citation in a C comment fires", failures)
405 good_src = Path(tmp) / "good.c"
406 good_src.write_text(
407 "/* see ra8_foo(); moved from x.c:12 to here */\n"
408 "// libs/y.c:9 CITES-OK: illustrative example\n",
409 encoding="utf-8",
410 )
411 expect(
412 not scan_file(good_src),
413 "symbol ref / moved-from / CITES-OK stays quiet (source)",
414 failures,
415 )
416 poison_src = Path(tmp) / "poison.c"
417 poison_src.write_text(
418 "/* moved from libs/old.c:12 to here; see libs/live.c:999999 */\n"
419 "/* compare libs/third_party/upstream.c:7 with libs/live.c:999999 */\n",
420 encoding="utf-8",
421 )
422 expect(
423 len(scan_file(poison_src)) == EXPECTED_POISON_FINDINGS,
424 "moved/vendor tokens do not exempt unrelated source citations",
425 failures,
426 )
427 _selftest_mcdc_reason_cases(Path(tmp), failures)
428 _selftest_fence_tool_cases(Path(tmp), failures)
429 bad_doc = Path(tmp) / "bad.md"
430 bad_doc.write_text("See `ra8_ipc_regs.h:267` for the bit.\n", encoding="utf-8")
431 expect(bool(scan_doc_file(bad_doc)), "a file:line citation in a doc fires", failures)
432 good_doc = Path(tmp) / "good.md"
433 good_doc.write_text(
434 "See ra8_ipc_regs.h SAIPCIR2. libs/z.c:3 <!-- CITES-OK: illustrative -->\n",
435 encoding="utf-8",
436 )
437 expect(not scan_doc_file(good_doc), "doc CITES-OK stays quiet", failures)
438 poison_doc = Path(tmp) / "poison.md"
439 poison_doc.write_text(
440 "moved from libs/old.c:12 to here; see libs/live.c:999999\n"
441 "compare libs/third_party/upstream.c:7 with libs/live.c:999999\n"
442 "clang is installed; see libs/live.c:999999\n",
443 encoding="utf-8",
444 )
445 expect(
446 len(scan_doc_file(poison_doc)) == EXPECTED_DOC_POISON_FINDINGS,
447 "moved/vendor/tool tokens do not exempt unrelated document citations",
448 failures,
449 )
450 tool_doc = Path(tmp) / "tool.md"
451 tool_doc.write_text("`clang libs/live.c:999999: warning`\n", encoding="utf-8")
452 expect(not scan_doc_file(tool_doc), "inline tool transcript stays quiet", failures)
453
454 _selftest_scope(failures)
455 return report(failures)
456
457
458def main() -> int:
459 """Reject in-tree citations that name a file by line number.
460
461 The rule exists because ``libs/foo.c:123`` goes stale the moment anything
462 above line 123 changes, and nothing detects that it has: the reference
463 still parses, still looks precise, and now points at the wrong line.
464 Function and symbol names survive edits, so they are what must be cited.
465
466 External HUM citations are unaffected -- the manual has stable page
467 numbers, and citing them is mandatory elsewhere in the tree.
468
469 Returns 1 listing each stale-prone citation, 0 when the scanned set is
470 clean.
471 """
472 if "--selftest" in sys.argv[1:]:
473 return selftest()
474
475 repo_root = Path(
476 subprocess.run(
477 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 # trusted: fixed git argv
478 check=True,
479 capture_output=True,
480 text=True,
481 ).stdout.strip()
482 )
483
484 files = _files_to_scan()
485 total_violations = 0
486 per_file_counts: dict[str, int] = {}
487 for scan, paths in (
488 (scan_file, [f for f in files if is_in_scope(f)]),
489 (scan_doc_file, [f for f in files if is_doc_in_scope(f)]),
490 ):
491 total_violations += _report_violations(repo_root, paths, scan, per_file_counts)
492
493 if total_violations == 0:
494 return 0
495
496 print(file=sys.stderr)
497 print(
498 f"check_line_citations: {total_violations} violation(s) across "
499 f"{len(per_file_counts)} file(s).",
500 file=sys.stderr,
501 )
502 if WARN_ONLY_MODE:
503 print(
504 "check_line_citations: WAVE 0 -- warn-only, not blocking commit.",
505 file=sys.stderr,
506 )
507 return 0
508 return 1
509
510
511if __name__ == "__main__":
512 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298