4"""check_line_citations.py -- Reject in-tree source citations with line numbers.
6Per CLAUDE.md "Code Style / Comment citations":
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.
12 External / vendor citations (HUM, FSP, RFC, datasheet) remain
13 MANDATORY for any HAL register access, ISR, or driver path.
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.
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).
27 * docs/reference/* paths (HUM PDFs etc).
28 * libs/third_party/* and apps/shared_libs/third_party/* (SOUP -- not our
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,
37 * In docs: inline-code spans (backticked) whose first token is one
38 of the same tool names.
41from __future__
import annotations
46from collections.abc
import Callable
47from pathlib
import Path
49sys.path.insert(0, str(Path(__file__).resolve().parent))
51from line_citation_lex
import (
55 find_mcdc_reason_spans,
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
71SNIPPET_TRUNCATE_LEN = 117
72EXPECTED_POISON_FINDINGS = 2
73EXPECTED_DOC_POISON_FINDINGS = 3
74EXPECTED_FENCE_POISON_FINDINGS = 4
76EXCLUDE_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/")
79DOC_EXCLUDE_PREFIXES = (
"docs/doxygen_theme/",
"libs/ra8_fonts/")
80DOC_EXTS = (
".md",
".txt")
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)
97 return identifiers[0].casefold()
in TOOL_OUTPUT_TOKENS
100def staged_files() -> list[str]:
101 """Paths added, copied, modified or renamed in the index.
103 Deletions are filtered out: a removed file has no citation left to check,
104 and reading its blob would fail.
106 out = subprocess.run(
107 [
"git",
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR"],
112 return [line
for line
in out.splitlines()
if line]
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)
120def is_doc_in_scope(path: str) -> bool:
121 """Any first-party Markdown / plain-text doc, derived from the tree.
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
129 if not path.lower().endswith(DOC_EXTS):
131 if path.startswith(EXCLUDE_PREFIXES)
or path.startswith(DOC_EXCLUDE_PREFIXES):
133 return not is_build_output_path(path)
136def _line_is_tool_exempt(line: str, column: int) -> bool:
137 """Return whether ``column`` sits in an inline tool-transcript span."""
139 while cursor < len(line):
140 if line[cursor] !=
"`":
144 while end_run < len(line)
and line[end_run] ==
"`":
146 marker = line[cursor:end_run]
147 close = line.find(marker, end_run)
151 if end_run <= column < close:
152 content = line[end_run:close].lstrip()
154 content == tok
or content.startswith(tok +
" ")
for tok
in TOOL_OUTPUT_TOKENS
156 cursor = close + len(marker)
160def scan_doc_file(path: Path) -> list[tuple[int, str, str]]:
161 """Scan a markdown or plain-text doc for stale-prone line citations.
163 Returns a list of ``(line_no, matched_text, snippet)`` violations.
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.
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.
179 text = path.read_text(encoding=
"utf-8", errors=
"replace")
182 violations: list[tuple[int, str, str]] = []
184 fence_is_tool =
False
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:
190 fence_marker = fence_match.group(1)
191 info = raw[fence_match.end(1) :].strip()
192 fence_is_tool = _fence_is_tool_output(info)
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:
199 fence_is_tool =
False
202 if in_fence
and fence_is_tool:
204 for m
in CITATION_RE.finditer(raw):
206 if is_exempt(matched, raw, m.start()):
208 if _line_is_tool_exempt(raw, m.start()):
210 snippet = raw.strip()
211 if len(snippet) > MAX_SNIPPET_LEN:
212 snippet = snippet[:SNIPPET_TRUNCATE_LEN] +
"..."
213 violations.append((line_no, matched, snippet))
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.
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
224 lines = text.splitlines()
225 if 1 <= line_no <= len(lines):
226 return lines[line_no - 1]
230def scan_file(path: Path) -> list[tuple[int, str, str]]:
231 """Return list of (line_no, matched_text, comment_snippet) violations."""
233 text = path.read_text(encoding=
"utf-8", errors=
"replace")
236 violations: list[tuple[int, str, str]] = []
237 seen: set[tuple[int, str]] = set()
245 spans = find_comment_spans(text) + find_mcdc_reason_spans(text)
246 for start, end
in spans:
247 region = text[start:end]
251 for m
in CITATION_RE.finditer(region):
252 abs_off = start + m.start()
253 line_no = line_of_offset(text, abs_off)
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):
259 if (line_no, matched)
in seen:
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))
269def _files_to_scan() -> list[str]:
270 """The file set for this run: staged paths, else the whole tree.
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
276 if "--all" in sys.argv:
277 return all_tracked_files()
278 staged = staged_files()
279 return staged
or all_tracked_files()
282def _report_violations(
285 scan: Callable[[Path], list[tuple[int, str, str]]],
286 per_file_counts: dict[str, int],
288 """Print every violation ``scan`` finds under ``paths``; return the count.
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.
297 if not path.is_file():
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>`")
310def _selftest_scope(failures: list[str]) ->
None:
311 """Assert derived scope: tools source/docs in, both SOUP roots out (#358)."""
313 is_in_scope(
"tools/ra8_emulator/src/main.c"),
314 "tools/ C is in scope (SCAN_ROOTS omitted it before #358)",
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)
321 not is_in_scope(
"apps/shared_libs/third_party/miniz/miniz.c"),
322 "vendored SOUP stays out of scope",
331def _selftest_mcdc_reason_cases(tmp: Path, failures: list[str]) ->
None:
332 """Assert both directions of the RA8_MCDC_DEACTIVATED reason scan (#547).
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.
341 tmp: A writable temporary directory for the fixture files.
342 failures: The accumulator each assertion records into.
344 bad_mcdc = tmp /
"bad_mcdc.c"
346 'RA8_MCDC_DEACTIVATED("guard justified in libs/foo.c:123")\n'
347 "static inline bool internal_guard(const void* p);\n",
351 bool(scan_file(bad_mcdc)),
352 "a file:line inside an RA8_MCDC_DEACTIVATED reason fires (#547)",
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',
362 not scan_file(good_mcdc),
363 "a symbol-only reason and a CITES-OK reason stay quiet (source)",
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"
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",
379 len(scan_doc_file(poison)) == EXPECTED_FENCE_POISON_FINDINGS,
380 "fence substrings and unrelated metadata do not exempt citations",
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",
391 not scan_doc_file(legitimate),
392 "exact tool fence identifiers exempt their transcripts",
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"
407 "/* see ra8_foo(); moved from x.c:12 to here */\n"
408 "// libs/y.c:9 CITES-OK: illustrative example\n",
412 not scan_file(good_src),
413 "symbol ref / moved-from / CITES-OK stays quiet (source)",
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",
423 len(scan_file(poison_src)) == EXPECTED_POISON_FINDINGS,
424 "moved/vendor tokens do not exempt unrelated source citations",
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"
434 "See ra8_ipc_regs.h SAIPCIR2. libs/z.c:3 <!-- CITES-OK: illustrative -->\n",
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",
446 len(scan_doc_file(poison_doc)) == EXPECTED_DOC_POISON_FINDINGS,
447 "moved/vendor/tool tokens do not exempt unrelated document citations",
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)
454 _selftest_scope(failures)
455 return report(failures)
459 """Reject in-tree citations that name a file by line number.
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.
466 External HUM citations are unaffected -- the manual has stable page
467 numbers, and citing them is mandatory elsewhere in the tree.
469 Returns 1 listing each stale-prone citation, 0 when the scanned set is
472 if "--selftest" in sys.argv[1:]:
477 [
"git",
"rev-parse",
"--show-toplevel"],
484 files = _files_to_scan()
486 per_file_counts: dict[str, int] = {}
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)]),
491 total_violations += _report_violations(repo_root, paths, scan, per_file_counts)
493 if total_violations == 0:
496 print(file=sys.stderr)
498 f
"check_line_citations: {total_violations} violation(s) across "
499 f
"{len(per_file_counts)} file(s).",
504 "check_line_citations: WAVE 0 -- warn-only, not blocking commit.",
511if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.