diff --git a/.changeset/6152-object-calendar-container-strict.md b/.changeset/6152-object-calendar-container-strict.md new file mode 100644 index 0000000000..2d13ffb3b0 --- /dev/null +++ b/.changeset/6152-object-calendar-container-strict.md @@ -0,0 +1,31 @@ +--- +'@object-ui/types': minor +--- + +feat(types)!: the `object-calendar` element's `calendar` container is the `@objectstack/spec` 17.7.0 slot by reference, and its `.passthrough()` is gone (objectui#6152, round 9) + +Clause-②: no + +**Narrowed (breaking).** `@objectstack/spec` 17.7.0 types `ComponentPropsMap['object-calendar'].calendar` +as a strict copy of the list view's calendar block: `startDateField` required, `endDateField`, +`titleField`, `colorField` and `allDayField` optional, and any other key refused with +`unrecognized_keys`. `@object-ui/types` kept the container as the list view block `.partial()` plus +`.passthrough()`, so it accepted two things the spec refuses. It now takes the row's own member by +reference, on the zod `ObjectCalendarSchema` and, through the derived `ObjectCalendarBlockConfig`, +on the TypeScript interface: + +- A key outside the five, inside the block, is refused. That includes `calendar.defaultView`: + `ObjectCalendar` reads the view mode from the node's own `defaultView` and never from the block, + so write `defaultView: 'week'` on the node. +- A block without `startDateField` is refused at `calendar.startDateField`. `ObjectCalendar` + returns the block whole, so such a block mounted a calendar that placed no event. +- `ObjectCalendarBlockConfig` no longer has an index signature, and its `startDateField` is + required, so a typed literal with either shape is now a compile error. + +The retired `calendar.dateField` / `calendar.endField` keep their by-name refusal (objectui#8355), +which names the key to write instead. A census pin in this package's tests re-derives, on every +run, that each `object-calendar` container authored in this repository's examples, docs and +READMEs parses on the narrowed container. + +What did not move: the list view's own `calendar` block, which keeps `.passthrough()` and +`defaultView`, and the renderers' reads. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 738feda334..b4c2619ab6 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -312,18 +312,20 @@ before the spec declared it; the spec declares it since `@objectstack/spec` never declares it draws exactly as before, with the all-day flag inferred from the absence of an end date. -Nor is a sixth key rejected on the way in. Both published faces of -`ObjectCalendarSchema` declare the `calendar` container (objectui#8651), with -the five keys above as its members. Their values are checked, so -`calendar: { startDateField: 42 }` is refused, and the retired `dateField` / -`endField` are refused by name (objectui#8355). The container keeps -`.passthrough()`, though, so any other key you write inside it still parses and -reaches the renderer unexamined. `@objectstack/spec` does not judge it there -either: on an `object-calendar` node it declares the `calendar` key but not that -key's shape. On the list-view path the container is objectui's own mirror of -`CalendarConfigSchema`, kept `.passthrough()` in the same way, while the spec's -own list-view block is strict. Annotating the block `CalendarConfig` is what -narrows it to the spec's five. +On an `object-calendar` node, a sixth key is rejected on the way in. Both +published faces of `ObjectCalendarSchema` declare the `calendar` container +(objectui#8651), and since objectui#6152 that container is `@objectstack/spec`'s +own `object-calendar` slot, taken by reference: strict, with the five keys above +as its members and `startDateField` required. A key outside the five is refused +with the spec's `unrecognized_keys` diagnostic, as the spec refuses it there; +that includes `defaultView`, which the renderer reads from the node itself, not +from this block. Values are checked too, so `calendar: { startDateField: 42 }` is +refused. The retired `dateField` / `endField` are refused by name +(objectui#8355), and the message names the key to write instead. On the list-view +path the container is a different contract: objectui's own mirror of +`CalendarConfigSchema`, still `.passthrough()`, while the spec's own list-view +block is strict. Annotating that block `CalendarConfig` is what narrows it to the +spec's five. ## Configuration diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index ecedf2585f..424005186e 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -310,9 +310,9 @@ import type { ObjectCalendarSchema } from '@object-ui/types'; // `calendar: { titleField: 42 }` are both compile errors, and // `check:doc-snippets` re-runs that check on every commit. Top-level key NAMES // are checked too since objectui#8347 removed `BaseSchema`'s `[key: string]: any`, -// but NOT inside the `calendar` block, which is open (its type is inferred from -// a `.passthrough()` schema), so a misspelt key there still compiles clean. Read -// the `calendar` block as type-checked values, never as a guarded key set. +// and since objectui#6152 so are the key names inside the `calendar` block: its +// type is the spec's strict `object-calendar` slot, so a misspelt key there, or +// a block without `startDateField`, is a compile error as well as a parse error. const schema: ObjectCalendarSchema = { type: 'object-calendar', objectName: 'events', diff --git a/packages/types/src/__tests__/object-calendar-container-by-reference-6152.test.ts b/packages/types/src/__tests__/object-calendar-container-by-reference-6152.test.ts new file mode 100644 index 0000000000..2ac385ba1f --- /dev/null +++ b/packages/types/src/__tests__/object-calendar-container-by-reference-6152.test.ts @@ -0,0 +1,475 @@ +/** + * 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. + * + * objectui#6152 round 9 — the `object-calendar` ELEMENT's own `calendar` container + * follows `@objectstack/spec` 17.7.0's slot BY REFERENCE, and its `.passthrough()` is gone. + * + * 17.7.0 types `ComponentPropsMap['object-calendar'].calendar` (objectstack#21464 stage 2) + * as a strict copy of the list view's calendar block. This package kept the container as + * `CalendarConfigSchema` `.partial()` + `.passthrough()`, so its door admitted two things + * the slot refuses: an unexamined key (`calendar.defaultView`, a misspelling), and a block + * without `startDateField`. objectui#8327 forbids that direction. The container is now the + * row's own member, extended only by objectui#8355's two by-name alias refusals. + * + * The blocks below: + * + * 1. the slot, measured on the INSTALLED spec — its wrapper chain, its strictness, its + * members, the required `startDateField`, and that it declares no `defaultView`; + * 2. the mirror IS that slot (member identity, not a copy); + * 3. verdict equality: on every input below, the mirror's container gives the slot's + * verdict and the slot's diagnostics, except the two aliases, which both refuse and + * objectui names at their own path; + * 4. both directions through the published doors, on the element: an unknown key, + * `defaultView` and the aliases are refused; the five declared members still parse; + * 5. the TypeScript face, which derives from the mirror and is closed the same way; + * 6. the corpus census: every authored `object-calendar` container in this repository + * parses on the narrowed door. The population is re-derived on every run, so no count + * is written down here (AGENTS.md #9). + */ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +import { + CalendarConfigSchema, + ObjectCalendarPropsSchema as SpecObjectCalendarPropsSchema, +} from '@objectstack/spec/ui'; +import type { ObjectCalendarProps as SpecObjectCalendarProps } from '@objectstack/spec/ui'; +import type { ObjectCalendarBlockConfig, ObjectCalendarSchema as TsObjectCalendarSchema } from '../objectql'; +import { ListViewSchema, ObjectCalendarSchema } from '../zod/objectql.zod.js'; +import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; + +/* ── Instruments ─────────────────────────────────────────────────────────────── */ + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; +type Parsed = { success: boolean; error?: { issues: readonly Issue[] } }; +type Parse = (doc: unknown) => Parsed; +type Zod = { + safeParse: Parse; + unwrap: () => Zod; + shape: Record; + _zod: { def: { type: string; innerType?: Zod; catchall?: Zod } }; +}; + +const issues = (r: Parsed) => + r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path, message: i.message, keys: i.keys })); +const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path }))); + +/** `['optional', 'object']` and the like: the def types from the outside in. */ +function wrapperChain(schema: Zod): string[] { + const chain: string[] = []; + let cur: Zod | undefined = schema; + while (cur) { + chain.push(cur._zod.def.type); + cur = cur._zod.def.innerType; + } + return chain; +} +const catchallOf = (object: Zod) => object._zod.def.catchall?._zod.def.type; + +const ROW = SpecObjectCalendarPropsSchema as unknown as Zod; +/** The installed slot, as the row holds it: `optional` around the strict block. */ +const SLOT = ROW.shape.calendar; +const SLOT_BLOCK = SLOT.unwrap(); +/** This package's member, as `ObjectCalendarSchema` holds it. */ +const MIRROR = (ObjectCalendarSchema as unknown as Zod).shape.calendar; +const MIRROR_BLOCK = MIRROR.unwrap(); +const SPEC_BLOCK = CalendarConfigSchema as unknown as Zod; + +const ALIASES = [['dateField', 'startDateField'], ['endField', 'endDateField']] as const; +const CONTROL_KEY = 'zzqxNoSuchField'; +const FIVE = { + startDateField: 'starts_at', + endDateField: 'ends_at', + titleField: 'subject', + colorField: 'status_color', + allDayField: 'is_all_day', +}; +const NODE = { type: 'object-calendar', objectName: 'event' }; + +const DOORS: ReadonlyArray = [ + ['the element mirror', (d) => ObjectCalendarSchema.safeParse(d)], + ['the tolerant face', (d) => AnyComponentSchema.safeParse(d)], + ['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)], + ['safeValidateSchema', (d) => safeValidateSchema(d)], +]; + +/* ── 1. The slot, on the installed protocol (the precondition) ───────────────── */ + +describe('objectui#6152 round 9 — the installed `object-calendar` `calendar` slot', () => { + it('is `optional` around a STRICT object', () => { + expect(wrapperChain(SLOT)).toEqual(['optional', 'object']); + expect(catchallOf(SLOT_BLOCK)).toBe('never'); + // CONTROL — the same reading answers differently on a block that is still open: the + // list VIEW's calendar block in this package stays `.passthrough()` (a different + // contract, not this round's). + const viewBlock = (ListViewSchema as unknown as Zod).shape.calendar.unwrap(); + expect(catchallOf(viewBlock)).toBe('unknown'); + }); + + it('is a copy of the list view\'s block: not the same object, the same member objects', () => { + expect(SLOT_BLOCK).not.toBe(SPEC_BLOCK); + expect(Object.keys(SLOT_BLOCK.shape).sort()).toEqual(Object.keys(SPEC_BLOCK.shape).sort()); + for (const key of Object.keys(SLOT_BLOCK.shape)) expect(SLOT_BLOCK.shape[key]).toBe(SPEC_BLOCK.shape[key]); + }); + + it('requires `startDateField` (control: the start binding alone parses)', () => { + expect(codeAndPath(SLOT_BLOCK.safeParse({ titleField: 'subject' }))) + .toEqual([{ code: 'invalid_type', path: ['startDateField'] }]); + expect(SLOT_BLOCK.safeParse({ startDateField: 'starts_at' }).success).toBe(true); + }); + + it('declares no `defaultView` — the ROW declares it flat (H2)', () => { + expect(Object.keys(SLOT_BLOCK.shape)).not.toContain('defaultView'); + expect(Object.keys(ROW.shape)).toContain('defaultView'); + }); +}); + +/* ── 2. The mirror IS the slot ──────────────────────────────────────────────── */ + +describe('objectui#6152 round 9 — the mirror\'s container is the row\'s own member, by reference', () => { + it('every slot member is the mirror\'s member (identity, not a copy)', () => { + for (const key of Object.keys(SLOT_BLOCK.shape)) expect(MIRROR_BLOCK.shape[key]).toBe(SLOT_BLOCK.shape[key]); + // CONTROL — the identity check can fail: the flat `startDateField` member is not the slot's. + expect((ObjectCalendarSchema as unknown as Zod).shape.startDateField).not.toBe(SLOT_BLOCK.shape.startDateField); + }); + + it('adds nothing but the two alias refusals, and stays strict', () => { + expect(Object.keys(MIRROR_BLOCK.shape).sort()) + .toEqual([...Object.keys(SLOT_BLOCK.shape), ...ALIASES.map(([alias]) => alias)].sort()); + expect(catchallOf(MIRROR_BLOCK)).toBe('never'); + expect(wrapperChain(MIRROR)).toEqual(['optional', 'object']); + }); +}); + +/* ── 3. Verdict equality ────────────────────────────────────────────────────── */ + +const INPUTS: ReadonlyArray = [ + ['an absent container', undefined], + ['the start binding alone', { startDateField: 'starts_at' }], + ['all five declared members', FIVE], + ['an empty block', {}], + ['a block without `startDateField`', { titleField: 'subject' }], + ['`defaultView` inside the block', { startDateField: 'starts_at', defaultView: 'week' }], + ['a misspelling of the start binding', { startDate: 'starts_at' }], + ['an unknown key beside a valid block', { startDateField: 'starts_at', [CONTROL_KEY]: 'x' }], + ['a wrong-typed required member', { startDateField: 42 }], + ['a wrong-typed optional member', { startDateField: 'starts_at', allDayField: true }], + ['a number', 42], + ['null', null], + ['a string', 'starts_at'], + ['an array', []], +]; + +describe('objectui#6152 round 9 — the container\'s verdict is the slot\'s', () => { + it.each(INPUTS)('%s: the same verdict and the same diagnostics', (_label, value) => { + expect(issues(MIRROR.safeParse(value))).toEqual(issues(SLOT.safeParse(value))); + }); + + it('both verdicts occur, so the agreement above is a reading', () => { + const verdicts = INPUTS.map(([, value]) => SLOT.safeParse(value).success); + expect(verdicts).toContain(true); + expect(verdicts).toContain(false); + }); + + it.each(ALIASES)('`%s`: both refuse; objectui names it at its own path, pointing at `%s`', (alias, canonical) => { + const value = { startDateField: 'starts_at', [alias]: 'kickoff' }; + const upstream = SLOT.safeParse(value); + expect(codeAndPath(upstream)).toEqual([{ code: 'unrecognized_keys', path: [] }]); + expect(upstream.error!.issues[0].keys).toEqual([alias]); + const local = MIRROR.safeParse(value); + expect(codeAndPath(local)).toEqual([{ code: 'invalid_type', path: [alias] }]); + expect(local.error!.issues[0].message).toContain(`Did you mean \`${alias}\` → \`${canonical}\`?`); + }); + + it('an alias in a block that also lacks `startDateField`: the missing binding is reported as the slot reports it', () => { + const value = { dateField: 'kickoff' }; + const without = (r: Parsed, drop: (i: Issue) => boolean) => issues(r).filter((i) => !drop(i as Issue)); + expect(without(MIRROR.safeParse(value), (i) => i.path[0] === 'dateField')) + .toEqual(without(SLOT.safeParse(value), (i) => i.code === 'unrecognized_keys')); + }); +}); + +/* ── 4. Both directions, through the doors ──────────────────────────────────── */ + +describe('objectui#6152 round 9 — on the element, through every published door', () => { + describe.each(DOORS)('%s', (_door, parse) => { + it('CONTROL: the five declared members parse', () => { + const r = parse({ ...NODE, calendar: FIVE }); + expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true); + }); + + it.each([CONTROL_KEY, 'defaultView'])('`calendar.%s` is refused, with the slot\'s `unrecognized_keys`', (key) => { + const r = parse({ ...NODE, calendar: { startDateField: 'starts_at', [key]: 'week' } }); + expect(codeAndPath(r)).toEqual([{ code: 'unrecognized_keys', path: ['calendar'] }]); + expect(r.error!.issues[0].keys).toEqual([key]); + }); + + it('a block without `startDateField` is refused at `calendar.startDateField`', () => { + expect(codeAndPath(parse({ ...NODE, calendar: { titleField: 'subject' } }))) + .toEqual([{ code: 'invalid_type', path: ['calendar', 'startDateField'] }]); + }); + + it.each(ALIASES)('`calendar.%s` is refused BY NAME at its own path', (alias) => { + expect(codeAndPath(parse({ ...NODE, calendar: { startDateField: 'starts_at', [alias]: 'kickoff' } }))) + .toEqual([{ code: 'invalid_type', path: ['calendar', alias] }]); + }); + }); + + it('the installed row gives the same verdicts on the same blocks', () => { + const row = (calendar: unknown) => ROW.safeParse({ objectName: 'event', calendar }); + expect(row(FIVE).success).toBe(true); + for (const key of [CONTROL_KEY, 'defaultView', ...ALIASES.map(([alias]) => alias)]) { + expect(codeAndPath(row({ startDateField: 'starts_at', [key]: 'week' }))) + .toEqual([{ code: 'unrecognized_keys', path: ['calendar'] }]); + } + expect(codeAndPath(row({ titleField: 'subject' }))) + .toEqual([{ code: 'invalid_type', path: ['calendar', 'startDateField'] }]); + }); + + it('CONTROL: `defaultView` written FLAT, where the renderer reads it, still parses', () => { + const r = ObjectCalendarSchema.safeParse({ ...NODE, calendar: { startDateField: 'starts_at' }, defaultView: 'week' }); + expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true); + }); +}); + +/* ── 5. The TypeScript face ─────────────────────────────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +type SpecBlock = NonNullable; + +describe('objectui#6152 round 9 — the TypeScript face is closed the same way', () => { + it('the derived type has no index signature, a required `startDateField`, and the slot\'s members', () => { + type _closed = Expect>; + type _required = Expect>; + type _members = Expect, keyof SpecBlock>>; + type _member = Expect>; + const checks: [_closed, _required, _members, _member] = [true, true, true, true]; + expect(checks).toEqual([true, true, true, true]); + }); + + it('an unknown key, `defaultView`, an alias and a missing start binding are `tsc` errors', () => { + // Real directives: this package type-checks its tests (`tsconfig.test.json`), so a + // re-opened container fails on the unused directive. + // @ts-expect-error the container is closed: an unknown key is refused (objectui#6152 round 9) + const unknownKey: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', calendar: { startDateField: 'starts_at', zzqxNoSuchField: 'x' } }; + // @ts-expect-error `defaultView` is the FLAT member; the slot does not declare it in the block + const nested: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', calendar: { startDateField: 'starts_at', defaultView: 'week' } }; + // @ts-expect-error `dateField` is refused by name (objectui#8355) + const alias: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', calendar: { startDateField: 'starts_at', dateField: 'kickoff' } }; + // @ts-expect-error `startDateField` is required inside the block + const unbound: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', calendar: { titleField: 'subject' } }; + // Lit control on the same face. + const live: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', calendar: FIVE, defaultView: 'week' }; + expect([unknownKey, nested, alias, unbound]).toHaveLength(4); + expect(live.calendar?.startDateField).toBe('starts_at'); + }); +}); + +/* ── 6. The corpus census ───────────────────────────────────────────────────── */ + +/** Walk up to the workspace root, so the corpus is found by repo layout, never by `process.cwd()`. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i += 1) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + dir = resolve(dir, '..'); + } + throw new Error('repo root (pnpm-workspace.yaml) not found from this test file'); +} + +const ROOT = repoRoot(); +/** + * The AUTHORED corpus: the examples and the schema catalog, the docs site, the published + * skills, the docs tree, the apps (the console's fixtures among them), and every package + * README. Test files are NOT corpus: they pin refusals on purpose. + */ +const CORPUS_DIRS = ['examples', 'content', 'skills', 'docs', 'apps']; +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.next', '.source', '.turbo', '__tests__', '__mocks__', 'test', 'tests', 'e2e']); +const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/; +/** Release history, not authored corpus: a published CHANGELOG is never re-addressed (AGENTS.md #11). */ +const HISTORY_FILE = /^CHANGELOG\.md$/; +const CODE_FILE = /\.([cm]?[jt]sx?|json)$/; +const MARKDOWN_FILE = /\.mdx?$/; +const MARKER = 'object-calendar'; +/** An authored element outside a fence, which the fence extractor would not read. */ +const UNFENCED_ELEMENT = /["']?type["']?\s*[:=]\s*\{?\s*["'`]object-calendar["'`]/; + +function walk(dir: string, out: string[]): void { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) walk(join(dir, entry.name), out); + } else if (!TEST_FILE.test(entry.name) && !HISTORY_FILE.test(entry.name)) { + out.push(join(dir, entry.name)); + } + } +} + +function corpusFiles(): string[] { + const files: string[] = []; + for (const dir of CORPUS_DIRS) walk(join(ROOT, dir), files); + files.push(join(ROOT, 'README.md')); + for (const pkg of readdirSync(join(ROOT, 'packages'))) { + const readme = join(ROOT, 'packages', pkg, 'README.md'); + if (existsSync(readme)) files.push(readme); + } + return files.filter((file) => readFileSync(file, 'utf8').includes(MARKER)); +} + +type Container = { at: string; value: unknown }; +type Unjudged = { at: string; why: string }; + +const unwrapExpr = (node: ts.Expression): ts.Expression => { + let cur = node; + while (ts.isAsExpression(cur) || ts.isSatisfiesExpression(cur) || ts.isParenthesizedExpression(cur)) cur = cur.expression; + return cur; +}; +const propName = (p: ts.ObjectLiteralElementLike) => + ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : undefined; +const prop = (obj: ts.ObjectLiteralExpression, key: string) => + obj.properties.find((p): p is ts.PropertyAssignment => propName(p) === key); + +const NOT_LITERAL = Symbol('not literal'); + +/** The literal value of an expression, resolving a bare identifier to a `const` in the same source. */ +function literal(expr: ts.Expression, source: ts.SourceFile): unknown { + const node = unwrapExpr(expr); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isArrayLiteralExpression(node)) { + const items = node.elements.map((e) => literal(e, source)); + return items.includes(NOT_LITERAL) ? NOT_LITERAL : items; + } + if (ts.isObjectLiteralExpression(node)) { + const out: Record = {}; + for (const p of node.properties) { + const name = propName(p); + if (name === undefined) return NOT_LITERAL; + const v = literal((p as ts.PropertyAssignment).initializer, source); + if (v === NOT_LITERAL) return NOT_LITERAL; + out[name] = v; + } + return out; + } + if (ts.isIdentifier(node)) { + let found: ts.Expression | undefined; + const visit = (n: ts.Node) => { + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === node.text && n.initializer) found = n.initializer; + ts.forEachChild(n, visit); + }; + visit(source); + return found ? literal(found, source) : NOT_LITERAL; + } + return NOT_LITERAL; +} + +/** Every `object-calendar` element's own container in one source text. */ +function containersIn(label: string, text: string, json: boolean, lineOffset: number, found: Container[], unjudged: Unjudged[]): void { + const source = ts.createSourceFile(label, json ? `(${text})` : text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const visit = (node: ts.Node) => { + if (ts.isObjectLiteralExpression(node)) { + const type = prop(node, 'type'); + const typeValue = type && unwrapExpr(type.initializer); + if (typeValue && (ts.isStringLiteral(typeValue) || ts.isNoSubstitutionTemplateLiteral(typeValue)) && typeValue.text === MARKER) { + const bag = prop(node, 'properties'); + const bagValue = bag && unwrapExpr(bag.initializer); + const slots = [prop(node, 'calendar'), bagValue && ts.isObjectLiteralExpression(bagValue) ? prop(bagValue, 'calendar') : undefined]; + const line = source.getLineAndCharacterOfPosition(node.getStart()).line + 1 + lineOffset; + const at = `${label}:${line}`; + if (!slots.some(Boolean) && node.properties.some(ts.isSpreadAssignment)) { + unjudged.push({ at, why: 'an element spreads a value that could carry `calendar`' }); + } + for (const slot of slots) { + if (!slot) continue; + const value = literal(slot.initializer, source); + if (value === NOT_LITERAL) unjudged.push({ at, why: `container is not a literal: ${slot.initializer.getText().slice(0, 60)}` }); + else found.push({ at, value }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); +} + +const FENCE = /^([ \t]*)(```|~~~)([^\n]*)\n([\s\S]*?)^\1\2[ \t]*$/gm; + +function census(files: ReadonlyArray) { + const found: Container[] = []; + const unjudged: Unjudged[] = []; + for (const [label, text] of files) { + if (CODE_FILE.test(label)) { + containersIn(label, text, label.endsWith('.json'), 0, found, unjudged); + } else if (MARKDOWN_FILE.test(label)) { + let outside = text; + for (const m of text.matchAll(FENCE)) { + outside = outside.replace(m[0], ''); + if (!m[4].includes(MARKER)) continue; + const lang = m[3].trim().split(/\s/)[0]; + const line = text.slice(0, m.index).split('\n').length; + containersIn(label, m[4], /^json/.test(lang), line, found, unjudged); + } + if (UNFENCED_ELEMENT.test(outside)) unjudged.push({ at: label, why: 'an element authored outside a fence' }); + } else { + unjudged.push({ at: label, why: 'a file type this census does not parse' }); + } + } + return { found, unjudged }; +} + +describe('objectui#6152 round 9 — the corpus census: every authored container parses on the narrowed door', () => { + const files = corpusFiles().map((file) => [relative(ROOT, file), readFileSync(file, 'utf8')] as const); + const { found, unjudged } = census(files); + + it('every authored element\'s container was judged — none is out of the census\'s reach', () => { + expect(unjudged).toEqual([]); + }); + + it('the census sees authored containers, so a zero below is a reading', () => { + expect(found.length).toBeGreaterThan(0); + }); + + it('each one parses on this package\'s container AND on the installed slot', () => { + const refused = found + .map(({ at, value }) => ({ at, local: codeAndPath(MIRROR.safeParse(value)), upstream: codeAndPath(SLOT.safeParse(value)) })) + .filter((row) => row.local.length > 0 || row.upstream.length > 0); + expect(refused).toEqual([]); + }); + + it('CONTROL: the extractor finds and judges a container in each carrier it reads, and fails loudly where it cannot', () => { + const doc = [ + '```tsx', + "const a = { type: 'object-calendar', objectName: 'e', calendar: { startDateField: 's', defaultView: 'week' } };", + "const cfg = { titleField: 't' };", + "const b = { type: 'object-calendar', objectName: 'e', calendar: cfg };", + "const c = { type: 'object-calendar', objectName: 'e', calendar: makeConfig() };", + '```', + '```json', + '{ "type": "object-calendar", "properties": { "calendar": { "startDateField": "s" } } }', + '```', + ].join('\n'); + const { found: probe, unjudged: missed } = census([ + ['fixture.mdx', doc], + ['fixture.json', '{ "type": "object-calendar", "calendar": { "startDateField": "s", "zzqxNoSuchField": 1 } }'], + ['fixture.yaml', 'type: object-calendar'], + ]); + expect(probe.map(({ value }) => MIRROR.safeParse(value).success)).toEqual([false, false, true, false]); + expect(missed.map(({ why }) => why)).toEqual([ + 'container is not a literal: makeConfig()', + 'a file type this census does not parse', + ]); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 006e5a6614..e3867aa4de 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4166,31 +4166,32 @@ export interface ObjectCalendarSchema extends BaseSchema { * runtime handoff, declared because the renderer reads them, not a second * spelling to write. * - * `@objectstack/spec` declares the KEY — - * `ComponentPropsMap['object-calendar'].calendar` — and this package's - * registration `inputs` publishes it, so authors are offered it. ⚠️ The spec - * does NOT declare its SHAPE: measured on 17.4.0, and again on 17.5.0, that - * slot is `z.unknown().optional()`, not `CalendarConfigSchema`, so the protocol - * accepts any value there at all. The member list below is objectui's own — - * see the mirror for the grounds. Both published faces of THIS package stayed - * silent about the key until objectui#8651, - * which is the objectui#6914 class: the value rode {@link BaseSchema}'s - * `[key: string]: any` here and `.passthrough()` on the mirror, admitted and - * never examined. `calendar: 42` type-checked, parsed green, and drew an - * empty calendar. + * `@objectstack/spec` declares the KEY and, since 17.7.0, its SHAPE: + * `ComponentPropsMap['object-calendar'].calendar` is a strict copy of the list + * view's calendar block (objectstack#21464 stage 2), five members with + * `startDateField` required. This package's registration `inputs` publishes + * the key, so authors are offered it. Through 17.6.0 the slot was + * `z.unknown().optional()`, and both published faces of THIS package stayed + * silent about the key until objectui#8651, which is the objectui#6914 class: + * the value rode {@link BaseSchema}'s `[key: string]: any` here and + * `.passthrough()` on the mirror, admitted and never examined. `calendar: 42` + * type-checked, parsed green, and drew an empty calendar. * * DERIVED from the mirror rather than re-spelled, so the two faces cannot * fork — the same construction {@link ListViewSchema} uses through - * `ListViewInferred`. What the mirror declares is the five members - * `ObjectCalendar`'s events pass destructures out of the resolved config. - * Through `@objectstack/spec` 17.4.0 that was the spec's four plus objectui's - * own `allDayField`; since 17.5.0 `CalendarConfigSchema` declares all five. - * - * ⛔ `defaultView` is deliberately NOT a member of this container even though - * a list VIEW's calendar block carries one: this renderer seeds its view state - * from {@link ObjectCalendarSchema.defaultView}, the FLAT member below, and - * never looks inside here. The container stays `.passthrough()`, so a block - * carrying it still parses — it is simply not advertised. + * `ListViewInferred`. Since objectui#6152 round 9 the mirror is the row's own + * slot BY REFERENCE, plus the two by-name alias refusals (objectui#8355), so + * this type is closed: `startDateField` is required, the four others are + * optional, and a fresh literal carrying any other key is a compile error, as + * the spec refuses it at parse. Until that round the mirror was `.partial()` + * and `.passthrough()`, so this type carried an index signature and an + * optional `startDateField`, and admitted both shapes the slot refuses. + * + * ⛔ `defaultView` is NOT a member of this container even though a list + * VIEW's calendar block carries one: this renderer seeds its view state from + * {@link ObjectCalendarSchema.defaultView}, the FLAT member below, and never + * looks inside here, and the slot does not declare it either. Written inside + * the block, it is refused; write it flat. */ calendar?: ObjectCalendarBlockConfig; /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index f5c8b9ec45..4e8fcaab5c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1884,71 +1884,47 @@ const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial() * from {@link CalendarConfig} above, which is a list VIEW's calendar block, and * the reason the two are not one const (objectui#8651). * - * Both derive from the same spec object. They differ on exactly one member, and - * the difference is a read site, not a preference: - * - * - a VIEW's block carries `defaultView`, and `ListView` LIFTS it onto the - * node it builds (`plugin-list/src/ListView.tsx`, the `calendar` branch), - * so the key is honoured from there. - * - this ELEMENT's block does not, because `ObjectCalendar` seeds its view - * state from the FLAT `defaultView` member of this schema and never looks - * inside the container. Declaring it here would advertise a write this - * renderer drops — `plugin-calendar/src/index.tsx` names that as this - * gate's own failure mode one layer in. - * - * ⚠️ THE MEMBER LIST IS objectui's OWN. Through 17.6.0 the spec did NOT - * supply one: `ComponentPropsMap['object-calendar'].calendar` was - * `z.unknown().optional()` (measured on 17.4.0, 17.5.0 and 17.6.0), so at THIS - * position the protocol accepted everything — a nonsense key, a wrong-typed - * member, even `calendar: 42`. `@objectstack/spec` 17.7.0 types it - * (objectstack#21464 stage 2, `7d674dfc`). MEASURED on the installed 17.7.0: - * the slot is the list view's own `calendar` member (wrapper chain - * `["optional","object"]`, a strict copy of `CalendarConfigSchema`, not the - * same object reference), so a nonsense key, the alias `dateField`, - * `calendar: 42` and `calendar: { startDateField: 42 }` are all refused there - * now. `CalendarConfigSchema` is the strict object the spec uses for a LIST - * VIEW's calendar block — four keys through 17.4.0, five since 17.5.0 declared - * `allDayField` (objectui#11073). - * - * The shape below is still objectui's, chosen as exactly the five members - * `ObjectCalendar.tsx`'s events pass destructures out of the resolved config. - * Through 17.4.0 that was the spec's four plus objectui's own `allDayField`, the - * same objectui-local lane objectui#8466 took for the FLAT spelling of this - * vocabulary, on this same interface, for the same renderer; since 17.5.0 it is - * the list view block's five exactly. - * - * Through 17.6.0 that made this mirror STRICTER than the protocol at this - * position, the sanctioned direction (objectui#8327's triage ruling forbids - * accepting what the platform REFUSES). ⚠️ Since 17.7.0 it is not, for KEYS: - * the `.passthrough()` below admits an unexamined key the spec now refuses. - * The objectui#11717 bump records that difference by row and leaves the - * declaration as it is; it narrows nothing on its own. - * - * ⛔ `.passthrough()` is kept, so this declaration refuses no KEY that parses - * today: a `calendar` block carrying `defaultView`, or any other unexamined - * key, still parses exactly as it did through `BaseSchema`'s own - * `.passthrough()`. It does refuse VALUES, which is the whole of what declaring - * buys — `calendar: 42` and `calendar: { startDateField: 42 }` are refused - * where both were admitted unexamined before. - * - * ⚠️ The key/value split is stated that way on purpose. An earlier cut wrote - * "REFUSES NOTHING that parses today", which is literally false for - * `calendar: 42`: it parsed at the merge-base and is refused here. The colon - * scoped it to keys and the next sentence gave the value narrowing, so it was - * defensible — but a sentence that needs its own punctuation to stay true is - * one reader away from being wrong, and the narrowing is the point of the - * declaration rather than a footnote to it. + * ⭐ BY REFERENCE since objectui#6152 round 9: this IS the `object-calendar` + * row's own `calendar` member (`ComponentPropsMap['object-calendar'].calendar`, + * unwrapped from its `optional`), extended by nothing but objectui's two named + * alias refusals below. Through `@objectstack/spec` 17.6.0 the spec supplied no + * shape here — the slot was `z.unknown().optional()` — so the member list was + * objectui's own. 17.7.0 types the slot (objectstack#21464 stage 2, + * `7d674dfc`) as a strict copy of the list view's calendar block: the same five + * member objects `CalendarConfigSchema` holds, with `startDateField` REQUIRED and + * every other key refused by the spec's own `unrecognized_keys` diagnostic. + * + * Taken from the ROW, not from `CalendarConfigSchema`, although today the two + * give the same verdict on every input: the row is the contract this element is + * judged by, so if the spec ever gives the element a block of its own this + * declaration follows without an edit. `.extend()` keeps the slot's strictness + * and its unknown-key message. The verdict-equality pin over a set of inputs, + * and the measurement of the slot itself, are in + * `../__tests__/object-calendar-container-by-reference-6152.test.ts`. + * + * What following the slot narrowed. Until this round the container was + * `CalendarConfigSchema` `.partial()` + `.passthrough()`, so it admitted two + * things the slot refuses, the direction objectui#8327's triage ruling forbids: + * + * - an unexamined KEY (`calendar.defaultView`, a misspelling such as + * `calendar.startDate`), now refused with the spec's `unrecognized_keys`; + * - a block WITHOUT `startDateField` (`calendar: { titleField }`), now refused + * at `calendar.startDateField`. + * + * Neither cost a reader. `getCalendarConfig` (`plugin-calendar/src/ObjectCalendar.tsx`) + * returns this block WHOLE and the events pass reads the five members only, so + * an extra key was dropped unread, and a block without a start binding mounted + * a calendar that placed nothing. The same test file's corpus census re-derives, + * on every run, that each authored container still parses. + * + * `defaultView` is the one member where the two contracts differ, and the + * difference is a read site: a VIEW's block carries it and `ListView` LIFTS it + * onto the node it builds, while this ELEMENT seeds its view state from the + * FLAT `defaultView` member of this schema and never looks inside the + * container. The slot does not declare it either, so it is refused here like + * any other key. */ -const ObjectCalendarBlockConfigSchema = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ - // Through `@objectstack/spec` 17.4.0 this member was objectui-local (see - // objectui#8466 for that measurement). 17.5.0 declares `allDayField` on - // `CalendarConfigSchema` itself as `z.string().optional()`, so this extension - // now restates the spec's member with the SAME accept set: removing it would - // change nothing a parse decides (objectui#8831 reports that and leaves the - // removal to its own change). The renderer honours the key in BOTH - // positions: this container, which is where it is authored, and the flat - // member of the node, which is the runtime handoff. - allDayField: z.string().optional().describe('Field carrying the all-day flag. Declared by the spec\'s CalendarConfigSchema since 17.5.0, with the same accept set as this member. LOAD-BEARING since objectui#8026'), +const ObjectCalendarBlockConfigSchema = stripImportedDefaults(SpecObjectCalendarPropsSchema).shape.calendar.unwrap().extend({ // ⭐ objectui#8355 — the same two spellings the view-level block above refuses, // and deliberately NOT the same string: `getCalendarConfig` reads this // container FIRST and returns it WHOLE, so a retired spelling here never @@ -1956,9 +1932,13 @@ const ObjectCalendarBlockConfigSchema = stripImportedDefaults(SpecCalendarConfig // calendar that places nothing. Measured per surface; see the consequence // table. Leaving this nesting silent is the half-measure the objectui#8365 // precedent names and refuses. + // + // The strict slot refuses both spellings already, as unrecognized keys. These + // arms keep the by-name message, which names the canonical key and the + // consequence at the key's own path; that message is why they stay. dateField: CalendarContainerDateAliasRefusals.dateField, endField: CalendarContainerDateAliasRefusals.endField, -}).passthrough(); +}); /** * The inferred twin of {@link ObjectCalendarBlockConfigSchema}, exported so the @@ -3006,7 +2986,12 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`, // so this description names the container as the place to write them, and // the five flat members below describe themselves as the runtime handoff. - calendar: ObjectCalendarBlockConfigSchema.optional().describe('Calendar configuration container, and the AUTHORED spelling of the five field-name keys: startDateField, endDateField, titleField, colorField, allDayField. Read FIRST by getCalendarConfig, ahead of the flat members, which are the runtime handoff and not a second authorable spelling'), + // + // objectui#6152 round 9 — the container's SHAPE is the row's own slot now, + // by reference: strict, `startDateField` required (see + // `ObjectCalendarBlockConfigSchema` above). The `calendar` key itself stays + // optional on both faces, as before. + calendar: ObjectCalendarBlockConfigSchema.optional().describe('Calendar configuration container, and the AUTHORED spelling of the five field-name keys: startDateField (required), endDateField, titleField, colorField, allDayField; any other key is refused, as ComponentPropsMap[\'object-calendar\'].calendar refuses it. Read FIRST by getCalendarConfig, ahead of the flat members, which are the runtime handoff and not a second authorable spelling'), startDateField: z.string().optional().describe(objectCalendarFlatField('Start date field', 'startDateField')), endDateField: z.string().optional().describe(objectCalendarFlatField('End date field', 'endDateField')), // ⭐ objectui#8355 — the FLAT spelling the retired ladder actually read, and diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 20df9c8d7d..99fed64fb9 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -567,6 +567,19 @@ export const ADJUDICATED = new Map([ ], }, ], + // objectui#6152 round 9 -- the corpus census of `object-calendar` containers. + // It walks the authored trees, reads every markdown document in them that + // names `object-calendar`, and parses each fenced element's `calendar` + // container -- so an edit to any of those documents IS an edit to this + // test's input. It reads the root `README.md` and every package's + // `README.md`, the second through the `packages` tree. + [ + 'packages/types/src/__tests__/object-calendar-container-by-reference-6152.test.ts', + { + reads: ['README.md', 'apps/**', 'content/**', 'docs/**', 'examples/**', 'packages/**', 'skills/**'], + walker: 'markdown-tree: walks `examples`, `content`, `skills`, `docs` and `apps`, and every package `README.md`, and parses the fences of each `.md`/`.mdx` that names `object-calendar`', + }, + ], [ 'packages/types/src/__tests__/object-calendar-record-source-7313.test.ts', { reads: ['content/docs/plugins/plugin-calendar.mdx'] },