3"""Structural ancestry model for the new-compound-decision gate.
5The public checker owns repository scope, git/index reads, citations, and
6reporting. This companion owns only the source-level comparison that decides
7whether a compound decision is pre-existing or newly introduced. Keeping the
8mechanism isolated makes the distinction reviewable and keeps both scripts
9within the repository's per-file size limit.
12from __future__
import annotations
15from collections
import Counter
16from collections.abc
import Callable
17from difflib
import SequenceMatcher
22DECISION_ANCESTRY_SIMILARITY = 0.55
25NO_ENCLOSING_FUNCTION =
"(file-scope)"
28COMPOUND_OP_RE = re.compile(
r"(?:\|\||&&)")
32DECISION_TOKEN_RE = re.compile(
33 r"[A-Za-z_]\w*|0[xX][0-9A-Fa-f]+|\d+(?:\.\d+)?|"
34 r"&&|\|\||==|!=|<=|>=|<<|>>|->|\+\+|--|[{}()\[\],;?:.~!%^&*+/|<>=-]"
36DECISION_KEYWORDS: frozenset[str] = frozenset(
70LEXICAL_NOISE_RE = re.compile(
74 r"|//(?:\\\r?\n|[^\n])*",
79def enclosing_function(src_text: str, decision_line: int) -> str |
None:
80 """Name of the function enclosing a 1-based source line, or None.
82 Relies on the repository's clang-format style: a function-definition body
83 opens with ``{`` alone, while control blocks keep the brace on their
84 header. Comment-only NOLINTNEXTLINE rows inside multiline signatures are
85 skipped so their parenthesized rule name cannot be mistaken for a symbol.
87 lines = src_text.splitlines()
88 idx = decision_line - 1
89 if idx < 0
or idx >= len(lines):
92 while brace >= 0
and lines[brace] !=
"{":
96 sig_parts: list[str] = []
98 while j >= 0
and lines[j].strip()
not in (
"",
"}",
"};",
"*/",
"/*"):
99 stripped = lines[j].strip()
100 if stripped.startswith(
"//")
or (stripped.startswith(
"/*")
and stripped.endswith(
"*/")):
103 sig_parts.insert(0, lines[j])
107 signature =
" ".join(part.strip()
for part
in sig_parts)
108 match = re.search(
r"([A-Za-z_]\w*)\s*\(", signature)
109 return match.group(1)
if match
else None
112def _component(path: str) -> str:
113 """Ownership component containing ``path`` (prefix before ``src/``)."""
116 return path.partition(marker)[0]
117 return path.rpartition(
"/")[0]
120def _blank_lexical_noise(match: re.Match[str]) -> str:
121 """Erase one literal/comment while retaining its newline count."""
122 token = match.group(0)
123 if token.startswith(
'"'):
125 elif token.startswith(
"'"):
129 return marker + (
"\n" * token.count(
"\n"))
132def lexical_code_view(text: str) -> str:
133 """Source with comments, literals, and preprocessor directives blanked.
135 This is the ONE definition of "code, not prose" for the MC/DC
136 compound-decision subsystem. The delta modes and the whole-tree ratchet
137 measurement both read decisions through it, so what the gate flags and
138 what the baseline counts can never disagree -- a second, line-local
139 definition living in the detector is what let 18 buckets of comment prose
140 be frozen into the ratchet baseline as real debt (issue #790).
142 Line numbering is preserved exactly: every blanked construct keeps its
143 newline count, so a caller may index the returned text by source line.
145 Blanked here, and therefore never a decision:
147 * a string or character literal, including one holding ``/*`` or ``//``
148 and one spliced across lines with a trailing backslash;
149 * a line comment, including a backslash-spliced continuation line;
150 * a block or Doxygen comment, including every interior line and any
151 ``@code`` example span inside it;
152 * a preprocessor directive and every continuation line of it.
154 Preprocessor logic is conditional COMPILATION, not a runtime decision, so
155 MC/DC -- a runtime coverage criterion -- does not apply to it. The
156 canonical case is the fail-closed stub-crypto guard
157 ``#if defined(RA8_INSECURE_STUB_CRYPTO) || defined(RA8_OFF_TARGET)``,
158 whose ``||`` selects a translation unit and is never evaluated at run
161 scrubbed = LEXICAL_NOISE_RE.sub(_blank_lexical_noise, text)
162 lines: list[str] = []
164 for line
in scrubbed.splitlines(keepends=
True):
165 stripped = line.lstrip()
166 if stripped.startswith(
"#"):
168 blanked =
"\n" if line.endswith(
"\n")
else ""
169 lines.append(blanked
if in_directive
else line)
170 if in_directive
and not line.rstrip().endswith(
"\\"):
172 return "".join(lines)
175def _compound_segments(text: str) -> list[tuple[int, str]]:
176 """Logical statement/control-header segments containing && or ||."""
177 segments: list[tuple[int, str]] = []
178 source = lexical_code_view(text)
185 if not buf
and not char.isspace():
191 paren_depth = max(0, paren_depth - 1)
195 bracket_depth = max(0, bracket_depth - 1)
196 boundary = char
in ";{}" and paren_depth == 0
and bracket_depth == 0
198 segment =
"".join(buf)
199 if COMPOUND_OP_RE.search(segment):
200 segments.append((start_line, segment))
206 if COMPOUND_OP_RE.search(tail):
207 segments.append((start_line, tail))
211def _drop_atomic_parentheses(tokens: list[str]) -> list[str]:
212 """Remove parentheses that cannot affect && / || grouping."""
217 stack: list[int] = []
218 for index, value
in enumerate(result):
221 elif value ==
")" and stack:
222 opening = stack.pop()
223 interior = result[opening + 1 : index]
224 if "&&" not in interior
and "||" not in interior:
225 result = result[:opening] + interior + result[index + 1 :]
231def _decision_fingerprint(segment: str) -> str:
232 """Alpha-normalized structural token fingerprint for one decision."""
233 identifiers: dict[str, str] = {}
234 normalized: list[str] = []
235 for lexeme
in DECISION_TOKEN_RE.findall(segment):
236 value =
"nullptr" if lexeme ==
"NULL" else lexeme
237 if re.fullmatch(
r"[A-Za-z_]\w*", value)
and value
not in DECISION_KEYWORDS:
238 value = identifiers.setdefault(value, f
"id{len(identifiers)}")
239 normalized.append(value)
240 return " ".join(_drop_atomic_parentheses(normalized))
243def _logical_decisions(text: str) -> list[tuple[int, str, str, str]]:
244 """Return ``(line, symbol, fingerprint, snippet)`` logical decisions."""
245 decisions: list[tuple[int, str, str, str]] = []
246 for start_line, segment
in _compound_segments(text):
247 operator = COMPOUND_OP_RE.search(segment)
250 line_no = start_line + segment[: operator.start()].count(
"\n")
251 symbol = enclosing_function(text, line_no)
or NO_ENCLOSING_FUNCTION
252 snippet = re.sub(
r"\s+",
" ", segment.strip())
253 decisions.append((line_no, symbol, _decision_fingerprint(segment), snippet))
257def _logical_operators(fingerprint: str) -> tuple[str, ...]:
258 """Ordered logical operators retained in one structural fingerprint."""
259 return tuple(value
for value
in fingerprint.split()
if value
in (
"&&",
"||"))
262def _decision_kind(fingerprint: str) -> str:
263 """Control/statement kind anchoring edited-decision ancestry."""
264 first = fingerprint.partition(
" ")[0]
265 return first
if first
in (
"if",
"while",
"for",
"return")
else "expression"
268def _similar_ancestor(
269 old_exact: Counter[tuple[str, str, str]],
273) -> tuple[str, str, str] |
None:
274 """Best same-symbol, same-topology edited ancestor above the policy floor."""
275 operators = _logical_operators(fingerprint)
276 best_key: tuple[str, str, str] |
None =
None
278 for key, count
in old_exact.items():
279 old_component, old_symbol, old_fingerprint = key
280 if count <= 0
or (old_component, old_symbol) != (component, symbol):
282 if _logical_operators(old_fingerprint) != operators:
284 if _decision_kind(old_fingerprint) != _decision_kind(fingerprint):
286 ratio = SequenceMatcher(
287 None, old_fingerprint.split(), fingerprint.split(), autojunk=
False
289 if ratio > best_ratio:
292 return best_key
if best_ratio >= DECISION_ANCESTRY_SIMILARITY
else None
295def _collect_decision_ancestry(
296 pairs: list[tuple[str |
None, str |
None]],
297 new_text_of: Callable[[str], str],
298 base_text_of: Callable[[str], str],
300 Counter[tuple[str, str, str]],
301 Counter[tuple[str, str]],
302 Counter[tuple[str, str]],
303 list[tuple[str, str, int, str, str, str]],
305 """Collect old/new structural inventories with cross-root move ancestry."""
306 old_exact: Counter[tuple[str, str, str]] = Counter()
307 old_totals: Counter[tuple[str, str]] = Counter()
308 new_totals: Counter[tuple[str, str]] = Counter()
309 new_items: list[tuple[str, str, int, str, str, str]] = []
310 for old_path, new_path
in pairs:
311 old_decisions = _logical_decisions(base_text_of(old_path))
if old_path
else []
312 new_decisions = _logical_decisions(new_text_of(new_path))
if new_path
else []
313 new_component = _component(new_path
or old_path
or "")
314 old_component = _component(old_path
or new_path
or "")
315 if old_component != new_component:
316 old_shapes = {(symbol, fingerprint)
for _, symbol, fingerprint, _
in old_decisions}
317 new_shapes = {(symbol, fingerprint)
for _, symbol, fingerprint, _
in new_decisions}
318 if old_shapes & new_shapes:
319 old_component = new_component
320 for _line, symbol, fingerprint, _snippet
in old_decisions:
321 old_exact[(old_component, symbol, fingerprint)] += 1
322 old_totals[(old_component, symbol)] += len(_logical_operators(fingerprint))
324 for line, symbol, fingerprint, snippet
in new_decisions:
325 new_items.append((new_component, new_path, line, symbol, fingerprint, snippet))
326 new_totals[(new_component, symbol)] += len(_logical_operators(fingerprint))
327 return old_exact, old_totals, new_totals, new_items
330def new_decision_occurrences(
331 pairs: list[tuple[str |
None, str |
None]],
332 new_text_of: Callable[[str], str],
333 base_text_of: Callable[[str], str],
334) -> list[tuple[str, int, str, str]]:
335 """New structural decisions after exact-symbol/component-move matching."""
336 old_exact, old_totals, new_totals, new_items = _collect_decision_ancestry(
337 pairs, new_text_of, base_text_of
340 unmatched: list[tuple[str, str, int, str, str, str]] = []
341 for component, path, line, symbol, fingerprint, snippet
in new_items:
342 key = (component, symbol, fingerprint)
343 if old_exact[key] > 0:
346 unmatched.append((component, path, line, symbol, fingerprint, snippet))
347 result: list[tuple[str, int, str, str]] = []
348 for component, path, line, symbol, fingerprint, snippet
in unmatched:
349 owner = (component, symbol)
351 if new_totals[owner] <= old_totals[owner]:
352 ancestor = _similar_ancestor(old_exact, component, symbol, fingerprint)
353 if ancestor
is not None:
354 old_exact[ancestor] -= 1
356 result.append((path, line, snippet, symbol))