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..90cd5bc890 --- /dev/null +++ b/scripts/__tests__/zod-wrapper-keys.shared.test.ts @@ -0,0 +1,169 @@ +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); + }); +}); + +/** + * ⚠️ 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', () => { + 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('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/, + ); + }); + + // 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('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('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/, + ); + }); +}); 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; }