feat(scripts): report-only component surface parity checker (objectui#4631) - #9551
Conversation
…#4631) Add `pnpm check:component-surface-parity` — a REPORT-ONLY checker that prints, per registered component type, where the four declared surfaces disagree: the spec's `ComponentPropsMap`, the keys the renderer actually reads, the TS schema interface in `packages/types`, and the registry `inputs` / `defaultProps` of the `register()` meta. The rule is the ruling's, not the script's: the TS interface key set is the union of the spec key set and the renderer-read key set, `inputs` is a subset of that union (so an omission is legal), and a `defaultProps` row that is a designer seed rather than a default claim must carry a registered reason. It exits 0 with findings — report-only first, blocking once the census reads zero — and it repairs nothing it finds. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Instrument reviewFAIL — not because a claim is false. All seven reproduce exactly, and the ablation Head judged: Reviewed in a private worktree at the head sha with The seven claims — re-measured, not taken on trust
Claim 5, done from scratch rather than replayed. Restore leg: Claim 6, second half. The subset rule is pinned executably at One attack that the instrument defeats cleanly. An empty ambient key set does not produce a Spec surface resolution. Findings against the instrument🔴 F1 — 5 registration call sites, covering ≥89 component types, are dropped from the population in silenceThe 507 → 212 funnel is honest as far as it is stated, and I reproduced every step of it: But the gap the PR body does not state is inside the population. Running the checker's own Five real if (crossCheck.calls > sites.length + crossCheck.unreadable.length) fail(…)— tolerates them by construction. Its own comment calls it "the non-vacuity control for the one All five register with a variable type:
Membership probe against the 212 records (212 records, 212 distinct types — no duplicates): Floor: ≥89 registered component types are outside a census presented as covering the registered Not a repair of anything the gate found — a repair of the gate: print the 🔴 F2 — the spec surface is inert on this tree, and the PR does not say so
All 30 spec-declared registrations resolve to no interface (22 This is not vacuity by construction — the synthetic test at 🟠 F3 — 12 of the 107
|
…ponent-surface-parity-gate
… census (objectui#4631) Every repair is in the matcher; no author declaration moves and no found row is repaired. F1 - a `register(variable, ...)` factory is now a fourth coverage bucket with every call site printed, and the header's Limits block states the exclusion. The old cross-check added `unreadable` to its own right-hand side, which made the largest exclusion in the census its quietest one. F2 - the census prints the spec-and-interface INTERSECTION beside its two halves, and shouts when it is zero; every `interface-missing-key` carries the arm that actually fired. F3 - the `inputs` rule is guarded on `renderer.unresolved`, exactly as both interface rules are guarded on `iface.unresolvedReason`. F4 - the census is split by the rest-spread caveat the findings already carry, so the headline cannot be quoted without it. F5 - registrations annotated with the base interface itself are named inside the "with a TS interface" count. F6 - `analyze()`'s JSDoc names the option its body reads. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Instrument reviewPASS — round 2. All four repairs land, both ablation legs reproduce independently, and the Head judged: Implemented-by: claude/issue-4631-component-surface-parity-gate Scope: the four repairs, the deliberate difference and the two ablations — not a re-review of the ⭐ F1 — the guard closes the hole, it does not move itThis was the one I was asked to attack, and it is the repair I am most satisfied with. Round 1's I probed the classification directly rather than trusting the shape:
And on the real corpus the guard is now exactly tight, with zero slack in any file:
All five sites are printed with file, line and the expression they register under: Stated in the header The one residual tolerance: a computed-but-nameable key such as F2 — the intersection is disclosed, and every row carries its causeReproduced exactly: F3 — guarded, and the row stays in its bucket
⛔ Nothing in F4 — the census is split, with a TOTAL rowReproduced exactly from F5 / F6 — both present
⭐ The deliberate difference — judged, and accepted
But the numbers are not the argument; I tested the reason, across all three values
So the reason is sound in every case the code can reach, not merely in the cases that exist. The pin On "derived rather than asserted": I would mildly prefer derived, and it is not a condition of Ablations — both legs re-run from the committed state, not replayedBase blob confirmed:
Both legs match the claimed shapes. My post-mutation hashes differ from the reported The F3 leg also gives a clean causal attribution of the census delta: with only that guard
The "moves with the base" sentence — present, with one precision nitIt is in the body, as a 🟢 Nit (low, not blocking). The sentence reads "at The "six rows … on Also reported, not acted on: the API still returns VerdictAll four findings are repaired at the matcher, none at an author's declaration, and no row the Landing is the PM's. Generated by Claude Code |
Correction to the review's "not behind" note — the branch is behind, and I am what made it so
The round-2 review says:
Re-measured just now, and the field is accurate:
⇒ ⭐ What actually happened, and it is the more useful halfThe review's reading was true when it was taken. Between then and its posting at So the claim "the API field is stale" was itself a reading with a timestamp, and the thing that ⛔ Not a reason to merge main by handThis lane's rule stands:
Generated by Claude Code |
Enqueue record — PR #9551
The three gates① Round-2 verdict ⭐ The head did not move for the body correction: the fix was a ② ③ Before the ready flip: Landing
What lands, and what deliberately does notA report-only checker, its 42 tests, one ⛔ None of the 339 disagreements it reports is repaired here, and none is filed as work. The
Round 1 returned FAIL on four counts; all four repairs landed in the matcher, none in an Generated by Claude Code |
Fixes #4631
Lands
pnpm check:component-surface-parity— a report-only checker that prints, perregistered component type, where the four declared surfaces disagree. It repairs nothing
it finds, and it exits
0with findings.The rule, which is the ruling's and not the script's
From the director-seat ruling on this card (batch #112 item 2, 2026-09-10), quoted rather
than paraphrased:
So: interface key set = spec ∪ renderer-reads;
inputs⊆ that union (an omission islegal); a
defaultPropsrow that is a designer seed rather than a default claim carries aregistered reason. Report-only first; the flip to blocking is a separate card on a measured
zero, and repairing any row below is governed by the same ruling's ordering note
(先判协议对不对 — the spec card goes first where the protocol is the thing that is wrong).
How this divides the work with the gate that already exists
apps/console/src/__tests__/registry-inputs-spec-parity.test.tsowns registryinputs↔ specComponentPropsMap, in both directions, from the live registry, with its own exemptiondiscipline — and it is one of a family:
git ls-tree -r --name-only origin/mainfiltered onspec-parity|inputs-speclists 36 paths, 34 of them*.test.ts(x). This checker neitherrepeats nor contradicts that family:
inputsagainst the union (spec ∪ renderer reads), which is the ruling'saccept set and is strictly wider — so a key this checker passes may still be judged
there, and those tests stay the authority on the spec-only direction;
packages/types, and what the renderer actually reads;runtime) is a full member of this population. The two populations differ on purpose.
scripts/check-handler-key-read-sites.mjsis the same read-site question narrowed toon*keys and judged against the zod arms; it blocks. This checker reuses that gate's extraction
primitives rather than growing a fourth copy of "which files ship" and "which registrations
exist", and judges every key against the TS interface instead.
Acceptance notes
Corpus — stated so every number below is reproducible from this text alone
Measured at
e9dc38ee9e, which is this branch withorigin/mainmerged in(
8d50bc2bf4, merge commit0a1fda0402).of two trees at once — this branch's matcher against
main's declarations — so a declarationlanding on
mainmoves it with nothing here changing. Two things moved it this round, and theyare separable; both legs were measured, the second by ablating the F3 guard on the merged
tree:
e085c14f3b— round 1, pre-mergeUIActionSchema, becausemainwidenedpackages/types/src/ui-action.tsin flight (#9543) — 4 left, 2 arrived, net −2e9dc38ee9einput-outside-keysetfindings the unguarded rule manufacturedSo the
107 → 95leg is the F3 repair, not the base; the base moved the census by 2, in theinterface-*columns only. ⛔ A ratchet card must nonetheless quote a ref, never 339 as afixed floor — the base can move it again tomorrow with nothing here changing.
packages/PKG/src/**(everyworkspace package), excluding
.d.tsand the tooling pattern(^|/)(__tests__|__mocks__|__benchmarks__)/|\.(test|spec|bench|stories)\.[cm]?[jt]sx?$.1443 files across 40 packages. Registrations read from that corpus: 212.
5 call sites register dynamically —
ComponentRegistry.register(variable, …)inside aloop — and their types exist only at run time, so this reader can name none of them. They
are printed as a coverage bucket (below), and the families behind them (the
ui:HTML-TAGblocks from
html-elements.tsx+semantic.tsx, thefield:*widgets fromfields/src/index.tsx, and theplaceholders.tsxvocabulary) are outside every numberhere. Membership probe against the 212:
ui:h1,ui:p,ui:img,field:text,field:select,field:lookupall absent; same-corpus controlsnav:menu,ui:toast,ui:sonnerall present.git grep -o "ComponentRegistry\.register(" HEAD -- packages | wc -l→ 507 occurrences in 301 files (
git grep -l … | wc -l); 148 of those files matchthe tooling pattern, 19 are
.md, 134 are in population. Absent-token control in the samecorpus:
qqzz_absent_token_9999→ 0 files.ComponentPropsMapfrom the installed@objectstack/spec/ui, read througha dynamic
import(). 45 entries (44 object shapes, 1z.never()read asdeclared-with-no-keys); 30 of the 212 registrations have one.
packages/types/src/**excludingzod/and__tests__. 448declarations indexed; 92 registrations resolve to one, of which 9 are annotated
BaseSchemaitself and so have no OWN members at all.no type has both: every spec-declared registration takes
schema: anyor no annotation. Soon this corpus the spec arm of
interface-missing-keyfires on nothing and the spec halfcan only ever suppress (it widens the accept set). Per-finding attribution confirms it:
spec=0 spec+renderer=0 renderer=91. The authority order is mechanised and pinned by thesuite; it is not doing work on this tree. The run prints this line and shouts when it is
zero.
BaseSchema's own members plus the renderloop's own reads and injected props in
packages/react/src/SchemaRenderer.tsx. Nothing islisted; both halves are re-derived every run.
The census, split by the caveat the findings already carry
interface-missing-key— the interface does not declare a key the spec declares or the renderer readsinterface-extra-key— the interface declares a key neither the spec declares nor the renderer readsinput-outside-keyset—inputspublishes a key outside (spec ∪ renderer reads)read-of-tombstoned-key— a renderer reads a key the interface refuses by name (: never)rest-spread means the instrument does not claim the row is a defect: 117 of 210 resolved
renderers forward their unnamed props with a rest spread, so the key may be honoured without a
named read (
ui:switch.checkedis the specimen — declared, published ininputs, honouredthrough the spread, named nowhere). ⛔ Quoting 339 without this split is quoting 190 rows the
gate itself hedges.
interface-missing-keyis deliberately never tagged: there the key isread, and a spread forward does not soften a missing declaration.
defaultPropsvs renderer fallback, over the same 212 registrations: 77 comparable pairs,68 agreeing, 9 diverging, all 9 carrying no registered designer-seed reason (the ledger ships
empty — populating it would be adjudicating these rows, which this card does not do):
defaultPropsaction:bar.variant"outline""ghost"action:button.size"md""default"action:group.size"sm""default"ui:pagination.totalPages101ui:data-table.selectabletruefalseui:data-table.exportabletruefalseui:data-table.rowActionstruefalseui:file-upload.buttonText"Choose files""DROP PAYLOAD OR CLICK TO UPLOAD"plugin-markdown:markdown.content""A comparable pair is a⚠️ Read 77/9 as bounded by that pattern, not as a repo total. This table is
defaultPropskey whose renderer also has a literal fallback forthe same key (
schema.k || LIT,schema.k ?? LIT, or a destructured{ k = LIT }). Afallback that is not a literal is recorded as "has a fallback, not comparable" rather than as
agreement.
unchanged by the base merge.
⭐
layout:flex.align— the first row of the 2026-09-07 census on this card — does notappear, because #7735 / #8229 repaired it. That reading is superseded by this one.
Coverage gaps — four named buckets, reported, never counted as agreement
(
plugin-list:list-view,view:list—elementDataSourceBlock(React.forwardRef(…)), adouble wrap the HOC hop does not follow). The
inputsrule is skipped for these, exactlyas both interface rules are skipped when the interface does not resolve.
schema: any, no annotation, or a props typethis reader cannot hop through);
inputsentry this reader cannot name (a spread);expression it registers under.
A zero read set or a zero key set read as truth would report every declared key as extra, so
each of these is a named bucket rather than a silent pass — and each rule is guarded against
its own bucket.
Two premises re-measured, and one the checker confirms by staying silent
toastandsonnerregistryinputsomitbuttonVariant, a key both the TS type declares and both renderers read — the designer/palette face cannot author it #7316 (toast/sonneromitbuttonVariantfrominputs) is NOT flagged. Under theruling's
inputs ⊆ keysetan omission is legal.node scripts/check-component-surface-parity.mjs --type ui:toastprintsbuttonVariantinboth
readsandinterfaceand absent frominputs, with no finding for it. The suite pinsthat shape executably, so widening the rule into a two-way mirror cannot happen silently.
registry-inputsuniqueness premise did not hold as written. As a content grep,git grep -l "registry-inputs" origin/main | wc -l→ 50 files, not one; as a path matchexactly one file is named
registry-inputs-spec-parity.test.ts. The division-of-work sectionabove is written against the whole family.
Non-vacuity — three controls that fire, three ablations that prove they can
scripts/__tests__/check-component-surface-parity.test.ts, 42 tests, all passing. Threeof them fire rather than prove silence, and each has a mutation behind it:
inputs ⊆ keysetrule1 failed, 31 passed—expected [] to include 'input-outside-keyset:ghostKey'2 failed, 40 passed— the bucket test and its real-tree twinrenderer.unresolvedguard on theinputsrule (F3)1 failed, 41 passed— does NOT report inputs for a registration whose renderer body it cannot reachRound-2 legs ran against blob
204c904083306c119e48cc35cf7e91e7dc799a96; each mutation wasproved on disk by hash (
1c5aa857…,a1931ee6…) before the suite was run, and each restoreleg returned the file to
204c9040…withgit diff HEADempty and the injected marker at 0occurrences. A
trap … EXIT INT TERMwith absolute paths was in place throughout.The suite also carries the five ways a parity gate can be invisibly vacuous, as executable
assertions: no registration read, no renderer body resolved, no interface found, an empty
ambient set (a confident red over a broken instrument), and a wrongly resolved spec. Each
is an
ExtractionError, never a pass.Verification
pnpm exec vitest run --disable-console-intercept=true scripts/__tests__/check-component-surface-parity.test.tspnpm type-check:scriptspnpm exec eslint --no-inline-configon both new filespnpm check:component-surface-paritynode scripts/check-entry-guard.mjsnode scripts/check-changeset-presence.mjsnode scripts/check-new-cross-file-line-citations.mjsnode scripts/check-pre-install-import-graph.mjsnode scripts/check-test-path-roots.mjsnode scripts/check-lint-coverage.mjsnode scripts/check-control-bytes.mjsnode scripts/check-vi-mock-override-shape.mjsAll re-run on the merged tree at
e9dc38ee9e. Repo-widepnpm lintand the fullpnpm testfarm are CI's run, not claimed here.
Out of scope — left alone on purpose, each its own card
ordering note governs them: decide whether the protocol is right first.
BaseSchema'sbody/childrentwins and its[key: string]: anyindex signature — theruling puts these on their own card in so many words. The ambient set reads
BaseSchema'smember NAMES only, so that a key every type inherits is not billed to one block; it reports,
widens and narrows nothing there.
inputsarray, or a renderer. In particular ⛔ nothingin
packages/plugin-listmoved for F3 — the repair is the matcher's guard, and the row staysin the coverage bucket where an unreadable renderer belongs.
TAGSloop is a censusnobody can reconstruct; naming the exclusion is the honest answer, and extending the reader
to resolve them is its own card.
Clause-②: no— a report-only checker plus its tests adds a build-time reading and noauthorable key, no published type and no accepted spelling. Nothing in any published
.d.tsmoves.
Implemented-by: claude/issue-4631-component-surface-parity-gate
Generated by Claude Code