diff --git a/.changeset/gantt-bar-object-declaration.md b/.changeset/gantt-bar-object-declaration.md new file mode 100644 index 0000000000..595be78c8f --- /dev/null +++ b/.changeset/gantt-bar-object-declaration.md @@ -0,0 +1,44 @@ +--- +'@object-ui/types': minor +--- + +**Declare a gantt BAR as an object on both published faces** (objectui#7365). + +`TimelineSchema.items[].items[]` — a gantt row's bars — was `z.array(z.any())` +in the zod mirror and `any[]` on the TypeScript face, so an authored `null` bar +(`{ items: [{ label: 'R', items: [null] }] }`) was green through `validate` and +only met the render-time date diagnostic, which named +`items[0].items[0].startDate is undefined` — a key the author never wrote. + +Both faces now declare the same shape objectui#7164 declared one level up: the +mirror's row `items` is `z.array(z.object({}).passthrough())`, and the TS face +states the row/bar shape its docblock previously carried only in prose. A bar +that is not a bar is refused at `validate`, by its own name, at +`items[i].items[j]`. + +**Breaking semantics, shipped as a `minor`** (objectui's own breaking changes +are never `major` — the fixed group follows `@objectstack`): this NARROWS a +published accept set. A document with a `null` (or numeric, string, boolean, +array) gantt bar used to `safeParse` green and now fails. In-repo stock measured +before the change on `289d146` and re-measured unchanged on `c4b3750`: five authored bars across `apps/` · `examples/` +· `content/` · `packages/types/examples/`, all well-formed objects, zero `null` +and zero non-object — positive-controlled, so nothing in this repository moves. + +**The TypeScript face breaks in the same direction, and it breaks at compile +time.** `TimelineSchema.items` was `any[]`; it is now an array of objects whose +`items` — the bars — is itself an array of objects. So a row's and a bar's own +keys type as `unknown` where they used to be `any`. Both of these compiled +before and are now `TS2322: Type 'unknown' is not assignable to type 'string'` +— annotate or narrow at the read site: + +```ts +const t: string = s.items![0].title; +const d: string = s.items![0].items![0].startDate; +``` + +And a `null` or non-object bar LITERAL no longer compiles at all — nor does a +`null` row literal, which the `any[]` element used to admit. + +A bar's own keys (`title` / `startDate` / `endDate` / `variant?`) stay +undeclared and open, feed-variant timelines are untouched, and the render-time +`malformedRow` copy and its ten language packs are unchanged. diff --git a/packages/types/src/__tests__/timeline-items-bar-shape-7365.test.ts b/packages/types/src/__tests__/timeline-items-bar-shape-7365.test.ts new file mode 100644 index 0000000000..fbd7e6b9f9 --- /dev/null +++ b/packages/types/src/__tests__/timeline-items-bar-shape-7365.test.ts @@ -0,0 +1,263 @@ +/** + * 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#7365 — a gantt BAR is declared an OBJECT on both faces (director + * seat, decision batch #71, 2026-09-07; maintainer reply verbatim + * 「其他同意」, option B). + * + * ## What was wrong + * + * objectui#7164 narrowed the ROW (`items: [null]` is refused) and stopped + * there DELIBERATELY, recording the stop in both faces' docblocks: the BARS + * inside a row stayed `z.array(z.any())`. So an authored `null` BAR — + * `{ items: [{ label: 'R', items: [null] }] }` — was green through `validate` + * and only met the render-time date diagnostic, which named it + * + * items[0].items[0].startDate is undefined, which is not a valid date + * + * — the WRONG FAULT, by a key the author never wrote. That is the same + * mis-naming class objectui#7164 repaired for rows, one level down. The + * ruling: a bar that is not a bar is refused at `validate`, by its own name. + * objectui#7164's deliberate stop is superseded KNOWINGLY. + * + * ## The accept set, before and after (measured on `289d146` + this change; re-measured unchanged on `c4b3750`) + * + * input main head + * items: [{ items: [null] }] accept REFUSE items[0].items[0] + * items: [{ items: [0] }] accept REFUSE items[0].items[0] + * items: [{ items: ['x'] }] accept REFUSE items[0].items[0] + * items: [{ items: [true] }] accept REFUSE items[0].items[0] + * items: [{ items: [[]] }] accept REFUSE items[0].items[0] + * items: [{ items: [undefined] }] accept REFUSE items[0].items[0] + * items: [{ items: [GOOD, null] }] accept REFUSE items[0].items[1] + * items: [{ items: [GOOD] }, { items: [null] }] accept REFUSE items[1].items[0] + * items: [{ items: [GOOD] }] accept accept + * items: [{ items: [{}] }] accept accept (keys stay open) + * items: [{ items: [] }] accept accept + * items: [{ label: 'R' }] accept accept + * a feed-variant items array accept accept + * + * ## What is deliberately NOT narrowed + * + * The bar stays `.passthrough()`: its own keys (`title` / `startDate` / + * `endDate` / `variant?`) are read dynamically by the renderer and are NOT + * declared, so `items: [{}]` still parses — this card refuses a bar that is + * not a bar, not a bar with the wrong keys. ⛔ Option A is REFUSED, not + * deferred: `timeline.gantt.unusableRange.malformedRow`'s copy is unchanged, + * no fourth path level was added, and the ten language packs are untouched. + * The render-time date diagnostic remains the defined outcome for anything + * that still reaches it, and the renderer stays only ever MORE lenient than + * `validate` — asserted over the in-repo fixtures at the foot of this file. + * + * ## Stock measured before the narrowing, positive-controlled + * + * A published accept set narrows here (Clause-② yes), so the in-repo stock of + * authored bars was counted on `289d146`, re-measured unchanged on + * `c4b3750`, across `apps/` · `examples/` · `content/` · + * `packages/types/examples/`: FIVE bars, all well-formed objects, + * ZERO `null` and ZERO non-object. The zero is a READING and not an empty + * search — the same walker reported the five well-formed bars, and a planted + * `null` bar plus a planted numeric bar in a scratch copy of the gantt fixture + * were both found. `hotcrm` is a separate repository and is not reachable from + * this checkout; it is unmeasured here and named as such on the PR. The + * fixture census at the foot of this file is the durable half of that reading. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { TimelineSchema } from '../zod/data-display.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +const GOOD_BAR = { title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }; +const gantt = (items: unknown) => ({ type: 'timeline', variant: 'gantt', items }); +const row = (items: unknown) => ({ label: 'R', items }); + +/** The first issue's path, dotted-and-indexed the way the renderer spells it. */ +const firstPath = (r: ReturnType): string | null => { + if (r.success) return null; + return r.error.issues[0].path.reduce( + (acc, seg) => (typeof seg === 'number' ? `${acc}[${seg}]` : acc ? `${acc}.${String(seg)}` : String(seg)), + '', + ); +}; + +/** Every issue path in the tree, per-arm `errors` included — the union door + * reports its arms nested, so a flat `issues[]` read would miss the bar. */ +const issuePaths = (r: ReturnType): string[] => { + if (r.success) return []; + const out: string[] = []; + const walk = (issues: readonly z.core.$ZodIssue[]) => { + for (const issue of issues) { + out.push(issue.path.map(String).join('.')); + const nested = (issue as { errors?: readonly (readonly z.core.$ZodIssue[])[] }).errors; + if (nested) for (const arm of nested) walk(arm); + } + }; + walk(r.error.issues); + return out; +}; + +describe('a gantt BAR that is not an object is refused at authoring time (objectui#7365)', () => { + const refused: [string, unknown, string][] = [ + ["the ruling's pin — a null bar", [row([null])], 'items[0].items[0]'], + ['a bar that is the number 0', [row([0])], 'items[0].items[0]'], + ['a bar that is a string', [row(['x'])], 'items[0].items[0]'], + ['a bar that is a boolean', [row([true])], 'items[0].items[0]'], + ['a bar that is an array', [row([[]])], 'items[0].items[0]'], + ['a bar that is undefined', [row([undefined])], 'items[0].items[0]'], + ['a null bar BESIDE a good one — the path names the bar, not the row', [row([GOOD_BAR, null])], 'items[0].items[1]'], + ['a null bar in the SECOND row', [row([GOOD_BAR]), row([null])], 'items[1].items[0]'], + ]; + + for (const [label, items, path] of refused) { + it(`${label} -> REFUSED at ${path}`, () => { + const r = TimelineSchema.safeParse(gantt(items)); + expect(r.success).toBe(false); + expect(firstPath(r)).toBe(path); + }); + } + + it('the refusal names the BAR, never `startDate` — the whole point of the card', () => { + const r = TimelineSchema.safeParse(gantt([row([null])])); + expect(r.success).toBe(false); + const issues = JSON.stringify(r.success ? [] : r.error.issues); + expect(issues).not.toContain('startDate'); + expect(firstPath(r)).toBe('items[0].items[0]'); + }); + + it('every refusal above is an `invalid_type` — the bar is refused for its TYPE, not its keys', () => { + // The bar stays `.passthrough()`, so an `unrecognized_keys` here would mean + // the refusal came from the wrong rule: the card declares OBJECT-ness only. + for (const [label, items] of refused) { + const r = TimelineSchema.safeParse(gantt(items)); + expect(r.success).toBe(false); + expect(r.success ? null : r.error.issues[0].code, label).toBe('invalid_type'); + } + }); + + it('the previous declaration accepted every bar above — the CONTROL that makes the refusals readings', () => { + // The exact bar-level declaration this card replaced — `z.array(z.any())` + // inside the row — rebuilt on today's `TimelineSchema` so the "before" + // column of the table in the header is measured against the same base, + // not remembered. + const previousRow = z.object({ items: z.array(z.any()).optional() }).passthrough(); + const previous = TimelineSchema.extend({ items: z.array(previousRow).optional() }); + for (const [label, items] of refused) { + expect(previous.safeParse(gantt(items)).success, `${label} was NOT accepted by the old mirror`).toBe(true); + } + // And two the old mirror already refused, so the control is two-sided — + // objectui#7164's ROW level is untouched by this card. + expect(previous.safeParse(gantt([null])).success).toBe(false); + expect(previous.safeParse(gantt([{ label: 'R', items: 5 }])).success).toBe(false); + }); +}); + +describe('objectui#7164 ROW-level refusals are unchanged by this card', () => { + const stillRefused: [string, unknown, string][] = [ + ['a null row', [null], 'items[0]'], + ['a row whose items is a number', [row(5)], 'items[0].items'], + ['a row whose items is null', [row(null)], 'items[0].items'], + ['a row that is an empty array', [[]], 'items[0]'], + ]; + for (const [label, items, path] of stillRefused) { + it(`${label} -> still REFUSED at ${path}`, () => { + const r = TimelineSchema.safeParse(gantt(items)); + expect(r.success).toBe(false); + expect(firstPath(r)).toBe(path); + }); + } +}); + +describe('what still parses — the accept set is narrowed, not redrawn', () => { + const accepted: [string, Record][] = [ + ["the ruling's pin, other half — a well-formed bar", gantt([row([GOOD_BAR])])], + ['a bar with NO declared keys — the bar stays passthrough', gantt([row([{}])])], + ['a bar with extra keys', gantt([row([{ ...GOOD_BAR, color: 'red', progress: 0.5 }])])], + ['a row with an empty bar list', gantt([row([])])], + ['a row with no items key (no bars yet)', gantt([{ label: 'R' }])], + ['an empty items list', gantt([])], + ['no items key at all', { type: 'timeline', variant: 'gantt' }], + [ + 'a feed-variant items array — it carries no `items` key and is untouched', + { type: 'timeline', variant: 'vertical', items: [{ time: '2024-01-15', title: 'Started', description: 'Kickoff', variant: 'success' }] }, + ], + ['a horizontal feed', { type: 'timeline', variant: 'horizontal', items: [{ time: '2024-01-01', title: 'Q1' }] }], + ]; + + for (const [label, doc] of accepted) { + it(`${label} -> accepted`, () => { + const r = TimelineSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues[0])).toBe(true); + }); + } +}); + +describe('fixture census — the in-repo bar stock, read from disk', () => { + /** + * The durable half of the stock measurement in this file's header: every + * in-repo JSON document that declares `type: "timeline"`, read from disk + * rather than copied here so a fixture edit is MEASURED, not remembered. The + * docs page's gantt example (`content/docs/plugins/plugin-timeline.mdx`) is a + * TS object literal typed `TimelineSchema` and is checked by `check:doc-types` + * against the narrowed declaration instead. + */ + const ROOT = resolve(__dirname, '../../../..'); + const files = [ + 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json', + 'examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json', + 'examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json', + ]; + + const read = (file: string) => JSON.parse(readFileSync(resolve(ROOT, file), 'utf8')); + + for (const file of files) { + it(`${file} -> accepted by the narrowed mirror`, () => { + const doc = read(file); + expect(doc.type).toBe('timeline'); + const r = TimelineSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues[0])).toBe(true); + }); + } + + it('packages/types/examples/data-display-examples.json#examples.timeline -> accepted', () => { + const timeline = read('packages/types/examples/data-display-examples.json').examples.timeline; + expect(timeline.type).toBe('timeline'); + expect(TimelineSchema.safeParse(timeline).success).toBe(true); + }); + + it('the in-repo bar stock is FIVE bars, all objects, zero null — positive-controlled', () => { + const bars: unknown[] = []; + for (const file of files) { + for (const r of read(file).items ?? []) for (const bar of r?.items ?? []) bars.push(bar); + } + // The control: the walker above found bars at all. A zero here with an + // empty `bars` would be indistinguishable from a broken traversal. + expect(bars.length).toBe(5); + expect(bars.filter((b) => b === null || typeof b !== 'object' || Array.isArray(b))).toEqual([]); + }); +}); + +describe('the same verdict through the real door the CLI applies (`safeValidateSchema`)', () => { + // `TimelineSchema.safeParse` above is the declaration; this is the union + // `objectui validate` actually runs. A `.passthrough()` arm elsewhere in the + // union could have re-admitted the document, so the door is measured, not + // assumed — and the good half is asserted beside it so a green refusal is + // not just "no arm matched anything". + it("a well-formed gantt document validates through the union", () => { + const r = safeValidateSchema(gantt([row([GOOD_BAR])])); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it("the ruling's pin — a `null` bar — is refused through the union, and the bar is named", () => { + const r = safeValidateSchema(gantt([row([null])])); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('items.0.items.0'); + }); +}); diff --git a/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts b/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts index 67c42690ea..43e289f021 100644 --- a/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts +++ b/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts @@ -43,13 +43,26 @@ * document `validate` admits — that is the invariant, and it is asserted at the * foot of this file over the in-repo fixtures. * - * ## What is deliberately NOT narrowed + * ## The bar level: a deliberate stop, since SUPERSEDED * - * The bars inside a row stay `z.any()` and the element stays `.passthrough()`: - * the two element shapes (feed item / gantt row) are discriminated by `variant` - * and read dynamically, and a feed item carries no `items` key, so the feed - * variants parse exactly as before. Narrowing the bar shape or refining by - * `variant` is a wider contract than the ruling named. + * This card narrowed the ROW and stopped one level short DELIBERATELY — the + * bars inside a row stayed `z.array(z.any())` — and this docblock recorded + * that stop as the contract. ⭐ It is no longer the contract: objectui#7365 + * (director seat, decision batch #71, 2026-09-07, option B) SUPERSEDES the + * stop KNOWINGLY. Every BAR is now `z.object({}).passthrough()` too, so a + * `null` bar is refused at `validate` by its own name, at `items[i].items[j]`, + * instead of reaching the render-time date diagnostic that used to name a + * `startDate` the author never wrote. The bar level is pinned by + * `./timeline-items-bar-shape-7365.test.ts`; the table above and every + * assertion below are the ROW level and are unmoved by it. + * + * ## What is still deliberately NOT narrowed, by either card + * + * The element and the bar both stay `.passthrough()`: the two element shapes + * (feed item / gantt row) are discriminated by `variant` and read dynamically, + * and a feed item carries no `items` key, so the feed variants parse exactly + * as before. Neither a row's nor a bar's own keys are declared, and refining + * by `variant` is still a wider contract than either ruling named. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index f437c6247d..ce7bdf4448 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1969,8 +1969,8 @@ export interface TimelineSchema extends BaseSchema { * The rows to draw. * * TWO element shapes, discriminated by `variant`, both read dynamically by - * the renderer (`items.map((item: any) => …)`), so the element type is left - * open rather than narrowed to either one: + * the renderer (`items.map((item: any) => …)`), so NEITHER shape's own keys + * are declared here — what is declared is what the two SHARE: * * - `vertical` / `horizontal` — a feed item: * `{ time, title, description?, variant?, icon?, color?, content?, className?, meta?, group? }` @@ -1979,13 +1979,46 @@ export interface TimelineSchema extends BaseSchema { * * `content/docs/plugins/plugin-timeline.mdx` carries both in full. * - * The zod mirror (`./zod/data-display.zod.ts`, objectui#7164) declares what - * both shapes share and nothing more: every element is an OBJECT, and a gantt - * row's own `items`, when present, is an ARRAY. `validate` refuses a `null` - * element and a row whose `items` is a number, a string or a plain object — - * the inputs that used to crash the gantt renderer from ordinary JSON. + * ## This type states the shared shape; it no longer describes it in prose + * + * The zod mirror (`./zod/data-display.zod.ts`) and this declaration state the + * SAME two levels, and the type below is the TypeScript spelling of the + * mirror's `z.object({}).passthrough()` at each of them: + * + * - every element is an OBJECT — a `null` row, a number, a string, an array + * are all refused (objectui#7164); + * - a gantt row's own `items`, when present, is an ARRAY — of OBJECTS. A + * `null` bar is refused by its own name, at `items[i].items[j]` + * (objectui#7365, director seat decision batch #71, 2026-09-07, option B). + * + * ⭐ objectui#7164 narrowed the ROW and stopped at the bar level + * DELIBERATELY, and this docblock recorded the stop in prose. That stop is + * SUPERSEDED KNOWINGLY, so the prose describing it is gone rather than + * qualified: the next reader should not re-derive a gap that has been closed. + * + * ⛔ The render-time diagnostic is UNCHANGED by that ruling + * (`timeline.gantt.unusableRange.malformedRow` and the ten language packs are + * untouched) — the renderer stays only ever MORE lenient than `validate`, and + * the date diagnostic remains the defined outcome for anything reaching it. */ - items?: any[]; + items?: Array<{ + /** + * A gantt row's bars — an ARRAY OF OBJECTS when present. Optional is + * deliberate: a row with no bars yet is an ordinary empty state + * (objectui#6750) and the renderer draws it. A feed item carries no + * `items` key at all and satisfies this element unchanged. + * + * The bar's OWN keys (`title` / `startDate` / `endDate` / `variant?`) are + * NOT declared, for the reason the element's are not: they are read + * dynamically and the mirror leaves them open too. + */ + items?: Record[]; + /** + * The element's own keys, undeclared and open — the TypeScript spelling of + * the mirror's `.passthrough()`. Both shapes above pass through here. + */ + [key: string]: unknown; + }>; /** * How item dates are rendered. * @default 'short' diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 9b4d3b005e..af3e03e208 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -694,7 +694,9 @@ const TimelineScaleSchema = z.enum(['hour', 'day', 'week', 'month', 'quarter', ' /** * One element of `TimelineSchema.items` — a feed item, or a gantt ROW when - * `variant` is `gantt` (objectui#7164, maintainer ruling 2026-09-02 A+). + * `variant` is `gantt` (objectui#7164, maintainer ruling 2026-09-02 A+; + * BAR level added by objectui#7365, director seat decision batch #71, + * 2026-09-07, option B). * * ## What this refuses, and why it is declared at all * @@ -710,26 +712,53 @@ const TimelineScaleSchema = z.enum(['hour', 'day', 'week', 'month', 'quarter', ' * is refused (`z.object` refuses every one of those); * - `items` on a row, when present, has to be an array. `.optional()` is * deliberate: a row with no bars yet is the same ordinary empty state - * objectui#6750 ruled for `items: []`, and the renderer draws it. + * objectui#6750 ruled for `items: []`, and the renderer draws it; + * - every BAR in that array is an object. A `null` bar is refused by its own + * name, at `items[i].items[j]`. * - * Nothing else is narrowed. The two element shapes (`{ time, title, … }` for a - * feed, `{ label, items: [{ title, startDate, endDate }] }` for a gantt row) are - * discriminated by `variant` and read dynamically by the renderer, so the - * element stays `.passthrough()` and the bars stay `z.any()` — a feed item - * carries no `items` key and parses green here unchanged. Measured before the - * narrowing: every in-repo `type: 'timeline'` fixture (the three schema-catalog - * documents, the docs page's examples, `examples/data-display-examples.json`) - * parses green on both sides of it. + * ## The bar level, and why it is no longer a declared STOP (objectui#7365) + * + * objectui#7164 narrowed the ROW and stopped there DELIBERATELY, and this + * docblock recorded the stop: the bars stayed `z.any()`. That stop is + * SUPERSEDED KNOWINGLY. An authored `null` bar was green through `validate` + * and then reached the render-time date diagnostic, which named + * `items[0].items[0].startDate is undefined` — a key the author never wrote. + * The ruling: a bar that is not a bar is refused at `validate`, by its own + * name. So `z.object({}).passthrough()` — the same shallow, keys-open shape + * the ROW carries, one level down. + * + * ⛔ Option A is REFUSED, not deferred: the render-time + * `timeline.gantt.unusableRange.malformedRow` copy is UNCHANGED, no fourth + * path level was added to that sentence, and the ten language packs are + * untouched. The date diagnostic remains the defined outcome for anything that + * still reaches it — the renderer is only ever more lenient than `validate`. + * + * Nothing beyond the bar's OBJECT-ness is narrowed. The two element shapes + * (`{ time, title, … }` for a feed, `{ label, items: [{ title, startDate, + * endDate }] }` for a gantt row) are discriminated by `variant` and read + * dynamically by the renderer, so the element and the bar both stay + * `.passthrough()` — a feed item carries no `items` key and parses green here + * unchanged, and a bar's own keys (`title` / `startDate` / `endDate` / + * `variant?`) are not declared. Measured before the ROW narrowing: every + * in-repo `type: 'timeline'` fixture (the three schema-catalog documents, the + * docs page's examples, `examples/data-display-examples.json`) parses green on + * both sides of it. Measured again before the BAR narrowing (objectui#7365, on + * `289d146`, re-measured unchanged on `c4b3750`): FIVE authored bars across + * `apps/` · `examples/` · `content/` · `packages/types/examples/`, ALL + * well-formed objects, ZERO `null` and ZERO + * non-object — a positive-controlled reading, not an empty search. * * Deliberately NOT exported, for the reason `TimelineScaleSchema` above gives: * every exported const here has to be registered in `zod-mirror-parity.test.ts`, * and the TS twin declares no separate row interface to pair it with — its - * `items?: any[]` docblock carries both shapes in prose. Pinned by - * `../__tests__/timeline-items-row-shape-7164.test.ts`. + * `items` docblock carries both shapes in prose and its own type states the + * two levels this schema states. Pinned by + * `../__tests__/timeline-items-row-shape-7164.test.ts` (row level) and + * `../__tests__/timeline-items-bar-shape-7365.test.ts` (bar level). */ const TimelineRowSchema = z .object({ - items: z.array(z.any()).optional().describe('A gantt row\'s bars — an array when present'), + items: z.array(z.object({}).passthrough()).optional().describe('A gantt row\'s bars — an array of objects when present'), }) .passthrough();