From d9539fded7791a5722c1b8e942b0765051d4f9dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:08:03 +0000 Subject: [PATCH 1/2] refactor(test-support): one shared home for the Zod wrapper-key list The literal wrapper-key list `['in','out','innerType','schema','left','right']` had five copies: three TypeScript test files and two `.mjs` CI gate scripts. The copies spanned a language boundary, so the objectui#5872 class-(1) pattern was unavailable across it -- `@object-ui/test-support`'s `exports["."]` is TypeScript source, and a bare `node scripts/check-*.mjs` has no build artefact to reach. Per the 2026-08-31 ruling on objectui#6923, the DATA gets a build-free home and the walks stay with their callers: - `packages/test-support/src/zod-wrapper-keys.json` holds the list; - `packages/test-support/src/zod-wrapper-keys.ts` holds the reasoning and types it for TypeScript consumers; `index.ts` re-exports `ZOD_WRAPPER_KEYS`; - a new `exports` subpath, `./zod-wrapper-keys`, points straight at the JSON so a bare-node gate can resolve it. The workspace root declares the package so the bare specifier resolves from `scripts/`. The gates read it via `createRequire` rather than an attributed JSON import: this module is loaded both by `node` and by Vite's SSR transform (its own pin tests), and under the latter the attributed import yields no default export -- measured, as "__vite_ssr_import_N__.default is not iterable". Constraint 4 of the ruling -- empty the list and the gate must go RED -- is paid by `scripts/__tests__/zod-wrapper-keys.shared.test.ts`, which drives one fixture per key through both gates' real entry points and asserts an unlisted spelling raises `ExtractionError`. It uses fixtures rather than the installed spec on purpose: measured against @objectstack/spec@17.2.0, `ui.ActionSchema` and `automation.FlowNodeSchema` need a wrapper hop but `data.FieldSchema` and `data.ObjectSchema` expose `.shape` at depth 0, so a spec-anchored counter-test would be vacuous for the designer gate today. Sharing a FUNCTION across the language boundary is explicitly outside that ruling and is not attempted here; the two walks stay local and stay different. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk --- .changeset/shared-zod-wrapper-keys-6923.md | 10 ++ package.json | 1 + ...ow-node-config.spec-reconciliation.test.ts | 5 +- .../flow-canvas-seeds.spec-parse.test.tsx | 5 +- packages/core/package.json | 1 + .../actions/__tests__/actionKeys.pin.test.ts | 5 +- packages/test-support/README.md | 43 ++++- packages/test-support/package.json | 3 +- .../src/__tests__/zod-wrapper-keys.test.ts | 56 +++++++ packages/test-support/src/index.ts | 10 ++ .../test-support/src/zod-wrapper-keys.json | 1 + packages/test-support/src/zod-wrapper-keys.ts | 92 +++++++++++ pnpm-lock.yaml | 6 + .../__tests__/zod-wrapper-keys.shared.test.ts | 147 ++++++++++++++++++ scripts/check-action-forward-parity.mjs | 24 ++- scripts/check-designer-field-key-parity.mjs | 24 ++- 16 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 .changeset/shared-zod-wrapper-keys-6923.md create mode 100644 packages/test-support/src/__tests__/zod-wrapper-keys.test.ts create mode 100644 packages/test-support/src/zod-wrapper-keys.json create mode 100644 packages/test-support/src/zod-wrapper-keys.ts create mode 100644 scripts/__tests__/zod-wrapper-keys.shared.test.ts diff --git a/.changeset/shared-zod-wrapper-keys-6923.md b/.changeset/shared-zod-wrapper-keys-6923.md new file mode 100644 index 0000000000..317c2a9eff --- /dev/null +++ b/.changeset/shared-zod-wrapper-keys-6923.md @@ -0,0 +1,10 @@ +--- +--- + +Internal only — no user-visible change, nothing to release. + +objectui#6923: the Zod wrapper-key list that five test/gate sites each spelled +out by hand now lives once, in `packages/test-support` (a `private: true`, +never-published package), and is read by both the TypeScript suites and the +`.mjs` CI gates. Only test files, CI gate scripts and the private +`test-support` package change; no released package's runtime code is touched. diff --git a/package.json b/package.json index f6919e7416..08f4874ce8 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "devDependencies": { "@changesets/cli": "^3.0.0", "@eslint/js": "^10.0.1", + "@object-ui/test-support": "workspace:*", "@objectstack/spec": "^17.0.0", "@playwright/test": "^1.62.1", "@testing-library/dom": "^10.4.1", diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 137f80e41c..4006cd0486 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -32,6 +32,9 @@ import { describe, it, expect } from 'vitest'; import * as Automation from '@objectstack/spec/automation'; +// The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that +// walk the same internals (objectui#6923, ruled 2026-08-31). +import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support'; import { fieldsForNodeType, type FlowConfigField } from './flow-node-config'; // Feature-detected exports — absent on a spec that predates framework#4278. @@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record | null const def = (s._def ?? s.def) as Record | undefined; if (!def) return null; if (def.shape) return def.shape as Record; - for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + for (const key of ZOD_WRAPPER_KEYS) { const found = def[key] ? objectShape(def[key], depth + 1) : null; if (found) return found; } diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx index 31d0db4f43..f497ac1c91 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx @@ -47,6 +47,9 @@ import * as React from 'react'; import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import * as Automation from '@objectstack/spec/automation'; +// The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that +// walk the same internals (objectui#6923, ruled 2026-08-31). +import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support'; import { FlowCanvas } from './FlowCanvas'; import { NODE_PALETTE, defaultNodeExtras, defaultNodeLabel } from './flow-canvas-parts'; @@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record | undefined; if (!def) return null; if (def.shape) return def.shape as Record; - for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + for (const key of ZOD_WRAPPER_KEYS) { const found = def[key] ? objectShape(def[key], depth + 1) : null; if (found) return found; } diff --git a/packages/core/package.json b/packages/core/package.json index eba4ca4860..a67119bbd9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,6 +38,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.10" }, diff --git a/packages/core/src/actions/__tests__/actionKeys.pin.test.ts b/packages/core/src/actions/__tests__/actionKeys.pin.test.ts index 7060b2a454..3445b86f1a 100644 --- a/packages/core/src/actions/__tests__/actionKeys.pin.test.ts +++ b/packages/core/src/actions/__tests__/actionKeys.pin.test.ts @@ -20,6 +20,9 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import ts from 'typescript'; import { ActionSchema as SpecActionSchema } from '@objectstack/spec/ui'; +// The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that +// walk the same internals (objectui#6923, ruled 2026-08-31). +import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support'; import { ACTION_DEF_KEYS, SPEC_ACTION_KEYS, @@ -73,7 +76,7 @@ function specActionKeys(): string[] { const def = (s._def ?? s.def) as Record | undefined; if (!def) return null; if (def.shape) return shapeOf(def.shape); - for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + for (const key of ZOD_WRAPPER_KEYS) { const found = def[key] ? walk(def[key], depth + 1) : null; if (found) return found; } diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 3ced469adf..3884d7321f 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -69,10 +69,35 @@ code imports — nothing in `src/` of a released package may import this. `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. + No copy of this reader is left in-tree. Of the other Zod-internals reader + classes the same card censused, the wrapper-key list is now shared as DATA + (below); array-element unwrapping is NOT confined here yet and is still + hand-copied — converting it is a separate round, one reader class at a time. +- `src/zod-wrapper-keys.json` + `src/zod-wrapper-keys.ts` — the Zod wrapper-key + vocabulary, exported as `ZOD_WRAPPER_KEYS` (objectui#6923, ruled 2026-08-31 — + objectui#5872 class (3)). The `.json` holds the data and the `.ts` holds the + reasoning; read the `.ts` header before touching either. + + This is the one class whose copies had grown OUT of tests and into `.mjs` CI + gate scripts, so the class-(1) pattern above was unavailable across it: this + package's `exports["."]` is TypeScript source and a bare + `node scripts/check-*.mjs` has no build artefact to reach. The ruling gave the + DATA a build-free home and a subpath of its own, and drew a boundary around + it — **the walks stay with their callers**. They are not identical + (`check-designer-field-key-parity.mjs` reads `node._def ?? node.def ?? + node._zod?.def`; `check-action-forward-parity.mjs` reads `s._def ?? s.def`), + and sharing a FUNCTION across the language boundary is explicitly outside that + ruling. Consumed by those two gates plus + `packages/core/src/actions/__tests__/actionKeys.pin.test.ts`, + `packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts` + and `packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx`. + No copy of the list is left in-tree. +- `src/__tests__/zod-wrapper-keys.test.ts` — the surface half: the module is the + JSON verbatim, it is non-empty, and it reaches consumers through the package + index rather than a deep path. The half that carries the discrimination is + `scripts/__tests__/zod-wrapper-keys.shared.test.ts`, which drives one fixture + per key through **both** `.mjs` gates' real entry points, so emptying the list + — or dropping a single entry — turns them red instead of quietly permissive. - `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 `[]` @@ -87,7 +112,15 @@ code imports — nothing in `src/` of a released package may import this. - Consumers add `"@object-ui/test-support": "workspace:*"` to **`devDependencies`** — never `dependencies`, since no consumer ships it. -- Import the package root (`@object-ui/test-support`), never a deep path. +- Import the package root (`@object-ui/test-support`), never a deep path. The + single exception is `@object-ui/test-support/zod-wrapper-keys`, a declared + `exports` subpath pointing straight at a `.json` file. It exists because a + bare-node CI gate has no other way in, it carries DATA only, and it was ruled + (objectui#6923) rather than assumed. It does not license a second one — a + TypeScript consumer has the package root and must use it. - There is no build: consumers resolve the TypeScript source through the `exports` map. `pnpm --filter @object-ui/test-support type-check` reads both the modules and their tests in one program. +- The workspace ROOT declares this package too, so that `node scripts/*.mjs` + can resolve the subpath above from `scripts/`. That root entry is what makes + the bare specifier work; a gate importing it without it fails at module load. diff --git a/packages/test-support/package.json b/packages/test-support/package.json index 10b55c0bb9..33324b09c8 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -11,7 +11,8 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" - } + }, + "./zod-wrapper-keys": "./src/zod-wrapper-keys.json" }, "scripts": { "type-check": "tsc --noEmit", diff --git a/packages/test-support/src/__tests__/zod-wrapper-keys.test.ts b/packages/test-support/src/__tests__/zod-wrapper-keys.test.ts new file mode 100644 index 0000000000..5b5e41c163 --- /dev/null +++ b/packages/test-support/src/__tests__/zod-wrapper-keys.test.ts @@ -0,0 +1,56 @@ +/** + * 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. + */ + +import { describe, expect, it } from 'vitest'; + +import { ZOD_WRAPPER_KEYS } from '../zod-wrapper-keys'; +import rawJson from '../zod-wrapper-keys.json'; +import * as surface from '../index'; + +/** + * objectui#6923 — the TypeScript half of the two-language list. + * + * The `.mjs` half, and the counter-example that makes the whole thing worth + * having, live in `scripts/__tests__/zod-wrapper-keys.shared.test.ts`: this file + * cannot import a CI gate without dragging `typescript` and the repo root into a + * package's own suite. That file also owns the ON-DISK byte comparison, because + * this package's `tsc` program has no `@types/node` and so no `node:fs` — which + * is why the equality below is against the imported JSON rather than the file. + * + * What is pinned here is the surface: the re-export chain does not TRANSFORM the + * list on its way to a TypeScript consumer, and consumers reach it through the + * package index rather than a deep path. + */ + +describe('ZOD_WRAPPER_KEYS', () => { + it('re-exports the JSON data file unchanged — one source, not a copy that agrees', () => { + // Not `toBe`: the assertion is about VALUE, so that a later decision to + // freeze or copy the array in `zod-wrapper-keys.ts` does not read as drift. + // What must never change is the content or the order. + expect([...ZOD_WRAPPER_KEYS]).toEqual(rawJson); + }); + + it('is non-empty — the one property an "both sides agree" test cannot see', () => { + // An empty list satisfies every equality assertion in this file. It is also + // the measured failure mode (PR #6047: three of four parity gates stayed + // GREEN on an empty vocabulary). The load-bearing half of this pin is in + // `scripts/__tests__/zod-wrapper-keys.shared.test.ts`, which drives one + // fixture per key through both gates; this is the cheap floor under it. + expect(ZOD_WRAPPER_KEYS.length).toBeGreaterThan(0); + expect(ZOD_WRAPPER_KEYS.every((k) => typeof k === 'string' && k.length > 0)).toBe(true); + }); + + it('is on the package surface, so consumers import the package and not a deep path', () => { + // objectui#4325: a deep subpath into another package resolved only through + // this repo's vitest alias, was TS2882 for `tsc`, and was ruled out rather + // than minted as permanent API. The `.json` subpath added for objectui#6923 + // is the deliberate, narrow exception — it exists because bare `node` has no + // other way in — and it does not license a second one for TypeScript. + expect(surface.ZOD_WRAPPER_KEYS).toBe(ZOD_WRAPPER_KEYS); + }); +}); diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index b3d8e5ce3b..abb9fd5d4b 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -42,3 +42,13 @@ export { } from './spec-tombstones'; export { shapeEnumOptions } from './spec-enum-options'; + +/** + * The Zod wrapper-key vocabulary (objectui#6923). The DATA lives in + * `zod-wrapper-keys.json` so that `node scripts/check-*.mjs` can read the same + * bytes through `@object-ui/test-support/zod-wrapper-keys` — the one thing this + * package's `.` entry, being TypeScript source, cannot offer a bare-node + * consumer. `zod-wrapper-keys.ts` carries the reasoning; read it before + * touching either side. + */ +export { ZOD_WRAPPER_KEYS } from './zod-wrapper-keys'; diff --git a/packages/test-support/src/zod-wrapper-keys.json b/packages/test-support/src/zod-wrapper-keys.json new file mode 100644 index 0000000000..2aac729c20 --- /dev/null +++ b/packages/test-support/src/zod-wrapper-keys.json @@ -0,0 +1 @@ +["in", "out", "innerType", "schema", "left", "right"] diff --git a/packages/test-support/src/zod-wrapper-keys.ts b/packages/test-support/src/zod-wrapper-keys.ts new file mode 100644 index 0000000000..ed90796308 --- /dev/null +++ b/packages/test-support/src/zod-wrapper-keys.ts @@ -0,0 +1,92 @@ +/** + * 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. + */ + +/** + * ZOD WRAPPER KEYS — the one literal list both language sides read + * (objectui#6923, ruled 2026-08-31; objectui#5872 class (3)). + * + * ## What the list is + * + * The `_def` / `def` member names a Zod node exposes for the schema it wraps. + * Walking them is how a reader gets from a wrapped node — a pipe, an effect, a + * refinement, an `.optional()` — down to the object `shape` underneath. Nothing + * here is a judgement: it is a vocabulary, transcribed from Zod's internals. + * The walk that consumes it stays with each caller (see the boundary below). + * + * ## Why it needed a home of its own, and not the usual one + * + * The copies had grown OUT of TypeScript and into `.mjs` CI gate scripts, so + * they now span a language boundary. `@object-ui/test-support` is `private: + * true` and its `exports["."]` resolves to `./src/index.ts` — TypeScript + * SOURCE — so a bare `node scripts/check-*.mjs` cannot import it and there is + * no build artefact for it to reach. That is the wall objectui#6923 was filed + * to get a ruling on, and the ruling's answer is this file's shape: + * + * - the data lives in `zod-wrapper-keys.json`, which needs no build step and + * no declaration file — `resolveJsonModule` types it for every TypeScript + * consumer, and `node` reads it through the `exports` subpath directly; + * - `@object-ui/test-support/zod-wrapper-keys` is that subpath, which is what + * the two `.mjs` gates import (the workspace root declares the package as a + * devDependency so the bare specifier resolves from `scripts/`); + * - this module re-exports it for the TypeScript side, typed and documented, + * and `index.ts` carries it onto the package surface. + * + * A `.mjs` data module was the other shape the ruling allowed, and was measured + * and rejected: `index.ts` re-exporting from a `.mjs` is TS7016 in every + * CONSUMER's program (the root config sets `allowJs: false`), so it would have + * cost either `allowJs` in each of the nine dependent packages or a hand-written + * `.d.mts` — the "second source of truth, free to drift silently" that + * `tsconfig.scripts.json`'s header already argues against. JSON has neither + * cost. The price JSON does charge is that it cannot carry its own prose, which + * is why this module exists rather than a bare re-export. + * + * ## The boundary — DATA only (part of the ruling, not a preference) + * + * The ruling covers the LIST. It deliberately does not open a door for sharing + * a function across the `.mjs` / TypeScript boundary: each caller keeps its own + * walk, and the walks are legitimately not identical — the designer gate reads + * `node._def ?? node.def ?? node._zod?.def` where the action gate reads + * `s._def ?? s.def`. Consolidating THOSE is a separate question that needs its + * own ruling on its own terms; do not fold it in here. + * + * ## Non-vacuity — the duty this list leaves with its callers + * + * The failure this list exists to prevent is not "the copies disagree", it is + * what a disagreeing copy DOES: a walk that stops matching returns no shape, + * the vocabulary derived from it becomes the empty set, and every "the renderer + * implements every name the spec accepts" assertion built on it passes over + * nothing. Measured on this exact family in PR #6047: on an empty vocabulary, + * three of four parity gates stayed GREEN. + * + * So a caller owes an assertion that separates "resolved a shape" from + * "resolved nothing". Both `.mjs` gates already pay it — they raise + * `ExtractionError` rather than return an empty key set — and + * `scripts/__tests__/zod-wrapper-keys.shared.test.ts` pins the other half: that + * EVERY entry here is load-bearing, one fixture per key, so emptying this list + * (or deleting a single entry) turns those gates red instead of quiet. + * + * ⚠️ That pin is deliberately driven from fixtures, not from whatever + * `@objectstack/spec` currently ships. Measured on `@objectstack/spec@17.2.0`: + * `ui.ActionSchema` and `automation.FlowNodeSchema` are reachable ONLY through + * a wrapper key, but `data.FieldSchema` and `data.ObjectSchema` expose `.shape` + * at depth 0 — so a counter-test anchored on the installed schemas would be + * vacuous for the designer gate today, and could go vacuous for the others the + * next time upstream unwraps something. Fixtures cannot rot that way. + */ + +import keys from './zod-wrapper-keys.json'; + +/** + * The `_def` / `def` member names to walk when unwrapping a Zod node, in the + * order every in-tree reader has always tried them. + * + * `readonly` because it is a vocabulary, not a working array: a caller that + * wants to filter or reorder should copy it. The `.mjs` gates read the same + * bytes through `@object-ui/test-support/zod-wrapper-keys`. + */ +export const ZOD_WRAPPER_KEYS: readonly string[] = keys; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa49d1a53..a25fab63da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1(jiti@2.7.0)) + '@object-ui/test-support': + specifier: workspace:* + version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) @@ -1202,6 +1205,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/scripts/__tests__/zod-wrapper-keys.shared.test.ts b/scripts/__tests__/zod-wrapper-keys.shared.test.ts new file mode 100644 index 0000000000..2afb68d570 --- /dev/null +++ b/scripts/__tests__/zod-wrapper-keys.shared.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helpers. Types are INFERRED from the .mjs sources by +// `tsconfig.scripts.json` (`allowJs`) — see objectui#3494 and that file's header. +import { specShapeKeys, ExtractionError } from '../check-action-forward-parity.mjs'; +import { schemaAcceptSet } from '../check-designer-field-key-parity.mjs'; + +/** + * objectui#6923 — the counter-example the ruling asks for, in the shape it asks + * for it. + * + * ## What was consolidated, and what deliberately was not + * + * One literal Zod wrapper-key list — `['in', 'out', 'innerType', 'schema', + * 'left', 'right']` — had five copies: three TypeScript test files and two + * `.mjs` CI gates. The copies spanned a language boundary, so the + * objectui#5872 class-(1) pattern (consolidate onto `@object-ui/test-support`) + * was unavailable: that package's `exports["."]` is TypeScript SOURCE, and a + * bare `node scripts/check-*.mjs` cannot import it. + * + * The 2026-08-31 ruling gave the DATA a build-free home + * (`packages/test-support/src/zod-wrapper-keys.json`, reachable as + * `@object-ui/test-support/zod-wrapper-keys`) and drew an explicit boundary + * around it: the WALKS stay with their callers. They are not even identical — + * the designer gate reads `node._def ?? node.def ?? node._zod?.def` where the + * action gate reads `s._def ?? s.def` — and unifying THOSE needs its own ruling. + * So this file tests the two walks THROUGH THEIR OWN PUBLIC ENTRY POINTS rather + * than testing a shared function, because there is no shared function. + * + * ## Why "both sides import the same list" is not the test + * + * That assertion passes just as well when the list is EMPTY. And an empty list + * is precisely the failure this family has already been measured producing: on + * an empty vocabulary in PR #6047's ablation, the "spec accepts a name we do not + * implement" half of three of four parity gates stayed GREEN, because it filters + * an empty list. A gate that silently stops matching is that failure with CI's + * authority behind it. + * + * So the ruling's constraint 4 is what this file pays: **empty the list and the + * gate must go red.** Two halves, and the second is what makes it true: + * + * 1. every key in the list is LOAD-BEARING — one fixture per key, reachable + * only through that key, driven through each gate's real entry point. Empty + * the list, or delete any single entry, and these fail by name; + * 2. an unlisted wrapper spelling is LOUD — `ExtractionError`, never a clean + * empty verdict. That is the drift scenario itself: Zod moves a spelling, + * the list stops matching, and the gate must say so rather than derive an + * empty vocabulary and pass everything. + * + * ## Why fixtures and not the installed spec + * + * Measured against `@objectstack/spec@17.2.0` while writing this: with the + * wrapper list emptied, `ui.ActionSchema` and `automation.FlowNodeSchema` become + * unreachable (the walk returns null — the gates raise), but `data.FieldSchema` + * and `data.ObjectSchema` still resolve 71 and 42 keys, because those two expose + * `.shape` at depth 0 and never need a wrapper hop at all. + * + * A counter-test anchored on the installed schemas would therefore be VACUOUS + * for the designer gate today, and could go vacuous for the others the next time + * upstream unwraps something — silently, which is the whole complaint. Fixtures + * cannot rot that way: this file constructs nodes whose shape is reachable ONLY + * through the wrapper key under test, so the discrimination is guaranteed by + * construction rather than borrowed from a dependency. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const DATA_FILE = path.join(repoRoot, 'packages/test-support/src/zod-wrapper-keys.json'); + +/** The list as it sits on disk — the bytes both language sides resolve to. */ +const onDisk: string[] = JSON.parse(readFileSync(DATA_FILE, 'utf8')); + +/** + * A node whose object shape is reachable ONLY by stepping through `key`. + * + * `shape` is deliberately not at the top level and not on `_def` directly: both + * walks check those first and would answer without consulting the vocabulary at + * all, which would make every assertion below pass with the list emptied. + */ +const wrappedIn = (key: string) => ({ _def: { [key]: { shape: { alpha: 1, beta: 2 } } } }); + +const SHAPE_KEYS = ['alpha', 'beta']; + +describe('the shared Zod wrapper-key list (objectui#6923)', () => { + it('is a non-empty list of strings', () => { + expect(Array.isArray(onDisk)).toBe(true); + expect(onDisk.length).toBeGreaterThan(0); + expect(onDisk.every((k) => typeof k === 'string' && k.length > 0)).toBe(true); + }); + + it('still carries every spelling the five former hand copies walked', () => { + // The membership the copies agreed on the day they were consolidated. + // Growing this list is expected; SHRINKING it is the drift, and the + // load-bearing tests below are what make a shrink fail rather than go quiet. + expect(onDisk).toEqual(['in', 'out', 'innerType', 'schema', 'left', 'right']); + }); + + it('is what the package `exports` subpath resolves to, so `node` reads these bytes', () => { + const manifest = JSON.parse( + readFileSync(path.join(repoRoot, 'packages/test-support/package.json'), 'utf8'), + ); + // The mechanism the ruling named: a subpath pointing straight at the data + // file, so a bare-node gate resolves it without a build step. If this moves, + // the two `.mjs` gates stop resolving and CI says so — but it says so as a + // module-not-found deep in a gate run, which is a poor way to learn it. + expect(manifest.exports['./zod-wrapper-keys']).toBe('./src/zod-wrapper-keys.json'); + expect(manifest.private).toBe(true); + }); +}); + +describe('constraint 4 — emptying the list must turn the gates RED, not quiet', () => { + describe('check-action-forward-parity.mjs', () => { + it.each(onDisk)('resolves a shape reachable only through `%s`', (key) => { + expect(specShapeKeys(wrappedIn(key), 'fixture')).toEqual(SHAPE_KEYS); + }); + + it('raises on a wrapper spelling the list does not carry', () => { + // Not `toThrow()` alone: the gate's own error type is the contract, and a + // bare throw would be satisfied by any incidental TypeError. + expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow(ExtractionError); + expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow( + /could not resolve `fixture`'s shape/, + ); + }); + }); + + describe('check-designer-field-key-parity.mjs', () => { + // Reached through the gate's own `importSpec` seam, so the walk under test + // is the one the gate really runs — this gate keeps its walk module-private + // and the ruling's boundary says to leave it there. + const acceptSetFor = (node: unknown) => + schemaAcceptSet('FieldSchema', async () => ({ FieldSchema: node })); + + it.each(onDisk)('resolves a shape reachable only through `%s`', async (key) => { + const { accept } = await acceptSetFor(wrappedIn(key)); + expect([...accept]).toEqual(SHAPE_KEYS); + }); + + it('raises on a wrapper spelling the list does not carry', async () => { + await expect(acceptSetFor(wrappedIn('bogusWrapper'))).rejects.toThrow( + /could not resolve `FieldSchema`'s shape/, + ); + }); + }); +}); diff --git a/scripts/check-action-forward-parity.mjs b/scripts/check-action-forward-parity.mjs index dd3cd9c46b..f704bc1c6a 100644 --- a/scripts/check-action-forward-parity.mjs +++ b/scripts/check-action-forward-parity.mjs @@ -129,6 +129,28 @@ import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { isEntrypoint } from "./invoked-as.mjs"; +/** + * The wrapper-key vocabulary, shared with the TypeScript readers that walk the + * same Zod internals (objectui#6923, ruled 2026-08-31 — objectui#5872 class (3)). + * + * A bare-node gate cannot import `@object-ui/test-support` itself: that entry is + * TypeScript source with no build artefact. `/zod-wrapper-keys` is the subpath + * the ruling gave the DATA — build-free JSON — so both language sides read the + * same bytes. The WALK stays local on purpose: sharing a FUNCTION across this + * boundary is explicitly outside that ruling. + * + * `createRequire` rather than `import ... with { type: "json" }`, and the + * difference is load-bearing: this module is imported BOTH by `node` (the gate + * run) and by Vite's SSR transform (its pin tests in `scripts/__tests__/`). + * Measured — under the SSR transform the attributed JSON import yields no + * default export, and the walk fails with "__vite_ssr_import_N__.default is not + * iterable" instead of reading the list. `createRequire` is the same idiom + * `loadSpecSchemas` below already uses, and behaves identically in both. + */ +const ZOD_WRAPPER_KEYS = createRequire(import.meta.url)( + "@object-ui/test-support/zod-wrapper-keys" +); + const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, ".."); @@ -354,7 +376,7 @@ export function specShapeKeys(schema, label) { const def = s._def ?? s.def; if (!def) return null; if (def.shape) return shapeOf(def.shape); - for (const key of ["in", "out", "innerType", "schema", "left", "right"]) { + for (const key of ZOD_WRAPPER_KEYS) { const found = def[key] ? walk(def[key], depth + 1) : null; if (found) return found; } diff --git a/scripts/check-designer-field-key-parity.mjs b/scripts/check-designer-field-key-parity.mjs index 2326d74ace..1a96dc0850 100644 --- a/scripts/check-designer-field-key-parity.mjs +++ b/scripts/check-designer-field-key-parity.mjs @@ -135,6 +135,28 @@ import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { isEntrypoint } from "./invoked-as.mjs"; +/** + * The wrapper-key vocabulary, shared with the TypeScript readers that walk the + * same Zod internals (objectui#6923, ruled 2026-08-31 — objectui#5872 class (3)). + * + * A bare-node gate cannot import `@object-ui/test-support` itself: that entry is + * TypeScript source with no build artefact. `/zod-wrapper-keys` is the subpath + * the ruling gave the DATA — build-free JSON — so both language sides read the + * same bytes. The WALK stays local on purpose: sharing a FUNCTION across this + * boundary is explicitly outside that ruling. + * + * `createRequire` rather than `import ... with { type: "json" }`, and the + * difference is load-bearing: this module is imported BOTH by `node` (the gate + * run) and by Vite's SSR transform (its pin tests in `scripts/__tests__/`). + * Measured — under the SSR transform the attributed JSON import yields no + * default export, and the walk fails with "__vite_ssr_import_N__.default is not + * iterable" instead of reading the list. `createRequire` is the same idiom + * `loadSpecSchemas` below already uses, and behaves identically in both. + */ +const ZOD_WRAPPER_KEYS = createRequire(import.meta.url)( + "@object-ui/test-support/zod-wrapper-keys" +); + const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, ".."); @@ -374,7 +396,7 @@ function shapeKeys(node, depth = 0, seen = new Set()) { const def = node._def ?? node.def ?? node._zod?.def; if (!def) return null; if (def.shape) return shapeOf(def.shape); - for (const key of ["in", "out", "innerType", "schema", "left", "right"]) { + for (const key of ZOD_WRAPPER_KEYS) { const found = def[key] ? shapeKeys(def[key], depth + 1, seen) : null; if (found) return found; } From c83904d5ba6e60d1a2f71c7813defd99b858fdfc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:10:06 +0000 Subject: [PATCH 2/2] test(scripts): the per-key legs must not vanish when the list is emptied Running this file's own ablation caught it: `it.each(onDisk)` generates its cases FROM the list under test, so with the list emptied it generated NONE. The suite reported fewer tests and stayed green on exactly the part that carries the discrimination -- this card's own defect, reproduced inside its counter-example. Replaced with one looping test per gate, each asserting the non-vacuity floor IN THE SAME TEST. No data, no cases, but the floor still fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk --- .../__tests__/zod-wrapper-keys.shared.test.ts | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/scripts/__tests__/zod-wrapper-keys.shared.test.ts b/scripts/__tests__/zod-wrapper-keys.shared.test.ts index 2afb68d570..90cd5bc890 100644 --- a/scripts/__tests__/zod-wrapper-keys.shared.test.ts +++ b/scripts/__tests__/zod-wrapper-keys.shared.test.ts @@ -110,38 +110,60 @@ describe('the shared Zod wrapper-key list (objectui#6923)', () => { }); }); +/** + * ⚠️ The per-key checks below are ONE test that loops, not `it.each(onDisk)`. + * + * That distinction was measured, not stylistic. `it.each` over the list under + * test generates its cases FROM that list, so with the list emptied it + * generates NONE — the suite reports fewer tests and stays green on the part + * that matters. Running this file's own ablation caught it: emptied, the + * `it.each` legs simply vanished and only the membership pins failed. That is + * this card's defect reproduced inside its own counter-example. + * + * A loop inside one test, with the non-vacuity floor asserted IN THE SAME TEST, + * cannot go quiet that way: no data, no cases, but the floor still fails. + */ describe('constraint 4 — emptying the list must turn the gates RED, not quiet', () => { - describe('check-action-forward-parity.mjs', () => { - it.each(onDisk)('resolves a shape reachable only through `%s`', (key) => { + it('check-action-forward-parity: every entry in the list is load-bearing', () => { + expect(onDisk.length).toBeGreaterThan(0); + for (const key of onDisk) { + // A shape reachable ONLY by stepping through `key`. Drop `key` from the + // list and this throws instead of resolving. expect(specShapeKeys(wrappedIn(key), 'fixture')).toEqual(SHAPE_KEYS); - }); + } + }); - it('raises on a wrapper spelling the list does not carry', () => { - // Not `toThrow()` alone: the gate's own error type is the contract, and a - // bare throw would be satisfied by any incidental TypeError. - expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow(ExtractionError); - expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow( - /could not resolve `fixture`'s shape/, - ); - }); + it('check-action-forward-parity: raises on a wrapper spelling the list does not carry', () => { + // Not `toThrow()` alone: the gate's own error type is the contract, and a + // bare throw would be satisfied by any incidental TypeError. + expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow(ExtractionError); + expect(() => specShapeKeys(wrappedIn('bogusWrapper'), 'fixture')).toThrow( + /could not resolve `fixture`'s shape/, + ); }); - describe('check-designer-field-key-parity.mjs', () => { - // Reached through the gate's own `importSpec` seam, so the walk under test - // is the one the gate really runs — this gate keeps its walk module-private - // and the ruling's boundary says to leave it there. - const acceptSetFor = (node: unknown) => - schemaAcceptSet('FieldSchema', async () => ({ FieldSchema: node })); + // The designer gate keeps its walk module-private, and the ruling's boundary + // says to leave it there — so it is reached through the gate's OWN + // `importSpec` seam, which means the walk under test is the one it really runs. + const acceptSetFor = (node: unknown) => + schemaAcceptSet('FieldSchema', async () => ({ FieldSchema: node })); - it.each(onDisk)('resolves a shape reachable only through `%s`', async (key) => { + it('check-designer-field-key-parity: every entry in the list is load-bearing', async () => { + expect(onDisk.length).toBeGreaterThan(0); + for (const key of onDisk) { const { accept } = await acceptSetFor(wrappedIn(key)); expect([...accept]).toEqual(SHAPE_KEYS); - }); + } + }); - it('raises on a wrapper spelling the list does not carry', async () => { - await expect(acceptSetFor(wrappedIn('bogusWrapper'))).rejects.toThrow( - /could not resolve `FieldSchema`'s shape/, - ); - }); + it('check-designer-field-key-parity: raises on a wrapper spelling the list does not carry', async () => { + // ⭐ This gate is the reason the whole file uses fixtures. With the list + // emptied, the gate's REAL run stays GREEN — measured — because + // `data.FieldSchema` and `data.ObjectSchema` expose `.shape` at depth 0 and + // never need a wrapper hop. A counter-test anchored on the installed spec + // would therefore assert nothing here; this one still fails. + await expect(acceptSetFor(wrappedIn('bogusWrapper'))).rejects.toThrow( + /could not resolve `FieldSchema`'s shape/, + ); }); });