From 3b010122f2ab026406093153448e6027e5f661d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 14:27:49 +0000 Subject: [PATCH] test(test-support): confine the spec enum-vocabulary reader to one module 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. --- .../zod-internals-reader-confinement.md | 8 ++ packages/components/package.json | 1 + .../data-table-selection-mode.test.tsx | 11 +- packages/plugin-list/package.json | 1 + .../add-record-position-spec-parity.test.tsx | 11 +- .../user-filter-arity-spec-parity.test.tsx | 11 +- packages/plugin-timeline/package.json | 1 + .../timeline-scale-spec-parity.test.ts | 10 +- packages/test-support/README.md | 18 +++ .../src/__tests__/spec-enum-options.test.ts | 104 ++++++++++++++++ packages/test-support/src/index.ts | 2 + .../test-support/src/spec-enum-options.ts | 112 ++++++++++++++++++ pnpm-lock.yaml | 9 ++ 13 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 .changeset/zod-internals-reader-confinement.md create mode 100644 packages/test-support/src/__tests__/spec-enum-options.test.ts create mode 100644 packages/test-support/src/spec-enum-options.ts diff --git a/.changeset/zod-internals-reader-confinement.md b/.changeset/zod-internals-reader-confinement.md new file mode 100644 index 0000000000..4fc9998c3e --- /dev/null +++ b/.changeset/zod-internals-reader-confinement.md @@ -0,0 +1,8 @@ +--- +--- + +Test-only change. Four spec-parity suites each carried a byte-for-byte identical +hand-written walk into Zod's internals to read a key's enum vocabulary; they now +import one `shapeEnumOptions` reader from the private, never-published +`@object-ui/test-support`. No published package's runtime behaviour changes — +the only edits to released packages are a `devDependencies` entry each. diff --git a/packages/components/package.json b/packages/components/package.json index fba2d9db8a..bc5216958a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -91,6 +91,7 @@ "tailwindcss": "^4.2.1" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@radix-ui/react-focus-scope": "^1.1.16", "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", diff --git a/packages/components/src/__tests__/data-table-selection-mode.test.tsx b/packages/components/src/__tests__/data-table-selection-mode.test.tsx index 9df47a33c6..1609f0561b 100644 --- a/packages/components/src/__tests__/data-table-selection-mode.test.tsx +++ b/packages/components/src/__tests__/data-table-selection-mode.test.tsx @@ -25,6 +25,7 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import { SelectionConfigSchema } from '@objectstack/spec/ui'; +import { shapeEnumOptions } from '@object-ui/test-support'; import { renderComponent } from './test-utils'; import { SUPPORTED_SELECTION_MODES } from '../renderers/complex/data-table'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the @@ -32,14 +33,6 @@ import { SUPPORTED_SELECTION_MODES } from '../renderers/complex/data-table'; // timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; -/** The spec's selection vocabulary, read through the `.default()` wrapper. */ -function specSelectionModes(): string[] { - const typeSchema = (SelectionConfigSchema as unknown as { shape?: Record }) - .shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined; - const options = typeSchema?.def?.innerType?.options; - return Array.isArray(options) ? [...options] : []; -} - const baseSchema = { type: 'data-table' as const, columns: [{ header: 'Name', accessorKey: 'name' }], @@ -53,7 +46,7 @@ const baseSchema = { }; describe('data-table selection mode covers the spec selection vocabulary', () => { - const specNames = specSelectionModes(); + const specNames = shapeEnumOptions(SelectionConfigSchema, 'type'); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read SelectionConfigSchema.shape.type options from the spec').not.toEqual([]); diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json index 3eb263d6b4..ac9e756364 100644 --- a/packages/plugin-list/package.json +++ b/packages/plugin-list/package.json @@ -54,6 +54,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", + "@object-ui/test-support": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/spec": "^17.0.0", "@types/react": "19.2.18", diff --git a/packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx b/packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx index c956f514d4..5f65c5d250 100644 --- a/packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx +++ b/packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx @@ -24,6 +24,7 @@ import { render, waitFor, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { AddRecordConfigSchema } from '@objectstack/spec/ui'; +import { shapeEnumOptions } from '@object-ui/test-support'; import { ListView, resolveAddRecordPlacement } from '../ListView'; import type { ListViewSchema } from '@object-ui/types'; import { SchemaRendererProvider } from '@object-ui/react'; @@ -43,16 +44,8 @@ beforeAll(() => { }); }); -/** The spec's position vocabulary, read through the `.default()` wrapper. */ -function specPositions(): string[] { - const positionSchema = (AddRecordConfigSchema as unknown as { shape?: Record }) - .shape?.position as { def?: { innerType?: { options?: readonly string[] } } } | undefined; - const options = positionSchema?.def?.innerType?.options; - return Array.isArray(options) ? [...options] : []; -} - describe('resolveAddRecordPlacement covers the spec position vocabulary', () => { - const specNames = specPositions(); + const specNames = shapeEnumOptions(AddRecordConfigSchema, 'position'); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read AddRecordConfigSchema.shape.position options from the spec').not.toEqual([]); diff --git a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx index bbc00d1d03..481a220904 100644 --- a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx +++ b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx @@ -27,6 +27,7 @@ import * as React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { UserFilterFieldSchema } from '@objectstack/spec/ui'; +import { shapeEnumOptions } from '@object-ui/test-support'; import { UserFilters, FILTER_CONTROL_KINDS } from '../UserFilters'; const objectDef = { @@ -44,16 +45,8 @@ const objectDef = { }, }; -/** The spec's control-type vocabulary, read through the `.optional()` wrapper. */ -function specControlTypes(): string[] { - const typeSchema = (UserFilterFieldSchema as unknown as { shape?: Record }) - .shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined; - const options = typeSchema?.def?.innerType?.options; - return Array.isArray(options) ? [...options] : []; -} - describe('FILTER_CONTROL_KINDS covers the spec user-filter control vocabulary', () => { - const specNames = specControlTypes(); + const specNames = shapeEnumOptions(UserFilterFieldSchema, 'type'); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read UserFilterFieldSchema.shape.type options from the spec').not.toEqual([]); diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json index 4bb697caf9..2dd25ee316 100644 --- a/packages/plugin-timeline/package.json +++ b/packages/plugin-timeline/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@object-ui/data-objectstack": "workspace:*", + "@object-ui/test-support": "workspace:*", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts index 697f0418ab..b4dd49d2c4 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts +++ b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts @@ -16,17 +16,11 @@ */ import { describe, it, expect } from 'vitest'; import { TimelineConfigSchema } from '@objectstack/spec/ui'; +import { shapeEnumOptions } from '@object-ui/test-support'; import { TIMELINE_SCALES, resolveTimelineScale, generateTimeScaleHeaders } from '../renderer'; -function specScales(): string[] { - const scaleSchema = (TimelineConfigSchema as unknown as { shape?: Record }) - .shape?.scale as { def?: { innerType?: { options?: readonly string[] } } } | undefined; - const options = scaleSchema?.def?.innerType?.options; - return Array.isArray(options) ? [...options] : []; -} - describe('timeline covers the spec scale vocabulary', () => { - const specNames = specScales(); + const specNames = shapeEnumOptions(TimelineConfigSchema, 'scale'); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read TimelineConfigSchema.shape.scale options from the spec').not.toEqual([]); diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 6a095a5a7c..3ced469adf 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -60,6 +60,24 @@ code imports — nothing in `src/` of a released package may import this. (the last two converged off local structural-only copies by objectui#4947). No copy of the judgement is left in-tree: gates import this module, they do not write the criterion out again. +- `src/spec-enum-options.ts` — the spec enum-vocabulary reader: + `shapeEnumOptions(schema, key)`. Answers "which names does this key of the + contract accept?" — the question four spec-parity suites each answered with a + byte-for-byte identical hand-written walk into Zod's `def.innerType` + (objectui#5872). Consumed by + `packages/components/src/__tests__/data-table-selection-mode.test.tsx`, + `packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx`, + `packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx` + and `packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`. + No copy of this reader is left in-tree. The other Zod-internals reader classes + the same card censused — array-element unwrapping, the wrapper-key walk — are + NOT confined here yet and are still hand-copied; converting them is a separate + round, one reader class at a time. +- `src/__tests__/spec-enum-options.test.ts` — the calibration for that reader: + one synthetic fixture per wrapper spelling it claims to walk (bare enum, + `.optional()`, `.default()`, a stack, and a `lazySchema()` thunk), the `[]` + cases that keep a consuming suite's non-vacuity assertion from being a rubber + stamp, and a non-empty check against the four real `@objectstack/spec` pairs. - `src/__tests__/spec-tombstones.test.ts` — the calibration for that judge: one synthetic fixture per recognition channel (so neither can quietly stop working), plus a cross-check of the structural verdict against what the diff --git a/packages/test-support/src/__tests__/spec-enum-options.test.ts b/packages/test-support/src/__tests__/spec-enum-options.test.ts new file mode 100644 index 0000000000..720e13640b --- /dev/null +++ b/packages/test-support/src/__tests__/spec-enum-options.test.ts @@ -0,0 +1,104 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * THE ENUM READER PROVES ITSELF — once, for every parity gate that imports it + * (objectui#5872). + * + * Two halves, and the second is the one that earns the consolidation: + * + * - SYNTHETIC fixtures pin each wrapper spelling the reader claims to walk, + * ALONE. The four hand copies this module replaced read exactly one + * (`def.innerType`), so the whole value of a shared reader is the spellings + * it adds; a fixture set that only exercised the one they already had would + * leave every addition untested and free to be silently wrong. + * - the REAL `@objectstack/spec` half pins the reader against the four + * (schema, key) pairs the converged suites actually ask about, asserting + * only that each vocabulary is NON-EMPTY. The names themselves stay + * un-pinned here on purpose: each consuming suite pins its own vocabulary + * against the renderer it judges, and a copy of those names in this file + * would be a fifth place to forget — the exact shape of the problem this + * module exists to end. + * + * The negative cases matter as much: `[]` is this reader's "could not read", + * and a reader that returned a non-empty array for a member with no enum at all + * would turn every consuming suite's non-vacuity assertion into a rubber stamp. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + AddRecordConfigSchema, + SelectionConfigSchema, + TimelineConfigSchema, + UserFilterFieldSchema, +} from '@objectstack/spec/ui'; + +import { shapeEnumOptions } from '../spec-enum-options'; + +describe('shapeEnumOptions walks every wrapper spelling it claims to', () => { + const VOCAB = ['alpha', 'beta', 'gamma'] as const; + + it('reads a bare, unwrapped enum member', () => { + const schema = z.object({ key: z.enum(VOCAB) }); + expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]); + }); + + it('reads through .optional()', () => { + const schema = z.object({ key: z.enum(VOCAB).optional() }); + expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]); + }); + + it('reads through .default() — the spelling all four hand copies carried', () => { + const schema = z.object({ key: z.enum(VOCAB).default('alpha') }); + expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]); + }); + + it('reads through a stack of wrappers, not just one level', () => { + const schema = z.object({ key: z.enum(VOCAB).nullable().optional() }); + expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]); + }); + + it('reads a member of a lazily-resolved shape', () => { + const inner = z.object({ key: z.enum(VOCAB).optional() }); + // The `lazySchema()` thunk spelling `resolvePropsShape` exists for: the + // shape is a FUNCTION that must be called before it has any keys at all. + const thunked = { shape: () => inner.shape }; + expect(shapeEnumOptions(thunked, 'key')).toEqual([...VOCAB]); + }); +}); + +describe('shapeEnumOptions answers [] rather than guessing', () => { + it('returns [] for a key the shape does not carry', () => { + expect(shapeEnumOptions(z.object({ other: z.string() }), 'key')).toEqual([]); + }); + + it('returns [] for a member that is not an enum', () => { + expect(shapeEnumOptions(z.object({ key: z.string().optional() }), 'key')).toEqual([]); + }); + + it('returns [] for something that is not a schema at all', () => { + expect(shapeEnumOptions(undefined, 'key')).toEqual([]); + expect(shapeEnumOptions({}, 'key')).toEqual([]); + }); +}); + +describe('shapeEnumOptions reads the real contract the parity gates ask about', () => { + const pairs: ReadonlyArray = [ + ['SelectionConfigSchema.type', SelectionConfigSchema, 'type'], + ['AddRecordConfigSchema.position', AddRecordConfigSchema, 'position'], + ['UserFilterFieldSchema.type', UserFilterFieldSchema, 'type'], + ['TimelineConfigSchema.scale', TimelineConfigSchema, 'scale'], + ]; + + it.each(pairs)('reads a non-empty vocabulary for %s', (_label, schema, key) => { + const options = shapeEnumOptions(schema, key); + expect(options.length, 'the reader went quietly empty on a live spec enum').toBeGreaterThan(0); + expect(options.every((name) => typeof name === 'string')).toBe(true); + }); +}); diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index a5348761b2..b3d8e5ce3b 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -40,3 +40,5 @@ export { tombstoneEvidence, tombstonedShapeKeys, } from './spec-tombstones'; + +export { shapeEnumOptions } from './spec-enum-options'; diff --git a/packages/test-support/src/spec-enum-options.ts b/packages/test-support/src/spec-enum-options.ts new file mode 100644 index 0000000000..7fabc06a59 --- /dev/null +++ b/packages/test-support/src/spec-enum-options.ts @@ -0,0 +1,112 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * SPEC ENUM VOCABULARY — one reader for every parity gate that asks + * "which names does this key of the contract accept?" (objectui#5872). + * + * ## The reader this replaces + * + * Four spec-parity suites in four packages each wrote out, byte-for-byte + * identical apart from the schema and key names: + * + * const v = (Schema as unknown as { shape?: Record }) + * .shape?.key as { def?: { innerType?: { options?: readonly string[] } } } | undefined; + * const options = v?.def?.innerType?.options; + * return Array.isArray(options) ? [...options] : []; + * + * `spec-tombstones.ts` already argues at length why that is worth ending, and + * 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 everything else a parity gate reads off a Zod node. This module is + * that sentence made true for one more reader class. + * + * ## Why the copies could not move together + * + * The hand copy reads EXACTLY ONE wrapper spelling — `def.innerType` — so it + * answers correctly only while the member is wrapped exactly once and Zod + * exposes `def`. A member that stops being `.optional()`/`.default()`, or a Zod + * build that exposes only `_def`, yields `undefined` and the derived vocabulary + * silently becomes the EMPTY SET. That is the objectui#4434 failure mode: every + * "the renderer implements every name the spec accepts" assertion built on an + * empty set passes over nothing. Four textually identical copies is the sharp + * case, because a reviewer who diffs one of them sees nothing that says the + * other three did not move. + * + * ## What this reader does that the copies did not + * + * - resolves the shape through `resolvePropsShape`, so all three `.shape` + * spellings and the `lazySchema()` thunk work, not just the plain one; + * - walks the wrapper chain instead of assuming a single level, and accepts + * `unwrap()`, `def.innerType` and `_def.innerType` as the step; + * - reads `.options` at every level, so an UNWRAPPED enum member answers too. + * + * All three are widenings — this reader answers wherever a hand copy answered, + * and in cases where a hand copy went quietly empty. Verdict preservation was + * measured rather than assumed when the four call sites converged: against the + * installed pin (`@objectstack/spec@17.2.0`, `zod@4.4.3`) it returns the + * identical array, in the identical order, for all four (schema, key) pairs. + * + * ## `[]` and the non-vacuity duty it leaves with the caller + * + * `[]` means "no vocabulary could be read", and it is deliberately NOT + * distinguished from "this enum is empty" — because no spec enum is empty, and + * every caller in-tree already carries the assertion that makes the difference + * observable: one `it('reads a non-empty enum from the spec')` per suite. A + * caller that adopts this reader owes that assertion too; without it a broken + * reader and a satisfied parity check look exactly alike. + */ + +import { resolvePropsShape } from './spec-tombstones'; + +/** A Zod node, as far as unwrapping to an enum needs to see it. */ +interface EnumCarrier { + options?: unknown; + unwrap?: () => unknown; + def?: { innerType?: unknown }; + _def?: { innerType?: unknown }; +} + +/** + * How many wrappers deep to look before giving up. + * + * Bounded rather than `while (node)`: the step below is reached through + * `unknown`, so a node that unwraps to itself — a shape this reader cannot + * rule out and should not hang on — ends the walk instead of the process. Eight + * is far past anything the contract stacks today (the deepest in-tree member is + * one wrapper: `.default()` or `.optional()`). + */ +const MAX_WRAPPER_DEPTH = 8; + +/** + * The enum names one key of a props schema accepts, unwrapped past + * `.optional()` / `.default()` / `.nullable()` — `[]` when it cannot be read. + * + * Signature deliberately mirrors `shapeMemberTypeName(schema, key)` in + * `spec-tombstones.ts`: same question shape ("about ONE member of a shape"), + * same tolerance of a schema this pin does not carry. + */ +export function shapeEnumOptions(schema: unknown, key: string): string[] { + const shape = resolvePropsShape(schema); + if (!shape) return []; + + let node = shape[key] as EnumCarrier | undefined; + for (let depth = 0; node && depth <= MAX_WRAPPER_DEPTH; depth += 1) { + const options = node.options; + // Not filtered to strings: the four converging call sites did not filter + // either, and dropping a non-string member here would narrow a vocabulary + // silently — the one thing this module exists to stop. + if (Array.isArray(options)) return [...options] as string[]; + const inner = + typeof node.unwrap === 'function' + ? node.unwrap() + : (node.def?.innerType ?? node._def?.innerType); + node = inner as EnumCarrier | undefined; + } + return []; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deb53ec2fe..74a7da38c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1135,6 +1135,9 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@radix-ui/react-focus-scope': specifier: ^1.1.16 version: 1.1.16(patch_hash=41d44316f54bd1c52774d12640dfe9062604489561eee39886b1912c4197e822)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2263,6 +2266,9 @@ importers: '@object-ui/react': specifier: workspace:* version: link:../react + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@object-ui/types': specifier: workspace:* version: link:../types @@ -2522,6 +2528,9 @@ importers: '@object-ui/data-objectstack': specifier: workspace:* version: link:../data-objectstack + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@types/react': specifier: 19.2.18 version: 19.2.18