3"""Structural validation for normalized suppression inventory rows."""
5from __future__
import annotations
8from collections
import Counter
9from pathlib
import Path
11from suppression_model
import Finding, Inventory, Suppression
14def deduplicate(inventory: Inventory) ->
None:
15 """Add findings for repeated rows without hiding either inventory record."""
17 (item.path, 0, 0, item.family, item.rule, item.scope)
18 if item.provenance ==
"central-list"
19 else item.duplicate_key()
20 for item
in inventory.suppressions
22 counts = Counter(keys)
23 for key, count
in sorted(counts.items()):
25 path, line, _column, family, rule, scope = key
26 message = f
"{count} identical {family}/{rule}/{scope} rows"
27 inventory.findings.append(Finding(
"duplicate-directive", message, path, line))
30def validate_fingerprints(inventory: Inventory) ->
None:
31 """Reject ambiguous public row identities before rendering inventory."""
32 counts = Counter(item.fingerprint
for item
in inventory.suppressions)
33 for fingerprint, count
in sorted(counts.items()):
35 message = f
"{count} rows share fingerprint {fingerprint}"
36 inventory.findings.append(Finding(
"duplicate-fingerprint", message))
39def validate_cppcheck_anchors(inventory: Inventory, root: Path) ->
None:
40 """Reject literal cppcheck line anchors that no longer name source."""
41 for item
in inventory.suppressions:
42 if item.provenance !=
"central-list":
44 match = re.fullmatch(
r"(?P<path>.+):(?P<line>[1-9][0-9]*)", item.scope)
47 rel = match.group(
"path")
48 if any(char
in rel
for char
in "*?["):
52 line_count = len(target.read_text(encoding=
"utf-8", errors=
"replace").splitlines())
54 message = f
"{item.scope} targets a missing source file"
55 inventory.findings.append(
56 Finding(
"dead-cppcheck-anchor", message, item.path, item.line)
59 source_line = int(match.group(
"line"))
60 if source_line > line_count:
61 message = f
"{item.scope} is past EOF ({line_count} lines)"
62 inventory.findings.append(
63 Finding(
"dead-cppcheck-anchor", message, item.path, item.line)
67def _region_identity(family: str, rules: frozenset[str]) -> tuple[str, ...]:
68 """Normalize only directional coverage suffixes for region pairing."""
69 if family ==
"coverage":
70 normalized = {re.sub(
r"_(?:START|STOP)$",
"", rule)
for rule
in rules}
71 return family, *sorted(normalized)
72 return family, *sorted(rules)
75def _regions_compatible(
76 family: str, start_rules: frozenset[str], end_rules: frozenset[str]
78 """Return whether one logical end closes one logical region start."""
79 return _region_identity(family, start_rules) == _region_identity(family, end_rules)
82def validate_regions(inventory: Inventory) ->
None:
83 """Reject invalid regions while honoring markdownlint state snapshots."""
84 groups: dict[tuple[str, int, int, str, str, str], list[Suppression]] = {}
85 for item
in inventory.suppressions:
86 if item.owner ==
"vendor" or item.provenance
not in {
91 if not item.scope.startswith((
"region-",
"state-")):
93 key = (item.path, item.line, item.column, item.family, item.tool, item.scope)
94 groups.setdefault(key, []).append(item)
95 stack_type = list[tuple[Suppression, frozenset[str]]]
96 stacks: dict[tuple[str, str, str], stack_type] = {}
97 snapshots: dict[tuple[str, str, str], stack_type] = {}
98 for group_key
in sorted(groups):
99 items = groups[group_key]
101 rules = frozenset(member.rule
for member
in items)
102 key = (item.path, item.family, item.tool)
103 stack = stacks.setdefault(key, [])
104 if item.scope ==
"state-capture":
105 snapshots[key] = list(stack)
106 elif item.scope ==
"state-restore":
107 stack[:] = snapshots.get(key, [])
108 elif item.scope ==
"region-start":
109 stack.append((item, rules))
111 inventory.findings.append(
113 "unmatched-region-end",
114 f
"{item.family}/{sorted(rules)}",
120 start, start_rules = stack.pop()
121 if not _regions_compatible(item.family, start_rules, rules):
122 message = f
"{sorted(start_rules)} at line {start.line} closed by {sorted(rules)}"
123 inventory.findings.append(
124 Finding(
"mismatched-region", message, item.path, item.line)
126 for stack
in stacks.values():
127 for item, rules
in stack:
128 message = f
"{item.family}/{sorted(rules)} has no region end"
129 inventory.findings.append(
130 Finding(
"unmatched-region-start", message, item.path, item.line)