diff --git a/.changeset/8317-strip-imported-defaults.md b/.changeset/8317-strip-imported-defaults.md new file mode 100644 index 0000000000..d07f524c95 --- /dev/null +++ b/.changeset/8317-strip-imported-defaults.md @@ -0,0 +1,59 @@ +--- +'@object-ui/types': minor +--- + +The validator stops writing values into an author's document on the keys it imports — +**this mirror authors no default, imported subschemas included** (objectui#8317, +director ruling, decision batch #90, 2026-09-08, under the maintainer's standing +delegation). + +Decision batch #69 (objectui#7735) ruled a principle: *a validator validates; it does +not write values into an author's document.* PR #8299 delivered it for the 41 +`.default()` call sites written in this package's own mirrors. Measured afterwards, **57 +`ZodDefault` nodes were still reachable from the published `@object-ui/types/zod` +barrel**, every one inside a subschema imported by reference from `@objectstack/spec` — +so `safeValidateSchema` went on substituting on those keys, with 41 stripped and 57 not +and no way to tell which was which from the document. Batch #90 ruled that the +principle holds for **every** key the validator answers, and those 57 are now stripped +where the spec enters this package (`.removeDefault()`, the established local pattern, +applied through `zod/imported-defaults.ts`). + +**What changes.** `safeValidateSchema` / `validateSchema` return the author's document +instead of the author's document plus keys they did not write: + +```js +safeValidateSchema({ type: 'object-view', objectName: 'account', navigation: {} }) +// before → navigation: { mode: 'page', preventNavigation: false, openNewTab: false, size: 'auto' } +// after → navigation: {} +``` + +The affected families: `app`'s `active` / `isDefault` · `object-view`'s +`navigation.{mode, preventNavigation, openNewTab, size}` · `list-view`'s `sharing.type`, +`userActions.*`, `addRecord.*`, `appearance.*`, `chart.chartType`, `tabs[].*`, +`timeline.scale` · `kanban`'s `grouping.fields[].{order, collapsed}` · `page`'s `kind` +and the whole `interfaceConfig` subtree · dashboard `chartConfig.*`, `header.*` and +`globalFilters[].scope` · `object-gallery`'s `gallery.*` · `contextSelectors[].*` · +`prefix.type`, `pagination.pageSize`, `selection.type` and the HTTP `method`. + +**The accept set does not move, and that is measured, not asserted.** Every one of these +keys stays omissible — `.default(v)` carries optionality as well as a value, so the +boundary re-optionalises what `.removeDefault()` hands back. A differential against the +raw `@objectstack/spec` schemas (a permanent pin, since both sides are importable) plus +a run over this repository's own 1,077-document corpus found **zero** documents whose +acceptance changed, on the tolerant face and on the strict authoring face alike. Keys, +value vocabularies and every `.refine()` / `.superRefine()` the spec installed are +carried through unchanged. + +**Migration.** If you read a value off `result.data` and relied on it being present +without having written it, read it off your own fallback instead — batch #69 already +ruled that the renderer's fallback is *the* authoritative default, and the renderers in +this workspace already carry theirs (`navigation?.mode ?? 'page'`, +`userActions.search !== false`). A census of every consumer of this barrel found no such +read: three production importers, one of which reads `result.data` at all, and it reads +only `type` / `id` / `label` / `title` / `children` — none of which carries a default on +any of the 107 component arms. + +⛔ **Not taken:** changing `@objectstack/spec` itself (1,546 call sites on another +repository's release train). This is reversible into it — every strip becomes a no-op +the day the spec adopts the same principle, because the boundary is the identity +function on a subtree with nothing to strip. diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts new file mode 100644 index 0000000000..30afd60f79 --- /dev/null +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -0,0 +1,495 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE IMPORT BOUNDARY — "this mirror authors no default, imported subschemas + * included" (objectui#8317, director ruling, decision batch #90, 2026-09-08, + * under the maintainer's standing delegation). + * + * Batch #69 (objectui#7735) ruled the principle: *a validator validates; it + * does not write values into an author's document.* PR #8299 delivered it for + * the 41 `.default()` call sites this repository wrote. Batch #90 ruled that + * the principle holds for EVERY key `safeValidateSchema` answers, so the 57 + * `ZodDefault` nodes that arrived by reference from `@objectstack/spec` are + * stripped where the spec enters this package (`../zod/imported-defaults.ts`). + * + * ## What this file pins, and why each half is here + * + * The count itself lives next door in `zod-mirror-authors-no-defaults-7735.test.ts` + * (the graph walk that priced #8299, now a ratchet at zero). This file pins the + * three properties that make that zero SAFE rather than merely true: + * + * 1. **The accept set does not move.** Measured as a live differential against + * the RAW spec schemas — which are importable here, so this is a permanent + * pin and not a throwaway measurement. `.default(v)` carries optionality as + * well as a value, and a naive unwrap makes an omissible key REQUIRED: a + * silent accept-set narrowing on a published surface, which this ruling did + * not authorise. + * 2. **Nothing else changes.** Same keys, same node types, same `def.checks` + * at every reachable node — a walk that dropped a `.superRefine()` would + * make this package ACCEPT what the spec refuses, and would leave no trace + * in any count. + * 3. **The boundary cannot be bypassed.** A source-level census: every + * `@objectstack/spec` import in `../zod/*.zod.ts` is bound through + * `stripImportedDefaults`. Without this, the next spec import silently + * re-opens the hole and only a residue count — measured on some later day — + * would notice. + * + * Every assertion below carries a control that fires. A differential whose two + * sides are the same object, or a census that matched nothing, is green for + * reasons that have nothing to do with the ruling. + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import type { z } from 'zod'; +import { + AppSchema as SpecAppSchema, + AppContextSelectorSchema as SpecAppContextSelectorSchema, + NavigationAreaSchema as SpecNavigationAreaSchema, + ChartTypeSchema as SpecChartTypeSchema, + DashboardSchema as SpecDashboardSchema, + DashboardWidgetSchema as SpecDashboardWidgetSchema, + GlobalFilterSchema as SpecGlobalFilterSchema, + GroupingConfigSchema as SpecGroupingConfigSchema, + PageSchema as SpecPageSchema, + PageTypeSchema as SpecPageTypeSchema, + PageVariableSchema as SpecPageVariableSchema, + ListViewSchema as SpecListViewSchema, + KanbanConfigSchema as SpecKanbanConfigSchema, + GanttConfigSchema as SpecGanttConfigSchema, + CalendarConfigSchema as SpecCalendarConfigSchema, + GalleryConfigSchema as SpecGalleryConfigSchema, + TimelineConfigSchema as SpecTimelineConfigSchema, + HttpMethodSubsetSchema as SpecHttpMethodSubsetSchema, + HttpRequestSchema as SpecHttpRequestSchema, + ViewDataSchema as SpecViewDataSchema, + ListColumnSchema as SpecListColumnSchema, + SelectionConfigSchema as SpecSelectionConfigSchema, + PaginationConfigSchema as SpecPaginationConfigSchema, + UserActionsConfigSchema as SpecUserActionsConfigSchema, + AriaPropsSchema as SpecAriaPropsSchema, + NavigationConfigSchema as SpecNavigationConfigSchema, + I18nLabelSchema as SpecI18nLabelSchema, +} from '@objectstack/spec/ui'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MIRROR_DIR = join(HERE, '..', 'zod'); + +/* ── the graph reader, shared by every measurement below ──────────────────── */ + +interface ZodDef { + type: string; + shape?: Record; + options?: unknown[]; + items?: unknown[]; + element?: unknown; + rest?: unknown; + valueType?: unknown; + keyType?: unknown; + left?: unknown; + right?: unknown; + in?: unknown; + out?: unknown; + innerType?: unknown; + checks?: unknown[]; + getter?: () => unknown; +} +const defOf = (node: unknown): ZodDef | null => + (node as { _zod?: { def?: ZodDef } } | null)?._zod?.def ?? null; + +/** Every node reachable from `roots`, with a `lazy` getter that throws RECORDED, never skipped. */ +function walk(roots: unknown[]): { nodes: Set; lazies: number; unreachable: string[] } { + const nodes = new Set(); + const unreachable: string[] = []; + const seenLazy = new Set(); + const stack: unknown[] = []; + let lazies = 0; + const push = (n: unknown): void => { + if (!n || !defOf(n) || nodes.has(n)) return; + nodes.add(n); + stack.push(n); + }; + roots.forEach(push); + while (stack.length) { + const node = stack.pop()!; + const def = defOf(node)!; + if (def.type === 'lazy') { + lazies++; + if (!seenLazy.has(node)) { + seenLazy.add(node); + try { push(def.getter!()); } catch (err) { unreachable.push(String(err)); } + } + continue; + } + if (def.shape) for (const v of Object.values(def.shape)) push(v); + if (def.options) for (const v of def.options) push(v); + if (def.items) for (const v of def.items) push(v); + for (const k of ['element', 'rest', 'valueType', 'keyType', 'left', 'right', 'in', 'out', 'innerType'] as const) { + if (def[k]) push(def[k]); + } + } + return { nodes, lazies, unreachable }; +} + +const defaultsIn = (root: unknown): number => + [...walk([root]).nodes].filter((n) => defOf(n)!.type === 'default').length; + +/** + * The complete population this package imports from `@objectstack/spec`, + * re-derived from the mirrors' own import statements by the census at the + * bottom of this file — so a symbol added there and forgotten here is a RED + * test, not a silent hole in the differential. + */ +const IMPORTED: Array = [ + ['AppSchema', SpecAppSchema], + ['AppContextSelectorSchema', SpecAppContextSelectorSchema], + ['NavigationAreaSchema', SpecNavigationAreaSchema], + ['ChartTypeSchema', SpecChartTypeSchema], + ['DashboardSchema', SpecDashboardSchema], + ['DashboardWidgetSchema', SpecDashboardWidgetSchema], + ['GlobalFilterSchema', SpecGlobalFilterSchema], + ['GroupingConfigSchema', SpecGroupingConfigSchema], + ['PageSchema', SpecPageSchema], + ['PageTypeSchema', SpecPageTypeSchema], + ['PageVariableSchema', SpecPageVariableSchema], + ['ListViewSchema', SpecListViewSchema], + ['KanbanConfigSchema', SpecKanbanConfigSchema], + ['GanttConfigSchema', SpecGanttConfigSchema], + ['CalendarConfigSchema', SpecCalendarConfigSchema], + ['GalleryConfigSchema', SpecGalleryConfigSchema], + ['TimelineConfigSchema', SpecTimelineConfigSchema], + ['HttpMethodSubsetSchema', SpecHttpMethodSubsetSchema], + ['HttpRequestSchema', SpecHttpRequestSchema], + ['ViewDataSchema', SpecViewDataSchema], + ['ListColumnSchema', SpecListColumnSchema], + ['SelectionConfigSchema', SpecSelectionConfigSchema], + ['PaginationConfigSchema', SpecPaginationConfigSchema], + ['UserActionsConfigSchema', SpecUserActionsConfigSchema], + ['AriaPropsSchema', SpecAriaPropsSchema], + ['NavigationConfigSchema', SpecNavigationConfigSchema], + ['I18nLabelSchema', SpecI18nLabelSchema], + ['SelectOptionSchema', SpecSelectOptionSchema], +] as const; + +/** The subset that actually carries an imported default — where the strip does work. */ +const CARRIES_DEFAULT = IMPORTED.filter(([, s]) => defaultsIn(s) > 0); +/** …and its complement, where the strip must be the identity function. */ +const CARRIES_NONE = IMPORTED.filter(([, s]) => defaultsIn(s) === 0); + +describe('the import boundary strips every imported default (objectui#8317)', () => { + describe('positive controls — the instruments see something', () => { + it('the imported population is non-empty and SPLIT, so neither branch below is vacuous', () => { + expect(IMPORTED.length).toBeGreaterThan(20); + expect(CARRIES_DEFAULT.length, 'nothing upstream carries a default — the strip is untested').toBeGreaterThan(5); + expect(CARRIES_NONE.length, 'everything upstream carries a default — the identity half is untested').toBeGreaterThan(1); + }); + + it('the walker reaches real graphs, with nothing lost', () => { + const { nodes, unreachable } = walk(IMPORTED.map(([, s]) => s)); + expect(unreachable).toEqual([]); + expect(nodes.size).toBeGreaterThan(500); + }); + + /** + * ⛔ The spec's own objects are NOT mutated. Every other consumer in the + * workspace imports the same module instance; a walker that patched in + * place would strip defaults out of `@objectstack/spec` for all of them and + * every assertion in this file would still be green. + */ + it('the RAW spec schemas still carry their defaults after the strip has run', () => { + for (const [name, schema] of CARRIES_DEFAULT) { + stripImportedDefaults(schema); + expect(defaultsIn(schema), `${name} was mutated in place`).toBeGreaterThan(0); + } + }); + }); + + describe('the strip', () => { + it.each(CARRIES_DEFAULT.map(([n]) => [n] as const))('%s comes back with no ZodDefault in it', (name) => { + const [, schema] = CARRIES_DEFAULT.find(([n]) => n === name)!; + expect(defaultsIn(stripImportedDefaults(schema))).toBe(0); + }); + + /** + * ⭐ THE IDENTITY PROPERTY — the ruling's reversibility, made mechanical. + * + * Batch #90 took option A over option B partly because "the 57 strips become + * no-ops if the spec later adopts the same principle". That is only true if + * the boundary is the identity function on a clean subtree, so it is + * asserted rather than asserted-about: a schema with nothing to strip comes + * back as the very same object. + */ + it.each(CARRIES_NONE.map(([n]) => [n] as const))('%s has nothing to strip, so it comes back REFERENCE-EQUAL', (name) => { + const [, schema] = CARRIES_NONE.find(([n]) => n === name)!; + expect(stripImportedDefaults(schema)).toBe(schema); + }); + + it('the walker docblock\'s `lazy` count is re-derived, not quoted', () => { + // The `lazy` arm is the one place the identity property cannot hold: it + // must rebuild without forcing the getter, so a clean subtree behind a + // `z.lazy` is rebuilt anyway. The module's docblock names THREE such + // nodes and says the exception costs nothing today because each sits + // inside a schema that is being rebuilt regardless. Both halves are + // measured here, so a spec bump that moves either one is red rather than + // quietly making the docblock false. + expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(3); + const lazyOwners = IMPORTED.filter(([, s]) => walk([s]).lazies > 0); + expect(lazyOwners.length, 'no schema owns a lazy — the count above found them elsewhere').toBeGreaterThan(0); + for (const [name, schema] of lazyOwners) { + expect( + defaultsIn(schema), + `${name} reaches a z.lazy but carries no default — the lazy exception now costs a ` + + 'rebuild of an otherwise clean subtree; update the docblock in `../zod/imported-defaults.ts`', + ).toBeGreaterThan(0); + } + }); + }); + + /** + * THE ACCEPT SET. Both directions on documents built from each schema's own + * declared keys, plus the shapes an author actually writes. + */ + describe('the accept set does not move', () => { + const PROBES: unknown[] = [ + undefined, null, {}, [], '', 0, false, 'page', + { bogus: 1 }, + { mode: 'page' }, { mode: 'bogus' }, + { type: 'text' }, { type: 'bogus' }, + { field: 'status' }, { fields: [{ field: 'stage' }] }, + { endpoint: '/api/x' }, { id: 'x', label: 'y' }, + { name: 'a', label: 'A' }, { pageSize: 25 }, { pageSize: 'lots' }, + ]; + + it.each(IMPORTED.map(([n]) => [n] as const))('%s answers every probe exactly as the spec does', (name) => { + const [, raw] = IMPORTED.find(([n]) => n === name)!; + const stripped = stripImportedDefaults(raw); + for (const probe of PROBES) { + expect( + stripped.safeParse(probe).success, + `${name} disagrees with @objectstack/spec on ${JSON.stringify(probe) ?? 'undefined'} — the ` + + 'strip must remove substitution and NOTHING else (objectui#8317)', + ).toBe(raw.safeParse(probe).success); + } + }); + + /** + * The half a naive unwrap breaks, stated on the shape rather than on a + * probe list. `.default(v)` makes a member omissible; `.removeDefault()` + * alone gives that omissibility back to a bare `ZodDefault(T)` — so every + * member that was omissible before must still be omissible after. + */ + it.each(CARRIES_DEFAULT.map(([n]) => [n] as const))('%s: no member became REQUIRED', (name) => { + const [, raw] = CARRIES_DEFAULT.find(([n]) => n === name)!; + const rawShape = defOf(raw)?.shape; + if (!rawShape) return; + const strippedShape = defOf(stripImportedDefaults(raw))!.shape!; + const optin = (n: unknown) => (n as { _zod?: { optin?: string } })._zod?.optin; + for (const key of Object.keys(rawShape)) { + expect( + optin(strippedShape[key]), + `${name}.${key} changed omissibility — removing a default must not narrow the accept set`, + ).toBe(optin(rawShape[key])); + } + }); + }); + + /** + * NOTHING ELSE CHANGES. Walked in parallel so the comparison is per-node + * rather than a summary: same node types in the same places, same keys, and + * — the one that has no other symptom — the same `def.checks`. + */ + describe('keys, node types and checks survive the walk', () => { + let totalCompared = 0; + + it.each(CARRIES_DEFAULT.map(([n]) => [n] as const))('%s keeps its shape, arms and checks', (name) => { + const [, raw] = CARRIES_DEFAULT.find(([n]) => n === name)!; + const stripped = stripImportedDefaults(raw); + const seen = new Set(); + let compared = 0; + const compare = (a: unknown, b: unknown, path: string): void => { + const da = defOf(a); const db = defOf(b); + if (!da || !db) { expect(!!da, `${name}${path}`).toBe(!!db); return; } + if (seen.has(a)) return; + seen.add(a); + compared++; + // The one sanctioned difference: a `ZodDefault` is replaced by what it + // was standing in for — `.removeDefault()`'s inner type, re-wrapped in + // an optional unless it was already omissible. Both spellings are + // legal here; what is NOT legal is the key becoming required, and the + // omissibility assertion above measures that directly. Everything + // below the replacement is compared as normal. + if (da.type === 'default') { + expect( + (b as { _zod?: { optin?: string } })._zod?.optin, + `${name}${path}: the replacement for a default is not omissible — removing a default ` + + 'must never make a key required', + ).toBe('optional'); + // `.removeDefault()` hands back the inner type. The boundary re-wraps + // it in an optional only when it was not already omissible, so the + // node facing `da.innerType` is either `b` itself or `b`'s inner. + const wrapped = db.type === 'optional' && defOf(da.innerType)!.type !== 'optional'; + compare(da.innerType, wrapped ? defOf(b)!.innerType : b, `${path}`); + return; + } + expect(db.type, `${name}${path}: node type changed`).toBe(da.type); + expect((da.checks ?? []).length, `${name}${path}: a check was DROPPED by the walk`).toBe((db.checks ?? []).length); + if (da.shape) { + expect(Object.keys(db.shape ?? {}).sort(), `${name}${path}: keys changed`).toEqual(Object.keys(da.shape).sort()); + for (const k of Object.keys(da.shape)) compare(da.shape[k], db.shape![k], `${path}.${k}`); + } + if (da.options) { + expect((db.options ?? []).length, `${name}${path}: arm count changed`).toBe(da.options.length); + da.options.forEach((o, i) => compare(o, db.options![i], `${path}|${i}`)); + } + for (const k of ['element', 'rest', 'valueType', 'keyType', 'left', 'right', 'in', 'out', 'innerType'] as const) { + if (da[k]) compare(da[k], db[k], `${path}<${k}>`); + } + }; + compare(raw, stripped, ''); + // Per-schema floor is deliberately low — `SelectionConfigSchema` is three + // nodes and that is the whole of it. The non-vacuity that matters is the + // AGGREGATE below, which no single small schema can satisfy alone. + expect(compared, `${name}: the parallel walk compared nothing`).toBeGreaterThan(1); + totalCompared += compared; + }); + + it('the parallel walk covered the whole imported population, not a corner of it', () => { + expect( + totalCompared, + 'the per-schema comparisons above touched almost no nodes — they are green for the wrong reason', + ).toBeGreaterThan(500); + }); + }); + + /** + * THE BOUNDARY CANNOT BE BYPASSED — a source census, because this is the only + * assertion that can see an import written TOMORROW. Every VALUE read of an + * `@objectstack/spec` binding inside a mirror must be the direct argument of + * `stripImportedDefaults(…)`. + * + * Two kinds of read are declared exceptions, and they are enumerated here + * rather than pattern-matched, so adding a third is an edit to this list: + * + * - a value VOCABULARY — `SpecListViewTypeEnum` / `ViewKindEnum`, which + * unwrap the spec's own `.default('grid')` to reach its enum. A set of + * values cannot write a key into a parsed document. ⚠️ They read the RAW + * binding on purpose: `stripImportedDefaults` leaves the STATIC type + * unchanged, so `.removeDefault()` on the stripped member would typecheck + * and throw. + * - a TYPE position, where there is no runtime schema to strip and the + * declared type is unchanged by the strip anyway. + */ + describe('every `@objectstack/spec` value read in the mirrors goes through the boundary', () => { + /** `:` for each read that is allowed to stay raw. */ + const VOCABULARY_EXCEPTIONS = new Set([ + 'views.zod.ts:SpecListViewTypeEnum', + 'objectql.zod.ts:ViewKindEnum', + ]); + + const isSpecModule = (m: string): boolean => + m === '@objectstack/spec' || m.startsWith('@objectstack/spec/'); + + interface Read { file: string; line: number; name: string; owner: string | null; wrapped: boolean; kind: 'value' | 'type' } + + const mirrorFiles = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.zod.ts')).sort(); + const reads: Read[] = []; + for (const file of mirrorFiles) { + const text = readFileSync(join(MIRROR_DIR, file), 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const aliases = new Set(); + const importRanges: [number, number][] = []; + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + if (!isSpecModule(stmt.moduleSpecifier.text)) continue; + importRanges.push([stmt.getStart(sf), stmt.getEnd()]); + const named = stmt.importClause?.namedBindings; + if (named && ts.isNamedImports(named)) for (const el of named.elements) aliases.add(el.name.text); + } + if (aliases.size === 0) continue; + const owningConst = (n: ts.Node): string | null => { + for (let p: ts.Node | undefined = n.parent; p; p = p.parent) { + if (ts.isVariableDeclaration(p) && ts.isIdentifier(p.name)) return p.name.text; + } + return null; + }; + const inTypePosition = (n: ts.Node): boolean => { + for (let p: ts.Node | undefined = n.parent; p; p = p.parent) { + if (ts.isTypeNode(p) || ts.isTypeQueryNode(p) || ts.isTypeAliasDeclaration(p)) return true; + } + return false; + }; + const visit = (n: ts.Node): void => { + if (ts.isIdentifier(n) && aliases.has(n.text)) { + const pos = n.getStart(sf); + const inImport = importRanges.some(([a, b]) => pos >= a && pos < b); + if (!inImport) { + const parent = n.parent; + const wrapped = + !!parent && ts.isCallExpression(parent) && + ts.isIdentifier(parent.expression) && parent.expression.text === 'stripImportedDefaults' && + parent.arguments.length === 1 && parent.arguments[0] === n; + reads.push({ + file, + line: sf.getLineAndCharacterOfPosition(pos).line + 1, + name: n.text, + owner: owningConst(n), + wrapped, + kind: inTypePosition(n) ? 'type' : 'value', + }); + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + } + + it('the census found the reads it is meant to police', () => { + expect(reads.length, 'no `@objectstack/spec` read found in any mirror — the census is vacuous').toBeGreaterThan(40); + expect(new Set(reads.map((r) => r.file)).size).toBeGreaterThan(4); + expect(reads.filter((r) => r.wrapped).length, 'the census can see no wrapped read at all').toBeGreaterThan(40); + }); + + it('no value read bypasses `stripImportedDefaults`', () => { + const offenders = reads + .filter((r) => r.kind === 'value' && !r.wrapped) + .filter((r) => !VOCABULARY_EXCEPTIONS.has(`${r.file}:${r.owner}`)); + expect( + offenders.map((r) => `${r.file}:${r.line} ${r.name} (in \`${r.owner ?? ''}\`)`), + 'an `@objectstack/spec` schema crosses into a mirror without the objectui#8317 import ' + + 'boundary. Wrap it: `stripImportedDefaults()`. ⛔ Do not exempt it — a validator ' + + 'does not write values into an author\'s document, imported subschemas included ' + + '(decision batch #90). A read that genuinely is not a crossing (a value vocabulary) goes ' + + 'in `VOCABULARY_EXCEPTIONS` above, with the reason.', + ).toEqual([]); + }); + + it('every declared exception still exists, and still reads a RAW binding', () => { + // An exception nobody uses is a hole waiting for a name collision. Both of + // these must be live, and both must be UNWRAPPED — if one were wrapped, + // `.removeDefault()` would typecheck and throw, and this list would be + // silently protecting nothing. + for (const key of VOCABULARY_EXCEPTIONS) { + const [file, owner] = key.split(':'); + const matching = reads.filter((r) => r.file === file && r.owner === owner && r.kind === 'value'); + expect(matching.length, `declared exception ${key} matches no read — delete it`).toBeGreaterThan(0); + for (const r of matching) expect(r.wrapped, `${key} is wrapped; the exception is dead`).toBe(false); + } + }); + + it('every symbol the mirrors import is covered by the differential above', () => { + const differential = new Set(IMPORTED.map(([n]) => n)); + const missing = [...new Set(reads.map((r) => r.name.replace(/^Spec/, '')))] + .filter((n) => !differential.has(n)); + expect( + missing, + 'a schema imported by a mirror is not in this file\'s `IMPORTED` list, so nothing measures ' + + 'whether the strip moved its accept set. Add it.', + ).toEqual([]); + }); + }); +}); diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 4d2d1e08b1..c8874f3ba3 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -70,6 +70,7 @@ import { } from '@objectstack/spec/ui'; import { ObjectViewSchema } from '../zod/objectql.zod'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; import { ViewSwitcherSchema } from '../zod/views.zod'; import { safeValidateSchema } from '../zod/index.zod'; import type { ObjectViewSchema as TsObjectViewSchema, NamedListView } from '../objectql'; @@ -322,11 +323,19 @@ describe('objectui#7779 — the zod mirror declares the eight keys and the tombs describe('objectui#7779 — the three spec-modelled keys are the spec\'s own slots BY REFERENCE', () => { it.each(SPEC_REFERENCED)('`%s` IS `SpecListViewSchema.shape.%s` — the same object, not a copy', (key) => { + // ⭐ objectui#8317 (decision batch #90): the spec enters this package through + // `stripImportedDefaults`, so the object to compare against is + // `SpecListViewSchema` AS IT ARRIVES HERE. That hop removes the imported + // `ZodDefault`s and nothing else — a slot with none comes back + // reference-equal, which is why `searchableFields` and `filterableFields` + // are unchanged by it and `navigation` (four defaults) is not. + // ⛔ Still `toBe`: a local restatement is the drift objectui#4588 measured. expect( shapeMember(ObjectViewSchema, key), - `ObjectViewSchema.${key} must be SpecListViewSchema.shape.${key} by reference — a local ` + - 'restatement is the drift objectui#4588 measured; if the spec slot is wrong, fix the spec', - ).toBe(shapeMember(SpecListViewSchema, key)); + `ObjectViewSchema.${key} must be stripImportedDefaults(SpecListViewSchema).shape.${key} by ` + + 'reference — a local restatement is the drift objectui#4588 measured; if the spec slot is ' + + 'wrong, fix the spec', + ).toBe(shapeMember(stripImportedDefaults(SpecListViewSchema), key)); }); it.each(SPEC_REFERENCED)('`%s` is also the slot `ObjectListViewSchema` carries under that name (the spec models it on both view faces)', (key) => { @@ -342,13 +351,29 @@ describe('objectui#7779 — the three spec-modelled keys are the spec\'s own slo it('`navigation` parses exactly as the spec\'s `NavigationConfigSchema` does (the slot is that schema, optional)', () => { const slot = shapeMember(ObjectViewSchema, 'navigation') as { safeParse(v: unknown): { success: boolean; data?: unknown } }; // The spec declares `mode: NavigationModeSchema.default('page')`, so a - // config that lets the mode default is legal authored metadata — the exact - // input the hand copy of objectui#4588 refused. + // config that omits the mode is legal authored metadata — the exact input + // the hand copy of objectui#4588 refused. ⭐ It is STILL accepted, and since + // objectui#8317 (decision batch #90) the parsed document no longer carries a + // `mode` the author did not write: acceptance unchanged, substitution gone. const defaulted = slot.safeParse({ view: 'summary_view' }); expect(defaulted.success).toBe(true); - expect((defaulted.data as { mode?: string }).mode).toBe('page'); + expect((defaulted.data as { mode?: string }).mode).toBeUndefined(); + expect(defaulted.data).toEqual({ view: 'summary_view' }); + // …and a document that DOES write it round-trips unchanged, which is what + // separates "stopped substituting" from "stopped declaring". + expect(slot.safeParse({ view: 'summary_view', mode: 'page' }).data) + .toEqual({ view: 'summary_view', mode: 'page' }); + // ⚠️ Two comparisons, deliberately. The first is against the spec slot AS IT + // ENTERS THIS PACKAGE — the object actually under test. The second is + // against the RAW spec slot, and it is the measurement that says the strip + // moved no accept set: every probe agrees with upstream, defaults or not. + const enteredSlot = stripImportedDefaults(SpecNavigationConfigSchema).optional(); for (const probe of [{ mode: 'drawer' }, { mode: 'bogus' }, 'page', { mode: 'page', bogus: 1 }, undefined]) { - expect(slot.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecNavigationConfigSchema.optional().safeParse(probe).success); + expect(slot.safeParse(probe).success, JSON.stringify(probe)).toBe(enteredSlot.safeParse(probe).success); + expect( + slot.safeParse(probe).success, + `${JSON.stringify(probe)} — the strip must move no accept set`, + ).toBe(SpecNavigationConfigSchema.optional().safeParse(probe).success); } // A string is refused at `navigation`, an unknown mode at `navigation.mode`: // the spec's strict object, not a local `z.any()`. diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index d4fac3ac50..cc31b95f5c 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -73,15 +73,44 @@ describe('AppContextSelectorSchema derives from the spec', () => { expect(localKeys.filter((k) => !specKeys.includes(k))).toEqual([]); }); - it('keeps the spec keys the old hand copy restated, defaults included', () => { - const parsed = AppContextSelectorSchema.parse({ + it('keeps the spec keys the old hand copy restated — DECLARED, and no longer written for the author', () => { + // ⭐ INVERTED by objectui#8317 (decision batch #90). This pin used to read + // the substituted values back out of the parse output — + // `optionsSource.valueKey === 'id'`, `labelKey === 'name'`, + // `persist === 'query'` — and that reading is exactly the defect batch #69 + // ruled against: a validator writing values into an author's document. The + // three keys are spec-declared and still accepted; what changed is that an + // author who did not write them does not get them back. + // + // ⚠️ The comment below this block has said the important half all along — + // "a materialised default is indistinguishable from an authored value" — + // and that indistinguishability is what objectui#8317 removed. Keep both + // directions asserted, or "stopped substituting" and "stopped declaring" + // read the same from here. + const authored = { id: 'active_package', label: 'Package', optionsSource: { endpoint: '/api/packages' }, - }); - expect(parsed.optionsSource.valueKey).toBe('id'); - expect(parsed.optionsSource.labelKey).toBe('name'); - expect(parsed.persist).toBe('query'); + }; + expect(AppContextSelectorSchema.parse(authored)).toEqual(authored); + + const spelled = { + id: 'active_package', + label: 'Package', + persist: 'query', + optionsSource: { endpoint: '/api/packages', valueKey: 'id', labelKey: 'name' }, + }; + expect(AppContextSelectorSchema.parse(spelled)).toEqual(spelled); + + // Still DECLARED — membership is what the spec derivation owes, and it is + // not readable off parse output any more (`BaseSchema` is passthrough, so + // an undeclared key would survive a parse too). + const optionsSource = shapeOf(AppContextSelectorSchema).optionsSource; + for (const key of ['valueKey', 'labelKey']) { + expect(Object.keys(shapeOf(optionsSource)), `optionsSource.${key} must stay declared`).toContain(key); + } + expect(Object.keys(shapeOf(AppContextSelectorSchema))).toContain('persist'); + // `includeAll` and `placement` were asserted here until spec 17.0.0 // removed them (framework#4509 / objectui#3208). Both carried schema // defaults, which is exactly why the liveness lint could not flag them: diff --git a/packages/types/src/__tests__/spec-subschema-parity.test.ts b/packages/types/src/__tests__/spec-subschema-parity.test.ts index 900513ceba..4174563c57 100644 --- a/packages/types/src/__tests__/spec-subschema-parity.test.ts +++ b/packages/types/src/__tests__/spec-subschema-parity.test.ts @@ -16,8 +16,16 @@ * re-exports too until the spec retired its whole theme module — see the * retirement block below.) These tests pin: * - * 1. Reference identity for every direct re-export. `toBe` — not structural - * equality — so a "faithful copy" fails too: a copy is a fork. + * 1. Identity for every direct re-export — `toBe`, not structural equality, + * so a "faithful copy" fails too: a copy is a fork. ⭐ Since objectui#8317 + * (decision batch #90) the subject of that identity is the spec object AS + * IT ENTERS THIS PACKAGE — `stripImportedDefaults(spec)` — not the raw + * spec object. That call is the whole of the sanctioned difference: it + * removes the imported `ZodDefault`s so this validator stops writing + * values into an author's document, and changes nothing else. A subtree + * with no default to strip comes back REFERENCE-EQUAL to the spec's own + * object, and three pairs below are exactly that, which is what keeps this + * assertion from degrading into "equals whatever the boundary produced". * 2. The one *derived* schema (`ListColumnSchema`): every spec field flows in, * the local-extension set is exactly the sanctioned one, and the `summary` * broadening keeps the spec enum as its first (by-reference) union arm. @@ -49,6 +57,7 @@ import { PaginationConfigSchema, } from '../zod/objectql.zod.js'; import { ChartTypeSchema } from '../zod/data-display.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; import { PageTypeSchema } from '../zod/layout.zod.js'; describe('spec sub-schema re-exports are the spec objects (by reference)', () => { @@ -85,7 +94,49 @@ describe('spec sub-schema re-exports are the spec objects (by reference)', () => it.each(pairs.map(([name]) => [name] as const))('%s', (name) => { const [, oui, spec] = pairs.find(([n]) => n === name)!; - expect(oui, `${name} must be the spec schema itself, not a copy`).toBe(spec); + expect( + oui, + `${name} must be the spec schema as it enters this package — ` + + '`stripImportedDefaults()` and nothing else. A local copy is a fork ' + + '(#2231); a copy with any OTHER edit is the same fork wearing the boundary\'s name.', + ).toBe(stripImportedDefaults(spec as never)); + }); + + /** + * ⭐ The control that keeps the assertion above from being vacuous. + * + * `stripImportedDefaults` is the identity function on a subtree with nothing + * to strip (see its walker's identity property), so for a schema that carries + * no `ZodDefault` the assertion above degenerates into `toBe(spec)` — the + * pre-objectui#8317 pin, unchanged and still load-bearing. These three are + * that case, asserted directly so it is a MEASUREMENT rather than a claim in + * a docblock: if the boundary ever started cloning unconditionally, this goes + * red and the pin above would not. + */ + it.each([ + ['HttpMethodSchema', HttpMethodSchema, SpecHttpMethodSubsetSchema], + ['ChartTypeSchema', ChartTypeSchema, SpecChartTypeSchema], + ['PageTypeSchema', PageTypeSchema, SpecPageTypeSchema], + ] as const)('%s carries no imported default, so it is still the raw spec object', (name, oui, spec) => { + expect(oui, `${name} has nothing to strip and must stay reference-equal to the spec object`).toBe(spec); + }); + + /** + * …and the other half of the same control: the four that DID carry an + * imported default are NOT the raw spec object any more. That is the + * behaviour change objectui#8317 shipped, pinned so it cannot be undone by + * quietly reverting the boundary — and so a reader who lands on the + * `toBe(stripImportedDefaults(...))` line above can see that the call is + * doing something. + */ + it.each([ + ['HttpRequestSchema', HttpRequestSchema, SpecHttpRequestSchema], + ['ViewDataSchema', ViewDataSchema, SpecViewDataSchema], + ['SelectionConfigSchema', SelectionConfigSchema, SpecSelectionConfigSchema], + ['PaginationConfigSchema', PaginationConfigSchema, SpecPaginationConfigSchema], + ] as const)('%s carried an imported default, so it is the boundary derivation', (name, oui, spec) => { + expect(oui, `${name} still IS the raw spec object — the import boundary was bypassed`).not.toBe(spec); + expect(oui).toBe(stripImportedDefaults(spec as never)); }); }); @@ -147,9 +198,10 @@ describe('ListColumnSchema is the spec schema (the extension collapsed)', () => it('is the spec schema itself, not a copy or an extension', () => { expect( ListColumnSchema, - 'ListColumnSchema must be SpecListColumnSchema by reference — if a local field ' + - 'is needed again, promote it into @objectstack/spec instead of re-extending here', - ).toBe(SpecListColumnSchema); + 'ListColumnSchema must be SpecListColumnSchema as it enters this package — if a local ' + + 'field is needed again, promote it into @objectstack/spec instead of re-extending here. ' + + 'The `stripImportedDefaults` hop is objectui#8317 and is the only sanctioned difference.', + ).toBe(stripImportedDefaults(SpecListColumnSchema)); }); it('carries no objectui-only fields', () => { @@ -209,11 +261,25 @@ describe('ListColumnSchema is the spec schema (the extension collapsed)', () => expect(ListColumnSchema.shape.summary.safeParse('median').success).toBe(false); }); - it('prefix parses the compound-cell form and defaults `type` to text', () => { - const parsed = ListColumnSchema.shape.prefix.parse({ field: 'status' }); - // spec v17 made `type` a ZodDefault, so ObjectGrid's cell renderer always - // gets a value where the old objectui-local schema left it undefined. - expect(parsed).toEqual({ field: 'status', type: 'text' }); + it('prefix parses the compound-cell form and no longer WRITES `type` for the author', () => { + // ⭐ INVERTED by objectui#8317 (decision batch #90). spec v17 made `type` a + // `ZodDefault`, and this pin used to assert the substitution: an author who + // wrote `{ field: 'status' }` got back `{ field: 'status', type: 'text' }`. + // That is precisely the "one authored document, two shapes" defect + // objectui#7735 was opened about, arriving through an imported subschema, + // and batch #90 ruled it out for imported keys as well as local ones. The + // author's document comes back as the author wrote it. + // + // ⛔ Not an accept-set change: the key stays omissible (that is what the + // boundary's re-optionalisation is for) and the value vocabulary is + // untouched — the two probes below are unchanged from the pre-#8317 pin. + // ⚠️ ObjectGrid's cell renderer is where the `'text'` fallback belongs, and + // batch #69 already ruled that the renderer's fallback is THE authoritative + // default; the mirror describing it is not the mirror writing it. + expect(ListColumnSchema.shape.prefix.parse({ field: 'status' })).toEqual({ field: 'status' }); + // …and a document that DOES write it round-trips unchanged. + expect(ListColumnSchema.shape.prefix.parse({ field: 'status', type: 'text' })) + .toEqual({ field: 'status', type: 'text' }); expect(ListColumnSchema.shape.prefix.safeParse({ field: 'status', type: 'badge' }).success).toBe(true); expect(ListColumnSchema.shape.prefix.safeParse({ field: 'status', type: 'pill' }).success).toBe(false); }); diff --git a/packages/types/src/__tests__/zod-mirror-authors-no-defaults-7735.test.ts b/packages/types/src/__tests__/zod-mirror-authors-no-defaults-7735.test.ts index dd8b2acb26..bba9af59bd 100644 --- a/packages/types/src/__tests__/zod-mirror-authors-no-defaults-7735.test.ts +++ b/packages/types/src/__tests__/zod-mirror-authors-no-defaults-7735.test.ts @@ -51,13 +51,23 @@ * `.optional().default(v).describe(…)`, so none of them was carrying * optionality. `acceptSetUnchanged` re-states that as a live assertion. * - * ## What this does NOT reach, measured rather than assumed + * ## What this did NOT reach — and no longer has to (objectui#8317) * - * Removing all 41 does not make `safeValidateSchema` stop substituting. 57 - * `ZodDefault` nodes remain reachable from the published barrel afterwards, and - * every one is inside a subschema imported from `@objectstack/spec`, so none of - * them can be removed from this repository. The residue block below enumerates - * them and says why its assertion is a floor and not a ratchet. + * Removing all 41 did not make `safeValidateSchema` stop substituting. 57 + * `ZodDefault` nodes remained reachable from the published barrel afterwards, + * every one inside a subschema imported by reference from `@objectstack/spec`, + * and this file used to assert that residue as a floor: "real, recorded, and + * NOT this repository's to remove." + * + * ⭐ Decision batch #90 (2026-09-08, director seat under the maintainer's + * standing delegation, objectui#8317) overturned that last clause. Batch #69's + * principle holds for EVERY key `safeValidateSchema` answers, not only the 41 + * this repository authored, so the 57 are stripped at this package's import + * boundary with `.removeDefault()` — `zod/imported-defaults.ts`, pinned by + * `imported-defaults-8317.test.ts`. ⛔ Option B (a 1546-site change on + * `@objectstack/spec`'s release train) was not taken; A is reversible into it. + * The residue block below now asserts ZERO, and says why that is a ratchet the + * boundary can hold when the old floor could not. */ import { describe, expect, it } from 'vitest'; @@ -214,48 +224,65 @@ describe('the zod mirror authors no defaults (objectui#7735)', () => { * The boundary of the ruling, stated as a live measurement rather than left * for the next reader to trip over. * - * `ZodDefault` nodes ARE still reachable from the published barrel — 57 of - * them at the head this landed on — and not one is authored here. Every one - * arrives inside a subschema imported from `@objectstack/spec`: `app`'s - * `active` / `isDefault` off `SpecAppSchema.shape`, `object-view`'s + * ⭐ ZERO since objectui#8317 (decision batch #90). `ZodDefault` nodes used + * to be reachable from the published barrel — 57 of them at the head #8299 + * landed on, none authored here: every one arrived inside a subschema + * imported from `@objectstack/spec` (`app`'s `active` / `isDefault` off + * `SpecAppSchema.shape`, `object-view`'s * `navigation.{mode,preventNavigation,openNewTab,size}`, `list-view`'s * `sharing.type`, `kanban`'s `grouping.fields[].{order,collapsed}`, - * `page`'s whole `interfaceConfig` subtree, and the dashboard chart config. - * The derivation is the assertion above it: the mirror files contain zero - * `.default()` CALLS, so every surviving node was constructed elsewhere. - * - * So `safeValidateSchema` still substitutes — on those keys, through those - * subtrees — and the "one authored document, two shapes" defect this card - * names survives there. That residue is real, it is recorded on - * objectui#7735, and it is NOT this repository's to remove: the values are - * written in `@objectstack/spec` and reach here by reference. Restating them - * locally to strip them would be the "narrower than the contract it - * implements" shape `complex.zod.ts` already records twice. + * `page`'s whole `interfaceConfig` subtree, and the dashboard chart + * config). Batch #90 ruled that a validator does not write values into an + * author's document on THOSE keys either, so they are stripped where the + * spec enters this package (`../zod/imported-defaults.ts`). * - * Asserted as `> 0` rather than `=== 57`: the exact figure moves with every - * `@objectstack/spec` bump, and a count ratchet on another package's - * contents is a false-alarm generator. What must not silently change is the - * ATTRIBUTION — and that is what the call-site assertion above holds. + * ⚠️ This is now a RATCHET at zero, and it can be one where the old + * `> 0` floor could not. The floor was a count of another package's + * contents and moved with every `@objectstack/spec` bump, which is why it + * was written as an inequality. Zero does not move with a bump: a default + * added upstream tomorrow arrives through the same boundary and is stripped + * by the same walk. What this assertion catches is a NEW ENTRY POINT — an + * `@objectstack/spec` import that skips `stripImportedDefaults`, or a + * `.default()` written here — and both of those are exactly the regression + * batch #69 and batch #90 rule out. */ - it('the residue that survives is entirely spec-derived, and it is not zero', () => { - const { nodes } = walk([AnyComponentSchema, ...exportedSchemas.map(([, v]) => v)]); + it('no `ZodDefault` is reachable from the published barrel at all', () => { + const { nodes, unreachable } = walk([AnyComponentSchema, ...exportedSchemas.map(([, v]) => v)]); + expect(unreachable).toEqual([]); const defaults = [...nodes].filter((n) => defOf(n)!.type === 'default'); expect(mirrorFiles.flatMap(defaultCallSites)).toEqual([]); expect( defaults.length, - 'if this reaches zero, `@objectstack/spec` stopped authoring defaults too — good news, ' + - 'but re-read the docblock above and retire this assertion deliberately rather than ' + - 'weakening it.', - ).toBeGreaterThan(0); + 'a `ZodDefault` is reachable from `@object-ui/types/zod` again. Either a `.default()` was ' + + 'written in a mirror file (objectui#7735) or an `@objectstack/spec` import bypassed ' + + '`stripImportedDefaults` at the boundary (objectui#8317). Route it through the boundary; ' + + '⛔ do not weaken this number.', + ).toBe(0); }); - it('a spec-derived subtree still substitutes — the residue, made concrete', () => { - const result = safeValidateSchema({ type: 'object-view', objectName: 'account', navigation: {} }); - expect(result.success).toBe(true); - const data = (result as { success: true; data: Record> }).data; - // Authored `navigation: {}`, parsed to a populated object. Nothing under - // `packages/types/src/zod` wrote those keys; `@objectstack/spec` did. - expect(Object.keys(data.navigation).length).toBeGreaterThan(0); + /** + * The reproducer that defined "done" on objectui#8317, both directions. + * + * ⚠️ The second direction is the one that makes the first meaningful. A + * mirror that had simply DROPPED these keys from its shape would satisfy + * "the author gets back what they wrote" for the empty document and quietly + * refuse — or silently pass through — the document that writes them. Only + * the pair distinguishes "stopped substituting" from "stopped declaring". + */ + it('a spec-derived subtree no longer substitutes — and still round-trips what the author DID write', () => { + const authoredEmpty = { type: 'object-view', objectName: 'account', navigation: {} }; + const empty = safeValidateSchema(authoredEmpty); + expect(empty.success).toBe(true); + expect((empty as { success: true; data: typeof authoredEmpty }).data).toEqual(authoredEmpty); + + const authoredFull = { + type: 'object-view', + objectName: 'account', + navigation: { mode: 'page', preventNavigation: false, openNewTab: false, size: 'auto' }, + }; + const full = safeValidateSchema(authoredFull); + expect(full.success).toBe(true); + expect((full as { success: true; data: typeof authoredFull }).data).toEqual(authoredFull); }); }); diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 56f30527de..1bdb828b3a 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -25,6 +25,38 @@ import { import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal } from './tombstone.zod.js'; import type { AppMenuItem } from '../app.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + // ============================================================================ // Unified NavigationItem Schema @@ -183,13 +215,13 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => NavigationItemO * * Drift guard: `__tests__/page-nav-misc-spec-parity.test.ts`. */ -export const NavigationAreaSchema = specFieldsExcept(SpecNavigationAreaSchema.shape, [ +export const NavigationAreaSchema = specFieldsExcept(stripImportedDefaults(SpecNavigationAreaSchema).shape, [ 'id', 'label', 'navigation', ] as const).extend({ - id: SpecNavigationAreaSchema.shape.id.describe('Unique identifier'), - label: SpecNavigationAreaSchema.shape.label.describe('Display label'), + id: stripImportedDefaults(SpecNavigationAreaSchema).shape.id.describe('Unique identifier'), + label: stripImportedDefaults(SpecNavigationAreaSchema).shape.label.describe('Display label'), navigation: z.array(NavigationItemSchema).describe('Navigation items within area'), }); @@ -273,7 +305,7 @@ export const AppActionSchema = z.object({ * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const AppContextSelectorSchema = SpecAppContextSelectorSchema.extend({ +export const AppContextSelectorSchema = stripImportedDefaults(SpecAppContextSelectorSchema).extend({ label: z.union([z.string(), z.record(z.string(), z.any())]) .describe('Dropdown label — plain string or objectui i18n label envelope'), }); @@ -303,7 +335,7 @@ export const AppContextSelectorSchema = SpecAppContextSelectorSchema.extend({ * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui apps. */ -const SpecAppFields = specFieldsExcept(SpecAppSchema.shape, [ +const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, [ 'name', 'label', 'description', diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 85483f321f..3e5229a96d 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -21,6 +21,38 @@ import { I18nLabelSchema } from '@objectstack/spec/ui'; import { retirementTombstone } from './tombstone.zod.js'; import { ExpressionWireSchema } from './expression.zod.js'; import type { SchemaNode } from '../base.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * A KEYED i18n label — the runtime mirror of `KeyedI18nLabel` in `../base.ts`. @@ -268,14 +300,14 @@ const BaseSchemaCore = z.object({ * relies on. Mirrors `BaseSchema.label: string | I18nLabel` (`../base.ts`), * widened by #4580's revised Q1-A ruling. */ - label: I18nLabelSchema.optional().describe('Display label (plain string or inline locale map)'), + label: stripImportedDefaults(I18nLabelSchema).optional().describe('Display label (plain string or inline locale map)'), /** * Description text. * * Same vocabulary and same resolver as `label` above — see `BaseSchema.description`. */ - description: I18nLabelSchema.optional().describe('Description text (plain string or inline locale map)'), + description: stripImportedDefaults(I18nLabelSchema).optional().describe('Description text (plain string or inline locale map)'), /** * Placeholder text diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 2e429ee821..5d981df7b0 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -32,6 +32,38 @@ import { DASHBOARD_COMPONENT_WIDGET_TYPES, DASHBOARD_WIDGET_TYPE_EXTENSIONS, } from '../complex.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * The retired declarative face, named once so every refusal below says the @@ -158,7 +190,7 @@ export const KanbanSchema = BaseSchema.extend({ conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), cardTemplates: z.array(CardTemplateSchema).optional().describe('Predefined card templates for quick-add'), columnWidths: ColumnWidthConfigSchema.optional().describe('Custom column width configuration'), - grouping: SpecGroupingConfigSchema.optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'), + grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'), draggable: retiredDeclarativeKanbanKey('draggable', 'board', 'Drag-and-drop is always on; delete the key.'), onColumnAdd: handlerKeyRefusal('onColumnAdd', 'retired', 'Column add handler'), onCardAdd: handlerKeyRefusal('onCardAdd', 'retired', 'Card add handler'), @@ -753,7 +785,7 @@ export const DashboardWidgetLayoutSchema = z.object({ * node loses no authored key while a widget refuses undeclared ones. */ export const DashboardWidgetTypeSchema = z.enum([ - ...SpecChartTypeSchema.options, + ...stripImportedDefaults(SpecChartTypeSchema).options, ...DASHBOARD_WIDGET_TYPE_EXTENSIONS, ...DASHBOARD_COMPONENT_WIDGET_TYPES, ]); @@ -808,7 +840,7 @@ export const DashboardWidgetTypeSchema = z.enum([ * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema.shape, [ +export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(SpecDashboardWidgetSchema).shape, [ 'id', 'type', ] as const).extend({ @@ -923,7 +955,7 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ export const GlobalFilterSchema = z.object({ - ...SpecGlobalFilterSchema.shape, + ...stripImportedDefaults(SpecGlobalFilterSchema).shape, options: z.array(z.union([ z.string(), z.object({ @@ -945,7 +977,7 @@ export const GlobalFilterSchema = z.object({ const specOwned: Record = { ...filter }; delete specOwned.options; delete specOwned.optionsFrom; - const result = SpecGlobalFilterSchema.safeParse(specOwned); + const result = stripImportedDefaults(SpecGlobalFilterSchema).safeParse(specOwned); if (result.success) return; for (const issue of result.error.issues) ctx.addIssue({ ...issue }); }); @@ -972,7 +1004,7 @@ export const GlobalFilterSchema = z.object({ * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui dashboards. */ -const SpecDashboardFields = specFieldsExcept(SpecDashboardSchema.shape, [ +const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, [ 'name', 'label', 'description', diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index d46d20fe68..7f66ff9a48 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -21,6 +21,38 @@ import { ChartTypeSchema as SpecChartTypeSchema, I18nLabelSchema } from '@object import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES, type TreeNode } from '../data-display.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * Alert Schema - Alert/notification component @@ -385,7 +417,7 @@ export const TreeViewSchema = BaseSchema.extend({ * how #2901 came to be filed against the wrong side of the contract: it read * this copy as the protocol and concluded the renderer had outgrown it. */ -export const ChartTypeSchema = SpecChartTypeSchema; +export const ChartTypeSchema = stripImportedDefaults(SpecChartTypeSchema); /** * Zod twin of {@link ChartDataSeries} — the objectui chart node's inline-data @@ -475,7 +507,7 @@ export const ChartDataSeriesSchema = z.object({ // // `chartType` is NOT among the six: it is an ALIAS REFUSAL ARM, declared // beside `type` above (objectui#7694). - label: I18nLabelSchema.optional().describe( + label: stripImportedDefaults(I18nLabelSchema).optional().describe( 'Legend / tooltip name for this series — a plain string or an inline locale map; defaults to the column key', ), variant: z.enum(['primary', 'comparison']).optional().describe( diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 742089caeb..0196ec8a14 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -26,6 +26,38 @@ import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; // and the form predicate keys below read ONE definition. Its docblock and // rationale moved with it. import { ExpressionWireSchema } from './expression.zod.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * Select Option Schema — derived from `@objectstack/spec/data` @@ -39,7 +71,7 @@ import { ExpressionWireSchema } from './expression.zod.js'; * is why the gap survived. */ export const SelectOptionSchema = z.object({ - ...SpecSelectOptionSchema.shape, + ...stripImportedDefaults(SpecSelectOptionSchema).shape, // Deliberate divergence: the spec requires a lowercase machine identifier; // standalone UI forms legitimately bind numeric/boolean values. The parity // test pins both directions so a future spec widening gets noticed. diff --git a/packages/types/src/zod/imported-defaults.ts b/packages/types/src/zod/imported-defaults.ts new file mode 100644 index 0000000000..aa76ce98b8 --- /dev/null +++ b/packages/types/src/zod/imported-defaults.ts @@ -0,0 +1,326 @@ +/** + * 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 IMPORT BOUNDARY — **this mirror authors no default, imported subschemas + * included** (objectui#8317, director ruling, decision batch #90, 2026-09-08, + * under the maintainer's standing delegation). + * + * Decision batch #69 (objectui#7735) ruled a principle, not two rows: *a + * validator validates; it does not write values into an author's document.* + * PR #8299 delivered it for the 41 `.default()` call sites written in this + * repository's own mirrors, and stopped there — the ruling's named scope could + * not reach a default this repository never wrote. Measured after that landing, + * **57 `ZodDefault` nodes were still reachable from the published + * `@object-ui/types/zod` barrel**, every one inside a subschema imported by + * reference from `@objectstack/spec`. So `safeValidateSchema` went on + * substituting on those keys: an author who wrote `navigation: {}` got back + * `navigation: { mode, preventNavigation, openNewTab, size }` — four keys they + * did not write — and the "one authored document, two shapes" defect + * objectui#7735 was opened about survived there. + * + * Batch #90 ruled option A: the principle holds for **every** key + * `safeValidateSchema` answers, not only the 41 this repository authored, and + * the imported defaults are stripped **here, at the boundary where the spec + * enters this package**. ⛔ Option B — the 1546-site spec-side change on + * another repository's release train — was NOT taken, and A is reversible into + * it: every strip below becomes a no-op the day `@objectstack/spec` adopts the + * same principle, with nothing to roll back. ⛔ Option C-unstated — 41 stripped, + * 57 not, indistinguishable from the document — was refused as the one outcome + * nobody defended. + * + * ## Why a WALK and not 57 hand-written `.removeDefault()` calls + * + * `.removeDefault()` is the established local pattern (`objectql.zod.ts`'s + * `ViewKindEnum`, `views.zod.ts`'s `SpecListViewTypeEnum`) and it is what runs + * below — but it only reaches a default sitting at the TOP of a member's + * wrapper chain. Of the 57, four are at that depth; the rest are nested inside + * imported subtrees (`page.interfaceConfig.*`, dashboard `chartConfig.*`, + * `kanban.grouping.fields[].*`), where reaching one by hand means restating the + * spec's own object locally. That is the "narrower than the contract it + * implements" shape this directory already records twice, and it is how a + * mirror silently stops being a mirror. Walking the graph instead keeps every + * key, every type and every check exactly as the spec declares them, and + * removes only the substitution. + * + * ## The accept set does not move, and that is load-bearing + * + * `.default(v)` carries optionality as well as a value: a member spelled + * `.default(v)` with no `.optional()` is omissible BECAUSE of the default, and + * a naive unwrap makes it REQUIRED — a silent accept-set narrowing on a + * published surface, which this ruling did not authorise. So the `default` arm + * below re-optionalises whatever `.removeDefault()` hands back, unless it is + * already optional. `__tests__/imported-defaults-8317.test.ts` re-derives that + * as a live differential over the repository's own document corpus rather than + * trusting this paragraph. + * + * ## Why objects are CLONED and not rebuilt + * + * Every node 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()` the spec installed on the way down is silently lost and this + * package would accept documents the spec refuses. Sibling precedent, and the + * file this walker is modelled on: `../strict-authoring-face.ts`. + * + * ⛔ The input is never mutated. `@objectstack/spec`'s own exports are shared + * with every other consumer in the workspace; this module derives and returns a + * new graph, and leaves the spec's objects exactly as it found them. + */ + +import { z } from 'zod'; + +/** + * 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. Same field set, and the same reason, as `../strict-authoring-face.ts`. + */ +interface WalkableDef { + type: string; + shape?: Record; + options?: z.ZodType[]; + items?: z.ZodType[]; + element?: z.ZodType; + rest?: z.ZodType; + valueType?: z.ZodType; + keyType?: z.ZodType; + left?: z.ZodType; + right?: z.ZodType; + in?: z.ZodType; + out?: z.ZodType; + innerType?: z.ZodType; + catchall?: z.ZodType; + getter?: () => z.ZodType; +} + +interface ZodInternals { + _zod: { def: WalkableDef; optin?: string }; + 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 + * 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'` and parse exactly like any other object. On this face + * those nodes arrive through `@objectstack/spec`-derived subtrees, which is + * precisely the population this module walks: an object-only guard hands each + * of them straight back and the entire subtree beneath it — defaults included — + * goes unwalked, with no symptom other than a residue count that will not fall. + * `../strict-authoring-face.ts` records the same lesson, learnt the hard way. + */ +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 — `def.checks` above all. */ +const cloneWithDef = (schema: z.ZodType, patch: Partial): z.ZodType => { + const Ctor = internals(schema).constructor; + return new Ctor({ ...internals(schema)._zod.def, ...patch }); +}; + +/** Does this node already answer "omissible" to an enclosing object? */ +const isAlreadyOptional = (schema: z.ZodType): boolean => + internals(schema)._zod.optin === 'optional'; + +/** + * A walker with ONE memo, shared by every call. Two schemas stripped through it + * share their derived subgraph, so `ListViewSchema` and the + * `UserActionsConfigSchema` nested inside it agree by construction rather than + * by assertion, and the second costs nothing. + */ +const memo = new Map(); + +const walk = (schema: z.ZodType): 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: spec subtrees are + // self-referential (a filter clause contains filter clauses), so a walker that + // forced the getter eagerly would not terminate. + // + // ⚠️ This is the ONE arm that cannot answer "was anything stripped below me?" + // without forcing the getter, so it ALWAYS rebuilds — the identity property + // below stops at a `z.lazy`. Measured on the imported population at the head + // this landed on: THREE distinct `lazy` nodes are reachable + // (`AppSchema.navigation[]` and `NavigationAreaSchema.navigation[]`, which are + // the same recursive nav item; the recursive filter clause under + // `DashboardWidgetSchema.filter` / `GlobalFilterSchema.optionsFrom.filter` / + // `PageSchema.slots.header…dataSource.filter`; and `PageSchema.regions[].components[]`). + // Each sits inside a schema that carries a default anyway, so today the + // exception costs no extra rebuild — the pin file re-derives that count and + // goes red if it moves, rather than trusting this sentence. + // + // ⛔ Rebuilt through `cloneWithDef`, not `z.lazy(…)`: a fresh `z.lazy` would + // be a different class with none of this node's own `def.checks` or + // description, which is the same silent-loss shape the clone rule exists for. + if (def.type === 'lazy') { + const out = cloneWithDef(schema, { getter: () => walk(def.getter!()) }); + memo.set(schema, out); + return out; + } + + /** + * ⭐ THE IDENTITY PROPERTY, and it is the ruling's reversibility made literal. + * + * A node is rebuilt ONLY if the walk actually changed something beneath it. + * A subtree with no `ZodDefault` in it therefore comes back REFERENCE-EQUAL + * to the spec's own object — so this module is exactly the identity function + * the day `@objectstack/spec` adopts the same principle, with nothing to roll + * back, and today it leaves every already-clean imported schema binding by + * reference exactly as it was before batch #90. + */ + const unchanged = (children: readonly (readonly [z.ZodType | undefined, z.ZodType | undefined])[]): boolean => + children.every(([before, after]) => before === after); + + let out: z.ZodType; + switch (def.type) { + /** + * THE STRIP. `.removeDefault()` is the ruled spelling and the established + * local pattern; it returns this node's inner type, which is then walked so + * a default nested under a default is reached too. + * + * The re-optionalisation is the accept-set half. `ZodDefault(ZodOptional(T))` + * — the `.optional().default(v)` spelling — unwraps to something already + * omissible and is left alone; a bare `ZodDefault(T)` unwraps to a REQUIRED + * member and is made optional again, because its omissibility was the + * default's doing and removing it must not narrow what this package accepts. + */ + case 'default': { + const inner = walk((schema as unknown as { removeDefault: () => z.ZodType }).removeDefault()); + out = isAlreadyOptional(inner) ? inner : z.optional(inner); + break; + } + case 'object': { + const shape: Record = {}; + let same = true; + for (const [key, value] of Object.entries(def.shape ?? {})) { + shape[key] = walk(value); + if (shape[key] !== value) same = false; + } + out = same ? schema : cloneWithDef(schema, { shape }); + 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': { + const options = (def.options ?? []).map(walk); + out = unchanged((def.options ?? []).map((o, i) => [o, options[i]] as const)) + ? schema + : cloneWithDef(schema, { options }); + break; + } + case 'array': { + const element = walk(def.element!); + out = element === def.element ? schema : cloneWithDef(schema, { element }); + break; + } + case 'tuple': { + const items = (def.items ?? []).map(walk); + const rest = def.rest ? walk(def.rest) : undefined; + out = unchanged([...(def.items ?? []).map((it, i) => [it, items[i]] as const), [def.rest, rest] as const]) + ? schema + : cloneWithDef(schema, { items, ...(def.rest ? { rest: rest! } : {}) }); + break; + } + case 'record': { + const valueType = walk(def.valueType!); + out = valueType === def.valueType ? schema : cloneWithDef(schema, { valueType }); + break; + } + case 'intersection': { + const left = walk(def.left!); + const right = walk(def.right!); + out = unchanged([[def.left, left], [def.right, right]]) ? schema : cloneWithDef(schema, { left, right }); + break; + } + // BOTH sides. A pipe spelled `X.transform(f)` holds the schema in `in`; one + // spelled `z.preprocess(f, X)` holds it in `out`. Walking only `in` leaves + // the second's real schema — and any default inside it — untouched, with no + // symptom other than a residue this module claims to have removed. + case 'pipe': { + const inSide = walk(def.in!); + const outSide = def.out ? walk(def.out) : undefined; + out = unchanged([[def.in, inSide], [def.out, outSide]]) + ? schema + : cloneWithDef(schema, { in: inSide, ...(def.out ? { out: outSide! } : {}) }); + break; + } + case 'optional': + case 'nullable': + case 'nonoptional': + case 'readonly': + case 'catch': { + const innerType = walk(def.innerType!); + out = innerType === def.innerType ? schema : cloneWithDef(schema, { innerType }); + break; + } + case 'custom': + case 'transform': + case 'function': + // No shape inside, so no default inside either. Returned as-is. + out = schema; + break; + default: + // Leaves: string, number, boolean, literal, enum, any, unknown, never, + // date, … — nothing to walk into. + out = schema; + } + memo.set(schema, out); + return out; +}; + +/** + * Strip every `ZodDefault` reachable from an imported `@objectstack/spec` + * schema, at this package's import boundary. + * + * Returns a schema with the same TypeScript type, the same keys, the same + * checks and the same accept set — differing only in that a key the author + * omitted stays omitted in `parse` output instead of being written for them. + * The input is left untouched. + * + * ⚠️ A schema that HAD a default in it is not reference-equal to the spec's + * afterwards: a mirror member re-exporting one of these re-exports this + * package's derivation, not the spec object itself. That is exactly what batch + * #90 ruled. A schema with nothing to strip comes back REFERENCE-EQUAL — see + * the identity property in the walker — so applying this at a crossing that is + * already clean changes nothing at all. + * + * ⚠️ Memoised across every call, so writing this at each crossing costs one + * walk per spec schema, not one per call site. `stripImportedDefaults(X)` is + * the SAME object every time, which is what lets the mirrors spell it inline + * instead of parking it in a local `const` — and inline is required, not + * stylistic: `check:spec-symbols` reads exactly one hop, so a mirror export + * under a spec-owned name has to show the spec binding in its own initializer. + * + * ⚠️ The STATIC type is deliberately unchanged — strictly, `T` in and `T` out — + * following `../strict-authoring-face.ts`, whose derivation makes the same + * choice for the same reason: this is a property of the PARSE, not of the + * declaration, and the TypeScript twins in `../*.ts` are hand-written and + * already declare these keys omissible. The one consequence worth naming, since + * it bites silently: on a member that HAD a default, `.removeDefault()` still + * TYPECHECKS against the unchanged static type and THROWS at runtime, because + * the node there is now a `ZodOptional`. Derive a value vocabulary off the + * `Imported…` binding instead (`views.zod.ts`'s `SpecListViewTypeEnum`). + * + * @example + * ```ts + * import { ListViewSchema as ImportedSpecListViewSchema } from '@objectstack/spec/ui'; + * const SpecListViewSchema = stripImportedDefaults(ImportedSpecListViewSchema); + * ``` + */ +export function stripImportedDefaults(schema: T): T { + return walk(schema) as T; +} diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index b8669172d6..4d26387c03 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -30,7 +30,30 @@ * console.error('Validation errors:', result.error); * } * ``` - * + * + * ## ⭐ This mirror authors no default, imported subschemas included + * + * `result.data` is the document you handed in. This face VALIDATES; it does not + * write values into an author's document — not on the keys this package + * declares (objectui#7735, decision batch #69) and not on the keys it imports + * by reference from `@objectstack/spec` (objectui#8317, decision batch #90). + * An author who writes `navigation: {}` gets `navigation: {}` back, not + * `navigation: { mode: 'page', preventNavigation: false, openNewTab: false, + * size: 'auto' }`. + * + * ⇒ There is no key on this face whose presence in `result.data` means anything + * other than "the author wrote it", and no import graph to read to find out + * which keys those are. The authoritative default for a key is the RENDERER's + * own fallback, which is what actually runs; a `@default` JSDoc tag DESCRIBES + * that fallback and never installs one. + * + * ⛔ So: no `.default()` in `zod/*.zod.ts`, and every `@objectstack/spec` schema + * crossing into a mirror shape wrapped in `stripImportedDefaults` + * (`zod/imported-defaults.ts`). Both halves are ratcheted at zero by + * `__tests__/zod-mirror-authors-no-defaults-7735.test.ts` and + * `__tests__/imported-defaults-8317.test.ts`. The accept set is unchanged by + * either: a key that was omissible stays omissible. + * * @packageDocumentation */ diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 80248353d4..80c19b1a5d 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -24,6 +24,38 @@ import { PageVariableSchema as SpecPageVariableSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * Div Schema - Basic HTML container @@ -362,7 +394,7 @@ export const PageNodeRegionSchema = z.object({ * variable nothing could ever write — and its `type` enum was missing * `record_id`, so a spec-valid record-picker variable was rejected outright. */ -export const PageVariableSchema = SpecPageVariableSchema; +export const PageVariableSchema = stripImportedDefaults(SpecPageVariableSchema); /** * Page Type Schema — `@objectstack/spec/ui` schema re-exported **by reference** @@ -374,7 +406,7 @@ export const PageVariableSchema = SpecPageVariableSchema; * `layout.ts` had drifted the OPPOSITE way (it carried five visualization names * the spec explicitly repudiates); both now come from the spec. */ -export const PageTypeSchema = SpecPageTypeSchema; +export const PageTypeSchema = stripImportedDefaults(SpecPageTypeSchema); /** * Spec-owned Page fields, flowing in **by reference** (objectstack#4115). @@ -398,7 +430,7 @@ export const PageTypeSchema = SpecPageTypeSchema; * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui pages. */ -const SpecPageFields = specFieldsExcept(SpecPageSchema.shape, [ +const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).shape, [ 'name', 'label', 'description', diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index b4a9d01943..d8104f2a07 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -39,6 +39,38 @@ import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { DrillDownConfigSchema } from './data-display.zod.js'; import { ViewSwitcherSchema } from './views.zod.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). + * + * **This mirror authors no default, imported subschemas included.** Batch #69 + * (objectui#7735) ruled that a validator validates and does not write values + * into an author's document; batch #90 ruled that this holds for EVERY key + * `safeValidateSchema` answers, not only the sites this repository wrote. So a + * schema arriving from `@objectstack/spec` crosses into a mirror shape only + * through `stripImportedDefaults`, which removes each reachable `ZodDefault` + * with `.removeDefault()` and keeps the key omissible. Keys, types, checks and + * the accept set are untouched, and a subtree carrying no default comes back + * reference-equal — so this is a no-op the day the spec adopts the same + * principle. + * + * ⛔ Spelled at every crossing rather than once per file, deliberately: a local + * `const Spec… = stripImportedDefaults(…)` would put the spec's provenance one + * hop away from every declaration that reads it, and `check:spec-symbols` + * (rule 1) reads exactly one hop — a mirror export under a spec-owned name has + * to show the spec binding in its OWN initializer. The verbosity is the + * provenance. + * + * ⚠️ A read that is NOT a crossing stays unwrapped and is declared as such: a + * value VOCABULARY (`./views.zod.ts`'s `SpecListViewTypeEnum` and + * `./objectql.zod.ts`'s `ViewKindEnum`, which unwrap the spec's own + * `.default('grid')` to reach its enum) and a TYPE position — neither puts a + * default into a parsed document. `../__tests__/imported-defaults-8317.test.ts` + * re-derives that exception list from the source rather than trusting this + * paragraph, and fails if an entry stops matching a real read. + */ + /** * HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -50,7 +82,7 @@ import { ViewSwitcherSchema } from './views.zod.js'; * so this repo keeps exporting it under the `HttpMethodSchema` name — following * the rename WITHOUT changing cross-package semantics (objectui#3499). */ -export const HttpMethodSchema = SpecHttpMethodSubsetSchema; +export const HttpMethodSchema = stripImportedDefaults(SpecHttpMethodSubsetSchema); /** * HTTP Request Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -58,14 +90,14 @@ export const HttpMethodSchema = SpecHttpMethodSubsetSchema; * `body` is the spec's `z.unknown()` (a superset of the old record/string/FormData/ * Blob union) and `method` now defaults to `'GET'` on parse. */ -export const HttpRequestSchema = SpecHttpRequestSchema; +export const HttpRequestSchema = stripImportedDefaults(SpecHttpRequestSchema); /** * View Data Source Schema — `@objectstack/spec/ui` schema re-exported by reference * (issue #2231; formerly a hand-written mirror that had drifted behind the spec's * fourth `provider: 'schema'` variant for schema-bound forms). */ -export const ViewDataSchema = SpecViewDataSchema; +export const ViewDataSchema = stripImportedDefaults(SpecViewDataSchema); /** * List Column Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -82,21 +114,21 @@ export const ViewDataSchema = SpecViewDataSchema; * One behavior change rides along: the spec's `prefix.type` defaults to `'text'` * on parse instead of staying `undefined`, so the renderer always gets a value. */ -export const ListColumnSchema = SpecListColumnSchema; +export const ListColumnSchema = stripImportedDefaults(SpecListColumnSchema); /** * Selection Config Schema — `@objectstack/spec/ui` schema re-exported by reference * (issue #2231; formerly a hand-written mirror). `type` now defaults to `'none'` * on parse instead of staying undefined. */ -export const SelectionConfigSchema = SpecSelectionConfigSchema; +export const SelectionConfigSchema = stripImportedDefaults(SpecSelectionConfigSchema); /** * Pagination Config Schema — `@objectstack/spec/ui` schema re-exported by reference * (issue #2231; formerly a hand-written mirror). `pageSize` is now the spec's * positive-int with a default of 25 on parse. */ -export const PaginationConfigSchema = SpecPaginationConfigSchema; +export const PaginationConfigSchema = stripImportedDefaults(SpecPaginationConfigSchema); /** * Sort Config Schema @@ -144,7 +176,7 @@ const SPEC_EXPORT_OPTIONS_OBJECT_SHAPE: SpecExportOptionsShape = ((): SpecExport options?: readonly Peelable[]; shape?: SpecExportOptionsShape; }; - let cur = SpecListViewSchema.shape.exportOptions as unknown as Peelable; + let cur = stripImportedDefaults(SpecListViewSchema).shape.exportOptions as unknown as Peelable; for (let i = 0; i < 5 && cur && !cur.options && typeof cur.unwrap === 'function'; i++) { cur = cur.unwrap(); } @@ -349,14 +381,14 @@ export const ObjectViewSchema = BaseSchema.extend({ defaultListView: z.string().optional().describe('Key of the listViews entry shown first'), // Spec slot by reference (objectui#7779) — `NavigationConfigSchema.optional()`, // the same object `ListViewSchema` derives its `navigation` from. - navigation: SpecListViewSchema.shape.navigation, + navigation: stripImportedDefaults(SpecListViewSchema).shape.navigation, table: z.lazy(() => ObjectGridSchema.omit({ type: true, objectName: true }).partial()).optional().describe('Table config'), form: z.lazy(() => ObjectFormSchema.omit({ type: true, objectName: true, mode: true }).partial()).optional().describe('Form config'), // Spec slots by reference (objectui#7779) — `array(string).optional()` on // both; the spec's own description marks `filterableFields` a legacy // shorthand for `userFilters.fields`. - searchableFields: SpecListViewSchema.shape.searchableFields, - filterableFields: SpecListViewSchema.shape.filterableFields, + searchableFields: stripImportedDefaults(SpecListViewSchema).shape.searchableFields, + filterableFields: stripImportedDefaults(SpecListViewSchema).shape.filterableFields, showSearch: z.boolean().optional().describe('Show search'), showFilters: z.boolean().optional().describe('Show filters'), showSort: z.boolean().optional().describe('Show sort controls'), @@ -514,26 +546,26 @@ const LIST_VIEW_LOCAL_OVERRIDES = [ // puts it on `GanttConfigSchema`/`TreeConfigSchema`: the renderers grow config knobs // ahead of the protocol (calendar's `allDayField`, for one), and stripping them here // would silently disable a shipped capability. -const KanbanConfig = SpecKanbanConfigSchema.partial().extend({ +const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `groupByField` */ groupField: z.string().optional().describe('Deprecated alias for groupByField'), /** @deprecated legacy alias for the spec's `columns` (fields shown on each card) */ cardFields: z.array(z.string()).optional().describe('Deprecated alias for columns'), }).passthrough(); -const CalendarConfig = SpecCalendarConfigSchema.partial().extend({ +const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ // objectui-only: the calendar renderer's initial view mode. No spec counterpart — // promote it rather than growing this extension. `'agenda'` was retired // (objectui#5784, following #5740): `CalendarView` renders no agenda view. defaultView: z.enum(['month', 'week', 'day']).optional().describe("Initial calendar view mode — 'month' | 'week' | 'day' ('agenda' was retired: objectui#5784)"), }).passthrough(); -const GalleryConfig = SpecGalleryConfigSchema.partial().extend({ +const GalleryConfig = stripImportedDefaults(SpecGalleryConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `coverField` */ imageField: z.string().optional().describe('Deprecated alias for coverField'), }).passthrough(); -const TimelineConfig = SpecTimelineConfigSchema.partial().extend({ +const TimelineConfig = stripImportedDefaults(SpecTimelineConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `startDateField` */ dateField: z.string().optional().describe('Deprecated alias for startDateField'), }).passthrough(); @@ -560,7 +592,7 @@ const ViewKindEnum = SpecListViewSchema.shape.type.removeDefault(); * before this extension an author writing `userActions: { group: false }` had * it silently stripped — valid on parse, no effect at render. */ -export const UserActionsSchema = SpecUserActionsConfigSchema.extend({ +export const UserActionsSchema = stripImportedDefaults(SpecUserActionsConfigSchema).extend({ group: z.boolean().optional().describe('Allow users to group records'), hideFields: z.boolean().optional().describe('Allow users to show/hide columns'), rowColor: z.boolean().optional().describe('Allow users to color rows by a field value'), @@ -594,7 +626,7 @@ export const ListViewSchema = BaseSchema // excludes the tombstone and hands the key back to `BaseSchema`'s // passthrough, turning a loud rejection into a silently-accepted dead key. // Re-forwarding needs an implementation card filed first (the ruling's text). - .extend(specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES).shape) + .extend(specFieldsExcept(stripImportedDefaults(SpecListViewSchema).shape, LIST_VIEW_LOCAL_OVERRIDES).shape) .extend({ // Component discriminator — load-bearing for the ObjectQLComponentSchema union. type: z.literal('list-view'), @@ -659,7 +691,7 @@ export const ListViewSchema = BaseSchema // `role`) plus `live`, which has no spec counterpart. The legacy // `{ label, describedBy }` spellings fold into the canonical ones at the // ListView boundary (#2890). - aria: SpecAriaPropsSchema.extend({ + aria: stripImportedDefaults(SpecAriaPropsSchema).extend({ live: z.enum(['polite', 'assertive', 'off']).optional() .describe('aria-live politeness for the list region (objectui-only — promote rather than grow this extension)'), }).optional().describe('ARIA attributes'), @@ -700,7 +732,7 @@ export const ListViewSchema = BaseSchema // nothing on the render path parses, so a stored array reaches `ListView` // un-lifted and its `resolvedExportOptions` fold is load-bearing // (objectui#4535 item 4). - exportOptions: SpecListViewSchema.shape.exportOptions, + exportOptions: stripImportedDefaults(SpecListViewSchema).shape.exportOptions, // Per-view-type configs — spec-derived (see the definitions above #2231). // `gantt` is NOT here: it flows in from the spec fields unmodified. kanban: KanbanConfig.optional().describe('Kanban-specific configuration'), @@ -826,7 +858,7 @@ export const ObjectMapSchema = BaseSchema.extend({ sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration, forwarded as $orderby'), map: ObjectMapConfigSchema.optional().describe('Map configuration (the author face)'), enableClustering: z.boolean().optional().describe('Group nearby markers into clusters'), - navigation: SpecNavigationConfigSchema.optional().describe('Record navigation behaviour (drawer/dialog/page)'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour (drawer/dialog/page)'), locationField: z.string().optional().describe('Location field (internal flat form; prefer map.locationField)'), titleField: z.string().optional().describe('Title field (internal flat form; prefer map.titleField)'), mapStyle: z.string().optional().describe('MapLibre style URL/spec (overrides the public demo default)'), @@ -935,7 +967,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // (objectui#5074). Absence semantics are load-bearing: an omitted `viewMode` // lets a persisted layout seed the timeline granularity before the // renderer's 'day' fallback — do NOT add `.default('day')` here. - viewMode: SpecGanttConfigSchema.shape.viewMode.describe( + viewMode: stripImportedDefaults(SpecGanttConfigSchema).shape.viewMode.describe( 'Initial timeline granularity, honoured by both renderer branches; when omitted, a persisted layout may seed it' ), // objectui#5903 — ten keys `ObjectGantt` reads and this mirror did not @@ -952,7 +984,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ holidays: z.array(z.string()).optional().describe("Non-working dates for the working calendar, ISO 'yyyy-mm-dd' (UTC)"), persistLayout: z.boolean().optional().describe('Opt OUT of layout persistence — only an explicit false disables it'), viewName: z.string().optional().describe("Layout-persistence scope; storage key is `objectName:viewName` (default 'default')"), - navigation: SpecNavigationConfigSchema.optional().describe('Record navigation behaviour on task click (drawer/dialog/page)'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour on task click (drawer/dialog/page)'), markers: z .array( z.object({ @@ -977,20 +1009,20 @@ export const ObjectGanttSchema = BaseSchema.extend({ // // The spec-modelled members are taken from `SpecGanttConfigSchema.shape` by // reference, exactly as `viewMode` above is, so the vocabulary cannot fork. - colorField: SpecGanttConfigSchema.shape.colorField, - dependenciesField: SpecGanttConfigSchema.shape.dependenciesField, - parentField: SpecGanttConfigSchema.shape.parentField, - typeField: SpecGanttConfigSchema.shape.typeField, - tooltipFields: SpecGanttConfigSchema.shape.tooltipFields, - baselineStartField: SpecGanttConfigSchema.shape.baselineStartField, - baselineEndField: SpecGanttConfigSchema.shape.baselineEndField, - groupByField: SpecGanttConfigSchema.shape.groupByField, - resourceView: SpecGanttConfigSchema.shape.resourceView, - assigneeField: SpecGanttConfigSchema.shape.assigneeField, - effortField: SpecGanttConfigSchema.shape.effortField, - capacity: SpecGanttConfigSchema.shape.capacity, - quickFilters: SpecGanttConfigSchema.shape.quickFilters, - autoZoomToFilter: SpecGanttConfigSchema.shape.autoZoomToFilter, + colorField: stripImportedDefaults(SpecGanttConfigSchema).shape.colorField, + dependenciesField: stripImportedDefaults(SpecGanttConfigSchema).shape.dependenciesField, + parentField: stripImportedDefaults(SpecGanttConfigSchema).shape.parentField, + typeField: stripImportedDefaults(SpecGanttConfigSchema).shape.typeField, + tooltipFields: stripImportedDefaults(SpecGanttConfigSchema).shape.tooltipFields, + baselineStartField: stripImportedDefaults(SpecGanttConfigSchema).shape.baselineStartField, + baselineEndField: stripImportedDefaults(SpecGanttConfigSchema).shape.baselineEndField, + groupByField: stripImportedDefaults(SpecGanttConfigSchema).shape.groupByField, + resourceView: stripImportedDefaults(SpecGanttConfigSchema).shape.resourceView, + assigneeField: stripImportedDefaults(SpecGanttConfigSchema).shape.assigneeField, + effortField: stripImportedDefaults(SpecGanttConfigSchema).shape.effortField, + capacity: stripImportedDefaults(SpecGanttConfigSchema).shape.capacity, + quickFilters: stripImportedDefaults(SpecGanttConfigSchema).shape.quickFilters, + autoZoomToFilter: stripImportedDefaults(SpecGanttConfigSchema).shape.autoZoomToFilter, // …and objectui's own ten, from the one field map above. ...GanttConfigExtensionFields, // `gantt` — the BLOCK face `getGanttConfig`'s FIRST branch reads and prefers @@ -1010,7 +1042,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // configuration` on failure. Maintainer ruling, objectui#6475 (2026-08-27), // Option A: enforce as-is, no warning window (excluded by the startup-stage // no-gradualism rule, objectstack#12668 — no named external-user evidence). - gantt: SpecGanttConfigSchema.extend(GanttConfigExtensionFields).optional().describe( + gantt: stripImportedDefaults(SpecGanttConfigSchema).extend(GanttConfigExtensionFields).optional().describe( 'Nested gantt config block — the authoring face, and the winner over the flattened top-level keys whenever present' ), // The query/data keys the fetch path reads. They were declared on @@ -1212,9 +1244,9 @@ export const ObjectGallerySchema = BaseSchema.extend({ objectName: z.string().optional().describe('ObjectQL object name'), filter: z.unknown().optional().describe('Query filter, forwarded verbatim as $filter'), data: z.array(z.record(z.string(), z.unknown())).optional().describe('Inline records'), - gallery: SpecGalleryConfigSchema.optional().describe('Gallery configuration (@objectstack/spec GalleryConfig)'), - navigation: SpecNavigationConfigSchema.optional().describe('Record navigation behaviour (drawer/dialog/page)'), - grouping: SpecGroupingConfigSchema.optional().describe('Grouping configuration for sectioned display'), + gallery: stripImportedDefaults(SpecGalleryConfigSchema).optional().describe('Gallery configuration (@objectstack/spec GalleryConfig)'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour (drawer/dialog/page)'), + grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration for sectioned display'), imageField: z.string().optional().describe('DEPRECATED — use gallery.coverField'), titleField: z.string().optional().describe('DEPRECATED — use gallery.titleField'), }); diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 5c8a30dd57..cce7acb267 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -24,6 +24,13 @@ import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; /** * The spec's own list-view type vocabulary, unwrapped from its `.default('grid')`. * + * ⭐ NOT a crossing of the objectui#8317 import boundary, and this file has no + * other read of `@objectstack/spec`, which is why it imports no + * `stripImportedDefaults`. A vocabulary is a set of VALUES, not a subschema: + * `.removeDefault()` here reaches the spec's enum and nothing that could write a + * key into a parsed document ever flows from it. The declared-exception list in + * `../__tests__/imported-defaults-8317.test.ts` names this site and its twin. + * * Deliberately NOT exported: `__tests__/zod-mirror-parity.test.ts` runs a * population census over every `export const` in this directory, and this is a * derivation helper rather than a mirror anyone should reach for. The same