Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/gantt-bar-object-declaration.md
Original file line number Diff line number Diff line change
@@ -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.
263 changes: 263 additions & 0 deletions packages/types/src/__tests__/timeline-items-bar-shape-7365.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TimelineSchema.safeParse>): string | null => {
if (r.success) return null;
return r.error.issues[0].path.reduce<string>(
(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<typeof safeValidateSchema>): 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<string, unknown>][] = [
["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');
});
});
25 changes: 19 additions & 6 deletions packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading