Skip to content

[docs] Record as ADR 0002: a guard must assert the SIZE of what it measured — the three that lied are exactly the three with no denominator assertion #559

Description

@serge-ivo

The three were not a coincidence, and the common cause is one line long

#426's overflow guard, runnerPanel.ts's tile invariant (#531) and jsx-tags.ts (#536) failed the same way — green while measuring a subset. I audited every scripts/check-*.mjs and every invariant test in the repo looking for a shared helper or a shared idiom, and the shared thing is not code. It is an absent assertion:

Every guard in this repo asserts its NUMERATOR — "the offender list is empty", "the count equals the pin". The ones that were caught lying are exactly the ones that never assert their DENOMINATOR — how many candidates they examined.

That is checkable, and I checked it both ways.

Guards that assert their denominator (none has been caught under-measuring):

guard the assertion
scripts/check-design-tokens.mjs:99 declared.size < 10"the @theme block moved or changed shape, and this guard would pass by checking nothing"
scripts/check-test-isolation.mjs:131 isolatedCount === 0"green, and running none of the hard tests"
scripts/check-migrations.mjs:366 --require-history"a history-dependent check that degrades to a silent no-op is worse than no check"
scripts/check-qa-config.mjs:47 ignore.length === 0"it is doing nothing"
workers/api/src/lib/security-invariants.test.ts:104 "finds the surfaces that verify inline at all" — plus five more toBeGreaterThan(0) pattern-still-matches checks (:251, :303, :370, :459, :500)
workers/api/src/lib/tool-reachability.test.ts:219 "reads the migrations it claims to read" — file count, parsed-name count, and one known declaration per parser shape
packages/sdk/src/safe-html-guard.test.ts:112 ALL.length > 100 && sinkHits.length > 0
workers/api/src/lib/coder2-parity.test.ts:685 checked >= 3"a cohesion rule that matched no agent would pass forever while meaning nothing"
scripts/lib/adr.mjs:45 throws rather than returning []"'no rules parsed' is indistinguishable from 'every rule covered' unless someone asserts otherwise"
store/console/src/pages/mute-touch-invariant.test.ts:191 discovers the surfaces and compares the set, rather than trusting a list

Guards that do not, and the two that were caught:

The rule generalises past lexers, which is why it is worth writing down rather than fixing three files: a directory walk blinded by an exclusion, a git log blinded by a shallow clone, and a regex blinded by a rename all present identically — a confident number over a set nobody sized.

The second half: a scanner that gives up must say so

The same idea one level down. store/console/src/lib/jsx-tags.ts:72:

if (j >= source.length) continue;

A tag whose end was never found is dropped in silence. That single continue is what turned a lexer bug into an under-count instead of a crash — had it counted the drops and had the test asserted zero, #536 would have failed on the day the first apostrophe landed rather than being noticed months later by a different lane. scripts/lib/bare-catch.mjs:118 (if (close === -1) continue;) has the same shape.

