ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_checker_nonfatal.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""AST declarations and active-call cross-check for checker nonfatal modes."""
4
5from __future__ import annotations
6
7import ast
8import re
9from dataclasses import dataclass
10from pathlib import Path
11
12from suppression_build_controls import _shell_tokens
13from suppression_catalog import ownership
14from suppression_hash_lex import hash_lines
15from suppression_model import Finding, Suppression
16
17EXPECTED_DECLARATIONS = 10
18EXPECTED_INFORMATIONAL_RULES = 3
19DECLARED_OPTIONS = {
20 "scripts/checks/stack_usage_check.py": {
21 "--allow-empty": "fresh-build enumeration exception",
22 "--warn-only": "soft stack-budget reporting mode",
23 },
24 "scripts/checks/audit_init_order.py": {"--no-strict": "advisory init-order mode"},
25 "scripts/checks/check_world_tags.py": {"--warn": "advisory World-tag mode"},
26 "scripts/checks/cite_check.py": {"--warn": "advisory citation mode"},
27}
28DEFAULT_NONFATAL_OPTIONS = {
29 "scripts/checks/check_world_tags.py": ("--warn", "--strict"),
30 "scripts/checks/cite_check.py": ("--warn", "--strict"),
31}
32DORMANT_CONSTANTS = {
33 "scripts/checks/check_line_citations.py": "WARN_ONLY_MODE",
34 "scripts/checks/check_inclusive_terminology.py": "WARN_ONLY_MODE",
35}
36INFORMATIONAL_AUTHORITY = ("scripts/checks/annot_rulekeys.py", "INFORMATIONAL_RULES")
37CALL_SURFACES = (
38 ".github/workflows/",
39 "just/",
40 "scripts/ci/",
41 "scripts/git/",
42)
43ROOT_CALL_SURFACES = frozenset({"justfile"})
44_SCALAR_ASSIGNMENT_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.+)$")
45_ARRAY_ASSIGNMENT_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=$")
46_ARRAY_APPEND_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\+=$")
47_VARIABLE_RE = re.compile(
48 r"^\$(?:{([A-Za-z_][A-Za-z0-9_]*)(?:\‍[(?:@|\*)\‍])?}|"
49 r"([A-Za-z_][A-Za-z0-9_]*))$"
50)
51_COMMAND_SEPARATORS = frozenset({";", "&&", "||", "|"})
52_MIN_ARRAY_ASSIGNMENT_TOKENS = 3
53_DECLARATION_BUILTINS = frozenset({"declare", "export", "local", "readonly", "typeset"})
54_STATIC_SHELLS = frozenset({"bash", "sh", "zsh"})
55_NON_ANALYSIS_MODES = frozenset({"--help", "--selftest", "--version", "-h"})
56
57
58def _literal_strings(node: ast.AST) -> list[str]:
59 """Return literal string leaves from a closed AST container."""
60 if isinstance(node, ast.Constant) and isinstance(node.value, str):
61 return [node.value]
62 if isinstance(node, (ast.Set, ast.Tuple, ast.List)):
63 return [item for child in node.elts for item in _literal_strings(child)]
64 return []
65
66
67def _help(call: ast.Call) -> str:
68 """Return one argparse declaration's literal help text."""
69 for keyword in call.keywords:
70 if keyword.arg == "help":
71 bits = _literal_strings(keyword.value)
72 if bits:
73 return " ".join(bits).strip()
74 if isinstance(keyword.value, ast.BinOp):
75 try:
76 value = ast.literal_eval(keyword.value)
77 except (ValueError, TypeError):
78 return ""
79 return value if isinstance(value, str) else ""
80 return ""
81
82
83@dataclass(frozen=True)
84class NonfatalSpec:
85 """Normalized fields for one declaration or invocation."""
86
87 rule: str
88 scope: str
89 reason: str
90 directive: str = "nonfatal-declaration"
91
92
93def _row(path: str, line: int, spec: NonfatalSpec) -> Suppression:
94 """Build a source-located nonfatal availability/activation row."""
95 return Suppression(
96 path,
97 line,
98 1,
99 "checker-nonfatal-control",
100 "repository-checker",
101 spec.rule,
102 spec.directive,
103 spec.scope,
104 spec.reason,
105 "checker-control-plane",
106 ownership(path),
107 () if spec.reason else ("blank-reason",),
108 )
109
110
111def _option_declarations(path: str, tree: ast.Module) -> tuple[list[Suppression], list[Finding]]:
112 """Parse only the five audited argparse nonfatal switches."""
113 expected = DECLARED_OPTIONS.get(path)
114 if expected is None:
115 return [], []
116 found: dict[str, Suppression] = {}
117 for node in ast.walk(tree):
118 if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
119 continue
120 if node.func.attr != "add_argument":
121 continue
122 flags = [
123 argument.value
124 for argument in node.args
125 if isinstance(argument, ast.Constant) and isinstance(argument.value, str)
126 ]
127 boolean_optional = any(
128 keyword.arg == "action"
129 and isinstance(keyword.value, ast.Attribute)
130 and keyword.value.attr == "BooleanOptionalAction"
131 for keyword in node.keywords
132 )
133 if boolean_optional:
134 flags.extend(f"--no-{flag[2:]}" for flag in tuple(flags) if flag.startswith("--"))
135 for flag in flags:
136 if flag in expected:
137 found[flag] = _row(
138 path,
139 node.lineno,
140 NonfatalSpec(flag, "available-inactive", _help(node) or expected[flag]),
141 )
142 findings = [
143 Finding("missing-nonfatal-declaration", flag, path)
144 for flag in expected
145 if flag not in found
146 ]
147 return [found[flag] for flag in expected if flag in found], findings
148
149
150def _constant_declaration(path: str, tree: ast.Module) -> tuple[list[Suppression], list[Finding]]:
151 """Inventory the two explicitly false warn-only module switches."""
152 expected = DORMANT_CONSTANTS.get(path)
153 if expected is None:
154 return [], []
155 for node in tree.body:
156 targets: list[ast.expr] = []
157 value: ast.AST | None = None
158 if isinstance(node, ast.Assign):
159 targets, value = node.targets, node.value
160 elif isinstance(node, ast.AnnAssign):
161 targets, value = [node.target], node.value
162 if any(isinstance(target, ast.Name) and target.id == expected for target in targets):
163 if isinstance(value, ast.Constant) and value.value is False:
164 reason = (
165 "Dormant compatibility mode is explicitly false; the checker remains fatal."
166 )
167 spec = NonfatalSpec(expected, "available-inactive", reason)
168 return [_row(path, node.lineno, spec)], []
169 finding = Finding(
170 "active-nonfatal-constant",
171 f"{expected} is not literal False",
172 path,
173 node.lineno,
174 )
175 return [], [finding]
176 return [], [Finding("missing-nonfatal-declaration", expected, path)]
177
178
179def _informational_declarations(
180 path: str, tree: ast.Module
181) -> tuple[list[Suppression], list[Finding]]:
182 """Split the annotation informational-rule authority into three rows."""
183 if (path, INFORMATIONAL_AUTHORITY[1]) != INFORMATIONAL_AUTHORITY:
184 return [], []
185 for node in tree.body:
186 target = None
187 value = None
188 if isinstance(node, ast.Assign) and len(node.targets) == 1:
189 target, value = node.targets[0], node.value
190 elif isinstance(node, ast.AnnAssign):
191 target, value = node.target, node.value
192 if not isinstance(target, ast.Name) or target.id != INFORMATIONAL_AUTHORITY[1]:
193 continue
194 values = _literal_strings(value) if value is not None else []
195 if len(values) != EXPECTED_INFORMATIONAL_RULES:
196 message = f"found {len(values)}; expected {EXPECTED_INFORMATIONAL_RULES}"
197 return [], [Finding("nonfatal-informational-count", message, path, node.lineno)]
198 reason = (
199 "Annotation records information rather than asserting a developer-fixable property."
200 )
201 return [
202 _row(path, node.lineno, NonfatalSpec(value, "informational-rule", reason))
203 for value in sorted(values)
204 ], []
205 return [], [Finding("missing-nonfatal-declaration", INFORMATIONAL_AUTHORITY[1], path)]
206
207
208def _logical_commands(path: str, text: str) -> list[tuple[int, str]]:
209 """Return comment-free command lines with backslash continuations joined."""
210 lines, _findings = hash_lines(path, text)
211 result: list[tuple[int, str]] = []
212 start = 0
213 buffer = ""
214 for item in lines:
215 code = item.code.strip()
216 if not code:
217 continue
218 if not buffer:
219 start = item.line
220 continued = code.endswith("\\")
221 buffer += code[:-1].rstrip() + " " if continued else code
222 if not continued:
223 result.append((start, buffer.strip()))
224 buffer = ""
225 if buffer:
226 result.append((start, buffer.strip()))
227 return result
228
229
230def _command_statements(command: str) -> list[list[tuple[str, bool]]]:
231 """Split one comment-free shell line at real command operators."""
232 statements: list[list[tuple[str, bool]]] = []
233 current: list[tuple[str, bool]] = []
234 for token in _shell_tokens(command):
235 if token.operator and token.value in _COMMAND_SEPARATORS:
236 if current:
237 statements.append(current)
238 current = []
239 continue
240 current.append((token.value, token.operator))
241 if current:
242 statements.append(current)
243 return statements
244
245
246def _expanded(value: str, variables: dict[str, tuple[str, ...]]) -> tuple[str, ...]:
247 """Expand only an exact, previously observed scalar/array reference."""
248 match = _VARIABLE_RE.fullmatch(value)
249 if match is None:
250 return (value,)
251 name = match.group(1) or match.group(2)
252 return variables.get(name, (value,))
253
254
255def _remember_assignment(
256 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
257) -> bool:
258 """Record closed-form scalar and argv-array assignments, without evaluation."""
259 core = _assignment_core(statement)
260 if len(core) == 1 and not core[0][1]:
261 match = _SCALAR_ASSIGNMENT_RE.fullmatch(core[0][0])
262 if match is not None:
263 variables[match.group(1)] = (match.group(2),)
264 return True
265 if len(core) < _MIN_ARRAY_ASSIGNMENT_TOKENS or core[0][1]:
266 return False
267 match = _ARRAY_ASSIGNMENT_RE.fullmatch(core[0][0])
268 append = _ARRAY_APPEND_RE.fullmatch(core[0][0])
269 if (match is None and append is None) or core[1] != ("(", True) or core[-1] != (")", True):
270 return False
271 if any(operator for _value, operator in core[2:-1]):
272 return False
273 selected = match if match is not None else append
274 if selected is None:
275 return False
276 name = selected.group(1)
277 values = tuple(part for value, _operator in core[2:-1] for part in _expanded(value, variables))
278 variables[name] = variables.get(name, ()) + values if append is not None else values
279 return True
280
281
282def _assignment_core(statement: list[tuple[str, bool]]) -> list[tuple[str, bool]]:
283 """Remove a bounded shell declaration prefix before one assignment."""
284 if not statement or statement[0][1] or statement[0][0] not in _DECLARATION_BUILTINS:
285 return statement
286 index = 1
287 while (
288 index < len(statement) and not statement[index][1] and statement[index][0].startswith("-")
289 ):
290 index += 1
291 return statement[index:]
292
293
294def _expanded_argv(
295 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
296) -> list[str]:
297 """Expand exact shell references after an assignment-only statement."""
298 return [
299 part
300 for value, operator in statement
301 if not operator
302 for part in _expanded(value, variables)
303 ]
304
305
306def _invokes_checker(argv: list[str], checker_name: str) -> bool:
307 """Require the checker to be the command or a Python script argument."""
308 positions = [index for index, value in enumerate(argv) if value.endswith(checker_name)]
309 if not positions:
310 return False
311 if 0 in positions:
312 return True
313 python_positions = [
314 index for index, value in enumerate(argv) if Path(value).name in {"python", "python3"}
315 ]
316 return any(python < checker for python in python_positions for checker in positions)
317
318
319def _active_rows(rel: str, line: int, argv: list[str]) -> list[Suppression]:
320 """Return every audited nonfatal flag on one resolved checker invocation."""
321 rows: list[Suppression] = []
322 for checker, options in DECLARED_OPTIONS.items():
323 if not _invokes_checker(argv, Path(checker).name):
324 continue
325 default = DEFAULT_NONFATAL_OPTIONS.get(checker)
326 for flag, reason in options.items():
327 active_by_default = (
328 default == (flag, "--strict")
329 and "--strict" not in argv
330 and not _NON_ANALYSIS_MODES.intersection(argv)
331 )
332 if flag in argv or active_by_default:
333 spec = NonfatalSpec(flag, f"active-caller:{checker}", reason, "nonfatal-invocation")
334 rows.append(_row(rel, line, spec))
335 return rows
336
337
338def _resolved_invocations(
339 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
340) -> list[list[str]]:
341 """Resolve one direct argv and bounded static ``shell -c`` payloads."""
342 argv = _expanded_argv(statement, variables)
343 invocations = [argv]
344 if (
345 len(argv) >= _MIN_ARRAY_ASSIGNMENT_TOKENS
346 and Path(argv[0]).name in _STATIC_SHELLS
347 and argv[1] == "-c"
348 and not any(marker in argv[2] for marker in ("$", "`", "\\n", "\\r"))
349 ):
350 nested_variables: dict[str, tuple[str, ...]] = {}
351 invocations.extend(
352 _expanded_argv(nested, nested_variables)
353 for nested in _command_statements(argv[2])
354 if not _remember_assignment(nested, nested_variables)
355 )
356 return invocations
357
358
359def _active_usages(root: Path, paths: list[str]) -> list[Suppression]:
360 """Find nonfatal flags passed by gate/Just/hook/workflow command surfaces."""
361 rows: list[Suppression] = []
362 for rel in paths:
363 if rel not in ROOT_CALL_SURFACES and not rel.startswith(CALL_SURFACES):
364 continue
365 path = root / rel
366 if not path.is_file():
367 continue
368 variables: dict[str, tuple[str, ...]] = {}
369 for line, command in _logical_commands(rel, path.read_text(errors="replace")):
370 for statement in _command_statements(command):
371 if _remember_assignment(statement, variables):
372 continue
373 for argv in _resolved_invocations(statement, variables):
374 rows.extend(_active_rows(rel, line, argv))
375 return rows
376
377
378def scan_checker_nonfatal_controls(
379 root: Path, paths: list[str]
380) -> tuple[list[Suppression], list[Finding]]:
381 """Inventory declarations separately from active invocations and lock counts."""
382 records: list[Suppression] = []
383 findings: list[Finding] = []
384 interesting = set(DECLARED_OPTIONS) | set(DORMANT_CONSTANTS) | {INFORMATIONAL_AUTHORITY[0]}
385 for rel in sorted(interesting):
386 if rel not in paths or not (root / rel).is_file():
387 findings.append(Finding("missing-nonfatal-authority", rel))
388 continue
389 try:
390 tree = ast.parse((root / rel).read_text(encoding="utf-8"), filename=rel)
391 except (OSError, SyntaxError) as exc:
392 findings.append(Finding("checker-nonfatal-ast", str(exc), rel))
393 continue
394 for parser in (_option_declarations, _constant_declaration, _informational_declarations):
395 rows, problems = parser(rel, tree)
396 records.extend(rows)
397 findings.extend(problems)
398 active = _active_usages(root, paths)
399 records.extend(active)
400 declarations = [item for item in records if item.directive == "nonfatal-declaration"]
401 if len(declarations) != EXPECTED_DECLARATIONS:
402 findings.append(
403 Finding(
404 "checker-nonfatal-declaration-count",
405 f"found {len(declarations)}; audited contract is {EXPECTED_DECLARATIONS}",
406 )
407 )
408 if active:
409 findings.append(
410 Finding(
411 "active-checker-nonfatal-invocation",
412 f"found {len(active)} active gate/Just/hook/workflow invocation(s)",
413 )
414 )
415 return records, findings