From 4f9db1319a1705ba87352fefb089f7c489846fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:06:09 +0000 Subject: [PATCH 1/4] feat(types): derive and export the strict authoring twin of the node face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish `StrictAnyComponentSchema`, `StrictSchemaNodeSchema` and `deriveStrictAuthoringSchema` from `@object-ui/types/zod` — a derived twin of the node face that closes every declared object at every depth. Additive: the rendering face keeps its `.passthrough()`, no accept set moves, and no consumer is wired to the new face. The twins are derived, never hand-written. Objects are cloned by patching a copy of their own def so `.refine()` / `.superRefine()` checks survive; a twin rebuilt with `z.object(shape)` would drop them and under-report red. Both sides of a pipe are walked, so a `z.preprocess`'s real schema is closed too. Part of #8345 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/8345-strict-authoring-face.md | 30 ++ packages/types/README.md | 35 ++ .../strict-authoring-face-8345.test.ts | 363 ++++++++++++++++++ packages/types/src/strict-authoring-face.ts | 312 +++++++++++++++ packages/types/src/zod/index.zod.ts | 33 ++ 5 files changed, 773 insertions(+) create mode 100644 .changeset/8345-strict-authoring-face.md create mode 100644 packages/types/src/__tests__/strict-authoring-face-8345.test.ts create mode 100644 packages/types/src/strict-authoring-face.ts diff --git a/.changeset/8345-strict-authoring-face.md b/.changeset/8345-strict-authoring-face.md new file mode 100644 index 0000000000..6b88b46de6 --- /dev/null +++ b/.changeset/8345-strict-authoring-face.md @@ -0,0 +1,30 @@ +--- +'@object-ui/types': minor +--- + +Publish the derived strict authoring face from `@object-ui/types/zod` +(objectui#8345, under the objectui#5250 ruling — maintainer 2026-09-04, +decision batch #25, option 2: "each node schema gets a derived strict variant; +`objectui validate` and the doc-snippet gates run strict; renderer props keep +the tolerant face unchanged"). + +**Additive. No existing accept set moves.** `BaseSchema` keeps its +`.passthrough()`, every published mirror keeps the documents it accepts today, +and no consumer in this repository is wired to the new face — wiring +`objectui validate` and the JSON-fence gate is a separate card. What is new: + +- `StrictAnyComponentSchema` — the document-root twin of `AnyComponentSchema`, + refusing any undeclared key at any depth with an `unrecognized_keys` issue + that names it. +- `StrictSchemaNodeSchema` — the child-slot twin of `SchemaNodeSchema`. +- `deriveStrictAuthoringSchema(schema, options)` — the derivation itself, so a + consumer can take the strict twin of any schema on the face rather than + writing a second walker. + +The twins are **derived**, never hand-written: every reachable object is closed +through unions, discriminated unions, arrays, tuples, records, intersections, +optionals, nullables, defaults, both sides of a pipe, and `z.lazy`. Objects are +cloned by patching a copy of their own def, so `.refine()` and `.superRefine()` +checks survive — a twin rebuilt with `z.object(shape)` would drop them and +under-report. Opaque `custom` / `function` / `transform` validators have no +shape to close and are reported through `onOpaqueShape` rather than skipped. diff --git a/packages/types/README.md b/packages/types/README.md index 08a68ae24d..1249b44994 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -121,6 +121,41 @@ function renderComponent(schema: AnySchema) { type ButtonSchema = SchemaByType<'button'>; ``` +### The strict authoring face + +`@object-ui/types/zod` publishes **two** faces over the same declarations. + +- The **rendering face** (`AnyComponentSchema`, `SchemaNodeSchema`, every named + mirror) is tolerant: a node may carry keys the schema does not declare, because + renderer props ride through it. +- The **strict authoring face** is a derived twin that closes every declared + object, at every depth. It is meant for authoring-time checking — validating a + document a person or an agent just wrote — where an undeclared key is far more + likely to be a typo than a renderer prop. + +```typescript +import { + AnyComponentSchema, + StrictAnyComponentSchema, + deriveStrictAuthoringSchema, +} from '@object-ui/types/zod'; + +const document = { type: 'card', childrn: [] }; // note the typo + +AnyComponentSchema.safeParse(document).success; // true — the tolerant face +StrictAnyComponentSchema.safeParse(document).success; // false — `unrecognized_keys: ["childrn"]` + +// Take the strict twin of any schema on the face: +const StrictButton = deriveStrictAuthoringSchema(ButtonSchema); +``` + +The twins are derived from the mirrors, never hand-written, so they cannot drift +from them. Strictness here is a property of the parse, not of the declaration: +the derived schema carries the same TypeScript type as the schema it came from. +Opaque `custom` / `function` / `transform` validators have no shape to close; +`deriveStrictAuthoringSchema` reports each one it meets through the optional +`onOpaqueShape` callback. + ## Type Categories ### Base Types diff --git a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts new file mode 100644 index 0000000000..3e8fd827f3 --- /dev/null +++ b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts @@ -0,0 +1,363 @@ +/** + * 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. + */ + +/** + * The derived STRICT AUTHORING FACE, and the three things objectui#8345 owes as + * pins rather than promises (objectui#5250, maintainer 2026-09-04, decision + * batch #25, option 2): + * + * (a) a known-good document parses under the strict face; + * (b) a document with one invented top-level key is REFUSED, with an + * `unrecognized_keys` issue NAMING that key; + * (c) the tolerant face is behaviourally unchanged on the same inputs. + * + * (c) is the load-bearing one and the reason several tests below assert things + * that read as obvious: the card publishes a SECOND face, and the whole appetite + * rests on the first one not moving. "Unchanged" that nobody measures is a + * promise; the tables below are the measurement. + * + * ## Two further properties this file pins, because the card's risk is there + * + * **The recursion point.** Since objectui#8344 a child slot resolves its + * component arm to the component union rather than to the ~21 base keys. A + * strict twin derived over the OLD recursion point would refuse every child + * node's own declared props — the order-of-magnitude error objectui#7935 exists + * to prevent, and the reason this card was blocked behind #8344. So a child + * node's OWN component props being accepted is pinned as directly as the + * invented key being refused. + * + * **Checks survive the clone.** The walker clones by patching a copy of the + * def, ⛔ never by rebuilding with `z.object(shape)`. A rebuilt twin drops + * `def.checks`, so every `.refine()` / `.superRefine()` on the way down is lost + * and the face UNDER-reports red — the one failure direction that reads as good + * news. Both the synthetic control and the live instance are below. + * + * ## Instruments, and one that is NOT one + * + * ⚠️ `vitest` does not typecheck. The type-level pins at the bottom are read by + * `tsc -p tsconfig.test.json` (the third program of this package's `type-check` + * script) and by nothing else — a green vitest run says nothing about them. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + AnyComponentSchema, + deriveStrictAuthoringSchema, + StrictAnyComponentSchema, + StrictSchemaNodeSchema, + type StrictAuthoringLimit, +} from '../zod/index.zod.js'; +import { SchemaNodeSchema } from '../zod/base.zod.js'; + +/* ── Reading a refusal ───────────────────────────────────────────────────── */ + +type Issue = { + code: string; + path: PropertyKey[]; + keys?: string[]; + errors?: 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. + */ +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 { + out.push(issue); + } + } + return out; +} + +/** Every key named by an `unrecognized_keys` issue anywhere in the refusal. */ +function refusedKeys(result: z.ZodSafeParseResult): string[] { + if (result.success) return []; + const keys = flatten(result.error.issues as unknown as Issue[]) + .filter((i) => i.code === 'unrecognized_keys') + .flatMap((i) => i.keys ?? []); + return [...new Set(keys)].sort(); +} + +/* ── Documents ───────────────────────────────────────────────────────────── */ + +/** Declared keys only, one level of nesting, two different component types. */ +const KNOWN_GOOD = { + type: 'card', + title: 'Quarterly figures', + children: [ + { type: 'button', label: 'Export', variant: 'default' }, + { type: 'text', content: 'Updated hourly' }, + ], +} as const; + +const INVENTED_TOP_LEVEL_KEY = 'inventedTopLevelKey'; +const INVENTED_CHILD_KEY = 'inventedChildKey'; + +const INVENTED_AT_ROOT = { ...KNOWN_GOOD, [INVENTED_TOP_LEVEL_KEY]: 1 }; +const INVENTED_AT_CHILD = { + type: 'card', + children: [{ type: 'button', label: 'Export', [INVENTED_CHILD_KEY]: 1 }], +}; + +describe('the strict authoring face — positive controls', () => { + it('the published barrel exports the derived faces and the derivation', () => { + expect(typeof StrictAnyComponentSchema.safeParse).toBe('function'); + expect(typeof StrictSchemaNodeSchema.safeParse).toBe('function'); + expect(typeof deriveStrictAuthoringSchema).toBe('function'); + }); + + it('the tolerant face admits an undeclared key — the control every table below is read against', () => { + // If this ever goes false, the rendering face's `.passthrough()` was + // flipped and no assertion in this file means what it says. + expect(AnyComponentSchema.safeParse(INVENTED_AT_ROOT).success).toBe(true); + expect(AnyComponentSchema.safeParse(INVENTED_AT_CHILD).success).toBe(true); + }); +}); + +describe('(a) a known-good document parses under the strict face', () => { + it('accepts it', () => { + expect(StrictAnyComponentSchema.safeParse(KNOWN_GOOD).success).toBe(true); + }); + + it('and the tolerant face accepts the same document — the two agree where nothing is invented', () => { + expect(AnyComponentSchema.safeParse(KNOWN_GOOD).success).toBe(true); + }); +}); + +describe('(b) one invented top-level key is refused, and the key is named', () => { + it('refuses, with an `unrecognized_keys` issue naming exactly that key', () => { + const result = StrictAnyComponentSchema.safeParse(INVENTED_AT_ROOT); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toEqual([INVENTED_TOP_LEVEL_KEY]); + }); + + it('the same document is accepted by the tolerant face — the difference is the whole card', () => { + expect(AnyComponentSchema.safeParse(INVENTED_AT_ROOT).success).toBe(true); + }); +}); + +describe('(c) the tolerant face is behaviourally unchanged — a pin, not a promise', () => { + /** + * Read AFTER the strict derivation has been forced, deliberately: the walk is + * deferred behind `z.lazy`, so a table read before it would not be evidence + * about anything the derivation does. + */ + it('every verdict of the tolerant face is what it was, with the strict face fully derived', () => { + StrictAnyComponentSchema.safeParse(KNOWN_GOOD); + StrictSchemaNodeSchema.safeParse(KNOWN_GOOD); + + const table: Array<[label: string, input: unknown, accepted: boolean]> = [ + ['a known-good document', KNOWN_GOOD, true], + ['an invented key at the root', INVENTED_AT_ROOT, true], + ['an invented key on a child', INVENTED_AT_CHILD, true], + ['a node whose `type` resolves in no arm', { type: 'no-such-component' }, false], + ['a node with no `type` at all', { label: 'x' }, false], + ]; + + expect(table.map(([label, input]) => [label, AnyComponentSchema.safeParse(input).success])) + .toEqual(table.map(([label, , accepted]) => [label, accepted])); + }); + + it('deriving mutates nothing: the tolerant graph carries exactly the closed objects it carried before', () => { + // `catchall: never` IS strictness. Some objects on the face are strict + // already — the schemas imported from `@objectstack/spec` are — so the + // reading that matters is that the count does not MOVE, not that it is zero. + const before = closedObjectCount(AnyComponentSchema); + const twin = deriveStrictAuthoringSchema(AnyComponentSchema); + twin.safeParse(KNOWN_GOOD); // force the deferred subtrees + expect(closedObjectCount(AnyComponentSchema)).toBe(before); + expect(before).toBeGreaterThan(0); // non-vacuity: the counter sees something + }); +}); + +describe('a nested node is judged by its own component schema (objectui#8344 is load-bearing)', () => { + it('refuses an invented key on a CHILD node, naming it', () => { + const result = StrictAnyComponentSchema.safeParse(INVENTED_AT_CHILD); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toEqual([INVENTED_CHILD_KEY]); + }); + + it("accepts a child's OWN component props — a strict twin of the pre-#8344 recursion point would refuse these", () => { + // `variant` and `size` are declared by `ButtonSchema` and by NOTHING in the + // base key set. If the child slot were judged by a strict `BaseSchemaCore`, + // both would come back as unrecognised — the order-of-magnitude error the + // blocker existed to prevent, spelled as an assertion. + const result = StrictAnyComponentSchema.safeParse({ + type: 'card', + children: [{ type: 'button', label: 'Export', variant: 'destructive', size: 'sm' }], + }); + expect(refusedKeys(result)).toEqual([]); + expect(result.success).toBe(true); + }); +}); + +describe('checks survive the clone — a twin rebuilt with `z.object(shape)` would not', () => { + const refined = z.object({ a: z.number() }).refine((v) => v.a > 0, 'a must be positive'); + + it('the derived twin still refuses what the refinement refuses', () => { + expect(deriveStrictAuthoringSchema(refined).safeParse({ a: -1 }).success).toBe(false); + expect(deriveStrictAuthoringSchema(refined).safeParse({ a: 1 }).success).toBe(true); + }); + + it('the banned spelling loses it — this is why the walker patches the def', () => { + // The caricature, kept as a control so the paragraph above is a measurement. + expect(z.object({ a: z.number() }).safeParse({ a: -1 }).success).toBe(true); + }); + + it('the live instance: the `chatbot` body clause still fires at a child slot', () => { + // `defineNodeComponentUnion` installs a `superRefine` on the union that sits + // in the node slot, and only there. So this document is accepted at the root + // and refused one slot down — on BOTH faces. A twin that dropped the check + // would accept the nested form and this test would go red on the strict row. + const chatbot = { type: 'chatbot', messages: [], body: { foo: 'bar' } }; + const nested = { type: 'card', children: [chatbot] }; + + expect(StrictAnyComponentSchema.safeParse(chatbot).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(nested).success).toBe(false); + // …and unchanged on the tolerant face, which is (c) again on this input. + expect(AnyComponentSchema.safeParse(chatbot).success).toBe(true); + expect(AnyComponentSchema.safeParse(nested).success).toBe(false); + }); +}); + +describe('the node face (child slot) twin', () => { + it('admits the primitives a child slot admits', () => { + expect(StrictSchemaNodeSchema.safeParse('a bare string').success).toBe(true); + expect(StrictSchemaNodeSchema.safeParse(7).success).toBe(true); + }); + + it('closes a component in that slot', () => { + expect(StrictSchemaNodeSchema.safeParse({ type: 'text', content: 'x' }).success).toBe(true); + const result = StrictSchemaNodeSchema.safeParse({ type: 'text', content: 'x', nope: 1 }); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toEqual(['nope']); + }); +}); + +describe('what strict could not close is enumerated, not claimed', () => { + /** Every def type the walker treats as opaque. Nothing else may be reported. */ + const OPAQUE_KINDS = ['custom', 'function', 'transform'] as const; + + it('every limit reported over the published face is one of the recorded opaque kinds', () => { + const limits: StrictAuthoringLimit[] = []; + const twin = deriveStrictAuthoringSchema(AnyComponentSchema, { + onOpaqueShape: (limit) => limits.push(limit), + }); + // ⚠️ A census is only as complete as the subtrees that have been walked, and + // `z.lazy` defers. Forcing one document parse resolves the node slot and, + // through it, the rest of the graph. + twin.safeParse(KNOWN_GOOD); + + expect(limits.length, 'a census that reports nothing is not a census').toBeGreaterThan(0); + const unexpected = [...new Set(limits.map((l) => l.kind))] + .filter((kind) => !(OPAQUE_KINDS as readonly string[]).includes(kind)) + .sort(); + expect(unexpected, 'the walker met a shape it could not close and this list does not name it').toEqual([]); + expect(limits.every((l) => l.path.startsWith('#'))).toBe(true); + }); + + it('the reporter recognises all three kinds — a synthetic positive control', () => { + // Which of the three the LIVE face exhibits moves with `@objectstack/spec`, + // so the population is asserted as a subset above and the reporter's own + // coverage is pinned here instead. Without this, a walker that had silently + // stopped recognising one kind would still pass the subset assertion. + const kinds: string[] = []; + deriveStrictAuthoringSchema( + z.object({ + opaqueCustom: z.custom((v) => typeof v === 'string'), + opaqueTransform: z.string().transform((v) => v.length), + opaqueFunction: z.function(), + }), + { onOpaqueShape: (limit) => kinds.push(limit.kind) }, + ); + expect([...new Set(kinds)].sort()).toEqual([...OPAQUE_KINDS]); + }); + + it('a `z.preprocess` has its real schema closed — the `out` side of a pipe is walked', () => { + // The asymmetry this guards: `X.transform(f)` keeps the schema in `in`, + // `z.preprocess(f, X)` keeps it in `out`. A walker that read only `in` + // closes the first and leaves the second wide open, with no symptom. + const preprocessed = z.preprocess((v) => v, z.object({ a: z.string() })); + expect(preprocessed.safeParse({ a: 'x', bogus: 1 }).success).toBe(true); + expect(deriveStrictAuthoringSchema(preprocessed).safeParse({ a: 'x', bogus: 1 }).success).toBe(false); + }); +}); + +/* ── Type-level pins — read by `tsc -p tsconfig.test.json`, NOT by vitest ──── */ + +/** The derivation returns the type it was given: strictness is a runtime property. */ +const assertionDerivationPreservesTheType: typeof AnyComponentSchema = + deriveStrictAuthoringSchema(AnyComponentSchema); + +/** Both faces inhabit one type — the twin invites exactly what the tolerant face invites. */ +const assertionTolerantFaceFitsTheCommonType: z.ZodType< + z.output, + z.input +> = AnyComponentSchema; +const assertionStrictFaceFitsTheCommonType: z.ZodType< + z.output, + z.input +> = StrictAnyComponentSchema; + +/** The node-slot twin carries `SchemaNodeSchema`'s declaration on both sides. */ +const assertionNodeTwinCarriesTheNodeDeclaration: typeof SchemaNodeSchema = StrictSchemaNodeSchema; + +describe('the type-level pins are reachable (vitest cannot read them)', () => { + it('names them so an unused-binding rule cannot delete the pins', () => { + expect([ + assertionDerivationPreservesTheType, + assertionTolerantFaceFitsTheCommonType, + assertionStrictFaceFitsTheCommonType, + assertionNodeTwinCarriesTheNodeDeclaration, + ].every((s) => typeof s.safeParse === 'function')).toBe(true); + }); +}); + +/* ── The counter used by the non-mutation pin ────────────────────────────── */ + +/** + * How many objects reachable from `schema` are CLOSED (`catchall` is `never`). + * Reads `_zod.def` for the same reason the walker does — zod publishes no other + * way to ask. Lazies are resolved so the census is not truncated at the node + * boundary. + */ +function closedObjectCount(schema: z.ZodType): number { + type Def = Record & { type: string }; + const seen = new Set(); + let closed = 0; + const visit = (node: unknown): void => { + if (typeof node !== 'object' || node === null || !('_zod' in node)) return; + if (seen.has(node)) return; + seen.add(node); + const def = (node as { _zod: { def: Def } })._zod.def; + if (def.type === 'object') { + const catchall = def.catchall as { _zod?: { def?: { type?: string } } } | undefined; + if (catchall?._zod?.def?.type === 'never') closed += 1; + } + if (def.type === 'lazy') { + const getter = def.getter as (() => unknown) | undefined; + if (getter) visit(getter()); + return; + } + if (def.shape) for (const v of Object.values(def.shape as Record)) visit(v); + if (Array.isArray(def.options)) for (const v of def.options) visit(v); + if (Array.isArray(def.items)) for (const v of def.items) visit(v); + for (const key of ['element', 'rest', 'valueType', 'keyType', 'left', 'right', 'in', 'out', 'innerType', 'catchall']) { + if (def[key]) visit(def[key]); + } + }; + visit(schema); + return closed; +} diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts new file mode 100644 index 0000000000..6ca2750233 --- /dev/null +++ b/packages/types/src/strict-authoring-face.ts @@ -0,0 +1,312 @@ +/** + * 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. + */ + +/** + * The STRICT AUTHORING FACE — a derived, unknown-key-closing twin of the node + * face (objectui#8345, under the objectui#5250 ruling: maintainer 2026-09-04, + * decision batch #25, option 2 — "each node schema gets a derived strict + * variant; `objectui validate` and the doc-snippet gates run strict; renderer + * props keep the tolerant face unchanged"). + * + * ## What this is, and what it deliberately is NOT + * + * It is a SECOND face over the SAME declarations. `BaseSchemaCore` stays + * `.passthrough()` and every published mirror keeps the accept set it has: this + * module adds a face, it does not change the existing one. Nothing in this + * repository consumes it yet — wiring `objectui validate`, the JSON-fence gate + * and `objectui check` is the devx half of the ruling and is ruled to come + * after, so the only consumer of these exports today is the pin file that + * measures them. + * + * It is DERIVED, never hand-written. A second hand-maintained copy of the 107 + * component schemas would be a parity ledger nobody can keep honest — this + * repository already carries the bill for that shape (objectui#6058, #6152, + * #7759). Everything below walks the mirrors that already exist. + * + * It is a RUNTIME face only. `deriveStrictAuthoringSchema` returns the same + * TypeScript type it was given, because strictness here is a property of the + * parse and not of the declaration — a document that type-checks against + * `SchemaNode` still type-checks. The TypeScript authoring face is a separate + * card with a separate ruling (objectui#7927). + * + * ## Why objects are CLONED and not rebuilt + * + * Every object is cloned by patching a copy of its own `_zod.def` and calling + * its own constructor. ⛔ Never rebuild one with `z.object(shape)`: that + * spelling keeps the shape and DROPS `def.checks`, so every `.refine()` and + * `.superRefine()` on the way down is silently lost and the twin UNDER-reports + * red — a strict face that quietly stopped enforcing a refinement is worse than + * no strict face, because it reads as evidence. The pin file holds a control + * that shows the rebuild spelling losing a real check. + * + * ## What strict cannot close, stated as the complete list + * + * Opaque validators — `custom`, `function` and `transform` — have no shape + * inside them to close, so the walker returns them untouched and REPORTS them + * 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. + * + * 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 + * is the `chatbot` body clause in `defineNodeComponentUnion` — it is preserved + * by the clone and still fires, but the schema it defers to inside is the + * tolerant `BaseSchemaCore.shape.body`. That direction is conservative: such a + * check can only ADD refusals, never accept something the strict shape refused. + * + * ## The recursion point, and why this card waited for it + * + * A child slot is `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and + * since objectui#8344 `SchemaNodeSchema`'s component arm IS the component union + * rather than the ~21 base keys. Strict-ifying the OLD recursion point measured + * the recursion point instead of the components — every child node's own + * declared props read as unrecognised. That is the order-of-magnitude error + * objectui#7935 exists to prevent, and it is why the derivation below can now + * run over the whole tree with no boundary at all. + */ + +import { z } from 'zod'; +import { SchemaNodeSchema } from './zod/base.zod.js'; +// ⚠️ A DELIBERATE MODULE CYCLE, and the reason it is safe is structural rather +// than lucky. `zod/index.zod.ts` re-exports the three values below, so it +// depends on this module and this module depends on it. ESM links cycles fine; +// what throws is READING a binding whose defining module has not run its body +// yet. `AnyComponentSchema` is therefore read ONLY inside the `z.lazy` getters +// at the bottom — never at this module's top level — so by the time anything +// can read it, `index.zod.ts` has finished evaluating and the objectui#8344 +// 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'; + +/** + * One shape the strict walker could not close, reported as it is met. + * + * `kind` is the zod def type of the opaque node; `path` is the trail from the + * 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`. */ + kind: string; + /** Trail from the walk root, e.g. `#/options/8/shape/props`. */ + path: string; +} + +/** Options for {@link deriveStrictAuthoringSchema}. */ +export interface DeriveStrictAuthoringOptions { + /** + * Called once per opaque node the walker meets, in walk order. Present so the + * limit list is a MEASUREMENT a caller can re-derive, not a claim in a + * docblock. Deduplication is the caller's business: the walker memoises by + * schema identity, so each distinct node is reported once per walk. + */ + onOpaqueShape?: (limit: StrictAuthoringLimit) => void; +} + +/** + * The subset of a zod def this walker reads. Zod does not publish `_zod.def` + * in its public types, and the alternative — a chain of `instanceof` narrowings + * against 15 concrete classes — would have to be rewritten whenever zod adds a + * wrapper. Sibling precedent for reading it: `defineNodeComponentUnion` in + * `zod/base.zod.ts` reads the same field to verify its own install. + */ +interface WalkableDef { + type: string; + shape?: Record; + options?: z.ZodType[]; + items?: z.ZodType[]; + element?: z.ZodType; + rest?: z.ZodType; + valueType?: z.ZodType; + left?: z.ZodType; + right?: z.ZodType; + in?: z.ZodType; + innerType?: z.ZodType; + out?: z.ZodType; + catchall?: z.ZodType; + getter?: () => z.ZodType; +} + +interface ZodInternals { + _zod: { def: WalkableDef }; + constructor: new (def: WalkableDef) => z.ZodType; +} + +const internals = (schema: z.ZodType): ZodInternals => schema as unknown as ZodInternals; + +const isZodType = (value: unknown): value is z.ZodType => + typeof value === 'object' && value !== null && '_zod' in value; + +/** + * Clone one schema with a patched def, PRESERVING everything else in it — + * `def.checks` above all, which is where `.refine()` / `.superRefine()` live. + */ +const cloneWithDef = (schema: z.ZodType, patch: Partial): z.ZodType => { + const Ctor = internals(schema).constructor; + return new Ctor({ ...internals(schema)._zod.def, ...patch }); +}; + +/** + * 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 + * by construction instead of by assertion. + */ +function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): (schema: T, path?: string) => T { + const memo = new Map(); + + const walk = (schema: z.ZodType, path: string): z.ZodType => { + if (!isZodType(schema)) return schema; + const cached = memo.get(schema); + if (cached) return cached; + const def = internals(schema)._zod.def; + + // `lazy` first, and memoised BEFORE the getter can re-enter: the node face + // is self-referential through every child slot, so a walker that recursed + // into the getter eagerly would not terminate. + if (def.type === 'lazy') { + const out: z.ZodType = z.lazy(() => walk(def.getter!(), `${path}/lazy`)); + memo.set(schema, out); + return out; + } + + let out: z.ZodType; + switch (def.type) { + case 'object': { + const shape: Record = {}; + for (const [key, value] of Object.entries(def.shape ?? {})) { + shape[key] = walk(value, `${path}/shape/${key}`); + } + // `catchall: z.never()` IS `.strict()` — spelled through the def so the + // clone keeps this object's own checks. `.strict()` would too, but only + // on a `ZodObject`; this arm also has to serve loose objects, which is + // every `BaseSchema` heir. + out = cloneWithDef(schema, { shape, catchall: z.never() }); + break; + } + // A discriminated union carries `type: 'union'` too, plus a + // `discriminator` the spread preserves — so both union kinds land here + // and neither is flattened into the other. + case 'union': + out = cloneWithDef(schema, { + options: (def.options ?? []).map((option, i) => walk(option, `${path}/options/${i}`)), + }); + break; + case 'array': + out = cloneWithDef(schema, { element: walk(def.element!, `${path}/element`) }); + break; + case 'tuple': + out = cloneWithDef(schema, { + items: (def.items ?? []).map((item, i) => walk(item, `${path}/items/${i}`)), + ...(def.rest ? { rest: walk(def.rest, `${path}/rest`) } : {}), + }); + break; + case 'record': + out = cloneWithDef(schema, { valueType: walk(def.valueType!, `${path}/valueType`) }); + break; + case 'intersection': + out = cloneWithDef(schema, { + left: walk(def.left!, `${path}/left`), + right: walk(def.right!, `${path}/right`), + }); + break; + case 'pipe': + // BOTH sides, and the `out` side is the one worth naming. A pipe + // spelled `X.transform(f)` holds the schema in `in` and the opaque + // transform in `out`; a pipe spelled `z.preprocess(f, X)` holds them + // the other way round. Walking only `in` closes the first and silently + // leaves the second's real schema tolerant — an asymmetry with no + // symptom, because the accept set it produces looks exactly like a + // schema that had nothing to close. Measured on the published face at + // the head this landed on: one pipe reachable, `in` an array and `out` + // the transform, so this arm changes no accept set today. It is here so + // the first `z.preprocess` to reach the node face does not open a hole. + out = cloneWithDef(schema, { + in: walk(def.in!, `${path}/in`), + ...(def.out ? { out: walk(def.out, `${path}/out`) } : {}), + }); + break; + case 'optional': + case 'nullable': + case 'default': + case 'nonoptional': + case 'readonly': + case 'catch': + out = cloneWithDef(schema, { innerType: walk(def.innerType!, `${path}/innerType`) }); + break; + case 'custom': + case 'transform': + case 'function': + // No shape inside to close. Reported, ⛔ never silently skipped. + options.onOpaqueShape?.({ kind: def.type, path }); + out = schema; + break; + default: + // Leaves: string, number, boolean, literal, enum, any, unknown, never, + // date, … — nothing to close and nothing to walk into. + out = schema; + } + memo.set(schema, out); + return out; + }; + + return (schema: T, path = '#'): T => walk(schema, path) as T; +} + +/** + * 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 + * 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. + * + * Each call builds its own memo, so deriving two schemas separately builds two + * graphs. The two faces below are derived through one shared walker for that + * reason. + */ +export function deriveStrictAuthoringSchema( + schema: T, + options?: DeriveStrictAuthoringOptions, +): T { + return createStrictWalker(options)(schema); +} + +/** + * The one walker behind both published faces, so the document face and the node + * face share a single derived graph. + */ +const faceWalker = createStrictWalker(); + +/** + * The strict authoring twin of `AnyComponentSchema` — a DOCUMENT root. + * + * Same accept set as `AnyComponentSchema` minus every undeclared key, at every + * depth. Undeclared keys are reported as `unrecognized_keys` issues naming the + * offending keys. + * + * Typed by its input and output rather than by its runtime class: the walk is + * deferred behind `z.lazy` (see the import comment above for why it must be), + * so this is a `ZodLazy` whose inner is the derived discriminated union. + */ +export const StrictAnyComponentSchema: z.ZodType< + z.output, + z.input +> = z.lazy(() => faceWalker(AnyComponentSchema)); + +/** + * The strict authoring twin of `SchemaNodeSchema` — a CHILD SLOT: a component + * document, or one of the primitives a slot admits. + * + * Prefer {@link StrictAnyComponentSchema} for a whole document: this face + * accepts a bare string or number, because a child slot does. + */ +export const StrictSchemaNodeSchema: z.ZodType< + z.output, + z.input +> = z.lazy(() => faceWalker(SchemaNodeSchema)); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 22c76ca2bc..b8669172d6 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -494,3 +494,36 @@ export function safeValidateSchema(schema: unknown) { * Version information */ export const SCHEMA_VERSION = '1.0.0'; + +// ============================================================================ +// Strict authoring face — the derived, unknown-key-closing twin (objectui#8345) +// ============================================================================ + +/** + * The strict twin of the node face, derived from the mirrors above rather than + * hand-written, under the objectui#5250 ruling (option 2: "each node schema + * gets a derived strict variant; `objectui validate` and the doc-snippet gates + * run strict; renderer props keep the tolerant face unchanged"). + * + * ⛔ Nothing here changes the accept set of anything exported above. The + * rendering face keeps its `.passthrough()`; this is a SECOND face, and no + * consumer in this repository is wired to it yet. + * + * ⚠️ The module is `../strict-authoring-face.ts`, OUTSIDE this directory, and + * the placement is deliberate. `__tests__/zod-mirror-parity.test.ts` runs a + * census closed over the `export const`s of `src/zod/*.zod.ts`: every one is + * either a registered hand-written mirror or a declared exclusion. A DERIVED + * twin restates no declaration and has nothing to drift from, so it is not a + * member of that population — and it lives outside the directory the census is + * closed over, which keeps that closure statement exactly as true as it is + * today rather than needing a new row to say "not really one of these". + */ +export { + deriveStrictAuthoringSchema, + StrictAnyComponentSchema, + StrictSchemaNodeSchema, +} from '../strict-authoring-face.js'; +export type { + DeriveStrictAuthoringOptions, + StrictAuthoringLimit, +} from '../strict-authoring-face.js'; From ad99bee1d08a3afb4f9864d6d4fc878f4f65aa9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:22:28 +0000 Subject: [PATCH 2/4] docs(types): import ButtonSchema in the strict-face README example The snippet used `ButtonSchema` without importing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- packages/types/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/README.md b/packages/types/README.md index 1249b44994..579faa8925 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -136,6 +136,7 @@ type ButtonSchema = SchemaByType<'button'>; ```typescript import { AnyComponentSchema, + ButtonSchema, StrictAnyComponentSchema, deriveStrictAuthoringSchema, } from '@object-ui/types/zod'; From 75d31cb15d6e77f2de2ef31dfe07bff42da4ee47 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 20:17:20 +0000 Subject: [PATCH 3/4] fix(types): close the strict twin over callable zod nodes, and pin the population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walker's type guard was `typeof value === 'object'`. Zod 4.4.3 builds some objects through `$ZodObjectJIT`, whose instances are CALLABLE — 20 of them are reachable on this face, all through `@objectstack/spec`-derived subtrees. The guard handed each one back untouched, so the subtree beneath went unwalked and 6 objects stayed open on the twin: an invented key inside one of them was accepted and silently dropped, while the same key at the root was refused and named. Nothing could catch that. No document in the 556-document corpus carries an undeclared key inside those 6 objects, and the pin file's own census began `typeof node !== 'object'` — it shared the blind spot with what it measured. Both guards now admit callable nodes, and the pin that was missing is here: a POPULATION pin requiring every object reachable on the twin to carry `catchall: never`, with the function-typed count asserted non-zero so the control cannot silently regress. REPRO-A is pinned as a document too, and the cycle's real invariant — the barrel is the sole entry — is pinned in both of its halves. Part of #8345 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../strict-authoring-face-8345.test.ts | 205 ++++++++++++++++-- packages/types/src/strict-authoring-face.ts | 34 ++- 2 files changed, 218 insertions(+), 21 deletions(-) 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 3e8fd827f3..4f24ec9339 100644 --- a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts +++ b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts @@ -44,6 +44,10 @@ * script) and by nothing else — a green vitest run says nothing about them. */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -174,11 +178,13 @@ describe('(c) the tolerant face is behaviourally unchanged — a pin, not a prom // `catchall: never` IS strictness. Some objects on the face are strict // already — the schemas imported from `@objectstack/spec` are — so the // reading that matters is that the count does not MOVE, not that it is zero. - const before = closedObjectCount(AnyComponentSchema); + const before = census(AnyComponentSchema); const twin = deriveStrictAuthoringSchema(AnyComponentSchema); twin.safeParse(KNOWN_GOOD); // force the deferred subtrees - expect(closedObjectCount(AnyComponentSchema)).toBe(before); - expect(before).toBeGreaterThan(0); // non-vacuity: the counter sees something + const after = census(AnyComponentSchema); + expect(after.closed).toBe(before.closed); + expect(after.openPaths.length).toBe(before.openPaths.length); + expect(before.closed).toBeGreaterThan(0); // non-vacuity: the census sees something }); }); @@ -246,6 +252,66 @@ describe('the node face (child slot) twin', () => { }); }); +describe('the population is closed — every reachable object on the twin, not a sample document', () => { + /** + * ⭐ THE PIN WHOSE ABSENCE LET A REAL DEFECT SHIP. + * + * Every other pin in this file reads a DOCUMENT: it invents a key at some + * place a test author thought of, and asks what the face says. That can only + * ever cover the places someone thought of — and the corpus cannot close the + * gap either, because no document among the 556 the measurement script reads + * carries an undeclared key inside the objects that were open. Base, head and + * the prototype-agreement check all read the same number whichever way the + * walker's type guard is written. + * + * This one reads the POPULATION instead: walk the derived twin and require + * that every object in it carries `catchall: never`. It is the assertion that + * makes the published sentence — "closes every declared object, at every + * depth" — checkable rather than asserted. + */ + it('every object reachable on the strict twin is closed', () => { + const twin = deriveStrictAuthoringSchema(AnyComponentSchema); + twin.safeParse(KNOWN_GOOD); // force every deferred subtree before counting + const seen = census(twin); + + expect(seen.openPaths, 'an object on the strict twin still admits undeclared keys').toEqual([]); + expect(seen.closed, 'the census found no closed objects — it is not reading the twin').toBeGreaterThan(250); + }); + + it('the census can see CALLABLE schema nodes — the control the defect turned on', () => { + // ⚠️ Non-vacuity, and specifically for the class that was invisible. A + // census that cannot see `typeof 'function'` nodes reports "all closed" + // over a graph it never entered. If this ever reads 0, the assertion above + // has quietly stopped covering ~20 subtrees and must not be trusted. + expect(census(AnyComponentSchema).functionTyped).toBeGreaterThan(0); + }); + + it('the tolerant face is NOT closed — so "closed" is a discriminator, not a tautology', () => { + expect(census(AnyComponentSchema).openPaths.length).toBeGreaterThan(0); + }); + + it('REPRO-A: an invented key deep inside a spec-derived subtree is refused and named', () => { + // The document the population pin exists for. Before the walker admitted + // callable nodes this parsed CLEAN and `inventedDeepKey` was silently + // dropped from the output, while the root-level control below was correctly + // refused — the asymmetry that falsified the published contract text. + const deep = { + type: 'page', + interfaceConfig: { source: 'x', sort: [{ field: 'a', order: 'asc', inventedDeepKey: 1 }] }, + }; + const result = StrictAnyComponentSchema.safeParse(deep); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toContain('inventedDeepKey'); + + // The control, in the same document: the root-level key was never the problem. + expect(refusedKeys(StrictAnyComponentSchema.safeParse({ ...deep, inventedTopKey: 1 }))) + .toContain('inventedTopKey'); + + // …and (c) again on this input: the tolerant face takes both. + expect(AnyComponentSchema.safeParse(deep).success).toBe(true); + }); +}); + describe('what strict could not close is enumerated, not claimed', () => { /** Every def type the walker treats as opaque. Nothing else may be reported. */ const OPAQUE_KINDS = ['custom', 'function', 'transform'] as const; @@ -295,6 +361,74 @@ describe('what strict could not close is enumerated, not claimed', () => { }); }); +describe('the barrel is the sole entry into the module cycle', () => { + /** + * `zod/index.zod.ts` re-exports from `../strict-authoring-face.ts`, which + * imports `AnyComponentSchema` back from it. The cycle is fine when the + * BARREL is entered first, and the reason usually given for that — "the deep + * module reads the binding only inside its lazy getters" — is true of the + * shipped source and NOT sufficient on its own. Measured: rollup 4.62.2, + * entered at the deep module first with a namespace import used as a value, + * throws `ReferenceError: Cannot access 'StrictAnyComponentSchema' before + * initialization` from the synthesized namespace object it places ahead of + * the deep module's body. Node, Vite/rolldown and Next Turbopack are green in + * both orders; rollup in that one order is not. + * + * ⇒ The load-bearing invariant is not "reads are deferred", it is **the + * barrel is the only way in**. That is what these two assertions hold, and + * between them they cover both routes a caller has: the published `exports` + * map for anything outside the package, and a relative or aliased specifier + * for anything inside this repository. + * + * ⚠️ The manifest half reads `package.json`; it does not write it. If the + * package's build layout changes the shape of `exports`, restate the + * invariant for the new shape rather than deleting the assertion. + */ + const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const DEEP_MODULE = 'strict-authoring-face'; + + it('the published exports map has no wildcard and no entry reaching the deep module', () => { + const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { + exports: Record; + }; + const subpaths = Object.keys(manifest.exports); + expect(subpaths.length, 'the exports map is empty — this assertion is reading the wrong file').toBeGreaterThan(5); + expect(subpaths.filter((key) => key.includes('*')), 'a wildcard subpath opens every internal module').toEqual([]); + expect( + subpaths.filter((key) => JSON.stringify(manifest.exports[key]).includes(DEEP_MODULE)), + 'an exports entry now reaches the deep module directly, so the barrel is no longer the sole entry', + ).toEqual([]); + }); + + it('no module in this repository imports the deep module except the barrel', () => { + // A specifier, not a mention: `scripts/measure-strict-authoring-face.mjs` + // shares the words in its own name and must not read as an importer. + const REPO_ROOT = join(PACKAGE_ROOT, '..', '..'); + const SKIP = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage', '.next', 'build', 'test-results']); + const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/; + const SPECIFIER = /(?:from|import|require)\s*\(?\s*['"][^'"]*strict-authoring-face[^'"]*['"]/; + const importers: string[] = []; + let scanned = 0; + const walk = (dir: string): void => { + let entries: import('node:fs').Dirent[]; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (SKIP.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!SOURCE.test(entry.name)) continue; + scanned += 1; + if (SPECIFIER.test(readFileSync(full, 'utf8'))) importers.push(full.slice(REPO_ROOT.length + 1)); + } + }; + for (const root of ['packages', 'apps', 'examples', 'scripts', 'e2e']) walk(join(REPO_ROOT, root)); + + expect(scanned, 'the scan found almost no source files — it is pointed at the wrong tree').toBeGreaterThan(1000); + expect(importers.sort(), 'something other than the barrel now enters the cycle at the deep module') + .toEqual(['packages/types/src/zod/index.zod.ts']); + }); +}); + /* ── Type-level pins — read by `tsc -p tsconfig.test.json`, NOT by vitest ──── */ /** The derivation returns the type it was given: strictness is a runtime property. */ @@ -325,39 +459,70 @@ describe('the type-level pins are reachable (vitest cannot read them)', () => { }); }); -/* ── The counter used by the non-mutation pin ────────────────────────────── */ +/* ── The census both population pins read ────────────────────────────────── */ /** - * How many objects reachable from `schema` are CLOSED (`catchall` is `never`). + * Walk a schema graph and count what is there. + * + * ⚠️ `isSchemaNode` admits CALLABLE nodes, and that is the whole reason this + * helper is worth reading. Its first version began `typeof node !== 'object'` + * and therefore could not see the 20 `$ZodObjectJIT` instances on this face — + * the identical blind spot the walker itself had. Two instruments sharing a + * defect with the thing they measure is not a control: the count read "clean" + * while six objects underneath those nodes were wide open. `functionTyped` is + * asserted non-zero below so the lesson cannot silently regress. + * * Reads `_zod.def` for the same reason the walker does — zod publishes no other * way to ask. Lazies are resolved so the census is not truncated at the node * boundary. */ -function closedObjectCount(schema: z.ZodType): number { - type Def = Record & { type: string }; +type CensusDef = Record & { type: string }; + +interface Census { + /** Distinct schema nodes reached. */ + nodes: number; + /** How many of those answered `typeof 'function'` (JIT instances). */ + functionTyped: number; + /** Nodes whose def type is `object`. */ + objects: number; + /** Of those, how many carry `catchall: never` — i.e. are closed. */ + closed: number; + /** Of those, the ones that do not, with the trail that reached them. */ + openPaths: string[]; +} + +const isSchemaNode = (value: unknown): boolean => + value !== null && (typeof value === 'object' || typeof value === 'function') && '_zod' in value; + +function census(schema: unknown): Census { const seen = new Set(); - let closed = 0; - const visit = (node: unknown): void => { - if (typeof node !== 'object' || node === null || !('_zod' in node)) return; - if (seen.has(node)) return; + const out: Census = { nodes: 0, functionTyped: 0, objects: 0, closed: 0, openPaths: [] }; + const visit = (node: unknown, path: string): void => { + if (!isSchemaNode(node) || seen.has(node)) return; seen.add(node); - const def = (node as { _zod: { def: Def } })._zod.def; + out.nodes += 1; + if (typeof node === 'function') out.functionTyped += 1; + const def = (node as { _zod: { def: CensusDef } })._zod.def; if (def.type === 'object') { + out.objects += 1; const catchall = def.catchall as { _zod?: { def?: { type?: string } } } | undefined; - if (catchall?._zod?.def?.type === 'never') closed += 1; + if (catchall?._zod?.def?.type === 'never') out.closed += 1; + else out.openPaths.push(`${path} [${catchall?._zod?.def?.type ?? 'strip'}]`); } if (def.type === 'lazy') { const getter = def.getter as (() => unknown) | undefined; - if (getter) visit(getter()); + if (getter) visit(getter(), `${path}/lazy`); return; } - if (def.shape) for (const v of Object.values(def.shape as Record)) visit(v); - if (Array.isArray(def.options)) for (const v of def.options) visit(v); - if (Array.isArray(def.items)) for (const v of def.items) visit(v); + if (def.shape) { + for (const [key, value] of Object.entries(def.shape as Record)) visit(value, `${path}/${key}`); + } + if (Array.isArray(def.options)) def.options.forEach((o, i) => visit(o, `${path}/opt${i}`)); + if (Array.isArray(def.items)) def.items.forEach((o, i) => visit(o, `${path}/item${i}`)); for (const key of ['element', 'rest', 'valueType', 'keyType', 'left', 'right', 'in', 'out', 'innerType', 'catchall']) { - if (def[key]) visit(def[key]); + if (def[key]) visit(def[key], `${path}/${key}`); } }; - visit(schema); - return closed; + visit(schema, '#'); + return out; } diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 6ca2750233..f3a8397e03 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -138,12 +138,44 @@ interface ZodInternals { const internals = (schema: z.ZodType): ZodInternals => schema as unknown as ZodInternals; +/** + * Is this a zod schema node? + * + * ⚠️ `typeof value === 'object'` is NOT the test, and writing it that way is a + * silent, measured coverage hole rather than a style slip. Zod 4.4.3 builds + * some objects through `$ZodObjectJIT`, whose instances are CALLABLE — they + * answer `typeof 'function'`, their constructor prints as a bound `ZodObject`, + * their traits read `ZodObject/$ZodObjectJIT/$ZodObject/$ZodType`, and they + * parse exactly like any other object. On this face, 20 such nodes are + * reachable, all of them arriving through `@objectstack/spec`-derived subtrees. + * + * An object-only guard hands each of them straight back, so the ENTIRE subtree + * beneath it goes unwalked. Measured, before this test admitted functions: 6 + * objects under those nodes stayed open on the twin, and a document with an + * invented key inside one of them — `page.interfaceConfig.sort[]` is the + * shortest — was ACCEPTED by the strict face and the key silently dropped, + * while the same key at the root was correctly refused and named. + * + * ⛔ Nothing in the corpus could catch that: no document among the 556 carries + * an undeclared key inside those 6 objects, so every corpus reading is + * identical whichever guard is written here. The population pin in + * `__tests__/strict-authoring-face-8345.test.ts` — every reachable object on + * the twin has `catchall: never`, with the function-typed count asserted + * non-zero — is what actually holds this line, and it too had to be taught the + * same lesson: its own census started `typeof node !== 'object'` and shared the + * blind spot with the thing it was measuring. + */ const isZodType = (value: unknown): value is z.ZodType => - typeof value === 'object' && value !== null && '_zod' in value; + value !== null && (typeof value === 'object' || typeof value === 'function') && '_zod' in value; /** * Clone one schema with a patched def, PRESERVING everything else in it — * `def.checks` above all, which is where `.refine()` / `.superRefine()` live. + * + * A callable JIT instance clones through its own bound constructor and comes + * back as an ordinary object-typed instance of the same class. That is a + * difference in representation, not in behaviour, and behaviour is what the + * pins measure: the clone parses, closes, and leaves the original untouched. */ const cloneWithDef = (schema: z.ZodType, patch: Partial): z.ZodType => { const Ctor = internals(schema).constructor; From 8e9b563d80742bfd297d5fc822ae05d63a8fcf5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 20:39:58 +0000 Subject: [PATCH 4/4] docs(types): re-derive the pipe census the blind guard had understated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pipe` arm's comment said "one pipe reachable, `in` an array and `out` the transform". That reading was taken through the object-only guard. With callable nodes admitted, four pipes are reachable — including a preprocessor under `page.interfaceConfig.filterBy[]` that the old walker could not see at all. No code changes; the comment now states what the tree measures. Part of #8345 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- packages/types/src/strict-authoring-face.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index f3a8397e03..269c59b682 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -252,10 +252,19 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}):