fix(types): declare bind on BaseSchema and its zod mirror - #6574
fix(types): declare bind on BaseSchema and its zod mirror#6574os-support-ai wants to merge 2 commits into
bind on BaseSchema and its zod mirror#6574Conversation
`bind` was read by ten production sites and declared by no schema shape — it
resolved as `any` through `BaseSchema`'s index signature and rode
`.passthrough()` on the validator, while `AGENTS.md` §4, the published
agent-facing `skills/objectui/rules/protocol.md` and `content/docs/fields/grid.mdx`
all taught it as an authorable key of every node.
The census chose the home rather than guessing it: nine reads go through
`useDataScope(schema.bind)` across eight packages, a tenth is `plugin-grid`'s
`gridNeedsDataSource` predicate, and two more sites destructure the key out of
the DOM prop spread. Per-component declaration was measured and rejected — it
costs nine copies and buys nothing extra, because neither half can refuse the
key on a non-reader either way.
Declaring it narrows the accept set on the VALUE, not the key: `bind: 42`
type-checked and parsed green before and is refused by both halves now. That
only refuses what already crashed, since `useDataScope` is `(path?: string)`
and resolves via `path.split('.')`.
`ObjectGridSlotKey` / `ObjectFormSlotKey` gain the key so the view slots still
ship the full configuration, and the member-count pins that guard exactly that
drift move 61->62 and 67->68. `ObjectPivotTable` drops its local duplicate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
`git grep` searches TRACKED files, so once the pin was committed its own literal pattern matched it and the scan reported itself. The pattern is now assembled from parts and the prose no longer spells it, so the file cannot be its own hit. Allow-listing it would have been the wrong repair — that puts a permanent hole in the scan at the path most likely to grow a copy. Also drops a useless initializer flagged by `no-useless-assignment`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
|
PM review of #6357 — substance ACCEPTED, but ⛔ this PR STAYS DRAFT and is NOT being enqueued. Triage attached human review to exactly this branch of the fork: "If the census says I have not requested a named reviewer because I do not have a verified handle for this repo's base-type review, and guessing one would route it wrong. Maintainer: this is the PR that needs your merge. The census is right, and it found what decides the cardVerified independently on
The tenth read is real — ⭐ My dispatch-order census missed all four, and I can now name why. My pattern was The fork was resolved on mechanism, not preferenceBoth halves are the kind of answer I wanted: Authorable, because the alternative is mechanically closed.
Contract-first cleared: Two things done right that are easy to get wrong⭐⭐⭐ The self-matching scan was repaired, not allow-listed. The single-declaration scan reported itself once committed, because ⭐⭐ The slot-key guard went red and was obeyed, not silenced. The honest characterisation of the riskThis is a narrowing on the value axis: The 42-package downstream sweep is green, and the stated limit is honest: it measures this repo only, and out-of-repo consumers writing a non-string One conflict in my own dispatch order, correctly flaggedThe dev noted my order said "push nothing if the census stops you" while the standing dev contract makes the empty-branch push a mandatory first-minute write-route probe. It ran the probe, the census did not stop it, and the two never diverged in practice — but the contradiction is mine and real. Same class as the #6537 order that named one file two ways. When I write a stop-and-report clause, it has to be reconciled with the contract's mandatory probe rather than stated as an absolute. #6575 and #6576 filed; Verdict: substance accepted, held for human merge. Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #6357.
Gates measured at
01f4e58b3, this branch's head and the tree every number below was read from.The triage ruling pre-authorised both branches of the fork and attached handling to this one:
The census says
BaseSchema. So this PR stays in draft and waits for a human. No auto-merge, no queue.packages/types/src/base.tsis not on the repo's governed surface (AGENTS.md/CLAUDE.md/.claude/**/docs/adr/**), so that is not what holds it — the ruling is.The blast radius is stated plainly:
BaseSchemais extended by ~111 interfaces and consumed by 42 downstream packages. That sweep is green (below), but it measures this repo only.The census — mine, independent, and it moved
The dispatch table listed nine
useDataScope(schema.bind)reads. All nine reproduce exactly. The census found four more sites it did not list, and two of them change the argument.Reads — 10
packages/components/src/renderers/data-display/list.tsx:17useDataScope(schema.bind)packages/components/src/renderers/data-display/tree-view.tsx:104useDataScope(schema.bind)packages/plugin-charts/src/ObjectChart.tsx:267useDataScope(schema.bind)packages/plugin-dashboard/src/ObjectDataTable.tsx:350useDataScope(schema.bind)packages/plugin-dashboard/src/ObjectPivotTable.tsx:67useDataScope(schema.bind)packages/plugin-grid/src/ObjectGrid.tsx:826useDataScope(schema.bind)packages/plugin-kanban/src/ObjectKanban.tsx:199useDataScope(schema.bind)packages/plugin-list/src/ObjectGallery.tsx:228useDataScope(schema.bind)packages/plugin-timeline/src/ObjectTimeline.tsx:142useDataScope(schema.bind)packages/plugin-grid/src/index.tsx:105if (schema?.bind != null) return false;— not a hook read.gridNeedsDataSourcetreats a presentbindas one of the escape hatches that makes a missing data-source adapter a legitimate configuration rather than a defect.Writers / strippers — 2
packages/plugin-dashboard/src/MetricWidget.tsx:273andMetricCard.tsx:74destructurebindout soSchemaRenderer's prop spread cannot writebind="data.revenue"onto the DOM (objectui#4357).SchemaRenderernever stripsbinditself — it is spread to every component as a React prop, which is why these two have to.⭐⭐ It was already declared — four times, in two spellings
The card says "declared by no schema shape", and that is exact. But
bindwas not undeclared:plugin-dashboard/src/ObjectPivotTable.tsx:45string, in aPivotTableSchema & {…}intersectionplugin-dashboard/src/ObjectDataTable.tsx:39string, in a hand-rolled inlineschematypeplugin-list/src/ObjectGallery.tsx:20string, in a hand-rolled inlineschematypeplugin-dashboard/src/schemaHostProps.ts:65unknownThat is the card's own warning already realised four times over — and
schemaHostProps.ts's own header names the hazard: "two copies of one key list is how a list becomes two disagreeing lists."Only one of the four was a true duplicate of a base member:
ObjectPivotTable's, becausePivotTableSchema extends BaseSchema. It is removed here. The other three are load-bearing — their containing types never referenceBaseSchema, so deleting the member deletes the declaration rather than inheriting it. They are ratcheted, not removed (finding filed).bindonBaseSchema: 0, against a live controlawk '/^export interface BaseSchema/,/^}/' packages/types/src/base.ts→bind0,visibleWhen3, 20 declared members. The dispatch's warning about a false zero was real and I avoided the pathspec that caused it —packages/*/srcdoes not glob nested directories; every scan above isgrep -rn packagesorgit grep.The fork, answered from the census
Authorable, not a runtime slot — and the runtime route is MECHANICALLY closed
RuntimeOnlyDeclaredcannot holdbind.zod-mirror-parity.test.tsdefinesCallbackShapedKeyas`on${'A'|…|'Z'}${string}`and pinsassertionRuntimeOnlyIsCallbackShapedOnly— a non-callback key filed there is a type error. That header states the reason: without it "the new category is a place to move any inconvenient key to, and the reclassification becomes the waiver it is not."So PR #6352's
onItemClickprecedent does not transfer.onItemClickis callback-shaped;bindis a path string. Different route by construction, not by judgement.Everything else agrees it is authorable — three documents teach it as an authorable key of every node:
AGENTS.md§4, this repo's own canonical instruction file: "Every node in the UI tree follows this shape (@object-ui/types)", listingbindas an optional string with the commentdata binding path: 'user.address.city';skills/objectui/rules/protocol.md:40,:151(published, agent-facing): "Every UI component node MUST follow this shape", and "a path string resolved byuseDataScope()";content/docs/fields/grid.mdx:190.useDataScopeis(path?: string)and resolves viapath.split('.')(packages/react/src/context/SchemaRendererContext.tsx:60), sostringis not a choice — it is the resolver's own signature. It is also the spelling three of the four pre-existing local declarations already used.⇒ authorable ⇒ the zod mirror member is required, and it is here.
BaseSchema, not per-component — and this is a measurement, not a preferenceThe decisive fact is that per-component declaration buys nothing that
BaseSchemadoes not.BaseSchemacarries[key: string]: anyon the TS side and is.passthrough()on the zod side, and every reader mirror isBaseSchema.extend(…), which inherits that. So neither half can refusebindon a non-reader either way. Nine declarations would cost nine copies of one key for exactly zero additional enforcement — and would be, in the card's own words, "the second declaration this class keeps generating."Supporting evidence:
BaseSchema.placeholderis declared for every node and documented "for input components" — a cross-cutting authorable key honoured by a subset already lives here.bindis universally DELIVERED.SchemaRendererspreads it to every component; that is whyMetricWidget/MetricCardmust strip it. Declaring it on the base states the delivered reality.FlexLayoutProps(layout.ts) is the in-repo mixin precedent, but it would need.extend()on nine mirrors and nineextendson the TS side to buy the same zero enforcement, while contradicting all three documents that teachbindas universal.Contract-first check:
@objectstack/specdoes NOT moveScanned
../objectstack/packages/spec/srcfor a UI-nodebind. Every hit is prose orFunction.prototype.bind—dashboard.zod.tsandview.zod.tsbind adataset(ADR-0021), a different vocabulary.bindis objectui-local renderer vocabulary. NoBlocked-by:, no reach intopackages/spec.What changed
packages/types/src/base.ts—BaseSchemadeclaresbindas an optional string, with the census, the home argument and the ceiling inline.packages/types/src/zod/base.zod.ts—BaseSchemaCoregainsbind: z.string().optional(). Not optional housekeeping: the pairbase.zod.ts#BaseSchemacarries noKnownDrift/UnmirroredDeclaredentry, so a TS declaration without this member reddens the parity ratchet by name (demonstrated in the ablation below).packages/types/src/objectql.ts—ObjectGridSlotKeyandObjectFormSlotKeygain'bind'. Required, not cosmetic: those unions are hand-written key lists whose whole documented hazard is that "a member that exists onObjectGridSchemabut is missing fromObjectGridSlotKeyis simply not configurable through the slot, and nothing says so."object-view-slot-key-lists.test.tscaught this the moment the base member landed — the guard doing precisely its job.packages/types/src/__tests__/object-view-slot-key-lists.test.ts— member counts 61→62 and 67→68. The historical-> 0measurement table is kept verbatim and annotated rather than rewritten; falsifying a recorded measurement is not a count bump.packages/types/src/__tests__/base-bind-declared.test.ts— new, in the house form (timeline-declared-keys.test.ts/gantt-declared-keys.test.ts). Runtime half, compile-time@ts-expect-errorhalf, a counter-probe against anever-narrowed declaration, an honest ceiling pin asserting thatbindTois still accepted, and a single-declaration scan that ratchets the three remaining non-home declarations with their reasons.packages/plugin-dashboard/src/ObjectPivotTable.tsx— drops its local duplicate.object-view-spec-parity.test.ts,zod-mirror-parity.test.ts) that this diff itself made false: "20 members" → 21.zod-mirror-parity.test.ts's own header warns that its counts rot and get quoted as fact; leaving them would have seeded exactly that.What this does NOT do — stated because the pin reads as more otherwise
bindTois still accepted by both halves. finding(types): BaseSchema's[key: string]: anyleaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155 / finding(types): ObjectViewSchema'stableandformslots declare ZERO properties — the same Omit-under-index-signature collapse as #6151, in property position #6269 own that ceiling and remain open, untouched here; the pin asserts the acceptance explicitly so nobody reads the two adjacent assertions as more than they are.data-tablestill ignoresbind. It does not calluseDataScope, so abindon it renders a header over an empty body with no error and no warning — recorded atprotocol.md:162and already pinned incomponents/src/__tests__/skill-guide-data-table-binding.test.tsx. This declaration neither causes nor cures it: the key was accepted on every node before it existed. Filed separately.skills/tree is touched. It is evidence here, not scope. It also needs no correction —protocol.mdtaughtbindas an optional string all along; this change makes the published types agree with it rather than the reverse.The narrowing, and why it is safe
It narrows the value, not the key:
bind: 42type-checked and parsed green before this change and is refused by both halves now.It only refuses what already crashed.
useDataScopeis(path?: string)and doespath.split('.'), so a truthy non-stringbindthrew aTypeErrorat render. Everybindauthored anywhere in this repo is a string ('customers','rows','treeNodes','items','customerNames','data.revenue','app.settings.users'). The member is optional, so nothing that renders today stops.Ablation — the pin has teeth, both legs proven on disk
Predicted direction: renaming the declaration makes the TS member fall back to
anythrough the index signature, the wrong-typed assignment starts succeeding, and the now-unused directive fails the build with TS2578 naming the key.No build leg is needed, and that is proven rather than assumed —
tsc -p tsconfig.test.json --listFiles:src/base.ts1 hit,dist/base.d.ts0 hits,base-bind-declared.test.ts1 hit (so the@ts-expect-errorblock is real enforcement, not decoration).Predicted instrument fired, and a second independent one did too: the mirror-parity ratchet, because the mutation left the mirror declaring
bindwhile the TS side no longer did. The script carried atrap … EXIT INT TERMwith absolute paths; the restore is proven by blob-hash equality and an emptygit diff HEAD, not by the trap having run.Gates — each gate's own verdict line, at
01f4e58b3turbo run build --filter='!@object-ui/site' --concurrency=2Tasks: 43 successful, 43 totalpnpm exec vitest run packages/types/Test Files 66 passed (66)/Tests 776 passed (776)pnpm exec vitest run packages/plugin-dashboard/Test Files 81 passed (81)/Tests 764 passed (764)@object-ui/typestype-checktsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json@object-ui/plugin-dashboardtype-checktsc --noEmit && tsc -p tsconfig.test.json--filter '...@object-ui/types'type-checktype-check: Doneeslint packages/types packages/plugin-dashboarderrorCount: 0(640 pre-existing warnings;lint.ymlsets no--max-warnings)check:control-bytes✅ check-control-bytes: OK (scanned 5426 tracked text file(s); skipped 85 binary).check-changeset-presence✅ 8 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-changeset-no-major✅ No changeset declares a 'major' bump.Sweep direction, declared:
--filter '...@object-ui/types'is the prefix form — downstream consumers, which is the direction a base-type narrowing can break. The suffix form would have been upstream dependencies and would have proven nothing.Sweep sha, declared honestly: the 42-package sweep and the 43-package build were measured at
64bd3934b. The only change between that commit and01f4e58b3is insidebase-bind-declared.test.ts(git diff 64bd3934b HEAD --stat→ 1 file, +12/−5) — no package imports that file, and every ratchet family was re-run at01f4e58b3and is in the table above.Lint scope, declared. Repo-scale
turbo run lintwas narrowed to the two affected packages. That narrowing is a measurement, not a skip, on three pieces of evidence: ① the population came from eslint's own config resolution, not a guess about which files count; ② the count is from--format json— 239 files, 0 errors; ③ type-aware linting is not enabled —eslint.config.js'slanguageOptionscarries onlyecmaVersionandglobals, with noparserOptions.project/projectService— so no rule's verdict on an untouched file can depend on this diff.Out-of-scope findings — filed unassigned, not fixed here
data-tableacceptsbindand silently renders an empty body. Documented and pinned, but carrying no card; searched the 242 open issues and found none.ObjectDataTableProps.schemaandObjectGalleryProps.schemaare hand-rolled inlineschematypes with noBaseSchemain their ancestry, so every base key is undeclared there and each must hand-copy the ones it needs — which is why both carry their ownbind.ObjectGallery's has no index signature either. Adjacent to finding(types): BaseSchema's[key: string]: anyleaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155 / finding(types): ObjectViewSchema'stableandformslots declare ZERO properties — the same Omit-under-index-signature collapse as #6151, in property position #6269 but distinct: those types never reachBaseSchemaat all.Checked and not filed:
eventsas a universally-taught-but-undeclared key is already covered by #6497 (EventableSchema/UIEventMapdo declare it).Generated by Claude Code