Skip to content

test(scripts): derive every handler key a registered renderer reads, and require its arm to declare it (objectui#7753) - #7805

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7753-handler-key-derivation-gate
Sep 5, 2026
Merged

test(scripts): derive every handler key a registered renderer reads, and require its arm to declare it (objectui#7753)#7805
os-sam merged 1 commit into
mainfrom
claude/issue-7753-handler-key-derivation-gate

Conversation

@os-sam

@os-sam os-sam commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7753

Option 1 of the card, per the PM ruling on it: one census that walks every registered renderer for schema.onX / props.onX reads and requires each to be a declared member of its arm. It lives in scripts/ because the read-site half is spread across @object-ui/plugin-* and packages/components, which @object-ui/types may not import. Shape copied from PR #7789: a check-*.mjs exporting its internals, a scripts/__tests__/ pin driving them over mkdtemp fixture trees, one step in ci.yml's type-check job, one package.json script, and the matching row in content/docs/guide/ci-cd-pipeline.md (obliged by #3653, which pins that table by command).

Both populations are DERIVED. Arms come from every type: z.literal(...) object literal in packages/types/src/zod, with members unioned along .extend() bases and resolved object spreads. Read sites come from every real ComponentRegistry.register('TYPE', C, ...) CALL, taking the handler reads in C and in every component C hands THE SAME DOCUMENT to. The only hand-written list is KNOWN_UNDECLARED_READS, which is an exemption list, never the population: every row names a card and a row whose read site the gate can no longer find FAILS it.

All numbers below were taken at 973255681, the head of this branch.


Reading 1 — it fires on real history, on the unmutated tree

bd1fc71118a6e9744f4ace14110ac49a93a9be63 is the pre-remediation head of PR #7743 (the tree kanban-handler-slots-7664.test.tsx's own docblock names). It is a real commit in this repository, checked out into a detached worktree and not modified in any way. At that tree the 'kanban' arm declares onCardMove, onQuickAdd, onColumnAdd, onCardAdd and NOT onCardClick, while KanbanRenderer still forwards schema.onCardClick at index.tsx:194.

$ node scripts/check-handler-key-read-sites.mjs --root /home/user/objectui-issue-7753-hist
exit 1

x  40 handler key(s) a registered renderer reads are not declared by their arm:
      ...
      'kanban'.onCardClick  read at packages/plugin-kanban/src/index.tsx:194
          registered in packages/plugin-kanban/src/index.tsx; KanbanSchema (complex.zod.ts) does not declare it.

40 findings there against 39 on main, and the one extra is exactly 'kanban'.onCardClick. This gate would have reddened CI on the day of the incident. No reconstruction was needed.

Reading 2 — green on main, with its own census counts

$ node scripts/check-handler-key-read-sites.mjs
exit 0

OK  106 arm(s), 216 registration(s) (120 with an arm), 62 reachable handler read(s),
    62 judged, 0 left unjudged on an arm with an unresolved spread,
    39 exempted by ledger — every judged read is a declared member of its arm.

106 arms derived from the mirrors, 3071 arm members in total, 216 registrations walked, 120 of them at a type that has an arm, 62 reachable handler reads judged across 25 types. The green is never "the walk found nothing": the CLI exits 1 if the census falls under its floors, and the pin asserts non-zero counters on the repository run and on every fixture that passes.

Reading 3 — a control, and a control on the control

Both legs planted on the committed tree, restored under trap ... EXIT INT TERM, each mutation proved on disk by blob hash BEFORE the gate was read and each restoration proved by blob-hash equality plus an empty git diff HEAD. HEAD blob for packages/types/src/zod/complex.zod.ts is 4bb6bcf1b81453730f52a068667e3ca53417b1b7.

Leg A — really delete a still-read key (87-byte member line onCardClick: handlerKeyRefusal(...) removed from the 'kanban' arm; anchor occurrences 1 to 0):

mutated blob : 12a0e45542466a4d6bbeab6eac4f6258d8f4c61f   (non-empty, differs from HEAD)
gate exit    : 1
             x  1 handler key(s) ... 'kanban'.onCardClick read at packages/plugin-kanban/src/index.tsx:194
restored     : 4bb6bcf1b81453730f52a068667e3ca53417b1b7   (equal to HEAD: yes)
git diff HEAD: empty

Leg B — the same operation, on a member nothing reads (96-byte member line coverImageField: z.string()... removed from the SAME arm in the SAME file; anchor occurrences 1 to 0):

mutated blob : 6ee8f9aceed5597053c92333f23de14d3f69ee3c   (non-empty, differs from HEAD)
gate exit    : 0
             OK  106 arm(s), ... 62 judged ... every judged read is a declared member of its arm.
restored     : 4bb6bcf1b81453730f52a068667e3ca53417b1b7   (equal to HEAD: yes)
git diff HEAD: empty

Identical edit shape, comparable size, opposite verdicts. The red is caused by semantics — a still-read key leaving an arm — not by the file having been edited.

A third leg is pinned rather than run by hand: a still-read key declared with the RETIRED disposition instead of deleted goes red as retired-but-read.

False positives on main: zero, and the zero is comparative

The first, deliberately coarse cut — "any schema.onX / props.onX read anywhere in the FILE of a registration must be a declared arm member" — produced 36 findings. Every narrowing below is named after the class it removed, and each is pinned in the test file:

# Narrowing False positives it removed
1 Registrations read off the AST, not the file text 13packages/types/src/complex.ts NAMES ComponentRegistry.register(...) in doc comments eleven times and registers nothing, so its own interfaces read as handler reads on three chatbot arms
2 Object spreads in arms resolved (X.pick({...}).shape) 4chatbot-enhanced / chatbot-floating onError and onSend ARE declared, through ...ChatbotSharedMirrorShape
3 props.onX scoped to the component's own outermost parameters 1'menubar'.onClick, from items.map((child) => child.onClick?.()), which is a menu ITEM's handler
4 Reads attributed to the registered component, not every type in the file the rest of the cross-arm bleed in plugin-kanban/index.tsx and plugin-chatbot/renderer.tsx

Two more false-positive classes appeared only after the walk was made transitive (which it had to be — see below), and both are pinned:

  • 'dashboard'.onRowClick and 'dashboard'.onClick — a dashboard's widgets each get their own document, so ObjectDataTable's schema.onRowClick is a read of the widget's document. Removed by following a JSX child only when the parent's document flows into its schema= attribute.
  • 'object-view'.onViewChangeObjectView builds { type: 'view-switcher', ..., storageKey: \view-pref-${schema.objectName}` }and hands it to. It mentions schema, so a mention test called it the parent's document and reported ViewSwitcher's read against an arm that is not even the one being read. Removed by treating an object literal that writes its own type` member as a new document.

One false NEGATIVE is worth recording too, because it is the failure this gate could most easily have shipped with: the first cut started the walk at the register call's second argument, which is usually an identifier. It found ten read sites, was green on main, and was green on bd1fc7111 — it never reached KanbanRenderer at all. 'kanban' registers ObjectKanbanRenderer, which is an HOC, which renders ObjectKanban through a render-prop parameter, which renders KanbanRenderer through an object spread. All four hops are load-bearing and all four are pinned as a fixture chain.

Coverage: 20 of the 67 ledger sites, and which 47 are not covered

The #6124 ledger is RUNTIME_SLOT (45) + RETIRED (22) = 67 sites. The existing kanban-handler-slots-7664.test.tsx covers 3.

  • 20 of the 45 RUNTIME_SLOT sites are covered: KanbanSchema.onCardMove / .onCardClick / .onQuickAdd, ChatbotSchema.onError / .onSend, ChatbotEnhancedSchema.onError / .onSend / .onClear, ChatbotFloatingSchema.onError / .onSend / .onClear, DataTableSchema.onRowEdit / .onRowDelete / .onSelectionChange / .onColumnsReorder, FormSchema.onCancel, CodeEditorSchema.onChange, CardSchema.onClick, ObjectDataTableSchema.onRowClick, AlertDialogSchema.onAction.
  • 0 of the 22 RETIRED sites, and this is a design boundary rather than a defect: a tombstone exists precisely because nothing reads the key, so it has no read site to derive from, by construction. What this gate adds there is the other direction — a renderer that ever starts reading a tombstoned key is a retired-but-read finding.
  • 25 RUNTIME_SLOT sites are not covered, and the reason is one channel: they reach the renderer without the key ever being NAMED as schema.onX or props.onX. Either the props are spread onto a Radix root or DOM listener slot (AccordionSchema.onValueChange, CollapsibleSchema.onOpenChange, ToggleGroupSchema.onValueChange, TabsSchema.onValueChange, ButtonSchema.onClick, MenuItemSchema.onClick, and the seven onOpenChange overlays Dialog / AlertDialog / Sheet / Drawer / Popover / HoverCard / DropdownMenu), or the handler is destructured straight out of props (InputSchema.onChange, TextareaSchema.onChange, SelectSchema.onChange, CheckboxSchema.onChange, FileUploadSchema.onChange, DatePickerSchema.onChange, InputOTPSchema.onChange, PaginationSchema.onPageChange, FilterBuilderSchema.onChange, CalendarViewSchema.onViewChange, FormSchema.onSubmit, FormSchema.onChange).

The destructured half was measured, not assumed. Extending the census to ({ schema, onChange, ... }) raises RUNTIME_SLOT coverage from 20 to 28 of 45 — and adds 86 findings (125 total) at full transitivity, or 11 (50 total) when limited to the registered component. Those 11 include view-switcher::ViewSwitcherSchema.onCreateView and .onViewAction, which ObjectView passes as ordinary React props: a destructured onX cannot be told apart statically from a host-supplied callback, so the extension buys coverage with rows nobody could act on. It is left out, and this paragraph is the record of the trade rather than a silent omission.

The gate's own first findings: 39, ledgered, not fixed here

The census also judges read sites the #6124 ledger never contained — 42 of the 62, of which 39 are keys a registered renderer reads that its arm never declared at all. That is the same passthrough exposure the card names, standing on main today (AlertDialogSchema.onAction was exactly this shape until #7104 declared it). They are filed as #7804 and carried in KNOWN_UNDECLARED_READS, every row naming that card, because each fix is a packages/PKG/src change and this card is dispatched Clause-② no — and because each key's #6124 disposition has to be measured, not guessed. The ledger drains row by row; a row whose read site disappears fails the gate.

Scope

No file under packages/ is touched — git diff --name-only origin/main..HEAD | grep -c '^packages/' is 0. handler-keys-json-refusal-6124.test.ts, every *.zod.ts and kanban-handler-slots-7664.test.tsx are unmodified. The gate observes; it changes no schema and no accept/refuse behaviour.

Verification run

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3


Generated by Claude Code

…er reads and requires its arm to declare it (objectui#7753)

`BaseSchema` is `.passthrough()`, so an `on*` key that leaves an arm is not
refused — it stops being judged and the value is KEPT. objectui#7664 measured
that on the built dist: `{ type: 'kanban', columns: [], onCardClick: { action:
'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the
parsed output, while every gate stayed green. The objectui#6124 ledger could not
see it — its population is two hand-written arrays, and the change re-keyed the
arm by SUBSTITUTION, holding `RUNTIME_SLOT` at 44 and `ALL_SITES` at 66.

This adds the derivation that was missing, generalising suite 3 of
`plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` from one arm to
the whole registry: arms from every `type: z.literal(…)` in
`packages/types/src/zod`, read sites from every real `ComponentRegistry.register`
call and the components each registration hands the SAME document to. Both
populations are derived; the ledger here is an exemption list only.

Verified red on the real pre-remediation tree (bd1fc71), naming
`'kanban'.onCardClick`, with one finding more than `main` and no other
difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(types): the #6124 handler-key ledger is hand-written by name, so it cannot see a DELETED key — 64 of its 67 sites have no read-site derivation

2 participants