ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_suppressions.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Inventory every suppression and reconcile it against the review ledger.
5
6Every recognized waiver row carries a durable two-part identity: a
7``site_id`` naming the occurrence independent of line movement and a
8``binding_sha256`` over exactly what a reviewer approved. The committed
9ledger (``.github/suppression-review-ledger.tsv`` with its rationale and
10batch authorities) binds each site to a reviewed decision; only a ``retain``
11row with an exact binding match marks a suppression approved, and nothing in
12this tool generates approval. ``--check`` stays nonzero while any site is
13unreviewed, carries an unremediated fix decision, or any integrity finding
14fires; exit 2 means the scan itself stopped being trustworthy. An exit-zero
15``--inventory`` means only that report generation succeeded.
16
17Usage::
18
19 python3 scripts/checks/check_suppressions.py --selftest
20 python3 scripts/checks/check_suppressions.py --inventory --format json
21 python3 scripts/checks/check_suppressions.py --inventory --format markdown
22 python3 scripts/checks/check_suppressions.py --check
23 python3 scripts/checks/check_suppressions.py --ledger-candidates
24 python3 scripts/checks/check_suppressions.py --list-files
25"""
26
27from __future__ import annotations
28
29import argparse
30import json
31import sys
32from pathlib import Path
33
34sys.path.insert(0, str(Path(__file__).resolve().parent))
35
36from suppression_ledger import candidate_rows
37from suppression_model import Inventory, Suppression
38from suppression_scan import MIN_REPOSITORY_FILES, git_paths, scan_repository
39from suppression_selftest import run_selftest
40
41REPO_ROOT = Path(__file__).resolve().parents[2]
42MAX_CHECK_DETAILS = 80
43INTEGRITY_CODES = frozenset(
44 {
45 "git-enumeration",
46 "invalid-text-encoding",
47 "duplicate-fingerprint",
48 "duplicate-site-identity",
49 "baseline-owner-mismatch",
50 "baseline-ceiling-integrity",
51 "baseline-growth",
52 "duplicate-baseline-row",
53 "duplicate-coverage-mask",
54 "duplicate-mcdc-deactivation",
55 "malformed-baseline-row",
56 "malformed-coverage-mask",
57 "malformed-mcdc-deactivation",
58 "malformed-mcdc-macro",
59 "malformed-native-test-skip",
60 "missing-baseline-file",
61 "missing-baseline-ceilings",
62 "missing-baseline-consumer",
63 "missing-baseline-provenance",
64 "missing-baseline-total",
65 "mcdc-owner-mismatch",
66 "stale-baseline-path",
67 "stranded-branch-marker",
68 "stranded-line-marker",
69 "stale-baseline-total",
70 "unknown-baseline-file",
71 "unknown-mcdc-macro",
72 "unexpected-family-count",
73 "unpaired-mcdc-deactivation",
74 "vacuous-family",
75 "missing-family",
76 "malformed-heredoc",
77 "malformed-ruff-config",
78 "malformed-tool-config",
79 "malformed-ansible-lint-config",
80 "malformed-ci-parity-workflow",
81 "malformed-doxygen-config",
82 "malformed-generated-marker",
83 "generated-marker-without-body",
84 "non-substantive-waiver-reason",
85 "self-generated-provenance",
86 "symlinked-generated-provenance",
87 "missing-generated-provenance",
88 "malformed-gitignore-scope-marker",
89 "malformed-global-exclusion-config",
90 "duplicate-file-size-waiver",
91 "untracked-generated-provenance",
92 "unterminated-cmake-bracket",
93 "checker-scope-ast",
94 "checker-scope-authority-count",
95 "checker-scope-value-count",
96 "checker-scope-value-digest",
97 "checker-scope-reason-digest",
98 "checker-classification-digest",
99 "checker-census-floor",
100 "checker-authority-mutation",
101 "checker-authority-rebinding",
102 "condition-dependent-authority",
103 "inline-scope-literal",
104 "non-authority-shape-mismatch",
105 "stale-checker-classification",
106 "unclassified-checker-constant",
107 "missing-checker-scope-authority",
108 "unresolved-checker-scope-authority",
109 "checker-nonfatal-ast",
110 "checker-nonfatal-declaration-count",
111 "missing-nonfatal-authority",
112 "missing-nonfatal-declaration",
113 "active-nonfatal-constant",
114 "active-checker-nonfatal-invocation",
115 "nonfatal-informational-count",
116 "unexpected-governance-count",
117 "python-tokenize",
118 "read-error",
119 "unterminated-comment",
120 "unterminated-html-comment",
121 "unterminated-heredoc",
122 "unterminated-line-comment-splice",
123 "unterminated-shell-quote",
124 "vacuous-baseline-files",
125 "vacuous-baseline-rows",
126 "unterminated-shell-arithmetic",
127 "unterminated-string",
128 "unsafe-symlink",
129 "vacuous-files",
130 "vacuous-inventory",
131 "missing-review-ledger",
132 "malformed-review-ledger",
133 "ledger-duplicate-batch",
134 "ledger-duplicate-site",
135 "ledger-schema-mismatch",
136 "ledger-unknown-reference",
137 "ledger-batch-mismatch",
138 "ledger-state-conflict",
139 "ledger-binding-mismatch",
140 "ledger-stale-site",
141 "ledger-resolved-still-present",
142 }
143)
144
145
146def _parser() -> argparse.ArgumentParser:
147 """Build the explicit-mode command-line parser."""
148 parser = argparse.ArgumentParser(description=__doc__)
149 modes = parser.add_mutually_exclusive_group(required=True)
150 modes.add_argument("--selftest", action="store_true", help="run both-direction fixtures")
151 modes.add_argument(
152 "--inventory",
153 action="store_true",
154 help="emit the ledger-reconciled inventory",
155 )
156 modes.add_argument("--check", action="store_true", help="fail on concerns or findings")
157 modes.add_argument(
158 "--ledger-candidates",
159 action="store_true",
160 help="print unreviewed ledger candidate rows for every live site",
161 )
162 modes.add_argument(
163 "--list-files",
164 action="store_true",
165 help="list JSON-escaped scan paths (use -z for exact NUL-delimited paths)",
166 )
167 parser.add_argument("--format", choices=("json", "markdown"), help="inventory output format")
168 parser.add_argument("-z", "--null", action="store_true", help="NUL-terminate --list-files")
169 return parser
170
171
172def _markdown_escape(value: object) -> str:
173 """Escape one scalar for a Markdown table cell."""
174 return str(value).replace("|", "\\|").replace("\n", " ")
175
176
177def _render_summary(inventory: Inventory) -> list[str]:
178 """Render deterministic Markdown summary bullets."""
179 families = inventory.family_counts()
180 lines = [
181 f"- Files scanned: {inventory.files_scanned}",
182 f"- Text files: {inventory.text_files}",
183 f"- Binary files: {inventory.binary_files}",
184 f"- Suppressions: {len(inventory.suppressions)}",
185 f"- Scanner findings: {len(inventory.findings)}",
186 ]
187 if families:
188 lines.append(
189 "- Families: " + ", ".join(f"{key}={value}" for key, value in families.items())
190 )
191 return lines
192
193
194def _render_markdown(inventory: Inventory) -> str:
195 """Render the non-authoritative phase-one Markdown inventory."""
196 lines = [
197 "# Suppression inventory",
198 "",
199 *_render_summary(inventory),
200 "",
201 ]
202 lines.extend(
203 [
204 "| Path | Line | Family | Tool | Rule | Scope | Owner | Reason | Concerns |",
205 "|---|---:|---|---|---|---|---|---|---|",
206 ]
207 )
208 ordered = sorted(inventory.suppressions, key=lambda item: (item.path, item.line, item.column))
209 for item in ordered:
210 values = (
211 item.path,
212 item.line,
213 item.family,
214 item.tool,
215 item.rule,
216 item.scope,
217 item.owner,
218 item.reason,
219 ", ".join(item.concerns),
220 )
221 lines.append("| " + " | ".join(_markdown_escape(value) for value in values) + " |")
222 lines.extend(["", "## Scanner findings", ""])
223 if not inventory.findings:
224 lines.append("None.")
225 else:
226 for finding in sorted(
227 inventory.findings, key=lambda item: (item.path, item.line, item.code)
228 ):
229 location = f"{finding.path}:{finding.line}" if finding.path else "repository"
230 lines.append(f"- `{finding.code}` at `{location}`: {_markdown_escape(finding.message)}")
231 return "\n".join(lines) + "\n"
232
233
234def _integrity_failed(inventory: Inventory) -> bool:
235 """Return whether scanner evidence is malformed or vacuous."""
236 return any(finding.code in INTEGRITY_CODES for finding in inventory.findings)
237
238
239def _review_count(inventory: Inventory) -> int:
240 """Count scanner findings and per-row review concerns.
241
242 Concerns on an approved row do not count again: the ledger binding the
243 approval hashes the concerns, so any change reopens the review instead.
244 """
245 pending = sum(item.disposition != "approved" for item in inventory.suppressions)
246 return (
247 len(inventory.findings)
248 + sum(
249 len(item.concerns) for item in inventory.suppressions if item.disposition != "approved"
250 )
251 + pending
252 )
253
254
255def _concern_line(item: Suppression, concern: str) -> str:
256 """Format one suppression concern for check-mode diagnostics."""
257 return f"{item.path}:{item.line}: {concern}: {item.family}/{item.rule}"
258
259
260def _run_check(inventory: Inventory) -> int:
261 """Print bounded diagnostics and return clean, debt, or malformed status."""
262 details = [
263 f"{item.path or 'repository'}:{item.line}: {item.code}: {item.message}"
264 for item in inventory.findings
265 ]
266 details.extend(
267 _concern_line(item, concern)
268 for item in inventory.suppressions
269 if item.disposition != "approved"
270 for concern in item.concerns
271 )
272 details.extend(
273 f"{item.path}:{item.line}: governance-pending: {item.fingerprint}"
274 for item in inventory.suppressions
275 if item.disposition != "approved"
276 )
277 for detail in details[:MAX_CHECK_DETAILS]:
278 print(detail)
279 if len(details) > MAX_CHECK_DETAILS:
280 print(f"... {len(details) - MAX_CHECK_DETAILS} additional item(s) omitted")
281 print(
282 f"check_suppressions.py: {len(inventory.suppressions)} suppression(s), "
283 f"{_review_count(inventory)} review item(s)"
284 )
285 if _integrity_failed(inventory):
286 return 2
287 return 1 if details else 0
288
289
290def _run_list_files(*, null: bool) -> int:
291 """List exact scan candidates and reject a collapsed Git enumeration."""
292 paths, findings = git_paths(REPO_ROOT)
293 if findings:
294 for finding in findings:
295 print(f"check_suppressions.py: FATAL -- {finding.message}", file=sys.stderr)
296 return 2
297 if len(paths) < MIN_REPOSITORY_FILES:
298 print(
299 f"check_suppressions.py: FATAL -- only {len(paths)} file(s); "
300 f"floor is {MIN_REPOSITORY_FILES}",
301 file=sys.stderr,
302 )
303 return 2
304 if null:
305 sys.stdout.buffer.write(b"\0".join(path.encode("utf-8") for path in paths) + b"\0")
306 else:
307 print("\n".join(json.dumps(path, ensure_ascii=True) for path in paths))
308 return 0
309
310
311def main(argv: list[str] | None = None) -> int:
312 """Run the selected public scanner mode."""
313 parser = _parser()
314 args = parser.parse_args(argv)
315 if args.format is not None and not args.inventory:
316 parser.error("--format requires --inventory")
317 if args.null and not args.list_files:
318 parser.error("--null requires --list-files")
319 if args.selftest:
320 return run_selftest()
321 if args.list_files:
322 return _run_list_files(null=args.null)
323 inventory, _ = scan_repository(REPO_ROOT)
324 if args.ledger_candidates:
325 print("site_id\tbinding_sha256\tstate\trationale_id\tbatch_id\tevidence_ref")
326 for row in candidate_rows(inventory):
327 print(row)
328 return 2 if _integrity_failed(inventory) else 0
329 if args.inventory:
330 if (args.format or "json") == "json":
331 print(json.dumps(inventory.as_dict(), indent=2, sort_keys=True, ensure_ascii=True))
332 else:
333 print(_render_markdown(inventory), end="")
334 return 2 if _integrity_failed(inventory) else 0
335 return _run_check(inventory)
336
337
338if __name__ == "__main__":
339 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298