test(types): extend the zod-mirror anti-drift guard from BaseSchema to the whole mirror population - #5929
Conversation
…or population objectui#5680 built a derived parity construction for `BaseSchema` alone: read the mirror's own `.shape` and compare each key against the TS declaration it restates, so a widening that forgets the mirror turns red with no key list to maintain. The sibling mirrors in `packages/types/src/zod/` had nothing equivalent, and the class already had two confirmed instances (objectui#4605, #5186). That one construction now covers 163 registered mirror/declaration pairs. A runtime census reads the directory off disk and fails when an exported const is in neither the registry nor the reasoned exclusion list, so a mirror added later cannot join the population silently. 17 pairs carry measured drift today and are pinned to their exact drifted key sets as a two-way ratchet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
✅ 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
|
…-mirror-drift-guard
…me the pair on failure CI checks out the PR's MERGE with `main`, not the branch head, so the guard was measured against a base this branch never held. objectui#5855 retired `DashboardComponentSchema.aria` from the TS declaration as spec-tombstoned and renderer-dead; that key therefore dropped out of the comparison and the ledger entry recording it as drifted went stale. The two-way ratchet fired exactly as designed — a ledgered drift corrected elsewhere must not stay recorded. Measured again on the merged tree: 17 of 163 pairs drift, one key fewer than before, and that is the only entry that moved. `assertionDriftMatchesLedger` is now an assignment to `never` rather than `Expect< Equal< …, never > >`. Identical teeth, but the failure prints the offending pair instead of `Type 'false' does not satisfy the constraint 'true'`, which named neither the pair nor the key and sent one CI failure to the compiler API to diagnose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
✅ 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
|
…eads legibly Ten of the 163 registered pairs are built FROM an @objectstack/spec schema, so one side of their comparison moves when the spec moves — and three of the seventeen ledgered drifts are among them (DashboardComponentSchema, DashboardWidgetSchema, PageNodeSchema). A spec bump can therefore change a measured drift set with nothing in this repo changing. That firing is correct — a vocabulary the spec widened or withdrew is exactly what wants triage — but it should not read as a mystery. SPEC_DERIVED_PAIRS writes the dependency down, the assertion's docblock points at it as the first thing to check when the named pair is in it, and a test re-derives the set from the mirror sources so the list cannot rot. This was NOT the cause of the CI red it documents: the lockfile pinned @objectstack/spec@17.2.0 across every commit involved and the merge did not touch the lockfile. Recorded because it is a real property of the ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
✅ 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
|
|
Green at Short version of the diagnosis (full detail, with the measurement tables, is in the PR body): The divergence, not the error, was the thing to explain, and all three offered candidates are refuted by measurement:
What actually differed: CI builds Proven by controlled experiment, environment held completely fixed (same Three things changed, none of them the assertion's teeth:
Nothing narrowed, nothing Follow-up note: #5927's table listed Generated by Claude Code Generated by Claude Code |
Fixes #5684
The anti-drift construction in objectui#5680 was built for a derived parity check for
BaseSchemaalone. This extends that same construction — not a second mechanism beside it — to the whole mirror population inpackages/types/src/zod/.What a mirror is, and why nothing saw the class
A mirror restates a TypeScript declaration by hand. When the declaration widens and the mirror does not follow, the published validator refuses a spelling the published types invite and the renderer implements:
declared != enforcedon a shipped surface (@object-ui/types/zod). Two instances were found independently before anything looked for the class — #4605 (corrected by #5680) and #5186 — and both were latent until someone tripped over them.The enumeration, with its control
The population unit is a pair, not a file: an exported zod const and the TS declaration it restates. The directory has 216 exported consts across 17 files.
Controls, classified by the same pass rather than by hand:
base.zod.ts#BaseSchema. finding(types): the zod BaseSchema mirror still declares visible/disabled as boolean and ariaLabel as string, after the TS declarations widened #4605 measured five real narrowings on it and types(zod): widen the BaseSchema mirror to match its TypeScript declaration #5680 corrected them, so it must land in the registry. It does, and it is checked by the same one construction as the other 162.objectql.zod.ts#HttpMethodSchema, which is= SpecHttpMethodSubsetSchema— a bare re-export with nothing restated locally. It lands inEXCLUSIONSwith that reason.StylePropsSchema({ className, style }) with TSStyleProps(a Tailwind vocabulary:padding,margin,gap, …). They share zero keys, and the phantom pair reported five keys of drift on a mirror with no counterpart at all.assertionEveryPairOverlapsrejected it. Filed as finding(types):StylePropsSchemaandStylePropsshare a name and zero keys — the naming asserts a mirror relationship that does not exist #5928; excluded here with the collision spelled out.A second name-derived pairing failed the same way and is worth naming because it changes a number: the const
FieldConstraintsSchemamirrorsFieldValidationRules, not the like-named legacyFieldConstraintsinfield-types.ts(the flat dialect #5186 withdrew). Under the wrong pairing it reported 5 drifted keys; under the right one it reports none — #5186's correction is holding exactly. A name-derived population would have published a 5-key phantom as a finding.What the guard does
packages/types/src/__tests__/zod-mirror-parity.test.ts:.shapeand asks, per key, whether the mirror would refuse the declared type. No key list to maintain — a hand-written key list is the same artefact the drift keeps producing.EXCLUSIONS. A mirror added later cannot join the directory silently.assertionNoVacuousEntryrejects an entry whose key union has degenerated toneveror to barestring— the.passthrough()failure mode types(zod): widen the BaseSchema mirror to match its TypeScript declaration #5680 measured, where a pin came out asneverwhile five keys were demonstrably narrow.assertionEveryPairOverlapsrejects a pairing that compares nothing.base-schema-zod-mirror-parity.test.tskeeps all its #4605 runtime pins and no longer restates the construction;BaseSchemais one registered row, and its six-key pin travelled across by name.The drift the guard caught: 17 of 163
Every one is
declared != enforcedon a published surface. They are not corrected here, and that is a deliberate call: each key is a two-way question — widen the mirror, or narrow the declaration under ADR-0049 — and #4605 answered it forBaseSchemaonly by measuring what the renderer actually reads. Widening 17 mirrors to match their declarations without that measurement is consumer-side tolerance (AGENTS.md #0.1) and would publish spellings that render nothing. The full measured table, with both sides printed from the checker, is #5927.ButtonGroupSchema.variant/size,ObjectChartSchema.chartType,FormSchema.validationMode,SelectSchema.defaultValue/value,DataTableSchema.selectable,ViewSwitcherSchema.defaultView/activeView/views.FormSchema.mode(disabled|read|editvscreate|edit|view),HeaderBarSchema.variant(floatingvstransparent),FilterFieldSchema.operators(is_emptyvsis_null),DataTableSchema.rowActions(booleanvsany[]),CalendarSchema.defaultValue/value,ChatbotSchema.body— where the two sides do not differ in width but in meaning.ComboboxSchema.optionsandCommandSchema.groupsare REQUIRED by the mirror and optional in the declaration.DashboardComponentSchema,DashboardWidgetSchema(here the mirror is the stricter side — the declaration saysunknown),FilterBuilderSchema,CRUDSchema,FormSchema.fields.PageNodeSchema.pageType.PageVisualizationAlias(src/layout.ts) documents it: the TS side retains five visualization names as a sanctioned local extension while the mirror takes the spec's vocabulary by reference, and the spec repudiates them. Widening the mirror would re-add spellings the spec rejects. The card's hard clause — a guard that forbids a decision the repo has already made is a defect — is honoured by ledgering it with its reason instead of "correcting" it.Exclusions (51), by reason
.shapeto read —z.lazy/z.ZodType<any>(recursive; acceptsany, so it cannot be narrower)z.discriminatedUnion/z.unionz.enum; checked where a mirrored KEY declares itz.recordStylePropsSchema)Plus, singled out because it is the exact phantom-assertion trap this file otherwise defends against:
objectql.zod.ts#ListViewSchemais excluded because the declaration is derived FROM the mirror —ListViewSchema = ListViewInferred & ListViewRuntimePropsandListViewInferred = z.input<typeof ListViewSchema>. Asserting parity there would be true no matter what either side said.Verification
All at
8c57c5118, the pushed head — which now carries a merge oforigin/main, so the local tree is the tree CI evaluates (see the diagnosis section below). Exit codes captured before any pipe.Lint narrowing, declared with its three pieces. ① The universe came from eslint's own flat config (
eslint .inside the package), not from a guess about which files count. ②--format jsonreports 114 files linted, 0 errors, 253 pre-existing warnings. ③eslint.config.jsconfigures no type-aware linting — noprojectService, noparserOptions.project, noproject:— so no rule's verdict on a file outside this diff can depend on this diff. The repo-wide farm is CI's run.No stale-
disthazard, and it is structural rather than lucky.@object-ui/typeshas no workspace dependencies (pnpm --filter '@object-ui/types^...'→ "No projects matched the filters"); the guard resolves../zod/*.zod.jsto sibling TypeScript source, and@objectstack/specis an unmodified npm package. Nothing on the resolution path is a workspacedist/, so no mutation below could have been masked by one — and legs A and B confirm it empirically, mutating.tssources and being seen with no build step.Reverse verification — direction predicted before each run
Every leg proved its mutation on disk by grepping injected and removed text (never an editor exit code), and the script carried a
trap … EXIT INT TERMrestore.AlertSchema.variant(TS)+ 'info', mirror untoucheddata-display.zod.ts#AlertSchemaTSC_EXIT=2; tsc printedType '"data-display.zod.ts#AlertSchema"' is not assignable to type 'never'TooltipSchema.delayDuration(TS) tonumber | string, mirror untouchedoverlay.zod.ts#TooltipSchemaTSC_EXIT=2; tsc printedType '"overlay.zod.ts#TooltipSchema"' is not assignable to type 'never'neverTSC_EXIT=2;LedgerMismatchnamed all 17readdirSync→[])VITEST_EXIT=1; 2 failed, 2 passedgit diff HEAD --statemptyTSC_EXIT=0;LedgerMismatch => neverTwo legs are worth reading closely rather than skimming:
neverfor all 17. A guard that could be neutered into silence would be worth little; this one fails when it stops seeing.Legs that do not discriminate, named as required: none of the four is silent under its mutation. Leg A and leg B use the same assertion (
assertionDriftMatchesLedger) and so do not distinguish which of the two mirrors is at fault from the tsc message alone — the entry name comes from resolvingLedgerMismatch, which is why it is exported.Diagnosis — the first CI red, stated as a measurement
The first push was green locally and red in CI on the same SHA,
TS2344onassertionDriftMatchesLedger. That divergence, not the error, was the thing to explain. Three candidates were put to me and all three are refuted by measurement:git worktree addat the pushed SHA,git status --porcelainempty,pnpm install --frozen-lockfile,pnpm --filter @object-ui/types type-check->CLEAN_TYPECHECK_EXIT=0node_modules/ spec driftThe spec-version candidate, re-derived from non-stale sources
This one deserved care, because my first reading of a version came from
/home/user/objectui— the shared checkout, whose HEAD is someone else's — which is exactly the trap that produced a wrong answer for a sibling task. Every figure below comes from this branch's own worktree or fromgit show <sha>:pnpm-lock.yaml, never from the shared tree:node_modules/@objectstack/spec/package.json@objectstack+spec@17.2.0_ai@7.0.65_zod@4.4.3_3d50d4c3b(the SHA CI called red)origin/maingit diff --stat 3d50d4c3b..<merge> -- pnpm-lock.yamlOne version, every commit involved, and the merge does not touch the lockfile. CI installs with
--frozen-lockfilefrom that same file, so its resolution is identical by construction — not by inspection of a log. The spec version could not have differed.What did differ
CI checks out the PR's merge ref, not the branch head.
actions/checkout@v7underon: pull_requestbuildsrefs/pull/N/merge, so the guard was measured against a base this branch never held.mainadvanced0fce2ef81..2074a596dwhile the branch was open, and one of those commits is18a8e7db7— "fix(types): retireDashboardComponentSchema.aria— spec-tombstoned, renderer-dead" (#5830 / #5855). Its diff deletesaria?: { … }from the TS declaration.Remove
ariafrom the declaration and it leaves the comparison, so it is no longer drifted — and the ledger entry recording it as drifted goes stale. That is the ratchet's second direction firing on its first real occasion, and it is the behaviour this file was built to have: a ledgered drift corrected elsewhere must not stay recorded. Nothing on the branch changed; the base did.The controlled experiment that proves it
Environment held completely fixed — same
node_modules,@objectstack/spec@17.2.0,tsc 6.0.3,node v22.22.2— reverting onlypackages/types/src/complex.tsto its pre-merge content, verified on disk both ways:One file's content flips the verdict with every other input fixed. The second error is independent corroboration: #5855's own guard fails on the same revert, so two guards written by different authors agree that
aria's presence is the variable.What the fix changes
'aria' | 'header' | 'widgets' | 'globalFilters'->'header' | 'widgets' | 'globalFilters'. Re-measuring all 163 pairs on the merged tree gives 17 drifted pairs, one key fewer, and that is the only entry that moved.Expect< Equal< …, never > >reports onlyType 'false' does not satisfy the constraint 'true'— naming neither pair nor key, which is what sent a CI failure to the compiler API to diagnose.assertionDriftMatchesLedgeris now an assignment tonever: identical teeth, legible failure. Verified on the live failure before correcting it, and re-proven by legs A and B, which now name their pair in the tsc message itself.DashboardComponentSchemais spec-derived (BaseSchema.extend(SpecDashboardFields.shape)). 10 of the 163 registered pairs are built from a spec schema, and 3 of the 17 ledgered drifts are among them (DashboardComponentSchema,DashboardWidgetSchema,PageNodeSchema). For those, a spec bump can change a measured drift set with nothing in this repo changing.SPEC_DERIVED_PAIRSrecords the set, the assertion's docblock points at it as the first thing to check when the named pair is in it, and a test re-derives the set from the mirror sources so the list cannot rot. Ablated: dropping one entry fails that test alone (1 failed, 4 passed).Nothing was narrowed, nothing was
@ts-expect-error-ed, and CI was not re-run in hope. Both runs are reported: the clean checkout of the original SHA (green — which is what refutes the three candidates) and the full local union on the merged head (green — the table above).Scope
Guard, its tests, exclusions, ledger, changeset. No mirror and no declaration is edited. #4605 and #5186 are regression evidence and are not re-litigated. The opposite drift direction — mirror wider than the declaration, #5853's class — is explicitly outside this construction and documented as such in
NarrowerThanDeclared's docblock.Follow-ups filed unassigned: #5927 (the 17 drifted pairs, needing a per-key ruling) and #5928 (the
StylePropsname collision).Generated by Claude Code
Generated by Claude Code