diff --git a/.changeset/8841-tree-view-config-derive.md b/.changeset/8841-tree-view-config-derive.md new file mode 100644 index 0000000000..647362090f --- /dev/null +++ b/.changeset/8841-tree-view-config-derive.md @@ -0,0 +1,58 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-tree': minor +'@object-ui/plugin-view': patch +'@object-ui/app-shell': patch +--- + +Derive `TreeViewConfig` from `@objectstack/spec` and drop `titleField`, the key the +protocol refuses on `ListView.tree` (objectui#8841). + +**What was wrong.** `@object-ui/types` published `TreeViewConfig` as a hand-written +interface — a copy of the protocol's `ListView.tree` block under a second name — and the +copy declared a fifth key, `titleField`. `@objectstack/spec@17.4.0` refuses that key +there by name: `TreeConfigSchema` is a `strictObject` since spec #15469 closed the +`.passthrough()` window 17.3.0 left open. So this package's published face accepted what +the contract rejects, and an author who followed `@object-ui/types` was refused at +publish with `Unrecognized key(s) on this tree configuration: 'titleField'`. The copy was +invisible to `scripts/check-spec-symbol-derivation.mjs`, which matches spec symbols BY +NAME — a hand copy renamed away from the spec's symbol has nothing for its rule 1 to +match (objectui#4592's recorded blind spot). + +**The grades, and why.** + +- `@object-ui/types` — **minor**. `TreeViewConfig` is now + `NonNullable` from `@objectstack/spec/ui`, and `titleField` is + removed from a **published** exported type. That is breaking for a producer that + annotates a `tree` block carrying the key; per this repo's version-alignment policy + (AGENTS.md — objectui's major tracks `@objectstack`'s) objectui's own breaking changes + ship as `minor` with the breaking semantics stated here. The precedent is the same + shape: `Remove the retired striped / bordered / virtualScroll list-view surface` + (`@object-ui/types` 17.6.0, minor) propagated a spec-side retirement into this package + the same way. +- `@object-ui/plugin-tree` — **minor**. `getTreeConfig`'s `labelField` chain loses its + third rung, `?? schema.titleField`. That rung read the flattened **node**, never the + block, and `titleField` is declared on neither face — not on `ObjectTreeSchema` (the TS + interface or its zod mirror) and not on the protocol's `ListView.tree`. It is a runtime + behaviour change, graded like one. The README's claim that this block is "the single + declaration of that shape" is corrected in the same stroke: the protocol owns it, and + this package publishes it derived. +- `@object-ui/plugin-view` — **patch**. `ObjectViewProps.views[n].tree` still resolves to + `TreeViewConfig`; what it admits narrows with the type. Type-only, no runtime change. +- `@object-ui/app-shell` — **patch**. The console's `tree` composition keeps both rungs; + only the second one's annotation changes (see below). No runtime change. + +**The tolerant reads are kept, and deliberately left undeclared.** Three +`labelField || titleField` dual-reads survive — `plugin-view`'s and `plugin-list`'s +`'tree'` branches and the console's own composition in `app-shell` — so a view record +that already stores `tree.titleField` keeps resolving exactly as before, and +objectui#6557's pin on that rung stays green. They read through `any` now; the console's +canonical rung stays annotated `TreeViewConfig` while its legacy rung is not, because +casting it to a type that no longer carries the key cannot compile and re-declaring the +key locally would fossilise a renderer-side alias into a second contract — the AGENTS.md +#0.1 defect this change undoes. Retiring those three reads is a follow-up. + +**Migration.** A host that writes `tree.titleField` should write `tree.labelField`, which +is the protocol's spelling and already wins wherever both are present. Nothing that +renders today stops rendering; what changes is that the key is now reported at compile +time by the same face that will refuse it at publish, instead of only at publish. diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 3044e9efb6..5c7619010b 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -2627,18 +2627,30 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // auto-detects when omitted. // // Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253): - // `viewDef` is `Record`, so both rungs below were - // `any` property accesses and a misspelling was invisible. This - // is the half of objectui#7559 a declaration CAN close, on the - // one block that now has a declaration to close it with — it - // does NOT make a missing rung visible, which is what the census - // pin (`ObjectView.relayRungCensus-7559.test.ts`) is for. + // `viewDef` is `Record`, so the canonical rung + // below was an `any` property access and a misspelling was + // invisible. This is the half of objectui#7559 a declaration CAN + // close, on the one block that now has a declaration to close it + // with — it does NOT make a missing rung visible, which is what + // the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is + // for. // // ⚠️ The cast is repeated per rung rather than hoisted into a // local: objectui#6557's convergence pin reads these seam lines // out of this file and requires each to name `viewDef` itself. + // + // ⛔ The `titleField` rung is deliberately NOT cast (objectui#8841). + // `TreeViewConfig` is now the spec's `ListView.tree` block, and + // `@objectstack/spec@17.4.0` refuses `titleField` there by name, + // so casting to it would not compile and re-declaring the key + // locally would fossilise a renderer-side alias into a second + // contract — AGENTS.md #0.1, and the defect objectui#8841 exists + // to undo. The rung stays as an UNDECLARED tolerant fallback, + // read through `any`, kept so already-stored view records keep + // resolving and so objectui#6557's pin on it stays honest. Its + // retirement is a follow-up, ⛔ not a rider here. ...((viewDef.tree as TreeViewConfig | undefined) || {}), - labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || (viewDef.tree as TreeViewConfig | undefined)?.titleField || 'name', + labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || viewDef.tree?.titleField || 'name', }, // The chart block the view DECLARED, forwarded WHOLE — a // pointer, not a copy of its key set (objectui#7823). diff --git a/packages/plugin-tree/README.md b/packages/plugin-tree/README.md index e6549489aa..2bd6551ddf 100644 --- a/packages/plugin-tree/README.md +++ b/packages/plugin-tree/README.md @@ -77,8 +77,19 @@ user can create. To render a tree from authored metadata, write the Host config is **not** untyped config. A block a host stores and re-writes is a contract, so the per-view `tree` block is exported from `@object-ui/types` and -is the single declaration of that shape — the renderer imports it rather than -keeping a private copy (objectui#8253, ruled 2026-09-07): +the renderer imports it rather than keeping a private copy (objectui#8253, +ruled 2026-09-07). + +⚠️ It is **not the single declaration of that shape**, and saying so was itself +the defect objectui#8841 fixed. `@objectstack/spec` owns this block — it +declares it as `TreeConfig` and hangs it on `ListView.tree` — and +`@object-ui/types` already publishes it a second way, derived, as +`ListViewSchema['tree']`. `TreeViewConfig` is now a **derivation of the +protocol's block** rather than a copy of it — in `packages/types/src/views.ts` +it is a one-line alias of `NonNullable`, taken from +`@objectstack/spec/ui`. So the accurate claim is the narrower one: this is the +name a host writes against, and it tracks the protocol by construction rather +than by anyone remembering to update it. ```ts import type { TreeViewConfig } from '@object-ui/types'; @@ -98,9 +109,18 @@ Annotating the block is what turns a typo into a diagnostic: `parentFeild` used to be stored, read by nobody and reported by nothing, because the `views` entry admits any key. Against this type it is a compile error. -`titleField` is also declared — a legacy second rung for `labelField`, kept -because the console's own composition still reads it. Prefer `labelField`, -which wins wherever both are present. +⛔ `titleField` is **not** part of this block. objectui#8253 declared it as a +legacy second rung for `labelField`; `@objectstack/spec@17.4.0` refuses +`tree.titleField` by name (`TreeConfigSchema` is strict since spec #15469), so +declaring it published a key the protocol rejects — an author who followed this +type was refused at publish. objectui#8841 removed it. + +The renderers still *tolerate* a `titleField` already stored on a view record: +`plugin-view`, `plugin-list` and the console's own composition each fall back to +it when `labelField` is absent, so nothing that renders today stops rendering. +Those reads are untyped tolerance awaiting a follow-up, ⛔ not a declaration — +write `labelField`, which is the protocol's spelling and wins wherever both are +present. ⛔ This does not make `tree` an authorable view type. objectui#5321 is unchanged: the block is written by a **host**, never by a document author, and diff --git a/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts index 3b23d98598..e54daaf345 100644 --- a/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts +++ b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts @@ -26,6 +26,18 @@ * maintainer 「同意」), option (a): `packages/types` exports the config, the * module-local copy becomes an import of it, and ⛔ there is no second copy. * + * ## What objectui#8841 changed here + * + * objectui#8253 shipped that export as a hand-written interface — a copy of the + * protocol's `ListView.tree` block under a second name — and the copy declared a + * fifth key, `titleField`, that `@objectstack/spec@17.4.0` REFUSES there. The + * pins in this file did not catch it because the census was a LITERAL KEY LIST + * maintained beside the type: the list was written to match the drift, so it + * agreed with the defect. objectui#8841 re-derives the type from the protocol + * and re-pins the census as PARITY WITH THE PROTOCOL — plus a runtime leg that + * reads the installed `TreeConfigSchema` by content, because a compile-time pin + * on a derived alias can only restate its own derivation. + * * ## Why the import below says `@object-ui/types` and not `../../types/src` * * This is the load-bearing part of the pin, not a style choice. This package's @@ -57,6 +69,16 @@ import { describe, it, expect } from 'vitest'; // relative path into `packages/types/src`. import type { TreeViewConfig } from '@object-ui/types'; +// The PROTOCOL's own declaration of this block, imported for the parity pins +// below (objectui#8841). `@object-ui/types` derives `TreeViewConfig` from +// `ListView['tree']`, so this import is the other end of that derivation and +// the only thing a census can honestly be total over. +import { TreeConfigSchema } from '@objectstack/spec/ui'; +import type { ListView as SpecListView } from '@objectstack/spec/ui'; + +/** The protocol's `ListView.tree` block. */ +type SpecTreeConfig = NonNullable; + /* -------------------------------------------------------------------------- */ /* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ /* -------------------------------------------------------------------------- */ @@ -66,49 +88,82 @@ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type IsAny = 0 extends 1 & T ? true : false; -/** - * The keys `getTreeConfig` in `ObjectTree.tsx` reads off this block, and the - * ones the ruling says the type carries EXACTLY. Pinned as a `keyof` equality - * rather than a bag of `HasKey` checks, because equality is the only spelling - * that fails in BOTH directions — a key added here without a reader is as much - * a defect as a key removed from under one. - */ -type DeclaredKey = - | 'parentField' - | 'labelField' - | 'titleField' - | 'fields' - | 'defaultExpandedDepth'; - -describe('objectui#8253 — TreeViewConfig is reachable through @object-ui/types', () => { +describe('objectui#8253/#8841 — TreeViewConfig is the protocol\'s block, reachable through @object-ui/types', () => { it('is pinned at compile time', () => { // Non-vacuity. `keyof any` is `string | number | symbol`, and every // `Equal<…>` below would report whatever an `any` made convenient. If the // import ever resolves to `any` — a broken export map degrades exactly // this way — this line fails FIRST and names the reason. type _ConfigIsReal = Assert, false>>; + type _SpecConfigIsReal = Assert, false>>; + + // ⭐ THE CENSUS, and objectui#8841 changed what it is made of. It used to be + // a LITERAL key list maintained here by hand — and a hand-maintained list + // is exactly what let `titleField` through: the list was updated to match + // the drift, so the pin agreed with the defect and stayed green while the + // published type accepted a key `@objectstack/spec@17.4.0` refuses on + // `ListView.tree`. A census can only be total over something it does not + // also author. + // + // So it is PARITY WITH THE PROTOCOL now. Structural equality, not `extends`: + // a hand-written twin passes an assignability check in both directions and + // would defeat the derivation entirely. + type _ParityWithSpec = Assert>; + + // FIRING CONTROL for the line above. An `Equal<…>` loosened until it cannot + // report `false` reads exactly like one that still works. This feeds it the + // near-miss that actually shipped — the spec's block plus `titleField` — and + // requires it to say `false`. + type _ParityCanFail = Assert, false>>; - // The census, total in both directions. This ALSO refuses an index - // signature: `[key: string]: any` would put `string` into `keyof` and this - // equality would fail. That matters more than it looks — an index - // signature here would re-open the exact hole the card was filed for, by - // making every misspelling assignable again. - type _Census = Assert>; + // The defect, pinned by name so its return is reported as itself rather + // than as an anonymous parity failure. + type _TitleFieldIsGone = Assert>; + + // ⛔ No index signature. This does NOT fall out of parity above: were the + // protocol's block `.passthrough()` again (it was, at 17.3.0), both sides + // would carry `[key: string]: unknown` and parity would still hold while + // every misspelling became assignable again. This line is what pins the + // STRICTNESS the card depends on, and it is the line that fires if the + // installed `@objectstack/spec` ever drops below 17.4.0. + type _NoIndexSignature = Assert>; // Every key is optional: a host writes the subset it means. `Partial` // is structurally identical to `T` only when nothing is required. type _AllOptional = Assert>>; - // Per-key types, read against the sibling node schema's spelling. - type _ParentField = Assert>; - type _LabelField = Assert>; - type _TitleField = Assert>; - type _Fields = Assert>; - type _Depth = Assert>; - expect(true).toBe(true); }); + it('parity is a measurement, not a tautology: the protocol\'s RUNTIME shape agrees', () => { + // The type-level pins above are all compile-time, and a compile-time pin on + // a derived alias can only ever restate the derivation. This is the leg that + // reads the installed artifact instead: the same `TreeConfigSchema` the + // publisher parses against, by content. + // + // ⛔ Deliberately NOT a literal key-set equality. Freezing the protocol's + // key list here would make a benign spec addition red in objectui and would + // put a second hand-maintained list back in the file this card emptied. What + // is pinned is the DEFECT and the INSTRUMENT, not the census. + const keys = Object.keys(TreeConfigSchema.shape); + expect(keys).not.toContain('titleField'); + + // FIRING CONTROL for the line above, same instrument: a key the protocol + // DOES declare is found, so the zero is a reading about `titleField` and + // not about an empty shape or an import that resolved to a stub. + expect(keys).toContain('parentField'); + + // FIRING CONTROL — the schema accepts what it declares, so the refusal + // below is a statement about the KEY and not about a schema that refuses + // everything. + expect(TreeConfigSchema.safeParse({ parentField: 'parent_id' }).success).toBe(true); + + // The refusal the card was filed for, taken from the protocol itself. + const refused = TreeConfigSchema.safeParse({ titleField: 'name' }); + expect(refused.success).toBe(false); + expect(JSON.stringify(refused.error?.issues)).toContain('titleField'); + }); + it('refuses the misspelling the card was filed for, at compile time', () => { // ⭐ A FRESH object literal is the right instrument HERE, and it is the // wrong one in `types/src/__tests__/menu-item-union.test.ts` — worth diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index ae26d1a9ab..9a0fc311cf 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -119,6 +119,38 @@ function fieldKey(f: any): string | undefined { return columnIdentity(f) || (f && typeof f === 'object' ? f.key : undefined) || undefined; } +/** + * Resolve the host's `tree` block (and the flattened node it may arrive on) + * into the form the renderer indexes. + * + * ## The `schema.titleField` rung is GONE (objectui#8841) + * + * ⛔ This comment sits ABOVE the function on purpose: the reader census in + * `types/src/__tests__/tree-view-config-readers-8253.test.ts` slices this + * function's BODY and asserts the key's absence from it, and prose about a + * deleted read inside that slice would read as the read itself — the same trap + * that file already documents for `interface TreeConfig`. + * + * A third rung used to stand in the `labelField` chain below, + * `?? schema.titleField`. Three measurements retired it: + * + * - it read the FLATTENED NODE (`schema`), never the block — `nested.titleField` + * was never spelled in this file — so it was not a `ListView.tree` read at all; + * - `titleField` is declared on NEITHER face: not on `ObjectTreeSchema` + * (`@object-ui/types`, the TS interface and the zod mirror alike), and not on + * the spec's `ListView.tree`, which refuses it by name since + * `@objectstack/spec@17.4.0` closed `TreeConfigSchema`. It survived only on + * `schema` being `any`; + * - it was unreachable from both in-repo producers of an `object-tree` node + * (`plugin-view`'s and `plugin-list`'s `'tree'` branches): each floors + * `labelField` to `'name'` before the node is built, so the `??` chain never + * fell through. + * + * objectui#8253's ruling said to declare the key only if the console writes it — + * measured, it does not (`CreateViewDialog.tsx`'s `tree` slot collects + * `parentField` alone) — else delete the read. This is that deletion, executed + * on objectui#8841. + */ function getTreeConfig(schema: any): ResolvedTreeConfig { const nested = (schema.tree || schema.filter?.tree || {}) as TreeViewConfig; const rawFields = Array.isArray(schema.fields) @@ -128,8 +160,7 @@ function getTreeConfig(schema: any): ResolvedTreeConfig { : []; return { parentField: fieldKey(schema.parentField ?? nested.parentField), - labelField: - fieldKey(schema.labelField ?? nested.labelField ?? schema.titleField) ?? 'name', + labelField: fieldKey(schema.labelField ?? nested.labelField) ?? 'name', fields: rawFields.map(fieldKey).filter((f: unknown): f is string => !!f), defaultExpandedDepth: schema.defaultExpandedDepth ?? nested.defaultExpandedDepth, }; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index c1a807af68..6765e24f38 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -325,6 +325,13 @@ export interface ObjectViewProps { * `@object-ui/types`, which `plugin-tree`'s resolver imports as well. One * declaration, three readers. * + * ⭐ And since objectui#8841 that one declaration is the PROTOCOL's: + * `TreeViewConfig` is `NonNullable` from + * `@objectstack/spec/ui`, not a hand copy of it under a second name. What + * this prop admits is therefore exactly what `@objectstack/spec` admits on + * `ListView.tree` — including its refusal of `titleField`, which the copy + * declared and the protocol rejects. + * * ⚠️ Reach, measured on this tree and NOT claimed wider than it is: the * console's own call site passes `mergedViews`, built by * `app-shell/src/views/ObjectView.tsx` as `views.map((v: any) => …)` over @@ -1542,6 +1549,12 @@ export const ObjectView: React.FC = ({ // Single-parent pointer field; auto-detected from the object's // `tree`/self-reference field when not specified. parentField: viewOptions.tree?.parentField, + // ⚠️ `titleField` is an UNDECLARED tolerant fallback (objectui#8841), + // not part of the block: `@objectstack/spec@17.4.0` refuses + // `tree.titleField` by name and `TreeViewConfig` no longer carries it. + // The read survives because `viewOptions` is untyped here, and it is + // kept so view records already storing the key keep resolving. + // ⛔ Not to be re-declared anywhere; its retirement is a follow-up. labelField: viewOptions.tree?.labelField || viewOptions.tree?.titleField || 'name', // The view's columns double as the tree-grid's flat columns. fields: viewOptions.tree?.fields || baseProps.fields, diff --git a/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts b/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts index adbdeb9b1c..d4ebb3c9d1 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts +++ b/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts @@ -110,7 +110,6 @@ describe('objectui#8253 — the views entry declares its `tree` block', () => { tree: { parentField: 'parent', labelField: 'name', - titleField: 'subject', fields: ['name', 'manager'], defaultExpandedDepth: 1, }, @@ -118,4 +117,28 @@ describe('objectui#8253 — the views entry declares its `tree` block', () => { expect(full.tree).toMatchObject({ parentField: 'parent', defaultExpandedDepth: 1 }); }); + + it('REFUSES `titleField` — the key the protocol rejects on `ListView.tree` (objectui#8841)', () => { + // objectui#8253 declared `titleField` on this block; `@objectstack/spec@17.4.0` + // REFUSES it there by name (`TreeConfigSchema` is a `strictObject` since spec + // #15469). So the declaration accepted what the contract rejects, and a host + // that followed this prop's type was refused at publish. objectui#8841 + // re-derives the block from the spec, which removes the key. + // + // This is the diagnostic that MOVED, and it is the point of the card: the + // host writing the entry inline now learns at compile time what it used to + // learn from a parse failure at publish. + // ⚠️ The directive must be the LAST comment line before the property. + const composed: ViewEntry = { + id: 'tree', + label: 'Hierarchy', + type: 'tree', + tree: { + // @ts-expect-error objectui#8841 — spec 17.4.0 refuses `tree.titleField`; the block no longer declares it. + titleField: 'subject', + }, + }; + + expect(composed.type).toBe('tree'); + }); }); diff --git a/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts b/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts index a46168a191..a35f2a1a84 100644 --- a/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts +++ b/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts @@ -7,8 +7,9 @@ */ /** - * objectui#8253 — `TreeViewConfig`'s key census is total over its readers, and - * the module-local copy it replaced is GONE rather than shadowed. + * objectui#8253 / objectui#8841 — `TreeViewConfig`'s key census is total over + * the PROTOCOL, its readers agree with it, and the module-local copy it + * replaced is GONE rather than shadowed. * * ## The card * @@ -41,6 +42,17 @@ * `"paths": {}` sends the specifier through the workspace dependency to * `packages/types/dist/index.d.ts`. Two files, two halves, neither redundant. * + * ## What objectui#8841 changed (and why the census stopped being a list) + * + * objectui#8253's export was a hand-written INTERFACE — the protocol's + * `ListView.tree` block, copied under a second name — and it declared a fifth + * key, `titleField`, that `@objectstack/spec@17.4.0` refuses on that block by + * name. The pins here did not catch it because `DECLARED` was AUTHORED beside + * the type: the list was written to match the drift, so it agreed with the + * defect and stayed green. objectui#8841 derives the type from the protocol and + * checks `DECLARED` against `TreeConfigSchema`'s own shape in both directions, + * so the list can no longer drift silently — only loudly. + * * ## Every zero below has a firing control * * A `not.toMatch` is worth nothing until the same instrument is shown matching @@ -53,6 +65,9 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { TreeConfigSchema } from '@objectstack/spec/ui'; +import type { ListView as SpecListView } from '@objectstack/spec/ui'; + import type { TreeViewConfig } from '../index'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -64,11 +79,21 @@ const VIEW_BRANCH = 'packages/plugin-view/src/ObjectView.tsx'; const LIST_BRANCH = 'packages/plugin-list/src/ListView.tsx'; const CONSOLE_COMPOSITION = 'packages/app-shell/src/views/ObjectView.tsx'; -/** The declared keys, as a value — so the runtime census and the type agree. */ +/** + * The declared keys, as a value — `it.each` needs one, and a failure named + * `reads \`labelField\`` is worth more than a failure named `reads keys[1]`. + * + * ⚠️ objectui#8841: this list is no longer AUTHORED here. It is checked against + * `@objectstack/spec`'s own `TreeConfigSchema` in both directions below, and + * that check is the reason the list is allowed to exist at all. The previous + * spelling was authored here, carried a fifth key (`titleField`) the protocol + * refuses on `ListView.tree`, and stayed green precisely because the list and + * the type were maintained together — a census cannot be total over something + * it also authors. + */ const DECLARED = [ 'parentField', 'labelField', - 'titleField', 'fields', 'defaultExpandedDepth', ] as const; @@ -87,6 +112,33 @@ type Equal = // as loudly as the reverse. type _CensusMatchesType = Assert>; +// objectui#8841 — and the type itself is the PROTOCOL's block, so the chain +// closes: list ≡ type ≡ spec. Structural equality rather than `extends`; a +// hand-written twin satisfies assignability in both directions and would +// defeat the derivation. +type SpecTreeConfig = NonNullable; +type _TypeIsTheProtocolBlock = Assert>; +// FIRING CONTROL: the instrument above must be able to say `false`, and the +// near-miss it is shown is the one that actually shipped. +type _ParityCanFail = Assert, false>>; + +describe('the census is the protocol\'s key set, not a copy of it (objectui#8841)', () => { + const specKeys = Object.keys(TreeConfigSchema.shape).sort(); + + it('list ≡ protocol, both directions', () => { + expect([...DECLARED].sort()).toEqual(specKeys); + }); + + it('CONTROL: the comparison can fail — the key that shipped is the one it rejects', () => { + // Without this, an `toEqual` between two lists that were both derived from + // the same place would be indistinguishable from one that cannot fail. + expect([...DECLARED, 'titleField'].sort()).not.toEqual(specKeys); + expect(specKeys).not.toContain('titleField'); + // FIRING CONTROL on the zero above: a key the protocol DOES declare. + expect(specKeys).toContain('parentField'); + }); +}); + describe('the module-local copy is gone, not shadowed (objectui#8253)', () => { const src = read(RESOLVER); @@ -168,112 +220,103 @@ describe('every declared key has a reader (objectui#8253)', () => { }); }); -describe('`titleField` stays DECLARED — the measurement the ruling asked for', () => { - // The ruling put this key to a measurement: declare it if the console writes - // it, else delete the read. The answer came back split, and the READ side - // decides. +describe('`titleField` is NOT declared — the reads that survive are tolerance (objectui#8841)', () => { + // objectui#8253 put this key to a measurement — declare it if the console + // WRITES it, else delete the read — and then declared it on READ-side + // evidence instead. `@objectstack/spec@17.4.0` settles it: `TreeConfigSchema` + // is a `strictObject` and REFUSES `titleField` on `ListView.tree` by name, so + // the declaration published a key the protocol rejects and an author who + // followed `@object-ui/types` was refused at publish. 协议为基准. // - // - the console's create-view dialog does NOT offer it: its `tree` slot - // collects `parentField` alone (asserted below); - // - but the console's own host composition reads it BY NAME, as do the - // `ListView` and `ObjectView` tree branches; - // - and objectui#6557 pinned the rung as live behaviour in - // `app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx` - // ("the tree's second view-declared rung … still answers"), a regression - // pin landed precisely so a later edit could not collapse the tree's two - // view-declared rungs into one. + // What this block owns now is the SHAPE OF THE REMAINDER, so the next session + // does not read "the key is gone" as "every read is gone": // - // ⇒ Deleting the read would reverse a recorded ruling and redden its pin — - // a maintainer's call, not a rider on this card. Declaring the key is what - // makes declared = enforced at all four sites at once. - - /** - * The console's tree rung, located by NAME so the failure message is the one - * line at issue rather than a dump of the whole file. - * - * `labelField:` is the only seam of its kind in that file — the other five - * (objectui#6557 counts six) all spell `titleField:` — and objectui#6557 - * separately requires every seam to name `viewDef` itself, so a rung hoisted - * into a local is already forbidden there and shows up here as an absent - * rung rather than as a silently passing one. - */ - const consoleTreeRung = () => - read(CONSOLE_COMPOSITION) - .split('\n') - .map((l) => l.trim()) - .find((l) => l.startsWith('labelField:') && l.includes('viewDef') && /\btree\b/.test(l)); - - /** - * Reads `titleField` off a `tree` expression — tolerant of an interposed TYPE - * ANNOTATION, intolerant of the read being gone. - * - * ⚠️ Why it is not `toContain('tree?.titleField')` any more. That was the - * first spelling, and objectui#7559 turned it RED on a tree where the read - * was untouched: typing the block as `TreeViewConfig` put a cast BETWEEN the - * two halves of the substring — - * `(viewDef.tree as TreeViewConfig | undefined)?.titleField`. A pin on a READ - * must not double as a pin on the annotation standing in front of it; the - * claim this file makes is "the console composition reads it", and that claim - * was still true when the substring stopped being there. - * - * ⛔ It is still a pin on the read, not a formality. The gap it tolerates is - * an `as` clause and nothing else: `||` may not appear in it, so `titleField` - * has to be taken off THIS `tree`, not off some other term further along the - * same line. Deleting the read reddens it — measured by ablation on - * objectui#7559, by test-case name. - */ - const READS_TITLE_FIELD = /\btree\b(?:\s+as\s+(?:[^|\n]|\|(?!\|))*?)?\s*\)?\s*\?\.titleField\b/; - - it('the console composition reads it', () => { - const rung = consoleTreeRung(); - // Locating the rung is half of the assertion: if the console stops - // composing a `tree` label from `viewDef` at all, that is where it surfaces. - expect(rung, 'the console composition has no `viewDef` tree `labelField` rung').toBeDefined(); - expect(rung).toMatch(READS_TITLE_FIELD); + // - the resolver's `schema.titleField` rung is DELETED (it read the flattened + // NODE, and `titleField` is declared on neither the node face nor the + // block's); + // - the three `labelField || titleField` dual-reads in plugin-view, + // plugin-list and app-shell SURVIVE as undeclared tolerant fallbacks, so + // already-stored view records keep resolving. They are recorded here for a + // follow-up, ⛔ not declared anywhere — re-declaring a renderer-side alias + // is the AGENTS.md #0.1 defect this card undoes. + + it('the protocol refuses it on `ListView.tree`, measured on the installed artifact', () => { + const refused = TreeConfigSchema.safeParse({ titleField: 'name' }); + expect(refused.success).toBe(false); + expect(JSON.stringify(refused.error?.issues)).toContain('titleField'); + + // FIRING CONTROL: the same schema accepts what it declares, so the refusal + // is about the KEY and not about a schema (or an import) that refuses + // everything. + expect(TreeConfigSchema.safeParse({ labelField: 'name' }).success).toBe(true); }); - it('CONTROL: the instrument tells a present read from a deleted one', () => { - // A regex loosened until it cannot fail reads exactly like one that still - // works, so both spellings this rung has actually worn are pinned as - // matching, and the shape that means "the read was deleted" as not. - const BEFORE_7559 = - "labelField: (viewDef as any).tree?.labelField || (viewDef as any).tree?.titleField || 'name',"; - const AFTER_7559 = - "labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField" + - " || (viewDef.tree as TreeViewConfig | undefined)?.titleField || 'name',"; - const READ_DELETED = - "labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || 'name',"; - - expect(BEFORE_7559).toMatch(READS_TITLE_FIELD); - expect(AFTER_7559).toMatch(READS_TITLE_FIELD); - expect(READ_DELETED).not.toMatch(READS_TITLE_FIELD); - - // And the tolerance is bounded: a `titleField` taken off a DIFFERENT term - // on the same line is not this rung's read. - expect("labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || other?.titleField || 'name',") - .not.toMatch(READS_TITLE_FIELD); + it('the resolver no longer reads it', () => { + const resolver = read(RESOLVER); + const from = resolver.slice(resolver.indexOf('function getTreeConfig')); + const fn = from.slice(0, from.indexOf('\n}\n') + 3); + + // Bound the slice, exactly as the census above does: a renamed function + // silently turns this into "the whole file" and the negative below would + // then be a statement about nothing. + expect(fn).toContain('function getTreeConfig'); + expect(fn.length).toBeLessThan(resolver.length / 2); + + expect(fn).not.toContain('titleField'); + + // FIRING CONTROL: the same slice, the same instrument, on the rung that IS + // there. Without this, a slice that had become empty would pass the line + // above. + expect(fn).toContain('labelField'); }); - it('the plugin-view tree branch reads it', () => { - expect(read(VIEW_BRANCH)).toContain('viewOptions.tree?.titleField'); + it('CONTROL: the explanation of the deleted rung is in the file, just not in the body', () => { + // The prose-vs-declaration trap this file already documents for `interface + // TreeConfig`, in its second instance. `ObjectTree.tsx` explains the removal + // ABOVE `getTreeConfig` precisely so the slice above stays a reading about + // the code. If someone moves that prose inside the function, the negative + // above starts failing for the wrong reason — this line is what tells them + // which of the two happened. + expect(read(RESOLVER)).toContain('titleField'); }); - it('the plugin-list tree branch reads it', () => { + it('the three tolerant dual-reads survive, undeclared, pending a follow-up', () => { + // ⚠️ This is a RECORD, not an endorsement. Each of these reads a key the + // protocol refuses and no face declares; each reads it through `any`, which + // is why none of them had to change when the declaration went. Retiring them + // touches three packages and is a decision, not a rider on this card. + expect(read(VIEW_BRANCH)).toContain('viewOptions.tree?.titleField'); expect(read(LIST_BRANCH)).toContain('treeCfg.titleField'); + + const rung = read(CONSOLE_COMPOSITION) + .split('\n') + .map((l) => l.trim()) + .find((l) => l.startsWith('labelField:') && l.includes('viewDef') && /\btree\b/.test(l)); + expect(rung, 'the console composition has no `viewDef` tree `labelField` rung').toBeDefined(); + expect(rung).toContain('titleField'); + + // ⛔ And the console rung is NOT cast to `TreeViewConfig` on the tolerant + // half. It cannot be — the type is the protocol's block now and does not + // carry the key — and casting it to a local `{ titleField?: string }` + // instead would re-declare exactly what this card removed. The canonical + // rung on the same line stays typed; that asymmetry is the change. + expect(rung).toContain("(viewDef.tree as TreeViewConfig | undefined)?.labelField"); + expect(rung).not.toContain('as TreeViewConfig | undefined)?.titleField'); }); - it("objectui#6557's pin on the rung is still in the tree", () => { - // Named explicitly: if this file is ever deleted, whoever deletes it is - // told here that this declaration's justification went with it. + it("objectui#6557's pin on the rung is still in the tree, and still passes", () => { + // Named explicitly: the tolerant console rung above is the behaviour that + // pin asserts, so deleting the rung without touching that file would redden + // it. Whoever takes the follow-up is told here. expect(read('packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx')) .toContain('tree.titleField'); }); it("CONTROL: the console's create-view dialog still does NOT offer it for `tree`", () => { - // The other half of the measurement, and the half that would have argued - // for deleting the read. `titleField` IS collected — for calendar, - // timeline and gantt — so a zero for the `tree` slot is a reading about - // the tree slot and not about a broken instrument. + // The half of objectui#8253's own measurement that argued for deleting the + // read, re-measured on this base rather than carried over: `titleField` IS + // collected — for calendar, timeline and gantt — so a zero for the `tree` + // slot is a reading about the tree slot and not about a broken instrument. const dialog = read('packages/app-shell/src/views/CreateViewDialog.tsx'); const treeSlot = (() => { const from = dialog.slice(dialog.indexOf('\n tree: [')); diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 544cb074e9..29824e7eb7 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -52,26 +52,58 @@ import type { ListView as SpecListView } from '@objectstack/spec/ui'; export type ViewType = NonNullable | 'list' | 'detail'; /** - * Per-view `tree` configuration — the HOST-composition contract (objectui#8253). + * Per-view `tree` configuration — the HOST-composition contract + * (objectui#8253), DERIVED from `@objectstack/spec` rather than re-declared + * (objectui#8841). * * This is the block a host writes as `tree` on a `views` entry - * (`ObjectViewProps.views[n].tree`, or `options.tree` on a stored view record), - * and it is the ONE declaration of that shape. ⛔ Do not re-derive a private - * copy of it: the module-local `TreeConfig` in `plugin-tree/src/ObjectTree.tsx` - * that used to be the only description of these keys is now an import of this - * type, and objectui#7646 is the shape a second copy takes. + * (`ObjectViewProps.views[n].tree`, or `options.tree` on a stored view record). + * ⛔ Do not re-derive a private copy of it: the module-local `TreeConfig` in + * `plugin-tree/src/ObjectTree.tsx` that used to be the only description of these + * keys is now an import of this type, and objectui#7646 is the shape a second + * copy takes. * - * ## Why it is declared here, and what it is NOT + * ## Why this is an ALIAS and not an interface (objectui#8841) * - * `tree` is a HOST-COMPOSITION-ONLY view type, ruled deliberate on objectui#5321 - * (maintainer ruling B, 2026-08-20): it is a member of neither - * `ObjectViewSchema.defaultViewType` nor `NamedListView.type`, so no AUTHORED - * document selects a tree view and the branch runs only when a host passes a - * `views` prop. ⛔ That ruling is untouched by this declaration — typing the - * host path does not put `tree` on an authored union, and this type is NOT the - * `object-tree` NODE schema. The node an author writes is `ObjectTreeSchema` - * (`./objectql.ts`), whose keys sit FLAT on the node; this block sits nested - * under a view entry and is written by a host, never by a document author. + * The spec owns this block. `@objectstack/spec` declares it as `TreeConfig` and + * hangs it on `ListView.tree`, and this package already publishes that same + * block derived — `zod/objectql.zod.ts` extends `SpecListViewSchema.shape` by + * reference and `tree` is not in `LIST_VIEW_LOCAL_OVERRIDES`. objectui#8253 + * shipped this name as a hand-written INTERFACE instead: a faithful copy of a + * spec object under a second name. That is objectui#4592's blind spot stated + * exactly — `scripts/check-spec-symbol-derivation.mjs` matches BY NAME, so a + * hand copy RENAMED away from the spec's symbol has nothing for rule 1 to + * match, and it passes every run while it drifts. + * + * It had already drifted when it landed. The copy declared a fifth key, + * `titleField`, which `@objectstack/spec@17.4.0` REFUSES on `ListView.tree` by + * name — `TreeConfigSchema` is a `strictObject` since spec #15469 closed the + * `.passthrough()` window 17.3.0 left open. So this package's published face + * accepted what the protocol rejects, and an author who followed this type was + * refused at publish. 协议为基准: this type now IS the spec's, so the next key + * the protocol adds, renames or retypes arrives here without an edit, and a key + * the protocol never declared cannot be added here at all. + * + * ⛔ Not a `Pick` of the spec's keys. A `Pick` restates the key list, and a + * restated key list is exactly what let `titleField` through. The `Pick` + * interim objectui#8841 offered was conditional on the 17.4.0 bump not having + * happened yet — it landed on `main` before this change (`chore(deps): take the + * 17.4.0 @objectstack/* line`), so the pinned spec is already strict and the + * plain alias is available. + * + * ## `titleField` is NOT declared here; the reads that survive are tolerance + * + * objectui#8253's own ruling put the key to a measurement — declare it if the + * console WRITES it, else delete the read — and the measured answer is no: the + * console's create-view dialog collects `parentField` alone in its `tree` slot + * (`app-shell/src/views/CreateViewDialog.tsx`), while `titleField` is what its + * calendar / timeline / gantt slots collect. The `schema.titleField` rung in + * `getTreeConfig` goes with this change; the three `labelField || titleField` + * dual-reads (`plugin-view`, `plugin-list`, `app-shell`) stay as ⛔ UNDECLARED + * tolerant fallbacks, reading through `any` and declared by nothing on either + * side. They are recorded for a follow-up, ⛔ not re-declared: fossilising a + * renderer-side alias into a second contract is what AGENTS.md #0.1 bans, and + * it is what this change undoes. * * ## Why it is a contract at all (objectui#8253, ruling batch #78, 2026-09-07) * @@ -83,67 +115,36 @@ export type ViewType = NonNullable | 'list' | 'detail'; * views entry, read by nobody, and reported by nothing: declared ≠ enforced on * a surface a non-author re-writes. * - * ## The reader census these keys are exactly total over + * ## Why the name is aliased here, and what it is NOT + * + * `tree` is a HOST-COMPOSITION-ONLY view type, ruled deliberate on objectui#5321 + * (maintainer ruling B, 2026-08-20): it is a member of neither + * `ObjectViewSchema.defaultViewType` nor `NamedListView.type`, so no AUTHORED + * document selects a tree view and the branch runs only when a host passes a + * `views` prop. ⛔ That ruling is untouched — aliasing the host path does not + * put `tree` on an authored union, and this type is NOT the `object-tree` NODE + * schema. The node an author writes is `ObjectTreeSchema` (`./objectql.ts`), + * whose keys sit FLAT on the node; this block sits nested under a view entry + * and is written by a host, never by a document author. + * + * ## The reader census this alias is exactly total over * - * Every key below is read, and every read of this block is of a key below: + * Every key is read, and every read of this BLOCK is of a key the spec declares: * * - `plugin-tree/src/ObjectTree.tsx` `getTreeConfig` — the resolver * - `plugin-view/src/ObjectView.tsx` the `'tree'` branch of `generateViewSchema` * - `plugin-list/src/ListView.tsx` the `'tree'` branch * - `app-shell/src/views/ObjectView.tsx` `options.tree`, the console's own composition * - * ⚠️ `fields` is `string[]`, matching `ObjectTreeSchema.fields`, even - * though `ObjectTree`'s `fieldKey` also normalises a column OBJECT + * ⚠️ `fields` is `string[]`, matching `ObjectTreeSchema.fields`, even though + * `ObjectTree`'s `fieldKey` also normalises a column OBJECT * (`{ name | fieldName | field | key }`). That tolerance exists because hosts * like `ListView` forward their own already-resolved column entries into the * same slot; it is a reader's resilience, ⛔ not an invitation to write column - * objects here, and declaring the wider form would widen the accept set past - * what the sibling node schema admits. + * objects here — and it is now the SPEC that refuses the wider form, not a + * local choice. */ -export interface TreeViewConfig { - /** - * Field holding the parent record reference (single-parent pointer). - * When omitted, the renderer auto-detects the object's `tree` / - * self-reference field. - */ - parentField?: string; - /** Field rendered (indented) in the tree's first column. Floored at `name`. */ - labelField?: string; - /** - * Legacy second rung for {@link TreeViewConfig.labelField}, kept DECLARED - * rather than removed — measured, objectui#8253. - * - * The ruling put this key to a measurement: declare it if the console writes - * it, else delete the read. The measurement came back split, and the half - * that decides is the READ side. The console's create-view dialog does NOT - * offer it — `CreateViewDialog.tsx`'s `tree` slot collects `parentField` and - * nothing else, while `titleField` is what its calendar / timeline / gantt - * slots collect. But the console's own host composition reads it BY NAME - * (`app-shell/src/views/ObjectView.tsx`, `viewDef.tree?.titleField`), so do - * `ListView` and `ObjectView`'s tree branches, and the rung is PINNED as live - * behaviour by `app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx` - * — "the tree's second view-declared rung (`tree.titleField`) still answers", - * a regression pin objectui#6557 landed precisely so a later edit could not - * collapse the tree's two view-declared rungs into one. - * - * ⇒ Deleting the read would reverse a recorded ruling and redden its pin, - * which is a maintainer's call and not a rider on this card. Declaring it is - * what makes declared = enforced across all four read sites at once. - * - * ⛔ Not a tolerant dual-read to be extended: `labelField` is the canonical - * spelling and wins wherever both are present. - */ - titleField?: string; - /** - * Additional fields rendered as flat columns alongside the label column. - * When omitted the host's own column list is used. - */ - fields?: string[]; - /** - * Initial expansion depth. `0` = roots only; omitted = expand everything. - */ - defaultExpandedDepth?: number; -} +export type TreeViewConfig = NonNullable; /** * Detail View Field Configuration