3"""Durable two-part identity for suppression inventory rows.
5Line and column are display coordinates, not identity. Every row carries:
7* ``site_id`` -- a full SHA-256 naming the occurrence. It hashes the path,
8 family, tool, rule, directive, a line-normalized scope, the normalized
9 suppressed construct (the source line's collapsed text with an attached
10 rationale removed, or structural evidence for repository-bound rows), and a
12 that separates otherwise identical repeated constructs. Inserting lines
13 above a site or re-indenting it does not change its ``site_id``; changing
15* ``binding_sha256`` -- a full SHA-256 over the canonical JSON of everything
16 a reviewer approved: path, family, tool, rule, directive, raw scope,
17 reason, provenance, owner, concerns, normalized evidence, match count,
18 recommendation, and the anchor. Any reason, target, rule, scope, owner,
19 evidence, count, or construct change invalidates the binding while the
23from __future__
import annotations
28from dataclasses
import replace
29from pathlib
import Path
31from suppression_model
import Finding, Inventory, Suppression
33IDENTITY_SCHEMA_VERSION =
"2-durable-site-identity"
34_LINE_REF_RE = re.compile(
r"^decision-line:\d+$")
35_LINE_REF_SUFFIX_RE = re.compile(
36 r"^(?P<head>decision-line):\d+$|^(?P<blob>blob):sha256:[0-9a-f]{64}$"
38_WS_RE = re.compile(
r"\s+")
39STRUCTURAL_PROVENANCE_PREFIXES = (
41 "module-ast-authority",
42 "checker-control-plane",
49def _normalized_scope(scope: str) -> str:
50 """Strip line references and content digests from a scope label.
52 The stripped detail stays in the binding: a moved decision line keeps its
53 site while a changed vendored blob breaks its reviewed binding.
55 match = _LINE_REF_SUFFIX_RE.match(scope)
58 return match.group(
"head")
or match.group(
"blob")
61def _normalized_evidence(evidence: tuple[str, ...]) -> tuple[str, ...]:
62 """Drop display-derived line references from structural evidence."""
63 return tuple(item
for item
in evidence
if not _LINE_REF_RE.match(item))
66def _structural_anchor(row: Suppression) -> str |
None:
67 """Return a structural anchor for rows not located by a source line."""
68 if row.provenance.split(
":")[0]
in STRUCTURAL_PROVENANCE_PREFIXES:
69 parts = _normalized_evidence(row.evidence)
70 return "|".join(parts)
if parts
else _normalized_scope(row.scope)
74def _line_anchor(row: Suppression, lines: list[str]) -> str:
75 """Return the construct without its review rationale."""
76 if not 1 <= row.line <= len(lines):
78 source = lines[row.line - 1]
80 reason_at = source.rfind(row.reason, max(row.column - 1, 0))
82 source = source[:reason_at].rstrip()
83 return _WS_RE.sub(
" ", source).strip()
86def compute_anchor(row: Suppression, lines: list[str]) -> str:
87 """Return the normalized suppressed construct for one row."""
88 structural = _structural_anchor(row)
89 if structural
is not None:
91 return _line_anchor(row, lines)
94def _site_payload(row: Suppression, anchor: str, ordinal: int) -> bytes:
95 """Serialize the durable occurrence identity inputs."""
97 IDENTITY_SCHEMA_VERSION,
103 _normalized_scope(row.scope),
107 return "\0".join(fields).encode(
"utf-8")
110def binding_payload(row: Suppression, anchor: str) -> bytes:
111 """Serialize the exact reviewed content of one row."""
114 "concerns": sorted(row.concerns),
115 "directive": row.directive,
116 "evidence": list(_normalized_evidence(row.evidence)),
117 "family": row.family,
118 "match_count": row.match_count,
121 "provenance": row.provenance,
122 "reason": row.reason,
123 "recommendation": row.recommendation,
128 return json.dumps(payload, sort_keys=
True, separators=(
",",
":")).encode(
"utf-8")
131def assign_identities(inventory: Inventory, root: Path) ->
None:
132 """Attach ``site_id`` and ``binding_sha256`` to every inventory row."""
133 text_cache: dict[str, list[str]] = {}
134 anchored: list[tuple[Suppression, str]] = []
135 for row
in inventory.suppressions:
136 if row.path
not in text_cache:
138 text_cache[row.path] = (
139 (root / row.path).read_text(encoding=
"utf-8", errors=
"replace").splitlines()
142 text_cache[row.path] = []
143 anchored.append((row, compute_anchor(row, text_cache[row.path])))
144 groups: dict[bytes, list[int]] = {}
145 for index, (row, anchor)
in enumerate(anchored):
146 groups.setdefault(_site_payload(row, anchor, 0), []).append(index)
147 site_ids: dict[int, str] = {}
148 for members
in groups.values():
149 members.sort(key=
lambda index: (anchored[index][0].line, anchored[index][0].column))
150 for ordinal, index
in enumerate(members):
151 row, anchor = anchored[index]
152 site_ids[index] = hashlib.sha256(_site_payload(row, anchor, ordinal)).hexdigest()
153 seen: dict[str, int] = {}
154 for index, (row, anchor)
in enumerate(anchored):
155 site_id = site_ids[index]
157 inventory.findings.append(
159 "duplicate-site-identity",
160 f
"site {site_id} names two rows",
165 seen[site_id] = index
166 binding = hashlib.sha256(binding_payload(row, anchor)).hexdigest()
167 inventory.suppressions[index] = replace(
168 row, site_id=site_id, binding_sha256=binding, anchor=anchor