ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
line_citation_lex.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Shared lexing and exemption rules for the in-tree ``file:line`` citation ban.
4
5Two tools implement that ban: :mod:`check_line_citations` is the gate that
6FAILS on a stale-prone citation, and :mod:`extract_line_citations` is the
7migration aid that lists the same set with the symbol each one should cite
8instead. They must agree on what counts as a citation, or the aid lists work
9the gate does not want and misses work it does.
10
11They did not agree. Both carried their own byte-identical copy of
12``find_comment_spans``, ``line_of_offset``, ``all_tracked_files``,
13``is_in_scope`` and the five exemption regexes, plus a hand-copied version of
14the same exemption cascade -- the extractor's carrying the comment "Apply the
15same exemptions as the gate", which is a duplication admitting to being one.
16Two copies of a rule is two places for it to drift, and drift here is silent:
17both tools keep running and simply describe different trees.
18
19The one thing deliberately NOT shared is ``EXCLUDE_PREFIXES``. The gate scans
20documentation as well as sources, while the extractor scans sources only.
21That is a real difference in what each tool is for, not an accident, so each
22keeps its own source-population policy.
23"""
24
25from __future__ import annotations
26
27import re
28import subprocess
29import sys
30from pathlib import Path
31
32sys.path.insert(0, str(Path(__file__).resolve().parent))
33
34from lint_targets import language_of
35
36#: Source extensions carrying C/C++ comments.
37SOURCE_EXTS = (".c", ".h", ".cpp", ".hpp", ".cc")
38
39#: A ``path.ext:NNN`` reference. Groups 1 and 2 are the path and the line
40#: number; ``group(0)`` is the whole citation, which is what the gate reports.
41CITATION_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_/.-]*\.(?:c|h|cpp|hpp|cc)):(\d+)\b")
42
43#: An explicit waiver. The reason is mandatory -- see :func:`is_exempt`.
44CITES_OK_RE = re.compile(r"CITES-OK:\s*(\S.*?)\s*$")
45
46#: "moved from x.c:12 to ..." is history, not a live reference.
47MOVED_FROM_RE = re.compile(r"moved from\s+\S+:\d+\s+to\b", re.IGNORECASE)
48
49#: Vendor manuals under docs/reference/ have stable line-addressable content.
50DOCS_REFERENCE_RE = re.compile(r"\bdocs/reference/")
51
52#: Vendored code is not ours to re-cite.
53THIRD_PARTY_RE = re.compile(r"\b(?:libs|apps/shared_libs)/third_party/")
54
55#: The annotation macro whose reason string is DO-178C 6.4.4.3 deactivation
56#: evidence. Its argument is a string LITERAL, so find_comment_spans -- which
57#: deliberately skips string literals -- never reaches it; find_mcdc_reason_spans
58#: does, so the in-tree line-citation ban covers a deactivation reason as
59#: docs/ANNOTATIONS.md says it does (#547).
60MCDC_REASON_MACRO = "RA8_MCDC_DEACTIVATED"
61
62
63def all_tracked_files() -> list[str]:
64 """Every tracked path, for the whole-tree sweep.
65
66 The whole-tree mode is what CI runs; the staged mode is the pre-commit
67 hook's. They differ only in this enumeration, so a rule can never apply
68 in one and not the other.
69 """
70 out = subprocess.run(
71 ["git", "ls-files"], # noqa: S607 # trusted: fixed git argv
72 check=True,
73 capture_output=True,
74 text=True,
75 ).stdout
76 return [line for line in out.splitlines() if line]
77
78
79def is_in_scope(path: str, exclude_prefixes: tuple[str, ...]) -> bool:
80 """Whether a source path is subject to the in-tree line-citation ban.
81
82 First-party-ness is DERIVED, not a hardcoded root list. The old
83 ``SCAN_ROOTS`` tuple (libs/, src/, tests/, examples/, port/) silently
84 omitted tools/ -- the #358 defect that let a ``file.c:123``
85 citation land there unseen. ``lint_targets.language_of`` decides first-party
86 C by suffix, language and the shared SOUP/generated/build exclusions, so a
87 new top-level directory is covered the day it lands and vendored trees stay
88 out without naming each of their files.
89
90 ``exclude_prefixes`` is a parameter rather than a module constant because
91 the gate and the extractor genuinely scan different sets -- see the module
92 docstring.
93 """
94 if not path.endswith(SOURCE_EXTS):
95 return False
96 if any(path.startswith(p) for p in exclude_prefixes):
97 return False
98 return language_of(path) == "c"
99
100
101def find_comment_spans(text: str) -> list[tuple[int, int]]: # noqa: PLR0912 # one char scanner; splitting the states hurts clarity
102 """Byte spans of every C/C++ comment, block and line.
103
104 String-literal aware, enough to skip ``"//foo"`` and ``"/* */"`` -- without
105 that a URL or a format string in code would be treated as a comment and
106 any citation-shaped text inside it reported.
107
108 Returns ``(start, end)`` offsets so the caller can test whether a match
109 fell inside a comment, which is the only place a citation counts.
110 """
111 spans: list[tuple[int, int]] = []
112 i = 0
113 n = len(text)
114 while i < n:
115 ch = text[i]
116 # String literal
117 if ch == '"':
118 i += 1
119 while i < n and text[i] != '"':
120 if text[i] == "\\" and i + 1 < n:
121 i += 2
122 continue
123 if text[i] == "\n":
124 break
125 i += 1
126 i += 1
127 continue
128 if ch == "'":
129 i += 1
130 while i < n and text[i] != "'":
131 if text[i] == "\\" and i + 1 < n:
132 i += 2
133 continue
134 if text[i] == "\n":
135 break
136 i += 1
137 i += 1
138 continue
139 if ch == "/" and i + 1 < n:
140 if text[i + 1] == "/":
141 start = i
142 while i < n and text[i] != "\n":
143 i += 1
144 spans.append((start, i))
145 continue
146 if text[i + 1] == "*":
147 start = i
148 i += 2
149 while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
150 i += 1
151 i = min(n, i + 2)
152 spans.append((start, i))
153 continue
154 i += 1
155 return spans
156
157
158def _reason_end(text: str, start: int) -> int:
159 """Offset of the ``)`` closing a reason that opened just after ``start``.
160
161 ``start`` is the index one past the macro's opening ``(``. The walk is
162 string- and paren-aware -- a ``)`` inside the reason string does not close
163 the call, and a nested ``(`` is balanced -- and a single ``quote`` variable
164 handles both ``"`` and ``'`` literals so the two are not duplicated.
165
166 Args:
167 text: The full source text.
168 start: Offset one past the opening parenthesis.
169
170 Returns:
171 The offset of the matching close parenthesis, or ``len(text)`` when the
172 call is unterminated.
173 """
174 n = len(text)
175 depth = 1
176 p = start
177 quote = "" # empty outside a literal, else the active quote character
178 while p < n and depth > 0:
179 ch = text[p]
180 if quote:
181 if ch == "\\":
182 p += 2
183 continue
184 if ch == quote:
185 quote = ""
186 elif ch in "\"'":
187 quote = ch
188 elif ch == "(":
189 depth += 1
190 elif ch == ")":
191 depth -= 1
192 if depth == 0:
193 return p
194 p += 1
195 return p
196
197
198def find_mcdc_reason_spans(text: str) -> list[tuple[int, int]]:
199 """Byte spans of the reason argument of each ``RA8_MCDC_DEACTIVATED(...)``.
200
201 The macro records WHY an MC/DC condition is deactivated -- DO-178C 6.4.4.3
202 evidence -- and its reason is a string literal (often several adjacent
203 literals across lines). :func:`find_comment_spans` deliberately skips string
204 literals, so without this the in-tree line-citation ban would never reach a
205 deactivation reason, though ``docs/ANNOTATIONS.md`` promises it does. This
206 returns the ``(start, end)`` offsets of the text BETWEEN the macro's outer
207 parentheses so the caller can scan it for a rot-prone ``file.ext:line`` token
208 exactly as it scans a comment.
209
210 A whole-token match is required, so a longer identifier merely ending in the
211 macro name is skipped; the macro's own ``#define`` site yields the parameter
212 name ``reason`` as its span, which carries no citation and is inert.
213
214 Args:
215 text: The full source text to scan.
216
217 Returns:
218 A list of ``(start, end)`` byte offsets, one per macro call.
219 """
220 spans: list[tuple[int, int]] = []
221 n = len(text)
222 mlen = len(MCDC_REASON_MACRO)
223 i = 0
224 while True:
225 idx = text.find(MCDC_REASON_MACRO, i)
226 if idx == -1:
227 break
228 i = idx + mlen
229 # Whole-token match: the preceding char must not be part of an
230 # identifier, or this is a longer name that only ends with the macro.
231 if idx > 0 and (text[idx - 1].isalnum() or text[idx - 1] == "_"):
232 continue
233 # The next non-space character must be the opening parenthesis.
234 k = i
235 while k < n and text[k] in " \t\r\n":
236 k += 1
237 if k >= n or text[k] != "(":
238 continue
239 start = k + 1
240 end = _reason_end(text, start)
241 spans.append((start, end))
242 i = end + 1
243 return spans
244
245
246def line_of_offset(text: str, offset: int) -> int:
247 """1-based line number containing a byte offset.
248
249 Counts newlines before the offset, so it stays correct on the
250 comment-blanked view, whose newlines are preserved for exactly this
251 reason.
252 """
253 return text.count("\n", 0, offset) + 1
254
255
256def is_exempt(matched: str, line: str, column: int) -> bool:
257 """Whether a citation on ``line`` is excused from the ban.
258
259 THE single definition of the exemption cascade, shared by the gate and the
260 migration aid so the two cannot describe different trees.
261
262 Exempt when the citation is part of a licence identifier or an include
263 directive (neither is a prose reference), names a vendor manual or vendored
264 code (not ours to re-cite), records where something moved from (history,
265 not a live pointer), or carries an explicit `CITES-OK: <reason>` waiver
266 whose reason is non-empty -- a bare marker waives nothing, the same rule
267 the gitignore-scope gate applies to its own marker.
268 """
269 if "SPDX-License-Identifier" in line:
270 return True
271 if line.lstrip().startswith("#include"):
272 return True
273 if DOCS_REFERENCE_RE.search(matched):
274 return True
275 if THIRD_PARTY_RE.search(matched):
276 return True
277 cok = CITES_OK_RE.search(line)
278 if cok and cok.group(1).strip():
279 return True
280 citation_end = column + len(matched)
281 return any(
282 moved.start() <= column and citation_end <= moved.end()
283 for moved in MOVED_FROM_RE.finditer(line)
284 )
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157