From fd8099b821d88d46d2777d898a443a1f4dc48b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 11:08:44 +0000 Subject: [PATCH 1/2] fix(types): walk `set` / `map` / `prefault` / `promise` in the strict authoring face, and report what the walker still has no arm for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walker's `default:` arm treated every def kind it had no arm for as a leaf, so the four schema-bearing wrappers fell through it in silence: the object inside stayed open and nothing reached `onOpaqueShape`. That is the MINOR remainder the director's review left on objectui#8345 — "the reporter's contract is every unclosed shape is reported". - `set` (element under `valueType`) and `map` (BOTH `keyType` and `valueType`) get arms of their own; `prefault` and `promise` join the `innerType` group. - The `default:` arm now reports a def that still carries a schema under its own def type instead of handing it back; on zod 4.4.3 that is only `success`, which can never refuse, so it is reported rather than walked. - Pins: an invented key inside each of the four wrappers is refused and named (promise via async parse, as zod requires); an opaque node inside each is still reported with its trail; the four kinds are counted at 0 on the forced published face, so the synthetic controls are the coverage; `success` is reported and a row of leaves is not. `refusedKeys` learns zod's `invalid_key` / `invalid_element` wrapping for Map entries with object keys. Nothing on the published face moves: none of the four kinds occurs on `AnyComponentSchema`, so both published twins accept and refuse exactly what they did, and the tolerant face is untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVJUngS9FKfuyQ9NFR2Nbn --- ...345-strict-authoring-face-wrapper-kinds.md | 22 +++ .../strict-authoring-face-8345.test.ts | 130 +++++++++++++++++- packages/types/src/strict-authoring-face.ts | 70 +++++++++- 3 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 .changeset/8345-strict-authoring-face-wrapper-kinds.md diff --git a/.changeset/8345-strict-authoring-face-wrapper-kinds.md b/.changeset/8345-strict-authoring-face-wrapper-kinds.md new file mode 100644 index 0000000000..cf5798add5 --- /dev/null +++ b/.changeset/8345-strict-authoring-face-wrapper-kinds.md @@ -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. diff --git a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts index 3a647aeda8..d2cbf6d3d3 100644 --- a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts +++ b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts @@ -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); } @@ -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', () => { @@ -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((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', () => { @@ -492,6 +613,8 @@ type CensusDef = Record & { 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; /** How many of those answered `typeof 'function'` (JIT instances). */ functionTyped: number; /** Nodes whose def type is `object`. */ @@ -507,13 +630,14 @@ const isSchemaNode = (value: unknown): boolean => function census(schema: unknown): Census { const seen = new Set(); - 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; diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 37599bd626..3b0dcb6527 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -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 @@ -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. @@ -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; @@ -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 @@ -203,6 +233,20 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): Date: Sun, 20 Sep 2026 13:08:24 +0000 Subject: [PATCH 2/2] test(types): build `promise` in the walkable-def null-mint matrix, the kind the strict walker now names The matrix pin (objectui#9692) requires every node kind either walker NAMES to be built by MATRIX, so the null sweep visits it; the strict authoring face now names `promise`, and MATRIX built `map`, `set` and `prefault` but never `promise` because no walker had named it before. One row, per the pin's own message ("add one entry per kind rather than narrowing this assertion"). Declared surface amendment for objectui#8345 (option A, card comment 5749989361): this file, this one row, nothing else in it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVJUngS9FKfuyQ9NFR2Nbn --- packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts b/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts index 4345d1bf6d..7f14c2e84a 100644 --- a/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts +++ b/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts @@ -129,6 +129,7 @@ const MATRIX: Record = { 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(),