From de8fda691dee7fd040165b5a8cd82569e419e292 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:36:02 +0000 Subject: [PATCH 1/2] feat(types): refuse `chartType` on a chart series by name, pointing at `type` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChartDataSeriesSchema` is a non-strict `z.object`, so an authored series `chartType` — the renderer's INTERNAL spelling of the declared `type`, the first limb of `normalizeSeries`' `str(raw.chartType) ?? str(raw.type)` — was stripped in silence while `safeParse` reported success. It is now declared as a named ALIAS REFUSAL arm (`aliasKeyRefusal()` in `zod/tombstone.zod.ts`, reusing `retirementTombstone`'s `z.never` primitive so `z.toJSONSchema` keeps working) that answers with the spec's own sentence — "Unrecognized key(s) on this chart series: `chartType`. Did you mean `chartType` → `type`?" — and the TS twin carries a `?: never` tombstone. Both-written is refused, not folded. Re-measured at implementation time, series-level, lit controls: docs 0, fixtures 0, designer inputs 0, src literals 0, tests 9 (internal-shape). Limb ablation over 304 files / 5817 tests: deleting the `chartType` limb left all green; deleting the `type` sibling went 2 red. No reader changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- ...4-chart-series-chart-type-alias-refusal.md | 78 ++++++ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-charts.mdx | 2 + ...ries-chart-type-alias-refusal-7694.test.ts | 251 ++++++++++++++++++ .../__tests__/chart-series-keys-7546.test.ts | 42 +-- packages/types/src/data-display.ts | 49 +++- packages/types/src/zod/data-display.zod.ts | 40 ++- packages/types/src/zod/tombstone.zod.ts | 58 +++- 8 files changed, 486 insertions(+), 36 deletions(-) create mode 100644 .changeset/7694-chart-series-chart-type-alias-refusal.md create mode 100644 packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts diff --git a/.changeset/7694-chart-series-chart-type-alias-refusal.md b/.changeset/7694-chart-series-chart-type-alias-refusal.md new file mode 100644 index 0000000000..e623e4ddd5 --- /dev/null +++ b/.changeset/7694-chart-series-chart-type-alias-refusal.md @@ -0,0 +1,78 @@ +--- +'@object-ui/types': minor +--- + +`ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) now REFUSES `chartType` on a chart +series BY NAME and points at `type` — the renderer-internal spelling the non-strict Zod object had +been **stripping in silence** while `safeParse` reported success (objectui#7694, the `domain:ui` PM +ruling on objectui#7546: option A, a named alias refusal, the posture `@objectstack/spec` already +takes). + +⚠️ Shipped as `minor`, not `patch`, because this is a NARROWING of a published accept surface, and +it is named here in the words a release reader can act on: + +- **Before:** `series: [{ name: 'revenue', chartType: 'line' }]` validated green through + `@object-ui/types/zod` (`safeValidateSchema`, `objectui check` / `objectui validate`, any + pipeline that keeps `parse()`'s output) — and the key was gone from the output, so a consumer of + the parse result drew that series in the chart's own family, precisely what the author was + overriding. On the TypeScript face the key was merely an excess property on a fresh literal; + a widened object carrying it assigned structurally. +- **After:** the same document REFUSES at `series[i].chartType` (issue code `invalid_type`) with + one message on both channels — the parse-time issue and the `.describe()` metadata: + `Unrecognized key(s) on this chart series: \`chartType\`. Did you mean \`chartType\` → \`type\`? …` + followed by the reason and the remedy. Write `type: 'bar' | 'line' | 'area'`. On the TypeScript + face `ChartDataSeries.chartType` is a `?: never` tombstone, so both the fresh literal and the + widened assignment are `tsc` errors. +- **Both written** (`{ type: 'bar', chartType: 'line' }`) is refused at `chartType` alone — the + key is not folded onto `type` and no precedence is minted between the two spellings. + +This repository's `major` is a cross-repo pin to `@objectstack`'s major, not a severity dial; the +break is announced here, which is the channel that carries it. + +## Why a refusal, and not the two alternatives + +`chartType` is the renderer's INTERNAL spelling of `type`: the first limb of `normalizeSeries`' +`str(raw.chartType) ?? str(raw.type)` (`@object-ui/plugin-charts`, `normalizeChartSchema.ts:244`), +written by the internal-shape producers that hand `dataKey`-shaped arrays straight to +`ChartRenderer` (`ObjectChart`, `DatasetWidget`; `core/utils/chart-presentation` translates authored +`type` *into* it) and by nothing an author writes. Re-measured at implementation time, series-level, +with lit controls (`dataKey` / `name` / `type` / `color`): docs 0, fixtures 0, designer inputs 0 +(the `chart` registration's `series` is one `code` input), src literals 0, tests 9 — every one an +internal-shape array that never meets this mirror. Limb ablation over 304 files / 5817 tests: +deleting `str(raw.chartType) ??` left all green; deleting the `?? str(raw.type)` sibling went 2 red. + +- **Not a fold onto `type`.** The renderer takes `chartType` FIRST, so a fold would let the alias + overwrite the canonical key when both are written — the inversion of the objectui#7113 precedence + rule (`xAxis` → `xAxisKey` folds *because* the reader already prefers the canonical key). +- **Not a second writable name.** `@objectstack/spec`'s `ChartSeriesSchema` lists `chartType` in its + alias map as a spelling of `type` and refuses it by name; declaring it here would mint a second + de-facto contract against the spec's own posture (AGENTS.md #0.1). + +## The primitive, and the JSON-Schema surface + +The new `aliasKeyRefusal()` helper (`zod/tombstone.zod.ts`, internal — not re-exported) reuses +`retirementTombstone`'s primitive, `z.never({ error }).optional().describe()`, deliberately not +`handlerKeyRefusal`'s `z.custom`: measured, `z.toJSONSchema` throws on a `z.custom` arm ("Custom +types cannot be represented in JSON Schema") and represents a `z.never` arm as `{ not: {} }` with its +description. `z.toJSONSchema(ChartDataSeriesSchema)` succeeded before this change and still does — +it now lists `chartType` as a refused property carrying the guidance. + +## Unchanged, deliberately + +The object stays non-strict — a truly undeclared key is still stripped, exactly as +`chart-inline-data-retired.test.ts` pins. The six keys objectui#7546 declared, the `data` tombstone +(objectui#6896) and the at-least-one-binding refinement (objectui#6939 / #7113) are untouched. +**No reader changed:** `normalizeSeries` still reads `chartType` first on the internal-shape arrays +its producers hand it; that limb is a reader decision, not this declaration's. + +## FROM → TO + +```ts +// ChartDataSeries ++ chartType?: never; // alias of `type` — refused by name; write `type` +``` + +Pinned in `packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts` — +the refusal envelope, both-written, the TS face, the spec's own posture measured live on the +installed `@objectstack/spec`, and the JSON-Schema surface; `chart-series-keys-7546.test.ts` block +(d) now pins the handoff from the gap that card reported. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 1be46b4385..8a01d23c1d 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -491,7 +491,7 @@ numbers of its own: `ChartDataSeries.data` is a retirement tombstone | `title` | `string` | Chart title. | | `description` | `string` | Chart description / subtitle. | | `categories` | `string[]` | An **alternative series list** — column names to plot, read only when `series` is absent, and ignored outright when it is present. Not axis labels: the category axis comes from `xAxisKey`. | -| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. | +| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. `chartType` on a series is refused by name — it is the renderer's internal spelling of `type`; write `type`. | | `data` | `Array>` | Rows to plot — one object per row, keyed by column name. | | `xAxisKey` | `string` | Row key holding the category (x) axis. The bare-string `xAxis: "month"` spelling folds onto this key at parse. | | `height` / `width` | `string \| number` | Chart dimensions. | diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index bf2680f6bf..14406b0d46 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -193,6 +193,8 @@ Each `series` entry names the column it plots (`dataKey`, or the spec spelling ` | `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Today the renderer applies it only on a `variant: 'comparison'` series **and** only on a mark that has a stroke to dash — a `line` or `area` mark (the chart's `chartType`, or a per-series `type` override), where it overrides the overlay's default `"4 4"`; a comparison `bar` or `scatter` mark takes `opacity` only and drops the dash, and on a primary series of any family it is read and unused. | | `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's per-family default on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` mark; on a primary series it is read and unused. | +`chartType` is **not** a series key. It is the renderer's internal spelling of `type` — `@objectstack/spec`'s `ChartSeries` lists it as an alias — and the validator refuses it by name (`Unrecognized key(s) on this chart series: chartType. Did you mean chartType → type?`) instead of dropping it silently, which is what it used to do. Write `type`. + An `area` chart keeps every key in this example live: `stack` stacks the two primary areas, and the comparison overlay takes both the dash and the opacity (on a `bar` chart the overlay would take `opacity` only). ```tsx diff --git a/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts b/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts new file mode 100644 index 0000000000..c8f82858ea --- /dev/null +++ b/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts @@ -0,0 +1,251 @@ +/** + * 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#7694 — `ChartDataSeriesSchema.chartType` is a NAMED ALIAS REFUSAL + * pointing at `type` (`domain:ui` PM ruling on objectui#7546 and the contract + * review of PR #7684: option A — the posture `@objectstack/spec` takes). + * + * ## The defect (measured on origin/main a00db9ef9 before this change) + * + * `ChartDataSeriesSchema` is a NON-STRICT `z.object` and `chartType` was + * undeclared on it, so `{ name: 'r', chartType: 'line' }` parsed GREEN to + * `{ name: 'r' }` — the key STRIPPED in silence — while the renderer's + * `normalizeSeries` reads that very key FIRST + * (`str(raw.chartType) ?? str(raw.type)`, `normalizeChartSchema.ts:244`). An + * author who wrote the internal spelling met a validator that said nothing and, + * on any path that keeps the parse output, a series drawn in the chart's own + * family — precisely what they were overriding. + * + * ## Liveness, RE-MEASURED at implementation time (a lit control on every count) + * + * Series-level `chartType` on the authoring face, controls `dataKey` / `name` / + * `type` / `color` in the same query: docs 0 (10 / 11 / 2 / 2), fixtures 0 + * (3 / 2 / 0 / 2), designer inputs 0 (the `chart` registration's `series` is + * ONE `code` input), src literals 0 (13 / 3 / 1 / 0), tests 9 (70 / 48 / 11 / + * 8) — every one of the nine an internal-shape array handed straight to + * `ChartRenderer`, which never meets this mirror. Limb ablation over 304 files + * / 5817 tests: deleting `str(raw.chartType) ??` left all 5817 green; deleting + * the `?? str(raw.type)` sibling went 2 red. The instrument is lit, the zero is + * a reading, and it agrees with the card's own table. + * + * ## The ruled shape, and the two refused + * + * A. a named refusal pointing at `type` — TAKEN. The spec's `ChartSeriesSchema` + * lists `chartType` in its alias map as a spelling of `type` and answers + * "Did you mean `chartType` → `type`?"; block (e) measures that live. + * B. fold `chartType` onto `type` at parse — REFUSED: the renderer takes + * `chartType` FIRST, so a fold would let the alias overwrite the canonical + * key when both are written, inverting objectui#7113's precedence rule. + * Block (c) pins "both written" as a refusal, not a silent winner. + * C. declare it as a second writable name — REFUSED: contradicts the spec's + * alias map and the in-repo docblocks calling it the INTERNAL spelling — + * the N-dialects hazard of AGENTS.md #0.1. + * + * ## The primitive — `z.never`, not `z.custom`, measured + * + * `aliasKeyRefusal()` (`../zod/tombstone.zod.ts`) reuses `retirementTombstone`'s + * primitive, `z.never({ error }).optional().describe()`, and NOT + * `handlerKeyRefusal`'s `z.custom`. Measured on the base: `z.toJSONSchema` + * represents a `z.never` arm as `{ not: {} }` with its description and THROWS + * on a `z.custom` arm ("Custom types cannot be represented in JSON Schema"). + * `z.toJSONSchema(ChartDataSeriesSchema)` succeeded before this change (11 + * properties) and goes on succeeding; block (f) pins it. + * + * ## Predictions, written before the first run (red-first) + * + * On the unmodified tree: (a) is red on `success` (true, key stripped); (b) is + * red on `.shape` (no `chartType`); (c)'s first case is red (parses green, + * `type: 'bar'` kept); (d)'s `Eq` line and its second `@ts-expect-error` are + * `tsc` errors (`ChartDataSeries` has no `chartType`, and the non-fresh + * assignment compiles); (f)'s `chartType` property is red. The CONTROLS, (e) + * and (f)'s counter-probe are GREEN before and after — they pin the reason for + * the arm and the spec's posture, not this change. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ChartSeriesSchema as SpecChartSeriesSchema } from '@objectstack/spec/ui'; +import type { ChartDataSeries } from '../data-display'; +import { ChartDataSeriesSchema } from '../zod/data-display.zod'; +import { aliasKeyRefusal, retirementTombstone } from '../zod/tombstone.zod'; + +/** The document that parsed green on the base, its key stripped. */ +const AUTHORED = { name: 'r', chartType: 'line' } as const; + +/** The remedy fragment both faces answer with. */ +const DID_YOU_MEAN = /Did you mean `chartType` → `type`\?/u; + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +type Issue = { code: string; path: string; message: string }; +/** `null` when the document parses; the issues otherwise. */ +const issuesOf = (input: unknown): Issue[] | null => { + const r = ChartDataSeriesSchema.safeParse(input); + return r.success ? null : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +}; + +/* ── (a) the refusal — by name, at its own path, with the remedy ──────────── */ + +describe('objectui#7694 — `{ name, chartType }` is REFUSED by name (it parsed green, key stripped, on the base)', () => { + it('refuses with one issue, `invalid_type` at `chartType`', () => { + const issues = issuesOf(AUTHORED); + expect(issues).not.toBeNull(); + expect(issues).toHaveLength(1); + expect(issues![0]).toMatchObject({ code: 'invalid_type', path: 'chartType' }); + }); + + it('the message names the key, points at `type`, and says why', () => { + const [issue] = issuesOf(AUTHORED)!; + expect(issue.message).toMatch(DID_YOU_MEAN); + expect(issue.message).toContain('INTERNAL spelling'); + expect(issue.message).toContain('objectui#7694'); + expect(issue.message).toContain('Write `type`'); + }); + + it.each(['line', 'bar', 'pie', 1, null, true, {}])('every value is refused (%j) — the refusal is about the KEY, not a value domain', (value) => { + const issues = issuesOf({ name: 'r', chartType: value }); + expect(issues?.map((i) => i.path)).toEqual(['chartType']); + }); + + it('ONE string feeds both channels — the issue message IS the `.describe()` metadata', () => { + const [issue] = issuesOf(AUTHORED)!; + expect(shapeOf(ChartDataSeriesSchema).chartType?.description).toBe(issue.message); + }); + + it('CONTROL — the series without the key parses; the arm is optional and absence is the accept set', () => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r' }).success).toBe(true); + }); +}); + +/* ── (b) declared, on the mirror's OWN shape — what zod-mirror-parity reads ── */ + +describe('objectui#7694 — `chartType` is on `.shape`, and its `z.input` is `undefined`', () => { + it('is a key of the mirror\'s own shape', () => { + expect(shapeOf(ChartDataSeriesSchema)).toHaveProperty('chartType'); + }); + + it('the arm\'s input type is exactly `undefined` — what a `?: never` twin declares, so the pair does not drift', () => { + type ArmInput = z.input['chartType']; + const armIsUndefined: Eq = true; + expect(armIsUndefined).toBe(true); + }); +}); + +/* ── (c) not a fold — both written is a refusal, nothing silently wins ─────── */ + +describe('objectui#7694 — when BOTH spellings are written the document is refused; no precedence is minted', () => { + it('`{ type: "bar", chartType: "line" }` refuses at `chartType` alone — `type` is neither overwritten nor kept', () => { + // A fold would have had to pick a winner. The renderer picks `chartType` + // (its first limb), so a "canonical wins" fold would state the OPPOSITE of + // what the reader does — the inversion of objectui#7113's `xAxis` → + // `xAxisKey` rule, where the fold restates the reader's own precedence — + // and an "alias wins" fold would let the internal spelling overwrite the + // authored one. Neither is minted: the document is refused, and the author + // is told which key to drop. + const issues = issuesOf({ name: 'r', type: 'bar', chartType: 'line' }); + expect(issues?.map((i) => i.path)).toEqual(['chartType']); + }); + + it('CONTROL — `type` alone, the author spelling of the same override, survives with its value', () => { + const r = ChartDataSeriesSchema.safeParse({ name: 'r', type: 'line' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toEqual({ name: 'r', type: 'line' }); + }); + + it('CONTROL — a truly undeclared key is STILL stripped in silence: the object is non-strict, which is why the arm exists', () => { + // The deletion-not-chosen. This is what `chartType` looked like on the + // base, and what it would look like again if someone "simplified" the arm + // away. `chart-series-keys-7546.test.ts` (e) pins the same fact. + const r = ChartDataSeriesSchema.safeParse({ name: 'r', notAKeyAtAll: 'x' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).not.toHaveProperty('notAKeyAtAll'); + }); +}); + +/* ── (d) the TypeScript face moves in lockstep ───────────────────────────── */ + +describe('objectui#7694 — `ChartDataSeries.chartType` is a `?: never` tombstone', () => { + it('`undefined` is its whole domain — the same as the arm\'s input', () => { + const tsIsUndefined: Eq = true; + expect(tsIsUndefined).toBe(true); + }); + + it('writing it is a `tsc` error at the authoring site', () => { + // @ts-expect-error `chartType` is the renderer's INTERNAL spelling of `type` — write `type` (objectui#7694) + const s: ChartDataSeries = { name: 'r', chartType: 'line' }; + expect(s.name).toBe('r'); + }); + + it('a NON-fresh object carrying it no longer assigns structurally either — this is the TS-face narrowing', () => { + // On the base the member did not exist, so this widened object assigned + // (excess-property checking applies to fresh literals only). The `never` + // member makes the assignment itself fail: `string` is not `undefined`. + const widened = { name: 'r', chartType: 'line' as string }; + // @ts-expect-error the member exists and is `never`; structural assignment fails, not only excess-property checking + const s: ChartDataSeries = widened; + expect(s.name).toBe('r'); + }); +}); + +/* ── (e) the spec agrees — same posture, same remedy, measured live ─────────── */ + +describe('objectui#7694 — `@objectstack/spec` takes the same posture, measured on the installed spec', () => { + it('`ChartSeriesSchema` refuses `chartType` by name and points at `type`', () => { + const r = SpecChartSeriesSchema.safeParse(AUTHORED); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toMatch(DID_YOU_MEAN); + }); + + it('CONTROL — the spec accepts `type`, the canonical spelling, and keeps it', () => { + const r = SpecChartSeriesSchema.safeParse({ name: 'r', type: 'line' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toHaveProperty('type', 'line'); + }); + + it('this mirror answers with the spec\'s own lead — "Unrecognized key(s) on this chart series" — so one remedy meets the author on both faces', () => { + const [ours] = issuesOf(AUTHORED)!; + expect(ours.message.startsWith('Unrecognized key(s) on this chart series: `chartType`.')).toBe(true); + // The rest of the spec's sentence is its own (`history`, a #4001 note) and + // is deliberately NOT pinned byte-for-byte: a spec reword must not turn an + // objectui pin red. The remedy fragment is the contract. + }); +}); + +/* ── (f) the published JSON-Schema surface, and the primitive that keeps it ── */ + +describe('objectui#7694 — `z.toJSONSchema(ChartDataSeriesSchema)` still succeeds, because the arm is `z.never`, not `z.custom`', () => { + it('succeeds, and now carries `chartType` as `{ not: {} }` with the guidance as its description', () => { + const js = z.toJSONSchema(ChartDataSeriesSchema) as { properties?: Record }; + expect(js.properties).toHaveProperty('chartType'); + expect(js.properties!.chartType).toMatchObject({ not: {}, description: expect.stringMatching(DID_YOU_MEAN) }); + }); + + it('COUNTER-PROBE — `handlerKeyRefusal`\'s `z.custom` primitive would have made that call throw', () => { + const custom = z.object({ chartType: z.custom(() => false, { error: 'x' }).optional() }); + expect(() => z.toJSONSchema(custom)).toThrow(/cannot be represented in JSON Schema/); + const never = z.object({ chartType: aliasKeyRefusal('chartType', 'type', 'this probe', 'detail.') }); + expect(() => z.toJSONSchema(never)).not.toThrow(); + }); + + it('the arm reports `invalid_type` like a retirement tombstone and is told apart by its WORDING — an alias, not a retirement', () => { + const arm = z.object({ k: aliasKeyRefusal('k', 'c', 'this probe', 'detail.') }).safeParse({ k: 1 }); + const tomb = z.object({ k: retirementTombstone('RETIRED (probe) — migration note.') }).safeParse({ k: 1 }); + expect(arm.error?.issues[0]?.code).toBe('invalid_type'); + expect(tomb.error?.issues[0]?.code).toBe('invalid_type'); + expect(arm.error?.issues[0]?.message).toBe('Unrecognized key(s) on this probe: `k`. Did you mean `k` → `c`? detail.'); + expect(arm.error?.issues[0]?.message).not.toContain('RETIRED'); + expect(tomb.error?.issues[0]?.message).not.toContain('Did you mean'); + }); +}); diff --git a/packages/types/src/__tests__/chart-series-keys-7546.test.ts b/packages/types/src/__tests__/chart-series-keys-7546.test.ts index 01a1a24a5b..2c1ed226a4 100644 --- a/packages/types/src/__tests__/chart-series-keys-7546.test.ts +++ b/packages/types/src/__tests__/chart-series-keys-7546.test.ts @@ -9,9 +9,12 @@ /** * objectui#7546 — `ChartDataSeriesSchema` declares the SIX series keys its * renderer reads and honours: `label`, `variant`, `opacity`, `dashArray`, - * `stack`, `yAxis`. The seventh key the review found, `chartType`, is measured - * NOT live on this authoring face and is deliberately left undeclared — see the - * last block, which pins that decision so it is visible rather than silent. + * `stack`, `yAxis`. The seventh key the review found, `chartType`, was measured + * NOT live on this authoring face and deliberately left undeclared by this card — + * REPORTED for its own card. That card, objectui#7694, has since ruled and landed + * the shape (a named alias refusal pointing at `type`); block (d) below now pins + * the HANDOFF, and `chart-series-chart-type-alias-refusal-7694.test.ts` pins the + * refusal itself. * * ## The defect (measured RED on origin/main a472b071 before the declaration) * @@ -66,6 +69,7 @@ * AGENTS.md #0.1 names. So it is REPORTED, per the ruling, not declared and * not retired: the right shape for it (a named alias refusal pointing at * `type`, as the spec does; or a fold) is a contract decision for its own card. + * That card is objectui#7694, and it took the refusal — see block (d). */ import { describe, it, expect } from 'vitest'; @@ -203,23 +207,27 @@ describe('objectui#7546 — the `ChartDataSeries` interface declares the same si }); }); -/* ── (d) the seventh key: measured NOT live here, reported — and pinned ────── */ - -describe('objectui#7546 — `chartType` is NOT declared on the series (reported, not retired)', () => { - it('is still stripped — the split is pinned so it stays visible, not so it lasts', () => { - // If this goes red, someone declared `chartType` on the series. That is a - // contract decision this card explicitly did not take (see the file - // header): either its own card landed — then move this pin there — or it - // was added by hand, and the spec's alias posture (`chartType` -> `type`) - // is the thing to re-read first. +/* ── (d) the seventh key: reported here, RULED and landed by objectui#7694 ──── */ + +describe('objectui#7546 — `chartType`: reported by this card, refused by name since objectui#7694', () => { + it('the gap this block used to pin is CLOSED — declared and refusing, no longer stripped', () => { + // Until objectui#7694 this block pinned `success === true` with `chartType` + // absent from the output — the silent strip, held visible until its own + // card ruled a shape. The ruling (option A: a named alias refusal pointing + // at `type`, the spec's own posture) landed, and the full pin — envelope, + // both-written, TS face, spec agreement, JSON-Schema surface — lives in + // `chart-series-chart-type-alias-refusal-7694.test.ts`. This is the + // handoff, restated rather than deleted (objectui#7070): the OLD reading + // must not come back, and if it does the thing to re-read first is still + // the spec's alias posture (`chartType` -> `type`). const r = ChartDataSeriesSchema.safeParse({ name: 'r', chartType: 'line' }); - expect(r.success).toBe(true); - if (r.success) expect(r.data).not.toHaveProperty('chartType'); - expect(shapeOf(ChartDataSeriesSchema)).not.toHaveProperty('chartType'); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toEqual(['chartType']); + expect(shapeOf(ChartDataSeriesSchema)).toHaveProperty('chartType'); }); - it('the TS face agrees — `chartType` is an excess property on `ChartDataSeries`', () => { - // @ts-expect-error `chartType` is the renderer's INTERNAL spelling of `type`; not a member here (objectui#7546) + it('the TS face agrees — `chartType` is a `?: never` tombstone on `ChartDataSeries`, not a member an author can write', () => { + // @ts-expect-error `chartType` is the renderer's INTERNAL spelling of `type`; refused by name — write `type` (objectui#7694) const s: ChartDataSeries = { name: 'r', chartType: 'line' }; expect(s.name).toBe('r'); }); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index e3ef905ac4..9fb073280c 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1546,16 +1546,45 @@ export interface ChartDataSeries { * carries the same union (objectui#7546). */ yAxis?: 'left' | 'right'; - // ⛔ NOT declared: `chartType`. It is the first limb of `normalizeSeries`' - // `str(raw.chartType) ?? str(raw.type)` (`normalizeChartSchema.ts:244`), but - // it is the renderer's INTERNAL spelling of `type` above, the spec's - // `ChartSeriesSchema` lists it as an alias of `type` and refuses it by name - // (`@objectstack/spec` `ui/chart.zod.ts:231`), and no document, fixture or - // designer input on this face writes it (objectui#7546 — measured with lit - // controls). Declaring it would mint a second writable name for one override; - // its shape — a named alias refusal like the spec's, or a fold — is a contract - // decision for its own card. Until then the mirror still strips it, and - // `__tests__/chart-series-keys-7546.test.ts` pins that gap so it stays visible. + /** + * NOT A KEY OF THIS SERIES — a named ALIAS REFUSAL pointing at {@link type} + * (objectui#7694; `domain:ui` PM ruling on objectui#7546 and the contract + * review of PR #7684: option A, the posture `@objectstack/spec` takes). + * + * `chartType` is the renderer's INTERNAL spelling of the declared `type`: the + * first limb of `normalizeSeries`' `str(raw.chartType) ?? str(raw.type)` + * (`normalizeChartSchema.ts:244`), written only by the internal-shape + * producers that hand `dataKey`-shaped arrays straight to `ChartRenderer` + * (`ObjectChart.tsx`, `DatasetWidget.tsx`; `core/utils/chart-presentation.ts` + * translates authored `type` INTO it) — and by no author. Re-measured at + * implementation time, series-level, with lit controls + * (`dataKey` / `name` / `type` / `color`): docs 0 (controls 10 / 11 / 2 / 2), + * fixtures 0 (3 / 2 / 0 / 2), designer inputs 0 (the `chart` registration's + * `series` is one `code` input), src literals 0 (13 / 3 / 1 / 0), tests 9 + * (70 / 48 / 11 / 8 — every one an internal-shape array that never meets the + * mirror). Limb ablation over 304 files / 5817 tests: deleting + * `str(raw.chartType) ??` left all green; deleting `?? str(raw.type)` went + * 2 red. The card's readings, re-taken, agree. + * + * The spec's `ChartSeriesSchema` lists `chartType` in its alias map as a + * spelling of `type` and refuses it by name — "Did you mean `chartType` → + * `type`?" — and this face answers with the same sentence + * (`aliasKeyRefusal()` in `zod/tombstone.zod.ts`). The two alternatives were + * ruled out: FOLDING it onto `type` would let the alias overwrite the + * canonical key when both are written (the renderer reads `chartType` FIRST, + * inverting objectui#7113's precedence rule), and DECLARING it as a second + * writable name would mint the N-dialects hazard of AGENTS.md #0.1 against + * the spec's own alias map. + * + * Until this declaration the non-strict Zod mirror STRIPPED the key in + * silence: `{ name: 'x', chartType: 'line' }` parsed green to `{ name: 'x' }` + * and the series drew in the chart's family — precisely what the author was + * overriding. Now the mirror refuses it by name and this `?: never` is a + * `tsc` error at the authoring site. Write {@link type}. The renderer's own + * read of the internal spelling is untouched — a reader decision, not this + * declaration's. + */ + chartType?: never; } /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 5657e60c82..072cb17912 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; import { ChartTypeSchema as SpecChartTypeSchema, I18nLabelSchema } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES } from '../data-display.js'; /** @@ -406,6 +406,36 @@ export const ChartDataSeriesSchema = z.object({ // ones `normalizeChartSchema` actually honours as a per-series override; see // the TS declaration for the read this narrowness is taken from. type: z.enum(['bar', 'line', 'area']).optional().describe('Per-series chart family override (combo charts)'), + // ALIAS REFUSAL (objectui#7694 — option A of the `domain:ui` PM ruling on + // objectui#7546 / the contract review of PR #7684). `chartType` is the + // renderer's INTERNAL spelling of `type` above — the first limb of + // `normalizeSeries`' `str(raw.chartType) ?? str(raw.type)` + // (`normalizeChartSchema.ts:244`) — written by the internal-shape producers + // that hand `dataKey`-shaped arrays straight to `ChartRenderer`, and by + // nothing on this authoring face: re-measured at implementation time with + // lit controls (docs 0, fixtures 0, designer inputs 0, src literals 0, + // tests 9 — all internal-shape; the limb's ablation left 304 files / 5817 + // tests green while its `type` sibling went 2 red). The TS twin's docblock + // carries the numbers. This object is NON-STRICT, so until now an authored + // `chartType` was STRIPPED in silence and the series drew in the chart's own + // family — precisely what the author was overriding. Now it is DECLARED and + // unwritable, refusing by name in the spec's own posture (`ChartSeriesSchema` + // lists it as an alias of `type`: "Did you mean `chartType` → `type`?"). + // Not a fold: when both are written the document is refused rather than one + // key silently winning — the renderer takes `chartType` FIRST, so a fold + // would invert the objectui#7113 precedence rule. Not a second writable + // name: that is the N-dialects hazard of AGENTS.md #0.1. + chartType: aliasKeyRefusal( + 'chartType', + 'type', + 'this chart series', + '`chartType` is the renderer\'s INTERNAL spelling of the declared `type` (objectui#7694): ' + + '`@objectstack/spec`\'s `ChartSeriesSchema` lists it as an alias of `type` and refuses it the ' + + 'same way, and nothing on this authoring face writes it. Write `type` (`bar` | `line` | `area`) ' + + 'for a per-series family override. Until this refusal an authored `chartType` was STRIPPED in ' + + 'silence by this non-strict object, so the series drew in the chart\'s own family — precisely ' + + 'what the author was overriding.', + ), color: z.string().optional().describe('Series color'), // THE SIX KEYS THE RENDERER READS (objectui#7546). Each was undeclared, and // because this object is NON-STRICT the mirror STRIPPED it in silence while @@ -419,12 +449,8 @@ export const ChartDataSeriesSchema = z.object({ // spec's `ChartSeriesSchema` members under the same names; the TS twin's // docblocks carry the read sites and the liveness measurement. // - // ⛔ `chartType` is NOT among them — deliberately. It is the renderer's - // INTERNAL spelling of `type` (the first limb of - // `str(raw.chartType) ?? str(raw.type)`), the spec refuses it by name as an - // alias of `type`, and nothing on this authoring face writes it. Declaring it - // is a contract decision for its own card; `chart-series-keys-7546.test.ts` - // pins the gap so it stays visible. + // `chartType` is NOT among the six: it is an ALIAS REFUSAL ARM, declared + // beside `type` above (objectui#7694). label: I18nLabelSchema.optional().describe( 'Legend / tooltip name for this series — a plain string or an inline locale map; defaults to the column key', ), diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index 8eaed046ff..54c35fbefc 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -7,7 +7,8 @@ */ /** - * @object-ui/types/zod - ADR-0049 retirement tombstone helper + * @object-ui/types/zod - ADR-0049 retirement tombstone helper, and its two + * named-refusal siblings (handler keys, alias spellings) * * @module zod/tombstone * @packageDocumentation @@ -120,3 +121,58 @@ export function handlerKeyRefusal(key: string, disposition: HandlerKeyDispositio const guidance = `${label ? `${label} — ` : ''}${what} ${remedy}`; return z.custom(() => false, { error: guidance }).optional().describe(guidance); } + +/** + * Declare a NAMED ALIAS REFUSAL ARM: a key that is a sibling SPELLING of a + * declared member — never a member itself — kept declared and unwritable so an + * authored value is refused by name and pointed at the canonical spelling, + * instead of being STRIPPED in silence by a non-strict `z.object` + * (objectui#7694, `domain:ui` PM ruling on objectui#7546: option A). The lead + * sentence is the one `@objectstack/spec`'s `strictObject({ aliases })` answers + * with, so an author meets the same remedy on both faces: + * + * Unrecognized key(s) on this chart series: `chartType`. Did you mean + * `chartType` → `type`? … + * + * The third member of this file's family, and deliberately neither of the + * other two by NAME — while sharing {@link retirementTombstone}'s PRIMITIVE: + * + * - not {@link retirementTombstone} by name: that retires a key the contract + * once declared (ADR-0049) and its guidance is a migration note; an alias + * was never a member, and its guidance must carry the canonical spelling. + * A census of `retirementTombstone(` sites is a census of RETIRED keys, and + * an alias arm filed under it would miscount. + * - not {@link handlerKeyRefusal}: that says why JSON cannot author a + * function-valued key; an alias has a perfectly authorable value under the + * other name. And not its `z.custom` primitive either — measured: + * `z.toJSONSchema` THROWS on a `z.custom` arm ("Custom types cannot be + * represented in JSON Schema") and represents a `z.never` arm as + * `{ not: {} }` carrying the description. `z.toJSONSchema(ChartDataSeriesSchema)` + * succeeded before the first alias arm landed and goes on succeeding. + * - not a FOLD (`.overwrite()`, the shape `foldChartXAxisAlias` takes in + * `data-display.zod.ts`): a fold is only honest where the canonical key is + * the READER's first limb, so "canonical wins when both are written" restates + * a precedence already running. Where the reader takes the ALIAS first, a + * fold would let it overwrite the canonical key; the refusal is the shape + * there (objectui#7113's precedence rule, not inverted). + * + * Same discipline as its siblings: ONE string feeds BOTH author-facing + * channels — the parse-time issue message and the `.describe()` metadata — so + * they cannot drift apart. The issue `code` is `invalid_type` at the key's own + * path, `z.input` is `undefined`, so the TypeScript twin is a `?: never` + * tombstone and the pair does not drift in `zod-mirror-parity.test.ts`. + * Pinned in `../__tests__/chart-series-chart-type-alias-refusal-7694.test.ts`. + * + * @param alias the refused spelling, spelled into the message so the issue + * is addressed even when read without its path + * @param canonical the declared member the author meant + * @param surface the object being authored, phrased as the spec phrases it + * (`'this chart series'`) + * @param detail the site's own reason and remedy — why the alias is not a + * member HERE, and what to write instead + */ +export function aliasKeyRefusal(alias: string, canonical: string, surface: string, detail: string) { + const guidance = + `Unrecognized key(s) on ${surface}: \`${alias}\`. Did you mean \`${alias}\` → \`${canonical}\`? ${detail}`; + return z.never({ error: guidance }).optional().describe(guidance); +} From 3929d10360d6beba53a61279232b4c4617a108f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:45:10 +0000 Subject: [PATCH 2/2] docs(types): correct the alias-refusal rationale and derive pin (e) from the spec's live lead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land-prep for PR #7737, asked for by its contract review (VERDICT: PASS, not withdrawn). Text and one assertion only — no runtime or type behaviour moves. - `tombstone.zod.ts`: the `aliasKeyRefusal` docblock argued that a census of `retirementTombstone(` sites is a census of keys the contract once declared. Re-measured on this merged head, that partition is false: `MenuItemSchema.type` (`overlay.zod.ts:196`, objectui#6523) is a `retirementTombstone` whose own guidance reads "`type` ('separator' or 'label') was an undeclared spelling two renderers used to read and is now a declared refusal, not a strip", and `overlay.ts:458-464` states it again ("a spelling the type never declared"). The docblock now cites that precedent and names the real differentiator — the composed spec sentence: a vocabulary, not a shape. - pin block (e): it compared ours and the spec's message each to a constant and never to each other, so a spec reword of the lead would have left both green while the faces diverged. The expected lead is now derived from the installed spec's live message at assert time, guarded so the cut still contains the remedy. The cut is unchanged — lead + `Did you mean …?`, excluding the spec's trailing clause (91 bytes, byte-equal to ours on 17.2.0). - changeset: names the parents the narrowing propagates through (`ChartSchema.series`, `ReportSectionSchema.chart`, reached via `safeValidateSchema` and `objectui check` / `validate`) and the io-mode nuance — `io:'input'` narrows; `io:'output'` already emitted `additionalProperties: false` on the base. Both measured on this head. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- ...4-chart-series-chart-type-alias-refusal.md | 17 ++++++++++- ...ries-chart-type-alias-refusal-7694.test.ts | 30 +++++++++++++++---- packages/types/src/zod/tombstone.zod.ts | 21 +++++++++---- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.changeset/7694-chart-series-chart-type-alias-refusal.md b/.changeset/7694-chart-series-chart-type-alias-refusal.md index e623e4ddd5..de2f03b395 100644 --- a/.changeset/7694-chart-series-chart-type-alias-refusal.md +++ b/.changeset/7694-chart-series-chart-type-alias-refusal.md @@ -25,6 +25,14 @@ it is named here in the words a release reader can act on: widened assignment are `tsc` errors. - **Both written** (`{ type: 'bar', chartType: 'line' }`) is refused at `chartType` alone — the key is not folded onto `type` and no precedence is minted between the two spellings. +- **Which documents to scan.** The narrowing does not stop at `ChartDataSeriesSchema`; it reaches + every document through the parents that embed it — `ChartSchema.series` + (`zod/data-display.zod.ts:622`, `z.array(ChartDataSeriesSchema)`) and, one level further out, + `ReportSectionSchema.chart` (`zod/reports.zod.ts:105`, `ChartSchema.optional()`). Authors meet it + through `safeValidateSchema()` (`zod/index.zod.ts:434`, which parses `AnyComponentSchema`) and + through the CLI's `objectui check` and `objectui validate` commands (`packages/cli/src/cli.ts:211` + and `:223`). In practice: every `chart` node's `series[]`, and every report section whose `chart` + carries one. This repository's `major` is a cross-repo pin to `@objectstack`'s major, not a severity dial; the break is announced here, which is the channel that carries it. @@ -55,7 +63,14 @@ The new `aliasKeyRefusal()` helper (`zod/tombstone.zod.ts`, internal — not re- `handlerKeyRefusal`'s `z.custom`: measured, `z.toJSONSchema` throws on a `z.custom` arm ("Custom types cannot be represented in JSON Schema") and represents a `z.never` arm as `{ not: {} }` with its description. `z.toJSONSchema(ChartDataSeriesSchema)` succeeded before this change and still does — -it now lists `chartType` as a refused property carrying the guidance. +it now lists `chartType` as a refused property carrying the guidance, 11 properties to 12. + +⚠️ The two io modes are not affected alike, measured on this tree. In `io: 'input'` the emitted +object carries no `additionalProperties` at all, so the accept set genuinely narrows there: +`chartType` goes from unmentioned to a property that nothing satisfies. In `io: 'output'` — the +default, and what a bare `z.toJSONSchema(…)` emits — the base **already** emitted +`additionalProperties: false`, so the key was outside the accept set before this change; what that +mode gains is the NAMED refusal and its guidance, not a narrower accept set. ## Unchanged, deliberately diff --git a/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts b/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts index c8f82858ea..45bd1ed93b 100644 --- a/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts +++ b/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts @@ -214,12 +214,32 @@ describe('objectui#7694 — `@objectstack/spec` takes the same posture, measured if (r.success) expect(r.data).toHaveProperty('type', 'line'); }); - it('this mirror answers with the spec\'s own lead — "Unrecognized key(s) on this chart series" — so one remedy meets the author on both faces', () => { + it('this mirror answers with the spec\'s OWN lead, derived from the installed spec at assert time — one remedy meets the author on both faces', () => { + // ⚠️ Deliberately NOT two comparisons against a constant, which is what + // this assertion held before: ours matched a literal and the spec's + // matched a regex, never each other, so a spec reword of the lead left + // BOTH green while the two faces silently diverged. The expected lead is + // read off the spec's LIVE message here, so the pin reddens exactly when + // they stop matching. + const spec = SpecChartSeriesSchema.safeParse(AUTHORED); + expect(spec.success).toBe(false); + if (spec.success) return; + const specMessage = spec.error.issues[0]!.message; + + // THE CUT: the spec's lead through its `Did you mean …?` remedy, and no + // further. Measured on the installed 17.2.0 that cut is 91 bytes and is + // byte-equal to ours. The clause after the `?` is the spec's own (a #4001 + // note) and is excluded on purpose — 17.3.0 rewords only that clause, so + // pinning it would redden this repo on a spec bump for no author-facing + // gain. + const cut = specMessage.indexOf('?') + 1; + const SHARED_LEAD = specMessage.slice(0, cut); + // Guard the cut itself: if a reword moves the first `?` off the remedy, + // this fails rather than quietly pinning a shorter, weaker prefix. + expect(SHARED_LEAD).toMatch(DID_YOU_MEAN); + const [ours] = issuesOf(AUTHORED)!; - expect(ours.message.startsWith('Unrecognized key(s) on this chart series: `chartType`.')).toBe(true); - // The rest of the spec's sentence is its own (`history`, a #4001 note) and - // is deliberately NOT pinned byte-for-byte: a spec reword must not turn an - // objectui pin red. The remedy fragment is the contract. + expect(ours.message.startsWith(SHARED_LEAD)).toBe(true); }); }); diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index 54c35fbefc..d431a1f9b2 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -137,11 +137,22 @@ export function handlerKeyRefusal(key: string, disposition: HandlerKeyDispositio * The third member of this file's family, and deliberately neither of the * other two by NAME — while sharing {@link retirementTombstone}'s PRIMITIVE: * - * - not {@link retirementTombstone} by name: that retires a key the contract - * once declared (ADR-0049) and its guidance is a migration note; an alias - * was never a member, and its guidance must carry the canonical spelling. - * A census of `retirementTombstone(` sites is a census of RETIRED keys, and - * an alias arm filed under it would miscount. + * - not {@link retirementTombstone} by name: that helper's guidance is a + * MIGRATION NOTE for a key the contract is withdrawing (ADR-0049), while an + * alias arm's guidance has to carry the canonical spelling instead. + * ⚠️ What separates the two is NOT declaration history. Measured on this + * tree: `MenuItemSchema.type` (`overlay.zod.ts:196`, objectui#6523) is a + * `retirementTombstone` whose own guidance reads "`type` ('separator' or + * 'label') was an undeclared spelling two renderers used to read and is now + * a declared refusal, not a strip", and `overlay.ts:458-464` states it + * again ("a spelling the type never declared"). A never-declared, + * renderer-read spelling turned into a named refusal pointing at the + * canonical key therefore ALREADY had a precedent in this package, and it + * was filed under `retirementTombstone`. + * What is new here is the MESSAGE: this helper composes the lead sentence + * `@objectstack/spec`'s `strictObject({ aliases })` answers with, so one + * remedy meets the author on both faces. Same `z.never` primitive, same + * `invalid_type` code, a three-line composer — a VOCABULARY, not a shape. * - not {@link handlerKeyRefusal}: that says why JSON cannot author a * function-valued key; an alias has a perfectly authorable value under the * other name. And not its `z.custom` primitive either — measured: