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..579faa8925 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -121,6 +121,42 @@ 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, + ButtonSchema, + 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..4f24ec9339 --- /dev/null +++ b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts @@ -0,0 +1,528 @@ +/** + * 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 { 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'; + +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 = census(AnyComponentSchema); + const twin = deriveStrictAuthoringSchema(AnyComponentSchema); + twin.safeParse(KNOWN_GOOD); // force the deferred subtrees + 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 + }); +}); + +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('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; + + 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); + }); +}); + +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. */ +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 census both population pins read ────────────────────────────────── */ + +/** + * 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. + */ +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(); + 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); + 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') 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(), `${path}/lazy`); + return; + } + 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], `${path}/${key}`); + } + }; + visit(schema, '#'); + return out; +} diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts new file mode 100644 index 0000000000..269c59b682 --- /dev/null +++ b/packages/types/src/strict-authoring-face.ts @@ -0,0 +1,353 @@ +/** + * 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; + +/** + * 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 => + 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; + 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. + // + // Re-derived on the published face at the head this landed on, once the + // guard above stopped skipping callable nodes: FOUR pipes are reachable + // — `transform` into `enum` (a preprocessor, under + // `page.interfaceConfig.filterBy[]`), and `object`, `string` and + // `array` each into a `transform`. So a preprocessor is already here, + // and today no OBJECT sits on an `out` side, which is why this arm + // moves no accept set yet. It is here so the first preprocessor that + // wraps an object does not open a hole. ⚠️ The earlier version of this + // comment said "one pipe reachable" — that was a reading taken through + // the blind guard, and it is exactly the class of number this file's + // pins now re-derive instead of quoting. + 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';