Skip to content

test(types): ledger the declared-but-unmirrored half of the zod mirror comparison - #6149

Merged
yinlianghui-tw merged 3 commits into
mainfrom
claude/issue-6058-zod-mirror-union
Aug 24, 2026
Merged

test(types): ledger the declared-but-unmirrored half of the zod mirror comparison#6149
yinlianghui-tw merged 3 commits into
mainfrom
claude/issue-6058-zod-mirror-union

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6141
Part of #6058

Round 2 of #6058, implementing the PM's ruling on all three open questions. Round 1
stopped rather than grow a ledger or soften a comparison; this round lands the shape the
ruling chose. Union gate run at ccde25fab.

⚠️ Read this before reviewing the evidence — the obvious reading is wrong twice

1. The runtime half does NOT move, and that is correct.zod-mirror-parity's
describe block reads Tests 5 passed (5) before the fix, after the fix, and under the
ablation. It is a population census — the registry is closed, SPEC_DERIVED_PAIRS
re-derives, every exclusion carries a reason — and it never compared keys at all. The
whole comparison this PR changes is compile-time. A reviewer who reads "the guard now
reddens" as a change in that 5-passed line will conclude the fix does nothing.

2. The file's exit code was not the discriminating signal on the un-seeded tree.
Round 1 measured PARITY_COMPILE_EXIT=2both before and after the ablation, because
the union comparison was already red on the 23 pre-existing pairs. What changed was the
per-pair drift set. Seeding the ledger is what restores the exit code as a usable
signal — and only while the tree is green at rest. Both readings are now written into the
file so the next person does not re-derive the wrong one.

What lands

Q1 was ruled option A: a second, separately ledgered class.

DriftOf goes back to NarrowerThanDeclared alone, so KnownDrift and
NarrowerThanDeclared are untouched
— 12 entries, 17 keys, shrink-only, and #5927 /
#6032's citable 17 → 13 shrink stays comparable with everything after it. The half that
was invisible until this card lands as UnmirroredOf against its own shrink-only ledger,
UnmirroredDeclared, seeded at the measured 16 entries / 121 keys.

⚠️A seed of 121 is a FLOOR, not a waiver of 121 defects. Before this ledger the guard
saw zero of them: NarrowerThanDeclared maps over the INTERSECTION of the mirror's
key set and the declaration's — MirroredKeys of M, intersected with keyof D — so a
declared key the mirror never mentions did not compare unequal, it left the comparison.
Nothing previously caught is being let through, because there was no such thing.
Visible-and-ratcheted facts go from 0 to 121, and any new declared-but-unmirrored key
on any of the 158 pairs reddens immediately. Same shape and same discipline as #6133's
KNOWN_HAND_TYPED_GUARDS.

Q4 was ruled option A — route, do not exempt. The 3 spec-derived entries (14 keys:
DashboardComponentSchema +title, DashboardWidgetSchema +pagination/searchable,
ObjectViewSchema at 11) are marked in the ledger for #2231; the other 13 entries
(107 keys) are local mirror omissions. The instrument itself is unchanged for them —
exempting spec-derived pairs would re-blind exactly the pairs #5927 leaned on hardest.

Q2 was ruled option A — the reading-versus-rejecting distinction is documented inline
on WithoutIndexSignature. #5155's ceiling on what a mirror can REJECT stands untouched;
what this lifts is what the GUARD can READ.

None of the 121 keys are mirrored here, per the order. They are handed back for a
follow-up lane, already sorted.

Fixes#6141 — the stale prose, in the same file and the same verification surface

The header said "163 pairs" twice and "13 entries" once. Measured: 158 pairs
(Object.keys(MIRRORS).length, which assertionRegistryHalvesAgree already pins equal to
keyof Declared) and 12 entries. The file's own "146 pairs with no entry" was the
number that was right — 158 − 12 = 146. Each count now states where it came from, because
these are the numbers other cards quote as the size of the drift problem: #5927's framing
and #6058's own dispatch both inherited "163".

Verification

