@@ -77,10 +77,22 @@ class Absence:
7777 absence claims survived for weeks. So an absence claim is only admissible with a
7878 ``positive_control`` that must still match; if the control goes quiet the search has gone blind and
7979 the claim is void, regardless of what the pattern returns.
80+
81+ ``mutation`` closes the hole the control does not: it is the realistic REINTRODUCTION this pattern
82+ claims to exclude, and the pattern must actually fire on it. A whole chapter of claims was once
83+ authored as prose narrations of shell commands — ``"rg -n 'tar.extractall' -> exit 1 (zero hits)"``
84+ where the field wanted ``tar\\ .extractall\\ (``. The field's type is ``str`` and prose is a valid
85+ ``str``, so the shape permitted it and only a detector caught it. Requiring the pattern to match a
86+ stated reintroduction makes prose *unwritable* rather than merely detectable.
87+
88+ Do NOT derive ``mutation`` from ``pattern``. A value generated from the thing it validates
89+ satisfies the check by construction, which would make this the most authoritative-looking vacuous
90+ gate in the file — the same defect class it exists to close, arriving through the fix.
8091 """
8192
8293 pattern : str
8394 positive_control : str
95+ mutation : str
8496
8597
8698@dataclass (frozen = True )
@@ -171,6 +183,21 @@ def load_scorecard(path: Path) -> list[Cell]:
171183 "recording the reason for non-applicability is the one MUST in ASVS 5.0's assessment "
172184 "chapter (docs/ASVS-ASSESSMENT-METHOD.md §1)"
173185 )
186+ for a in raw .get ("absence" , []):
187+ if not str (a .get ("mutation" , "" )).strip ():
188+ raise ScorecardError (
189+ # NO LITERAL CODE EXAMPLE HERE. This module is inside the corpus that absence
190+ # patterns are searched over, so an illustrative call in this string becomes a
191+ # real corpus hit and reads as FALSE. The first draft used one and broke two
192+ # live claims (5.2.5, 5.3.3) the moment they were backfilled: the guidance for
193+ # a check contaminated the check. The Absence docstring carries the example in
194+ # escaped-regex form, which cannot self-match.
195+ f"cell { raw .get ('id' )!r} : absence claim { a .get ('pattern' )!r} has no `mutation` — "
196+ "state the realistic reintroduction this pattern excludes, so the pattern can "
197+ "be proved capable of firing. Author it from what the code would look like if "
198+ "the thing came back; do NOT derive it from the pattern, which makes the check "
199+ "vacuous. See the Absence docstring for a worked example"
200+ )
174201 cells .append (
175202 Cell (
176203 id = str (raw ["id" ]),
@@ -186,7 +213,13 @@ def load_scorecard(path: Path) -> list[Cell]:
186213 for e in raw .get ("evidence" , [])
187214 ),
188215 absence = tuple (
189- Absence (pattern = str (a ["pattern" ]), positive_control = str (a ["positive_control" ]))
216+ Absence (
217+ pattern = str (a ["pattern" ]),
218+ positive_control = str (a ["positive_control" ]),
219+ # No default. A missing mutation must be authored, not inferred — see the
220+ # Absence docstring on why deriving one from the pattern is worse than none.
221+ mutation = str (a ["mutation" ]),
222+ )
190223 for a in raw .get ("absence" , [])
191224 ),
192225 )
@@ -231,25 +264,65 @@ def check_completeness(cells: list[Cell], corpus: dict[str, int]) -> list[str]:
231264 want = corpus .get (c .id )
232265 if want is not None and c .level != want :
233266 problems .append (f"{ c .id } : level { c .level } but the corpus says L{ want } " )
267+
268+ # A DECIDED verdict with no evidence at all is the conflation this whole tool exists to prevent:
269+ # a guess wearing a verdict's clothes. The method is explicit — every non-`unverified` cell carries
270+ # at least one anchor — but nothing enforced it, because `check_anchors` iterates the evidence a
271+ # cell HAS. A cell with none is not checked and fails nothing; the gate could only ever validate
272+ # evidence that existed, never assert that it must. Measured when this landed: 14 of 59 decided
273+ # cells carried zero anchors AND zero absence claims, several inherited from the prose lineage
274+ # where the verdict was reached but never anchored.
275+ unevidenced = sorted (
276+ (c .id for c in cells if c .verdict in DECIDED_VERDICTS and not c .evidence and not c .absence ),
277+ key = _sort_key ,
278+ )
279+ if unevidenced :
280+ problems .append (
281+ f"evidence: { len (unevidenced )} decided cell(s) carry NO anchor and NO absence claim, so "
282+ "nothing about them is verified — either anchor them or return them to `unverified`, "
283+ f"which is what an unevidenced verdict actually is: { ', ' .join (unevidenced )} "
284+ )
234285 return problems
235286
236287
237288def check_anchors (cells : list [Cell ], root : Path , findings : Findings ) -> None :
238- """Open every evidence anchor and assert its token still resolves.
289+ """Open every evidence anchor and assert its token still resolves, and resolves UNAMBIGUOUSLY .
239290
240291 When the code moves, this reds a test — instead of the sentence rotting in place and the next
241292 session funding work that is already done.
293+
294+ **Uniqueness is not pedantry; it is what makes the resolution mean anything.** An ``expect`` that
295+ occurs many times in its file resolves from almost anywhere: with ``await conn.rollback()``
296+ appearing 101 times in one module, *any* line number in that file lands within ±40 of some
297+ occurrence, so the anchor cannot fail and certifies nothing. Two such anchors sat in this scorecard
298+ as evidence for weeks.
299+
300+ It also closes a defect in the REPAIR path rather than the detection path. When code moves, the
301+ check correctly reports it — but a re-anchor to the nearest occurrence can silently install a
302+ *stale-but-resolving* anchor that passes forever. That happened live: after ADR 0154 landed,
303+ ``UPDATE sessions SET revoked_at=`` had two occurrences 19 lines apart — one the keep-N revoke, one
304+ a different method entirely — each inside the other's window, so the check would have accepted the
305+ wrong one. A repair is exactly where suspicion lapses, because the tool has just proved it works.
242306 """
243307 for c in cells :
244308 for a in c .evidence :
245309 target = root / a .path
246310 if not target .is_file ():
247311 findings .problems .append (f"{ c .id } : evidence path { a .path } does not exist" )
248312 continue
249- lines = target .read_text (encoding = "utf-8" , errors = "replace" ).splitlines ()
313+ text = target .read_text (encoding = "utf-8" , errors = "replace" )
314+ lines = text .splitlines ()
250315 lo = max (0 , a .line - 1 - ANCHOR_WINDOW )
251316 hi = min (len (lines ), a .line + ANCHOR_WINDOW )
252317 findings .checked_anchors += 1
318+ occurrences = text .count (a .expect )
319+ if occurrences > 1 :
320+ findings .problems .append (
321+ f"{ c .id } : { a .path } :{ a .line } anchor is AMBIGUOUS — { a .expect !r} occurs "
322+ f"{ occurrences } times in the file, so the line number is not load-bearing and a "
323+ "re-anchor cannot be checked. Cite a longer token that appears exactly once"
324+ )
325+ continue
253326 if a .expect in "\n " .join (lines [lo :hi ]):
254327 continue
255328 where = " (found elsewhere in the file)" if a .expect in "\n " .join (lines ) else ""
@@ -265,6 +338,14 @@ def check_absences(cells: list[Cell], root: Path, findings: Findings) -> None:
265338 for c in cells :
266339 for a in c .absence :
267340 findings .checked_absences += 1
341+ # Before asking what the corpus says, ask whether the pattern is a pattern at all. A prose
342+ # narration greps to nothing and is indistinguishable from a true absence.
343+ if not re .search (a .pattern , a .mutation ):
344+ findings .problems .append (
345+ f"{ c .id } : absence claim is INERT — { a .pattern !r} does not match its own stated "
346+ f"reintroduction { a .mutation !r} , so it would stay quiet if the thing came back"
347+ )
348+ continue
268349 control = _grep_count (a .positive_control , corpus_files )
269350 if control == 0 :
270351 findings .problems .append (
@@ -328,6 +409,17 @@ def check_pinning(scorecard: Path, corpus: Path) -> list[str]:
328409 "pinning: [scorecard].asvs_version is missing — bare requirement ids re-point across "
329410 "ASVS versions, so the scorecard must say which version its ids mean"
330411 )
412+ anchor = str (meta .get ("anchor_commit" , "" )).strip ()
413+ # actions/checkout resolves `ref:` as a BRANCH OR TAG name unless it is a full 40-char hash, so an
414+ # abbreviated anchor fails with "A branch or tag with the name ... could not be found" -- a gate
415+ # failing for a reason with nothing to do with what it measures. Caught in CI twice; refused here.
416+ if anchor and not re .fullmatch (r"[0-9a-f]{40}" , anchor ):
417+ problems .append (
418+ f"pinning: [scorecard].anchor_commit { anchor !r} must be a FULL 40-character SHA -- an "
419+ "abbreviated hash is not resolvable by actions/checkout, which treats a short ref as a "
420+ "branch or tag name"
421+ )
422+
331423 declared = str (meta .get ("corpus_sha256" , "" )).strip ()
332424 actual = corpus_digest (corpus )
333425 if not declared :
0 commit comments