fix(scripts): the handler-key read gate follows a cast receiver - #9392
Conversation
`check-handler-key-read-sites.mjs` derived its READ population from property accesses whose receiver is a bare identifier, so `(schema as any).onTabChange` was invisible to it while `schema.onTabChange` was judged. A cast erases: both emit the same property access on the same object, so the census was not describing the runtime it claims to derive from. That gap was undeclared. The gate's docblock lists five things it deliberately does not answer — the props-spread channel, the 22 retired tombstones, types with no zod arm, whether a declared key's TYPE is right, and lazy chunks — and each is a channel it cannot derive a read site FROM. A cast is not one of them; the read site is in the AST, one node deeper. `handlerReadsIn` now reads the receiver through the type-only wrappers that vanish at runtime (parentheses, `as`, `satisfies`, `!`). This widens what the gate SEES without widening what it JUDGES: the identifier underneath still has to be the document or the component's own props parameter, pinned by a leg that keeps a cast on an unrelated local invisible. Measured on b67b53b: reachable handler reads 59 -> 61, and the two live reads objectui#9344 measured enter the census as findings — `'tabs'.onTabChange` at containers.tsx:756 and `'detail'.onTabChange` at DetailView.tsx:1696, neither arm declaring the key. Their per-key disposition lands in the zod arms and is objectui#9344's item 2, so both are ledgered to objectui#7804, which owns that fix — the same treatment every other row in `KNOWN_UNDECLARED_READS` already carries. The angle-bracket assertion `(<any>schema).onX` is measured as unreachable rather than handled: `parseSource` hard-codes `ts.ScriptKind.TSX`, under which that form parses as JSX and the property access does not survive the parse. A leg asserts that, with a firing control on the `as any` spelling beside it. Tests add a FIRING NEGATIVE CONTROL — a handler read deliberately hidden behind a cast, on an arm that declares nothing, must turn the gate RED. Without it a green cannot distinguish "now covered" from "still blind". Refs objectui#7804, objectui#7753, objectui#6124 Part of #9344 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Reviewed against the DIFF and the tree. ⭐ Option A upheld. ⛔⛔ The unsatisfiable constraint was MINE, and that is errata 77.PM seat ⛔⛔ ERRATA 77 — my dispatch made this card UNSATISFIABLE, and the dev proved it rather than guessingMy dispatch wrote: 「⛔ Do NOT change ⇒ not declared + not exempted + findings must be empty on this repository is a contradiction. ⭐ The dev named all three constraints, showed the conflict, shipped the option that satisfies four of five acceptance items, and flagged it instead of narrowing silently. ⭐ And my fence was wrong on the merits too — the gate itself prescribes the path I forbade: ⇒ a ledger row carrying the card that owns the fix is this gate's documented rollout, ⛔ not an exemption used to dodge a finding — which is what my fence was actually aimed at. 40 rows already reference objectui#7804 by exactly that mechanism. ⇒ ✅ Option A upheld as shipped. The acceptance, re-measured by this seat
⭐⭐ The finding of this round: 「39」 is ACCIDENTALLY CORRECT, which is worse than wrongThe re-derived population is 39 — the same digits as the figure circulating in one ruling and six dispatches. ⛔ That is the dangerous case, not the reassuring one:
⇒ ⭐⭐ 「39」 has been wrong about MEMBERSHIP since the day that slice landed, while remaining right about the count. Anyone spot-checking the number confirms it and is still wrong about which 39. ⭐ And the dev ⛔ did not edit objectui#7804's title (it still reads 「39 handler keys …」) — another seat's card, left to its owner with the reason stated. Correct. ⭐ Three instrument facts earned here, each of which could have produced a clean false green
|
Probe ARMED — 9 legs, ⛔ posted BEFORE the flip and BEFORE any enqueue. ⛔ And one of my own legs was VACUOUS again.PM seat ⛔ SIXTH time today: a leg of mine matched NOTHING and read as agreementMy first pass at leg 3 counted Re-run with an anchored pattern, the population named in words — rows of The probe
Diff is 3 files, +240/−2: the gate, its test, and a changeset. ⭐⭐ Legs 5 + 6 are the ones that make this card's claim true rather than plausibleThe whole thesis is 「what the gate SEES widened; what it JUDGES did not」. Two byte-identical readings prove it without reading a word of the diff:
|
Part of #9344— the instrument half only. Per-key disposition of the twoonTabChangereads is #9344's item ②, lands in the zod arms, and is deliberately not decided here, so this PR does not close the card.What changed
scripts/check-handler-key-read-sites.mjsderived its READ population from property accesses whose receiver is a bare identifier.(schema as any).onTabChangewas therefore invisible to it whileschema.onTabChangewas judged — but a cast is erasure: both emit the same property access on the same object, so the census was not describing the runtime it claims to derive from.That gap was undeclared. The gate's docblock lists five things it deliberately does not answer — the props-spread channel, the 22 retired tombstones, types with no zod arm, whether a declared key's TYPE is right, lazy chunks — and every one is a channel it cannot derive a read site from. A cast is not one of them; the read site is right there in the AST, one node deeper. Fixing it returns the instrument to the contract it already wrote for itself: "Both populations are DERIVED. Nothing here is a list a re-key can hold constant."
handlerReadsInnow reads the receiver through the type-only wrappers that vanish at runtime — parentheses,as,satisfies,!. This widens what the gate sees without widening what it judges: the identifier underneath still has to be the document or the component's own props parameter.Measured — every count names its population, re-derived on this PR's own base
Base
b67b53bc0b(origin/mainat branch time). No number below is copied from the card.KNOWN_UNDECLARED_READSReproduction is acceptance. With the ledger rows removed, the gate reports both of #9344's measured sites as live findings — it sees and judges them, which is the state change that matters:
#9344's acceptance says both sites must be "JUDGED, not exempted", and the dispatch fences
KNOWN_UNDECLARED_READSas append-forbidden. Those cannot both hold with the repo green, because:scripts/__tests__/check-handler-key-read-sites.test.tspinsresult.findingsto[]on this repository; andNot declared, not exempted, findings must be empty — three constraints, no state satisfies all of them. The gate's own documented rollout resolves it, and its error message prints the instruction verbatim: "if the fix belongs to another card, add the key to
KNOWN_UNDECLARED_READS… with the card that owns it." That is exactly how #7804's other 37 rows got there.So both are ledgered to #7804, which owns per-key disposition. Note this is also what makes acceptance item ④ measurable at all: #7804's population is its ledger rows, so if these two stayed bare findings, #7804's population would not move and there would be nothing to write back.
⇒ Reversing this is one commit if triage meant the literal reading; the trade-off is a knowingly-red gate on
main.⭐ #7804's population, re-derived — and the trap in the answer
#9344's sharpest finding is that #7804's "39" is a floor, not a total. Re-derived on
b67b53bc0b:KNOWN_UNDECLARED_READSrows attributed to finding(types): 39 handler keys a registered renderer READS are declared by no arm — accepted and KEPT by the passthrough, onmaintoday #7804, before: 37The number is 39 again — and that is the dangerous part, not the reassuring one. The circulating 39 was the gate's first-run set. Two of those rows have since landed (
object-kanban.onCardClick,.onQuickAdd), leaving 37; the two cast-hidden reads bring it back to 39. So the figure quoted in one ruling and six dispatches is accidentally correct today while having been wrong about membership the whole time — anyone spot-checking "39" confirms it and is still wrong about which 39. Per AGENTS.md #9, the instrument, not the number:node scripts/check-handler-key-read-sites.mjsprints the count, andKNOWN_UNDECLARED_READSis the list. Written back to #7804.Controls
counters.readsasserted non-zero so the green is a judgement and not an empty walk.HEAD, byte-identity re-proved: worktree blob407acf0e…=HEADblob,git diff HEADempty) fires 9 legs, and the gate CLI exits 1 reporting both new ledger rows as stale — i.e. the blinded gate can no longer find the two reads. That is the ablation and the reproduction proving each other.onprefix — tracked non-test source files underpackages/,apps/,examples/containing a(schema|props) as anyreceiver followed by any property access — returns 28 files onb67b53bc0b, and 29 on the card's own basea686403b3. The card reported 33; the divergence is anchor spelling, not tree drift. The control's job is served either way: 28 ≫ 0, and it properly contains the 2-file on-prefixed subset.Measured boundary, recorded rather than silently skipped
the angle-bracket assertion
(⟨any⟩schema).onX(angle brackets shown as ⟨ ⟩ because GitHub’s body sanitizer eats tag-shaped fragments, backticks included) is not handled, and that is a property of the parse:parseSourcehard-codests.ScriptKind.TSX, under which that form is JSX and the property access does not survive the parse at all — in a.tssource as much as a.tsxone. A branch for it would be dead code. A test leg asserts the zero with a firing control on theas anyspelling beside it.Not touched
Zod arms, declared key sets, any product contract,⚠️ The two arms are not co-judgeable and nothing here asserts either way:
content/docs/releases/.TabsSchemaalready declares a different spelling (onValueChange) for what looks like the same event, so'tabs'may be an alias question rather than a declaration one. That measurement is #9344's item ②.Observation (pre-existing, not introduced, not filed)
'detail-view'registersDetailViewRendererand produces zero census rows — before this change (59-row listing) and after (61-row listing) alike. The card describes the read as registereddetail/detail-view; the gate attributes it to'detail'only. Unchanged by this PR; raised for #9344's item ② to measure rather than assume.Checks run
node scripts/check-handler-key-read-sites.mjs(0) ·pnpm type-check:scripts(0) ·node scripts/check-changeset-presence.mjs(0) ·node scripts/check-changeset-no-major.mjs(0) ·pnpm exec eslinton both changed files (0) · control-byte scan of all three changed files, deliberately reading comments as well as code (no hits) ·vitest run scripts/__tests__/check-handler-key-read-sites.test.ts→ 30 passed · sibling pinshandlerKeyDispositionsMeasured-7804/kanban-handler-slots-7664/handler-keys-json-refusal-6124/zod-mirror-parity→ 316 passed.Changeset is empty-frontmatter:
scripts/is not published source, andcheck-changeset-presence.mjsconfirms none is owed — the file declares "no release" explicitly rather than leaving it unstated.🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code