ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
cite_check.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"""cite_check.py -- validate Hardware User's Manual citations in source.
5
6This script scans C / header files for in-line annotations of the form
7
8 /* HUM Ch X.Y "section name", p NNNN */
9
10and runs one or both of two complementary passes:
11
12 Cite-VALIDATION (always on) -- for every cite that already exists,
13 verify that:
14 1. The chapter number X exists in docs/reference/CHAPTER_MAP.md.
15 2. The page number NNNN falls inside chapter X's page range.
16 3. The comment is well-formed (a `HUM Ch` comment that does not
17 parse is reported as malformed).
18
19 Cite-COVERAGE (--require-cites) -- the complementary headline rule
20 from CLAUDE.md: every direct MMIO register read/write must be
21 immediately preceded by a `/* HUM ... */` cite. Flags accesses that
22 have none. Modelled block-aware (one cite covers the contiguous block
23 of accesses beneath it).
24
25Modes:
26
27 --warn (default) -- exit 0, print findings to stderr.
28 --strict (onward) -- exit 1 on any finding.
29 --require-cites -- also run the cite-COVERAGE pass (advisory unless
30 combined with --strict).
31
32The script also accepts a list of explicit file arguments. With no
33arguments it scans every first-party C file, derived from git ls-files
34via lint_targets (#358) -- so tools/ra8_emulator (which models RA8
35registers and cites the RA8 HUM) and port/usbx were previously omitted
36and their citations went unvalidated. Vendored C under port/threadx and both
37canonical third-party roots is dropped automatically.
38
39The chapter map is parsed from CHAPTER_MAP.md so the page-range
40truth lives in exactly one place. The pre-commit hook invokes this
41script after build_chapter_map.sh has been run; nothing here calls
42pdftotext.
43
44Citation format details:
45
46 /* HUM Ch X.Y "..." p NNNN */ single-page form
47 /* HUM Ch X.Y "..." p NNNN-MMMM */ page range form
48
49The X chapter number is required; subsection Y is optional in the
50parser (some module-stop-only writes only carry the chapter). The
51section-name string is enforced as present so a future linter pass
52can grep for human-readable context.
53
54The script is intentionally conservative: an unparseable comment
55that *looks* like a HUM cite (starts with `HUM Ch`) is reported as
56malformed even if it would otherwise pass the page-in-range check.
57"""
58
59from __future__ import annotations
60
61import argparse
62import pathlib
63import re
64import sys
65import tempfile
66from collections.abc import Iterable
67
68sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
69
70from lint_targets import first_party_paths
71from selftest_assert import expect, report
72
73REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
74CHAPTER_MAP_PATH = REPO_ROOT / "docs" / "reference" / "CHAPTER_MAP.md"
75
76SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp")
77
78CITE_RE = re.compile(
79 r"""
80 /\*\s*HUM\s+Ch\s+
81 (?P<chapter>\d{1,2}) # chapter number
82 (?:\.(?P<sub>\d{1,3}(?:\.\d{1,3})*))? # optional subsection X.Y[.Z...]
83 \s*
84 "(?P<section>[^"]*)" # quoted section name
85 \s*,?\s*
86 (?:(?:Table|Figure)\s+\d{1,3}(?:\.\d{1,3})*\s*)? # optional Table/Figure ref
87 p\s+
88 (?P<start>\d{1,5}) # start page
89 (?:\s*-\s*(?P<end>\d{1,5}))? # optional end page
90 (?:\s*--[^*]*)? # optional trailing "-- note" clause before the close
91 \s*\*/
92 """,
93 re.VERBOSE,
94)
95
96LOOSE_HUM_RE = re.compile(r"/\*\s*HUM\s+Ch\b[^*]*\*/")
97
98# HUM chapter numbers are 1-based; the manual has fewer than 100 chapters.
99MAX_HUM_CHAPTER = 99
100
101
102def parse_chapter_map(path: pathlib.Path) -> dict[int, tuple[int, int, str]]:
103 """Parse CHAPTER_MAP.md into {chapter: (start_page, end_page, title)}.
104
105 The map's table rows look like:
106
107 | 1 | Overview | 69 | 110 |
108
109 Lines that don't match the row pattern are ignored.
110 """
111 if not path.exists():
112 msg = f"chapter map missing: {path}"
113 raise FileNotFoundError(msg)
114
115 row_re = re.compile(
116 r"^\|\s*(\d{1,2})\s*\|\s*([^|]+?)\s*\|\s*(\d{1,5})\s*\|\s*(\d{1,5})\s*\|\s*$"
117 )
118
119 chapters: dict[int, tuple[int, int, str]] = {}
120 for line in path.read_text(encoding="utf-8").splitlines():
121 m = row_re.match(line)
122 if m is None:
123 continue
124 num = int(m.group(1))
125 title = m.group(2).strip()
126 start = int(m.group(3))
127 end = int(m.group(4))
128 if not (1 <= num <= MAX_HUM_CHAPTER):
129 continue
130 chapters[num] = (start, end, title)
131 return chapters
132
133
134def iter_source_files(targets: Iterable[pathlib.Path]) -> Iterable[pathlib.Path]:
135 """Yield every C/H source file under any of the given targets."""
136 for t in targets:
137 if not t.exists():
138 continue
139 if t.is_file():
140 if t.suffix.lower() in SOURCE_SUFFIXES:
141 yield t
142 continue
143 for sub in t.rglob("*"):
144 if not sub.is_file():
145 continue
146 if sub.suffix.lower() not in SOURCE_SUFFIXES:
147 continue
148 # Skip vendored / build trees: nothing under either canonical
149 # third-party root or any build directory is linted for HUM cites.
150 parts = set(sub.parts)
151 if "third_party" in parts or "build" in parts:
152 continue
153 if "_deps" in parts:
154 continue
155 yield sub
156
157
158# ---------------------------------------------------------------------------
159# Register-access coverage pass ("does every MMIO access HAVE a cite?").
160#
161# cite_check's original job is to validate cites that already EXIST. The
162# coverage pass answers the complementary, headline question from CLAUDE.md's
163# HUM-citation policy: every direct register read/write must be immediately
164# preceded by a `/* HUM ... */` cite. It is enabled with --require-cites
165# (advisory unless combined with --strict) and models the tree's real
166# convention -- one cite comment covers the contiguous block of accesses
167# beneath it until the next non-access statement.
168# ---------------------------------------------------------------------------
169
170# An MMIO field access: `<ptr>->FIELD` or `accessor()->FIELD`, where FIELD is
171# the project's ALL-CAPS register-field spelling (>= 2 chars). First-party C
172# structs in this tree use lower_snake_case members, so an upper-case member
173# after `->` is a reliable register-access signal with a low false-positive
174# rate.
175#
176# The trailing `\b` is load-bearing: without it the ALL-CAPS run is allowed to
177# stop in the middle of a CamelCase identifier, so a C++ member call whose first
178# two characters happen to be upper-case matched as a register. `text->CData()`
179# in an XML shim was read as `text->CD` and reported as an uncited MMIO
180# access -- a class that grows with every C++ shim added, since `\b` is the only
181# thing that distinguishes `->CFDGSTS` (a real CAN-FD register) from `->CData`.
182ACCESS_RE = re.compile(r"(?:\‍)|[A-Za-z_]\w*)\s*->\s*[A-Z][A-Z0-9_]+\b")
183# Raw volatile-pointer dereference, e.g. `*(volatile uint32_t *)addr = ...`.
184RAW_DEREF_RE = re.compile(r"\*\s*\‍(\s*volatile\b")
185# A HUM cite token anywhere on a line (any recognised form: "HUM Ch",
186# "HUM N.N", "HUM Table", ...). Used only to decide whether an access is
187# covered, so a permissive match is deliberate: it can only SUPPRESS a finding.
188HUM_ON_LINE_RE = re.compile(r"\bHUM\b")
189
190# How far up we scan for a covering cite before giving up.
191MAX_CITE_LOOKUP_LINES = 40
192
193
194class _Blanker:
195 """Character state machine blanking comment and literal interiors.
196
197 One method per state. The machine is the whole algorithm, so it is
198 expressed as states rather than as one loop with a state variable and
199 four branches -- each method's contract is "consume from i, return the
200 next state and index", which is checkable a state at a time.
201 """
202
203 def __init__(self, text: str) -> None:
204 """Prepare a mutable blanking buffer over ``text``.
205
206 ``out`` starts as a character-for-character copy so blanking preserves
207 every offset -- line and column numbers reported against the blanked
208 view stay valid for the original.
209 """
210 self.text = text
211 self.out = list(text)
212 self.n = len(text)
213
214 def _peek(self, i: int) -> str:
215 """The character after ``i``, or "" at end of text.
216
217 Returning "" rather than raising lets every two-character token test
218 (``//``, ``/*``, ``*/``) run unguarded at the last position.
219 """
220 return self.text[i + 1] if i + 1 < self.n else ""
221
222 def _blank(self, i: int, count: int = 1) -> None:
223 """Overwrite ``count`` characters with spaces, clamped to the end."""
224 for k in range(i, min(i + count, self.n)):
225 self.out[k] = " "
226
227 def _code(self, i: int, c: str) -> tuple[str, int]:
228 """Step the scanner through ordinary code, returning ``(next_state, index)``.
229
230 Code itself is left intact; this only recognises where a comment or
231 literal begins and hands control to the matching state.
232 """
233 nxt = self._peek(i)
234 if c == "/" and nxt == "/":
235 self._blank(i, 2)
236 return "line_comment", i + 2
237 if c == "/" and nxt == "*":
238 self._blank(i, 2)
239 return "block_comment", i + 2
240 if c == '"':
241 return "string", i + 1
242 if c == "'":
243 return "char", i + 1
244 return "code", i + 1
245
246 def _line_comment(self, i: int, c: str) -> tuple[str, int]:
247 """Blank a ``//`` comment, ending at the newline, which is preserved."""
248 if c == "\n":
249 return "code", i + 1
250 self._blank(i)
251 return "line_comment", i + 1
252
253 def _block_comment(self, i: int, c: str) -> tuple[str, int]:
254 """Blank a ``/* */`` comment, preserving its interior newlines.
255
256 Keeping the newlines is what holds line numbers stable across a
257 multi-line comment.
258 """
259 if c == "*" and self._peek(i) == "/":
260 self._blank(i, 2)
261 return "code", i + 2
262 if c != "\n":
263 self._blank(i)
264 return "block_comment", i + 1
265
266 def _literal(self, i: int, c: str, state: str) -> tuple[str, int]:
267 """Blank a string or char literal, honouring backslash escapes.
268
269 An escaped closer must not end the literal, and a line continuation
270 inside one must not be mistaken for its end -- both are why this
271 consumes the escaped character rather than only the backslash.
272 """
273 closer = '"' if state == "string" else "'"
274 if c == "\\":
275 self._blank(i)
276 if i + 1 < self.n and self.text[i + 1] != "\n":
277 self._blank(i + 1)
278 return state, i + 2
279 if c == closer:
280 return "code", i + 1
281 if c != "\n":
282 self._blank(i)
283 return state, i + 1
284
285 def run(self) -> str:
286 """Drive the machine over the whole text and return the blanked copy."""
287 state, i = "code", 0
288 while i < self.n:
289 c = self.text[i]
290 if state == "code":
291 state, i = self._code(i, c)
292 elif state == "line_comment":
293 state, i = self._line_comment(i, c)
294 elif state == "block_comment":
295 state, i = self._block_comment(i, c)
296 else: # string or char literal
297 state, i = self._literal(i, c, state)
298 return "".join(self.out)
299
300
301def blank_comments_and_strings(text: str) -> str:
302 """Return `text` with comment and string interiors replaced by spaces.
303
304 Newlines and total length are preserved so per-line indexing still lines
305 up with the original. Blanking prose keeps a comment like
306 ``channel-index -> MSTP id`` or a log string like ``"SCR->CTL"`` from
307 being misread as a register access.
308 """
309 return _Blanker(text).run()
310
311
312def is_access_line(blanked_line: str) -> bool:
313 """True if a code (comment/string-blanked) line performs an MMIO access."""
314 return bool(ACCESS_RE.search(blanked_line)) or bool(RAW_DEREF_RE.search(blanked_line))
315
316
317def scan_access_coverage(path: pathlib.Path, text: str) -> tuple[list[str], int]:
318 """Return uncited-access findings AND the total access-line count, in one pass.
319
320 `cite_ratchet.py` needs both numbers for every file: the findings are the
321 debt it ratchets, and the total is its detector-health floor -- the uncited
322 count alone cannot distinguish "the tree got cited" from "ACCESS_RE stopped
323 matching", and the second reads as a burn-down. Blanking is the expensive
324 step (a per-character state machine over every source file), so both come
325 out of one blanking pass rather than two.
326
327 Args:
328 path: File the findings are reported against.
329 text: Raw source text.
330
331 Returns:
332 A ``(findings, access_line_count)`` pair. The count includes CITED
333 accesses; only the findings are uncited.
334 """
335 orig = text.splitlines()
336 blanked = blank_comments_and_strings(text).splitlines()
337 return (
338 _uncited_in_lines(path, orig, blanked),
339 sum(1 for line in blanked if is_access_line(line)),
340 )
341
342
343def _is_block_continuation(blanked_line: str) -> bool:
344 """True if a line is part of the same register-access block while walking up.
345
346 Blank lines, and statement fragments split across lines (a trailing binary
347 operator, or a leading continuation token), are transparent: one cite above
348 a block still covers accesses whose value expression wraps onto its own
349 line.
350 """
351 s = blanked_line.strip()
352 if s == "":
353 return True
354 if s.endswith(("=", "|", "&", "+", "-", "*", ",", "(", "<<", ">>", "?", ":")):
355 return True
356 return s[0] in ".|&)+*?:"
357
358
359def _uncited_in_lines(path: pathlib.Path, orig: list[str], blanked: list[str]) -> list[str]:
360 """Findings for the already-blanked view of one file.
361
362 The single definition of "this access lacks a citation". Both
363 `find_uncited_accesses` and `scan_access_coverage` delegate here so a
364 second, drifting copy of the block-walk cannot appear.
365
366 Args:
367 path: File the findings are reported against.
368 orig: Original source lines -- citations are read from these, since
369 blanking erases comment interiors.
370 blanked: The comment/string-blanked view, line for line with `orig`.
371
372 Returns:
373 One finding string per uncited access, in line order.
374 """
375 findings: list[str] = []
376 for i, bline in enumerate(blanked):
377 if not is_access_line(bline):
378 continue
379 # Same-line trailing cite, e.g. `reg->X = 1; /* HUM Ch .. */`.
380 if HUM_ON_LINE_RE.search(orig[i]):
381 continue
382 covered = False
383 j = i - 1
384 steps = 0
385 while j >= 0 and steps < MAX_CITE_LOOKUP_LINES:
386 # A HUM token on a comment-blanked (i.e. non-code) line is a cite.
387 if blanked[j].strip() == "" and HUM_ON_LINE_RE.search(orig[j]):
388 covered = True
389 break
390 if is_access_line(blanked[j]) or _is_block_continuation(blanked[j]):
391 j -= 1
392 steps += 1
393 continue
394 break # a real statement with no covering cite above the block
395 if not covered:
396 findings.append(
397 f"{path}:{i + 1}: MMIO access without preceding HUM cite: {orig[i].strip()[:70]}"
398 )
399 return findings
400
401
402def find_uncited_accesses(path: pathlib.Path, text: str) -> list[str]:
403 """Return findings for MMIO accesses lacking a preceding HUM cite.
404
405 Models the tree convention that one `/* HUM ... */` comment covers the
406 contiguous block of register accesses directly beneath it: an access is
407 covered when walking upward -- past blank lines, comment-only lines, other
408 accesses in the same block, and statement continuations -- reaches a HUM
409 cite before any other statement.
410
411 Args:
412 path: File the findings are reported against.
413 text: Raw source text.
414
415 Returns:
416 One finding string per uncited access, in line order.
417 """
418 return _uncited_in_lines(path, text.splitlines(), blank_comments_and_strings(text).splitlines())
419
420
421def check_file(
422 path: pathlib.Path,
423 chapters: dict[int, tuple[int, int, str]],
424 *,
425 require_cites: bool = False,
426) -> list[str]:
427 """Return a list of finding strings for one file."""
428 findings: list[str] = []
429 try:
430 text = path.read_text(encoding="utf-8", errors="replace")
431 except OSError as exc:
432 return [f"{path}: read error: {exc}"]
433
434 parsed_spans: list[tuple[int, int]] = []
435 for m in CITE_RE.finditer(text):
436 parsed_spans.append(m.span())
437 chapter = int(m.group("chapter"))
438 start = int(m.group("start"))
439 end_raw = m.group("end")
440 end = int(end_raw) if end_raw else start
441 section = m.group("section")
442
443 line_no = text.count("\n", 0, m.start()) + 1
444
445 if chapter not in chapters:
446 findings.append(f"{path}:{line_no}: HUM Ch {chapter} not in chapter map")
447 continue
448 ch_start, ch_end, ch_title = chapters[chapter]
449
450 if start < ch_start or end > ch_end:
451 findings.append(
452 f'{path}:{line_no}: HUM Ch {chapter} "{section}" '
453 f"page range {start}-{end} outside chapter range "
454 f"{ch_start}-{ch_end} ({ch_title})"
455 )
456 continue
457
458 if end < start:
459 findings.append(f"{path}:{line_no}: HUM Ch {chapter} reversed page range {start}-{end}")
460 continue
461
462 # Catch malformed HUM cites that did NOT match CITE_RE. Anything
463 # under LOOSE_HUM_RE that wasn't already covered by a parsed span
464 # is reported as malformed.
465 for lm in LOOSE_HUM_RE.finditer(text):
466 if any(s <= lm.start() < e for s, e in parsed_spans):
467 continue
468 line_no = text.count("\n", 0, lm.start()) + 1
469 findings.append(
470 f"{path}:{line_no}: malformed HUM cite "
471 f'{lm.group(0)!r} -- expected /* HUM Ch X.Y "..." p NNNN */'
472 )
473
474 if require_cites:
475 findings.extend(find_uncited_accesses(path, text))
476
477 return findings
478
479
480def _build_parser() -> argparse.ArgumentParser:
481 """Build the command-line parser for this gate."""
482 parser = argparse.ArgumentParser(description=__doc__)
483 parser.add_argument(
484 "paths",
485 nargs="*",
486 help="files or directories to scan (default: every tracked first-party C file)",
487 )
488 parser.add_argument(
489 "--warn",
490 action="store_true",
491 help="warn-only mode: print findings, exit 0 (default)",
492 )
493 parser.add_argument(
494 "--strict",
495 action="store_true",
496 help="strict mode: exit 1 on any finding (onward)",
497 )
498 parser.add_argument(
499 "--require-cites",
500 action="store_true",
501 help=(
502 "also flag MMIO register accesses that lack a preceding HUM cite "
503 "(the citation-COVERAGE pass, complementary to the default "
504 "cite-VALIDATION pass). Advisory unless combined with --strict."
505 ),
506 )
507 parser.add_argument(
508 "--selftest",
509 action="store_true",
510 help="prove the validator fires on a malformed cite and the scope holds",
511 )
512 parser.add_argument(
513 "--chapter-map",
514 default=str(CHAPTER_MAP_PATH),
515 help=f"path to CHAPTER_MAP.md (default: {CHAPTER_MAP_PATH})",
516 )
517 return parser
518
519
520def _report_findings(
521 findings: list[str], file_count: int, *, strict: bool, require_cites: bool
522) -> None:
523 """Print every finding and a one-line summary naming the mode it ran in.
524
525 The summary splits the two passes apart when --require-cites is on: an
526 uncited MMIO access and a malformed citation are different defects, and a
527 single total hides which one a reader has to go and fix.
528 """
529 for line in findings:
530 print(line, file=sys.stderr)
531 verdict = "strict" if strict else "warn"
532 total = len(findings)
533 head = f"cite_check.py: {total} finding(s) across {file_count} file(s) [{verdict}]"
534 if require_cites:
535 uncited = sum(1 for line in findings if "MMIO access without preceding HUM cite" in line)
536 head += f" ({uncited} uncited-access, {total - uncited} cite-validation)"
537 print(head, file=sys.stderr)
538
539
540# ---------------------------------------------------------------------------
541# Selftest -- both directions, plus a scope assertion under tools/, silently
542# omitted until #358.
543# ---------------------------------------------------------------------------
544
545# An uncited MMIO write. The cite-COVERAGE pass must FIRE on this.
546_ST_UNCITED_C = """\
547void drv_init(void)
548{
549 volatile r_canfd_regs_t* reg = canfd();
550 reg->CFDGSTS = 0U;
551}
552"""
553
554# The same write, cited. The pass must stay QUIET.
555_ST_CITED_C = """\
556void drv_init(void)
557{
558 volatile r_canfd_regs_t* reg = canfd();
559 /* HUM Ch 25 "Ultra-Low-Power Timer" p 1190 */
560 reg->CFDGSTS = 0U;
561}
562"""
563
564# Address-of a register field handed to a register-agnostic poll helper. The
565# load happens INSIDE the helper, so this call site is the only place the
566# register is named -- it must FIRE. Asserted so a future "precision" narrowing
567# that drops address-of has to argue with a failing selftest rather than with a
568# silently shrinking backlog.
569_ST_ADDR_OF_C = """\
570ra8_err_t drv_wait(void)
571{
572 volatile r_canfd_regs_t* reg = canfd();
573 return ra8_hw_wait_flag_set32(&reg->CFDGSTS, mask, spin);
574}
575"""
576
577# A C++ member call whose first two characters are upper-case. NOT a register;
578# the pass must stay QUIET (it read this as `text->CD` before the trailing \\b).
579_ST_CAMEL_CPP = """\
580bool shim_is_cdata(const XMLText* text)
581{
582 if (text->CData()) {
583 return true;
584 }
585 return false;
586}
587"""
588
589# A genuinely two-character register name, so the \\b fix cannot be satisfied by
590# simply demanding a longer ALL-CAPS run. Must FIRE.
591_ST_SHORT_REG_C = """\
592void drv_poke(void)
593{
594 volatile r_i3c_regs_t* reg = i3c();
595 reg->CD = 0U;
596}
597"""
598
599
600def _selftest_coverage(chapters: dict[int, tuple[int, int, str]], failures: list[str]) -> None:
601 """Assert the cite-COVERAGE pass fires and stays quiet on the right inputs.
602
603 Both directions, because a coverage pass that quietly stopped matching
604 reports a shrinking backlog -- which reads as progress.
605
606 Args:
607 chapters: Parsed chapter map, as returned by ``parse_chapter_map``.
608 failures: Accumulator that ``expect`` appends failed labels to.
609 """
610 cases: tuple[tuple[str, str, bool, str], ...] = (
611 ("uncited.c", _ST_UNCITED_C, True, "an uncited MMIO write fires under --require-cites"),
612 ("cited.c", _ST_CITED_C, False, "the same write with a HUM cite above stays quiet"),
613 ("addrof.c", _ST_ADDR_OF_C, True, "&reg->FIELD passed to a poll helper fires"),
614 ("shim.cpp", _ST_CAMEL_CPP, False, "a C++ `->CData()` member call stays quiet"),
615 ("short.c", _ST_SHORT_REG_C, True, "a two-character register name still fires"),
616 )
617 with tempfile.TemporaryDirectory() as tmp:
618 for name, body, want_finding, label in cases:
619 path = pathlib.Path(tmp) / name
620 path.write_text(body, encoding="utf-8")
621 got = bool(check_file(path, chapters, require_cites=True))
622 expect(got == want_finding, label, failures)
623
624 # The coverage pass is opt-in: without --require-cites the very input
625 # that fires above must produce nothing, or the default mode would have
626 # been silently reporting a backlog it never promised to.
627 path = pathlib.Path(tmp) / "uncited.c"
628 expect(
629 not check_file(path, chapters),
630 "the coverage pass stays off unless --require-cites is given",
631 failures,
632 )
633
634
635def selftest() -> int:
636 """Prove a malformed cite fires, a well-formed one is quiet, and scope holds."""
637 print("cite_check.py --selftest")
638 failures: list[str] = []
639 chapters = parse_chapter_map(CHAPTER_MAP_PATH)
640 with tempfile.TemporaryDirectory() as tmp:
641 bad = pathlib.Path(tmp) / "bad.c"
642 bad.write_text("/* HUM Ch 25 a malformed cite with no page */\n", encoding="utf-8")
643 good = pathlib.Path(tmp) / "good.c"
644 good.write_text('/* HUM Ch 25 "Ultra-Low-Power Timer" p 1190 */\n', encoding="utf-8")
645 expect(bool(check_file(bad, chapters)), "a malformed HUM cite fires", failures)
646 expect(
647 not check_file(good, chapters),
648 "a well-formed, in-range cite stays quiet",
649 failures,
650 )
651 _selftest_coverage(chapters, failures)
652 scope = set(first_party_paths(SOURCE_SUFFIXES))
653 expect(
654 any(s.startswith("tools/") for s in scope),
655 "tools/ is in scope (the scan-dir list omitted it before #358)",
656 failures,
657 )
658 expect(
659 not any(
660 s.startswith(("libs/third_party/", "apps/shared_libs/third_party/")) for s in scope
661 ),
662 "vendored SOUP stays out of scope",
663 failures,
664 )
665 return report(failures)
666
667
668def main(argv: list[str]) -> int:
669 """Verify every register access carries a Hardware User's Manual citation.
670
671 The rule exists because a register write with no citation cannot be
672 reviewed: the reader has no way to confirm the bit pattern against the
673 manual, and a wrong one produces hardware that misbehaves rather than
674 code that fails to build.
675
676 Citations are looked for only in COMMENTS, which is why the source is
677 scanned through the blanking pass above -- a manual section number
678 appearing in a string literal is not a citation.
679
680 Returns 0 when every access is cited, 1 otherwise.
681 """
682 args = _build_parser().parse_args(argv)
683
684 if args.selftest:
685 return selftest()
686
687 if args.warn and args.strict:
688 print("cite_check.py: --warn and --strict are mutually exclusive", file=sys.stderr)
689 return 2
690
691 # Default to warn unless explicitly strict.
692 strict = args.strict and not args.warn
693
694 try:
695 chapters = parse_chapter_map(pathlib.Path(args.chapter_map))
696 except FileNotFoundError as exc:
697 print(f"cite_check.py: {exc}", file=sys.stderr)
698 return 2
699
700 if args.paths:
701 targets = [pathlib.Path(p) for p in args.paths]
702 else:
703 # Derived from git ls-files (#358): every first-party C file, not just
704 # a short fixed root list. tools/ra8_emulator models RA8
705 # registers and cites the RA8 HUM, and port/usbx holds first-party RA8
706 # USB glue -- both were silently omitted, so their cites went
707 # unvalidated. Vendored C under port/threadx and both canonical
708 # third-party roots is already dropped by first_party_paths.
709 targets = [REPO_ROOT / rel for rel in first_party_paths(SOURCE_SUFFIXES)]
710
711 findings: list[str] = []
712 file_count = 0
713 for f in iter_source_files(targets):
714 file_count += 1
715 findings.extend(check_file(f, chapters, require_cites=args.require_cites))
716
717 if findings:
718 _report_findings(findings, file_count, strict=strict, require_cites=args.require_cites)
719 return 1 if strict else 0
720
721 print(
722 f"cite_check.py: 0 findings across {file_count} file(s)",
723 file=sys.stderr,
724 )
725 return 0
726
727
728if __name__ == "__main__":
729 raise SystemExit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157