From 024d964aab6dc3114d065d47ec1dacae1a747b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 15:44:16 +0000 Subject: [PATCH] docs(spec): name all five exportOptions members on object-grid, not two `ComponentPropsMap['object-grid'].exportOptions` is `z.unknown()`, so nothing about the key is parsed, refused or stripped and the `.describe()` string is the entire account of its shape. It named `formats` and `streaming`; the only renderer reads five, and `maxRecords`, `includeHeaders` and `fileNamePrefix` had no authoring surface at all. Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` with objectui's own scanner: formats 2 read sites, streaming 2, maxRecords 1, includeHeaders 1, fileNamePrefix 1, absent-name control 0. The key is unchanged and no accept set moves. A pin holds the describe string's member enumeration equal to the members `ListViewExportOptionsSchema` declares, so the list is derived rather than restated, and records that the key is unvalidated today. `content/docs/references/ui/component.mdx` is regenerated. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- ...ct-grid-export-options-describe-members.md | 52 +++++++ content/docs/references/ui/component.mdx | 2 +- ...ct-grid-export-options-members.pin.test.ts | 127 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 3 +- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 .changeset/17166-object-grid-export-options-describe-members.md create mode 100644 packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts diff --git a/.changeset/17166-object-grid-export-options-describe-members.md b/.changeset/17166-object-grid-export-options-describe-members.md new file mode 100644 index 0000000000..fa6e4ad06d --- /dev/null +++ b/.changeset/17166-object-grid-export-options-describe-members.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': patch +--- + +`ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two + +The entry is `z.unknown()`, so nothing about this key is parsed, refused or +stripped: a member that does not exist draws no error and has no effect, and a +member that does exist cannot be discovered from the schema. That makes the +`.describe()` string the entire account of the key's shape rather than a summary +of an enforced one — and it projects straight into +`content/docs/references/ui/component.mdx`, which is what an author (or a +generating model, ADR-0033) reads. + +It named two members, `formats` and `streaming`. The only renderer reads five. + +Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` +— objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the +`schema.exportOptions` expression and the `exportConfig` local bound to it, with +objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose +comment/string stripping is what stops a prose mention of a key being counted as +a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, +`includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control +(`zzzNotAMember`) 0 on the same instrument — which is what makes those five +counts readings rather than a matcher that matches anything. The same instrument +answers the same five, with the same per-member counts, at objectui +`3fbdd4a2dae1`, so the set is not an artefact of the pin's age. + +The three missing members are `maxRecords`, `includeHeaders` and +`fileNamePrefix`. An author reading the old string learned that +`exportOptions` takes `{ formats, streaming }` and had no way to reach the other +three short of reading the renderer's source — the shape objectstack#8010 +closed for this same key one layer out, when `streaming` was read for releases +while no schema declared it. + +⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either +direction. Giving `exportOptions` a real shape is a separate and much larger +change with its own review requirements; this is the docs half only. + +The new list is not restated in prose that can drift on its own. A pin holds the +describe string's member enumeration equal to the members +`ListViewExportOptionsSchema` declares — the spec's own five-key declaration of +this same authoring block, reached through `ListViewSchema.exportOptions`'s +object branch and itself derived from that same read set. Both spellings reach +one renderer, so narrowing or widening the declared block now reds the +`z.unknown()` prose instead of leaving it quietly behind: the declared side has +parse failures to catch drift, this side had nothing. The pin also records that +the key is unvalidated today, so the day it grows an accept set is a deliberate +decision rather than a silent one. + +`content/docs/references/ui/component.mdx` is regenerated from the string +(`gen:schema` then `gen:docs`) and carries the same one-line change. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 2e3c935754..f59531a029 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -428,7 +428,7 @@ Sort field and direction pair | **reorderableColumns** | `boolean` | optional | Allow column drag-reorder | | **frozenColumns** | `number` | optional | How many leading columns stay frozen (default 1) | | **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers | -| **exportOptions** | `any` | optional | Export config (`{ formats, streaming }`) | +| **exportOptions** | `any` | optional | Export config (`{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`). Unvalidated here (`z.unknown()`), so this list is the whole account of the shape; `ListViewSchema.exportOptions` declares the same five members with their per-member contract | | **operations** | `any` | optional | Operation toggles (`{ export: false, … }`) | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | diff --git a/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts b/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts new file mode 100644 index 0000000000..eb2122414e --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17166] `ComponentPropsMap['object-grid'].exportOptions`'s `.describe()` must + * name every member the renderer reads — because here the prose IS the shape. + * + * ## Why this key is different from its neighbours + * + * The entry is `z.unknown()`. Nothing is parsed, nothing is refused, nothing is + * stripped: an author who writes a member that does not exist gets no error and + * no effect, and an author who omits one that does exist has no way to discover + * it. So the `.describe()` string is not a SUMMARY of an enforced shape — it is + * the entire account of the shape that exists at this position, and it projects + * straight into `content/docs/references/ui/component.mdx`, which is what an + * author (or a generating model, ADR-0033) reads. + * + * Until #17166 that string named two members, `formats` and `streaming`, of the + * five the only renderer reads. + * + * ## What was measured, and on which tree + * + * Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` + * — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the + * `schema.exportOptions` expression and the `exportConfig` local bound to it, + * with objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose + * comment/string stripping is what keeps a prose mention of a key from being + * counted as a read): + * + * formats 2 · streaming 2 · maxRecords 1 · includeHeaders 1 · fileNamePrefix 1 + * + * and an absent-name control (`zzzNotAMember`) reading 0 on the same instrument, + * which is what makes those five counts readings rather than a matcher that + * matches anything. ⚠️ Those counts are a dated observation and belong to that + * tree; this pin does NOT re-derive them, and ⛔ must not be read as asserting + * them today. + * + * ## Why the list is DERIVED here and not restated + * + * A restated list is a third copy of the contract, and the copy is what drifts — + * which is the whole defect this file closes. So the expected member list is + * read from `ListViewSchema.exportOptions`'s object branch + * (`ListViewExportOptionsSchema`), the spec's OWN five-key declaration of this + * same authoring block, itself derived from that same read set at #8010. Both + * spellings — the page-component `object-grid` props and the list view's + * `exportOptions` — reach one renderer, so the two surfaces describe one block. + * + * ⇒ Narrowing or widening the declared block reds this pin instead of leaving + * the `z.unknown()` prose quietly behind, which is the direction of rot that has + * no other guard: the declared side has parse failures, this side has nothing. + * + * ⛔ This pin does NOT ask the key to stop being `z.unknown()`. Giving it a real + * shape is an accept-set change with its own review requirements; the last test + * below records that it is unvalidated TODAY, so that change reds here and is + * made deliberately rather than by accident. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentPropsMap } from './component.zod'; +import { ListViewSchema } from './view.zod'; + +/** + * The member names enumerated inside the first `({ … })` group of a describe + * string, e.g. `Export config ({ formats, streaming })` -> `['formats', 'streaming']`. + * + * Deliberately anchored to the parenthesised group rather than "any identifier + * in the sentence": the prose around it names `z.unknown()` and + * `ListViewSchema.exportOptions`, and a scan that read those as members would + * pass for the wrong reason. The self-test below is what proves the anchor + * discriminates instead of matching anything. + */ +function describedMembers(description: string): string[] { + const group = /\(\{([^}]*)\}\)/.exec(description); + if (!group) return []; + return group[1]!.split(',').map((k) => k.trim()).filter(Boolean); +} + +/** The spec's own declaration of this block: the object branch of the list view's union. */ +function declaredMembers(): string[] { + const optional = (ListViewSchema as unknown as { shape: Record }) + .shape.exportOptions as { unwrap(): { options: Array<{ shape?: Record }> } }; + const branches = optional.unwrap().options; + const objectBranch = branches.find((b) => b.shape !== undefined); + return objectBranch === undefined ? [] : Object.keys(objectBranch.shape!); +} + +const gridProps = ComponentPropsMap['object-grid'] as unknown as { + shape: Record; + safeParse(v: unknown): { success: boolean }; +}; +const description = gridProps.shape.exportOptions?.description ?? ''; + +describe('object-grid `exportOptions` — the describe names every declared member (#17166)', () => { + it('the parser discriminates: it reads a member group and does not invent one', () => { + // Lit control — a planted group is read back exactly. + expect(describedMembers('Export config ({ alpha, beta })')).toEqual(['alpha', 'beta']); + // Dark control — a sentence with no member group yields nothing, so a + // green equality below can never come from a matcher that matches anything. + expect(describedMembers('Export config, unvalidated.')).toEqual([]); + // And a name absent from the group is not produced by prose that mentions it. + expect(describedMembers('Export config ({ alpha }) — zzzNotAMember is not a member')) + .toEqual(['alpha']); + }); + + it('scans something: both sides are non-empty and the authority is the five-key block', () => { + // Non-vacuity floor. The assertion below is an equality, and an equality + // between two empty lists passes for the worst possible reason. + expect(description).not.toBe(''); + expect(describedMembers(description).length).toBeGreaterThanOrEqual(5); + expect(declaredMembers().length).toBeGreaterThanOrEqual(5); + expect(declaredMembers()).toContain('formats'); + }); + + it('names exactly the members `ListViewExportOptionsSchema` declares', () => { + // Named rather than counted: a failure must say WHICH member the prose is + // short of, because the fix is to name it — the reader gets nothing else. + expect([...describedMembers(description)].sort()).toEqual([...declaredMembers()].sort()); + }); + + it('is still unvalidated, which is why the prose carries the whole account', () => { + // The premise of this file, asserted rather than assumed: an undeclared + // member is neither refused nor honoured here. If this ever goes red the + // key grew an accept set and the describe's "Unvalidated here" sentence — + // and this pin's reason to exist — need re-deciding, deliberately. + expect(gridProps.safeParse({ exportOptions: { zzzNotAMember: 1 } }).success).toBe(true); + expect(description).toContain('Unvalidated here'); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 1de632a2b2..f5e6bf1d72 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2580,7 +2580,8 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ reorderableColumns: z.boolean().optional().describe('Allow column drag-reorder'), frozenColumns: z.number().optional().describe('How many leading columns stay frozen (default 1)'), showColumnTypeIcons: z.boolean().optional().describe('Show field-type icons in column headers'), - exportOptions: z.unknown().optional().describe('Export config ({ formats, streaming })'), + exportOptions: z.unknown().optional() + .describe('Export config ({ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }). Unvalidated here (`z.unknown()`), so this list is the whole account of the shape; `ListViewSchema.exportOptions` declares the same five members with their per-member contract'), operations: z.unknown().optional().describe('Operation toggles ({ export: false, … })'), /** * Data source binding — `ViewDataSchema`, the #5090-pinned authority the