ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_control_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Syntax-aware inventory for test, workflow, and Ansible controls."""
4
5from __future__ import annotations
6
7import ast
8import re
9from dataclasses import dataclass
10from pathlib import Path
11
12import yaml
13from suppression_catalog import ownership
14from suppression_hash_lex import HashLexLine, hash_lines
15from suppression_model import Finding, Suppression
16
17CMAKE_CONTROL_HINT = re.compile(
18 r"\b(?:DISABLED|WILL_FAIL|SKIP_RETURN_CODE|SKIP_REGULAR_EXPRESSION|"
19 r"PASS_REGULAR_EXPRESSION|FAIL_REGULAR_EXPRESSION)\b"
20)
21# Only workflow and Ansible trees carry the YAML result/log controls.
22YAML_CONTROL_ROOTS = (".github/workflows/", "infra/ansible/")
23YAML_CONTROL_HINT = re.compile(
24 r"\b(?:continue-on-error|if-no-files-found|ignore_errors|failed_when|"
25 r"changed_when|no_log)\s*:"
26)
27CTEST_PROPERTIES = frozenset(
28 {
29 "DISABLED",
30 "WILL_FAIL",
31 "SKIP_RETURN_CODE",
32 "SKIP_REGULAR_EXPRESSION",
33 "PASS_REGULAR_EXPRESSION",
34 "FAIL_REGULAR_EXPRESSION",
35 }
36)
37PYTHON_CALLS = {
38 "pytest.mark.skip": ("pytest", "skip", 0),
39 "pytest.mark.skipif": ("pytest", "skipif", None),
40 "pytest.mark.xfail": ("pytest", "xfail", None),
41 "pytest.skip": ("pytest", "skip", 0),
42 "pytest.xfail": ("pytest", "xfail", 0),
43 "unittest.skip": ("unittest", "skip", 0),
44 "unittest.skipIf": ("unittest", "skipIf", 1),
45 "unittest.skipUnless": ("unittest", "skipUnless", 1),
46 "unittest.expectedFailure": ("unittest", "expectedFailure", None),
47 "unittest.SkipTest": ("unittest", "SkipTest", 0),
48 "self.skipTest": ("unittest", "skipTest", 0),
49}
50BARE_PYTHON_CONTROLS = {
51 "pytest.mark.skip": ("pytest", "skip"),
52 "pytest.mark.xfail": ("pytest", "xfail"),
53 "unittest.expectedFailure": ("unittest", "expectedFailure"),
54}
55
56
57@dataclass(frozen=True)
58class CMakeCall:
59 """One active CMake command invocation."""
60
61 name: str
62 body: str
63 line: int
64 column: int
65
66
67def _concerns(reason: str, *extra: str) -> tuple[str, ...]:
68 """Return review concerns without treating inventory as approval."""
69 concerns = list(extra)
70 if not reason.strip():
71 concerns.append("blank-reason")
72 return tuple(dict.fromkeys(concerns))
73
74
75def _python_aliases(tree: ast.AST) -> dict[str, str]:
76 """Resolve explicit pytest/unittest import aliases used by controls."""
77 aliases: dict[str, str] = {}
78 for node in ast.walk(tree):
79 if isinstance(node, ast.Import):
80 for item in node.names:
81 if item.name in {"pytest", "unittest"}:
82 aliases[item.asname or item.name] = item.name
83 elif isinstance(node, ast.ImportFrom) and node.module in {"pytest", "unittest"}:
84 for item in node.names:
85 if item.name != "*":
86 aliases[item.asname or item.name] = f"{node.module}.{item.name}"
87 return aliases
88
89
90def _dotted_name(node: ast.AST, aliases: dict[str, str]) -> str:
91 """Return a dotted name for one Python name/attribute expression."""
92 parts: list[str] = []
93 cursor: ast.AST = node
94 while isinstance(cursor, ast.Attribute):
95 parts.append(cursor.attr)
96 cursor = cursor.value
97 if isinstance(cursor, ast.Name):
98 parts.append(cursor.id)
99 resolved = list(reversed(parts))
100 resolved[0] = aliases.get(resolved[0], resolved[0])
101 return ".".join(resolved)
102 return ""
103
104
105def _string_value(node: ast.AST | None) -> tuple[str, bool]:
106 """Return a literal rationale and whether its value is dynamic."""
107 if node is None:
108 return "", False
109 if isinstance(node, ast.Constant) and isinstance(node.value, str):
110 return node.value.strip(), False
111 return "dynamic reason expression", True
112
113
114def _python_reason(call: ast.Call, position: int | None) -> tuple[str, bool]:
115 """Read a Python skip reason from its tool-specific argument slot."""
116 for keyword in call.keywords:
117 if keyword.arg == "reason":
118 return _string_value(keyword.value)
119 if position is not None and len(call.args) > position:
120 return _string_value(call.args[position])
121 return "", False
122
123
124def _strict_xfail(call: ast.Call) -> bool:
125 """Return whether an xfail call makes an unexpected pass fail."""
126 for keyword in call.keywords:
127 if keyword.arg == "strict":
128 return isinstance(keyword.value, ast.Constant) and keyword.value.value is True
129 return False
130
131
132def _python_record(path: str, node: ast.AST, dotted: str, call: ast.Call | None) -> Suppression:
133 """Build one Python test-control inventory row."""
134 if call is None:
135 tool, rule = BARE_PYTHON_CONTROLS[dotted]
136 reason, dynamic = "", False
137 else:
138 tool, rule, position = PYTHON_CALLS[dotted]
139 reason, dynamic = _python_reason(call, position)
140 extras: list[str] = []
141 if dynamic:
142 extras.append("dynamic-reason")
143 if dotted == "pytest.mark.xfail" and (call is None or not _strict_xfail(call)):
144 extras.append("non-strict-xfail")
145 return Suppression(
146 path,
147 getattr(node, "lineno", 1),
148 getattr(node, "col_offset", 0) + 1,
149 "test-control",
150 tool,
151 rule,
152 dotted,
153 "test",
154 reason,
155 "python-ast",
156 ownership(path),
157 _concerns(reason, *extras),
158 )
159
160
161def _bare_python_nodes(tree: ast.AST) -> list[ast.AST]:
162 """Return bare marker/decorator nodes that are not calls."""
163 nodes: list[ast.AST] = []
164 for node in ast.walk(tree):
165 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
166 nodes.extend(item for item in node.decorator_list if not isinstance(item, ast.Call))
167 elif isinstance(node, (ast.Assign, ast.AnnAssign)):
168 value = node.value
169 if value is not None and not isinstance(value, ast.Call):
170 nodes.append(value)
171 return nodes
172
173
174def _python_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
175 """Parse Python test-control calls and decorators through the AST."""
176 if Path(path).suffix not in {".py", ".pyi"} or ownership(path) != "first-party":
177 return [], []
178 try:
179 tree = ast.parse(text, filename=path)
180 except SyntaxError as exc:
181 message = exc.msg or "Python parser rejected test-control source"
182 return [], [Finding("malformed-test-control", message, path, exc.lineno or 0)]
183 aliases = _python_aliases(tree)
184 records: list[Suppression] = []
185 for node in ast.walk(tree):
186 if not isinstance(node, ast.Call):
187 continue
188 dotted = _dotted_name(node.func, aliases)
189 if dotted in PYTHON_CALLS:
190 records.append(_python_record(path, node, dotted, node))
191 for node in _bare_python_nodes(tree):
192 dotted = _dotted_name(node, aliases)
193 if dotted in BARE_PYTHON_CONTROLS:
194 records.append(_python_record(path, node, dotted, None))
195 return records, []
196
197
198def _cmake_source(path: str, text: str) -> tuple[str, list[HashLexLine]]:
199 """Mask CMake comments/bracket payloads while retaining line positions."""
200 lines, _ = hash_lines(path, text)
201 return "\n".join(line.code for line in lines), lines
202
203
204def _cmake_call_end(source: str, opening: int) -> int | None:
205 """Find the matching close parenthesis outside quoted arguments."""
206 depth = 1
207 quote = False
208 escaped = False
209 for index in range(opening + 1, len(source)):
210 char = source[index]
211 if escaped:
212 escaped = False
213 elif quote and char == "\\":
214 escaped = True
215 elif char == '"':
216 quote = not quote
217 elif not quote and char == "(":
218 depth += 1
219 elif not quote and char == ")":
220 depth -= 1
221 if depth == 0:
222 return index
223 return None
224
225
226def _quoted_cmake_end(source: str, opening: int) -> int | None:
227 """Return the end of a top-level quoted token, honoring escapes."""
228 escaped = False
229 for index in range(opening + 1, len(source)):
230 char = source[index]
231 if escaped:
232 escaped = False
233 elif char == "\\":
234 escaped = True
235 elif char == '"':
236 return index
237 return None
238
239
240def _cmake_calls(source: str) -> tuple[list[CMakeCall], bool]:
241 """Parse top-level CMake calls without searching argument strings."""
242 calls: list[CMakeCall] = []
243 position = 0
244 while position < len(source):
245 if source[position] == '"':
246 end = _quoted_cmake_end(source, position)
247 if end is None:
248 return calls, True
249 position = end + 1
250 continue
251 if not (source[position].isalpha() or source[position] == "_"):
252 position += 1
253 continue
254 start = position
255 while position < len(source) and (source[position].isalnum() or source[position] == "_"):
256 position += 1
257 name = source[start:position]
258 while position < len(source) and source[position].isspace():
259 position += 1
260 if position >= len(source) or source[position] != "(":
261 continue
262 opening = position
263 end = _cmake_call_end(source, opening)
264 if end is None:
265 return calls, True
266 line = source.count("\n", 0, start) + 1
267 prior = source.rfind("\n", 0, start)
268 calls.append(CMakeCall(name.lower(), source[opening + 1 : end], line, start - prior))
269 position = end + 1
270 return calls, False
271
272
273def _cmake_tokens(body: str) -> list[str]:
274 """Tokenize CMake command arguments closely enough for property pairs."""
275 pattern = re.compile(r'"(?:\\.|[^"\\])*"|[^\s()]+')
276 tokens: list[str] = []
277 for match in pattern.finditer(body):
278 token = match.group(0)
279 if token.startswith('"') and token.endswith('"'):
280 token = token[1:-1]
281 tokens.append(token)
282 return tokens
283
284
285def _nearby_comments(lines: list[HashLexLine], line_no: int) -> str:
286 """Collect one contiguous rationale immediately before a CMake call."""
287 notes: list[str] = []
288 index = line_no - 2
289 while index >= 0:
290 line = lines[index]
291 if line.code.strip():
292 break
293 note = line.comment.strip()
294 if not note:
295 break
296 notes.append(note)
297 index -= 1
298 return " ".join(reversed(notes))
299
300
301def _property_pairs(call: CMakeCall) -> tuple[list[str], list[tuple[str, str]]]:
302 """Return test targets and property/value pairs from one setter call."""
303 tokens = _cmake_tokens(call.body)
304 upper = [token.upper() for token in tokens]
305 if call.name == "set_tests_properties":
306 if "PROPERTIES" not in upper:
307 return [], []
308 split = upper.index("PROPERTIES")
309 targets = tokens[:split]
310 elif call.name == "set_property" and upper and upper[0] == "TEST":
311 if "PROPERTY" not in upper:
312 return [], []
313 split = upper.index("PROPERTY")
314 targets = [
315 item for item in tokens[1:split] if item.upper() not in {"APPEND", "APPEND_STRING"}
316 ]
317 else:
318 return [], []
319 values = tokens[split + 1 :]
320 return targets, list(zip(values[::2], values[1::2], strict=False))
321
322
323def _cmake_property_active(name: str, value: str) -> bool:
324 """Return whether a recognized CTest property actually softens selection."""
325 if name not in {"DISABLED", "WILL_FAIL"}:
326 return True
327 return value.strip().upper() not in {"0", "FALSE", "OFF", "NO", "N"}
328
329
330def _ctest_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
331 """Inventory active CTest result/skip properties, not getter mentions."""
332 item = Path(path)
333 if item.name != "CMakeLists.txt" and item.suffix != ".cmake":
334 return [], []
335 if not CMAKE_CONTROL_HINT.search(text):
336 return [], []
337 source, lines = _cmake_source(path, text)
338 calls, malformed = _cmake_calls(source)
339 findings = (
340 [Finding("malformed-ctest-control", "unterminated CMake call", path)] if malformed else []
341 )
342 records: list[Suppression] = []
343 for call in calls:
344 targets, pairs = _property_pairs(call)
345 for raw_name, value in pairs:
346 name = raw_name.upper()
347 if name not in CTEST_PROPERTIES or not _cmake_property_active(name, value):
348 continue
349 reason = _nearby_comments(lines, call.line)
350 records.append(
351 Suppression(
352 path,
353 call.line,
354 call.column,
355 "ctest",
356 "ctest",
357 name,
358 f"{name} {value}",
359 ",".join(targets) or "test",
360 reason,
361 "cmake-ast-lite",
362 ownership(path),
363 _concerns(reason),
364 )
365 )
366 return records, findings
367
368
369def _yaml_scalar(node: yaml.Node | None) -> str:
370 """Return a scalar's source spelling, or an empty marker for structures."""
371 return node.value if isinstance(node, yaml.ScalarNode) else ""
372
373
374def _yaml_true(value: str) -> bool:
375 """Return whether one YAML spelling is an unconditional true value."""
376 return value.strip().lower() in {"true", "yes", "on", "1"}
377
378
379def _yaml_false(value: str) -> bool:
380 """Return whether one YAML spelling is an unconditional false value."""
381 return value.strip().lower() in {"false", "no", "off", "0"}
382
383
384def _mapping_name(node: yaml.MappingNode, inherited: str) -> str:
385 """Return a task/step name from the closest containing mapping."""
386 for key, value in node.value:
387 if _yaml_scalar(key) == "name" and isinstance(value, yaml.ScalarNode):
388 return value.value.strip()
389 return inherited
390
391
392def _yaml_record(
393 path: str, key: yaml.ScalarNode, value: yaml.Node, name: str
394) -> Suppression | None:
395 """Normalize one active workflow or Ansible suppression mapping."""
396 control = key.value
397 raw = _yaml_scalar(value).strip()
398 family = "workflow" if path.startswith(".github/workflows/") else "ansible"
399 active = False
400 extras: tuple[str, ...] = ()
401 if family == "workflow" and control == "continue-on-error":
402 active = not _yaml_false(raw)
403 elif family == "workflow" and control == "if-no-files-found":
404 active = raw.lower() in {"ignore", "warn"}
405 elif family == "ansible" and control in {"ignore_errors", "no_log"}:
406 active = _yaml_true(raw) or (bool(raw) and not _yaml_false(raw))
407 extras = ("broad-result-mask",) if control == "ignore_errors" else ("broad-output-mask",)
408 elif family == "ansible" and control in {"failed_when", "changed_when"}:
409 active = _yaml_false(raw)
410 if control == "failed_when":
411 extras = ("broad-result-mask",)
412 if not active:
413 return None
414 return Suppression(
415 path,
416 key.start_mark.line + 1,
417 key.start_mark.column + 1,
418 family,
419 "github-actions" if family == "workflow" else "ansible",
420 control if control != "if-no-files-found" else raw.lower(),
421 f"{control}: {raw}",
422 "step" if family == "workflow" else "task",
423 name,
424 "yaml-compose",
425 ownership(path),
426 _concerns(name, *extras),
427 )
428
429
430def _walk_yaml(node: yaml.Node, path: str, inherited: str, seen: set[int]) -> list[Suppression]:
431 """Walk composed YAML nodes while retaining source locations and names."""
432 if id(node) in seen:
433 return []
434 seen.add(id(node))
435 records: list[Suppression] = []
436 if isinstance(node, yaml.MappingNode):
437 name = _mapping_name(node, inherited)
438 for key, value in node.value:
439 if isinstance(key, yaml.ScalarNode):
440 record = _yaml_record(path, key, value, name)
441 if record is not None:
442 records.append(record)
443 records.extend(_walk_yaml(value, path, name, seen))
444 elif isinstance(node, yaml.SequenceNode):
445 for value in node.value:
446 records.extend(_walk_yaml(value, path, inherited, seen))
447 return records
448
449
450def _yaml_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
451 """Compose workflow/Ansible YAML and inventory only active mapping keys."""
452 relevant = path.startswith(YAML_CONTROL_ROOTS)
453 if not relevant or Path(path).suffix not in {".yml", ".yaml"}:
454 return [], []
455 if not YAML_CONTROL_HINT.search(text):
456 return [], []
457 try:
458 documents = list(yaml.compose_all(text))
459 except yaml.YAMLError as exc:
460 return [], [Finding("malformed-yaml-control", str(exc), path)]
461 records: list[Suppression] = []
462 for document in documents:
463 if document is not None:
464 records.extend(_walk_yaml(document, path, "", set()))
465 return records, []
466
467
468def scan_control_file(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
469 """Run every non-overlapping test/infrastructure control recognizer."""
470 records: list[Suppression] = []
471 findings: list[Finding] = []
472 for scanner in (_python_controls, _ctest_controls, _yaml_controls):
473 scanned, errors = scanner(path, text)
474 records.extend(scanned)
475 findings.extend(errors)
476 return records, findings