The good ones already do the opposite, and say why:

  • workers/api/src/lib/tool-reachability.test.ts:158catch { // A blob this cannot parse is not silently skipped — the count assertion below fails. }
  • workers/api/src/lib/coder2-parity.test.ts:56throw new Error(...) when no migration matches
  • workers/api/src/lib/pipelines/seed-drift.test.ts:27expect(start).toBeGreaterThan(-1) before slicing

And a third finding, which is the reason to standardise rather than patch

Eight independent implementations of "strip JS source so a scanner can match it" ship in this repo, at four different fidelities:

# implementation handles
1 workers/api/src/lib/source-guard.ts:37 comments, quotes, templates with ${…} preserved as code, regex, escapes — the reference
2 workers/api/src/lib/prompt-claims.ts:319 promptTextOf the deliberate inverse (keeps literal text, drops code); templates handled
3 scripts/lib/bare-catch.mjs:18 comments, quotes, backticks — but blanks the whole template span, so a catch {} inside ${…} is invisible
4 store/console/src/lib/jsx-tags.ts:42 + the quote tracker at :59 line-start comments only; no backtick, no escapes#536
5 scripts/lib/design-tokens.mjs:122 comments only, // not preceded by :
6 workers/api/src/lib/usage-aggregates.ts:73 comments only
7 workers/api/src/lib/usage-claims.test.ts:65 comments only, incl. SQL -- — a second copy inside the same feature area as #6
8 store/console/src/pages/mute-touch-invariant.test.ts:97 comments + all three quote kinds, regex-based

Only #1 and #2 are separately unit-tested as lexers. Each of the others is one legal character away from measuring less than it prints, and nothing in the output distinguishes the fidelities: all eight produce a confident number.

Measured impact today: #4 loses 33 tags across 2 of 107 .tsx files and makes one pinned count wrong (#536, comment there has the numbers). #3's template gap costs nothing — I ran bareCatches over all eight pinned trees with #1's stripper substituted and both agree at zero everywhere, and the four unpinned trees are also at zero. #5#8 are comment-only strippers whose subjects (class names, SQL comments, JSX guards) do not span template interpolations.

What to do

  1. Write the ADR (full text below), so the next guard is written with a denominator from the start. This is the deliverable; the rest is follow-up.
  2. Give scanTags a drop counter as part of [bug] jsx-tags.ts does not treat a backtick as a delimiter, so one apostrophe in a template literal drops a whole tag from two source guards #536, and have control-shapes.test.ts / control-labels.test.ts assert it is zero and that the tag/control totals are within a stated range. That converts the class of bug from "quietly smaller" to "loud".
  3. Consolidate the lexers where the fidelity actually matters. [codex] Add PAGS browser runner runtime #1 is the reference and is already .ts in workers/api; the console cannot import across that boundary, so the honest options are (a) move a lexer to packages/sdk where both trees can consume it, or (b) leave the copies and require each to carry its own unit test naming what it does NOT handle. I would take (b) first — it is cheap, it is the discipline scripts/lib/bare-catch.mjs's header already claims ("a regex-shaped guard nobody tests is one edit away from silently passing"), and (a) is a package-boundary change that should not ride on a bug fix. Refactor: split console-instances.js (957 lines) into -core + -board #7 and Server-side deployment notifications (deploy succeeded/failed → push), not polling-only #6 should merge regardless; two comment strippers for one feature is not a boundary, it is a copy.
  4. Print the denominator in every guard's success line. ✓ store/console: 43 hand-authored control shapes over 5473 tags in 69 files costs one interpolation and makes the next under-count visible in a passing build. check-file-size.mjs:1042 and check-test-isolation.mjs:140 already do this; most do not.

Alternatives considered and rejected

Acceptance criteria

  1. platform/docs/adr/0002-a-guard-states-what-it-measured.md exists with the text below, and docs/adr/README.md's index gains its row.
  2. Its rules parse with scripts/lib/adr.mjs — ids in the **G1 — … form the regex at adr.mjs:30 requires — so a later test can read them the way ADR 0001's two tests do. (No new test is required by this issue; the format is so that one is possible without editing the parser.)
  3. The ADR is linked from scripts/lib/bare-catch.mjs and store/console/src/lib/jsx-tags.ts, one hop from where it would be broken — ADR 0001's third enforcement item, and the thing mute-touch-invariant.test.ts:299 asserts for its own.

Regression risk

None to running code. The risk is the ADR being read as "add a toBeGreaterThan to everything": G2 below is deliberately written as the guard states the size of the set it examined, not the guard contains an assertion of shape X, and the Consequences section says what it costs when the denominator is genuinely unstable.


Proposed text — platform/docs/adr/0002-a-guard-states-what-it-measured.md

# ADR 0002 — A guard states the size of what it measured

**Status:** Accepted (2026-08-13) · **Owner decision** · Supersedes nothing.

## Context

This repository defends itself with guards: eight `scripts/check-*.mjs` wired into `ci.yml`, and
around fifteen invariant tests that sweep source trees, migrations and documents. Almost every one
exists because someone was surprised once, and the good ones carry the incident in their header.

On 2026-08-12 three of them were found to be measuring less than they asserted, within a single day:

  * the **#426 mobile-overflow guard** — with both the phone and desktop layouts in the DOM, its
    aria-label query matched a `display:none` element with a zero rect and a computed opacity of 1.
    It passed while measuring nothing. Fixed under #514 by tagging every action control with
    `data-msg-action` and measuring what is painted.
  * **`store/console/src/lib/runnerPanel.ts`'s tile invariant** — its own header promised the tile
    was "derived from BOTH readings, so a disagreement is a test failure rather than a screenshot",
    but `machinesToShow` merged them only for the synthesised tile and the test iterated only the
    pinned node. A non-pinned node with a live socket was untested. Fixed under #531.
  * **`store/console/src/lib/jsx-tags.ts`** — the tag scanner does not treat a backtick as a string
    delimiter, so one apostrophe inside a template literal swallows the rest of a tag. Measured: 33
    tags invisible across two files, and a pinned ratchet reading 42 where the tree holds 43 (#536).

Each was fixed on its own terms. The class is what this record is for, because the three have one
property in common and it is not their subject matter: **each asserted only what it found, never
how much it looked at.** An empty offender list and an empty input set produce the same green tick,
and the guards where someone did assert the input set — `check-design-tokens.mjs`'s
`declared.size < 10`, `check-test-isolation.mjs`'s `isolatedCount === 0`,
`check-migrations.mjs --require-history`, `security-invariants.test.ts`'s "finds the surfaces that
verify inline at all", `tool-reachability.test.ts`'s "reads the migrations it claims to read",
`adr.mjs` throwing rather than returning `[]` — are, so far, none of the ones that failed.

A guard that under-reports is worse than no guard, because the number it prints is trusted. That is
the whole reason this is a constraint and not a style note: a subset measurement does not merely
fail to protect, it actively certifies ground it never walked.

## Decision

**A guard must make the size of the set it examined an assertion, not a by-product.**

Four rules. A guard that violates one is incomplete regardless of what it correctly detects.

**G1 — The input set is asserted, not assumed.** A guard that walks a directory, reads a config,
parses a document or matches a pattern must fail when that input is empty, implausibly small, or
structurally unrecognisable — with a message that says the guard has stopped measuring, not that
the code is clean. `parseThemeColorTokens` returning 3 tokens is not a clean tree, it is a moved
`@theme` block.

**G2 — The denominator is stated in the passing output.** A guard's success line names how many
files, tags, routes, migrations or phases it examined. A number that is only correct while nobody
reads it is how an under-count survives; printing it puts the evidence in every green build.

**G3 — A scanner that cannot parse something reports it.** Skipping an input the parser failed on
converts a bug in the parser into a silently smaller measurement. Count the failures and assert
they are zero, or throw. `if (j >= source.length) continue;` is the shape this forbids.

**G4 — A new guard is proven by watching it fail.** Before it lands, reintroduce the defect it was
written for — revert the fix, plant the fixture, delete the attribute — and record that the
assertion flipped. A guard whose only evidence is that it passes has evidence of nothing: every
guard passes on the day it is written, including the ones that measure the empty set.

## Consequences

Guards get slightly longer and slightly noisier. `✓ store/console: 43 hand-authored control shapes
over 5473 tags in 69 files` is a worse headline than `✓ clean` and a much better artefact, because
the next person to change the scanner can see the denominator move.

G1 has a real cost where the denominator is genuinely unstable: a threshold set too tight fails on
honest growth, and one set too loose asserts nothing. The answer is a bound with a reason beside it
("fewer than 10 colour tokens means the block moved"), never a bound chosen to make today's number
pass.

G4 costs one throwaway commit per guard, and it is the rule most likely to be skipped under time
pressure. It is also the only one of the four that would have caught all three of the 2026-08-12
failures before they shipped.

This record does NOT require a shared lexer, a guard framework, or a common harness. Guards measure
different kinds of thing and a framework that fitted all of them would fit none of them well. What
is required is that each states its own size in its own vocabulary.

## Enforcement

  * Review. The three failures above were all found by reading, and this document is what makes
    "where is the denominator?" a routine question at review rather than an insight.
  * The pattern is already established and can be copied rather than invented:
    `scripts/check-design-tokens.mjs` (G1), `scripts/check-file-size.mjs` and
    `scripts/check-test-isolation.mjs` (G2), `workers/api/src/lib/tool-reachability.test.ts` (G1
    and G3), `scripts/lib/adr.mjs` (G1 by throwing).
  * A pointer to this ADR belongs beside any hand-rolled source scanner, one hop from where it
    would be broken.
  * A mechanical check of G1 is deliberately NOT proposed: it would grade on the presence of an
    assertion rather than on what the assertion means, and this repository has already measured
    what that produces (#305's `biome-ignore` comments, which had drifted off the lines they
    covered and were suppressing nothing).

Verified vs inferred

Verified: every file:line in the tables above, read at cfe8ff7; the eight strippers and their
handling, read in full; the 33-tag / 42-vs-43 measurement (posted with its method on #536); the
bare-catch cross-check against source-guard.ts's stripper over all eight pinned trees plus four
unpinned ones, all zero.

Inferred: the causal claim — that the absence of a denominator assertion is why these three
failed rather than a correlate. It is supported by the split in the two tables (ten guards with the
assertion, none caught; the ones caught lacked it) and by the mechanism being sufficient in each
case, but three incidents is not a large sample and I have not audited a store outside PAGS for a
counter-example.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions