From ab88df743d2b18fb5ffb7a4022b08b29aff5d5f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:36:06 +0000 Subject: [PATCH 1/7] test(types): pin that a retired `timeScale` document is refused, shown red first The pin precedes its fix on purpose: on this tree `timeScale` is still `TimelineScaleSchema.optional()`, so the timeScale-only document parses green (vitest: 1 failed | 4 passed) and the compile-time half reports TS2578 on the unused directive. Both legs are the red the retirement turns green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../timeline-timescale-retired.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/types/src/__tests__/timeline-timescale-retired.test.ts diff --git a/packages/types/src/__tests__/timeline-timescale-retired.test.ts b/packages/types/src/__tests__/timeline-timescale-retired.test.ts new file mode 100644 index 0000000000..ed10d91f5a --- /dev/null +++ b/packages/types/src/__tests__/timeline-timescale-retired.test.ts @@ -0,0 +1,126 @@ +/** + * 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. + */ + +/** + * Retirement pin — the `timeScale` alias is REFUSED, not silently defaulted + * (objectui#6355). + * + * ## The failure this pin exists to prevent + * + * `timeScale` was this renderer's pre-spec spelling of the gantt axis bucket. + * `scale` is canonical (objectui#6170 maintainer ruling 2026-08-25: it is + * `@objectstack/spec` `ui/TimelineConfig.json`'s axis key AND the renderer's + * preferred read), and objectui#6355's ruling (2026-08-27) retires the alias + * immediately, with no phased window. + * + * Dropping `resolveTimelineScale`'s `?? schema.timeScale` fallback is the whole + * behavioural change, and on its own it is the WRONG shape: a stored document + * that spells only `timeScale` would stop being read and fall through to the + * renderer's historical `month` default. The chart silently changes bucket and + * NOTHING errors — the same class of silent axis breakage objectui#2942 closed, + * running in the other direction. + * + * So the deliverable is not "`scale` works". It is: **an old-spelling document + * is refused loudly at the authoring boundary.** That is what the two halves of + * the tombstone buy, and it is what this file pins. + * + * ## Why the tombstone, and not simply deleting the key + * + * `BaseSchema` is `.passthrough()` on the Zod side and carries a + * `[key: string]: any` index signature on the TS side (objectui#5155 / + * objectui#6269 own that ceiling). An UNDECLARED key is therefore accepted by + * both halves, unvalidated. Deleting `timeScale` outright would hand the old + * spelling exactly the silent no-op this card exists to prevent: it would parse + * green, type-check green, and do nothing. + * + * Keeping the key declared as `?: never` / `z.never().optional()` is what makes + * the retirement audible. That is this package's tombstone convention — + * {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm` + * (objectui#4314) — and it is lockstep: both halves or neither, because either + * half alone leaves the other surface silently accepting the retired spelling. + * `absent` stays valid on both halves, so every document that never wrote the + * alias is untouched. + */ + +import { describe, it, expect } from 'vitest'; +import { TimelineSchema } from '../zod/data-display.zod.js'; +import type { TimelineSchema as TimelineSchemaTS } from '../data-display.js'; + +/** The exact document class this card protects: gantt, axis spelled the OLD way, no `scale`. */ +const TIMESCALE_ONLY_DOCUMENT = { + type: 'timeline', + variant: 'gantt', + timeScale: 'day', + rowLabel: 'Projects', + items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }], +} as const; + +describe('timeScale is RETIRED — the old spelling is refused, not silently defaulted (objectui#6355)', () => { + it('REFUSES a timeScale-only document, naming the retired key', () => { + // The pin. Before the retirement this document parsed GREEN (`timeScale` + // was `TimelineScaleSchema.optional()`), which is why this assertion is red + // on the pre-fix tree. Asserting the ENVELOPE — not merely `success:false` — + // so the pin cannot be satisfied by an unrelated rejection: a document that + // failed for its `items` shape would read identically otherwise. + const result = TimelineSchema.safeParse(TIMESCALE_ONLY_DOCUMENT); + expect(result.success, 'a timeScale-only document was ACCEPTED — it will silently revert to the month default').toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === 'timeScale'); + expect(issue, 'parse failed, but not on the `timeScale` path').toBeTruthy(); + expect(issue?.code).toBe('invalid_type'); + expect((issue as { expected?: string } | undefined)?.expected).toBe('never'); + }); + + it('ACCEPTS the same document migrated to the canonical `scale`', () => { + // Counter-probe. Without it the assertion above is satisfied by any schema + // that refuses everything, and the pin would prove nothing about the KEY. + const { timeScale: _retired, ...rest } = TIMESCALE_ONLY_DOCUMENT; + const result = TimelineSchema.safeParse({ ...rest, scale: 'day' }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('leaves a document that never wrote the alias untouched', () => { + // `absent` stays valid — `.optional()` on the tombstone. The retirement + // narrows exactly one spelling and nothing else. + expect(TimelineSchema.safeParse({ type: 'timeline' }).success).toBe(true); + expect(TimelineSchema.safeParse({ type: 'timeline', variant: 'gantt', scale: 'quarter' }).success).toBe(true); + }); + + it('keeps `timeScale` DECLARED — a tombstone, not a deletion', () => { + // The route guard, and the reason this is a tombstone at all. `BaseSchema` + // is `.passthrough()`, so removing the key from the mirror would make the + // old spelling parse green again and do nothing — the silent reversion, + // reintroduced by the very edit meant to remove it. + expect( + Object.keys(TimelineSchema.shape), + 'timeScale left the mirror — under .passthrough() the retired spelling becomes a SILENT no-op', + ).toContain('timeScale'); + }); +}); + +describe('timeScale is RETIRED — the TS half of the tombstone (objectui#6355)', () => { + it('refuses the retired spelling at compile time', () => { + // The mirror twin's compile-time half. On the pre-fix tree `timeScale` is + // `TimelineScale | undefined`, so `'month'` is a LEGAL assignment, the + // directive below is unused, and `tsc` fails the build with TS2578 naming + // the key — this leg is red before the fix in `type-check`, not in vitest, + // which strips types. `tsconfig.test.json` compiles this file, so the + // directive is real enforcement (objectui#3009). + + // @ts-expect-error — `timeScale` is RETIRED (objectui#6355): declared `?: never`, so no value is authorable. + const retired: TimelineSchemaTS['timeScale'] = 'month'; + + // Counter-probe on the same surface: the canonical key still accepts the + // whole vocabulary, so the directive above pins the KEY's retirement and + // not a blanket narrowing of the node. + const canonical: TimelineSchemaTS['scale'] = 'month'; + + expect([retired, canonical]).toHaveLength(2); + }); +}); From 7fd9667d1f976947ebe4298a96cf5d34bc29a920 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:49:56 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(plugin-timeline)!:=20retire=20the=20`t?= =?UTF-8?q?imeScale`=20alias=20=E2=80=94=20`scale`=20is=20the=20only=20axi?= =?UTF-8?q?s=20spelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling objectui#6355 (2026-08-27): immediate retirement, no phased window, while the project is at startup stage. `timeScale` was this renderer's pre-spec spelling of the gantt axis bucket; `scale` is canonical (@objectstack/spec ui/TimelineConfig.json, and the key the renderer already preferred). The alias is tombstoned on BOTH halves in lockstep — `?: never` on `TimelineSchema` and `z.never().optional()` on the Zod twin — rather than deleted. BaseSchema is .passthrough() with a `[key: string]: any` index signature, so deleting the key would let the retired spelling parse and type-check green while the renderer no longer read it: the gantt axis would silently fall back to the month default and nothing would error. The tombstone is what makes the removal audible. Also: resolveTimelineScale drops the `?? schema.timeScale` fallback; the designer drops its deprecated timeScale input; ObjectTimeline now composes `scale` (it wrote the alias, which would have silently reverted every object-bound gantt); both in-repo authors and the docs are migrated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../6355-retire-timeline-timescale-alias.md | 57 ++++++++++++++++++ content/docs/plugins/plugin-timeline.mdx | 22 ++++--- .../plugin-timeline/gantt-style-timeline.json | 2 +- .../src/renderers/complex/TIMELINE.md | 4 +- .../plugin-timeline/src/ObjectTimeline.tsx | 12 +++- .../timeline-scale-spec-parity.test.ts | 44 +++++++++++--- packages/plugin-timeline/src/renderer.tsx | 60 +++++++++---------- .../__tests__/timeline-declared-keys.test.ts | 58 +++++++++++------- packages/types/src/data-display.ts | 38 ++++++++---- packages/types/src/zod/data-display.zod.ts | 14 ++++- 10 files changed, 229 insertions(+), 82 deletions(-) create mode 100644 .changeset/6355-retire-timeline-timescale-alias.md diff --git a/.changeset/6355-retire-timeline-timescale-alias.md b/.changeset/6355-retire-timeline-timescale-alias.md new file mode 100644 index 0000000000..67cf90ac23 --- /dev/null +++ b/.changeset/6355-retire-timeline-timescale-alias.md @@ -0,0 +1,57 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/components': minor +'@object-ui/types': minor +--- + +Retire the `timeScale` alias on the timeline node — `scale` is the only axis spelling +(objectui#6355, maintainer ruling 2026-08-27). + +**BREAKING for authored metadata.** `timeScale` was this renderer's pre-spec spelling of the +Gantt axis bucket. `scale` is canonical — it is `@objectstack/spec` `ui/TimelineConfig.json`'s +axis key and the key the renderer preferred (objectui#6170 ruling, 2026-08-25: `timeScale` +goes the alias-retirement route, not a silent second spelling). objectui#6355's ruling +retires it immediately, with no phased window, while the project is at startup stage. + +**What breaks, and how you will find out.** A timeline document that spells `timeScale` is +now **refused**, loudly, at the authoring boundary: + +- `TimelineSchema.timeScale` is declared `?: never` — writing it is a type error; +- the Zod twin declares `z.never().optional()` — parsing a document that carries the key + fails with `invalid_type` / `expected: never` on the `timeScale` path. + +The fix is a rename: `timeScale` → `scale`. The accepted values are unchanged (`hour`, +`day`, `week`, `month`, `quarter`, `year`), so no value needs rewriting. + +**Why a tombstone rather than deleting the key.** `BaseSchema` is `.passthrough()` on the +Zod side and carries `[key: string]: any` on the TS side, so an *undeclared* key is accepted +unvalidated by both halves. Deleting `timeScale` outright would have let the retired spelling +parse green and type-check green while the renderer no longer read it — the Gantt axis would +silently fall back to the `month` default, the chart would change bucket, and nothing would +error. That is the silent axis breakage objectui#2942 closed, running in the other direction, +and it is the specific outcome this retirement is shaped to prevent. Keeping the key declared +as `never` on both halves is what makes the removal audible. Absent stays valid on both, so a +document that never wrote the alias is untouched. + +Also in this change: + +- `resolveTimelineScale` drops the `?? schema.timeScale` fallback read; its parameter narrows + to `{ scale?: unknown }`. +- The designer drops its deprecated `timeScale` input. The `scale` input already offers all + six buckets. +- `ObjectTimeline` now emits the resolved axis under `scale` when it composes the schema it + hands to the renderer. It previously wrote the alias, which would have made **every** + object-bound Gantt fall through to the `month` default the moment the fallback read went — + silently, since that is a composed schema no author ever sees. Writing `scale` after the + spread also restores the precedence the surrounding code intends: a `timelineConfig.scale` + now actually beats a flat `schema.scale`, where under the alias the resolver's + `scale ?? timeScale` ordering let the flat key win. +- The two in-repo authors are migrated in the same change: the schema-catalog + `gantt-style-timeline.json` fixture and the registration's own `examples.gantt` block. +- Docs drop the `timeScale` row and gain a retirement callout; + `packages/components/.../TIMELINE.md`'s Gantt table now documents `scale` with the full + six-value vocabulary it has accepted since objectui#2942 (its row still claimed three). + +Version note: `minor`, not `major`, per AGENTS.md §版本号策略 — objectui's major tracks the +`@objectstack` major and all publishable packages share one `fixed` group, so a breaking +narrowing is declared `minor` with the break spelled out here. diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx index ed89574354..fffb5eacb1 100644 --- a/content/docs/plugins/plugin-timeline.mdx +++ b/content/docs/plugins/plugin-timeline.mdx @@ -73,7 +73,6 @@ const schema = { dateFormat?: 'short' | 'long' | 'iso', // Gantt-specific scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year', - timeScale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year', // deprecated alias of `scale` rowLabel?: string, minDate?: string, maxDate?: string, @@ -117,7 +116,6 @@ const schema = { | `items` | array | `[]` | Array of timeline items | | `dateFormat` | string | `'short'` | Date formatting: short, long, or iso | | `scale` | string | `'month'` | Gantt axis bucket: hour, day, week, month, quarter, or year | -| `timeScale` | string | — | **Deprecated** — the pre-spec spelling of `scale`, still read as a fallback | | `rowLabel` | string | `'Items'` | Label for Gantt rows | | `minDate` | string | auto | Override min date for Gantt (YYYY-MM-DD) | | `maxDate` | string | auto | Override max date for Gantt (YYYY-MM-DD) | @@ -125,8 +123,16 @@ const schema = { Every key above is declared on the exported `TimelineSchema`, so an editor completes them and a wrong value is a type error. `scale` is the canonical axis -key — it is `@objectstack/spec`'s `ui/TimelineConfig.json` spelling and the one -the renderer reads first (`scale ?? timeScale`). +key and the only one — it is `@objectstack/spec`'s `ui/TimelineConfig.json` +spelling and the one the renderer reads. + + +`timeScale`, the pre-spec spelling of `scale`, is **retired** (objectui#6355). +It is no longer read, and it is no longer authorable: the key is tombstoned on +`TimelineSchema`, so writing it is a type error and a validation failure rather +than a chart that quietly falls back to the `month` default. Rename the key to +`scale` — the accepted values are unchanged. + `events`, `orientation` and `position` are also declared on `TimelineSchema` @@ -195,7 +201,7 @@ const roadmap = { const schedule = { type: 'timeline', variant: 'gantt', - timeScale: 'week', + scale: 'week', rowLabel: 'Teams', items: [ { @@ -291,13 +297,13 @@ For Gantt-style timelines, choose the appropriate time scale: ```tsx // Day scale - for short projects -{ timeScale: 'day' } +{ scale: 'day' } // Week scale - for medium projects -{ timeScale: 'week' } +{ scale: 'week' } // Month scale - for long projects -{ timeScale: 'month' } +{ scale: 'month' } ``` ## Customization diff --git a/examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json b/examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json index 508f1e4392..9b078eeab9 100644 --- a/examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json +++ b/examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json @@ -2,7 +2,7 @@ "type": "timeline", "variant": "gantt", "dateFormat": "short", - "timeScale": "month", + "scale": "month", "rowLabel": "Projects", "items": [ { diff --git a/packages/components/src/renderers/complex/TIMELINE.md b/packages/components/src/renderers/complex/TIMELINE.md index 0454aa5e9f..e6f689db27 100644 --- a/packages/components/src/renderers/complex/TIMELINE.md +++ b/packages/components/src/renderers/complex/TIMELINE.md @@ -113,7 +113,7 @@ Perfect for project management, resource planning, and multi-track timelines. "type": "timeline", "variant": "gantt", "dateFormat": "short", - "timeScale": "month", + "scale": "month", "rowLabel": "Project Tasks", "items": [ { @@ -216,7 +216,7 @@ Perfect for project management, resource planning, and multi-track timelines. | Prop | Type | Default | Description | |------|------|---------|-------------| -| `timeScale` | `'day' \| 'week' \| 'month'` | `'month'` | Time scale for the timeline header | +| `scale` | `'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` | `'month'` | Time scale for the timeline header. The pre-spec `timeScale` spelling is retired (objectui#6355) | | `rowLabel` | `string` | `'Items'` | Label for the row header column | | `minDate` | `string` | Auto-calculated | Override minimum date (YYYY-MM-DD) | | `maxDate` | `string` | Auto-calculated | Override maximum date (YYYY-MM-DD) | diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index f28b5f0c9e..d0604c86b3 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -401,8 +401,16 @@ export const ObjectTimeline: React.FC = ({ ...schema, items: effectiveItems || [], className: className || schema.className, - // Map spec 'scale' to renderer 'timeScale' (used by gantt variant) - ...(resolvedScale ? { timeScale: resolvedScale } : {}), + // Emit the resolved axis under the canonical `scale` (used by the gantt + // variant). This used to write the `timeScale` alias, which objectui#6355 + // retired: leaving it would have made EVERY object-bound gantt fall + // through to the `month` default the moment `resolveTimelineScale` stopped + // reading the alias — silently, since this is a composed schema no author + // ever sees. Writing `scale` after the spread also restores the priority + // the line above intends: a `timelineConfig.scale` now actually beats a + // flat `schema.scale`, where under the alias the resolver's `scale ?? + // timeScale` ordering let the flat key win. + ...(resolvedScale ? { scale: resolvedScale } : {}), onItemClick: (item: any) => { const record = item._data || item; navigation.handleClick(record); diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts index b4dd49d2c4..6ad340a8ba 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts +++ b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts @@ -13,6 +13,12 @@ * spec's `scale` (`TimelineConfigSchema`) was ignored for ALL six values; * and the header generator only knew month/week/day, so `hour` / `quarter` / * `year` produced zero header columns — a blank gantt axis. + * + * #2942 fixed the first drift by preferring `scale` and keeping `timeScale` as + * a fallback. objectui#6355 finishes it: the alias is RETIRED, so `scale` is + * the single axis spelling here. The refusal that keeps that retirement from + * being a silent revert is pinned in `@object-ui/types` + * (`__tests__/timeline-timescale-retired.test.ts`). */ import { describe, it, expect } from 'vitest'; import { TimelineConfigSchema } from '@objectstack/spec/ui'; @@ -43,21 +49,43 @@ describe('timeline covers the spec scale vocabulary', () => { }); }); -describe('resolveTimelineScale honors the spec key first', () => { +describe('resolveTimelineScale reads the spec key, and only it', () => { it('reads the spec `scale` key', () => { expect(resolveTimelineScale({ scale: 'quarter' })).toBe('quarter'); }); - it('keeps the legacy `timeScale` dialect for stored JSON', () => { - expect(resolveTimelineScale({ timeScale: 'day' })).toBe('day'); + it("absent/unknown values keep the renderer's historical month default", () => { + expect(resolveTimelineScale({})).toBe('month'); + expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month'); }); - it('the spec key wins when both are present', () => { - expect(resolveTimelineScale({ scale: 'year', timeScale: 'day' })).toBe('year'); + it('no longer reads the RETIRED `timeScale` alias (objectui#6355)', () => { + // This replaces two assertions that pinned the alias branch — "keeps the + // legacy timeScale dialect" and "the spec key wins when both are present". + // Neither could survive the retirement honestly: the first pinned exactly + // the branch being deleted, and the second would have kept passing for a + // NEW reason (the alias ignored outright rather than losing a precedence + // contest), which is a pin that reads green while measuring nothing. + // + // The reversion below is REAL and is the accepted cost of the ruling + // (2026-08-27: immediate retirement, no phased window, startup stage). It + // is not left silent — that is the whole point of the retirement's other + // half. `@object-ui/types` tombstones the key on BOTH surfaces + // (`TimelineSchema.timeScale?: never` and the Zod twin's `z.never()`), so a + // document that still spells it is refused at the authoring boundary before + // it can ever reach this resolver and quietly re-bucket the chart. The pin + // for that refusal lives in + // `packages/types/src/__tests__/timeline-timescale-retired.test.ts`. + // A stored document exactly as it sits on disk today: axis spelled the old way. + const storedDocument: Record = { variant: 'gantt', timeScale: 'day' }; + expect(resolveTimelineScale(storedDocument)).toBe('month'); }); - it("absent/unknown values keep the renderer's historical month default", () => { - expect(resolveTimelineScale({})).toBe('month'); - expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month'); + it('the retired alias cannot override an authored `scale`', () => { + // Counter-probe: the canonical key still wins, and still works, on a + // document that carries both. Without this the assertion above is satisfied + // by a resolver that returns 'month' for everything. + const bothSpellings: Record = { variant: 'gantt', scale: 'year', timeScale: 'day' }; + expect(resolveTimelineScale(bothSpellings)).toBe('year'); }); }); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 0ddf84dd9d..d3d718f7f5 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -46,16 +46,30 @@ export const TIMELINE_SCALES: ReadonlySet = new Set([ ]); /** - * Resolve the axis scale for the gantt variant. The spec key is `scale`; - * `timeScale` is this renderer's pre-spec dialect, kept for stored JSON — - * before #2942 ONLY `timeScale` was read, so every spec-authored `scale` - * (all six values) was silently ignored. An absent/unknown value keeps the - * renderer's historical `month` default. The `vertical` / `horizontal` - * variants are sequential event feeds with no time axis, so `scale` has - * nothing to bucket there by construction. + * Resolve the axis scale for the gantt variant. `scale` is the ONLY axis key — + * it is `@objectstack/spec` `ui/TimelineConfig.json`'s spelling. + * + * The `timeScale` alias this used to fall back to (`scale ?? timeScale`) is + * RETIRED (objectui#6355, maintainer ruling 2026-08-27: immediate retirement, + * no phased window, while the project is at startup stage). It was this + * renderer's pre-spec dialect: before #2942 ONLY `timeScale` was read, so every + * spec-authored `scale` was silently ignored — this function was the fix, and + * dropping the alias half completes it. + * + * A document that still spells `timeScale` no longer reaches this function with + * an axis, and would fall to the `month` default below. That reversion is NOT + * left silent: `@object-ui/types` tombstones the key on both halves + * (`TimelineSchema.timeScale?: never` and the Zod twin's `z.never()`), so the + * retired spelling is refused at the authoring boundary rather than quietly + * re-bucketing the chart. The tombstone is why this deletion is safe; the two + * ship together. + * + * An absent/unknown value keeps the renderer's historical `month` default. The + * `vertical` / `horizontal` variants are sequential event feeds with no time + * axis, so `scale` has nothing to bucket there by construction. */ -export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unknown }): string { - const raw = schema.scale ?? schema.timeScale; +export function resolveTimelineScale(schema: { scale?: unknown }): string { + const raw = schema.scale; return typeof raw === 'string' && TIMELINE_SCALES.has(raw) ? raw : 'month'; } @@ -411,11 +425,11 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time const minDate = schema.minDate || dateRange.minDate; const maxDate = schema.maxDate || dateRange.maxDate; - // Generate time scale headers — the spec `scale` key drives this - // (legacy `timeScale` kept for stored JSON); every spec scale produces - // a header row (#2942). + // Generate time scale headers — the spec `scale` key is the only axis + // spelling (the `timeScale` alias is retired, objectui#6355); every spec + // scale produces a header row (#2942). const timeHeaders = generateTimeScaleHeaders( - resolveTimelineScale(schema as { scale?: unknown; timeScale?: unknown }), + resolveTimelineScale(schema as { scale?: unknown }), minDate, maxDate, displayLocale, @@ -544,7 +558,8 @@ ComponentRegistry.register( defaultValue: 'short', }, // The designer's axis key is `scale` — the spec's spelling - // (`ui/TimelineConfig.json`) and the one `resolveTimelineScale` prefers. + // (`ui/TimelineConfig.json`) and, since objectui#6355 retired the + // `timeScale` alias, the only one `resolveTimelineScale` reads. // It offers all six buckets: `hour` / `quarter` / `year` have rendered // correctly since #2942 but were offered by neither the designer nor the // exported type, so they were authorable and undiscoverable (objectui#6170). @@ -555,21 +570,6 @@ ComponentRegistry.register( label: 'Time Scale (Gantt only)', defaultValue: 'month', }, - // Kept so a stored `timeScale` still round-trips through the designer. - // Deprecated in favour of `scale`; retiring the alias is routed separately - // (objectui#6170 maintainer ruling 2026-08-25). `ComponentInput` has no - // `deprecated` slot and no index signature, so the notice lives in - // `description` — this package's stated ceiling for anything the coarse - // `type` cannot express. - { - name: 'timeScale', - type: 'enum', - enum: [...TIMELINE_SCALES], - label: 'Time Scale (Gantt only)', - description: - 'DEPRECATED — use `scale`, which @objectstack/spec owns and this renderer reads first. Kept so stored JSON keeps working.', - advanced: true, - }, { name: 'rowLabel', type: 'string', @@ -676,7 +676,7 @@ ComponentRegistry.register( gantt: { variant: 'gantt', dateFormat: 'short', - timeScale: 'month', + scale: 'month', rowLabel: 'Projects', items: [ { diff --git a/packages/types/src/__tests__/timeline-declared-keys.test.ts b/packages/types/src/__tests__/timeline-declared-keys.test.ts index 114be7dca2..3117b694da 100644 --- a/packages/types/src/__tests__/timeline-declared-keys.test.ts +++ b/packages/types/src/__tests__/timeline-declared-keys.test.ts @@ -78,13 +78,25 @@ function unwrap(schema: any): any { return cur; } -/** The eight keys this card declared, each with a value its declared type refuses. */ +/** + * The keys this card declared, each with a value its declared type refuses. + * + * `timeScale` was the eighth. objectui#6355 RETIRED it, so it is no longer a + * declared key carrying a vocabulary — it is a `?: never` / `z.never()` + * tombstone, for which "refuses a wrong-typed value" and "accepts a well-typed + * value" are the wrong assertions in both directions: the first would pass for + * a reason that has nothing to do with this card, and the second cannot pass at + * all. Its retirement has its own pin, `timeline-timescale-retired.test.ts`, + * which also + * carries the counter-probes. Removing it here rather than editing its value in + * place is deliberate: it is the fixture that pinned the branch that was + * deleted. + */ const DECLARED: ReadonlyArray = [ ['variant', 'diagonal'], ['items', 'not-an-array'], ['dateFormat', 'medieval'], ['scale', 'fortnight'], - ['timeScale', 'fortnight'], ['rowLabel', 5], ['minDate', 20240101], ['maxDate', 20241231], @@ -134,7 +146,6 @@ describe('TimelineSchema — the eight presentational keys are declared (objectu items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }], dateFormat: 'iso', scale: 'quarter', - timeScale: 'month', rowLabel: 'Projects', minDate: '2024-01-01', maxDate: '2024-12-31', @@ -154,9 +165,10 @@ describe('TimelineSchema — the eight presentational keys are declared (objectu }); describe('TimelineSchema — `scale` is canonical and shares the spec vocabulary', () => { - // The renderer resolves `scale ?? timeScale` and accepts six values on either - // (`resolveTimelineScale`, pinned against the spec by - // `plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`). This is + // The renderer resolves `scale` and accepts six values (`resolveTimelineScale`, + // pinned against the spec by + // `plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`; the + // `timeScale` alias it used to also read is retired, objectui#6355). This is // the third leg of that agreement: the exported TYPE offers the same six. const specScales: string[] = unwrap(TimelineConfigSchema.shape.scale).options; @@ -164,19 +176,22 @@ describe('TimelineSchema — `scale` is canonical and shares the spec vocabulary expect(specScales, 'could not read TimelineConfigSchema.shape.scale options').not.toEqual([]); }); - it('`scale` and the deprecated `timeScale` alias accept exactly the spec vocabulary', () => { - for (const key of ['scale', 'timeScale'] as const) { - for (const value of specScales) { - const result = TimelineSchema.safeParse({ ...MINIMAL, [key]: value }); - expect(result.success, `${key} refused spec scale '${value}'`).toBe(true); - } + it('`scale` accepts exactly the spec vocabulary — and is the only key that does', () => { + // It used to loop over `['scale', 'timeScale']`. The alias is RETIRED + // (objectui#6355) and now refuses every one of these values; that half is + // pinned in `timeline-timescale-retired.test.ts`. + for (const value of specScales) { + const result = TimelineSchema.safeParse({ ...MINIMAL, scale: value }); + expect(result.success, `scale refused spec scale '${value}'`).toBe(true); } }); it('the registry `inputs` three-value timeScale enum is NOT the contract', () => { - // Before this card the designer offered `timeScale: day | week | month` and - // the type offered neither key. `hour` / `quarter` / `year` were authorable, - // rendered correctly, and were undiscoverable from both surfaces. + // Before objectui#6170 the designer offered `timeScale: day | week | month` + // and the type offered neither key. `hour` / `quarter` / `year` were + // authorable, rendered correctly, and were undiscoverable from both + // surfaces. That designer input is gone entirely now — objectui#6355 retired + // the alias and dropped its control; `scale` offers all six. for (const value of ['hour', 'quarter', 'year']) { expect(TimelineSchema.safeParse({ ...MINIMAL, scale: value }).success, value).toBe(true); } @@ -233,8 +248,12 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => { const dateFormat: TimelineSchemaTS['dateFormat'] = 'medieval'; // @ts-expect-error — `scale` is declared `TimelineScale | undefined`. const scale: TimelineSchemaTS['scale'] = 'fortnight'; - // @ts-expect-error — `timeScale` is the deprecated alias, same six values. - const timeScale: TimelineSchemaTS['timeScale'] = 'fortnight'; + // `timeScale` used to sit here as the deprecated alias with the same six + // values. It is RETIRED (objectui#6355) and its directive would now hold for + // a different reason — `never` refuses `'fortnight'` the way it refuses + // every value — so keeping it here would read as vocabulary enforcement + // while measuring the tombstone. The tombstone has its own pin, with its own + // counter-probe: `timeline-timescale-retired.test.ts`. // @ts-expect-error — `rowLabel` is declared `string | undefined`. const rowLabel: TimelineSchemaTS['rowLabel'] = 5; // @ts-expect-error — `minDate` is declared `string | undefined` (schemas are JSON). @@ -247,8 +266,8 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => { const position: TimelineSchemaTS['position'] = 'centre'; expect([ - variant, dateFormat, scale, timeScale, rowLabel, minDate, maxDate, orientation, position, - ]).toHaveLength(9); + variant, dateFormat, scale, rowLabel, minDate, maxDate, orientation, position, + ]).toHaveLength(8); }); it('accepts the well-typed value on every declared key', () => { @@ -260,7 +279,6 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => { items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }], dateFormat: 'iso', scale: 'quarter', - timeScale: 'month', rowLabel: 'Projects', minDate: '2024-01-01', maxDate: '2024-12-31', diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index c6d735d4dd..5365e337d9 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1395,7 +1395,10 @@ export type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'yea * purpose — it is a runtime slot `ObjectTimeline` installs when it composes * this schema, not authorable metadata, and this package's convention keeps * callback-shaped keys off the authored surface (see `RuntimeOnlyDeclared` in - * `__tests__/zod-mirror-parity.test.ts`). The registration's own `inputs` metadata and + * `__tests__/zod-mirror-parity.test.ts`). That measurement is a dated record + * and is kept as one: `timeScale` has since been RETIRED (objectui#6355, + * ruling 2026-08-27), so the read set is EIGHT keys today and `scale` is the + * sole axis spelling. The registration's own `inputs` metadata and * `content/docs/plugins/plugin-timeline.mdx`'s property table agreed with the * renderer all along; only this type disagreed — including with the docs * page's own TypeScript example, which did not compile, because `events` was @@ -1442,21 +1445,36 @@ export interface TimelineSchema extends BaseSchema { dateFormat?: 'short' | 'long' | 'iso'; /** * Gantt axis bucket size. **Canonical spelling** — it is `@objectstack/spec` - * `ui/TimelineConfig.json`'s axis key AND the renderer's preferred read - * (`resolveTimelineScale` resolves `scale ?? timeScale`). + * `ui/TimelineConfig.json`'s axis key AND the renderer's only read + * (`resolveTimelineScale`). The `timeScale` alias it used to fall back to is + * RETIRED (objectui#6355) and tombstoned below. * @default 'month' */ scale?: TimelineScale; /** - * Gantt axis bucket size — this renderer's pre-spec dialect, still read as a - * fallback so stored JSON keeps working. + * RETIRED (objectui#6355, maintainer ruling 2026-08-27) — this renderer's + * pre-spec dialect for the gantt axis bucket. Author {@link + * TimelineSchema.scale}, which `@objectstack/spec` owns and this renderer + * now reads exclusively. * - * @deprecated Use {@link TimelineSchema.scale}, which `@objectstack/spec` - * owns and this renderer prefers. Retiring the alias is routed separately - * (objectui#6170 maintainer ruling 2026-08-25: "`timeScale` goes the - * alias-retirement route, not a silent second spelling"). + * `?: never` is this package's tombstone convention (see {@link + * StaticTableColumn} objectui#5474, `crud.ts` `confirm` objectui#4314), and + * it is load-bearing rather than decorative. {@link BaseSchema} carries + * `[key: string]: any`, so DELETING this member would let the retired + * spelling type-check green and do nothing — the renderer no longer reads it, + * and the axis would silently fall back to the `month` default with no + * diagnostic. That is the silent axis breakage objectui#2942 closed, running + * in the other direction. Keeping the key declared as `never` is what makes + * the retirement audible at the authoring boundary. + * + * Lockstep with the Zod twin (`zod/data-display.zod.ts`, `z.never()`): both + * halves or neither, since either half alone leaves the other surface + * silently accepting the retired spelling. Absent stays valid on both, so a + * document that never wrote the alias is untouched. + * + * @deprecated RETIRED (objectui#6355) — author `scale` instead. */ - timeScale?: TimelineScale; + timeScale?: never; /** * Header label above the Gantt row-label gutter. * @default 'Items' diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index ac32525771..cc5fb86d55 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -362,6 +362,10 @@ const TimelineScaleSchema = z.enum(['hour', 'day', 'week', 'month', 'quarter', ' * have zero read points, but removing them is a breaking narrowing routed * through ADR-0049 rather than done here. `events` follows the declaration from * required to OPTIONAL — strictly more input parses than before. + * + * `timeScale` is RETIRED (objectui#6355) and carries the `z.never()` tombstone + * below — still mirrored, deliberately, because the parity ratchet compares key + * SETS and because a tombstone must be present on both halves to be audible. */ export const TimelineSchema = BaseSchema.extend({ type: z.literal('timeline'), @@ -369,7 +373,15 @@ export const TimelineSchema = BaseSchema.extend({ items: z.array(z.any()).optional().describe('Rows to draw — feed items, or gantt rows when variant is gantt'), dateFormat: z.enum(['short', 'long', 'iso']).optional().describe('How item dates are rendered'), scale: TimelineScaleSchema.optional().describe('Gantt axis bucket size (canonical spelling — the spec key)'), - timeScale: TimelineScaleSchema.optional().describe('DEPRECATED pre-spec alias for scale; still read as a fallback'), + // RETIRED (objectui#6355, ruling 2026-08-27): the pre-spec alias for `scale`. + // The TS twin (`../data-display.ts`) types it `?: never`; here any authored + // value is a loud parse rejection (absent stays valid), mirroring how + // `@objectstack/spec` retires keys and how `crud.zod.ts` retires `confirm`. + // NOT deletable: `BaseSchema` is `.passthrough()`, so dropping the key would + // let the retired spelling parse green while the renderer no longer reads it + // — a silent revert to the `month` default, which is the whole failure this + // retirement exists to make audible. One axis spelling: `scale` above. + timeScale: z.never().optional().describe('RETIRED (objectui#6355) — author scale instead'), rowLabel: z.string().optional().describe('Header label above the gantt row-label gutter'), minDate: z.string().optional().describe('Override the auto-calculated gantt axis start (YYYY-MM-DD)'), maxDate: z.string().optional().describe('Override the auto-calculated gantt axis end (YYYY-MM-DD)'), From e015ba3638950c8a5135e8d3e5d61949fac7a727 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:10:47 +0000 Subject: [PATCH 3/7] test(plugin-timeline): pin the three in-repo gantt axis authors on canonical `scale` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the schema-catalog fixture, the registration's examples.gantt, the designer inputs, and ObjectTimeline's composed schema — the last of which no grep of authored metadata would find, and the one that would have silently reverted every object-bound gantt to the month default. Each assertion goes through the real resolveTimelineScale rather than reading the key, so a rename that left the value unreachable cannot pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../ObjectTimeline.scaleComposition.test.tsx | 195 ++++++++++++++++++ .../timeline-inrepo-authors-migrated.test.tsx | 109 ++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/plugin-timeline/src/ObjectTimeline.scaleComposition.test.tsx create mode 100644 packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx diff --git a/packages/plugin-timeline/src/ObjectTimeline.scaleComposition.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.scaleComposition.test.tsx new file mode 100644 index 0000000000..6d153d49bc --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.scaleComposition.test.tsx @@ -0,0 +1,195 @@ +/** + * 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. + */ + +/** + * `ObjectTimeline` composes the gantt axis under the canonical `scale` + * (objectui#6355). + * + * ## Why this one needed its own pin + * + * `ObjectTimeline` resolves the axis (`timelineConfig?.scale ?? schema.scale`) + * and writes it into the schema it hands to `TimelineRenderer`. Until this card + * it wrote the RETIRED `timeScale` alias. + * + * That made it the highest-risk writer of the three the retirement had to + * migrate, and the only one no grep of authored metadata would find: it is a + * COMPOSED object, not a document anybody authors. Dropping + * `resolveTimelineScale`'s fallback read while leaving this write in place would + * have sent EVERY object-bound gantt — every one driven by a spec + * `timeline.scale` — back to the `month` default. Silently: the tombstone + * refuses documents at the authoring boundary, and this object never crosses + * one, so nothing would have errored and no fixture would have changed. + * + * ## What is asserted, and why it is not the string + * + * The captured object is run through `resolveTimelineScale` — the real read + * path, the actual function whose behaviour changed. Asserting `captured.scale + * === 'year'` alone would still pass if the resolver stopped reading `scale`; + * asserting the resolver's output makes the two halves agree or fail. + * + * `TimelineRenderer` is stubbed here ON PURPOSE, and the stub is the + * measurement rather than a shortcut around it: what is under test is the value + * of one key on the object crossing that boundary. (`timeline-date-binding.test.tsx` + * documents the opposite case — for DATE BUCKETING a stub hides the defect, + * because the evidence there lives in the markup the real renderer emits.) + * + * ## Why this file sits in `src/` and not in `src/__tests__/` + * + * It mocks `./renderer` with the SAME specifier `ObjectTimeline.tsx` itself + * uses, from the same directory — the arrangement `ObjectTimeline.test.tsx` + * already relies on. Written from `__tests__/` as `vi.mock('../renderer')` the + * stand-in did not install: the real `TimelineRenderer` rendered and every + * assertion failed on a missing stub marker. That is the inert-mock class + * `scripts/check-vi-mock-specifiers.mjs` exists for, and the reason + * `renderTimeline` below asserts the stub actually ran instead of trusting it. + */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { ObjectTimeline } from './ObjectTimeline'; + +/** + * Every schema handed to `TimelineRenderer` during a test. + * + * `vi.hoisted` because `vi.mock` factories are hoisted above module-level + * declarations: a plain `const` here is in its temporal dead zone when the + * factory runs, and the stand-in never installs — the suite then exercises the + * REAL renderer, which is the inert-mock failure `scripts/check-vi-mock-specifiers.mjs` + * documents one level down (a mock that reports the same green a working one does). + */ +const { composed } = vi.hoisted(() => ({ composed: [] as Array> })); + +// A PLAIN factory, no `importOriginal`. The `importOriginal` form left this +// stand-in inert here — the real `TimelineRenderer` rendered and no assertion +// could tell — so this follows the arrangement `ObjectTimeline.test.tsx` already +// proves works against this same module. +vi.mock('./renderer', () => ({ + TimelineRenderer: ({ schema }: { schema: Record }) => { + composed.push(schema); + return
{String((schema.items as unknown[])?.length ?? 0)}
; + }, +})); + +/** + * The REAL `resolveTimelineScale`, reached past the whole-module mock above. + * Asserting through it rather than reading the key directly is the point: it is + * the production read path, so a migration that renamed the key but left the + * value unreachable cannot pass. + */ +let resolveTimelineScale: (schema: { scale?: unknown }) => string; + +beforeAll(async () => { + const actual = await vi.importActual('./renderer'); + resolveTimelineScale = actual.resolveTimelineScale; + expect(typeof resolveTimelineScale, 'could not reach the real resolveTimelineScale').toBe('function'); +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + useObjectLabel: () => ({ + fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb, + translateOptions: (_o: string, _f: string, opts: unknown[]) => opts, + fieldLabel: (_o: string, _f: string, fb: string) => fb, + }), + }; +}); + +const rows = [ + { id: '1', name: 'Spring Launch', start_date: '2099-09-01', end_date: '2099-09-30' }, + { id: '2', name: 'Summer Push', start_date: '2100-10-01', end_date: '2100-10-31' }, +]; + +async function renderTimeline(schema: Record) { + const props = { schema, data: rows } as unknown as React.ComponentProps; + render(); + // Waiting on the STUB's own marker proves the stand-in actually installed. An + // inert `vi.mock` reports the same green a working one does + // (`scripts/check-vi-mock-specifiers.mjs`), so the capture is asserted, never assumed. + await waitFor(() => expect(screen.getAllByTestId('captured').length).toBeGreaterThan(0)); + expect(composed.length, 'the TimelineRenderer stand-in never ran — the mock is inert').toBeGreaterThan(0); + return composed[composed.length - 1]; +} + +/** + * `variant: 'vertical'`, deliberately, even though the axis it carries is a + * GANTT concern. The composition under test is variant-independent — + * `ObjectTimeline` writes the resolved axis unconditionally — and rendering the + * gantt variant here would exercise an unrelated pre-existing crash: + * `calculateDateRange` reads gantt ROW shape (`row.items[].startDate`) while + * `ObjectTimeline` composes feed items, so `Math.min()` over an empty list + * yields `Infinity` and `new Date(Infinity).toISOString()` throws + * `RangeError: Invalid time value`. That is filed separately; pinning it here + * would couple this card's pin to a defect it does not own. + */ +const BASE = { + type: 'timeline', + variant: 'vertical', + objectName: 'campaign', + timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, +}; + +beforeEach(() => { + composed.length = 0; +}); + +describe('ObjectTimeline composes the axis under `scale` (objectui#6355)', () => { + it('emits the spec `timeline.scale` as `scale`, and the resolver reads it', async () => { + const schema = await renderTimeline({ ...BASE, timeline: { ...BASE.timeline, scale: 'year' } }); + + expect(schema.timeScale, 'ObjectTimeline still composes the RETIRED alias').toBeUndefined(); + expect(schema.scale).toBe('year'); + // The read path, not the key name: these must agree or the migration is half done. + expect(resolveTimelineScale(schema)).toBe('year'); + }); + + it('emits a flat `schema.scale` the same way', async () => { + const schema = await renderTimeline({ ...BASE, scale: 'quarter' }); + + expect(schema.timeScale).toBeUndefined(); + expect(resolveTimelineScale(schema)).toBe('quarter'); + }); + + it('lets `timeline.scale` win over a flat `scale`, as the code intends', async () => { + // Under the alias this was INVERTED and nothing noticed: ObjectTimeline + // resolved `timelineConfig.scale ?? schema.scale` and wrote the winner to + // `timeScale`, but the spread carried the flat `scale` through untouched and + // `resolveTimelineScale`'s `scale ?? timeScale` ordering then preferred it. + // Writing the resolved value under `scale` after the spread is what makes + // the stated precedence real. + const schema = await renderTimeline({ + ...BASE, + scale: 'day', + timeline: { ...BASE.timeline, scale: 'year' }, + }); + + expect(resolveTimelineScale(schema)).toBe('year'); + }); + + it('composes no axis at all when none is configured', async () => { + // Counter-probe: the assertions above must be reading a value this component + // actually put there, not one it always writes. + const schema = await renderTimeline({ ...BASE }); + + expect(schema.scale).toBeUndefined(); + expect(schema.timeScale).toBeUndefined(); + expect(resolveTimelineScale(schema)).toBe('month'); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx new file mode 100644 index 0000000000..a98b1ef13d --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx @@ -0,0 +1,109 @@ +/** + * 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. + */ + +/** + * The in-repo authors of the gantt axis are migrated off the retired + * `timeScale` alias (objectui#6355). + * + * The ruling retires the alias in one PR, migration included, so this repo must + * not keep authoring the spelling it just made unauthorable. Three writers + * existed on `origin/main`: + * + * 1. the schema-catalog fixture `gantt-style-timeline.json`; + * 2. the registration's own `examples.gantt` block (`../renderer`); + * 3. `ObjectTimeline`, which COMPOSES a schema for `TimelineRenderer` and + * wrote the resolved axis under the alias. That one is invisible to a + * grep of authored metadata — no author ever sees that object — and it is + * the one that would have silently reverted every object-bound gantt to + * the `month` default the moment the fallback read went. It is pinned in + * `timeline-object-scale-composition.test.tsx`. + * + * The point of pinning 1 and 2 is not that the string changed. It is that these + * documents still RESOLVE to the bucket they declare. A migration that renamed + * the key but left the value unreachable would look identical in a diff and + * would render a `month` axis for a document that says `month` — green for the + * wrong reason. So each assertion below goes through `resolveTimelineScale`, + * the real read path, and the fixture is additionally parsed by the published + * validator that now REFUSES the old spelling. + */ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ComponentRegistry } from '@object-ui/core'; +import { TimelineSchema } from '@object-ui/types/zod'; +import { resolveTimelineScale } from '../renderer'; +// Importing the package entry performs the registration, exactly as a host does. +import '../index'; + +// Vitest's root is the repo root (`vitest.config.mts`), so the fixture is +// addressed from there. `existsSync` is asserted before any test reads it: a +// path that silently resolves to nothing would turn every assertion below into a +// vacuous pass, which is the failure this file is meant to detect, not commit. +const CATALOG_FIXTURE = resolve( + process.cwd(), + 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json', +); + +describe('in-repo gantt authors use the canonical `scale` (objectui#6355)', () => { + it('the schema-catalog fixture this pin reads is actually on disk', () => { + expect(existsSync(CATALOG_FIXTURE), `fixture not found at ${CATALOG_FIXTURE}`).toBe(true); + }); + + it('the schema-catalog fixture authors `scale`, and it is the value that resolves', () => { + const raw = readFileSync(CATALOG_FIXTURE, 'utf8'); + const doc = JSON.parse(raw) as Record; + + expect(doc.variant, 'fixture is no longer the gantt one this pin was written for').toBe('gantt'); + expect(doc.timeScale, 'the schema-catalog fixture still authors the RETIRED alias').toBeUndefined(); + expect(doc.scale).toBe('month'); + + // The read path, not the string: this is what the renderer would bucket by. + expect(resolveTimelineScale(doc)).toBe('month'); + + // And the published validator accepts it — the same validator that now + // refuses the pre-migration spelling of this very document. + const parsed = TimelineSchema.safeParse(doc); + expect(parsed.success ? null : parsed.error.issues).toBe(null); + }); + + it('the pre-migration form of that same fixture is REFUSED', () => { + // Counter-probe, and the tightest statement of what the migration bought: + // this exact document, with only the key renamed back, does not parse. + const doc = JSON.parse(readFileSync(CATALOG_FIXTURE, 'utf8')) as Record; + const { scale, ...rest } = doc; + const preMigration = { ...rest, timeScale: scale }; + + const parsed = TimelineSchema.safeParse(preMigration); + expect(parsed.success, 'the retired spelling of the shipped fixture still parses').toBe(false); + }); + + it("the registration's own `examples.gantt` authors `scale`, and it resolves", () => { + // Read back from the registry rather than restated here, so this cannot + // drift from the declaration it is pinning. + const meta = ComponentRegistry.getMeta('plugin-timeline:timeline'); + const gantt = (meta?.examples as Record> | undefined)?.gantt; + + expect(gantt, 'the registration no longer publishes a `gantt` example').toBeDefined(); + expect(gantt!.variant).toBe('gantt'); + expect(gantt!.timeScale, "the registration's gantt example still authors the RETIRED alias").toBeUndefined(); + expect(gantt!.scale).toBe('month'); + expect(resolveTimelineScale(gantt!)).toBe('month'); + + const parsed = TimelineSchema.safeParse({ type: 'timeline', ...gantt }); + expect(parsed.success ? null : parsed.error.issues).toBe(null); + }); + + it('the designer no longer offers the retired alias as an input', () => { + const meta = ComponentRegistry.getMeta('plugin-timeline:timeline'); + const inputs = (meta?.inputs ?? []) as Array<{ name?: string }>; + const names = inputs.map((i) => i.name); + + expect(names, 'the deprecated `timeScale` control is still offered').not.toContain('timeScale'); + expect(names, 'the canonical `scale` control must remain').toContain('scale'); + }); +}); From 188e5745f84fa51dd4237eecb42bc6bc75fbcfc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:15:22 +0000 Subject: [PATCH 4/7] test: move the catalog-fixture half of the author pin to scripts/__tests__ packages/plugin-timeline's test tsconfig carries no node types, so the fs-reading assertions belonged where the repo's other catalog test lives. The registry and designer assertions stay in the package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../timeline-inrepo-authors-migrated.test.tsx | 48 ++----------- ...meline-catalog-scale-migrated-6355.test.ts | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts diff --git a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx index a98b1ef13d..fbb5e8fdf7 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx @@ -14,7 +14,10 @@ * not keep authoring the spelling it just made unauthorable. Three writers * existed on `origin/main`: * - * 1. the schema-catalog fixture `gantt-style-timeline.json`; + * 1. the schema-catalog fixture `gantt-style-timeline.json` — pinned in + * `scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts`, which + * is where the repo's file-reading tests live (this package's test + * tsconfig carries no node types); * 2. the registration's own `examples.gantt` block (`../renderer`); * 3. `ObjectTimeline`, which COMPOSES a schema for `TimelineRenderer` and * wrote the resolved axis under the alias. That one is invisible to a @@ -32,56 +35,13 @@ * validator that now REFUSES the old spelling. */ import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { ComponentRegistry } from '@object-ui/core'; import { TimelineSchema } from '@object-ui/types/zod'; import { resolveTimelineScale } from '../renderer'; // Importing the package entry performs the registration, exactly as a host does. import '../index'; -// Vitest's root is the repo root (`vitest.config.mts`), so the fixture is -// addressed from there. `existsSync` is asserted before any test reads it: a -// path that silently resolves to nothing would turn every assertion below into a -// vacuous pass, which is the failure this file is meant to detect, not commit. -const CATALOG_FIXTURE = resolve( - process.cwd(), - 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json', -); - describe('in-repo gantt authors use the canonical `scale` (objectui#6355)', () => { - it('the schema-catalog fixture this pin reads is actually on disk', () => { - expect(existsSync(CATALOG_FIXTURE), `fixture not found at ${CATALOG_FIXTURE}`).toBe(true); - }); - - it('the schema-catalog fixture authors `scale`, and it is the value that resolves', () => { - const raw = readFileSync(CATALOG_FIXTURE, 'utf8'); - const doc = JSON.parse(raw) as Record; - - expect(doc.variant, 'fixture is no longer the gantt one this pin was written for').toBe('gantt'); - expect(doc.timeScale, 'the schema-catalog fixture still authors the RETIRED alias').toBeUndefined(); - expect(doc.scale).toBe('month'); - - // The read path, not the string: this is what the renderer would bucket by. - expect(resolveTimelineScale(doc)).toBe('month'); - - // And the published validator accepts it — the same validator that now - // refuses the pre-migration spelling of this very document. - const parsed = TimelineSchema.safeParse(doc); - expect(parsed.success ? null : parsed.error.issues).toBe(null); - }); - - it('the pre-migration form of that same fixture is REFUSED', () => { - // Counter-probe, and the tightest statement of what the migration bought: - // this exact document, with only the key renamed back, does not parse. - const doc = JSON.parse(readFileSync(CATALOG_FIXTURE, 'utf8')) as Record; - const { scale, ...rest } = doc; - const preMigration = { ...rest, timeScale: scale }; - - const parsed = TimelineSchema.safeParse(preMigration); - expect(parsed.success, 'the retired spelling of the shipped fixture still parses').toBe(false); - }); - it("the registration's own `examples.gantt` authors `scale`, and it resolves", () => { // Read back from the registry rather than restated here, so this cannot // drift from the declaration it is pinning. diff --git a/scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts b/scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts new file mode 100644 index 0000000000..3bb551bb8c --- /dev/null +++ b/scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +/** + * The shipped schema-catalog gantt fixture is migrated off the retired + * `timeScale` alias (objectui#6355). + * + * The ruling retires the alias and migrates the in-repo authors in one PR, so + * this repo must not keep shipping a catalog entry spelling the key it just made + * unauthorable — that entry is a worked example users copy. + * + * Lives here, next to the repo's other catalog test, because it reads the + * fixture off disk and `packages/plugin-timeline`'s test tsconfig carries no + * node types. Its siblings — the registration's `examples.gantt` block and the + * designer inputs — are pinned in + * `packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx`, + * and the refusal that makes the retirement audible is pinned in + * `packages/types/src/__tests__/timeline-timescale-retired.test.ts`. + * + * The assertion is not that the string changed. The fixture is parsed by the + * published validator — the same one that now REFUSES its pre-migration form — + * so a rename that left the document invalid, or a value that stopped being + * reachable, fails here. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TimelineSchema } from '@object-ui/types/zod'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const FIXTURE = path.join( + REPO_ROOT, + 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json', +); + +describe('schema-catalog gantt fixture uses the canonical `scale` (objectui#6355)', () => { + it('the fixture this pin reads is actually on disk', () => { + // Asserted before anything reads it: a path that silently resolved to + // nothing would turn every assertion below into a vacuous pass. + expect(fs.existsSync(FIXTURE), `fixture not found at ${FIXTURE}`).toBe(true); + }); + + it('authors `scale`, not the retired alias, and still validates', () => { + const doc = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')) as Record; + + expect(doc.variant, 'fixture is no longer the gantt one this pin was written for').toBe('gantt'); + expect(doc.timeScale, 'the schema-catalog fixture still authors the RETIRED alias').toBeUndefined(); + expect(doc.scale).toBe('month'); + + const parsed = TimelineSchema.safeParse(doc); + expect(parsed.success ? null : parsed.error.issues).toBe(null); + }); + + it('the pre-migration form of that same fixture is REFUSED', () => { + // Counter-probe, and the tightest statement of what the migration bought: + // this exact document with only the key renamed back does not parse. Without + // it, the assertion above would also pass against a validator that accepts + // both spellings. + const doc = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')) as Record; + const { scale, ...rest } = doc; + const preMigration = { ...rest, timeScale: scale }; + + const parsed = TimelineSchema.safeParse(preMigration); + expect(parsed.success, 'the retired spelling of the shipped fixture still parses').toBe(false); + }); +}); From d161cfa84671cbfbecdcac02eb8d7e760c52e45c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:19:00 +0000 Subject: [PATCH 5/7] test(types): home the catalog-fixture pin where node types and the validator meet scripts/__tests__ cannot resolve @object-ui/types/zod (scripts/ is not a workspace package, so there is no dependency edge); plugin-timeline's test tsconfig has no node types. packages/types has both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../timeline-inrepo-authors-migrated.test.tsx | 5 +++-- .../timeline-catalog-fixture-migrated.test.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) rename scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts => packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts (81%) diff --git a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx index fbb5e8fdf7..e5851f3188 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx @@ -15,8 +15,9 @@ * existed on `origin/main`: * * 1. the schema-catalog fixture `gantt-style-timeline.json` — pinned in - * `scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts`, which - * is where the repo's file-reading tests live (this package's test + * `@object-ui/types`' + * `__tests__/timeline-catalog-fixture-migrated.test.ts`, which is where + * node types and the Zod validator are both available (this package's test * tsconfig carries no node types); * 2. the registration's own `examples.gantt` block (`../renderer`); * 3. `ObjectTimeline`, which COMPOSES a schema for `TimelineRenderer` and diff --git a/scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts b/packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts similarity index 81% rename from scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts rename to packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts index 3bb551bb8c..a4fbde01b7 100644 --- a/scripts/__tests__/timeline-catalog-scale-migrated-6355.test.ts +++ b/packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts @@ -14,9 +14,13 @@ * this repo must not keep shipping a catalog entry spelling the key it just made * unauthorable — that entry is a worked example users copy. * - * Lives here, next to the repo's other catalog test, because it reads the - * fixture off disk and `packages/plugin-timeline`'s test tsconfig carries no - * node types. Its siblings — the registration's `examples.gantt` block and the + * Lives in `@object-ui/types` because it needs both halves at once: node types + * to read the fixture off disk (`tsconfig.test.json` declares them, and other + * tests here already read files) and the published Zod validator to judge it. + * `packages/plugin-timeline`'s test tsconfig carries no node types, and + * `scripts/__tests__` — the repo's other file-reading test home — cannot resolve + * `@object-ui/types/zod` at all, since `scripts/` is not a workspace package and + * has no dependency edge to it. Its siblings — the registration's `examples.gantt` block and the * designer inputs — are pinned in * `packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx`, * and the refusal that makes the retirement audible is pinned in @@ -31,9 +35,9 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { TimelineSchema } from '@object-ui/types/zod'; +import { TimelineSchema } from '../zod/data-display.zod.js'; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); const FIXTURE = path.join( REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json', From 5c4308c78394cd6901e5237fb4dca21f02aaeb98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:29:11 +0000 Subject: [PATCH 6/7] test(types): pin the retired spelling in the document form authors write The existing leg reads the member's type; this one writes a TimelineSchema document, which is what an author or a metadata-generating AI actually produces, and is the leg that proves the tombstone survives BaseSchema's index signature. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../timeline-timescale-retired.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/types/src/__tests__/timeline-timescale-retired.test.ts b/packages/types/src/__tests__/timeline-timescale-retired.test.ts index ed10d91f5a..83098945cc 100644 --- a/packages/types/src/__tests__/timeline-timescale-retired.test.ts +++ b/packages/types/src/__tests__/timeline-timescale-retired.test.ts @@ -123,4 +123,28 @@ describe('timeScale is RETIRED — the TS half of the tombstone (objectui#6355)' expect([retired, canonical]).toHaveLength(2); }); + + it('refuses the retired spelling in the form authors actually write', () => { + // The assertion above reads the MEMBER's type. This one writes a DOCUMENT, + // which is the shape an author (or an AI generating metadata) produces, and + // it is the leg that proves the tombstone survives `BaseSchema`'s + // `[key: string]: any`: if the index signature won, `timeScale` would widen + // back to `any` here and the directive would go unused (TS2578). + + // @ts-expect-error — `timeScale` is RETIRED (objectui#6355); the document must name `scale`. + const retiredDocument: TimelineSchemaTS = { + type: 'timeline', + variant: 'gantt', + timeScale: 'month', + }; + + // The migrated document — same node, canonical key — still type-checks. + const migratedDocument: TimelineSchemaTS = { + type: 'timeline', + variant: 'gantt', + scale: 'month', + }; + + expect([retiredDocument, migratedDocument]).toHaveLength(2); + }); }); From 82f289e1d2eed9e5a8a68c7b2a9dffd2b0fc150f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:30:28 +0000 Subject: [PATCH 7/7] test(types): put the tombstone directive on the property it suppresses Excess-property checking reports at the member, so the directive one line above the const suppressed nothing: it went unused (TS2578) while the real TS2322 stood. Caught by the ablation's restore leg. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../types/src/__tests__/timeline-timescale-retired.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/timeline-timescale-retired.test.ts b/packages/types/src/__tests__/timeline-timescale-retired.test.ts index 83098945cc..8db87909f1 100644 --- a/packages/types/src/__tests__/timeline-timescale-retired.test.ts +++ b/packages/types/src/__tests__/timeline-timescale-retired.test.ts @@ -131,10 +131,13 @@ describe('timeScale is RETIRED — the TS half of the tombstone (objectui#6355)' // `[key: string]: any`: if the index signature won, `timeScale` would widen // back to `any` here and the directive would go unused (TS2578). - // @ts-expect-error — `timeScale` is RETIRED (objectui#6355); the document must name `scale`. const retiredDocument: TimelineSchemaTS = { type: 'timeline', variant: 'gantt', + // The directive belongs on the PROPERTY, not on the `const`: excess-property + // checking reports the error at the member, so a directive one line up + // suppresses nothing and goes unused (TS2578) while the real error stands. + // @ts-expect-error — `timeScale` is RETIRED (objectui#6355); the document must name `scale`. timeScale: 'month', };