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
22 changes: 22 additions & 0 deletions .changeset/8345-strict-authoring-face-wrapper-kinds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/types': patch
---

`deriveStrictAuthoringSchema` now walks `set`, `map`, `prefault` and `promise`
— the four schema-bearing wrappers its `default:` arm used to hand back as if
they were leaves, leaving the object inside each one OPEN and reporting nothing
(objectui#8345, the follow-up the director's review owed the card). An object
inside any of them is closed like every other object on the twin, an invented
key inside it is refused with an `unrecognized_keys` issue naming it, and an
opaque node inside it is still reported through `onOpaqueShape` — the report
path passes through the wrapper instead of stopping at it. A schema-bearing kind
the walker still has no arm for is now REPORTED under its own def type rather
than swallowed (on zod 4.4.3 that is only `success`, which can never refuse).

**Nothing on the published face moves.** None of the four kinds occurs on
`AnyComponentSchema` today — the pin file counts each at 0 on the forced graph
— so `StrictAnyComponentSchema` and `StrictSchemaNodeSchema` accept and refuse
exactly what they did, and the tolerant face is untouched as ever. The only
observable change is for a caller deriving its OWN schema through
`deriveStrictAuthoringSchema`, and it is a narrowing: an undeclared key inside a
set, map, prefault or promise is refused where it used to be silently dropped.
130 changes: 127 additions & 3 deletions packages/types/src/__tests__/strict-authoring-face-8345.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,23 @@ type Issue = {
path: PropertyKey[];
keys?: string[];
errors?: Issue[][];
issues?: Issue[];
};

/**
* Flatten a zod error tree. A refusal inside a union arrives as one
* `invalid_union` carrying a group of issue lists per arm, and a child slot is
* a union of six arms, so the interesting issue is never at the top level.
* a union of six arms, so the interesting issue is never at the top level. A
* refusal inside a `Map` or `Set` entry whose key is not a property key arrives
* the same way one level down: one `invalid_key` / `invalid_element` carrying
* the entry's own issues under `issues`.
*/
function flatten(issues: readonly Issue[], out: Issue[] = []): Issue[] {
for (const issue of issues) {
if (issue.code === 'invalid_union' && issue.errors) {
for (const group of issue.errors) flatten(group, out);
} else if (issue.issues) {
flatten(issue.issues, out);
} else {
out.push(issue);
}
Expand Down Expand Up @@ -324,7 +330,12 @@ describe('the population is closed — every reachable object on the twin, not a
});

describe('what strict could not close is enumerated, not claimed', () => {
/** Every def type the walker treats as opaque. Nothing else may be reported. */
/**
* Every def type the walker treats as opaque. Over the PUBLISHED face nothing
* else may be reported: a schema-bearing wrapper the walker has no arm for
* is reported under its own def type (the `success` case below), so a new
* kind arriving on the face reds the subset assertion and names itself.
*/
const OPAQUE_KINDS = ['custom', 'function', 'transform'] as const;

it('every limit reported over the published face is one of the recorded opaque kinds', () => {
Expand Down Expand Up @@ -370,6 +381,116 @@ describe('what strict could not close is enumerated, not claimed', () => {
expect(preprocessed.safeParse({ a: 'x', bogus: 1 }).success).toBe(true);
expect(deriveStrictAuthoringSchema(preprocessed).safeParse({ a: 'x', bogus: 1 }).success).toBe(false);
});

/**
* ⚠️ THE FOUR WRAPPER KINDS THE FIRST ROUND SWALLOWED — the director's item 3
* on objectui#8345. `set`, `map`, `prefault` and `promise` each carry a schema,
* and the walker's `default:` arm used to hand every one of them back as if it
* were a leaf: the object inside stayed OPEN and nothing was reported — a hole
* with no symptom, because none of the four is on the published face (the
* census at the end of this block pins that absence, which is why every
* control here is synthetic rather than a document). Each is walked now: the
* inner object is closed, an invented key inside it is refused and NAMED, and
* an opaque node inside is still reported — the report path passes through
* the wrapper instead of stopping at it.
*/
describe('the four wrapper kinds are walked, not swallowed — `set` / `map` / `prefault` / `promise`', () => {
const inner = z.object({ a: z.string() });
const INVENTED = 'inventedInsideWrapper';
const withInvented = { a: 'x', [INVENTED]: 1 };

it('`set`: an invented key inside the element is refused and named', () => {
const wrapped = z.set(inner);
expect(wrapped.safeParse(new Set([withInvented])).success).toBe(true); // (c) on this input
const result = deriveStrictAuthoringSchema(wrapped).safeParse(new Set([withInvented]));
expect(result.success).toBe(false);
expect(refusedKeys(result)).toEqual([INVENTED]);
});

it('`map`: the key schema AND the value schema are closed', () => {
const wrapped = z.map(inner, inner);
expect(wrapped.safeParse(new Map([[withInvented, withInvented]])).success).toBe(true);
const twin = deriveStrictAuthoringSchema(wrapped);
expect(refusedKeys(twin.safeParse(new Map([[{ a: 'k' }, withInvented]])))).toEqual([INVENTED]);
expect(refusedKeys(twin.safeParse(new Map([[withInvented, { a: 'v' }]])))).toEqual([INVENTED]);
expect(twin.safeParse(new Map([[{ a: 'k' }, { a: 'v' }]])).success).toBe(true);
});

it('`prefault`: the inner object is closed and the prefault value still applies', () => {
const wrapped = inner.prefault({ a: 'fallback' });
expect(wrapped.safeParse(withInvented).success).toBe(true);
const twin = deriveStrictAuthoringSchema(wrapped);
expect(refusedKeys(twin.safeParse(withInvented))).toEqual([INVENTED]);
expect(twin.safeParse(undefined)).toEqual({ success: true, data: { a: 'fallback' } });
});

it('`promise`: the inner object is closed (async parse — zod refuses to parse a promise synchronously)', async () => {
const wrapped = z.promise(inner);
expect((await wrapped.safeParseAsync(Promise.resolve(withInvented))).success).toBe(true);
const result = await deriveStrictAuthoringSchema(wrapped).safeParseAsync(Promise.resolve(withInvented));
expect(result.success).toBe(false);
expect(refusedKeys(result)).toEqual([INVENTED]);
});

it('the report path passes THROUGH each wrapper: an opaque node inside is still reported, with its trail', () => {
// One opaque node PER wrapper, deliberately: the walker memoises by node
// identity and reports each distinct node once, so a single shared
// instance would be reported at the first wrapper only and read as four
// silent ones.
const opaque = () => z.custom<string>((v) => typeof v === 'string');
const reported: StrictAuthoringLimit[] = [];
deriveStrictAuthoringSchema(
z.object({
inSet: z.set(opaque()),
inMapKey: z.map(opaque(), z.string()),
inMapValue: z.map(z.string(), opaque()),
inPrefault: opaque().prefault('x'),
inPromise: z.promise(opaque()),
}),
{ onOpaqueShape: (limit) => reported.push(limit) },
);
expect(reported.map((l) => `${l.kind}@${l.path}`).sort()).toEqual([
'custom@#/shape/inMapKey/keyType',
'custom@#/shape/inMapValue/valueType',
'custom@#/shape/inPrefault/innerType',
'custom@#/shape/inPromise/innerType',
'custom@#/shape/inSet/valueType',
]);
});

it('none of the four is on the published face today — the controls above are the coverage, not the corpus', () => {
// Read on the FORCED tolerant graph, so the count is of the whole face and
// not of whatever the eager part happened to hold.
StrictAnyComponentSchema.safeParse(KNOWN_GOOD);
const { kinds } = census(AnyComponentSchema);
expect(['set', 'map', 'prefault', 'promise'].map((kind) => [kind, kinds[kind] ?? 0]))
.toEqual([['set', 0], ['map', 0], ['prefault', 0], ['promise', 0]]);
// Non-vacuity: the same census sees the kinds the face IS made of.
expect(kinds.object).toBeGreaterThan(250);
expect(kinds.union).toBeGreaterThan(0);
});
});

it('a schema-bearing kind the walker has NO arm for is reported, not swallowed — `success` is the live instance', () => {
// zod 4.4.3 leaves exactly one such kind: `z.success(X)` parses X into a
// boolean and can never refuse, so walking it would move an OUTPUT value
// while closing nothing. It is reported under its own def type instead —
// the half of the reporter's contract the four wrappers above used to break.
const reported: StrictAuthoringLimit[] = [];
deriveStrictAuthoringSchema(
z.object({ flag: z.success(z.object({ a: z.string() })) }),
{ onOpaqueShape: (limit) => reported.push(limit) },
);
expect(reported).toEqual([{ kind: 'success', path: '#/shape/flag' }]);

// The control the other way: a leaf is a leaf, and nothing is reported.
const leafReports: StrictAuthoringLimit[] = [];
deriveStrictAuthoringSchema(
z.object({ s: z.string(), n: z.number(), e: z.enum(['a', 'b']), l: z.literal(1), d: z.date() }),
{ onOpaqueShape: (limit) => leafReports.push(limit) },
);
expect(leafReports).toEqual([]);
});
});

describe('the barrel is the sole entry into the module cycle', () => {
Expand Down Expand Up @@ -492,6 +613,8 @@ type CensusDef = Record<string, unknown> & { type: string };
interface Census {
/** Distinct schema nodes reached. */
nodes: number;
/** Distinct schema nodes reached, by def type — what the face is made of. */
kinds: Record<string, number>;
/** How many of those answered `typeof 'function'` (JIT instances). */
functionTyped: number;
/** Nodes whose def type is `object`. */
Expand All @@ -507,13 +630,14 @@ const isSchemaNode = (value: unknown): boolean =>

function census(schema: unknown): Census {
const seen = new Set<unknown>();
const out: Census = { nodes: 0, functionTyped: 0, objects: 0, closed: 0, openPaths: [] };
const out: Census = { nodes: 0, kinds: {}, functionTyped: 0, objects: 0, closed: 0, openPaths: [] };
const visit = (node: unknown, path: string): void => {
if (!isSchemaNode(node) || seen.has(node)) return;
seen.add(node);
out.nodes += 1;
if (typeof node === 'function') out.functionTyped += 1;
const def = (node as { _zod: { def: CensusDef } })._zod.def;
out.kinds[def.type] = (out.kinds[def.type] ?? 0) + 1;
if (def.type === 'object') {
out.objects += 1;
const catchall = def.catchall as { _zod?: { def?: { type?: string } } } | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const MATRIX: Record<string, z.ZodType> = {
nullable: z.nullable(leaf),
default: leaf.default(1),
prefault: leaf.prefault(1),
promise: z.promise(leaf),
catch: leaf.catch(0),
readonly: z.object({ a: leaf }).readonly(),
nonoptional: z.optional(leaf).nonoptional(),
Expand Down
70 changes: 63 additions & 7 deletions packages/types/src/strict-authoring-face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@
* through `onOpaqueShape`. That is the whole limit list, not a sample of it,
* and the pin file re-derives it rather than quoting this sentence.
*
* The reporter's contract is wider than that list, and the difference is the
* follow-up the director owed this card: EVERY shape the walker meets and does
* not close is reported. The first round's `default:` arm treated whatever it
* had no arm for as a leaf, so `set`, `map`, `prefault` and `promise` — each a
* wrapper carrying a schema — were handed back in silence with the object
* inside them still open and nothing reported. Those four are walked now. What
* still falls through is reported under its own def type rather than swallowed;
* on zod 4.4.3 the only schema-bearing kind that does is `success`, which
* parses its inner schema into a boolean and can never refuse, so reporting it
* is the truth and walking it would move an output value while closing nothing.
*
* One further limit is worth naming because it is invisible in the shape: a
* check installed with `.superRefine()` is a CLOSURE, and a closure that
* consults another schema keeps consulting the TOLERANT one. The live instance
Expand Down Expand Up @@ -82,7 +93,7 @@ import { SchemaNodeSchema } from './zod/base.zod.js';
// recursion-point fill is installed. The pin file asserts that end state from
// the published barrel rather than trusting this paragraph.
import { AnyComponentSchema } from './zod/index.zod.js';
import { carryRegistryMeta, cloneWithDef, internals, isZodType } from './zod/node-derivation.js';
import { carryRegistryMeta, cloneWithDef, internals, isZodType, type WalkableDef } from './zod/node-derivation.js';

/**
* One shape the strict walker could not close, reported as it is met.
Expand All @@ -91,7 +102,12 @@ import { carryRegistryMeta, cloneWithDef, internals, isZodType } from './zod/nod
* root of the walk, so a consumer can say WHERE rather than only how many.
*/
export interface StrictAuthoringLimit {
/** The zod def type that has no shape to close: `custom`, `function` or `transform`. */
/**
* The zod def type that has no shape to close — `custom`, `function` or
* `transform` — or, for a schema-bearing wrapper the walker has no arm for,
* that wrapper's own def type (reported rather than swallowed; see the
* `default:` arm).
*/
kind: string;
/** Trail from the walk root, e.g. `#/options/8/shape/props`. */
path: string;
Expand Down Expand Up @@ -134,6 +150,20 @@ export interface DeriveStrictAuthoringOptions {
* acquire). Only the three primitives above are common, and only they moved.
*/

/**
* The def members through which a node can hold another schema — the union of
* what the arms above read. A def carrying none of them is a leaf; one carrying
* any of them and reaching the `default:` arm is an unclosed shape to report.
* `getter` is absent on purpose: `lazy` is resolved before the switch.
*/
const SCHEMA_BEARING_MEMBERS = [
'shape', 'options', 'items', 'element', 'rest',
'valueType', 'keyType', 'left', 'right', 'in', 'out', 'innerType',
] as const satisfies readonly (keyof WalkableDef)[];

const carriesSchema = (def: WalkableDef): boolean =>
SCHEMA_BEARING_MEMBERS.some((member) => def[member] != null);

/**
* A walker with ONE memo. Two schemas derived through the same walker share
* their derived subgraph, so the second costs nothing and the two twins agree
Expand Down Expand Up @@ -203,6 +233,20 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): <T exte
case 'record':
out = cloneWithDef(schema, { valueType: walk(def.valueType!, `${path}/valueType`) });
break;
// A `set` keeps its element under `valueType`, exactly like `record`; a
// `map` keeps BOTH a key schema and a value schema, and a key can be an
// object as easily as a value can. These two, with `prefault` and
// `promise` below, are the wrappers the first round's `default:` arm
// swallowed — inner object open, nothing reported.
case 'set':
out = cloneWithDef(schema, { valueType: walk(def.valueType!, `${path}/valueType`) });
break;
case 'map':
out = cloneWithDef(schema, {
keyType: walk(def.keyType!, `${path}/keyType`),
valueType: walk(def.valueType!, `${path}/valueType`),
});
break;
case 'intersection':
out = cloneWithDef(schema, {
left: walk(def.left!, `${path}/left`),
Expand Down Expand Up @@ -237,9 +281,11 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): <T exte
case 'optional':
case 'nullable':
case 'default':
case 'prefault':
case 'nonoptional':
case 'readonly':
case 'catch':
case 'promise':
out = cloneWithDef(schema, { innerType: walk(def.innerType!, `${path}/innerType`) });
break;
case 'custom':
Expand All @@ -250,8 +296,17 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): <T exte
out = schema;
break;
default:
// Leaves: string, number, boolean, literal, enum, any, unknown, never,
// date, … — nothing to close and nothing to walk into.
// Leaves — string, number, boolean, literal, enum, any, unknown, never,
// date, … — have nothing to close and nothing to walk into. ⚠️ But a
// wrapper this switch has NO ARM FOR is not a leaf: it carries a schema
// the walk never reached, i.e. an unclosed shape, and the reporter's
// contract is that every unclosed shape the walker meets is reported.
// So it is reported under its own def type rather than handed back in
// silence, which is how `set` / `map` / `prefault` / `promise` slipped
// through the first round with their inner objects open. The pin file
// holds `success` — the one schema-bearing kind zod 4.4.3 still routes
// here — as the live instance, and a row of leaves as the control.
if (carriesSchema(def)) options.onOpaqueShape?.({ kind: def.type, path });
out = schema;
}
memo.set(schema, out);
Expand All @@ -265,9 +320,10 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): <T exte
* Derive the strict authoring twin of any schema on the published zod face.
*
* Every reachable object gains `catchall: z.never()`, reached through unions,
* discriminated unions, arrays, tuples, records, intersections, optionals,
* nullables, defaults, pipes and `z.lazy` (memoised, so the self-referential
* node face terminates). The returned schema has the same TypeScript type as
* discriminated unions, arrays, tuples, records, sets, maps, intersections,
* optionals, nullables, defaults, prefaults, promises, pipes and `z.lazy`
* (memoised, so the self-referential node face terminates). A schema-bearing
* wrapper with no arm here is reported through `onOpaqueShape`, never skipped. The returned schema has the same TypeScript type as
* the input and shares no mutable state with it — the input is left exactly as
* it was, which is what keeps the rendering face untouched.
*
Expand Down
Loading