From caf2310f4ef1dc2dfe059142455f9dcf1c0bbdc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:24:31 +0000 Subject: [PATCH 1/5] feat(types): declare `drillDown` / `title` / `compareTo` on both published copies of `ObjectChartSchema` (objectui#8885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectChart.tsx` reads all three off `schema`, and neither published copy of the shape declared any of them — not the TS interface (`objectql.ts`) nor the zod mirror (`zod/objectql.zod.ts`). They rode `BaseSchema`'s index signature / `.passthrough()` and arrived unvalidated. This is the objectui#6914 class, and `drillDown` was its sharpest instance: the 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 neither published shape mentioned. Each key binds to the spec symbol that already owns it, never a local near-copy: - `drillDown` -> `ChartDrillDown` / `ChartDrillDownSchema`. Deliberately NOT this repo's wider `DrillDownConfig`: that type also carries `mode` and `report` for the table / pivot / metric widgets, and `ObjectChart.tsx` reads neither, so declaring them would be authoring bait. - `title` -> `I18nLabel`, the union `ChartConfigSchema.title` carries and the union `normalizeChartSchema`'s `label()` already resolves. - `compareTo` -> `DashboardWidgetSchema.shape.compareTo` BY REFERENCE, which is literally where the value comes from (`DashboardRenderer` forwards `widget.compareTo` verbatim onto the node). Measured against the DECLARED FLOOR, not the installed tree: `@objectstack/spec` 17.3.0 (the `^17.3.0` this package declares) already carries all three symbols, including the `target: 'navigate'` arm, so no floor moves. `xAxisKey` / `series` / `aggregate` / `filter` are read by the same file and are objectui#7946's remit; they are ledgered by name in the new census pin, each with an assertion that it is STILL READ, rather than swept in here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- ...-object-chart-drilldown-title-compareto.md | 19 ++ .../object-chart-undeclared-keys-8885.test.ts | 302 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 9 + packages/types/src/objectql.ts | 109 ++++++- packages/types/src/zod/objectql.zod.ts | 16 + 5 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 .changeset/8885-object-chart-drilldown-title-compareto.md create mode 100644 packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts 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/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 9427e7c150..e1034ae157 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3096,6 +3096,15 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'complex.zod.ts#KanbanSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', + // 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 of this pair, which is exactly + // what this list exists to make legible rather than mysterious. + 'objectql.zod.ts#ObjectChartSchema', 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', // objectui#7762: `exportOptions` is the spec's OBJECT ARM by reference — peeled out of diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9b32d3525d..433292150a 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -99,6 +99,9 @@ import type { NavigationConfig, GanttConfig as SpecGanttConfig, CalendarConfig as SpecCalendarConfig, + ChartDrillDown, + I18nLabel, + DashboardWidget as SpecDashboardWidget, } from '@objectstack/spec/ui'; /** @@ -3051,7 +3054,47 @@ export type KanbanConditionalFormattingRule = | SpecConditionalFormattingRule; /** - * Object Chart Component Schema + * Object Chart Component Schema — the node `plugin-charts`' `ObjectChart` + * renders, registered as `object-chart`. + * + * ## 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 — `title: 42` is refused now — but does NOT buy rejection + * of a MISSPELLING: `drillDwn: {}` still compiles, exactly as it does on + * `ObjectGallerySchema` (objectui#6576). The counter-probe in + * `__tests__/object-chart-undeclared-keys-8885.test.ts` pins that honestly. + * + * ## Four keys are still undeclared here, deliberately + * + * `xAxisKey`, `series`, `aggregate` and `filter` are read by the same file and + * belong to objectui#7946, which ruled on them separately (PR #8884). They are + * ledgered BY NAME in this card's census pin — with an assertion that each is + * still read — rather than swept in here, so neither card's ruling is taken on + * the other's behalf. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -3072,6 +3115,70 @@ export interface ObjectChartSchema extends BaseSchema { dimensions?: string[]; /** Dataset measure names */ values?: string[]; + /** + * 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 as of + * `@objectstack/spec` 17.4.0 (objectstack#5435 widened the union after + * objectui#3382 implemented the arm). A comment in `ObjectChart.tsx` and the + * `description` on this component's registry `inputs` both still say + * `'drawer' | 'dialog'`; they predate that release and are stale prose, not a + * narrower contract — see this card's acceptance notes. + */ + drillDown?: ChartDrillDown; + /** + * AUTHORABLE — the chart's heading, and the drill drawer's heading fallback. + * + * Two read sites, and the union is the one they jointly require: + * `normalizeChartSchema` resolves it through `label()`, which accepts a plain + * string OR an inline locale map and picks a string out of it, and + * `ObjectChart.tsx` uses it as `resolveDrillTitle`'s fallback. `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 c6b4330c61..b3593111cf 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -34,6 +34,9 @@ import { UserActionsConfigSchema as SpecUserActionsConfigSchema, AriaPropsSchema as SpecAriaPropsSchema, NavigationConfigSchema as SpecNavigationConfigSchema, + 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'; @@ -1258,6 +1261,19 @@ 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 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. `xAxisKey` / `series` / `aggregate` / `filter` are read + // by the same file and belong to objectui#7946 (PR #8884); the census pin + // `../__tests__/object-chart-undeclared-keys-8885.test.ts` ledgers them by + // name rather than sweeping them in here. + 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.'), + 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.'), }); /** From 3dc1747810033cd20001d9b7b8581179268e6bd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:29:27 +0000 Subject: [PATCH 2/5] fix(types): route the `compareTo` crossing through the objectui#7779 strip-then-slot idiom, and ledger the new imports (objectui#8885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ledgers the tree keeps and this change moved: - `imported-defaults-8317.test.ts` reads the boundary in ONE position — `stripImportedDefaults()` with the binding as the direct argument — so `stripImportedDefaults(Spec….shape.compareTo)` read as an UNWRAPPED crossing. Re-spelled as `stripImportedDefaults(Spec…).shape.compareTo`, the idiom `ObjectViewSchema` already uses for its three by-reference slots. Same accept set, and now visible to the census. - `ChartDrillDownSchema` joins that file's `IMPORTED` list, so the differential actually measures whether the strip moves its accept set, and `objectql.zod.ts#ObjectChartSchema` joins `SPEC_DERIVED_PAIRS` in `zod-mirror-parity.test.ts`, which is what makes a future spec bump on this mirror legible rather than a mystery. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- packages/types/src/__tests__/imported-defaults-8317.test.ts | 3 +++ packages/types/src/zod/objectql.zod.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 30afd60f79..427251451f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -73,6 +73,7 @@ import { AriaPropsSchema as SpecAriaPropsSchema, NavigationConfigSchema as SpecNavigationConfigSchema, I18nLabelSchema as SpecI18nLabelSchema, + ChartDrillDownSchema as SpecChartDrillDownSchema, } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -173,6 +174,8 @@ const IMPORTED: Array = [ ['AriaPropsSchema', SpecAriaPropsSchema], ['NavigationConfigSchema', SpecNavigationConfigSchema], ['I18nLabelSchema', SpecI18nLabelSchema], + // objectui#8885: `ObjectChartSchema.drillDown` crosses this boundary. + ['ChartDrillDownSchema', SpecChartDrillDownSchema], ['SelectOptionSchema', SpecSelectOptionSchema], ] as const; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index b3593111cf..c726fa615e 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1272,7 +1272,11 @@ export const ObjectChartSchema = BaseSchema.extend({ .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.'), - compareTo: stripImportedDefaults(SpecDashboardWidgetSchema.shape.compareTo) + // 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.'), }); From 13d4ef32479ab4fc17d55a2fdf32085c01204efc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:33:05 +0000 Subject: [PATCH 3/5] docs(audits): re-point the four line citations this branch moved (objectui#8885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Position axis, measured with `scripts/cross-file-line-citation-census.mjs` at the branch point and on this branch. Adding three import lines to `zod/objectql.zod.ts` shifted everything below them by exactly 3, and four citations in this audit that RESOLVED at the branch point stopped resolving. Repaired by CONTENT, not by arithmetic: each cited line was read at the branch point and located again on this branch, and all four are byte-identical at +3 (`350`->`353`, `365`->`368`, `643`->`646`, `713`->`716`). The census now reports 540 false / 295 resolving on this branch — the branch point's figures exactly. ⛔ Only the citations THIS branch moved are touched. The one remaining difference is `objectql.ts:2961 -> ObjectKanban.tsx:264`, which is the branch point's `:2958` row with its own source line shifted: that citation was already drifted before this branch existed, so it stays objectui#8875's, not this card's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- docs/audits/2026-07-objectview-detailview-schema.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/audits/2026-07-objectview-detailview-schema.md b/docs/audits/2026-07-objectview-detailview-schema.md index 807f6ea927..cd342c0c17 100644 --- a/docs/audits/2026-07-objectview-detailview-schema.md +++ b/docs/audits/2026-07-objectview-detailview-schema.md @@ -126,11 +126,11 @@ 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. | -| `showSearch` / `showFilters` / `showSort` | `UserActionsConfigSchema.{search,filter,sort}` (`:350-352`) | Scope A step 3, same fold. | +| `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}` (`:353-355`) | 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. | -| `description` | `description` (`:713`) | Same name, same i18n type difference. | +| `description` | `description` (`:716`) | Same name, same i18n type difference. | | `layout` (`drawer\|modal\|page`) | `NavigationConfigSchema.mode` (`:585`) | Spec is a superset (`split`, `popover`, `new_window`, `none`). Fold into `navigation`, don't keep a parallel three-value enum. | **Restructure — the container shape (4)** From d553e5d1b7f63c9dba0016227974cd8f821477b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:05:22 +0000 Subject: [PATCH 4/5] fix(scripts): re-point the one UNGATED_EXAMPLES key this branch moved (objectui#8885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UNGATED_EXAMPLES` in `check-doc-example-types.mjs` is keyed by `` `${block.file}:${block.line} ${block.symbol}` `` — the LINE NUMBER IS PART OF THE KEY. Adding three import lines to `packages/types/src/objectql.ts` moved the collected block, so the key stopped naming a block that exists and `scripts/__tests__/check-doc-example-types.test.ts` went red on 'every row names a block that is actually in the compiled tier'. Located by anchor, not by arithmetic: the block is the `@example` tag on `ObjectFormSchema.mobile`, and `sed -n '1614p'` at the branch point and on `origin/main` and `sed -n '1617p'` here are the same line, byte for byte. The checker's own census agrees — it collects exactly one block in that file, at `:1617`. ⛔ ONE key, line number only. No row added, none deleted (90 before, 90 after), no `card` / `codes` / `reason` touched, no assertion in the test changed. Positive control: before, the checker reported exactly one ledger row with no matching block; after, zero — and a base-vs-branch diff of the key list shows exactly one line differing, so no other row was moved to make this pass. ⚠️ Why the earlier position-axis pass did not catch this: the line-citation census reads `path:line` references in PROSE and COMMENTS. This one is a machine-checked key embedded in a data structure, which that census cannot see. "Census green" is not "position axis clean". The full carrier sweep is in the PR body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- scripts/check-doc-example-types.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index 3d12caf002..2ff5dfc33e 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:1614 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1617 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: From 84af85e40efa6bf66dfe1f941d2c8624eb6b62a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:37:48 +0000 Subject: [PATCH 5/5] fix(docs): keep only the class-(A) half of this branch's line-citation repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-measured with `scripts/cross-file-line-citation-census.mjs` on `origin/main` and on this tree. The previous round repaired FOUR citations in the 2026-07 audit as class (A). Only TWO of them are. Class (A), kept — both on the `defaultViewType` row, both RESOLVE on `origin/main` and are broken by this branch's three added import lines in `objectql.zod.ts`. Repaired by content, not arithmetic: `:365` and `:643` on main read ` * STRICT \`ObjectListViewSchema\`,` and ` // objectui-only object binding (...)`, and both are byte-identical at `:368` / `:646` here. Class (B), reverted to `origin/main` — `:350-352` and `:713`. Measured ALREADY FALSE on `origin/main` before this branch existed (`:350` reads `[drifted]` there). Shifting an already-false address by a hunk delta moves a wrong pointer to a differently wrong place, which the census tool's own header forbids and objectui#8875 reserves. Census parity after this commit: 545 false / 290 resolving on BOTH `origin/main` and this tree — this branch newly falsifies nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- docs/audits/2026-07-objectview-detailview-schema.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/audits/2026-07-objectview-detailview-schema.md b/docs/audits/2026-07-objectview-detailview-schema.md index cd342c0c17..63fcc94c12 100644 --- a/docs/audits/2026-07-objectview-detailview-schema.md +++ b/docs/audits/2026-07-objectview-detailview-schema.md @@ -127,10 +127,10 @@ wrong thing.** The prerequisite is to make the declaration match the reads. | --- | --- | --- | | `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` (`: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}` (`:353-355`) | Scope A step 3, same fold. | +| `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. | -| `description` | `description` (`:716`) | Same name, same i18n type difference. | +| `description` | `description` (`:713`) | Same name, same i18n type difference. | | `layout` (`drawer\|modal\|page`) | `NavigationConfigSchema.mode` (`:585`) | Spec is a superset (`split`, `popover`, `new_window`, `none`). Fold into `navigation`, don't keep a parallel three-value enum. | **Restructure — the container shape (4)**