Skip to content

test(test-support): confine the spec enum-vocabulary reader to one module (#5872 class 1) - #6047

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-5872-zod-internals-reader-confinement
Aug 24, 2026
Merged

test(test-support): confine the spec enum-vocabulary reader to one module (#5872 class 1)#6047
yinlianghui merged 1 commit into
mainfrom
claude/issue-5872-zod-internals-reader-confinement

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Refs #5872 — reader class (1) only, the four verbatim copies. Classes (2), (3) and (4) from the card's census stay hand-copied, so this does not close the card.

What this is

packages/test-support/src/spec-tombstones.ts says of resolvePropsShape:

Reaching into internals is confined to this module so that a gate never has to.

That was true of shape resolution and false of every other reader a spec-parity gate runs over a Zod node. This PR makes it true for one more reader class: the enum vocabulary of one key.

New module packages/test-support/src/spec-enum-options.ts, one export shapeEnumOptions(schema, key), and the four call sites converted onto it.

Census, re-derived on the merge-base (not read off the card)

Merge-base b0de7a85c. The card's census is from 2026-08-23; re-derived today:

the card saidmeasured on b0de7a85cdelta
class (1): four verbatim copies, four packages4, same four filesnone
"~10 parity tests" reading Zod internals11 files match innerType (10 tests + core/src/actions/actionKeys.ts, a comment)+1 vs "~10", and one file is not a test

Two census notes worth stating because the dispatch flagged them:

"Verbatim" verified byte-for-byte, not by eye

Each of the four readers was extracted, the schema identifier, the member key and the local variable name normalised to S / K / V, and the result hashed:

sha256(normalised) = 226c3eb04c1dafc0 x4
DISTINCT NORMALISED FORMS: 1 of 4

Raw byte lengths differ only by identifier length (337 / 344 / 335 / 331). So all four are genuinely identical — no copy differs by a ?. or a default. The class the card called verbatim really is verbatim.

Verdict preservation, measured

The shared reader is a widening of the hand copy in three ways: it resolves the shape through resolvePropsShape (all three .shape spellings plus the lazySchema() thunk, where the copies read only the plain one), walks the wrapper chain instead of assuming a single def.innerType level, and reads .options at every level so an unwrapped enum answers too. A widening is exactly where a verdict can flip, so it was measured before anything was converted — the old reader and the candidate run side by side against the installed pin (@objectstack/spec@17.2.0, zod@4.4.3):

SelectionConfigSchema.type OLD(3) ["none","single","multiple"] NEW(3) identical
AddRecordConfigSchema.position OLD(3) ["top","bottom","both"] NEW(3) identical
UserFilterFieldSchema.type OLD(5) ["select","multi-select","boolean","date-range",…] NEW(5) identical
TimelineConfigSchema.scale OLD(6) ["hour","day","week","month","quarter","year"] NEW(6) identical
DISAGREEMENTS: 0 of 4

Same arrays, same order. No verdict flipped, and no test was adjusted to keep it green — nothing needed adjusting.

Then the same suites, same invocation, before and after — counts and the test-name fingerprint, because a suite that loses one test and gains another is all-green:

filestestsfingerprint of (file, sorted test names)
before4 passed (4)31 passed (31)c909f7b78011cad5c33d
after4 passed (4)31 passed (31)c909f7b78011cad5c33d

Per file, identical both times: 8 / 5 / 10 / 8.

The only test-count movement anywhere is the new calibration suite in test-support, declared rather than folded in: 2 files / 13 tests before, 3 files / 25 tests after — the 12 new calibration tests, with the two pre-existing suites unmoved.

Confinement, falsified rather than asserted

After the change, searching the whole repo for the reader class outside packages/test-support:

A. innerType?.options | innerType?: { options outside packages/test-support -> 0
B. CONTROL — innerType inside packages/test-support/src/ -> 8
(3 executable: spec-enum-options.ts:71,72,108; 5 in the docblocks that
quote the copies this module retired)
C. CONTROL — shapeEnumOptions call sites -> 4 consumers + index + calibration

A zero with a control that finds the reader where it now lives, and finds every consumer asking through the export.

Reverse verification — direction predicted before running

Prediction, written down first: break the shared reader so it reports an empty vocabulary and all four converted files must go red, each at minimum through its own reads a non-empty enum from the spec; if only some go red the sites are not really sharing the reader.

Mutation (anchor asserted unique first, 1 occurrence): return [...options] as string[]return [] as string[] — the quiet-permissive failure this module exists to stop. Proved on disk before reading anything: injected text count 1, removed text count 0, landing site printed (spec-enum-options.ts:104).

Test Files 5 failed (5)
Tests 17 failed | 26 passed (43)

All five, and per file:

  • data-table-selection-modereads a non-empty enum from the spec, does not accept selection modes the spec rejects
  • add-record-position-spec-parityreads a non-empty enum from the spec, gives every spec position a placement that matches its name
  • user-filter-arity-spec-parityreads a non-empty enum from the spec, does not declare control types the spec rejects
  • timeline-scale-spec-parityreads a non-empty enum from the spec, declares exactly the spec scales
  • spec-enum-options.test.ts — all 5 wrapper fixtures + all 4 real-contract pairs

Exactly as predicted. Restored under trap … EXIT INT TERM; git diff HEAD --stat empty afterwards.

Worth naming what the ablation also shows: in three of the four suites, the parity assertion in the "spec accepts a name we do not implement" direction stayed green on an empty vocabulary. That is the card's own argument, reproduced — an empty option set makes half of each parity gate pass over nothing, and only the non-vacuity assertion catches it.

The new dependency edge, declared

None of @object-ui/components, @object-ui/plugin-list, @object-ui/plugin-timeline depended on @object-ui/test-support. Each gains "@object-ui/test-support": "workspace:*" in devDependencies (the README's convention; no consumer ships it), plus the pnpm-lock.yaml update. node scripts/check-phantom-dependencies.mjs exit 0.

@object-ui/test-support is private: true and never published, so no published surface widens.

Gates

Run on 3b010122f; exit codes captured before any pipe.

gateexit
pnpm --filter @object-ui/test-support run type-check (tsc --noEmit)0
pnpm --filter @object-ui/components run type-check (tsc --noEmit && tsc -p tsconfig.test.json)0
pnpm --filter @object-ui/plugin-list run type-check (same two-step)0
pnpm --filter @object-ui/plugin-timeline run type-check (same two-step)0
pnpm exec vitest run packages/test-support/src packages/plugin-list/src packages/plugin-timeline/src --maxWorkers=2 — 58 files, 770 tests0
pnpm exec vitest run packages/components/src --maxWorkers=2 — 183 files, 1681 tests0
node scripts/check-phantom-dependencies.mjs0
node scripts/check-changeset-presence.mjs0
pnpm exec eslint . in each of the four packages0 (0 errors; pre-existing warnings only, none on a changed line)

Script names are echoed in each run, so a zero-match silent pass cannot read as green. The dependency closure was built first — pnpm --filter '@object-ui/components^...' --filter '@object-ui/plugin-list^...' --filter '@object-ui/plugin-timeline^...' build, exit 0 — so a Cannot find module reading could not be mistaken for a real failure.

Changeset has empty frontmatter — this repo's "releases nothing" declaration. It is accurate: the only edits to released packages are three devDependencies lines and four test files.

Not in this PR

Per the triage fence, one reader class at a time. Left exactly as found, and why:

  • (2) array-element unwrapping — three different spellings for one question (recordDetailsInputs.spec-parity.test.ts:83, previews/__tests__/block-config.test.ts, clientValidation.optOuts.test.ts). Not verbatim, so a shared reader has to pick a behaviour where three disagree — the flip risk this card explicitly refuses to run blind. It needs its own round with its own before/after measurement.
  • (3) the wrapper-key walk — the literal ['in','out','innerType','schema','left','right'] in three files. Textually a list, not an expression; a separate reader shape.
  • (4) recordRelatedListInputs.spec-parity.test.ts:61-65 and the newer packages/types/src/__tests__/gantt-view-mode-declared.test.ts:53 — single-site _def.innerType ?? _def.type unwraps. packages/types and packages/plugin-detail both carry live sibling work this round.

Generated by Claude Code

…dule
Four spec-parity suites in four packages each carried a byte-for-byte
identical walk into Zod's internals to read a key's enum vocabulary
(verified by normalising the schema/key/local names and hashing: one
distinct form across all four). They now import one `shapeEnumOptions`
from the private, never-published `@object-ui/test-support`.
The shared reader is a widening of what the hand copies did: it resolves
the shape through `resolvePropsShape` (all three `.shape` spellings plus
the `lazySchema()` thunk), walks the wrapper chain instead of assuming a
single `def.innerType` level, and reads `.options` at every level. Against
the installed pin it returns the identical array in the identical order
for all four (schema, key) pairs, so no verdict moves.
Consolidating readers only; no schema, no type declaration and no
runtime code is touched. The other reader classes the same census named
(array-element unwrapping, the wrapper-key walk) are left as they are.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3219.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-CVK4M7Wi.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.23KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
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.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.12KB61.87KB
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)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
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)8.50KB2.88KB
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)52.40KB17.45KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 24, 2026 14:48
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 4049d91Aug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5872-zod-internals-reader-confinement branch August 24, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesdocumentationImprovements or additions to documentationpackage: componentsplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui@os-litant