ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcdc_compound_delta.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Structural ancestry model for the new-compound-decision gate.
4
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.
10"""
11
12from __future__ import annotations
13
14import re
15from collections import Counter
16from collections.abc import Callable
17from difflib import SequenceMatcher
18
19# Same-symbol fingerprints with the same logical-operator topology above this
20# ratio are edited ancestry (casts, redundant parentheses, and equivalent
21# bound spelling), not a new compound decision.
22DECISION_ANCESTRY_SIMILARITY = 0.55
23
24# Bucket for a decision whose enclosing function cannot be resolved.
25NO_ENCLOSING_FUNCTION = "(file-scope)"
26
27# Logical operators are matched outside lexical noise by lexical_code_view().
28COMPOUND_OP_RE = re.compile(r"(?:\|\||&&)")
29
30# Tokens retained in the structural fingerprint. Identifiers are alpha-
31# normalized; language keywords and operators keep their spelling.
32DECISION_TOKEN_RE = re.compile(
33 r"[A-Za-z_]\w*|0[xX][0-9A-Fa-f]+|\d+(?:\.\d+)?|"
34 r"&&|\|\||==|!=|<=|>=|<<|>>|->|\+\+|--|[{}()\‍[\‍],;?:.~!%^&*+/|<>=-]"
35)
36DECISION_KEYWORDS: frozenset[str] = frozenset(
37 {
38 "if",
39 "else",
40 "while",
41 "for",
42 "return",
43 "true",
44 "false",
45 "nullptr",
46 "sizeof",
47 "alignof",
48 }
49)
50
51# Full-source lexical noise removal, applied before any logical operator is
52# looked for. The alternation is ordered so a comment delimiter inside a
53# literal stays data and a quote inside a comment stays comment: string, then
54# character literal, then block comment, then line comment.
55#
56# `re.DOTALL` is what makes the block-comment body span lines. A LINE-LOCAL
57# version of this rule cannot see that a `&&` or `||` sits on an interior line
58# of a multi-line Doxygen block, so it counts prose as a decision -- which is
59# exactly how 18 whole buckets of phantom debt entered the compound-decision
60# ratchet baseline (issue #790).
61#
62# `\\.` under DOTALL also consumes a backslash-newline line splice, so a
63# spliced string literal is consumed whole; the line-comment alternative
64# spells the splice out for the same reason, since `[^\n]` alone would stop at
65# the newline and leak the continuation line back into the scan.
66#
67# C23 has no raw string literal and this scan reads `.c` translation units
68# only (see `_path_included`), so there is no raw-string case to handle.
69# Extending the decision scan to C++ would need one.
70LEXICAL_NOISE_RE = re.compile(
71 r'"(?:\\.|[^"\\])*"'
72 r"|'(?:\\.|[^'\\])*'"
73 r"|/\*.*?\*/"
74 r"|//(?:\\\r?\n|[^\n])*",
75 re.DOTALL,
76)
77
78
79def enclosing_function(src_text: str, decision_line: int) -> str | None:
80 """Name of the function enclosing a 1-based source line, or None.
81
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.
86 """
87 lines = src_text.splitlines()
88 idx = decision_line - 1
89 if idx < 0 or idx >= len(lines):
90 return None
91 brace = idx
92 while brace >= 0 and lines[brace] != "{":
93 brace -= 1
94 if brace < 0:
95 return None
96 sig_parts: list[str] = []
97 j = brace - 1
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("*/")):
101 j -= 1
102 continue
103 sig_parts.insert(0, lines[j])
104 if "(" in lines[j]:
105 break
106 j -= 1
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
110
111
112def _component(path: str) -> str:
113 """Ownership component containing ``path`` (prefix before ``src/``)."""
114 marker = "/src/"
115 if marker in path:
116 return path.partition(marker)[0]
117 return path.rpartition("/")[0]
118
119
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('"'):
124 marker = '""'
125 elif token.startswith("'"):
126 marker = "''"
127 else:
128 marker = ""
129 return marker + ("\n" * token.count("\n"))
130
131
132def lexical_code_view(text: str) -> str:
133 """Source with comments, literals, and preprocessor directives blanked.
134
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).
141
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.
144
145 Blanked here, and therefore never a decision:
146
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.
153
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
159 time.
160 """
161 scrubbed = LEXICAL_NOISE_RE.sub(_blank_lexical_noise, text)
162 lines: list[str] = []
163 in_directive = False
164 for line in scrubbed.splitlines(keepends=True):
165 stripped = line.lstrip()
166 if stripped.startswith("#"):
167 in_directive = True
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("\\"):
171 in_directive = False
172 return "".join(lines)
173
174
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)
179 buf: list[str] = []
180 line = 1
181 start_line = 1
182 paren_depth = 0
183 bracket_depth = 0
184 for char in source:
185 if not buf and not char.isspace():
186 start_line = line
187 buf.append(char)
188 if char == "(":
189 paren_depth += 1
190 elif char == ")":
191 paren_depth = max(0, paren_depth - 1)
192 elif char == "[":
193 bracket_depth += 1
194 elif char == "]":
195 bracket_depth = max(0, bracket_depth - 1)
196 boundary = char in ";{}" and paren_depth == 0 and bracket_depth == 0
197 if boundary:
198 segment = "".join(buf)
199 if COMPOUND_OP_RE.search(segment):
200 segments.append((start_line, segment))
201 buf = []
202 start_line = line
203 if char == "\n":
204 line += 1
205 tail = "".join(buf)
206 if COMPOUND_OP_RE.search(tail):
207 segments.append((start_line, tail))
208 return segments
209
210
211def _drop_atomic_parentheses(tokens: list[str]) -> list[str]:
212 """Remove parentheses that cannot affect && / || grouping."""
213 result = tokens[:]
214 changed = True
215 while changed:
216 changed = False
217 stack: list[int] = []
218 for index, value in enumerate(result):
219 if value == "(":
220 stack.append(index)
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 :]
226 changed = True
227 break
228 return result
229
230
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))
241
242
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)
248 if operator is None:
249 continue
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))
254 return decisions
255
256
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 ("&&", "||"))
260
261
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"
266
267
268def _similar_ancestor(
269 old_exact: Counter[tuple[str, str, str]],
270 component: str,
271 symbol: str,
272 fingerprint: 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
277 best_ratio = 0.0
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):
281 continue
282 if _logical_operators(old_fingerprint) != operators:
283 continue
284 if _decision_kind(old_fingerprint) != _decision_kind(fingerprint):
285 continue
286 ratio = SequenceMatcher(
287 None, old_fingerprint.split(), fingerprint.split(), autojunk=False
288 ).ratio()
289 if ratio > best_ratio:
290 best_key = key
291 best_ratio = ratio
292 return best_key if best_ratio >= DECISION_ANCESTRY_SIMILARITY else None
293
294
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],
299) -> tuple[
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]],
304]:
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))
323 if new_path:
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
328
329
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
338 )
339
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:
344 old_exact[key] -= 1
345 else:
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)
350 ancestor = None
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
355 else:
356 result.append((path, line, snippet, symbol))
357 return result