diff --git a/.changeset/8885-object-chart-drilldown-title-compareto.md b/.changeset/8885-object-chart-drilldown-title-compareto.md new file mode 100644 index 0000000000..6490e0e98b --- /dev/null +++ b/.changeset/8885-object-chart-drilldown-title-compareto.md @@ -0,0 +1,19 @@ +--- +"@object-ui/types": minor +--- + +`ObjectChartSchema` declares `drillDown`, `title` and `compareTo` — on BOTH published copies of the shape. + +`ObjectChart.tsx` reads all three off `schema`, and until now neither published copy declared any of them: not the TS interface (`packages/types/src/objectql.ts`) and not the zod mirror (`packages/types/src/zod/objectql.zod.ts`). They rode `BaseSchema`'s index signature / `.passthrough()` and arrived unvalidated. `drillDown` was the sharpest case — this component's registry `inputs` advertise it to the designer palette, and `@objectstack/spec` publishes `ChartDrillDownSchema` for exactly this carrier, so an author was offered a key that neither published shape mentioned. + +Each key binds to the `@objectstack/spec` symbol that already owns it rather than to a local near-copy: + +- `drillDown` is the spec's `ChartDrillDown` / `ChartDrillDownSchema`, whose own documentation names `` as its carrier. Deliberately NOT this repo's wider `DrillDownConfig`: that type also carries `mode` and `report` for the table / pivot / metric widgets, and this component reads neither — so a chart drill now refuses those two by name instead of accepting and dropping them. +- `title` is the spec's `I18nLabel` — a plain string or an inline locale map, the union `normalizeChartSchema`'s `label()` already resolves and the union `ChartConfigSchema.title` carries. +- `compareTo` is bound by reference to `DashboardWidgetSchema.shape.compareTo`, which is literally where the value comes from: `DashboardRenderer` forwards the dashboard widget's own key verbatim onto the node. + +What this buys is the VALUE check. `title: 42`, `drillDown: { target: 'popover' }` and `compareTo: { kind: 'lastWeek' }` are now compile errors and parse errors; before, all three rode through silently. It does not buy rejection of a misspelling — `BaseSchema` still carries `[key: string]: any` and is still `.passthrough()` — and the pin for this change states that bound honestly rather than implying more. + +Four keys the same file reads (`xAxisKey`, `series`, `aggregate`, `filter`) belong to objectui#7946 and are ledgered by name, each with an assertion that it is still read, rather than swept in here. + +Part of objectui#8885. diff --git a/docs/audits/2026-07-objectview-detailview-schema.md b/docs/audits/2026-07-objectview-detailview-schema.md index 807f6ea927..63fcc94c12 100644 --- a/docs/audits/2026-07-objectview-detailview-schema.md +++ b/docs/audits/2026-07-objectview-detailview-schema.md @@ -126,7 +126,7 @@ wrong thing.** The prerequisite is to make the declaration match the reads. | objectui | Spec | Note | | --- | --- | --- | | `objectName` | `data: { provider: 'object', object }` (`ViewDataSchema`, `:29`) | Same divergence as scope A step 6 — and the same upstream blocker (`react-blocks.ts` sanctions `objectName` as a React-tier prop). Move together, or not at all. | -| `defaultViewType` | `ListViewSchema.type` (`:643`) | Spec is a superset (adds `chart`, `tree`). objectui's own `ListViewSchema` **already imports this enum by reference** (`objectql.zod.ts:365`) — this schema should use the same import instead of restating a narrower copy. | +| `defaultViewType` | `ListViewSchema.type` (`:646`) | Spec is a superset (adds `chart`, `tree`). objectui's own `ListViewSchema` **already imports this enum by reference** (`objectql.zod.ts:368`) — this schema should use the same import instead of restating a narrower copy. | | `showSearch` / `showFilters` / `showSort` | `UserActionsConfigSchema.{search,filter,sort}` (`:350-352`) | Scope A step 3, same fold. | | `showCreate` | `AddRecordConfigSchema.enabled` (`:448`) | Spec's config also carries `position` / `mode` / `formView`; the boolean is a lossy shorthand for it. | | `title` | `label` (`:642`) | Type differs: objectui `z.string()`, spec `I18nLabelSchema`. Promoting means accepting the i18n envelope. | diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index f4033350be..9f26e53ca2 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -74,6 +74,7 @@ import { NavigationConfigSchema as SpecNavigationConfigSchema, I18nLabelSchema as SpecI18nLabelSchema, ChartAggregateSchema as SpecChartAggregateSchema, + ChartDrillDownSchema as SpecChartDrillDownSchema, } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -175,6 +176,8 @@ const IMPORTED: Array = [ ['NavigationConfigSchema', SpecNavigationConfigSchema], ['I18nLabelSchema', SpecI18nLabelSchema], ['ChartAggregateSchema', SpecChartAggregateSchema], + // objectui#8885: `ObjectChartSchema.drillDown` crosses this boundary. + ['ChartDrillDownSchema', SpecChartDrillDownSchema], ['SelectOptionSchema', SpecSelectOptionSchema], ] as const; diff --git a/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts b/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts new file mode 100644 index 0000000000..37aa165b4f --- /dev/null +++ b/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts @@ -0,0 +1,302 @@ +/** + * 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#8885 — `drillDown`, `title` and `compareTo` are declared on BOTH + * published copies of `ObjectChartSchema` (the TS interface in `../objectql.ts` + * and the zod mirror in `../zod/objectql.zod.ts`), each bound to the + * `@objectstack/spec` symbol that already owns it. + * + * ## The class + * + * objectui#6914: a key read behind a cast and declared on neither published + * face. `ObjectChart.tsx` read all three off `schema` while both faces stayed + * silent, so they rode `BaseSchema`'s `[key: string]: any` / `.passthrough()` + * and arrived UNVALIDATED. `drillDown` was the sharpest instance — this + * component's registry `inputs` advertise it to the designer palette AND + * `@objectstack/spec` publishes `ChartDrillDownSchema` for exactly this + * carrier, so an author was offered a key that neither published shape + * mentioned. + * + * ## Why each binds to the spec rather than to a local type + * + * `../objectql.ts` states the rule it inherits: "Never Redefine Types. ALWAYS + * import them." A hand-written near-copy is what `check:spec-symbols` exists to + * stop, and the measured cost is not hypothetical — a local rule that looks + * equivalent to a spec symbol can disagree with it in BOTH directions at once. + * So: + * + * - `drillDown` → the spec's `ChartDrillDownSchema` / `ChartDrillDown`, whose + * own doc names `` as its carrier. ⛔ NOT this + * repo's wider `DrillDownConfig`: that one also carries `mode` and `report` + * for the table / pivot / metric widgets, and `ObjectChart.tsx` reads + * NEITHER — declaring them would advertise two keys accepted and then + * dropped. + * - `title` → `I18nLabel`, the union `ChartConfigSchema.title` carries and + * that `plugin-charts`' `normalizeChartSchema` already resolves through its + * `label()` helper (plain string OR inline locale map). The spec's + * `REACT_BLOCKS` entry for `ObjectChart` lists `title` among its + * `dataProps`, so this is a key the platform's authoring surface offers. + * - `compareTo` → `DashboardWidgetSchema.shape.compareTo` BY REFERENCE: + * `DashboardRenderer` composes the node with `compareTo: widget.compareTo`, + * forwarding the widget key verbatim, so producer and consumer are bound to + * one declaration instead of two dialects. + * + * ## The ceiling, stated rather than assumed (objectui#5155) + * + * `BaseSchema` is `.passthrough()` and its TS twin carries `[key: string]: any`, + * so declaring a key buys it its declared TYPE — `title: 42` is refused now — + * but does NOT buy rejection of a MISSPELLING: `drillDwn: {}` still parses and + * still compiles, exactly as `visibleWhn` does on `ObjectGallerySchema` + * (objectui#6576). The counter-probe below pins that honestly so nobody reads + * the declaration as more than it is. + * + * ## Four keys stay ledgered, and the ledger is not a waiver + * + * `xAxisKey`, `series`, `aggregate` and `filter` are read by the same file and + * are objectui#7946's remit (PR #8884), not this card's. They are listed BY + * NAME in {@link LEDGERED_OTHER_CARD_READS}, and every entry carries an + * assertion that it is STILL READ — a stale exception is a hole. The ledger + * deliberately does NOT assert that they stay undeclared, so this pin holds + * whether or not that card has landed; what keeps it from rotting into a wider + * equation is the independent pair of assertions below (every read key is + * declared-or-ledgered, AND every key this card declared is still read). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import type { ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget } from '@objectstack/spec/ui'; +import type { ObjectChartSchema } from '../objectql.js'; +import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const WIDGET_FILE = 'packages/plugin-charts/src/ObjectChart.tsx'; + +/** The three keys objectui#8885 ruled on. */ +const DECLARED_BY_THIS_CARD = ['drillDown', 'title', 'compareTo'] as const; + +/** + * Keys read off `schema` in `ObjectChart.tsx` that this card deliberately does + * NOT rule on — objectui#7946's four (PR #8884). Every entry must still be + * READ; see the file header for why the ledger asserts that and nothing else. + */ +const LEDGERED_OTHER_CARD_READS = ['xAxisKey', 'series', 'aggregate', 'filter'] as const; + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * `Equal`, not `extends`: through `BaseSchema`'s index signature an UNDECLARED + * member reads `any`, and a one-way check accepts `any` on both sides — which + * is precisely the before-state this card removed (the objectui#7087 + * disabled-twin lesson). + */ +export type assertionDrillDownIsSpecType = Expect>; +export type assertionTitleIsSpecI18nLabel = Expect>; +export type assertionCompareToIsWidgetKey = Expect>; +/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ +export type assertionEqualCanFail = Expect, false>>; + +describe('ObjectChartSchema — compile-time pins for the three keys (objectui#8885)', () => { + it('accepts the spec vocabulary on all three, contextually typed with no cast', () => { + const schema: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + objectName: 'opportunity', + drillDown: { enabled: true, target: 'navigate', columns: ['name', 'amount'], maxRows: 50 }, + title: 'Revenue by stage', + compareTo: { kind: 'previousYear' }, + }; + expect(schema.drillDown?.target).toBe('navigate'); + expect(schema.compareTo?.kind).toBe('previousYear'); + }); + + it('accepts the inline-locale-map arm of `title` — the arm `label()` resolves', () => { + const schema: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + title: { en: 'Revenue by stage', 'zh-CN': '按阶段的收入' }, + }; + expect(schema.title).toMatchObject({ en: 'Revenue by stage' }); + }); + + it('refuses a wrong-typed value on each of the three — the check `.passthrough()` was skipping', () => { + // Each directive fails the build (TS2578) the moment the member stops + // being declared, so these are the pins that go red if a key is lost. + + // @ts-expect-error — `title` is `I18nLabel`, not a number. + const badTitle: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', title: 42 }; + // @ts-expect-error — `'popover'` is not an arm of `ChartDrillDown['target']`. + const badTarget: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', drillDown: { target: 'popover' } }; + // @ts-expect-error — `kind` is the converged two-arm enum, and it is REQUIRED. + const badKind: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', compareTo: { kind: 'lastWeek' } }; + + expect([badTitle.title, badTarget.drillDown, badKind.compareTo]).toHaveLength(3); + }); + + it('refuses the table/pivot keys on a chart drill — `mode` and `report` are NOT read here', () => { + // The measured reason `ChartDrillDown` is the binding rather than this + // repo's wider `DrillDownConfig`: those two keys reach no read site in + // `ObjectChart.tsx`, so declaring them would be authoring bait. + + // @ts-expect-error — `mode` belongs to the table / list widgets' drill. + const withMode: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', drillDown: { enabled: true, mode: 'record' } }; + expect(withMode.drillDown).toBeTruthy(); + }); + + it('the ceiling: a MISSPELLING still compiles, because `BaseSchema` carries an index signature', () => { + // Not a defect being papered over — the honest bound of what declaring a + // key buys. Revisit deliberately when objectui#5155 lands. + const typo: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', drillDwn: { enabled: true } }; + expect(typo.drillDwn).toEqual({ enabled: true }); + }); +}); + +/* ── Mirror parity, per key ────────────────────────────────────────────────── */ + +describe('the zod mirror declares the same three keys (objectui#8885)', () => { + it.each(DECLARED_BY_THIS_CARD)('the mirror declares `%s`', (key) => { + expect(Object.keys(ObjectChartMirror.shape)).toContain(key); + }); + + it('the mirror CHECKS the declared values, not just their presence', () => { + // Non-vacuity for the three `.toContain` assertions above: a key declared + // as `z.any()` would satisfy them and validate nothing. + const base = { type: 'object-chart', chartType: 'bar' } as const; + + expect(ObjectChartMirror.safeParse({ + ...base, + drillDown: { enabled: true, target: 'navigate', columns: ['name'], maxRows: 50 }, + title: 'Revenue by stage', + compareTo: { kind: 'previousPeriod', dimension: 'close_date' }, + }).success).toBe(true); + + // The inline-locale-map arm of `title`, which `label()` resolves. + expect(ObjectChartMirror.safeParse({ ...base, title: { en: 'Revenue', 'zh-CN': '收入' } }).success).toBe(true); + + // One refusal per key, each on the VALUE rather than on the key name. + expect(ObjectChartMirror.safeParse({ ...base, title: 42 }).success).toBe(false); + expect(ObjectChartMirror.safeParse({ ...base, drillDown: { target: 'popover' } }).success).toBe(false); + expect(ObjectChartMirror.safeParse({ ...base, drillDown: { maxRows: 'lots' } }).success).toBe(false); + expect(ObjectChartMirror.safeParse({ ...base, compareTo: { kind: 'lastWeek' } }).success).toBe(false); + expect(ObjectChartMirror.safeParse({ ...base, compareTo: { dimension: 'close_date' } }).success).toBe(false); + }); + + it('the drill mirror is the spec\'s CHART subset — `mode` / `report` are refused BY NAME', () => { + // `ChartDrillDownSchema` is `$strict`, which is what makes this a refusal + // rather than a silent strip. The wider `DrillDownConfigSchema` + // (`data-display.zod.ts`) is a different widget's contract and would accept + // both keys — so this is the assertion that fails if the binding is ever + // re-pointed at it. + for (const drillDown of [{ enabled: true, mode: 'record' }, { enabled: true, report: { name: 'pipeline' } }]) { + const parsed = ObjectChartMirror.safeParse({ type: 'object-chart', chartType: 'bar', drillDown }); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain('unrecognized_keys'); + } + }); + + it('the ceiling on the mirror too: an undeclared MISSPELLING still parses', () => { + // `BaseSchema` is `.passthrough()`, so this is the honest bound. Pinned + // here rather than argued, and the counterpart to the tsc probe above. + expect(ObjectChartMirror.safeParse({ type: 'object-chart', chartType: 'bar', drillDwn: { enabled: true } }).success).toBe(true); + }); +}); + +/* ── Read census on the widget file ────────────────────────────────────────── */ + +/** + * Comments are STRIPPED before the census, and that is load-bearing rather than + * tidiness: `ObjectChart.tsx` discusses `schema.chart` in prose — explaining + * that the upstream list-view resolver could NOT be called here, because that + * key reads `undefined` on every schema this component receives. Measured on + * the branch point: without stripping, the census reports 17 reads including a + * phantom `chart`; with stripping, 16 and no phantom. A census that read + * comments could only be cleared by declaring a dead key or ledgering a + * phantom. + */ +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +/** + * Every key read off `schema`, cast-aware: `schema.x`, `schema?.x`, + * `(schema as T).x`, `schema['x']`. + * + * ⚠️ The cast arm matches `as T` for ANY `T`, not just `as any`. The read this + * card exists for is spelled `(schema as { drillDown?: DrillDownConfig }) + * .drillDown`, which an `as any`-only pattern misses entirely — and a census + * that misses a read reports the hole as clean. + */ +function schemaReads(src: string): Set { + const re = /\bschema(?:\?)?\.([A-Za-z_$][\w$]*)|\(\s*schema as [^)]*\)\.([A-Za-z_$][\w$]*)|\bschema\[['"]([A-Za-z_$][\w$]*)['"]\]/g; + const out = new Set(); + for (const m of src.matchAll(re)) out.add(m[1] ?? m[2] ?? m[3]); + return out; +} + +describe('ObjectChart.tsx — every key read off `schema` is declared or ledgered (objectui#8885)', () => { + const source = readFileSync(join(REPO_ROOT, WIDGET_FILE), 'utf8'); + const reads = schemaReads(stripComments(source)); + + it('the three keys this card declared are STILL READ — a declaration nothing reads is dead', () => { + // Non-vacuity: a widget that read nothing off `schema` would satisfy every + // "declared or ledgered" check below vacuously. + expect(reads.size).toBeGreaterThan(10); + expect(reads.has('objectName')).toBe(true); + for (const key of DECLARED_BY_THIS_CARD) { + expect(reads.has(key), `${key} is declared by objectui#8885 but no longer read`).toBe(true); + } + }); + + it('every key read off `schema` is declared by the mirror, or ledgered by name', () => { + const declared = new Set([...Object.keys(ObjectChartMirror.shape), ...LEDGERED_OTHER_CARD_READS]); + const readNotDeclared = [...reads].filter((k) => !declared.has(k)).sort(); + expect(readNotDeclared, `${WIDGET_FILE} reads keys its schema type does not declare (objectui#6914 class)`).toEqual([]); + }); + + it('each ledgered key is still READ — a stale exception is a hole', () => { + // The independent half of the pair: this is what keeps the equation above + // from being widened into vacuity by adding names to the ledger. + for (const key of LEDGERED_OTHER_CARD_READS) { + expect(reads.has(key), `${key} is ledgered as objectui#7946's remit but no longer read`).toBe(true); + } + }); + + it('the control keys are the OTHER card\'s, and this card moved none of them', () => { + // "A control that breaks when the subject breaks is not a control": these + // four are read by the same file through the same helper, so they exercise + // the census identically — while their DISPOSITION stays objectui#7946's. + // Whether they are declared is that card's answer; that they are read is + // this card's assertion. + expect([...LEDGERED_OTHER_CARD_READS].every((k) => reads.has(k))).toBe(true); + expect(DECLARED_BY_THIS_CARD.some((k) => (LEDGERED_OTHER_CARD_READS as readonly string[]).includes(k))).toBe(false); + }); + + it('the census can see a drifted key, and does not see one that only a COMMENT mentions (non-vacuity controls)', () => { + // A census that returned an empty set for any input would pass every + // assertion above while measuring nothing. + const probe = schemaReads(stripComments( + "const a = schema.objectName; const b = (schema as { drillDown?: unknown }).drillDown; const c = schema?.title; const d = schema['drillDwn'];", + )); + expect([...probe].sort()).toEqual(['drillDown', 'drillDwn', 'objectName', 'title']); + expect([...probe].filter((k) => !new Set(Object.keys(ObjectChartMirror.shape)).has(k))).toEqual(['drillDwn']); + + // The comment half. Both a block and a line comment, and a `//` inside a + // URL, which must NOT eat the code after it. + const commented = schemaReads(stripComments( + "/** asked for `schema.chart` it would read undefined */\n// see schema.phantom\nconst u = 'https://example.test/x'; const a = schema.objectName;", + )); + expect([...commented].sort()).toEqual(['objectName']); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 61efcf3b2d..d49e3ff08d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3096,11 +3096,22 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'complex.zod.ts#KanbanSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', - // objectui#7946 (rework round): `aggregate` is `SpecChartAggregateSchema` by - // reference rather than the local near-copy the first cut declared, so a spec - // bump that widens or narrows the object-bound aggregation vocabulary moves - // ONE side of this pair — which is exactly what this list exists to make - // legible rather than mysterious. + // ⭐ ONE entry, FOUR spec crossings — two cards put them there and both grounds + // are recorded, because either one alone is enough to keep this membership and + // deleting the entry needs both to be gone. + // - objectui#7946 (rework round): `aggregate` is `SpecChartAggregateSchema` by + // reference rather than the local near-copy the first cut declared, so a + // spec bump that widens or narrows the object-bound aggregation vocabulary + // moves ONE side of this pair. + // - objectui#8885: the three keys `ObjectChart.tsx` reads that neither + // published face declared are each the SPEC's own schema at the crossing — + // `SpecChartDrillDownSchema`, `SpecI18nLabelSchema`, and + // `SpecDashboardWidgetSchema.shape.compareTo` by reference (the producer's + // own declaration: `DashboardRenderer` forwards `widget.compareTo` + // verbatim). So a spec bump that moves the chart drill vocabulary, the + // i18n label union, or the widget's comparison directive moves ONE side too. + // Either way it is exactly what this list exists to make legible rather than + // mysterious. 'objectql.zod.ts#ObjectChartSchema', 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b9fd24b93d..5a5b5d821e 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -100,6 +100,9 @@ import type { ChartAggregate, GanttConfig as SpecGanttConfig, CalendarConfig as SpecCalendarConfig, + ChartDrillDown, + I18nLabel, + DashboardWidget as SpecDashboardWidget, } from '@objectstack/spec/ui'; /** @@ -3063,14 +3066,6 @@ export type KanbanConditionalFormattingRule = * neither this interface nor its zod mirror. They rode `BaseSchema`'s * `[key: string]: any` / `.passthrough()` and arrived UNVALIDATED. * - * ## The ceiling, stated rather than assumed (objectui#5155) - * - * `BaseSchema` still carries `[key: string]: any`, so anchoring buys DECLARED - * members their declared types — `xAxisKey: 42` is refused now — but does NOT - * buy rejection of a MISSPELLING: `xAxisKy: 'x'` still compiles, exactly as it - * does on `ObjectGallerySchema` (objectui#6576). The counter-probe in - * `__tests__/widget-schema-anchors-7946.test.ts` pins that honestly. - * * ## AUTHORABLE vs INTERNAL, per key (objectui#7946, ADR-0049) * * The four keys added by that card do NOT share one verdict, and the ruling @@ -3106,6 +3101,52 @@ export type KanbanConditionalFormattingRule = * undeclared does not make them unauthorable — it only means an `xAxisKey: 42` * rides through unchecked. Declaring buys the VALUE check without minting new * authorable vocabulary, and the descriptions say which is which. + * + * ## The three keys objectui#8885 declared, and why each is bound to the spec + * + * `ObjectChart.tsx` reads `drillDown`, `title` and `compareTo` off `schema`, + * and until objectui#8885 neither published copy of this shape mentioned any of + * them — the objectui#6914 class (a key read behind a cast, declared on neither + * published face). They rode `BaseSchema`'s `[key: string]: any` / + * `.passthrough()` and arrived UNVALIDATED, while two independent declarations + * already pointed at `drillDown`: this component's registry `inputs` advertise + * it to the designer palette, and `@objectstack/spec` publishes + * `ChartDrillDownSchema` for exactly this carrier. + * + * ⛔ None of the three is re-declared locally. Each binds to the spec symbol + * that already owns it, per this file's standing rule ("Never Redefine Types. + * ALWAYS import them.") — a local near-copy is the fork `check:spec-symbols` + * exists to stop, and the one that would drift the day the protocol moves: + * + * - `drillDown` → `ChartDrillDown` (`ChartDrillDownSchema`), which the spec + * documents as the `ObjectChart` react-tier prop by name. + * - `title` → `I18nLabel`, the union the spec's own `ChartConfigSchema.title` + * carries and that this package's `normalizeChartSchema` already resolves. + * - `compareTo` → `SpecDashboardWidget['compareTo']`, bound BY REFERENCE to + * the producer's own declaration (see the member doc). + * + * ## The ceiling, stated rather than assumed (objectui#5155) + * + * `BaseSchema` still carries `[key: string]: any`, so declaring a key buys it + * its declared TYPE — `xAxisKey: 42` and `title: 42` are both refused now — but + * does NOT buy rejection of a MISSPELLING: `xAxisKy: 'x'` and `drillDwn: {}` + * still compile, exactly as they do on `ObjectGallerySchema` (objectui#6576). + * ONE ceiling, two cards, and each pins it honestly with its own counter-probe: + * `__tests__/widget-schema-anchors-7946.test.ts` and + * `__tests__/object-chart-undeclared-keys-8885.test.ts`. + * + * ## Two cards ruled on this shape, and neither ruled for the other + * + * All eight keys above are declared, but they arrived under two separate + * rulings — objectui#7946 (`filter`, `aggregate`, `xAxisKey`, `series`, + * `colors`) and objectui#8885 (`drillDown`, `title`, `compareTo`) — and each + * card measured only its own. Neither swept the other's keys in, so neither + * took a disposition on the other's behalf. + * + * ⭐ Both census pins were written to survive that, and it is why landing order + * did not matter: each ledgers the OTHER card's keys by name and asserts only + * that each is STILL READ, never that it is still undeclared. So declaring a + * ledgered key does not redden its ledger — dropping the READ does. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -3248,6 +3289,94 @@ export interface ObjectChartSchema extends BaseSchema { * published copies of one shape disagreed silently until objectui#7946. */ colors?: string[] | Record; + /** + * AUTHORABLE — segment drill-down. Clicking a bar / slice / point opens the + * underlying records, filtered by the clicked category, in a drawer + * (default), a dialog, or the object's full list page. Absent means OFF; `{}` + * is enough to turn it on. + * + * ⛔ `ChartDrillDown` from `@objectstack/spec/ui`, NOT this repo's wider + * {@link DrillDownConfig}, and the difference is measured rather than + * stylistic. The spec type is the CHART subset — `enabled` / `filter` / + * `title` / `target` / `columns` / `maxRows`, all six of which + * `ObjectChart.tsx` reads — while `DrillDownConfig` additionally carries + * `mode` and `report` for the table / pivot / metric widgets, which this + * component reads NEITHER of. Declaring the wider type here would advertise + * two keys that are accepted and then dropped, which is the authoring bait + * objectui#3354 removed from `DrillDownConfig` itself. + * + * ⚠️ The `target: 'navigate'` arm is live on BOTH faces AT THIS PACKAGE'S + * DECLARED FLOOR, so nothing here is owed a floor bump: the floor is + * `@objectstack/spec` `^17.3.0` (`packages/types/package.json`), and + * `ChartDrillDownSchema.target` already reads + * `z.enum(['drawer', 'dialog', 'navigate'])` at the published 17.3.0 — and at + * 17.2.0 before it — after objectstack#5435 widened the union that + * objectui#3382 had implemented. ⛔ Cite the FLOOR here, never the version the + * lockfile happens to resolve: a range dependency guarantees the floor, and + * this sentence previously named 17.4.0 for no better reason than that the + * tree was resolving 17.4.0 that day. + * + * ⇒ What is left is an unmade decision about the DESIGNER PALETTE, not a + * protocol gap. The `description` on this component's registry `inputs` still + * lists two arms, and `packages/plugin-charts/src/index.test.ts` pins that + * withholding BY NAME. Widening an advertised authoring vocabulary is a shape + * decision, not the prose this round is allowed to touch — see this card's + * acceptance notes for the named successor. (The prose in `ObjectChart.tsx` + * that once claimed the protocol itself was narrower is already corrected; + * objectui#7946's rework round fixed it, so ⛔ do not repeat that half of the + * claim.) + */ + drillDown?: ChartDrillDown; + /** + * AUTHORABLE — the chart's heading, and the drill drawer's heading fallback. + * + * Two read sites, and ⛔ they do NOT jointly require the union — ONE of them + * does, and the other's CALLER narrows it before the helper ever sees it: + * + * - `normalizeChartSchema`'s `label()` takes this value as `unknown` and + * resolves BOTH arms itself, picking the first string out of a locale map. + * ⭐ This is the read that requires the union: declaring `string` alone + * would refuse a locale-map `title` that works today. + * - `ObjectChart.tsx` passes it to `resolveDrillTitle` as the drill drawer's + * heading fallback, and that helper's `fallback` parameter is a plain + * `string` (`@object-ui/core`'s `utils/drill-down.ts`). It never sees the + * map arm: the call site pre-resolves through `pickLocalized(schema.title, + * language)` first, which objectui#7946's rework round added precisely so + * the map arm could not reach a heading as an object. + * + * `I18nLabel` is exactly that union and is what `@objectstack/spec`'s own + * `ChartConfigSchema.title` carries — and the spec's `REACT_BLOCKS` entry for + * `ObjectChart` lists `title` among its `dataProps`, so this is a key the + * platform's authoring surface already offers. + * + * ⚠️ NOT a `BaseSchema` member — `title` there belongs to `HTMLAttributes`, + * not to the node shape — so before objectui#8885 it rode the index signature + * as `any` and a `title: 42` reached `label()` unchecked. + */ + title?: I18nLabel; + /** + * INTERNAL (relay-composed) — the period-over-period comparison directive. + * When present the chart runs a second, time-shifted query and overlays the + * previous window as `__comparison` series. + * + * Bound BY REFERENCE to `SpecDashboardWidget['compareTo']` because that is + * literally where the value comes from: `DashboardRenderer` composes this + * node with `compareTo: widget.compareTo`, forwarding the dashboard widget's + * own key verbatim. Binding to the producer's declaration is what keeps the + * two from drifting into a second dialect; the shape it resolves to is the + * converged `{ kind, dimension? }` (objectstack#5011), which the renderer + * side projects as `CompareToConfig` in `@object-ui/core` and the analytics + * contract publishes as `DatasetCompareTo`. + * + * INTERNAL rather than authorable: no producer in this repo puts it on an + * `object-chart` node an author wrote, this component's registry `inputs` do + * not advertise it, and `dimension` is deliberately never read on this path + * (the executor resolves it, so a renderer that guessed would trade a loud + * error for a quietly wrong window). Declaring it mints no new authorable + * vocabulary — the key is already authorable ON THE WIDGET — and buys the + * value check that `.passthrough()` was skipping. + */ + compareTo?: SpecDashboardWidget['compareTo']; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 1879a7c7b1..cdc68c240c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -35,6 +35,9 @@ import { AriaPropsSchema as SpecAriaPropsSchema, NavigationConfigSchema as SpecNavigationConfigSchema, ChartAggregateSchema as SpecChartAggregateSchema, + ChartDrillDownSchema as SpecChartDrillDownSchema, + I18nLabelSchema as SpecI18nLabelSchema, + DashboardWidgetSchema as SpecDashboardWidgetSchema, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; @@ -1313,6 +1316,29 @@ export const ObjectChartSchema = BaseSchema.extend({ z.array(z.string()), z.record(z.string(), z.string()), ]).optional().describe('Positional palette (string[]) OR a value→color map ({ value: color }, kanban-style). Select/lookup option colors and explicit maps win over the palette per category.'), + // ── objectui#8885: three more keys `ObjectChart.tsx` reads that neither + // published copy of this shape declared. Each is the SPEC's own schema at the + // crossing, never a local near-copy — see the TS twin in `../objectql.ts` for + // the per-key measurement. + // + // ⚠️ The keys ABOVE (`filter` / `aggregate` / `xAxisKey` / `series` / + // `colors`) are objectui#7946's and were ruled on separately; this card swept + // none of them in, and that card swept none of these three in. The two census + // pins record the split — `../__tests__/object-chart-undeclared-keys-8885.test.ts` + // and `../__tests__/widget-schema-anchors-7946.test.ts` each ledger the other + // card's keys BY NAME and assert only that each is STILL READ, never that it + // is still undeclared, which is why the landing order of the two never + // mattered. + drillDown: stripImportedDefaults(SpecChartDrillDownSchema).optional() + .describe('Segment drill config — @objectstack/spec ChartDrillDownSchema ({ enabled?, filter?, title?, target?: drawer | dialog | navigate, columns?, maxRows? }). Present = on; {} is enough. NOT the wider DrillDownConfig: a chart reads neither `mode` nor `report`.'), + title: stripImportedDefaults(SpecI18nLabelSchema).optional() + .describe('Chart heading, and the drill drawer heading fallback. @objectstack/spec I18nLabel — a plain string or an inline locale map, the union `normalizeChartSchema`’s `label()` resolves. Not a BaseSchema member.'), + // Strip-then-slot, the objectui#7779 idiom `ObjectViewSchema` above uses: + // the boundary is applied to the whole imported schema and the slot is taken + // off the RESULT, so the crossing is visible to the objectui#8317 census in + // the position it reads (`stripImportedDefaults()`). + compareTo: stripImportedDefaults(SpecDashboardWidgetSchema).shape.compareTo + .describe('Period-over-period comparison directive, forwarded verbatim from the dashboard widget key of the same name — bound BY REFERENCE to `DashboardWidgetSchema.shape.compareTo` so the producer and this consumer cannot drift into two dialects.'), }); /** diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index bfc1ba5f09..cb5f9c4da7 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1015,7 +1015,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1615 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1618 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: