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 000000000..de2f03b39 --- /dev/null +++ b/.changeset/7694-chart-series-chart-type-alias-refusal.md @@ -0,0 +1,93 @@ +--- +'@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. +- **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. + +## 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, 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 + +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 d0a817e4c..94c9fa590 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 bf2680f6b..14406b0d4 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 000000000..45bd1ed93 --- /dev/null +++ b/packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts @@ -0,0 +1,271 @@ +/** + * 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, 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(SHARED_LEAD)).toBe(true); + }); +}); + +/* ── (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 01a1a24a5..2c1ed226a 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 e3ef905ac..9fb073280 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 5657e60c8..072cb1791 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 8eaed046f..d431a1f9b 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,69 @@ 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 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: + * `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); +}