From f3dc26f5e84f8d08ac1dc75da83ec4f71312e4ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 01:54:02 +0000 Subject: [PATCH 1/2] feat(types,plugin-gantt): declare the flattened GanttConfig face ObjectGantt reads (#6051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getGanttConfig` has two branches. When `startDateField` and `endDateField` are both present at the TOP level it builds its config from top-level keys and returns early; otherwise it reads the `gantt` block. Everything the first branch reads was undeclared, and unlike #5903's ten none of it was behind a cast: `BaseSchema` carries `[key: string]: any` and the helper's parameter was `ObjectGridSchema | any`, so `schema.colorField` type-checked as `any` with no syntax to grep for. The census is therefore an AST enumeration, not a compile-and-observe — an index signature absorbs exactly the evidence an annotation would have produced. - Re-derived on the post-#5903 tree: 47 top-level keys read, 19 declared, 28 residue. #5903 absorbed NONE of the card's 24; the residue is four larger because #5903 retyped `ObjectGanttProps.schema` to `ObjectGanttSchema`, which is correct and is why `staticData`/`filter`/`sort` (declared on `ObjectGridSchema`) now need declaring here, plus `gantt` itself. - The 24 flattened members are DERIVED from `GanttConfig`, so the flat spelling cannot fork from the block spelling; the invariant is pinned in the type system (every `GanttConfig` key is declared at the node's top level). - Nine members move out of `plugin-gantt`'s package-private `GanttConfigEx` into `@object-ui/types`' `GanttConfig` to make that derivation possible. A move, not new vocabulary: the `gantt` block already honoured all nine. - `getGanttConfig(schema: ObjectGanttSchema)` — without it the reads would still land on the index signature. - The zod mirror gains the same 28 at the same requiredness, spec-modelled ones from `GanttConfigSchema.shape` by reference, so `zod-mirror-parity` stays at zero drift and no ledger entry is added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .../6051-gantt-flat-config-declared-keys.md | 67 +++ packages/plugin-gantt/src/ObjectGantt.tsx | 110 +---- .../gantt-flat-config-declared-keys.test.ts | 398 ++++++++++++++++++ packages/types/src/objectql.ts | 207 +++++++++ packages/types/src/zod/objectql.zod.ts | 90 ++++ 5 files changed, 785 insertions(+), 87 deletions(-) create mode 100644 .changeset/6051-gantt-flat-config-declared-keys.md create mode 100644 packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts diff --git a/.changeset/6051-gantt-flat-config-declared-keys.md b/.changeset/6051-gantt-flat-config-declared-keys.md new file mode 100644 index 000000000..32ea4b3be --- /dev/null +++ b/.changeset/6051-gantt-flat-config-declared-keys.md @@ -0,0 +1,67 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-gantt': minor +--- + +`ObjectGanttSchema` declares the flattened `GanttConfig` face `ObjectGantt` +actually reads (objectui#6051). `getGanttConfig` has two branches: when +`startDateField` and `endDateField` are both present at the TOP level it builds +its config from top-level keys and returns early; otherwise it reads the `gantt` +block. Everything the first branch reads was undeclared — and unlike +objectui#5903's ten, none of it was hidden behind a cast. `BaseSchema` carries +`[key: string]: any` (objectui#5155's structural ceiling) and the helper's +parameter was `ObjectGridSchema | any`, so `schema.colorField` type-checked as +`any` with no syntax anywhere to grep for. That is also why the census here is an +AST enumeration and not a compile-and-observe: an index signature absorbs every +literal name, so annotating the parameter compiles clean while enforcing nothing. + +**28 keys join the declared surface, each additive and each with a live read +site.** 24 flattened `GanttConfig` members — `colorField`, `borderColorField`, +`dependenciesField`, `parentField`, `typeField`, `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `tooltipFields`, `baselineStartField`, +`baselineEndField`, `groupByField`, `resourceView`, `assigneeField`, +`effortField`, `capacity`, `quickFilters`, `autoZoomToFilter`, `timeSegments`, +`interactions`, `exportFileName`, `timeZone`, `dependencyTypes` — plus `gantt` +(the block face itself) and the three query keys the fetch path reads, +`staticData`, `filter` and `sort`. Nothing is declared that the renderer does not +consume. + +The 24 are DERIVED from `GanttConfig` rather than restated, so the flat spelling +cannot fork from the block spelling, and the invariant is pinned in the type +system: every key of `GanttConfig` must be declared at the node's top level. +Making that derivation possible moved nine members — `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, +`timeZone`, `exportFileName`, `interactions` — out of `plugin-gantt`'s +package-private `GanttConfigEx` and into `@object-ui/types`' `GanttConfig`. They +are a MOVE, not new vocabulary: the `gantt` block already honoured all nine, and +a type private to the plugin could be referenced by neither authoring face. + +Both halves move together, as in objectui#5903: the TS declaration and its zod +mirror gain the same 28 keys at the same requiredness (all optional), the +spec-modelled ones taken from `GanttConfigSchema.shape` by reference, so the +`zod-mirror-parity` ratchet stays at zero drift for this pair and no `KnownDrift` +or `UnmirroredDeclared` entry is added. The mirror builds the flat face and the +`gantt` block from one field map, so they are one schema expressed twice. + +Accept-set change, stated plainly. All 28 keys are additive as KEYS — every one +is optional, and nothing previously legal loses its slot. What changes is that a +**declared** key is now type-validated, so `capacity: 'one'` and +`summaryExtent: 'parent'` are refused where they used to parse green. An +**undeclared** key is still accepted — `BaseSchema` is `.passthrough()`, so this +bought no rejection of misspellings. + +One key's VALUES get stricter and it is worth naming: `gantt` had no mirror entry +at all, so a block rode through `.passthrough()` unvalidated; it is now parsed as +`GanttConfig`, which derives from the spec's `GanttConfigSchema` and REQUIRES +`startDateField`, `endDateField` and `titleField`. That is not a new contract — +`getGanttConfig`'s block branch already fed the block to +`GanttConfigSchema.safeParse` and logged `[ObjectGantt] Invalid gantt +configuration` on failure. The declared face now equals the face the renderer was +already checking. +`packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts` pins both +halves so neither can be misread. + +Which face WINS is unchanged and was not decided here: the flat branch is checked +first and returns early, so a node carrying both spellings still renders the flat +one. (`plugin-map` had the opposite precedence ruled on in objectui#5018; no +equivalent ruling exists for gantt.) diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index ae81c5c22..c1a4635a2 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -24,7 +24,7 @@ import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { toast } from 'sonner'; -import type { ObjectGanttSchema, ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; +import type { ObjectGanttSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; // Aliased on import, following PR #4169's convention: this repo has its OWN // `resolveI18nLabel` over a DIFFERENT vocabulary (the KEYED `{ key, defaultValue }` @@ -89,8 +89,19 @@ export interface QuickFilterDef { } /** - * Hierarchy/type fields are ObjectUI extensions on top of the spec's - * GanttConfig (not yet in @objectstack/spec GanttConfigSchema). + * The gantt config as THIS renderer consumes it: `GanttConfig` from + * `@object-ui/types` — the spec's `GanttConfigSchema` plus objectui's own + * extensions — with `quickFilters` and `timeSegments` narrowed to the plugin's + * runtime types, and the spec-declared members re-documented with the behaviour + * this renderer gives them. + * + * ⚠️ Nothing here may declare a key `GanttConfig` does not (objectui#6051). Nine + * members that lived ONLY here — `lockField`, `objectField`, `summaryExtent`, + * `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, `timeZone`, + * `exportFileName`, `interactions` — were lifted into `@object-ui/types`, because + * a type private to this package can be referenced by neither authoring face. + * Each key is now declared once and both faces derive from it: the `gantt` block + * and the flattened top-level spelling on `ObjectGanttSchema`. */ type GanttConfigEx = GanttConfig & { parentField?: string; @@ -101,55 +112,9 @@ type GanttConfigEx = GanttConfig & { * style levels that only group, never schedule. */ typeField?: string; - /** - * Record field marking a node as view-only (truthy → locked). A locked - * row's bar can't be dragged/resized, its progress can't be dragged, no - * dependency can be drawn from it, and its inline-edit / context-menu - * edit+delete are hidden — but clicking it (open drawer / jump) still works. - * Independent of the global `readOnly`; use to freeze individual levels (e.g. - * work orders) while siblings stay editable. Maps to {@link GanttTask.locked}. - */ - lockField?: string; - /** - * Record field carrying the row's OBJECT API NAME. Mixed-object - * trees (an `api` provider composing parent-object rows with child-object rows) - * need the detail drawer and its full-page link to follow each row's REAL - * object — otherwise a child row's `→` link builds a URL under the view's bound - * object and 404s. Empty/missing value → falls back to the bound object. - */ - objectField?: string; - /** - * How a summary bar's span is computed. `'children'` (default) - * rolls the bar up from its children — min start / max end / duration-weighted - * progress — and IGNORES the record's own dates. `'self'` renders the bar from - * the record's OWN start/end/progress, falling back to rollup - * only for records without dates (e.g. pure grouping levels). Use `'self'` - * when the parent's schedule is authoritative — e.g. a shift plan whose - * work-order children are locked history: under rollup, dragging the plan - * persists its own dates but the bar snaps back to the children's extent on - * refetch. - */ - summaryExtent?: 'children' | 'self'; - /** - * Auto-collapse tree nodes at/below this 0-indexed depth on first render. - * Roots are depth 0. Every node at depth `>= defaultCollapsedDepth` - * with children starts folded; the user can still expand them. Example: a - * project→product→production-plan→work-order tree uses - * `defaultCollapsedDepth: 2` so every production plan (and its work orders) - * starts collapsed. Forwarded to {@link GanttView}. - */ - defaultCollapsedDepth?: number; /** Baseline (planned) start/end fields → planned-vs-actual reference bars. */ baselineStartField?: string; baselineEndField?: string; - /** - * Record field carrying a per-task alert stroke color: any CSS color or - * semantic palette name (red/orange/…). When present the bar keeps its fill - * but gets an outline + halo in that color — e.g. red for overdue, orange for - * due-soon — typically a server-computed alert field. Empty/null → no stroke. - * Maps to {@link GanttTask.borderColor}. - */ - borderColorField?: string; /** * Dynamic Group by. When set, leaf tasks are bucketed by this * field and rendered under one synthesized summary row per distinct value @@ -182,37 +147,6 @@ type GanttConfigEx = GanttConfig & { * (unfiltered) task set while filtering only hides bars. */ autoZoomToFilter?: boolean; - /** - * Whether the backing store persists dependency link TYPES (fs/ss/ff/sf). - * Default true. Set false when dependencies are bare predecessor ids - * (predecessor ids only) — the link menu hides the type switcher (a switch would be - * silently reverted on refetch) and drag-created links are always FS. - * Forwarded to {@link GanttView}. - */ - dependencyTypes?: boolean; - /** - * Business time zone, IANA name like 'Asia/Shanghai'. Renders the - * chart's calendar — shift bands, day columns, snapping, today line, date - * labels — in this zone's wall time for every viewer, instead of the - * browser's zone (which misplaces shift bands for viewers elsewhere). Persisted - * data stays real instants. Forwarded to {@link GanttView}. - */ - timeZone?: string; - /** - * Base name for exported PNG/PDF files, e.g. the view's display - * label — the host's view schema often reaches this component stripped of - * `label`, so views declare it here. Falls back to the object schema label, - * then the object API name. A timestamp suffix is always appended. - */ - exportFileName?: string; - /** - * Per-interaction switches: `move` / `resize` / `progress` / `link`, - * each defaulting to true. Metadata-drivable so a view can e.g. allow bar - * moves but pin durations (`{ resize: false }`) or keep the dependency UI - * read-only (`{ link: false }`). They only narrow what `readOnly` / row locks - * already allow. Forwarded to {@link GanttView}. - */ - interactions?: GanttInteractions; /** * Shift segmentation. When set, the day-mode timeline splits each shift-day * (starting at `dayStart`) into the configured bands (day | night | …): @@ -289,10 +223,12 @@ export interface ObjectGanttProps { * that hid even that. Removing the casts without moving the type would have * changed nothing — the reads would still land on the index signature. * - * The grid-style `{ gantt: { … } }` block keeps working exactly as before: - * `getGanttConfig` reads it through the same index signature, and the - * registered renderer (`index.tsx`) passes `schema: any`, so no runtime shape - * is turned away. + * The grid-style `{ gantt: { … } }` block keeps working exactly as before, and + * since objectui#6051 it is DECLARED rather than read through that index + * signature: `gantt`, the 24 flattened `GanttConfig` keys `getGanttConfig`'s + * first branch reads, and the `staticData`/`filter`/`sort` the fetch path reads + * all sit on `ObjectGanttSchema` now. The registered renderer (`index.tsx`) + * still passes `schema: any`, so no runtime shape is turned away. */ schema: ObjectGanttSchema; dataSource?: DataSource; @@ -363,10 +299,10 @@ function extractServerMessage(err: unknown): string | null { /** * Helper to get gantt configuration from schema */ -function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null { +function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null { let config: GanttConfigEx | null = null; - // 1. Check top-level properties (ObjectGanttSchema style) + // 1. Check top-level properties (the flattened ObjectGanttSchema style) if (schema.startDateField && schema.endDateField) { config = { startDateField: schema.startDateField, @@ -402,7 +338,7 @@ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null { return config; } - // 2. Check schema.gantt (ObjectGridSchema style) + // 2. Check schema.gantt (the block face, ObjectGridSchema style) if (schema.gantt) { config = schema.gantt as GanttConfigEx; } diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts new file mode 100644 index 000000000..be91d0c0e --- /dev/null +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -0,0 +1,398 @@ +/** + * 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. + */ + +/** + * Declaration pin — the flattened `GanttConfig` face, plus the query keys, that + * `ObjectGantt` reads off the node and `ObjectGanttSchema` did not declare + * (objectui#6051). + * + * ## The concealment is an INDEX SIGNATURE, not a cast + * + * objectui#5903's ten keys were hidden by `(schema as any).K` — a syntax a regex + * can find. These are hidden by `BaseSchema`'s `[key: string]: any` + * (objectui#5155's structural ceiling) reached through a parameter typed + * `ObjectGridSchema | any`: `schema.colorField` type-checked as `any` with no cast + * anywhere. Same outcome, no syntax to grep for. + * + * The consequence for anyone re-measuring this: "annotate the parameter and see + * what errors" DOES NOT WORK here. The index signature absorbs every literal name, + * so the annotation compiles clean while enforcing nothing — the same blind + * instrument objectui#6373 records. The census behind this card is therefore an + * AST enumeration of every top-level key read off the `schema` prop in + * `packages/plugin-gantt/src/**` (non-test), stripping `as` / parenthesis / + * non-null wrappers and following local aliases. + * + * ## The measurement, re-derived on the post-#5903 tree + * + * The card reported 24 keys. #5903 landed (PR #6053) between the filing and this + * work, so the population was re-derived rather than inherited: + * + * - 47 distinct top-level keys are read (the card's 47, reproduced); + * - 19 of them are declared — `ObjectGanttSchema`'s own plus `BaseSchema`'s + * `label`/`data`, and the ten #5903 added; + * - 28 are the residue. #5903 absorbed NONE of the card's 24: its ten + * (`skipWeekends`, `holidays`, `persistLayout`, `viewName`, `navigation`, + * `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`) + * are disjoint from them. + * + * The residue is four LARGER than the card's list, and #5903 is why. The card + * scored "declared by neither `ObjectGanttSchema` nor `ObjectGridSchema`" over + * `getGanttConfig`'s flat branch only. #5903 retyped `ObjectGanttProps.schema` + * from `ObjectGridSchema` to `ObjectGanttSchema` — correct, and it is what makes + * these reads resolve against THIS interface — but `staticData`, `filter` and + * `sort` were declared on `ObjectGridSchema` and are not on this one. `gantt`, the + * block face, was outside the line range the card cited and is declared by + * neither. All four have live read sites; all four are declared here. + * + * ## Every key is DERIVED, so the two faces cannot fork + * + * The 24 flattened members take their type from {@link GanttConfig} — the same + * type the `gantt` block carries — rather than restating it. The invariant that + * keeps that true as either side moves is the type-level pin at the bottom: + * every key of `GanttConfig` is declared on the node's flat face. + * + * ## `gantt` is the one key whose VALUES get stricter, and it was already enforced + * + * The other 27 are new optional members: additive on both sides. `gantt` is a new + * optional member too, but it carries a TYPE where the mirror previously had no + * entry at all, so a block that used to ride through `.passthrough()` unvalidated + * is now parsed. `GanttConfig` derives from the spec's `GanttConfigSchema`, which + * REQUIRES `startDateField`, `endDateField` and `titleField` — so a block missing + * one is refused where it used to pass. + * + * That is not a new contract. `getGanttConfig`'s block branch already fed the + * block to `GanttConfigSchema.safeParse` and logged `[ObjectGantt] Invalid gantt + * configuration` when it failed; the flat branch returns before reaching it. + * Declaring `gantt` as `GanttConfig` makes the declared face equal the face the + * renderer was already checking, rather than inventing a stricter one. + * + * ## What the pin has teeth against, and what it does not + * + * Unchanged from #5903, and worth restating because it is the half people read + * wrongly: `BaseSchema` is `.passthrough()` on the zod side and carries + * `[key: string]: any` on the TS side, so declaring these keys does NOT buy + * rejection of a misspelling. What it buys is that a DECLARED key is validated + * (`capacity: 'one'` is refused where it used to parse green), that the published + * types now TEACH the vocabulary, and that the type-level pins below fail when a + * declaration is removed. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectGanttSchema } from '../zod/objectql.zod.js'; +import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; + +const MINIMAL = { + type: 'object-gantt', + objectName: 'task', + startDateField: 'start', + endDateField: 'end', +} as const; + +/** + * The 28 keys this card declared, each with a value its declared type refuses. + * + * 24 flattened `GanttConfig` members, the `gantt` block, and the three query keys + * (`staticData` / `filter` / `sort`). + */ +const DECLARED: ReadonlyArray = [ + // — the flattened GanttConfig face — + ['colorField', 5], + ['borderColorField', 5], + ['dependenciesField', 5], + ['parentField', 5], + ['typeField', 5], + ['lockField', 5], + ['objectField', 5], + ['summaryExtent', 'parent'], + ['defaultCollapsedDepth', '2'], + ['tooltipFields', 'name'], + ['baselineStartField', 5], + ['baselineEndField', 5], + ['groupByField', 5], + ['resourceView', 'yes'], + ['assigneeField', 5], + ['effortField', 5], + ['capacity', 'one'], + ['quickFilters', [{ label: 'Owner' }]], + ['autoZoomToFilter', 'yes'], + ['timeSegments', { bands: [{ label: 'Day' }] }], + ['interactions', 'none'], + ['exportFileName', 5], + ['timeZone', 5], + ['dependencyTypes', 'yes'], + // — the block face — + ['gantt', 'flat'], + // — the query keys the fetch path reads — + ['staticData', { id: 1 }], + ['filter', 'name = 1'], + ['sort', 5], +]; + +/** One well-typed value per declared key — the counter-probe for the block above. */ +const GOOD = { + colorField: 'status', + borderColorField: 'alert', + dependenciesField: 'predecessors', + parentField: 'parent', + typeField: 'kind', + lockField: 'locked', + objectField: 'object_name', + summaryExtent: 'self' as const, + defaultCollapsedDepth: 2, + tooltipFields: ['owner', { field: 'stage', label: 'Stage' }], + baselineStartField: 'plan_start', + baselineEndField: 'plan_end', + groupByField: 'owner', + resourceView: true, + assigneeField: 'owner', + effortField: 'effort', + capacity: 2, + quickFilters: [{ field: 'owner', label: 'Owner' }], + autoZoomToFilter: false, + timeSegments: { + dayStart: '08:00', + bands: [{ key: 'day', label: 'Day shift', start: '08:00', end: '20:00' }], + showMidnight: true, + }, + interactions: { move: true, resize: false, progress: true, link: false }, + exportFileName: 'Shift Plan', + timeZone: 'Asia/Shanghai', + dependencyTypes: false, + gantt: { + // `startDateField` / `endDateField` / `titleField` are REQUIRED by the spec's + // `GanttConfigSchema`, so they are required inside this block — and they were + // already enforced at runtime: `getGanttConfig`'s block branch feeds the block + // to `GanttConfigSchema.safeParse` and warns when it fails. Declaring `gantt` + // as `GanttConfig` makes the declared face equal that already-enforced face. + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + lockField: 'locked', + timeSegments: { bands: [{ label: 'Day shift', start: '08:00', end: '20:00' }] }, + }, + staticData: [{ id: 1, name: 'Task' }], + filter: [['name', '=', 'Task']], + sort: 'name desc', +}; + +describe('ObjectGanttSchema — the flattened gantt config is declared (objectui#6051)', () => { + it('the mirror declares every one of the 28', () => { + const shape = Object.keys(ObjectGanttSchema.shape); + for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); + }); + + it('the census is the measured 28, not a shorter list that drifted', () => { + // Non-vacuity for the loops below: they iterate DECLARED, so a truncated + // DECLARED would pass everything while checking less. The number is the + // measured residue stated in this file's header. + expect(DECLARED).toHaveLength(28); + expect(new Set(DECLARED.map(([k]) => k)).size).toBe(28); + expect(Object.keys(GOOD).sort()).toEqual(DECLARED.map(([k]) => k).sort()); + }); + + it('declares them all OPTIONAL — none of the 28 may become required', () => { + // Requiredness is the half the zod-mirror-parity ratchet compares against + // `../objectql.ts`, where all 28 are `?:`. A mirror that required one would + // reject every gantt already published. + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('materialises NO defaults — an omitted key stays absent after parse', () => { + // `autoZoomToFilter` and `dependencyTypes` default ON *in the renderer*, which + // reads `!== false`. A `.default(true)` here would arrive downstream as an + // explicit author choice; the two spellings are not interchangeable. + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success).toBe(true); + if (!result.success) return; + for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false); + }); + + it('refuses a wrong-typed value on each declared key', () => { + for (const [key, bad] of DECLARED) { + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); + expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); + if (result.success) continue; + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `${key} failed, but not on the ${key} path`).toBeTruthy(); + } + }); + + it('accepts a well-typed value on every declared key', () => { + // Counter-probe for the assertion above: it must be the VALUE being refused, + // not the key. A pin that only ever sees red proves nothing. + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, ...GOOD }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('the block face accepts the same vocabulary as the flat face', () => { + // Both are built from one field map in the mirror. Feed the whole flat payload + // to `gantt` and it must parse — the property this card exists to make true. + const flatOnly = { ...GOOD } as Record; + delete flatOnly.gantt; + delete flatOnly.staticData; + delete flatOnly.filter; + delete flatOnly.sort; + // Plus the three the spec requires of a `GanttConfig` (see `GOOD.gantt`). + const block = { ...flatOnly, startDateField: 'start', endDateField: 'end', titleField: 'name' }; + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: block }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('does NOT reject an undeclared key — objectui#5155 ceiling, measured not assumed', () => { + // Declaring the 28 bought validation of DECLARED keys, not rejection of + // undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this card + // as "misspellings now fail" is reading it wrong, and this pin says so in the + // one place that cannot rot. + const misspelled = ObjectGanttSchema.safeParse({ ...MINIMAL, colourField: 'status', lockFeild: 'locked' }); + expect(misspelled.success).toBe(true); + }); +}); + +/* ── The derived invariant: the two authoring faces are one vocabulary ─────── */ + +/** + * A declaration's OWN declared members, with any index signature stripped. + * + * Same construction as `zod-mirror-parity.test.ts` and for the same measured + * reason: `keyof ObjectGanttSchema` resolves to bare `string`, because + * `BaseSchema`'s `[key: string]: any` absorbs every literal name. A homomorphic + * mapped type maps declared members and index signatures separately, so remapping + * the index-signature keys to `never` leaves the literal members. + */ +type WithoutIndexSignature = { + [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K]; +}; +type DeclaredKeys = Extract, string>; + +/** + * Keys of the BLOCK face that the FLAT face does not declare. `never` is the contract. + * + * `DeclaredKeys` is applied to BOTH sides, and that is load-bearing rather than + * symmetry for its own sake: the spec's `GanttConfigSchema` is `$loose`, so + * `GanttConfig` carries `[x: string]: unknown` of its own and bare + * `keyof GanttConfig` resolves to `string` — measured, when this pin was first + * written that way, and it made the `Exclude` unconditionally `string`. Two index + * signatures, two chances for the same vacuity; the non-vacuity test below pins + * both. + */ +type FlatFaceGaps = Exclude, DeclaredKeys>; + +describe('ObjectGanttSchema (TS) — the flat face declares the whole block vocabulary', () => { + it('every GanttConfig key is declared at the top level too', () => { + // Derived, with no key list to maintain: add a member to `GanttConfig` (or to + // the spec's `GanttConfigSchema`, which it derives from) without declaring the + // flattened spelling and this line stops compiling, NAMING the missing key. + const noGaps: FlatFaceGaps extends never ? true : FlatFaceGaps = true; + expect(noGaps).toBe(true); + }); + + it('the invariant above is not vacuous', () => { + // Two ways `FlatFaceGaps` could be `never` while proving nothing. + // + // 1. `DeclaredKeys` degenerating to `string` — the exact + // index-signature trap this card is about — would `Exclude` everything. + const notWidened: string extends DeclaredKeys ? never : true = true; + // 2. the SAME degeneration on the other side would make the `Exclude` source + // `string`, which is what happened before `DeclaredKeys` was applied here. + const blockNotWidened: string extends DeclaredKeys ? never : true = true; + // 3. `DeclaredKeys` resolving to `never` would leave nothing to + // exclude, and both the spec's members and objectui's must be in it. + const blockHasSpecKeys: 'colorField' extends DeclaredKeys ? true : never = true; + const blockHasLocalKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; + // 4. ...and the flat face must really carry the derived members, not `any`. + const flatHasKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; + expect([notWidened, blockNotWidened, blockHasSpecKeys, blockHasLocalKeys, flatHasKeys]) + .toEqual([true, true, true, true, true]); + }); +}); + +describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () => { + it('refuses a wrong-typed value on every declared key', () => { + // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stops being declared, because the member then resolves to + // `any` through `BaseSchema`'s index signature and the assignment starts + // succeeding. That failure is the signal this card exists to create, and + // `tsconfig.test.json` compiles this file, so it is real enforcement (#3009). + + // @ts-expect-error — `colorField` is declared `string | undefined`. + const colorField: ObjectGanttSchemaTS['colorField'] = 5; + // @ts-expect-error — `borderColorField` is declared `string | undefined`. + const borderColorField: ObjectGanttSchemaTS['borderColorField'] = 5; + // @ts-expect-error — `dependenciesField` is declared `string | undefined`. + const dependenciesField: ObjectGanttSchemaTS['dependenciesField'] = 5; + // @ts-expect-error — `parentField` is declared `string | undefined`. + const parentField: ObjectGanttSchemaTS['parentField'] = 5; + // @ts-expect-error — `typeField` is declared `string | undefined`. + const typeField: ObjectGanttSchemaTS['typeField'] = 5; + // @ts-expect-error — `lockField` is declared `string | undefined`. + const lockField: ObjectGanttSchemaTS['lockField'] = 5; + // @ts-expect-error — `objectField` is declared `string | undefined`. + const objectField: ObjectGanttSchemaTS['objectField'] = 5; + // @ts-expect-error — `summaryExtent` is declared `'children' | 'self' | undefined`. + const summaryExtent: ObjectGanttSchemaTS['summaryExtent'] = 'parent'; + // @ts-expect-error — `defaultCollapsedDepth` is declared `number | undefined`. + const defaultCollapsedDepth: ObjectGanttSchemaTS['defaultCollapsedDepth'] = '2'; + // @ts-expect-error — `tooltipFields` is declared an ARRAY of field refs. + const tooltipFields: ObjectGanttSchemaTS['tooltipFields'] = 'name'; + // @ts-expect-error — `baselineStartField` is declared `string | undefined`. + const baselineStartField: ObjectGanttSchemaTS['baselineStartField'] = 5; + // @ts-expect-error — `baselineEndField` is declared `string | undefined`. + const baselineEndField: ObjectGanttSchemaTS['baselineEndField'] = 5; + // @ts-expect-error — `groupByField` is declared `string | undefined`. + const groupByField: ObjectGanttSchemaTS['groupByField'] = 5; + // @ts-expect-error — `resourceView` is declared `boolean | undefined`. + const resourceView: ObjectGanttSchemaTS['resourceView'] = 'yes'; + // @ts-expect-error — `assigneeField` is declared `string | undefined`. + const assigneeField: ObjectGanttSchemaTS['assigneeField'] = 5; + // @ts-expect-error — `effortField` is declared `string | undefined`. + const effortField: ObjectGanttSchemaTS['effortField'] = 5; + // @ts-expect-error — `capacity` is declared `number | undefined`. + const capacity: ObjectGanttSchemaTS['capacity'] = 'one'; + // @ts-expect-error — `quickFilters[].field` is required. + const quickFilters: ObjectGanttSchemaTS['quickFilters'] = [{ label: 'Owner' }]; + // @ts-expect-error — `autoZoomToFilter` is declared `boolean | undefined`. + const autoZoomToFilter: ObjectGanttSchemaTS['autoZoomToFilter'] = 'yes'; + // @ts-expect-error — `timeSegments.bands[]` requires `start` and `end`. + const timeSegments: ObjectGanttSchemaTS['timeSegments'] = { bands: [{ label: 'Day' }] }; + // @ts-expect-error — `interactions` is declared an object of switches. + const interactions: ObjectGanttSchemaTS['interactions'] = 'none'; + // @ts-expect-error — `exportFileName` is declared `string | undefined`. + const exportFileName: ObjectGanttSchemaTS['exportFileName'] = 5; + // @ts-expect-error — `timeZone` is declared `string | undefined`. + const timeZone: ObjectGanttSchemaTS['timeZone'] = 5; + // @ts-expect-error — `dependencyTypes` is declared `boolean | undefined`. + const dependencyTypes: ObjectGanttSchemaTS['dependencyTypes'] = 'yes'; + // @ts-expect-error — `gantt` is declared `GanttConfig | undefined`, an object. + const gantt: ObjectGanttSchemaTS['gantt'] = 'flat'; + // @ts-expect-error — `staticData` is declared `any[] | undefined`. + const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; + // @ts-expect-error — `filter` is declared `any[] | undefined`. + const filter: ObjectGanttSchemaTS['filter'] = 'name = 1'; + // @ts-expect-error — `sort` is declared `string | SortConfig[] | undefined`. + const sort: ObjectGanttSchemaTS['sort'] = 5; + + expect([ + colorField, borderColorField, dependenciesField, parentField, typeField, + lockField, objectField, summaryExtent, defaultCollapsedDepth, tooltipFields, + baselineStartField, baselineEndField, groupByField, resourceView, assigneeField, + effortField, capacity, quickFilters, autoZoomToFilter, timeSegments, + interactions, exportFileName, timeZone, dependencyTypes, gantt, + staticData, filter, sort, + ]).toHaveLength(28); + }); + + it('accepts the well-typed value on every declared key', () => { + // Counter-probe for the directives above: without this, a declaration narrowed + // to `never` would satisfy every one of them. + const ok: ObjectGanttSchemaTS = { ...MINIMAL, ...GOOD }; + expect(ok.summaryExtent).toBe('self'); + expect(ok.gantt?.lockField).toBe('locked'); + expect(ok.interactions?.resize).toBe(false); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3ebeefacc..f8946e372 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -204,6 +204,105 @@ export type GanttConfig = SpecGanttConfig & { */ showMidnight?: boolean; }; + // ── objectui's own extensions, lifted out of `plugin-gantt` (objectui#6051) ── + // + // The nine members below were declared ONLY in `plugin-gantt`'s package-private + // `GanttConfigEx` intersection — the type `getGanttConfig` casts the `gantt` + // block to. `ObjectGantt` honours every one of them on BOTH authoring faces + // (the `gantt: { … }` block AND the flattened top-level spelling declared on + // `ObjectGanttSchema`), and a type that lives inside the plugin can be + // referenced by neither declaration — so the vocabulary is lifted here rather + // than restated, and the two faces derive from ONE source that cannot fork. + // + // Like `timeSegments` above, each is legal metadata rather than a second + // dialect: `GanttConfigSchema` is `$loose` upstream, so a key the spec does not + // model passes its parse instead of being rejected. + /** + * Record field marking a node as view-only (truthy → locked). A locked + * row's bar can't be dragged/resized, its progress can't be dragged, no + * dependency can be drawn from it, and its inline-edit / context-menu + * edit+delete are hidden — but clicking it (open drawer / jump) still works. + * Independent of the global `readOnly`; use to freeze individual levels (e.g. + * work orders) while siblings stay editable. Maps to `GanttTask.locked`. + */ + lockField?: string; + /** + * Record field carrying the row's OBJECT API NAME. Mixed-object + * trees (an `api` provider composing parent-object rows with child-object rows) + * need the detail drawer and its full-page link to follow each row's REAL + * object — otherwise a child row's `→` link builds a URL under the view's bound + * object and 404s. Empty/missing value → falls back to the bound object. + */ + objectField?: string; + /** + * How a summary bar's span is computed. `'children'` (default) + * rolls the bar up from its children — min start / max end / duration-weighted + * progress — and IGNORES the record's own dates. `'self'` renders the bar from + * the record's OWN start/end/progress, falling back to rollup + * only for records without dates (e.g. pure grouping levels). Use `'self'` + * when the parent's schedule is authoritative — e.g. a shift plan whose + * work-order children are locked history: under rollup, dragging the plan + * persists its own dates but the bar snaps back to the children's extent on + * refetch. + */ + summaryExtent?: 'children' | 'self'; + /** + * Auto-collapse tree nodes at/below this 0-indexed depth on first render. + * Roots are depth 0. Every node at depth `>= defaultCollapsedDepth` + * with children starts folded; the user can still expand them. Example: a + * project→product→production-plan→work-order tree uses + * `defaultCollapsedDepth: 2` so every production plan (and its work orders) + * starts collapsed. Forwarded to `GanttView`. + */ + defaultCollapsedDepth?: number; + /** + * Record field carrying a per-task alert stroke color: any CSS color or + * semantic palette name (red/orange/…). When present the bar keeps its fill + * but gets an outline + halo in that color — e.g. red for overdue, orange for + * due-soon — typically a server-computed alert field. Empty/null → no stroke. + * Maps to `GanttTask.borderColor`. + */ + borderColorField?: string; + /** + * Whether the backing store persists dependency link TYPES (fs/ss/ff/sf). + * Default true. Set false when dependencies are bare predecessor ids + * (predecessor ids only) — the link menu hides the type switcher (a switch would be + * silently reverted on refetch) and drag-created links are always FS. + * Forwarded to `GanttView`. + */ + dependencyTypes?: boolean; + /** + * Business time zone, IANA name like 'Asia/Shanghai'. Renders the + * chart's calendar — shift bands, day columns, snapping, today line, date + * labels — in this zone's wall time for every viewer, instead of the + * browser's zone (which misplaces shift bands for viewers elsewhere). Persisted + * data stays real instants. Forwarded to `GanttView`. + */ + timeZone?: string; + /** + * Base name for exported PNG/PDF files, e.g. the view's display + * label — the host's view schema often reaches this component stripped of + * `label`, so views declare it here. Falls back to the object schema label, + * then the object API name. A timestamp suffix is always appended. + */ + exportFileName?: string; + /** + * Per-interaction switches: `move` / `resize` / `progress` / `link`, + * each defaulting to true. Metadata-drivable so a view can e.g. allow bar + * moves but pin durations (`{ resize: false }`) or keep the dependency UI + * read-only (`{ link: false }`). They only narrow what `readOnly` / row locks + * already allow. Forwarded to `GanttView`. + */ + interactions?: { + /** Bar / subtree dragging (move along the timeline). */ + move?: boolean; + /** Edge resize grips (change duration). */ + resize?: boolean; + /** The progress drag handle. */ + progress?: boolean; + /** Dependency UI: drag-to-link dots AND the create/delete menu entries. */ + link?: boolean; + }; }; /** @@ -2292,6 +2391,114 @@ export interface ObjectGanttSchema extends BaseSchema { * with) {@link readOnly}. Read at `ObjectGantt.tsx`. */ mobileReadOnly?: boolean; + + // ── The flattened `GanttConfig` face (objectui#6051) ──────────────────────── + // + // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. When + // `startDateField` AND `endDateField` are present at the TOP LEVEL it builds the + // config from top-level keys and RETURNS EARLY; otherwise it reads the `gantt` + // block declared below. The keys of the first branch were declared by neither + // this interface nor `ObjectGridSchema`: they were reachable only through + // `BaseSchema`'s `[key: string]: any`, so `schema.colorField` type-checked as + // `any` with no cast anywhere to grep for. That is why the census behind this + // card is an AST enumeration and not a compile-and-observe — an index signature + // swallows exactly the evidence a type annotation would have produced. + // + // Every member below is DERIVED from {@link GanttConfig}, the same type the + // `gantt` block carries, so the flat spelling cannot drift from the block + // spelling. All are optional, matching the renderer: the flat branch reads each + // key bare and forwards `undefined` unchanged. + // + // ⚠️ WHICH face wins is unchanged here and is not this card's question: the flat + // branch is checked first and returns early, so a node carrying both spellings + // renders the flat one. (`plugin-map` had the opposite precedence ruled on in + // objectui#5018; no equivalent ruling exists for gantt.) + + /** Record field carrying the bar fill colour. See {@link GanttConfig}. */ + colorField?: GanttConfig['colorField']; + /** Per-task alert stroke colour field. See {@link GanttConfig.borderColorField}. */ + borderColorField?: GanttConfig['borderColorField']; + /** + * Record field holding this task's predecessors. The CANONICAL spelling — the + * flat branch reads `dependenciesField || dependencyField`, so the singular + * {@link ObjectGanttSchema.dependencyField} above stays accepted as the legacy + * alias and this one wins. + */ + dependenciesField?: GanttConfig['dependenciesField']; + /** Single-parent pointer field building the task tree. See {@link GanttConfig}. */ + parentField?: GanttConfig['parentField']; + /** Record field mapping onto a node kind (task/summary/milestone/group). */ + typeField?: GanttConfig['typeField']; + /** Record field marking a row view-only. See {@link GanttConfig.lockField}. */ + lockField?: GanttConfig['lockField']; + /** Record field carrying the row's own object API name (mixed-object trees). */ + objectField?: GanttConfig['objectField']; + /** How a summary bar's span is computed. See {@link GanttConfig.summaryExtent}. */ + summaryExtent?: GanttConfig['summaryExtent']; + /** Auto-collapse depth on first render. See {@link GanttConfig.defaultCollapsedDepth}. */ + defaultCollapsedDepth?: GanttConfig['defaultCollapsedDepth']; + /** Extra record fields listed in the bar tooltip. See {@link GanttConfig}. */ + tooltipFields?: GanttConfig['tooltipFields']; + /** Baseline (planned) start field → planned-vs-actual reference bars. */ + baselineStartField?: GanttConfig['baselineStartField']; + /** Baseline (planned) end field → planned-vs-actual reference bars. */ + baselineEndField?: GanttConfig['baselineEndField']; + /** Dynamic group-by field, replacing the parent hierarchy. */ + groupByField?: GanttConfig['groupByField']; + /** Render the per-resource load histogram instead of the timeline grid. */ + resourceView?: GanttConfig['resourceView']; + /** Record field the resource view buckets by. Required for {@link resourceView}. */ + assigneeField?: GanttConfig['assigneeField']; + /** Record field carrying each task's load units (default 1). */ + effortField?: GanttConfig['effortField']; + /** Per-resource capacity ceiling (default 1); loads above it flag overload. */ + capacity?: GanttConfig['capacity']; + /** Quick-filter dropdowns rendered above the chart. See {@link GanttConfig}. */ + quickFilters?: GanttConfig['quickFilters']; + /** Recompute the timeline range when filtering (default true). */ + autoZoomToFilter?: GanttConfig['autoZoomToFilter']; + /** Shift segmentation for the day-mode timeline. See {@link GanttConfig.timeSegments}. */ + timeSegments?: GanttConfig['timeSegments']; + /** Per-interaction switches. See {@link GanttConfig.interactions}. */ + interactions?: GanttConfig['interactions']; + /** Base name for exported PNG/PDF files. See {@link GanttConfig.exportFileName}. */ + exportFileName?: GanttConfig['exportFileName']; + /** Business time zone (IANA name) the calendar renders in. */ + timeZone?: GanttConfig['timeZone']; + /** Whether the store persists dependency link TYPES (fs/ss/ff/sf). */ + dependencyTypes?: GanttConfig['dependencyTypes']; + + /** + * The BLOCK face — the same `GanttConfig` vocabulary nested under one key + * (`ObjectGridSchema` style, and what the spec's `ListView.gantt` carries). + * + * Read at `ObjectGantt.tsx` (`getGanttConfig`, branch 2) and honoured in full; + * it was undeclared here for the same reason the flat keys were. Declaring it + * as {@link GanttConfig} is what makes the two faces provably one vocabulary. + * + * ⚠️ `GanttConfig` derives from the spec's `GanttConfigSchema`, which REQUIRES + * `startDateField`, `endDateField` and `titleField` — so those three are + * required INSIDE this block while their flattened twins above stay optional. + * That asymmetry is the spec's, and it is already enforced at runtime: the + * block branch feeds the block to `GanttConfigSchema.safeParse` and warns when + * it fails, while the flat branch returns before reaching that check. + */ + gantt?: GanttConfig; + + // ── The query/data keys the fetch path reads (objectui#6051) ──────────────── + // + // These are NOT gantt config: they are the read the component issues. They were + // declared on `ObjectGridSchema`, which is what `ObjectGanttProps.schema` used + // to be typed as — objectui#5903 retyped that prop to this interface, which is + // correct and is why they now have to be declared HERE. `plugin-gantt`'s + // registry mapping (`OBJECT_GANTT_DATA_SOURCE` in `index.tsx`) names `filter` + // and `sort` as the two keys the element data-source binding maps onto. + /** Inline records, wrapped into a `{ provider: 'value' }` config by `getDataConfig`. */ + staticData?: any[]; + /** Query filter (JSON Rules format), forwarded verbatim as `$filter`. */ + filter?: any[]; + /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. */ + sort?: string | SortConfig[]; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e8b9441de..e54bf9379 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -594,6 +594,59 @@ export const ObjectTreeSchema = BaseSchema.extend({ defaultExpandedDepth: z.number().optional().describe('Default expansion depth (0 = roots only)'), }); +/** + * objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares + * on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051 + * lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`; + * `timeSegments` was already there). + * + * Held as ONE field map and spread into BOTH faces below — the flattened + * top-level spelling and the nested `gantt` block — so the two cannot drift on + * this side either, the same way the TS side derives both from `GanttConfig`. + * + * Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads + * `^export const` out of this directory and would require a registered TS + * counterpart for it. It has none of its own — it is a fragment of `GanttConfig`, + * and `GanttConfig` is checked through the two faces that carry it. + */ +const GanttConfigExtensionFields = { + borderColorField: z.string().optional().describe('Record field carrying a per-task alert stroke colour'), + lockField: z.string().optional().describe('Record field marking a row view-only (truthy → locked)'), + objectField: z.string().optional().describe("Record field carrying the row's own object API name"), + summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed"), + defaultCollapsedDepth: z.number().optional().describe('Auto-collapse tree nodes at/below this 0-indexed depth'), + dependencyTypes: z.boolean().optional().describe('Whether the store persists dependency link TYPES (fs/ss/ff/sf)'), + timeZone: z.string().optional().describe("Business time zone (IANA name) the chart's calendar renders in"), + exportFileName: z.string().optional().describe('Base name for exported PNG/PDF files'), + interactions: z + .object({ + move: z.boolean().optional().describe('Bar / subtree dragging'), + resize: z.boolean().optional().describe('Edge resize grips'), + progress: z.boolean().optional().describe('The progress drag handle'), + link: z.boolean().optional().describe('Dependency UI: drag-to-link dots and the create/delete menu'), + }) + .optional() + .describe('Per-interaction switches, each defaulting to true'), + timeSegments: z + .object({ + dayStart: z.string().optional().describe("Clock time the shift-day begins, 'HH:mm'"), + bands: z + .array( + z.object({ + key: z.string().optional().describe('Stable band id'), + label: z.string().describe('Display label'), + start: z.string().describe("Band start, 'HH:mm'"), + end: z.string().describe("Band end, 'HH:mm'"), + color: z.string().optional().describe('Accent colour for the column tint'), + }) + ) + .describe('Ordered bands covering the 24h shift-day'), + showMidnight: z.boolean().optional().describe('Draw the dashed calendar-midnight cue'), + }) + .optional() + .describe('Shift segmentation for the day-mode timeline'), +}; + /** * ObjectGantt Schema */ @@ -642,6 +695,43 @@ export const ObjectGanttSchema = BaseSchema.extend({ showBaselines: z.boolean().optional().describe('Render planned-vs-actual baseline bars — defaults ON, only an explicit false disables'), readOnly: z.boolean().optional().describe('Disable every write path and lock the record drawer'), mobileReadOnly: z.boolean().optional().describe('Auto read-only on narrow viewports — defaults ON, only an explicit false disables'), + // objectui#6051 — the FLATTENED `GanttConfig` face. `getGanttConfig` builds its + // config from these top-level keys and returns early whenever `startDateField` + // and `endDateField` are both present; nothing declared them, on either side, + // because `BaseSchema`'s index signature admits them untyped. Mirrored at the + // SAME requiredness as `../objectql.ts` (all optional) so the zod-mirror-parity + // ratchet stays at zero drift for this pair. + // + // The spec-modelled members are taken from `SpecGanttConfigSchema.shape` by + // reference, exactly as `viewMode` above is, so the vocabulary cannot fork. + colorField: SpecGanttConfigSchema.shape.colorField, + dependenciesField: SpecGanttConfigSchema.shape.dependenciesField, + parentField: SpecGanttConfigSchema.shape.parentField, + typeField: SpecGanttConfigSchema.shape.typeField, + tooltipFields: SpecGanttConfigSchema.shape.tooltipFields, + baselineStartField: SpecGanttConfigSchema.shape.baselineStartField, + baselineEndField: SpecGanttConfigSchema.shape.baselineEndField, + groupByField: SpecGanttConfigSchema.shape.groupByField, + resourceView: SpecGanttConfigSchema.shape.resourceView, + assigneeField: SpecGanttConfigSchema.shape.assigneeField, + effortField: SpecGanttConfigSchema.shape.effortField, + capacity: SpecGanttConfigSchema.shape.capacity, + quickFilters: SpecGanttConfigSchema.shape.quickFilters, + autoZoomToFilter: SpecGanttConfigSchema.shape.autoZoomToFilter, + // …and objectui's own ten, from the one field map above. + ...GanttConfigExtensionFields, + // The BLOCK face — the same vocabulary nested under one key, which is what + // `getGanttConfig`'s second branch reads. Built from the SAME field map, so the + // block and the flat spelling above are one schema expressed twice. + gantt: SpecGanttConfigSchema.extend(GanttConfigExtensionFields) + .optional() + .describe('Gantt configuration (the block face; the flattened top-level spelling wins when both are present)'), + // The query/data keys the fetch path reads. They were declared on + // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before + // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here. + staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config'), + filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), + sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration, forwarded as $orderby'), }); /** From 2760b32a66a9272a83de404c38b9ab219cde8ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 02:42:01 +0000 Subject: [PATCH 2/2] refactor(types): sever the `gantt` block declaration to its own card (#6475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM ruling 2026-08-26 on #6051: `gantt` is the one key of the measured 28 whose declaration would NOT have been additive, and a published CLI's refusal behaviour gets decided on its own card rather than inside a 27-key declaration PR. The other 27 are unchanged and land here. Severed: `gantt?: GanttConfig` on `ObjectGanttSchema` and its `SpecGanttConfigSchema.extend(GanttConfigExtensionFields)` mirror entry. With no mirror entry a block rides through `.passthrough()` unvalidated; with one it is parsed against the spec's `GanttConfigSchema`, which REQUIRES startDateField / endDateField / titleField — and `ObjectGanttSchema` reaches the CLI's `validate`/`check` through `AnyComponentSchema`, so a block missing one of the three would move from "accepted, then warned about at runtime" to "refused at authoring time". - The omission is recorded where someone would look for the key: a comment on the declaration, a comment in the mirror, and the `ObjectGanttProps.schema` JSDoc. All three name #6475 and say `getGanttConfig` still reads and honours the block, so none of them can be read as "nothing reads `gantt`". - Today's behaviour is PINNED rather than left implicit — the test that used to assert the block face accepts the flat vocabulary now asserts the mirror has no `gantt` entry and that a block missing the required trio (and even a wrong-typed block) parses green. The omission is a measured state. - `GanttConfigExtensionFields` stays: it is still the single source the flat face is built from, and the one line #6475 would need. Its doc no longer claims it feeds two faces. This change is now additive-only end to end: 27 new optional keys, no narrowing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .../6051-gantt-flat-config-declared-keys.md | 49 ++++++---- packages/plugin-gantt/src/ObjectGantt.tsx | 14 +-- .../gantt-flat-config-declared-keys.test.ts | 96 +++++++++---------- packages/types/src/objectql.ts | 37 +++---- packages/types/src/zod/objectql.zod.ts | 24 +++-- 5 files changed, 121 insertions(+), 99 deletions(-) diff --git a/.changeset/6051-gantt-flat-config-declared-keys.md b/.changeset/6051-gantt-flat-config-declared-keys.md index 32ea4b3be..7aa10e1dc 100644 --- a/.changeset/6051-gantt-flat-config-declared-keys.md +++ b/.changeset/6051-gantt-flat-config-declared-keys.md @@ -15,16 +15,25 @@ parameter was `ObjectGridSchema | any`, so `schema.colorField` type-checked as AST enumeration and not a compile-and-observe: an index signature absorbs every literal name, so annotating the parameter compiles clean while enforcing nothing. -**28 keys join the declared surface, each additive and each with a live read +**27 keys join the declared surface, each additive and each with a live read site.** 24 flattened `GanttConfig` members — `colorField`, `borderColorField`, `dependenciesField`, `parentField`, `typeField`, `lockField`, `objectField`, `summaryExtent`, `defaultCollapsedDepth`, `tooltipFields`, `baselineStartField`, `baselineEndField`, `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`, `quickFilters`, `autoZoomToFilter`, `timeSegments`, -`interactions`, `exportFileName`, `timeZone`, `dependencyTypes` — plus `gantt` -(the block face itself) and the three query keys the fetch path reads, -`staticData`, `filter` and `sort`. Nothing is declared that the renderer does not -consume. +`interactions`, `exportFileName`, `timeZone`, `dependencyTypes` — plus the three +query keys the fetch path reads, `staticData`, `filter` and `sort`. Nothing is +declared that the renderer does not consume. + +**`GanttConfig` itself gains nine members and is a published type**, exported by +name from `packages/types/src/index.ts`: `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, +`timeZone`, `exportFileName`, `interactions`. The entry file's diff is empty only +because the export list already named the type — the widening happened at the +declaration. + +**The 28th measured key, `gantt` (the block face), is deliberately NOT declared** +— see the closing section. The 24 are DERIVED from `GanttConfig` rather than restated, so the flat spelling cannot fork from the block spelling, and the invariant is pinned in the type @@ -37,27 +46,33 @@ are a MOVE, not new vocabulary: the `gantt` block already honoured all nine, and a type private to the plugin could be referenced by neither authoring face. Both halves move together, as in objectui#5903: the TS declaration and its zod -mirror gain the same 28 keys at the same requiredness (all optional), the +mirror gain the same 27 keys at the same requiredness (all optional), the spec-modelled ones taken from `GanttConfigSchema.shape` by reference, so the `zod-mirror-parity` ratchet stays at zero drift for this pair and no `KnownDrift` or `UnmirroredDeclared` entry is added. The mirror builds the flat face and the `gantt` block from one field map, so they are one schema expressed twice. -Accept-set change, stated plainly. All 28 keys are additive as KEYS — every one -is optional, and nothing previously legal loses its slot. What changes is that a +Accept-set change, stated plainly. All 27 keys are additive — every one is +optional, and nothing previously legal loses its slot. What changes is that a **declared** key is now type-validated, so `capacity: 'one'` and `summaryExtent: 'parent'` are refused where they used to parse green. An **undeclared** key is still accepted — `BaseSchema` is `.passthrough()`, so this -bought no rejection of misspellings. +bought no rejection of misspellings. There is no narrowing anywhere in this +change. -One key's VALUES get stricter and it is worth naming: `gantt` had no mirror entry -at all, so a block rode through `.passthrough()` unvalidated; it is now parsed as -`GanttConfig`, which derives from the spec's `GanttConfigSchema` and REQUIRES -`startDateField`, `endDateField` and `titleField`. That is not a new contract — -`getGanttConfig`'s block branch already fed the block to -`GanttConfigSchema.safeParse` and logged `[ObjectGantt] Invalid gantt -configuration` on failure. The declared face now equals the face the renderer was -already checking. +**`gantt` is severed on purpose (objectui#6475), not overlooked.** It is the 28th +key of the measured residue and a genuine read — `getGanttConfig`'s second branch +honours it in full — but it is the one key whose declaration would NOT have been +additive. It has no mirror entry today, so a block rides through `.passthrough()` +unvalidated; declaring it as `GanttConfig` means it gets parsed against the spec's +`GanttConfigSchema`, which REQUIRES `startDateField`, `endDateField` and +`titleField`, and `ObjectGanttSchema` reaches the CLI's `validate` / `check` +through `AnyComponentSchema`. A published CLI's refusal behaviour is decided on +its own card, where reviewers can see what they are approving; objectui#6475 +carries the full measurement, including the case FOR enforcing it (the renderer +already feeds that block to `GanttConfigSchema.safeParse` and warns, so enforcing +restores declared = enforced rather than inventing a contract). Today's behaviour +is pinned in the test file so the omission is a measured state, not a silent gap. `packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts` pins both halves so neither can be misread. diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index c1a4635a2..e886e376d 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -223,12 +223,14 @@ export interface ObjectGanttProps { * that hid even that. Removing the casts without moving the type would have * changed nothing — the reads would still land on the index signature. * - * The grid-style `{ gantt: { … } }` block keeps working exactly as before, and - * since objectui#6051 it is DECLARED rather than read through that index - * signature: `gantt`, the 24 flattened `GanttConfig` keys `getGanttConfig`'s - * first branch reads, and the `staticData`/`filter`/`sort` the fetch path reads - * all sit on `ObjectGanttSchema` now. The registered renderer (`index.tsx`) - * still passes `schema: any`, so no runtime shape is turned away. + * objectui#6051 declared what the FLAT branch reads: the 24 flattened + * `GanttConfig` keys `getGanttConfig`'s first branch consumes, plus the + * `staticData` / `filter` / `sort` the fetch path reads. The grid-style + * `{ gantt: { … } }` block keeps working exactly as before and is still read + * through the index signature — declaring it is the one change that would not + * have been additive, and it is severed to objectui#6475. The registered + * renderer (`index.tsx`) still passes `schema: any`, so no runtime shape is + * turned away either way. */ schema: ObjectGanttSchema; dataSource?: DataSource; diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index be91d0c0e..b2efa27e2 100644 --- a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -40,6 +40,9 @@ * `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`) * are disjoint from them. * + * 27 of the 28 are declared here. The 28th, `gantt`, is severed to objectui#6475 + * — see the section below. + * * The residue is four LARGER than the card's list, and #5903 is why. The card * scored "declared by neither `ObjectGanttSchema` nor `ObjectGridSchema`" over * `getGanttConfig`'s flat branch only. #5903 retyped `ObjectGanttProps.schema` @@ -56,20 +59,28 @@ * keeps that true as either side moves is the type-level pin at the bottom: * every key of `GanttConfig` is declared on the node's flat face. * - * ## `gantt` is the one key whose VALUES get stricter, and it was already enforced + * ## Why `gantt` is measured here but NOT declared here + * + * The 27 declared below are new OPTIONAL members: additive on both sides, nothing + * previously legal loses its slot. `gantt` is the one that would not have been. + * It has no mirror entry at all, so a block rides through `.passthrough()` + * unvalidated; declaring it as `GanttConfig` means it gets parsed, and + * `GanttConfig` derives from the spec's `GanttConfigSchema`, which REQUIRES + * `startDateField`, `endDateField` and `titleField`. `ObjectGanttSchema` is a + * member of `AnyComponentSchema`, so that reaches `safeValidateSchema` and with + * it the CLI's `validate` / `check` — a block missing one of the three would move + * from "accepted, then warned about at runtime" to "refused at authoring time". * - * The other 27 are new optional members: additive on both sides. `gantt` is a new - * optional member too, but it carries a TYPE where the mirror previously had no - * entry at all, so a block that used to ride through `.passthrough()` unvalidated - * is now parsed. `GanttConfig` derives from the spec's `GanttConfigSchema`, which - * REQUIRES `startDateField`, `endDateField` and `titleField` — so a block missing - * one is refused where it used to pass. + * PM ruling (2026-08-26): sever it, so a published CLI's refusal behaviour is + * decided on its own card rather than inside a 27-key declaration PR. + * objectui#6475 carries the full measurement, including the case FOR enforcing — + * `getGanttConfig`'s block branch already feeds the block to + * `GanttConfigSchema.safeParse` and logs `[ObjectGantt] Invalid gantt + * configuration`, so declaring it restores declared = enforced rather than + * inventing a stricter contract. * - * That is not a new contract. `getGanttConfig`'s block branch already fed the - * block to `GanttConfigSchema.safeParse` and logged `[ObjectGantt] Invalid gantt - * configuration` when it failed; the flat branch returns before reaching it. - * Declaring `gantt` as `GanttConfig` makes the declared face equal the face the - * renderer was already checking, rather than inventing a stricter one. + * Today's behaviour is pinned below rather than left implicit, so the omission is + * a measured state and not a silent gap. * * ## What the pin has teeth against, and what it does not * @@ -94,10 +105,11 @@ const MINIMAL = { } as const; /** - * The 28 keys this card declared, each with a value its declared type refuses. + * The 27 keys this card declared, each with a value its declared type refuses. * - * 24 flattened `GanttConfig` members, the `gantt` block, and the three query keys - * (`staticData` / `filter` / `sort`). + * 24 flattened `GanttConfig` members and the three query keys (`staticData` / + * `filter` / `sort`). The 28th measured key, `gantt`, is severed to objectui#6475 + * and is pinned separately below as an UNDECLARED read. */ const DECLARED: ReadonlyArray = [ // — the flattened GanttConfig face — @@ -125,8 +137,6 @@ const DECLARED: ReadonlyArray = [ ['exportFileName', 5], ['timeZone', 5], ['dependencyTypes', 'yes'], - // — the block face — - ['gantt', 'flat'], // — the query keys the fetch path reads — ['staticData', { id: 1 }], ['filter', 'name = 1'], @@ -163,25 +173,13 @@ const GOOD = { exportFileName: 'Shift Plan', timeZone: 'Asia/Shanghai', dependencyTypes: false, - gantt: { - // `startDateField` / `endDateField` / `titleField` are REQUIRED by the spec's - // `GanttConfigSchema`, so they are required inside this block — and they were - // already enforced at runtime: `getGanttConfig`'s block branch feeds the block - // to `GanttConfigSchema.safeParse` and warns when it fails. Declaring `gantt` - // as `GanttConfig` makes the declared face equal that already-enforced face. - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - lockField: 'locked', - timeSegments: { bands: [{ label: 'Day shift', start: '08:00', end: '20:00' }] }, - }, staticData: [{ id: 1, name: 'Task' }], filter: [['name', '=', 'Task']], sort: 'name desc', }; describe('ObjectGanttSchema — the flattened gantt config is declared (objectui#6051)', () => { - it('the mirror declares every one of the 28', () => { + it('the mirror declares every one of the 27', () => { const shape = Object.keys(ObjectGanttSchema.shape); for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); }); @@ -190,14 +188,14 @@ describe('ObjectGanttSchema — the flattened gantt config is declared (objectui // Non-vacuity for the loops below: they iterate DECLARED, so a truncated // DECLARED would pass everything while checking less. The number is the // measured residue stated in this file's header. - expect(DECLARED).toHaveLength(28); - expect(new Set(DECLARED.map(([k]) => k)).size).toBe(28); + expect(DECLARED).toHaveLength(27); + expect(new Set(DECLARED.map(([k]) => k)).size).toBe(27); expect(Object.keys(GOOD).sort()).toEqual(DECLARED.map(([k]) => k).sort()); }); - it('declares them all OPTIONAL — none of the 28 may become required', () => { + it('declares them all OPTIONAL — none of the 27 may become required', () => { // Requiredness is the half the zod-mirror-parity ratchet compares against - // `../objectql.ts`, where all 28 are `?:`. A mirror that required one would + // `../objectql.ts`, where all 27 are `?:`. A mirror that required one would // reject every gantt already published. const result = ObjectGanttSchema.safeParse(MINIMAL); expect(result.success ? null : result.error.issues).toBe(null); @@ -230,18 +228,17 @@ describe('ObjectGanttSchema — the flattened gantt config is declared (objectui expect(result.success ? null : result.error.issues).toBe(null); }); - it('the block face accepts the same vocabulary as the flat face', () => { - // Both are built from one field map in the mirror. Feed the whole flat payload - // to `gantt` and it must parse — the property this card exists to make true. - const flatOnly = { ...GOOD } as Record; - delete flatOnly.gantt; - delete flatOnly.staticData; - delete flatOnly.filter; - delete flatOnly.sort; - // Plus the three the spec requires of a `GanttConfig` (see `GOOD.gantt`). - const block = { ...flatOnly, startDateField: 'start', endDateField: 'end', titleField: 'name' }; - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: block }); - expect(result.success ? null : result.error.issues).toBe(null); + it('the `gantt` BLOCK face is still undeclared, and today rides through UNVALIDATED', () => { + // The 28th measured key, severed to objectui#6475. Pinned rather than left + // implicit so the omission is a measured state: a block missing the trio the + // spec's `GanttConfigSchema` REQUIRES parses green today, because the mirror + // has no `gantt` entry and `BaseSchema` is `.passthrough()`. + expect(Object.keys(ObjectGanttSchema.shape)).not.toContain('gantt'); + const missingTrio = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { lockField: 'locked' } }); + expect(missingTrio.success).toBe(true); + // Not even a wrong-TYPED block is refused — that is what "no entry" means, and + // it is exactly what objectui#6475 proposes to change. + expect(ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: 'flat' }).success).toBe(true); }); it('does NOT reject an undeclared key — objectui#5155 ceiling, measured not assumed', () => { @@ -368,8 +365,6 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () const timeZone: ObjectGanttSchemaTS['timeZone'] = 5; // @ts-expect-error — `dependencyTypes` is declared `boolean | undefined`. const dependencyTypes: ObjectGanttSchemaTS['dependencyTypes'] = 'yes'; - // @ts-expect-error — `gantt` is declared `GanttConfig | undefined`, an object. - const gantt: ObjectGanttSchemaTS['gantt'] = 'flat'; // @ts-expect-error — `staticData` is declared `any[] | undefined`. const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; // @ts-expect-error — `filter` is declared `any[] | undefined`. @@ -382,9 +377,9 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () lockField, objectField, summaryExtent, defaultCollapsedDepth, tooltipFields, baselineStartField, baselineEndField, groupByField, resourceView, assigneeField, effortField, capacity, quickFilters, autoZoomToFilter, timeSegments, - interactions, exportFileName, timeZone, dependencyTypes, gantt, + interactions, exportFileName, timeZone, dependencyTypes, staticData, filter, sort, - ]).toHaveLength(28); + ]).toHaveLength(27); }); it('accepts the well-typed value on every declared key', () => { @@ -392,7 +387,6 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () // to `never` would satisfy every one of them. const ok: ObjectGanttSchemaTS = { ...MINIMAL, ...GOOD }; expect(ok.summaryExtent).toBe('self'); - expect(ok.gantt?.lockField).toBe('locked'); expect(ok.interactions?.resize).toBe(false); }); }); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index f8946e372..b84200d08 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2468,22 +2468,27 @@ export interface ObjectGanttSchema extends BaseSchema { /** Whether the store persists dependency link TYPES (fs/ss/ff/sf). */ dependencyTypes?: GanttConfig['dependencyTypes']; - /** - * The BLOCK face — the same `GanttConfig` vocabulary nested under one key - * (`ObjectGridSchema` style, and what the spec's `ListView.gantt` carries). - * - * Read at `ObjectGantt.tsx` (`getGanttConfig`, branch 2) and honoured in full; - * it was undeclared here for the same reason the flat keys were. Declaring it - * as {@link GanttConfig} is what makes the two faces provably one vocabulary. - * - * ⚠️ `GanttConfig` derives from the spec's `GanttConfigSchema`, which REQUIRES - * `startDateField`, `endDateField` and `titleField` — so those three are - * required INSIDE this block while their flattened twins above stay optional. - * That asymmetry is the spec's, and it is already enforced at runtime: the - * block branch feeds the block to `GanttConfigSchema.safeParse` and warns when - * it fails, while the flat branch returns before reaching that check. - */ - gantt?: GanttConfig; + // ⛔ `gantt` — the BLOCK face — is DELIBERATELY still undeclared here + // (objectui#6475). It is the 28th key of this card's residue and the only one + // objectui#6051 did not declare; the omission is a scoping decision, not an + // oversight, and reading it as "nothing reads `gantt`" would be wrong — + // `getGanttConfig`'s second branch reads it and honours it in full. + // + // Declaring it is not additive the way the 27 above are. It has no mirror entry + // at all today, so a block rides through `.passthrough()` UNVALIDATED; declaring + // it as {@link GanttConfig} means it gets parsed, and `GanttConfig` derives from + // the spec's `GanttConfigSchema`, which REQUIRES `startDateField`, + // `endDateField` and `titleField`. Because `ObjectGanttSchema` is a member of + // `AnyComponentSchema`, that reaches `safeValidateSchema` and therefore the + // CLI's `validate` / `check` commands: a block missing one of the three moves + // from "accepted, then warned about at runtime" to "refused at authoring time". + // + // That is very likely the RIGHT change — the renderer already feeds the block to + // `GanttConfigSchema.safeParse` and warns, so enforcing restores + // declared = enforced rather than inventing a contract — but it is a published + // CLI's refusal behaviour, and an in-repo census cannot see authored metadata + // living outside this tree. objectui#6475 carries the full measurement and the + // decision. // ── The query/data keys the fetch path reads (objectui#6051) ──────────────── // diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e54bf9379..13559a758 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -600,9 +600,11 @@ export const ObjectTreeSchema = BaseSchema.extend({ * lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`; * `timeSegments` was already there). * - * Held as ONE field map and spread into BOTH faces below — the flattened - * top-level spelling and the nested `gantt` block — so the two cannot drift on - * this side either, the same way the TS side derives both from `GanttConfig`. + * Held as ONE field map rather than inlined, so the flattened top-level spelling + * below is built from a single source — the same way the TS side derives its + * flattened members from `GanttConfig`. It is deliberately the shape the nested + * `gantt` block would ALSO be built from, one line, if objectui#6475 rules that + * block in; today that entry is severed and this map has one consumer. * * Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads * `^export const` out of this directory and would require a registered TS @@ -720,12 +722,16 @@ export const ObjectGanttSchema = BaseSchema.extend({ autoZoomToFilter: SpecGanttConfigSchema.shape.autoZoomToFilter, // …and objectui's own ten, from the one field map above. ...GanttConfigExtensionFields, - // The BLOCK face — the same vocabulary nested under one key, which is what - // `getGanttConfig`'s second branch reads. Built from the SAME field map, so the - // block and the flat spelling above are one schema expressed twice. - gantt: SpecGanttConfigSchema.extend(GanttConfigExtensionFields) - .optional() - .describe('Gantt configuration (the block face; the flattened top-level spelling wins when both are present)'), + // ⛔ `gantt` — the BLOCK face `getGanttConfig`'s second branch reads — is + // DELIBERATELY still unmirrored (objectui#6475). It would be one line here, + // `SpecGanttConfigSchema.extend(GanttConfigExtensionFields).optional()`, built + // from the same field map as the flat face above; the reason it is not is that + // it is the one entry that NARROWS. With no entry a block rides through + // `.passthrough()` unvalidated; with one it is parsed against the spec's + // `GanttConfigSchema`, which REQUIRES startDateField/endDateField/titleField — + // and this mirror reaches the CLI's `validate`/`check` through + // `AnyComponentSchema`, so that is a published refusal change. See the TS + // declaration's note in `../objectql.ts` and objectui#6475. // The query/data keys the fetch path reads. They were declared on // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here.