From db4c848e6047f7cb61105c8f4e1be28636ebbc1f Mon Sep 17 00:00:00 2001 From: Justin Bieber Date: Sat, 5 Sep 2026 16:01:43 +0000 Subject: [PATCH 1/2] feat(types)!: retire TreeViewSchema.data as an ADR-0049 tombstone; `nodes` is the only inline spelling (objectui#6951 B1) `TreeViewSchema` declared two spellings for its one inline-nodes slot: `nodes` (read second, after a `bind`-resolved value) and `data` (read third), both declared by objectui#6150; `data` was required until objectui#6939 / PR #7533 made it optional. Maintainer ruling B1 (2026-09-04): retire `data`; `nodes` stays optional; no presence refinement (a `bind`-only tree-view is a legal, rendering document). `BaseSchema` declares `data?: any` / `z.any().optional()`, so deleting the member would ADMIT the key unvalidated; the retirement is a tombstone on the extended schema that shadows the base member, and the base-vs-extended contrast is pinned on both faces. - `data?: never` on the interface, `retirementTombstone()` named refusal on the Zod mirror (message names `nodes` and `bind`); `nodes` docblocks and describe rewritten without the three-limb narrative - tree-view.tsx reads `boundData || schema.nodes || []` - packages/types/examples/data-display-examples.json and the content/docs/api/schema-reference.md fence spell `nodes`; docs row replaced by a retirement note - PR #7533's pins that asserted `data` accepted flipped to refusal in place; the catalog mirror test's data-spelling probes flipped; #6150 control document moves to `nodes: []` - new pin tree-view-data-retired-6951: both faces refuse, the union entry carries the guidance, BaseSchema alone accepts the same document (both faces), `nodes`-only / `bind`-only / bare document parse, TreeNode.data control, tree-scoped catalog absence, renderer read set off disk, tsc legs - changeset (types/components minor) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .changeset/6951-tree-view-data-retired.md | 78 ++++ content/docs/api/schema-reference.md | 2 +- .../components/data-display/tree-view.mdx | 19 +- .../test/tree-view-nodes-mirror-6939.test.tsx | 43 ++- .../src/renderers/data-display/tree-view.tsx | 2 +- .../types/examples/data-display-examples.json | 2 +- .../tree-view-data-optional-6939.test.ts | 70 +++- .../tree-view-data-retired-6951.test.ts | 352 ++++++++++++++++++ .../undeclared-but-consumed-keys-6150.test.ts | 4 +- packages/types/src/data-display.ts | 54 +-- packages/types/src/zod/data-display.zod.ts | 21 +- 11 files changed, 572 insertions(+), 75 deletions(-) create mode 100644 .changeset/6951-tree-view-data-retired.md create mode 100644 packages/types/src/__tests__/tree-view-data-retired-6951.test.ts diff --git a/.changeset/6951-tree-view-data-retired.md b/.changeset/6951-tree-view-data-retired.md new file mode 100644 index 0000000000..6bdeff42c8 --- /dev/null +++ b/.changeset/6951-tree-view-data-retired.md @@ -0,0 +1,78 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +**Breaking for authored metadata:** `TreeViewSchema.data` is RETIRED (objectui#6951, +maintainer ruling B1 of 2026-09-04; ADR-0049 enforce-or-remove). A `tree-view` +node that authors `data` no longer validates: the parse fails loudly on the +`data` path with the explanation in the message, the TS member is a `?: never` +tombstone so the same document is refused at compile time, and the renderer no +longer reads the key. Write `nodes` — or bind the tree with `bind`, which is +unchanged and still read first. + +**What was measured, on this branch's base.** `TreeViewSchema` declared two +spellings for its one inline-nodes slot — `nodes` (read second) and `data` (read +third: `boundData || schema.nodes || schema.data || []` at +`renderers/data-display/tree-view.tsx:105`), both declared by objectui#6150. +`data` had been REQUIRED until objectui#6939 / PR #7533 made it optional, so +this retirement starts from a declared-and-optional member on both faces. The +in-repo corpus at the retirement: seven `tree-view` nodes under +`examples/schema-catalog` and `packages/types/examples` plus one `content/docs` +fence — six on `nodes`, two on `data` (`packages/types/examples/data-display-examples.json` +and `content/docs/api/schema-reference.md`), both rewritten; no package source +authored either spelling. + +**Who is affected — a `data` authored on a `tree-view` node:** + +```json +{ "type": "tree-view", + "data": [{ "id": "root", "label": "Project" }] } // ← was tolerated (read third) +``` + +now fails validation with: + +> RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write +> `nodes` (or bind the tree with `bind`). It was the second spelling of the one +> inline-nodes slot, read only as the last limb of +> `boundData || schema.nodes || schema.data || []`, and was retired under +> ADR-0049 enforce-or-remove with no deprecation window (maintainer ruling B1, +> 2026-09-04). The renderer reads `bind` then `nodes` now, so an authored `data` +> would render an empty tree. Rename the key; the array is unchanged. + +**Two published faces, one retirement — and why a tombstone, not a deletion.** +The TypeScript interface `TreeViewSchema` (`@object-ui/types`, `data-display.ts`) +declares `data?: never`; the Zod mirror `TreeViewSchema` (`@object-ui/types/zod`, +`data-display.zod.ts`) declares `data` as a `retirementTombstone()`. `BaseSchema` +already declares `data?: any` (`z.any().optional()` on the mirror), so DELETING +the member would not have refused the key — it would have ADMITTED it, +unvalidated, through the base member, and the renderer would have drawn an empty +tree. The tombstone on the extended schema shadows the base member on both +faces; the pin measures the base accepting the very document the extended +schema refuses. + +**What the ruling kept, deliberately.** `nodes` stays OPTIONAL and no "at least +one of" presence rule was added: `{ "type": "tree-view", "bind": "treeNodes" }` +is a legal, rendering document (`bind` is the first source the renderer reads), +and a bare `{ "type": "tree-view" }` stays legal as PR #7533 left it. +`TreeNode.data` — the per-node payload on each tree node — is a different +member on a different schema and is untouched. + +**`@object-ui/components`** — the `tree-view` renderer's read is +`boundData || schema.nodes || []`; nothing else in the package moves. + +**Who is NOT affected.** A document that already wrote `nodes` (the four +`components-data-display-tree-view/*` catalog entries and the nested tree in +`components-complex-resizable/editor-interface.json`) is untouched; `title`, +`bind`, the selection / expansion keys and `className` are unchanged. The +catalog is now pinned tree-wide against the retired spelling. + +**Migration:** rename `data` to `nodes` on every `tree-view` node; the array is +unchanged. If a document authored both, `nodes` was already the value that +rendered — delete `data`. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated spelling. It is not `major` per +this repo's fixed-group convention (objectui's own breaking changes ship as +`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略, +mechanically enforced by `scripts/check-changeset-no-major.mjs`). diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 94c9fa5905..5c2f9c90f4 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -514,7 +514,7 @@ A hierarchical tree component for nested data with expand/collapse and selection "multiSelect": false, "showLines": true, "defaultExpandedIds": ["root", "src"], - "data": [ + "nodes": [ { "id": "root", "label": "project", diff --git a/content/docs/components/data-display/tree-view.mdx b/content/docs/components/data-display/tree-view.mdx index 7dfc470552..795757daa3 100644 --- a/content/docs/components/data-display/tree-view.mdx +++ b/content/docs/components/data-display/tree-view.mdx @@ -27,9 +27,8 @@ interface TreeNode { interface TreeViewSchema { type: 'tree-view'; - // Data - data?: TreeNode[]; // Tree data (alias for nodes) - nodes?: TreeNode[]; // Tree nodes + // Data — inline nodes, or bind the tree with `bind` (read first) + nodes?: TreeNode[]; // Tree nodes (the one inline spelling) title?: string; // Tree title // Selection @@ -55,6 +54,20 @@ interface TreeViewSchema { } ``` +> **Retired: `data`** (objectui#6951, ADR-0049 enforce-or-remove). `data` was a +> second spelling of the tree's one inline-nodes slot — the renderer read it only +> as the last limb of `bind || nodes || data` — and it is no longer part of +> `TreeViewSchema` on either face. A `tree-view` node that authors `data` now +> fails validation with: +> +> > RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write +> > `nodes` (or bind the tree with `bind`). … +> +> Rename the key to `nodes`; the array is unchanged. `bind` is unchanged and is +> still read first, so a `bind`-only tree-view stays a legal document, and +> `nodes` stays optional (no presence rule was added). `TreeNode.data` — the +> per-node payload — is a different member and is untouched. + ## Examples ### Organization Chart diff --git a/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx index 9a6f562f49..a076ab4f83 100644 --- a/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx +++ b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx @@ -153,16 +153,19 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer }); describe('objectui#6939 — the fixtures were the side that was right', () => { - it.each(IDS)('%s: "correcting" it to `data` changes no pixel', (id) => { - // The card's own discriminator, re-measured here rather than quoted: the - // spelling swap moves nothing, so the schema was the wrong side. Contrast - // the same probe on the sibling groups, where the "correction" emptied the - // board or blanked the tile. - const authored = measure(asAuthored(id)); + it.each(IDS)('%s: spelling it `data` now draws an EMPTY tree — the retired limb is not read (objectui#6951)', (id) => { + // Flipped. At objectui#6939 the spelling swap moved nothing (the renderer + // read `data` third), which is how the fixtures were shown to be the right + // side. objectui#6951 then RETIRED `data` on both faces and dropped the + // renderer's read of it, so the same swap now blanks the tree body: the + // title still draws, every authored root label is gone. That is the + // enforce-or-remove half measured through the DOM. + const schema = asAuthored(id) as { title: string; nodes: { label: string }[] }; + const authored = measure(schema); const corrected = measure(asDataSpelling(id)); - expect(corrected.elements).toBe(authored.elements); - expect(corrected.text).toBe(authored.text); - expect(corrected.sha256).toBe(authored.sha256); + expect(corrected.elements).toBeLessThan(authored.elements); + expect(corrected.text).toContain(schema.title); + for (const node of schema.nodes) expect(corrected.text).not.toContain(node.label); }); it.each(IDS)('%s stays on the spelling its renderer reads FIRST', (id) => { @@ -171,13 +174,15 @@ describe('objectui#6939 — the fixtures were the side that was right', () => { expect('data' in schema).toBe(false); }); - it('both spellings validate — the accept set widened, it did not move', () => { - // ⛔ Do NOT "repair" a future red here by migrating the fixtures to `data`. - // The renderer reads `nodes` first and the registration's `defaultProps` - // spell it `nodes`; the fixtures ARE those defaults. + it('only `nodes` validates now — `data` is refused by name (objectui#6951)', () => { + // Flipped from "both spellings validate". ⛔ Do NOT "repair" a future red + // here by migrating the fixtures to `data`: that spelling is retired. The + // refusal names the key and the spelling to write instead. for (const id of IDS) { expect(reasons(asAuthored(id))).toEqual([]); - expect(reasons(asDataSpelling(id))).toEqual([]); + const why = reasons(asDataSpelling(id)); + expect(why.length).toBeGreaterThan(0); + expect(why.some((r) => r.startsWith('data:') && r.includes('write `nodes`'))).toBe(true); } }); @@ -189,12 +194,12 @@ describe('objectui#6939 — the fixtures were the side that was right', () => { expect(TreeViewSchema.safeParse({ type: 'tree-view', nodes: 'not-an-array' }).success).toBe(false); expect(TreeViewSchema.safeParse({ type: 'tree-view', data: 'not-an-array' }).success).toBe(false); // …and a good shape still passes, so the two above are not failing for some - // unrelated reason. ⚠️ The carrier carries BOTH spellings on purpose: it is - // legal with or without this card, so this control cannot redden for the - // relaxation it is controlling for. (That the authored, `nodes`-only - // fixtures parse is the first describe block's claim, not this one's.) + // unrelated reason. (`data` stays in the shape as a TOMBSTONE since + // objectui#6951 — declared and refused by name, never a passthrough hole.) + // objectui#6951: the carrier is `nodes`-only now — `data` is retired, so a + // both-spellings carrier would redden for the retirement, not for shape. expect(TreeViewSchema.safeParse({ - type: 'tree-view', title: 'File Explorer', data: [], nodes: [{ id: '1', label: 'Documents' }], + type: 'tree-view', title: 'File Explorer', nodes: [{ id: '1', label: 'Documents' }], }).success).toBe(true); }); }); diff --git a/packages/components/src/renderers/data-display/tree-view.tsx b/packages/components/src/renderers/data-display/tree-view.tsx index f05224be19..db563c3454 100644 --- a/packages/components/src/renderers/data-display/tree-view.tsx +++ b/packages/components/src/renderers/data-display/tree-view.tsx @@ -102,7 +102,7 @@ ComponentRegistry.register('tree-view', // Support data binding const boundData = useDataScope(schema.bind); - const rawNodes = boundData || schema.nodes || schema.data || []; + const rawNodes = boundData || schema.nodes || []; const nodes = Array.isArray(rawNodes) ? rawNodes : []; return ( diff --git a/packages/types/examples/data-display-examples.json b/packages/types/examples/data-display-examples.json index 613aaf3e71..9dffa7a830 100644 --- a/packages/types/examples/data-display-examples.json +++ b/packages/types/examples/data-display-examples.json @@ -189,7 +189,7 @@ "tree-view": { "type": "tree-view", "id": "file-explorer", - "data": [ + "nodes": [ { "id": "root", "label": "Project", diff --git a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts index ecba9b125f..1fbd0941c3 100644 --- a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts +++ b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts @@ -29,6 +29,17 @@ * stopped there and wrote so: relaxing `data` is an accept-set change and a * separate ruling. This is that change; `nodes` and `title` are untouched here. * + * ## What objectui#6951 (maintainer ruling B1, 2026-09-04) did to this pin + * + * `data` was RETIRED outright — `?: never` on the TS face, `retirementTombstone()` + * on the mirror, and the renderer's read is now `boundData || schema.nodes || []`. + * The relaxation this file records is still history worth keeping: `nodes` stays + * optional, a document with no inline source stays legal (no refinement, as ruled), + * and the "deleted member falls through to `BaseSchema.data`" measurement is + * exactly why the retirement is a tombstone and not a deletion. The pins below + * that asserted `data` ACCEPTED are flipped to refusal in place; the refusal + * envelope itself is pinned in `tree-view-data-retired-6951.test.ts`. + * * ## Why `data` stays DECLARED instead of being deleted * * Deleting the member is the intuitive reading of "the renderer prefers @@ -52,7 +63,7 @@ import type { TreeNode, TreeViewSchema as TsTreeViewSchema } from '../data-displ const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '..', '..', '..', '..'); const READER = 'packages/components/src/renderers/data-display/tree-view.tsx'; -const READ_TEXT = 'boundData || schema.nodes || schema.data || []'; +const READ_TEXT = 'boundData || schema.nodes || []'; /* ── Type-level pins (invariant equality, house form) ─────────────────────── */ @@ -81,8 +92,12 @@ type Expect< T extends true > = T; * exactly what the file header, the TS-face doc comment and the mirror's * `.describe()` all say, and what this comment used to contradict. */ -export type _TreeDataIsOptionalTreeNodes = - Expect< Equal< TsTreeViewSchema['data'], TreeNode[] | undefined > >; +export type _TreeDataIsRetired = + Expect< Equal< TsTreeViewSchema['data'], undefined > >; +// ^ objectui#6951: `data?: never` reads as `undefined`. The second limb above +// still bites — a DELETED member resolves to the inherited `any` and this +// equality goes red — and so does the first: `TreeNode[] | undefined` (the +// pre-retirement declaration) is not `undefined`. /** `nodes` and `title` are objectui#6150's and are unchanged by this card. */ export type _TreeNodesStillTreeNodes = @@ -123,13 +138,16 @@ describe('objectui#6939 — a `nodes`-only tree-view is a legal document', () => } }); - it('the accept set only WIDENED — every `data` document that parsed still parses', () => { - // The patch reasoning. Nothing that validated before this change may stop - // validating: `packages/types/examples/data-display-examples.json` authors - // its tree-view on `data`, and the renderer still reads that limb third. - expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES }).success).toBe(true); - expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES, nodes: NODES }).success).toBe(true); - expect(TreeViewSchema.safeParse({ ...ROOT, data: [] }).success).toBe(true); + it('every `data` document is now REFUSED by name — objectui#6951 retired the spelling', () => { + // Flipped from "the accept set only WIDENED": PR #7533 relaxed `data` and + // objectui#6951 then retired it. `packages/types/examples/data-display-examples.json` + // now authors its tree-view on `nodes`. The refusal lands on the `data` + // path with the guidance; the envelope is pinned in the retirement file. + for (const doc of [{ ...ROOT, data: NODES }, { ...ROOT, data: NODES, nodes: NODES }, { ...ROOT, data: [] }]) { + const r = TreeViewSchema.safeParse(doc); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data'); + } }); it('a tree-view with NO data source at all is legal, and that admits nothing new', () => { @@ -153,10 +171,17 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED', if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data'); }); - it('element-level enforcement survives the relaxation', () => { + it('a well-formed `data` array is refused at the KEY, not at an element — the tombstone, not element validation', () => { + // Pre-retirement this pinned `data.0.id` (element-level enforcement). The + // tombstone refuses the key itself, so the path is `data` and the message + // names the spelling to write instead. const r = TreeViewSchema.safeParse({ ...ROOT, data: [{ label: 'no id' }] }); expect(r.success).toBe(false); - if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data.0.id'); + if (!r.success) { + const issue = r.error.issues.find((i) => i.path.join('.') === 'data'); + expect(issue).toBeTruthy(); + expect(issue?.message).toContain('write `nodes`'); + } }); it('control: `BaseSchema` alone would have admitted both of those', () => { @@ -179,7 +204,11 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED', // the ablation below — not because passthrough moved, but because the // pre-repair mirror refuses the carrier itself. A control that fails for // the change it is controlling FOR is not a control. - const r = TreeViewSchema.safeParse({ ...ROOT, data: [], [SENTINEL]: 'not-an-array' }); + // objectui#6951: the carrier moved from `data: []` to `nodes: []` — the + // same reasoning, the other way round: `data` is now the retired spelling, + // so a `data`-bearing carrier would redden for the retirement, not for + // passthrough. `nodes: []` is legal before and after. + const r = TreeViewSchema.safeParse({ ...ROOT, nodes: [], [SENTINEL]: 'not-an-array' }); expect(r.success).toBe(true); if (r.success) expect((r.data as Record)[SENTINEL]).toBe('not-an-array'); }); @@ -190,16 +219,17 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED', }); }); -describe('objectui#6939 — the declaration still names a live read', () => { - it('the renderer reads `data` as the third limb', () => { - // A key whose reader is gone must be DROPPED, not declared (objectui#6150's - // own rule). Line numbers drift and stay in prose; the READ is the fact. +describe('objectui#6951 — the renderer no longer reads the retired limb', () => { + it('the read is `boundData || schema.nodes || []` — `bind` first, `nodes` second, nothing third', () => { + // Enforce-or-remove: a retired key must stop being READ as well as declared. + // Line numbers drift and stay in prose; the READ is the fact. const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); - expect(src, `${READER} no longer reads \`schema.data\` as \`${READ_TEXT}\``).toContain(READ_TEXT); + expect(src, `${READER} does not read \`${READ_TEXT}\``).toContain(READ_TEXT); + expect(src.match(/schema\.data\b/g)).toBeNull(); }); - it('and it reads `nodes` ahead of it — the order the relaxation rests on', () => { + it('and `bind` is still read ahead of `nodes` — the order the no-refinement ruling rests on', () => { const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); - expect(src.indexOf('schema.nodes')).toBeLessThan(src.indexOf('schema.data ||')); + expect(src.indexOf('useDataScope(schema.bind)')).toBeLessThan(src.indexOf('schema.nodes')); }); }); diff --git a/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts new file mode 100644 index 0000000000..d7c0ac6f8a --- /dev/null +++ b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts @@ -0,0 +1,352 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — `TreeViewSchema.data` is REFUSED on both published faces and + * no longer READ by the renderer (objectui#6951 B1, ADR-0049 enforce-or-remove). + * Maintainer ruling B1 of 2026-09-04: retire `data`; `nodes` is the only inline + * spelling; `nodes` stays OPTIONAL; no presence refinement. + * + * ## What was measured before the retirement + * + * `TreeViewSchema` declared two spellings for its one inline-nodes slot — + * `nodes` (read second) and `data` (read third, after a `bind`-resolved value + * and after `nodes`) — both declared by objectui#6150. `data` had been REQUIRED + * until objectui#6939 / PR #7533 made it optional, which is the state this + * retirement starts from. Corpus at the retirement: four catalog entries plus + * the nested tree in `components-complex-resizable/editor-interface.json` on + * `nodes`; `packages/types/examples/data-display-examples.json` and the + * `content/docs/api/schema-reference.md` fence on `data` — both rewritten to + * `nodes`; no package source authored either. + * + * ## The trap this pin exists to keep visible — `BaseSchema.data` + * + * `BaseSchema` declares `data?: any` (`z.any().optional()` on the mirror) and is + * `.passthrough()`. So DELETING the member from `TreeViewSchema` would not + * refuse an authored `data`: the key would be ADMITTED, unvalidated, through + * the base member — and with the renderer no longer reading it, drawn as an + * empty tree. The retirement therefore has to be a tombstone on the EXTENDED + * schema that SHADOWS the base member, and the fact that it does is not + * assumed here — it is measured: the base alone accepts the very document the + * extended schema refuses (the "base control" below), on both faces. + * + * ## What the ruling deliberately did NOT do + * + * No `nodes`-required, no "at least one of `nodes` / `data`" refinement: + * `{ type: 'tree-view', bind: 'treeNodes' }` is a legal, RENDERING document + * (`packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx`), + * because `bind` is the FIRST source the renderer reads. A two-limb presence + * rule would refuse it. So `bind`-only and `nodes`-only both parse below, and + * so does a bare `{ type: 'tree-view' }` — the same document class PR #7533 + * made legal, unchanged. + * + * The `@ts-expect-error` directives are REAL enforcement: this package + * type-checks its tests through `tsconfig.test.json`, so re-widening the + * declaration fails the build on the unused directive. A green `vitest` run is + * NOT evidence about them — type assertions are erased before it runs. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { TreeViewSchema } from '../zod/data-display.zod.js'; +import { BaseSchema } from '../zod/base.zod.js'; +import { safeValidateSchema } from '../zod/index.zod.js'; +import type { TreeNode, TreeViewSchema as TreeViewSchemaTS } from '../data-display.js'; +import type { BaseSchema as BaseSchemaTS } from '../base.js'; + +const ROOT = resolve(__dirname, '../../../..'); + +/** + * The FULL guidance string, pinned as a literal so the derived assertions below + * cannot all drift together. The first sentence is the contract an author acts + * on: the retired key, and the two spellings that still work. + */ +const GUIDANCE = + 'RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write `nodes` (or bind the tree with ' + + '`bind`). It was the second spelling of the one inline-nodes slot, read only as the last limb of ' + + '`boundData || schema.nodes || schema.data || []`, and was retired under ADR-0049 enforce-or-remove with no ' + + 'deprecation window (maintainer ruling B1, 2026-09-04). The renderer reads `bind` then `nodes` now, so an ' + + 'authored `data` would render an empty tree. Rename the key; the array is unchanged.'; +const PRESCRIPTIVE = '`data` is no longer part of TreeViewSchema; write `nodes` (or bind the tree with `bind`).'; + +const NODES: TreeNode[] = [{ id: '1', label: 'Documents', children: [{ id: '1.1', label: 'Reports' }] }]; + +/** The values an author would plausibly have written on the retired key. */ +const RETIRED_VALUES: readonly unknown[] = [NODES, []]; + +const describeOf = (schema: unknown, key: string): string | undefined => + ((schema as { shape: Record }).shape[key])?.description; + +/** Flatten a union refusal so the arm-level issues are addressable by path. */ +type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string; errors?: Issue[][] }; +const flatIssues = (issues: Issue[]): Issue[] => + issues.flatMap((i) => (i.code === 'invalid_union' && i.errors ? i.errors.flat().flatMap((e) => flatIssues([e])) : [i])); + +/* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */ + +describe('TreeViewSchema.data is RETIRED — the Zod half of the tombstone (objectui#6951)', () => { + it.each(RETIRED_VALUES.map((v) => [JSON.stringify(v).slice(0, 40), v] as const))( + 'REFUSES `data: %s`, naming the retired key in the path — a well-formed array included', + (_label, value) => { + // The pin. Before the retirement this document parsed GREEN (`data` was + // `z.array(TreeNodeSchema).optional()` since PR #7533). Asserting the + // ENVELOPE — not merely `success:false` — so the pin cannot be satisfied + // by an unrelated rejection, and asserting a WELL-FORMED array so it is + // the key that is refused, not the elements. + const result = TreeViewSchema.safeParse({ type: 'tree-view', data: value }); + expect(result.success, 'an authored `data` was ACCEPTED').toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === 'data'); + expect(issue, 'parse failed, but not on the `data` path').toBeTruthy(); + expect(issue?.code).toBe('invalid_type'); + expect((issue as { expected?: string } | undefined)?.expected).toBe('never'); + expect(issue?.path).toEqual(['data']); + }, + ); + + it('the refusal CARRIES the guidance — it names `nodes` and `bind`, not zod\'s generic message', () => { + const result = TreeViewSchema.safeParse({ type: 'tree-view', data: NODES }); + expect(result.success).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === 'data'); + expect(issue?.message).not.toContain('Invalid input: expected never, received '); + expect(issue?.message).toContain(PRESCRIPTIVE); + expect(issue?.message).toContain('write `nodes`'); + expect(issue?.message).toBe(GUIDANCE); + // ONE string, BOTH channels — asserted derived, so the parse message and + // the generated-docs metadata cannot drift apart (objectui#6931). + expect(issue?.message).toBe(describeOf(TreeViewSchema, 'data')); + }); + + it('is refused through `safeValidateSchema` too — the `AnyComponentSchema` union arm carries the tombstone', () => { + // The entry point a validating host actually calls, and the door through + // which `BaseSchema.data` could have let the key in: if the base member + // won inside the union, this document would validate green. + const result = safeValidateSchema({ type: 'tree-view', data: NODES }); + expect(result.success, 'a `tree-view` node authoring `data` validated GREEN through the union').toBe(false); + if (result.success) return; + + const named = flatIssues(result.error.issues as Issue[]).find((i) => i.path[0] === 'data'); + expect(named, 'the union refusal does not name the `data` path').toBeTruthy(); + expect(named?.message).toBe(GUIDANCE); + + // Positive controls on the same door: the migrated document and the + // bind-only document both validate. + expect(safeValidateSchema({ type: 'tree-view', nodes: NODES }).success).toBe(true); + expect(safeValidateSchema({ type: 'tree-view', bind: 'treeNodes' }).success).toBe(true); + }); + + it('BASE CONTROL: `BaseSchema` alone ACCEPTS the same document — the extended tombstone shadows `BaseSchema.data`', () => { + // The PM's mechanism assumption, measured rather than assumed. `BaseSchema` + // declares `data: z.any().optional()`; had the member been DELETED from + // `TreeViewSchema`, this is the acceptance an authored `data` would have + // inherited. The extended schema's tombstone wins over it — the two + // readings differ on the SAME document. + const base = BaseSchema.safeParse({ type: 'tree-view', data: NODES }); + expect(base.success).toBe(true); + if (base.success) expect(base.data.data).toEqual(NODES); + expect(TreeViewSchema.safeParse({ type: 'tree-view', data: NODES }).success).toBe(false); + }); + + it('keeps the key DECLARED — a tombstone, not a deletion', () => { + // The route guard, for the trap above: remove `data` from the mirror and an + // authored array rides `BaseSchema.data` (`z.any()`) into an empty tree. + expect( + Object.keys(TreeViewSchema.shape), + 'data left the mirror — an authored array now rides BaseSchema.data unvalidated', + ).toContain('data'); + expect(describeOf(TreeViewSchema, 'data')).toContain('RETIRED (objectui#6951)'); + }); +}); + +/* ── the inside of the boundary: what the ruling kept, measured ──────────── */ + +describe('the retirement narrows exactly `data` — `nodes`, `bind` and the empty document stay legal (objectui#6951 B1)', () => { + it('`nodes`-only parses and the value SURVIVES the parse', () => { + const result = TreeViewSchema.safeParse({ type: 'tree-view', title: 'Files', nodes: NODES }); + expect(result.success ? null : result.error.issues).toBe(null); + if (result.success) { + expect(result.data.nodes).toEqual(NODES); + expect(result.data.title).toBe('Files'); + } + }); + + it('`bind`-only parses — the document a two-limb presence refinement would have refused', () => { + // `packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx` + // renders exactly this document from a data scope; `bind` is the FIRST + // limb the renderer reads. The ruling rejected any `nodes`/`data` + // presence rule for precisely this document; pinned so a later "at least + // one of" cannot land without going red here first. + const result = TreeViewSchema.safeParse({ type: 'tree-view', bind: 'treeNodes' }); + expect(result.success ? null : result.error.issues).toBe(null); + if (result.success) expect(result.data.bind).toBe('treeNodes'); + }); + + it('a bare `{ type: "tree-view" }` parses — `nodes` stays optional, no refinement (as ruled)', () => { + // The document class PR #7533 made legal, unchanged: an empty tree was + // already a legal rendering outcome (`nodes: []`), and the ruling records + // "`nodes` stays optional; no refinement" in as many words. + const result = TreeViewSchema.safeParse({ type: 'tree-view' }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('still REFUSES a wrong-typed `nodes` — the mirror did not stop validating', () => { + // Counter-probe in the other direction: the schema is not `z.any()` in + // disguise, so the green results above are readings. + const result = TreeViewSchema.safeParse({ type: 'tree-view', nodes: 'not-an-array' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.find((i) => i.path[0] === 'nodes')).toBeTruthy(); + }); + + it('control: `TreeNode.data` — the per-node payload — is still ACCEPTED and survives', () => { + // `data` also exists on `TreeNode` (`z.any().optional()`, "custom node + // data"). It is a different member on a different schema, untouched; the + // pin that the retirement was located by SYMBOL, not by grep hit. + const result = TreeViewSchema.safeParse({ type: 'tree-view', nodes: [{ id: '1', label: 'A', data: { size: 3 } }] }); + expect(result.success ? null : result.error.issues).toBe(null); + if (result.success) expect(result.data.nodes?.[0]?.data).toEqual({ size: 3 }); + }); + + it('an UNDECLARED key still rides `.passthrough()` — the DELETED row, measured live', () => { + // A key the mirror does not declare is neither refused nor stripped, it is + // KEPT. `data` is not in this class only because it is DECLARED as a + // tombstone; the base member would otherwise have caught it (base control). + const result = TreeViewSchema.safeParse({ type: 'tree-view', nodes: NODES, notAKeyAtAll: 'anything' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toHaveProperty('notAKeyAtAll', 'anything'); + }); +}); + +/* ── the corpus: no shipped fixture authors the retired spelling ─────────── */ + +/** Every `tree-view` node (an object whose OWN `type` is `"tree-view"`) in a parsed JSON document. */ +function* treeViewNodes(node: unknown, path: string): Generator<[string, Record]> { + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) yield* treeViewNodes(node[i], `${path}[${i}]`); + return; + } + if (!node || typeof node !== 'object') return; + const obj = node as Record; + if (obj.type === 'tree-view') yield [path, obj]; + for (const [k, v] of Object.entries(obj)) yield* treeViewNodes(v, `${path}.${k}`); +} + +function* jsonFiles(dir: string): Generator { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) yield* jsonFiles(full); + else if (entry.endsWith('.json')) yield full; + } +} + +describe('no shipped JSON fixture authors `tree-view.data` any more (objectui#6951) — tree-scoped', () => { + // Tree-scoped on purpose: a file-scoped pin sees only the files its author + // knew about. At the retirement the walk found six `tree-view` nodes in the + // catalog (four entries + the nested tree in + // `components-complex-resizable/editor-interface.json`, all on `nodes`) and + // one in `packages/types/examples/data-display-examples.json` (rewritten from + // `data` to `nodes`). Each is parsed per node, because `SchemaNodeSchema` + // does not descend into `AnyComponentSchema`. + const CATALOG = resolve(ROOT, 'examples/schema-catalog/src/schemas'); + const TYPES_EXAMPLES = resolve(ROOT, 'packages/types/examples'); + + it('every `tree-view` node in the catalog and the types examples spells `nodes` (or nothing inline), and parses green', () => { + const offenders: string[] = []; + let seen = 0; + for (const dir of [CATALOG, TYPES_EXAMPLES]) { + for (const file of jsonFiles(dir)) { + const doc = JSON.parse(readFileSync(file, 'utf8')) as unknown; + for (const [path, node] of treeViewNodes(doc, '$')) { + seen++; + if ('data' in node) offenders.push(`${file.slice(ROOT.length + 1)} ${path}`); + const result = TreeViewSchema.safeParse(node); + if (!result.success) offenders.push(`${file.slice(ROOT.length + 1)} ${path}: ${JSON.stringify(result.error.issues)}`); + } + } + } + // Non-vacuity: seven at the retirement; a walk that finds none is broken. + expect(seen).toBeGreaterThanOrEqual(7); + expect(offenders).toEqual([]); + }); +}); + +/* ── the renderer half: the retired key is no longer READ ────────────────── */ + +describe('the `tree-view` renderer no longer reads `schema.data` (objectui#6951, enforce-or-remove)', () => { + it('the read set, off disk: `bind` first, `nodes` second, nothing third', () => { + // Read off disk so a renderer-side re-widening cannot pass while the + // schema faces still refuse. + const src = readFileSync(resolve(ROOT, 'packages/components/src/renderers/data-display/tree-view.tsx'), 'utf8'); + expect(src).toContain('const rawNodes = boundData || schema.nodes || [];'); + expect(src.match(/schema\.data\b/g)).toBeNull(); + expect(src.indexOf('useDataScope(schema.bind)')).toBeLessThan(src.indexOf('schema.nodes')); + }); +}); + +/* ── the TS half: the `tsc` channel ──────────────────────────────────────── */ + +describe('TreeViewSchema.data is RETIRED — the TS half of the tombstone (objectui#6951)', () => { + it('refuses the retired key at compile time', () => { + // On the pre-fix tree `data` is `TreeNode[] | undefined`, so the assignment + // is LEGAL, the directive is unused, and `tsc` fails the build with TS2578 + // naming the key — red before the fix in `type-check`, not in vitest. + + // @ts-expect-error — `data` is RETIRED (objectui#6951): declared `?: never`, so no value is authorable. + const retired: TreeViewSchemaTS['data'] = NODES; + + // Counter-probes on the same surface: the live siblings still accept their + // values, so the directive pins the KEY's retirement, not a blanket + // narrowing of the interface. + const nodes: TreeViewSchemaTS['nodes'] = NODES; + const bind: TreeViewSchemaTS['bind'] = 'treeNodes'; + + expect([retired, nodes, bind]).toHaveLength(3); + }); + + it('refuses the retired key in the form authors actually write — and shadows the inherited `BaseSchema.data?: any`', () => { + // The leg that proves the tombstone beats BOTH escape hatches on the TS + // face: `BaseSchema` carries `data?: any` AND `[key: string]: any`. A + // declared `never` member wins over the inherited `any` and over the index + // signature; if either won, `data` would widen back and the directive + // would go unused (TS2578). + const retiredDocument: TreeViewSchemaTS = { + type: 'tree-view', + // @ts-expect-error — `data` is RETIRED (objectui#6951); write `nodes` (or bind the tree with `bind`). + data: NODES, + }; + + // The migrated documents — `nodes`, and `bind`-only — still type-check. + const nodesDocument: TreeViewSchemaTS = { type: 'tree-view', title: 'Files', nodes: NODES }; + const bindDocument: TreeViewSchemaTS = { type: 'tree-view', bind: 'treeNodes' }; + + // BASE CONTROL on the TS face: the same literal IS a legal `BaseSchema` — + // the acceptance a deleted member would have fallen through to. + const baseDocument: BaseSchemaTS = { type: 'tree-view', data: NODES }; + + expect([retiredDocument, nodesDocument, bindDocument, baseDocument]).toHaveLength(4); + }); + + it('refuses it through a WIDENED value too — the half a deletion would have missed', () => { + // Excess-property checking only reaches a FRESH literal (objectui#7654 + // measured the contrast); the declared `never` makes the assignment itself + // ill-typed, so freshness stops mattering. + const raw = { type: 'tree-view' as const, data: NODES }; + // @ts-expect-error — `data` is RETIRED (objectui#6951), reached through a non-fresh value. + const document: TreeViewSchemaTS = raw; + expect(document.type).toBe('tree-view'); + }); + + it('control: `TreeNode.data?: any` still type-checks — retired by symbol, not by grep', () => { + const node: TreeNode = { id: '1', label: 'A', data: { size: 3 } }; + expect(node.data).toEqual({ size: 3 }); + }); +}); diff --git a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts index 0fff7f0fba..b059b021f7 100644 --- a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts +++ b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts @@ -151,7 +151,7 @@ const NODE: SchemaNode = { type: 'text', value: 'x' } as SchemaNode; const TEXT_CONTROL = { type: 'text' }; const CAROUSEL_CONTROL = { type: 'carousel', items: [] }; const FILTER_CONTROL = { type: 'filter-builder', fields: [] }; -const TREE_CONTROL = { type: 'tree-view', data: [] }; +const TREE_CONTROL = { type: 'tree-view', nodes: [] }; const CHECKBOX_CONTROL = { type: 'checkbox', label: 'Accept' }; const UPLOAD_CONTROL = { type: 'file-upload', label: 'Attach' }; const HOVER_CONTROL = { type: 'hover-card', content: NODE, trigger: NODE }; @@ -180,7 +180,7 @@ const CASES: Case[] = [ { type: 'TreeViewSchema', key: 'nodes', mirror: TreeViewSchema as never, control: TREE_CONTROL, legal: [{ id: 'a', label: 'A' }], illegal: 'not-an-array', - reader: R + 'data-display/tree-view.tsx', readText: 'boundData || schema.nodes || schema.data || []' }, + reader: R + 'data-display/tree-view.tsx', readText: 'boundData || schema.nodes || []' }, { type: 'TreeViewSchema', key: 'title', mirror: TreeViewSchema as never, control: TREE_CONTROL, legal: 'Folders', illegal: 42, reader: R + 'data-display/tree-view.tsx', readText: '{schema.title}' }, diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 9fb073280c..0f8683fce6 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1270,37 +1270,41 @@ export interface TreeNode { export interface TreeViewSchema extends BaseSchema { type: 'tree-view'; /** - * Tree data — the fallback spelling, read only when - * {@link TreeViewSchema.nodes} is absent. - * - * READ SITE: `packages/components/src/renderers/data-display/tree-view.tsx:105` - * — `const rawNodes = boundData || schema.nodes || schema.data || []`. - * - * OPTIONAL since objectui#6939. It was REQUIRED, which refused four catalog - * entries the renderer draws correctly — a third-choice limb cannot be the - * one key a document must carry. - * - * ⚠️ Kept DECLARED rather than deleted, and the distinction is measured: - * {@link BaseSchema} already declares `data?: any` (its zod twin is - * `z.any().optional()`), so removing this member would NOT reject the key — - * it would admit it unvalidated while the renderer still reads it. Declaring - * it optional is the only shape in which `declared` and `enforced` agree. + * RETIRED (objectui#6951, ADR-0049 enforce-or-remove) — the second spelling + * of the tree's one inline-nodes slot, read only as the LAST limb of + * `boundData || schema.nodes || schema.data || []`. Maintainer ruling B1 + * (2026-09-04): retire `data`; `nodes` is the only inline spelling; `nodes` + * stays OPTIONAL and no presence refinement is added — a `bind`-only + * tree-view (`{ type: 'tree-view', bind: 'treeNodes' }`) is a legal, + * rendering document because `bind` is the FIRST source the renderer reads. + * + * History: REQUIRED until objectui#6939 / PR #7533 made it optional (it had + * refused four catalog entries the renderer draws correctly). That PR kept + * it DECLARED because {@link BaseSchema} declares `data?: any` (zod twin + * `z.any().optional()`), so deleting the member would ADMIT the key + * unvalidated — and that same fact is why this is a tombstone, not a + * deletion: `?: never` here beats the inherited `any`, and the zod tombstone + * on the extended schema beats the base's `z.any()`; both directions are + * pinned in `__tests__/tree-view-data-retired-6951.test.ts`. The renderer no + * longer reads it (`tree-view.tsx:105` is `boundData || schema.nodes || []`). + * Write `nodes` (or bind the tree with `bind`); the array is unchanged. + * @deprecated Not part of this contract — write `nodes`. */ - data?: TreeNode[]; + data?: never; /** - * Tree data — the spelling the renderer reads FIRST. + * Inline tree nodes — the ONE inline spelling. * - * READ SITE: `renderers/data-display/tree-view.tsx:105`, the middle limb of - * `boundData || schema.nodes || schema.data || []`, so `nodes` WINS over - * {@link TreeViewSchema.data} when both are authored (and a `bind`-resolved - * value wins over both). + * READ SITE: `renderers/data-display/tree-view.tsx:105`, the second limb of + * `boundData || schema.nodes || []` — a `bind`-resolved value wins, and + * {@link BaseSchema.bind} stays the first-read source, which is why this + * member is optional and no "at least one of" rule exists (objectui#6951 B1). * * Declared by objectui#6150, which deliberately stopped at the declaration: * `{ type: 'tree-view', nodes }` only became a LEGAL document at - * objectui#6939, the accept-set change that relaxed - * {@link TreeViewSchema.data}. The registration's own `inputs` and - * `defaultProps` spell it `nodes`, and the four catalog entries ARE those - * `defaultProps`. + * objectui#6939 (PR #7533), the accept-set change that relaxed the then + * required `data`; objectui#6951 retired that `data` spelling outright (the + * tombstone above). The registration's own `inputs` and `defaultProps` + * spell it `nodes`, and the four catalog entries ARE those `defaultProps`. */ nodes?: TreeNode[]; /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 072cb17912..1cbacf060a 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -335,10 +335,25 @@ export const TreeNodeSchema: z.ZodType = z.lazy(() => */ export const TreeViewSchema = BaseSchema.extend({ type: z.literal('tree-view'), - data: z.array(TreeNodeSchema).optional() - .describe('Tree data, read THIRD as the fallback limb of `boundData || schema.nodes || schema.data || []` at renderers/data-display/tree-view.tsx:105. OPTIONAL since objectui#6939 — requiring a third-choice limb refused four catalog entries the renderer draws correctly. Kept DECLARED rather than deleted: `BaseSchema.data` is `z.any().optional()`, so removing this member would not reject the key, it would admit it unvalidated while the read stays'), + // ADR-0049 RETIREMENT TOMBSTONE (objectui#6951, maintainer ruling B1 of + // 2026-09-04). `data` was the second spelling of the one inline-nodes slot, + // read only as the last limb of `boundData || schema.nodes || schema.data || []`; + // the renderer now reads `bind` then `nodes`. A plain deletion here would NOT + // refuse the key: `BaseSchema.data` is `z.any().optional()`, so the authored + // array would be admitted unvalidated and render an empty tree. The tombstone + // on this extended schema shadows the base member and refuses BY NAME — one + // string, both channels (parse-time message and `.describe()`), see + // `./tombstone.zod.ts`; the base-vs-extended contrast is pinned in + // `../__tests__/tree-view-data-retired-6951.test.ts`. + data: retirementTombstone( + 'RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write `nodes` (or bind the tree with ' + + '`bind`). It was the second spelling of the one inline-nodes slot, read only as the last limb of ' + + '`boundData || schema.nodes || schema.data || []`, and was retired under ADR-0049 enforce-or-remove with no ' + + 'deprecation window (maintainer ruling B1, 2026-09-04). The renderer reads `bind` then `nodes` now, so an ' + + 'authored `data` would render an empty tree. Rename the key; the array is unchanged.', + ), nodes: z.array(TreeNodeSchema).optional() - .describe('Tree data, read FIRST at renderers/data-display/tree-view.tsx:105 — the middle limb of `boundData || schema.nodes || schema.data || []`, so it wins over `data`. Declared by objectui#6150; a `nodes`-only document became LEGAL at objectui#6939, which relaxed `data` (the registration\'s own `inputs` and `defaultProps` spell it `nodes`, and the four catalog entries ARE those `defaultProps`)'), + .describe('Inline tree nodes — the one inline spelling, read at renderers/data-display/tree-view.tsx:105 as the second limb of `boundData || schema.nodes || []` (a `bind`-resolved value wins, so this stays optional and no presence rule exists — objectui#6951 B1). Declared by objectui#6150; a `nodes`-only document became LEGAL at objectui#6939; the `data` fallback spelling was retired by objectui#6951 (the registration\'s own `inputs` and `defaultProps` spell it `nodes`, and the four catalog entries ARE those `defaultProps`)'), title: z.string().optional() .describe('Heading above the tree, read at renderers/data-display/tree-view.tsx:115 (presence gate) and :117 (the h3 body) (objectui#6150)'), defaultExpandedIds: z.array(z.string()).optional().describe('Default expanded node IDs'), From 80aae7cb3c9a2495fd9d01f01f31787636052c4f Mon Sep 17 00:00:00 2001 From: Justin Bieber Date: Sat, 5 Sep 2026 16:12:08 +0000 Subject: [PATCH 2/2] test(types,catalog): the tree-view absence pin's floor is six, and the mirror probe reads the arm-level issue The first run counted the JSON population as seven; the walk finds six (four catalog entries, the nested tree in editor-interface.json, and the types example). The catalog mirror test's `data`-spelling probe asserted the guidance on the union door's top-level issue, which is the invalid_union envelope; it now reads the `data` path issue off TreeViewSchema.safeParse and keeps the union-door refusal as a separate check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .../test/tree-view-nodes-mirror-6939.test.tsx | 12 +++++++++--- .../__tests__/tree-view-data-retired-6951.test.ts | 8 ++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx index a076ab4f83..a13e159580 100644 --- a/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx +++ b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx @@ -180,9 +180,15 @@ describe('objectui#6939 — the fixtures were the side that was right', () => { // refusal names the key and the spelling to write instead. for (const id of IDS) { expect(reasons(asAuthored(id))).toEqual([]); - const why = reasons(asDataSpelling(id)); - expect(why.length).toBeGreaterThan(0); - expect(why.some((r) => r.startsWith('data:') && r.includes('write `nodes`'))).toBe(true); + // The union door refuses (its top-level issue is the `invalid_union` + // envelope); the arm-level issue on the `data` path carries the guidance. + expect(reasons(asDataSpelling(id)).length).toBeGreaterThan(0); + const direct = TreeViewSchema.safeParse(asDataSpelling(id)); + expect(direct.success).toBe(false); + if (!direct.success) { + const issue = direct.error.issues.find((i) => i.path.join('.') === 'data'); + expect(issue?.message).toContain('write `nodes`'); + } } }); diff --git a/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts index d7c0ac6f8a..692af86963 100644 --- a/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts +++ b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts @@ -250,11 +250,11 @@ function* jsonFiles(dir: string): Generator { describe('no shipped JSON fixture authors `tree-view.data` any more (objectui#6951) — tree-scoped', () => { // Tree-scoped on purpose: a file-scoped pin sees only the files its author - // knew about. At the retirement the walk found six `tree-view` nodes in the + // knew about. At the retirement the walk found five `tree-view` nodes in the // catalog (four entries + the nested tree in // `components-complex-resizable/editor-interface.json`, all on `nodes`) and // one in `packages/types/examples/data-display-examples.json` (rewritten from - // `data` to `nodes`). Each is parsed per node, because `SchemaNodeSchema` + // `data` to `nodes`) — six in all. Each is parsed per node, because `SchemaNodeSchema` // does not descend into `AnyComponentSchema`. const CATALOG = resolve(ROOT, 'examples/schema-catalog/src/schemas'); const TYPES_EXAMPLES = resolve(ROOT, 'packages/types/examples'); @@ -273,8 +273,8 @@ describe('no shipped JSON fixture authors `tree-view.data` any more (objectui#69 } } } - // Non-vacuity: seven at the retirement; a walk that finds none is broken. - expect(seen).toBeGreaterThanOrEqual(7); + // Non-vacuity: six at the retirement; a walk that finds none is broken. + expect(seen).toBeGreaterThanOrEqual(6); expect(offenders).toEqual([]); }); });