ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_baseline_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Parse every authoritative ratchet baseline into suppression inventory rows."""
4
5from __future__ import annotations
6
7import hashlib
8import json
9import re
10from dataclasses import dataclass
11from pathlib import Path, PurePosixPath
12
13from suppression_catalog import ownership
14from suppression_model import Finding, Suppression
15
16MIN_BASELINE_FILES = 9
17PATH_COUNT_COLUMNS = 2
18PATH_RULE_COUNT_COLUMNS = 3
19TREE_MEASURED_COLUMNS = 6
20TREE_UNMEASURED_COLUMNS = 3
21TREE_METRIC_FIELDS = 4
22MIN_BASELINE_ROWS = 5343
23CEILING_LEDGER_PATH = ".github/suppression-debt-ceilings.tsv"
24CEILING_LEDGER_SHA256 = "dcfbe6ffd0ecfe10e45646989ea32d1725d02e2c5585d3718440b27b267ce67b"
25CEILING_LEDGER_HEADER = (
26 "# Suppression debt per-key ceilings v1.",
27 "# Key digests bind the canonical baseline path and consumer-semantic key.",
28 "# Rows may disappear or decrease; a new key or weaker value is growth.",
29 "# baseline<TAB>sha256<TAB>ceiling",
30)
31CEILING_LEDGER_COLUMNS = 3
32
33
34@dataclass(frozen=True)
35class BaselineSpec:
36 """Grammar and provenance for one authoritative committed baseline."""
37
38 tool: str
39 shape: str
40 consumer: str
41 reason: str
42 declared_pattern: str = ""
43 declared_is_sum: bool = False
44 key_shape: str = "path-rule"
45 ceiling_shape: str = "count"
46 provenance_anchors: tuple[str, ...] = ()
47
48
49SPECS: dict[str, BaselineSpec] = {
50 ".github/agnostic-register-baseline.txt": BaselineSpec(
51 "agnostic-registers",
52 "path-rule-count",
53 "scripts/checks/check_agnostic_registers.py",
54 "Frozen driver reach-in debt first measured by issue #698; counts may only shrink.",
55 provenance_anchors=(
56 "# Concrete RA8 driver reach-in debt, per (file, peripheral family).",
57 "# Consumed by scripts/checks/check_agnostic_registers.py --check",
58 ),
59 ),
60 ".github/cite-baseline.txt": BaselineSpec(
61 "cite-ratchet",
62 "path-count",
63 "scripts/checks/cite_ratchet.py",
64 "Uncited MMIO debt predating issue #534 enforcement; counts may only shrink.",
65 r"Total at this baseline:\s*(\d+)\s+uncited access",
66 declared_is_sum=True,
67 key_shape="path",
68 provenance_anchors=(
69 "# HUM citation-COVERAGE ratchet baseline -- per-file uncited-access counts.",
70 "# Consumed by scripts/checks/cite_ratchet.py --check (CI gate: cite-check).",
71 ),
72 ),
73 ".github/emulator-matrix-baseline.txt": BaselineSpec(
74 "emulator-matrix",
75 "matrix",
76 "scripts/checks/matrix_ratchet.py",
77 "CI-runner emulator failures measured under issue #400 need an explained cause.",
78 r"total:\s*(\d+)",
79 key_shape="matrix-app",
80 ceiling_shape="matrix-verdict",
81 provenance_anchors=(
82 "# ra8_emulator example-matrix baseline -- see scripts/checks/matrix_ratchet.py",
83 "# MEASURE THIS ON THE CI RUNNER, NEVER ON A DEVELOPER BOX -- see #400.",
84 ),
85 ),
86 ".github/freestanding-runtime-baseline.json": BaselineSpec(
87 "freestanding-runtime",
88 "freestanding-apps",
89 "scripts/checks/check_freestanding_runtime.py",
90 "Zero-debt freestanding target ratchet from issue #847; per-app debt may only shrink.",
91 key_shape="rule",
92 ceiling_shape="count",
93 ),
94 ".github/hum-register-baseline.txt": BaselineSpec(
95 "hum-register-map",
96 "path-rule-count",
97 "scripts/checks/check_hum_register_map.py",
98 "Frozen register-map migration debt checked against the committed HUM.",
99 provenance_anchors=(
100 "# HUM register-map debt, per (file, rule). Generated by",
101 "# scripts/checks/check_hum_register_map.py --update.",
102 ),
103 ),
104 ".github/mcdc-baseline.txt": BaselineSpec(
105 "llvm-cov-mcdc",
106 "percentage",
107 "scripts/ci/gates/tests.sh",
108 "Aggregate MC/DC floor frozen from the pinned llvm-cov measurement.",
109 key_shape="repository",
110 ceiling_shape="percentage",
111 provenance_anchors=(
112 "# Aggregate reachable decision-complete MC/DC percentage ratchet floor.",
113 "# Consumed by scripts/ci/gates/tests.sh (CI gate: mcdc).",
114 ),
115 ),
116 ".github/mcdc-compound-baseline.txt": BaselineSpec(
117 "mcdc-compound-ratchet",
118 "path-rule-count",
119 "scripts/checks/mcdc_compound_ratchet.py",
120 "Compound decisions predating issue #426 enforcement; counts may only shrink.",
121 r"Total at this baseline:\s*(\d+)\s+uncovered compound decision",
122 declared_is_sum=True,
123 provenance_anchors=(
124 "# MC/DC compound-decision ratchet baseline -- per-file-per-function counts.",
125 "# Consumed by scripts/checks/mcdc_compound_ratchet.py --check",
126 ),
127 ),
128 ".github/misra-baseline.txt": BaselineSpec(
129 "misra-ratchet",
130 "path-rule-count",
131 "scripts/checks/misra_ratchet.py",
132 "Cppcheck 2.13 debt with migration provenance in issues #786 and #790.",
133 r"total findings:\s*(\d+)",
134 declared_is_sum=True,
135 provenance_anchors=(
136 "# MISRA-C 2012 ratchet baseline -- per-file-per-rule finding counts.",
137 "# Consumed by scripts/checks/misra_ratchet.py --check (CI job: misra).",
138 ),
139 ),
140 ".github/tidy-baseline.txt": BaselineSpec(
141 "clang-tidy-ratchet",
142 "path-rule-count",
143 "scripts/checks/tidy_ratchet.py",
144 "Findings exposed by issue #369 and #370 scope expansion; counts may only shrink.",
145 provenance_anchors=(
146 "# clang-tidy ratchet baseline -- per-file-per-check finding counts.",
147 "# Consumed by scripts/checks/tidy_ratchet.py --check (CI gate: tidy).",
148 ),
149 ),
150 ".github/tree-coverage-baseline.txt": BaselineSpec(
151 "tree-coverage",
152 "tree-coverage",
153 "scripts/checks/check_tree_coverage.py",
154 "Single-tree debt from the pinned gcovr producer; coverage may only improve.",
155 r"rows:\s*(\d+)",
156 key_shape="path",
157 ceiling_shape="tree-coverage",
158 provenance_anchors=(
159 "# ONE coverage baseline for every first-party translation unit.",
160 "# Emitted by `python3 scripts/checks/check_tree_coverage.py --update`.",
161 ),
162 ),
163}
164BASELINE_CEILINGS: dict[str, tuple[int, int]] = {
165 ".github/agnostic-register-baseline.txt": (292, 672),
166 ".github/cite-baseline.txt": (246, 2831),
167 ".github/emulator-matrix-baseline.txt": (0, 0),
168 ".github/freestanding-runtime-baseline.json": (7, 0),
169 ".github/hum-register-baseline.txt": (46, 451),
170 ".github/mcdc-baseline.txt": (1, 1),
171 ".github/mcdc-compound-baseline.txt": (957, 1693),
172 ".github/misra-baseline.txt": (2710, 20014),
173 ".github/tidy-baseline.txt": (96, 121),
174 ".github/tree-coverage-baseline.txt": (1059, 9404),
175}
176BASELINE_PERCENTAGE_FLOORS = {".github/mcdc-baseline.txt": 89.72}
177
178
179KNOWN_MATRIX_VERDICTS = frozenset({"FAULT", "TRUNCATED", "UNKNOWN", "BUILD_FAIL", "NO_ELF"})
180KNOWN_TREE_REASONS = frozenset(
181 {
182 "compiled-not-executed",
183 "firmware-composition",
184 "hosted-no-coverage-build",
185 "platform-cross-only",
186 }
187)
188DebtRow = tuple[str, str, int, str]
189CeilingLedger = dict[str, tuple[str, str]]
190ValidationContext = tuple[
191 Path,
192 str,
193 BaselineSpec,
194 dict[str, int],
195 CeilingLedger | None,
196 list[Finding],
197]
198SAFE_RULE_RE = re.compile(r"[A-Za-z0-9_.+@()/-]+")
199PERCENT_RE = re.compile(r"(?:100(?:\.0+)?|[0-9]{1,2}(?:\.[0-9]+)?)")
200SHA256_RE = re.compile(r"[0-9a-f]{64}")
201
202
203def _safe_target(value: str) -> bool:
204 """Return whether a target is one canonical safe repository-relative path."""
205 path = PurePosixPath(value)
206 return (
207 bool(value)
208 and bool(path.parts)
209 and "\\" not in value
210 and not path.is_absolute()
211 and ".." not in path.parts
212 and value == path.as_posix()
213 )
214
215
216def _semantic_key(spec: BaselineSpec, target: str, rule: str) -> str:
217 """Return the baseline consumer's canonical bucket identity."""
218 if spec.key_shape == "path-rule":
219 return f"{target}\0{rule}"
220 if spec.key_shape == "path":
221 return target
222 if spec.key_shape == "rule":
223 return rule
224 if spec.key_shape == "repository":
225 return "repository"
226 raise ValueError(spec.key_shape)
227
228
229def _ceiling_key(baseline: str, semantic_key: str) -> str:
230 """Hash one canonical consumer bucket together with its owning baseline."""
231 payload = f"{baseline}\0{semantic_key}".encode("ascii")
232 return hashlib.sha256(payload).hexdigest()
233
234
235def _record_evidence(record: Suppression, prefix: str) -> str:
236 """Return one required evidence value from a suppression record."""
237 return next(item.removeprefix(prefix) for item in record.evidence if item.startswith(prefix))
238
239
240def _record(
241 baseline: str,
242 line: int,
243 spec: BaselineSpec,
244 debt: DebtRow,
245 *,
246 ceiling: tuple[str, str] = ("", ""),
247) -> Suppression:
248 """Build one source-located ratchet inventory row."""
249 target, rule, count, reason = debt
250 semantic_key, ceiling_value = ceiling
251 key = semantic_key or _semantic_key(spec, target, rule)
252 value = ceiling_value or ("present" if spec.ceiling_shape == "presence" else str(count))
253 return Suppression(
254 baseline,
255 line,
256 1,
257 "baseline-ratchet",
258 spec.tool,
259 rule,
260 "committed-baseline",
261 "repository" if not target else f"file:{target}",
262 reason or spec.reason,
263 "ratchet-baseline",
264 ownership(target) if target else "first-party",
265 (),
266 evidence=(
267 f"consumer:{spec.consumer}",
268 f"target:{target or 'repository'}",
269 f"frozen-count:{count}",
270 f"ceiling-key:{_ceiling_key(baseline, key)}",
271 f"ceiling-value:{value}",
272 ),
273 match_count=count,
274 recommendation="burn-down-only",
275 )
276
277
278ParseResult = tuple[Suppression | None, str]
279
280
281def _positive_count(value: str) -> int | None:
282 """Parse a strictly positive decimal baseline count."""
283 return int(value) if re.fullmatch(r"[1-9][0-9]*", value) else None
284
285
286def _parse_path_rule_count(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
287 """Parse path, rule, and count fields."""
288 fields = raw.split("\t")
289 if len(fields) != PATH_RULE_COUNT_COLUMNS:
290 return None, "expected path<TAB>rule<TAB>count"
291 target, rule, count_text = fields
292 count = _positive_count(count_text)
293 if not _safe_target(target) or SAFE_RULE_RE.fullmatch(rule) is None or count is None:
294 return None, "invalid path, rule, or positive count"
295 return _record(baseline, line, spec, (target, rule, count, "")), ""
296
297
298def _parse_path_count(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
299 """Parse path and count fields."""
300 fields = raw.split("\t")
301 if len(fields) != PATH_COUNT_COLUMNS:
302 return None, "expected path<TAB>count"
303 target, count_text = fields
304 count = _positive_count(count_text)
305 if not _safe_target(target) or count is None:
306 return None, "invalid path or positive count"
307 return _record(baseline, line, spec, (target, "uncited-mmio", count, "")), ""
308
309
310def _parse_path(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
311 """Parse one repository-relative path."""
312 if "\t" in raw or not _safe_target(raw):
313 return None, "expected one repository-relative path"
314 return _record(baseline, line, spec, (raw, "historical-gap", 1, "")), ""
315
316
317def _parse_percentage(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
318 """Parse one aggregate percentage floor."""
319 if PERCENT_RE.fullmatch(raw) is None:
320 return None, "expected one percentage in the inclusive range 0..100"
321 reason = f"Aggregate MC/DC may not fall below {raw}%."
322 return (
323 _record(
324 baseline,
325 line,
326 spec,
327 ("", "aggregate-mcdc-floor", 1, reason),
328 ceiling=("", raw),
329 ),
330 "",
331 )
332
333
334def _parse_matrix(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
335 """Parse one explained emulator debt row."""
336 fields = raw.split("\t")
337 if len(fields) != PATH_RULE_COUNT_COLUMNS:
338 return None, "expected app<TAB>verdict<TAB>cause"
339 app, verdict, cause = fields
340 if not _safe_target(app) or verdict not in KNOWN_MATRIX_VERDICTS or not cause.strip():
341 return None, "unknown verdict or blank failure cause"
342 return (
343 _record(
344 baseline,
345 line,
346 spec,
347 ("", verdict, 1, cause.strip()),
348 ceiling=(app, verdict),
349 ),
350 "",
351 )
352
353
354def _parse_tree_coverage(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
355 """Parse gcovr measured and declared-unmeasured rows."""
356 fields = raw.split("\t")
357 if len(fields) == TREE_UNMEASURED_COLUMNS and fields[1] == "UNMEASURED":
358 target, _kind, reason = fields
359 if not _safe_target(target) or reason not in KNOWN_TREE_REASONS:
360 return None, "invalid UNMEASURED path or reason class"
361 debt = (target, f"unmeasured:{reason}", 1, reason)
362 return _record(baseline, line, spec, debt, ceiling=("", f"U:{reason}")), ""
363 if len(fields) == TREE_MEASURED_COLUMNS and fields[1] == "MEASURED":
364 target = fields[0]
365 counts = fields[2:]
366 if not _safe_target(target) or any(not value.isdecimal() for value in counts):
367 return None, "invalid MEASURED path or non-negative counts"
368 covered_line, total_line, covered_branch, total_branch = map(int, counts)
369 if covered_line > total_line or covered_branch > total_branch:
370 return None, "covered count exceeds total count"
371 debt = (total_line - covered_line) + (total_branch - covered_branch)
372 row = (target, "measured-line-branch-floor", max(debt, 1), "")
373 value = f"M:{covered_line},{total_line},{covered_branch},{total_branch}"
374 return _record(baseline, line, spec, row, ceiling=("", value)), ""
375 return None, "expected a tool-emitted MEASURED or UNMEASURED row"
376
377
378def _parse_opaque(baseline: str, line: int, raw: str, spec: BaselineSpec) -> ParseResult:
379 """Parse one nonempty checker-owned key."""
380 if not raw.strip() or "\t" in raw:
381 return None, "expected one nonempty checker-owned key"
382 return _record(baseline, line, spec, ("", raw.strip(), 1, "")), ""
383
384
385def _freestanding_archive_units(archives: object) -> int | None:
386 """Count live-archive debt units, or None when malformed."""
387 if not isinstance(archives, dict):
388 return None
389 members = 0
390 for name, items in archives.items():
391 if not isinstance(name, str) or not isinstance(items, list):
392 return None
393 if not all(isinstance(m, str) for m in items):
394 return None
395 members += len(items)
396 return members
397
398
399def _freestanding_units(entry: object) -> int | None:
400 """Count debt units in one freestanding app entry, or None when malformed."""
401 if not isinstance(entry, dict):
402 return None
403 symbols = entry.get("forbidden_symbols")
404 if not isinstance(symbols, list) or not all(isinstance(s, str) for s in symbols):
405 return None
406 members = _freestanding_archive_units(entry.get("forbidden_archives"))
407 provider = entry.get("sbrk_provider")
408 end = entry.get("end_symbol")
409 if members is None or not isinstance(provider, str) or not isinstance(end, bool):
410 return None
411 return len(symbols) + members + (0 if provider == "none" else 1) + (1 if end else 0)
412
413
414def _json_key_line(text: str, key: str) -> int:
415 """Return the 1-based line defining one JSON object key."""
416 match = re.search(r'"' + re.escape(key) + r'"\s*:', text)
417 if match is None:
418 return 1
419 return text.count("\n", 0, match.start()) + 1
420
421
422def _freestanding_rows(
423 text: str,
424) -> tuple[list[tuple[DebtRow, int]], str]:
425 """Parse the freestanding JSON ratchet into located per-app debt rows."""
426 try:
427 data = json.loads(text)
428 except json.JSONDecodeError:
429 return [], "baseline is not valid JSON"
430 if not isinstance(data, dict):
431 return [], "baseline root must be an object"
432 apps = data.get("apps")
433 exceptions = data.get("linker_script_exceptions")
434 if not isinstance(apps, dict) or not isinstance(exceptions, list):
435 return [], "baseline must define apps and linker_script_exceptions"
436 if not all(isinstance(e, str) for e in exceptions):
437 return [], "linker_script_exceptions must list strings"
438 rows, error = _freestanding_app_rows(text, apps)
439 if error:
440 return [], error
441 rows.append(
442 (
443 ("", "linker-script-exceptions", len(exceptions), ""),
444 _json_key_line(text, "linker_script_exceptions"),
445 )
446 )
447 return rows, ""
448
449
450def _freestanding_app_rows(
451 text: str, apps: dict[object, object]
452) -> tuple[list[tuple[DebtRow, int]], str]:
453 """Parse sorted per-app debt rows with source locations."""
454 names = sorted(app for app in apps if isinstance(app, str))
455 if len(names) != len(apps):
456 return [], "app keys must be strings"
457 rows: list[tuple[DebtRow, int]] = []
458 for app in names:
459 if SAFE_RULE_RE.fullmatch(app) is None:
460 return [], f"unsafe app key {app!r}"
461 units = _freestanding_units(apps[app])
462 if units is None:
463 return [], f"malformed app entry {app!r}"
464 rows.append((("", app, units, ""), _json_key_line(text, app)))
465 return rows, ""
466
467
468PARSERS = {
469 "path-rule-count": _parse_path_rule_count,
470 "path-count": _parse_path_count,
471 "path": _parse_path,
472 "percentage": _parse_percentage,
473 "matrix": _parse_matrix,
474 "tree-coverage": _parse_tree_coverage,
475 "opaque": _parse_opaque,
476}
477
478
479def _validate_target(
480 root: Path, baseline: str, record: Suppression, findings: list[Finding]
481) -> None:
482 """Reject stale paths and exempt-owner baseline rows."""
483 target = next(
484 item.removeprefix("target:") for item in record.evidence if item.startswith("target:")
485 )
486 if target == "repository":
487 return
488 if not (root / target).is_file():
489 findings.append(Finding("stale-baseline-path", target, baseline, record.line))
490 if record.owner != "first-party":
491 message = f"{target} is {record.owner}"
492 findings.append(Finding("baseline-owner-mismatch", message, baseline, record.line))
493
494
495def _only_lost_code(now: tuple[int, int], base: tuple[int, int]) -> bool:
496 """Return whether a smaller metric lost no more covered than total items."""
497 covered, total = now
498 base_covered, base_total = base
499 return (total < base_total) and ((base_covered - covered) <= (base_total - total))
500
501
502def _metric_regressed(now: tuple[int, int], base: tuple[int, int]) -> bool:
503 """Return whether uncovered debt grew or a non-shrink ratio fell."""
504 covered, total = now
505 base_covered, base_total = base
506 debt_grew = (total - covered) > (base_total - base_covered)
507 ratio_fell = (
508 base_total > 0
509 and covered * base_total < base_covered * total
510 and not _only_lost_code(now, base)
511 )
512 return debt_grew or ratio_fell
513
514
515def _tree_value(value: str) -> tuple[str, tuple[int, ...] | str] | None:
516 """Parse one compact tree-coverage ceiling value."""
517 if value.startswith("U:") and value.removeprefix("U:") in KNOWN_TREE_REASONS:
518 return "U", value.removeprefix("U:")
519 if value.startswith("M:"):
520 fields = value.removeprefix("M:").split(",")
521 if len(fields) == TREE_METRIC_FIELDS and all(field.isdecimal() for field in fields):
522 counts = tuple(int(field) for field in fields)
523 if counts[0] <= counts[1] and counts[2] <= counts[3]:
524 return "M", counts
525 return None
526
527
528def _tree_regressed(candidate: str, audited: str) -> bool:
529 """Return whether a candidate tree row weakens its audited per-metric floor."""
530 now = _tree_value(candidate)
531 base = _tree_value(audited)
532 regressed = now is None or base is None
533 if now is not None and base is not None:
534 now_kind, now_value = now
535 base_kind, base_value = base
536 if base_kind == "U" and now_kind == "U":
537 regressed = now_value != base_value
538 elif base_kind == "U" and isinstance(now_value, tuple):
539 line_ok = now_value[1] == 0 or now_value[0] * 100 >= now_value[1] * 90
540 branch_ok = now_value[3] == 0 or now_value[2] * 100 >= now_value[3] * 80
541 regressed = not (line_ok and branch_ok)
542 elif (
543 base_kind == "M"
544 and now_kind == "M"
545 and isinstance(now_value, tuple)
546 and isinstance(base_value, tuple)
547 ):
548 regressed = _metric_regressed(now_value[:2], base_value[:2]) or _metric_regressed(
549 now_value[2:], base_value[2:]
550 )
551 else:
552 regressed = True
553 return regressed
554
555
556def _ceiling_regressed(spec: BaselineSpec, candidate: str, audited: str) -> bool:
557 """Compare one candidate value with its immutable per-key ceiling."""
558 if spec.ceiling_shape == "count":
559 return not candidate.isdecimal() or not audited.isdecimal() or int(candidate) > int(audited)
560 if spec.ceiling_shape == "presence":
561 return candidate != "present" or audited != "present"
562 if spec.ceiling_shape == "percentage":
563 return PERCENT_RE.fullmatch(candidate) is None or float(candidate) < float(audited)
564 if spec.ceiling_shape == "matrix-verdict":
565 return candidate != audited
566 if spec.ceiling_shape == "tree-coverage":
567 return _tree_regressed(candidate, audited)
568 return True
569
570
571def ceiling_snapshot(records: list[Suppression]) -> CeilingLedger:
572 """Return the immutable semantic-key ceiling map represented by records."""
573 return {
574 _record_evidence(record, "ceiling-key:"): (
575 record.path,
576 _record_evidence(record, "ceiling-value:"),
577 )
578 for record in records
579 }
580
581
582def _parse_ceiling_ledger(text: str) -> tuple[CeilingLedger, list[str]]:
583 """Parse the exact committed ceiling-ledger grammar."""
584 lines = text.splitlines()
585 errors: list[str] = []
586 if tuple(lines[: len(CEILING_LEDGER_HEADER)]) != CEILING_LEDGER_HEADER:
587 errors.append("header does not match the authenticated v1 ledger header")
588 ledger: CeilingLedger = {}
589 for line_no, raw in enumerate(lines[len(CEILING_LEDGER_HEADER) :], start=5):
590 if not raw:
591 continue
592 fields = raw.split("\t")
593 if len(fields) != CEILING_LEDGER_COLUMNS:
594 errors.append(f"line {line_no}: expected baseline<TAB>sha256<TAB>ceiling")
595 continue
596 baseline, digest, value = fields
597 if baseline not in SPECS or SHA256_RE.fullmatch(digest) is None or not value:
598 errors.append(f"line {line_no}: invalid baseline, digest, or ceiling")
599 continue
600 if digest in ledger:
601 errors.append(f"line {line_no}: duplicate digest {digest}")
602 continue
603 ledger[digest] = (baseline, value)
604 return ledger, errors
605
606
607def _load_ceiling_ledger(root: Path) -> tuple[CeilingLedger, list[Finding]]:
608 """Load and authenticate the committed per-key ceiling ledger."""
609 path = root / CEILING_LEDGER_PATH
610 if not path.is_file():
611 return {}, [Finding("missing-baseline-ceilings", CEILING_LEDGER_PATH)]
612 raw = path.read_bytes()
613 digest = hashlib.sha256(raw).hexdigest()
614 findings: list[Finding] = []
615 if digest != CEILING_LEDGER_SHA256:
616 findings.append(
617 Finding(
618 "baseline-ceiling-integrity",
619 f"ledger sha256 {digest} does not match {CEILING_LEDGER_SHA256}",
620 CEILING_LEDGER_PATH,
621 )
622 )
623 try:
624 text = raw.decode("ascii")
625 except UnicodeDecodeError as error:
626 decode_finding = Finding("baseline-ceiling-integrity", str(error), CEILING_LEDGER_PATH)
627 return {}, [*findings, decode_finding]
628 ledger, errors = _parse_ceiling_ledger(text)
629 findings.extend(
630 Finding("baseline-ceiling-integrity", message, CEILING_LEDGER_PATH) for message in errors
631 )
632 return ledger, findings
633
634
635def _validate_ceiling(
636 baseline: str,
637 spec: BaselineSpec,
638 record: Suppression,
639 ledger: CeilingLedger,
640 findings: list[Finding],
641) -> None:
642 """Reject a new semantic bucket or a weaker value for an audited bucket."""
643 digest = _record_evidence(record, "ceiling-key:")
644 candidate = _record_evidence(record, "ceiling-value:")
645 entry = ledger.get(digest)
646 if entry is None:
647 findings.append(
648 Finding("baseline-growth", "new consumer-semantic debt bucket", baseline, record.line)
649 )
650 return
651 owner, audited = entry
652 if owner != baseline or _ceiling_regressed(spec, candidate, audited):
653 message = f"bucket ceiling {audited!r} weakened to {candidate!r}"
654 findings.append(Finding("baseline-growth", message, baseline, record.line))
655
656
657def _accept_record(record: Suppression, context: ValidationContext) -> None:
658 """Apply semantic duplicate, target, ceiling, and percentage validation."""
659 root, baseline, spec, keys, ledger, findings = context
660 key = _record_evidence(record, "ceiling-key:")
661 prior = keys.get(key)
662 if prior is not None:
663 message = f"consumer-semantic key duplicates line {prior}"
664 findings.append(Finding("duplicate-baseline-row", message, baseline, record.line))
665 else:
666 keys[key] = record.line
667 _validate_target(root, baseline, record, findings)
668 if ledger is not None:
669 _validate_ceiling(baseline, spec, record, ledger, findings)
670 minimum = BASELINE_PERCENTAGE_FLOORS.get(baseline)
671 value = _record_evidence(record, "ceiling-value:")
672 if minimum is not None and float(value) < minimum:
673 message = f"{value}% is below the audited {minimum:.2f}% floor"
674 findings.append(Finding("baseline-growth", message, baseline, record.line))
675
676
677def _parse_file(
678 root: Path, baseline: str, spec: BaselineSpec, ledger: CeilingLedger | None
679) -> tuple[list[Suppression], list[Finding]]:
680 """Parse and structurally validate one committed baseline."""
681 text = (root / baseline).read_text(encoding="ascii")
682 if spec.shape == "freestanding-apps":
683 return _parse_freestanding_file(root, baseline, spec, ledger)
684 records: list[Suppression] = []
685 findings: list[Finding] = []
686 keys: dict[str, int] = {}
687 parser = PARSERS[spec.shape]
688 exact_lines = frozenset(text.splitlines())
689 findings.extend(
690 Finding("missing-baseline-provenance", repr(anchor), baseline)
691 for anchor in spec.provenance_anchors
692 if anchor not in exact_lines
693 )
694 for line_no, raw in enumerate(text.splitlines(), start=1):
695 if not raw.strip() or raw.lstrip().startswith("#"):
696 continue
697 record, error = parser(baseline, line_no, raw, spec)
698 if record is None:
699 findings.append(Finding("malformed-baseline-row", error, baseline, line_no))
700 continue
701 _accept_record(record, (root, baseline, spec, keys, ledger, findings))
702 records.append(record)
703 findings.extend(_check_file_totals(baseline, spec, records, text))
704 return records, findings
705
706
707def _check_file_totals(
708 baseline: str, spec: BaselineSpec, records: list[Suppression], text: str
709) -> list[Finding]:
710 """Enforce audited row/unit ceilings and declared totals for one baseline."""
711 findings: list[Finding] = []
712 row_ceiling, unit_ceiling = BASELINE_CEILINGS[baseline]
713 units = sum(item.match_count for item in records)
714 if len(records) > row_ceiling or units > unit_ceiling:
715 message = (
716 f"parsed {len(records)} row(s)/{units} debt unit(s); "
717 f"audited ceilings are {row_ceiling}/{unit_ceiling}"
718 )
719 findings.append(Finding("baseline-growth", message, baseline))
720 if spec.declared_pattern:
721 match = re.search(spec.declared_pattern, text, re.IGNORECASE)
722 if match is None:
723 findings.append(Finding("missing-baseline-total", spec.declared_pattern, baseline))
724 else:
725 declared = int(match.group(1))
726 actual = (
727 sum(item.match_count for item in records) if spec.declared_is_sum else len(records)
728 )
729 if declared != actual:
730 message = f"declares {declared}, parsed {actual}"
731 findings.append(Finding("stale-baseline-total", message, baseline))
732 return findings
733
734
735def _parse_freestanding_file(
736 root: Path, baseline: str, spec: BaselineSpec, ledger: CeilingLedger | None
737) -> tuple[list[Suppression], list[Finding]]:
738 """Parse the freestanding JSON ratchet through the shared validation path."""
739 text = (root / baseline).read_text(encoding="ascii")
740 records: list[Suppression] = []
741 findings: list[Finding] = []
742 keys: dict[str, int] = {}
743 rows, error = _freestanding_rows(text)
744 if error:
745 return [], [Finding("malformed-baseline-row", error, baseline)]
746 for debt, line_no in rows:
747 record = _record(baseline, line_no, spec, debt)
748 _accept_record(record, (root, baseline, spec, keys, ledger, findings))
749 records.append(record)
750 findings.extend(_check_file_totals(baseline, spec, records, text))
751 return records, findings
752
753
754def scan_baseline_repository(
755 root: Path,
756 paths: list[str],
757 *,
758 enforce_floors: bool,
759 ceiling_ledger: CeilingLedger | None = None,
760) -> tuple[list[Suppression], list[Finding]]:
761 """Discover, parse, and inventory all authoritative ratchet baselines."""
762 authorities = sorted(
763 rel
764 for rel in paths
765 if rel.startswith(".github/")
766 and re.search(r"baseline|ratchet", PurePosixPath(rel).name, re.IGNORECASE)
767 )
768 discovered = [rel for rel in authorities if rel in SPECS]
769 records: list[Suppression] = []
770 findings = [
771 Finding("unknown-baseline-file", authority, authority)
772 for authority in authorities
773 if authority not in SPECS
774 ]
775 ledger = ceiling_ledger
776 if enforce_floors and ledger is None:
777 ledger, ledger_findings = _load_ceiling_ledger(root)
778 findings.extend(ledger_findings)
779 for baseline in discovered:
780 spec = SPECS[baseline]
781 if not (root / spec.consumer).is_file():
782 findings.append(Finding("missing-baseline-consumer", spec.consumer, baseline))
783 parsed, errors = _parse_file(root, baseline, spec, ledger)
784 records.extend(parsed)
785 findings.extend(errors)
786 if enforce_floors:
787 findings.extend(
788 Finding("missing-baseline-file", baseline, baseline)
789 for baseline in sorted(set(SPECS) - set(discovered))
790 )
791 if len(discovered) < MIN_BASELINE_FILES:
792 message = f"only {len(discovered)} files; floor is {MIN_BASELINE_FILES}"
793 findings.append(Finding("vacuous-baseline-files", message))
794 if len(records) < MIN_BASELINE_ROWS:
795 message = f"only {len(records)} rows; audited floor is {MIN_BASELINE_ROWS}"
796 findings.append(Finding("vacuous-baseline-rows", message))
797 return records, findings