Every exit code captured before any pipe; each gate quotes its own verdict line.
vitest from the repo root, never package-scoped (#3378).

The two-instruments ablation, carried forward with the seeded ledger

Same mutation as the card: revert only the zod mirror to its pre-#5903 state, leaving
the ten TS declarations on ObjectGanttSchema intact. Applied by an anchored script that
exits non-zero on an anchor miss, under trap … EXIT INT TERM restoring with
git checkout HEAD -- (never the bare form, which reads the mutation back and exits 0).

Mutation confirmed on disk, not by the editor's exit code — each of the ten keys
counted 1 → 0 in the mirror, the objectui#5903 banner 0, and the deliberately-untouched
control on the other side still present (skipWeekends in objectql.ts: 2,
mobileReadOnly: 1).

readingbefore mutationafter mutation
the ablated pair's measured unmirrored set[]the ten keys, exactly
tsc -p tsconfig.test.jsonEXIT=0, 0 linesEXIT=2, names the pair
runtime halfTests 5 passed (5), EXIT=0Tests 5 passed (5), EXIT=0
gantt-declared-keys.test.ts9 passed, EXIT=02 failed | 7 passed, EXIT=1

The after-set is criticalPath, holidays, markers, mobileReadOnly, navigation, persistLayout, readOnly, showBaselines, skipWeekends, viewName — exactly the ten #5903
declared, no more and no less. Restore leg verified: git status --short empty.

The new ratchet is SHOWN to fail, in both directions

⭐ A baseline that has never been shown to fail is indistinguishable from no baseline, and
a run over today's tree can only ever show that today's tree is green. Recognition is
pinned two ways.

Durably, in the file — both reconciliations route through one exported
ReconcileAgainstLedger, driven by synthetic pairs: agreement → silent; a clean pair →
silent; growth on a clean pair → red; growth on a ledgered pair (which is also a
fact deleted from the seed) → red; a stale key → red; a stale whole entry → red.
They are mutually non-vacuous: a degenerate never implementation fails the pins that
expect the pair key, and a degenerate pass-through fails the pins that expect never.

End-to-end, on the real tree — three further ablations, each anchor-checked and
trap-restored:

  • STALE: inject wrapperClass into the InputSchema mirror. Measured set goes
    ['wrapperClass'][]; EXIT=2 naming form.zod.ts#InputSchema.
  • SEED DELETION: delete the whole InputSchema entry and one key (viewTabBar) from
    ObjectViewSchema's 11, mirrors untouched. EXIT=2 naming both
    form.zod.ts#InputSchema and objectql.zod.ts#ObjectViewSchema — a fact removed from
    the seed is not silently re-acceptable, whether it is a whole entry or one key inside
    one.
  • MANY OFFENDERS: neutralise all 10 single-line entries → the error prints five names,
    ... 4 more ..., and the last.

The recorded trap, sharpened

The round-1 note said the assertion "names only its FIRST offending member". Measuring it
properly gives a more useful rule: the two spellings print differently. Through a
named alias (assertionDriftMatchesLedger via LedgerMismatch) TS prints the alias
name and elaborates with exactly one member and no ellipsis, so 23 offenders read as
one, and --noErrorTruncation does not expand it. Written inline (as the new
assertion is) TS resolves the union and prints a real ... N more ... marker. That is why
the new assertion is spelled inline, and it is written into the file.

The authoritative read is still the compiler API — a Program over tsconfig.test.json,
one binding per pair, walking each type's union members via isStringLiteral().value,
bypassing type printing entirely. Non-vacuity control: it read all 158 pairs and
returned an EMPTY set for 142
, and its output matched the seeded ledger exactly.

Gates at ccde25fab

gateexitits own verdict line
tsc -p packages/types/tsconfig.test.json00 lines of output — ⭐ this is what makes the round landable where round 1 was not
pnpm --filter @object-ui/types type-check0echoes tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json (script name confirmed, not a zero-match)
vitest, repo root, whole packages/types0Test Files 58 passed (58) / Tests 632 passed (632)
check:changeset-presence0"1 source file(s) of 1 released package(s) changed … Every one of them has an EMPTY frontmatter"
check:changeset-fixed0"All workspace packages are in the changeset fixed group."
check:changeset-no-major0"No changeset declares a major bump."
check:control-bytes0"OK (scanned 5088 tracked text file(s); skipped 85 binary)"
check:lint-coverage0"46/46 packages linted, 0 with outstanding errors"
check:type-check-coverage0"41/41 packages compile their tests, 0 declared debt"

Control-byte scan beyond the gate on the changed files: grep -naP exit 1, no match.

eslint narrowing, declared with all three pieces of evidence — targeted run on the one
changed file, EXIT=0, 0 errors / 0 warnings. (1) The population is read from eslint's own
config resolution, not from a guess about which files count; (2) the file count 1 is read
from the --format json array length; (3) invariance for untouched files — eslint.config.js
declares no projectService and no parserOptions.project (grep count 0), so type-aware
linting is off and nothing in this diff can move the verdict on a file it did not touch.
Three present, so this is a measurement. The repo-wide pnpm lint run remains CI's.

The changeset has empty frontmatter: nothing outside __tests__/ is touched, so this
releases nothing.


Generated by Claude Code

…rrored keys
The forward comparison mapped over the INTERSECTION of the mirror's shape keys
and `keyof` the declaration, so a key declared on the TS side and absent from
the mirror did not compare unequal — it left the comparison entirely. The
reverse direction was separately erased by `BaseSchema`'s `[key: string]: any`,
which collapses `keyof` to bare `string`.
`WithoutIndexSignature` recovers a declaration's own literal members through a
homomorphic mapped type, and `ForwardDrift` unions the narrowness check with
the declared-but-unmirrored set.
Part of #6058 — instrument only; the measurement it produces is the card's
deliverable and the ledger question it raises is a ruling, not an edit.
…r comparison
Round 2 of objectui#6058, implementing the PM's Q1 option A ruling. The union
comparison landed on the branch at cef2559 as one type; the ruling splits where
it is RECORDED, not what it compares.
`DriftOf` goes back to `NarrowerThanDeclared` alone, so `KnownDrift` keeps its
meaning ("the mirror declares the key and REFUSES its declared type"), its 12
entries and its citable 17 -> 13 history from objectui#5927 / PR objectui#6032,
untouched. The half that was invisible until this card — a declared key the mirror
has never heard of, which did not compare unequal but LEFT the comparison — lands
as `UnmirroredOf` against its own shrink-only ledger, `UnmirroredDeclared`, seeded
at the measured 16 entries / 121 keys.
A seed of 121 is a FLOOR, not a waiver: the guard saw ZERO of these before, so
nothing previously caught is let through, and visible-and-ratcheted facts go from
0 to 121. Same shape as objectui#6133's KNOWN_HAND_TYPED_GUARDS.
Both reconciliations now route through one exported `ReconcileAgainstLedger` —
semantics-preserving for the existing assertion — so the recognition pins under it
cover both. Those pins drive it with synthetic pairs in every direction a ledger
can be wrong (growth on a clean pair, growth on a ledgered pair, a stale key, a
stale entry), because a run over today's tree can only ever show today's tree is
green.
Q4's routing is recorded rather than exempted: the 3 spec-derived entries (14 keys)
are marked for objectui#2231; the other 13 (107 keys) are local mirror omissions.
Fixes objectui#6141 in passing: the header's "163 pairs" (twice) and "13 entries"
were stale, and they are the numbers other cards quote as the size of the drift
problem. Corrected to the measured 158 / 12, with each number's provenance stated.
Part of #6058
Both measured on this branch rather than assumed, because the difference is what
cost objectui#6058 a pass. Through a NAMED alias TS prints the alias and elaborates
with exactly ONE member and no ellipsis, so 23 offending pairs read as one; written
INLINE it resolves the union and prints a real `... N more ...` marker. That is why
the new assertion is spelled inline, and it is worth writing down so the next reader
does not re-derive a wrong count from a truthful-looking error.
Also corrects the exit-code framing on `assertionUnmirroredMatchesLedger`. The
round-1 reading — exit 2 before AND after the ablation — was a property of the
UN-SEEDED tree, where the comparison was already red on 23 pairs. Seeding restored
the exit code as a usable signal; the note now says which state it describes and
that the per-pair set is the reading either way. The runtime half's
`Tests 5 passed (5)` does not move in either state, which is the half a reviewer is
most likely to misread.
Part of #6058
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.1 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-CBugrLTy.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)168.48KB46.47KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto 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): zod-mirror-parity's prose says "163 pairs" and "13 entries"; the registry measures 158 pairs and 12 ledger entries

2 participants

@yinlianghui-tw@claude