3"""Shared lexing and exemption rules for the in-tree ``file:line`` citation ban.
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.
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.
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.
25from __future__
import annotations
30from pathlib
import Path
32sys.path.insert(0, str(Path(__file__).resolve().parent))
34from lint_targets
import language_of
37SOURCE_EXTS = (
".c",
".h",
".cpp",
".hpp",
".cc")
41CITATION_RE = re.compile(
r"\b([A-Za-z_][A-Za-z0-9_/.-]*\.(?:c|h|cpp|hpp|cc)):(\d+)\b")
44CITES_OK_RE = re.compile(
r"CITES-OK:\s*(\S.*?)\s*$")
47MOVED_FROM_RE = re.compile(
r"moved from\s+\S+:\d+\s+to\b", re.IGNORECASE)
50DOCS_REFERENCE_RE = re.compile(
r"\bdocs/reference/")
53THIRD_PARTY_RE = re.compile(
r"\b(?:libs|apps/shared_libs)/third_party/")
60MCDC_REASON_MACRO =
"RA8_MCDC_DEACTIVATED"
63def all_tracked_files() -> list[str]:
64 """Every tracked path, for the whole-tree sweep.
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.
76 return [line
for line
in out.splitlines()
if line]
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.
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.
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
94 if not path.endswith(SOURCE_EXTS):
96 if any(path.startswith(p)
for p
in exclude_prefixes):
98 return language_of(path) ==
"c"
101def find_comment_spans(text: str) -> list[tuple[int, int]]:
102 """Byte spans of every C/C++ comment, block and line.
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.
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.
111 spans: list[tuple[int, int]] = []
119 while i < n
and text[i] !=
'"':
120 if text[i] ==
"\\" and i + 1 < n:
130 while i < n
and text[i] !=
"'":
131 if text[i] ==
"\\" and i + 1 < n:
139 if ch ==
"/" and i + 1 < n:
140 if text[i + 1] ==
"/":
142 while i < n
and text[i] !=
"\n":
144 spans.append((start, i))
146 if text[i + 1] ==
"*":
149 while i + 1 < n
and not (text[i] ==
"*" and text[i + 1] ==
"/"):
152 spans.append((start, i))
158def _reason_end(text: str, start: int) -> int:
159 """Offset of the ``)`` closing a reason that opened just after ``start``.
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.
167 text: The full source text.
168 start: Offset one past the opening parenthesis.
171 The offset of the matching close parenthesis, or ``len(text)`` when the
172 call is unterminated.
178 while p < n
and depth > 0:
198def find_mcdc_reason_spans(text: str) -> list[tuple[int, int]]:
199 """Byte spans of the reason argument of each ``RA8_MCDC_DEACTIVATED(...)``.
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.
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.
215 text: The full source text to scan.
218 A list of ``(start, end)`` byte offsets, one per macro call.
220 spans: list[tuple[int, int]] = []
222 mlen = len(MCDC_REASON_MACRO)
225 idx = text.find(MCDC_REASON_MACRO, i)
231 if idx > 0
and (text[idx - 1].isalnum()
or text[idx - 1] ==
"_"):
235 while k < n
and text[k]
in " \t\r\n":
237 if k >= n
or text[k] !=
"(":
240 end = _reason_end(text, start)
241 spans.append((start, end))
246def line_of_offset(text: str, offset: int) -> int:
247 """1-based line number containing a byte offset.
249 Counts newlines before the offset, so it stays correct on the
250 comment-blanked view, whose newlines are preserved for exactly this
253 return text.count(
"\n", 0, offset) + 1
256def is_exempt(matched: str, line: str, column: int) -> bool:
257 """Whether a citation on ``line`` is excused from the ban.
259 THE single definition of the exemption cascade, shared by the gate and the
260 migration aid so the two cannot describe different trees.
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.
269 if "SPDX-License-Identifier" in line:
271 if line.lstrip().startswith(
"#include"):
273 if DOCS_REFERENCE_RE.search(matched):
275 if THIRD_PARTY_RE.search(matched):
277 cok = CITES_OK_RE.search(line)
278 if cok
and cok.group(1).strip():
280 citation_end = column + len(matched)
282 moved.start() <= column
and citation_end <= moved.end()
283 for moved
in MOVED_FROM_RE.finditer(line)
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.