Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/shared-zod-wrapper-keys-6923.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -105,7 +108,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, unknown> | null
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, unknown>;
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;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -121,7 +124,7 @@ function objectShape(schema: unknown, depth = 0): Record<string, ZodLike | undef
const def = (s._def ?? s.def) as Record<string, unknown> | undefined;
if (!def) return null;
if (def.shape) return def.shape as Record<string, ZodLike | undefined>;
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -73,7 +76,7 @@ function specActionKeys(): string[] {
const def = (s._def ?? s.def) as Record<string, unknown> | 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;
}
Expand Down
43 changes: 38 additions & 5 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `[]`
Expand All@@ -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.
3 changes: 2 additions & 1 deletion packages/test-support/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/test-support/src/__tests__/zod-wrapper-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
1 change: 1 addition & 0 deletions packages/test-support/src/zod-wrapper-keys.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
["in", "out", "innerType", "schema", "left", "right"]
92 changes: 92 additions & 0 deletions packages/test-support/src/zod-wrapper-keys.ts
Original file line numberDiff line numberDiff line change
@@ -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;
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading