3"""AST declarations and active-call cross-check for checker nonfatal modes."""
5from __future__
import annotations
9from dataclasses
import dataclass
10from pathlib
import Path
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
17EXPECTED_DECLARATIONS = 10
18EXPECTED_INFORMATIONAL_RULES = 3
20 "scripts/checks/stack_usage_check.py": {
21 "--allow-empty":
"fresh-build enumeration exception",
22 "--warn-only":
"soft stack-budget reporting mode",
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"},
28DEFAULT_NONFATAL_OPTIONS = {
29 "scripts/checks/check_world_tags.py": (
"--warn",
"--strict"),
30 "scripts/checks/cite_check.py": (
"--warn",
"--strict"),
33 "scripts/checks/check_line_citations.py":
"WARN_ONLY_MODE",
34 "scripts/checks/check_inclusive_terminology.py":
"WARN_ONLY_MODE",
36INFORMATIONAL_AUTHORITY = (
"scripts/checks/annot_rulekeys.py",
"INFORMATIONAL_RULES")
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_]*))$"
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"})
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):
62 if isinstance(node, (ast.Set, ast.Tuple, ast.List)):
63 return [item
for child
in node.elts
for item
in _literal_strings(child)]
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)
73 return " ".join(bits).strip()
74 if isinstance(keyword.value, ast.BinOp):
76 value = ast.literal_eval(keyword.value)
77 except (ValueError, TypeError):
79 return value
if isinstance(value, str)
else ""
83@dataclass(frozen=True)
85 """Normalized fields for one declaration or invocation."""
90 directive: str =
"nonfatal-declaration"
93def _row(path: str, line: int, spec: NonfatalSpec) -> Suppression:
94 """Build a source-located nonfatal availability/activation row."""
99 "checker-nonfatal-control",
100 "repository-checker",
105 "checker-control-plane",
107 ()
if spec.reason
else (
"blank-reason",),
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)
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):
120 if node.func.attr !=
"add_argument":
124 for argument
in node.args
125 if isinstance(argument, ast.Constant)
and isinstance(argument.value, str)
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
134 flags.extend(f
"--no-{flag[2:]}" for flag
in tuple(flags)
if flag.startswith(
"--"))
140 NonfatalSpec(flag,
"available-inactive", _help(node)
or expected[flag]),
143 Finding(
"missing-nonfatal-declaration", flag, path)
147 return [found[flag]
for flag
in expected
if flag
in found], findings
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)
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:
165 "Dormant compatibility mode is explicitly false; the checker remains fatal."
167 spec = NonfatalSpec(expected,
"available-inactive", reason)
168 return [_row(path, node.lineno, spec)], []
170 "active-nonfatal-constant",
171 f
"{expected} is not literal False",
176 return [], [Finding(
"missing-nonfatal-declaration", expected, path)]
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:
185 for node
in tree.body:
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]:
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)]
199 "Annotation records information rather than asserting a developer-fixable property."
202 _row(path, node.lineno, NonfatalSpec(value,
"informational-rule", reason))
203 for value
in sorted(values)
205 return [], [Finding(
"missing-nonfatal-declaration", INFORMATIONAL_AUTHORITY[1], path)]
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]] = []
215 code = item.code.strip()
220 continued = code.endswith(
"\\")
221 buffer += code[:-1].rstrip() +
" " if continued
else code
223 result.append((start, buffer.strip()))
226 result.append((start, buffer.strip()))
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:
237 statements.append(current)
240 current.append((token.value, token.operator))
242 statements.append(current)
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)
251 name = match.group(1)
or match.group(2)
252 return variables.get(name, (value,))
255def _remember_assignment(
256 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
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),)
265 if len(core) < _MIN_ARRAY_ASSIGNMENT_TOKENS
or core[0][1]:
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):
271 if any(operator
for _value, operator
in core[2:-1]):
273 selected = match
if match
is not None else append
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
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:
288 index < len(statement)
and not statement[index][1]
and statement[index][0].startswith(
"-")
291 return statement[index:]
295 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
297 """Expand exact shell references after an assignment-only statement."""
300 for value, operator
in statement
302 for part
in _expanded(value, variables)
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)]
314 index
for index, value
in enumerate(argv)
if Path(value).name
in {
"python",
"python3"}
316 return any(python < checker
for python
in python_positions
for checker
in positions)
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):
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)
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))
338def _resolved_invocations(
339 statement: list[tuple[str, bool]], variables: dict[str, tuple[str, ...]]
341 """Resolve one direct argv and bounded static ``shell -c`` payloads."""
342 argv = _expanded_argv(statement, variables)
345 len(argv) >= _MIN_ARRAY_ASSIGNMENT_TOKENS
346 and Path(argv[0]).name
in _STATIC_SHELLS
348 and not any(marker
in argv[2]
for marker
in (
"$",
"`",
"\\n",
"\\r"))
350 nested_variables: dict[str, tuple[str, ...]] = {}
352 _expanded_argv(nested, nested_variables)
353 for nested
in _command_statements(argv[2])
354 if not _remember_assignment(nested, nested_variables)
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] = []
363 if rel
not in ROOT_CALL_SURFACES
and not rel.startswith(CALL_SURFACES):
366 if not path.is_file():
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):
373 for argv
in _resolved_invocations(statement, variables):
374 rows.extend(_active_rows(rel, line, argv))
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))
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))
394 for parser
in (_option_declarations, _constant_declaration, _informational_declarations):
395 rows, problems = parser(rel, tree)
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:
404 "checker-nonfatal-declaration-count",
405 f
"found {len(declarations)}; audited contract is {EXPECTED_DECLARATIONS}",
411 "active-checker-nonfatal-invocation",
412 f
"found {len(active)} active gate/Just/hook/workflow invocation(s)",
415 return records, findings