diff --git a/.changeset/8655-object-tree-props-schema-typed.md b/.changeset/8655-object-tree-props-schema-typed.md new file mode 100644 index 0000000000..ddc7aca299 --- /dev/null +++ b/.changeset/8655-object-tree-props-schema-typed.md @@ -0,0 +1,47 @@ +--- +'@object-ui/plugin-tree': minor +--- + +`ObjectTreeProps.schema` is the published `object-tree` node instead of `any`, +and `getTreeConfig`'s parameter with it (objectui#8655). + +The type is DERIVED, not written out: `Extract< ObjectQLComponentSchema, { type: +'object-tree' } >`, the spelling that union's own docblock teaches. So every key +name and key type still has exactly one declaration, `ObjectTreeSchema` in +`@object-ui/types`, and a key added or retyped there arrives here without an +edit. `Extract` rather than a named import because the TS barrel does not export +`ObjectTreeSchema` — the zod barrel does, its nine siblings are all on the TS +one, and repairing that omission is a published-surface addition on another +package, so it is reported rather than smuggled in here. + +Accept-set change on the published props type, stated plainly: + +- NARROWS. `any` accepted everything; the node accepts what `ObjectTreeSchema` + declares plus anything `BaseSchema`'s `[key: string]: any` admits. Breaking + for a TypeScript consumer that passed a value the node schema refuses at a + DECLARED key — `labelField` and `fields` are the two that can bite, since the + renderer tolerates host column OBJECTS there while the declaration says + `string` / `string[]`. This package's own test carried exactly that shape and + now says so at the mount rather than compiling silently. +- No runtime behaviour changes. One `as any` is removed at a read site and one + is deliberately kept; both are casts, neither is a value. + +Why the type had to come first: the two reads this card was filed on were +reported UNANSWERABLE, not undeclared. Through `any`, `checker.getPropertyOfType` +returns `undefined` for a key the node certainly declares exactly as it does for +a nonsense token, so no verdict was available at all. Typed, the checker answers, +and the two answers point in opposite directions — `data` is DECLARED (on +`BaseSchema`), so its cast is gone; `navigation` is genuinely undeclared and its +cast stays, marking the read rather than hiding it. + +What the re-measurement then surfaced, reported rather than tidied: six more +reads became answerable, five declared and one — `filter` — not. Its verdict is +declare-by-mirror-alignment and it is ⛔ NOT executed here, because the two files +that would carry it are held by an in-flight branch; the ledger row in +`ObjectTree.schemaTyped-8655.test.ts` reddens the day it lands. `tree` is +declared by the protocol on the VIEW and on zero element faces, so declaring it +on the node would fork rather than mirror the contract, and retiring the read +needs a producer census a text search cannot make. `navigation` belongs to a +blocked card and is ledgered, ⛔ not ruled. + +Nothing in `@object-ui/types` is declared, widened or narrowed by this change. diff --git a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts new file mode 100644 index 0000000000..f969358076 --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts @@ -0,0 +1,407 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8655 — `ObjectTreeProps.schema` is the published `object-tree` node, + * and every key this renderer reads off it now has an ANSWER. + * + * ## The card's ordering, and why it is the whole point + * + * `ObjectTreeProps` declared `schema: any`. Two reads — `data` and + * `navigation` — were filed as class (d), *structurally unanswerable*, ⛔ not + * as "undeclared": `checker.getPropertyOfType` through `any` returns + * `undefined` for `objectName`, a key this node certainly declares, exactly as + * it does for a nonsense token. An absence returned by an instrument unable to + * see a declaration is not a reading (objectui#8410). So the order was ① type + * the prop, ② re-run the checker, ③ classify — and ⛔ never ③ first. + * + * The answerability control is pinned below and it FIRES: `Declares` answers + * `false` for both a real member and a nonsense token when asked of `any`, and + * separates them when asked of the node. + * + * ## What ② returned, and what ① surfaced on the way + * + * Both class-(d) reads changed status, in OPPOSITE directions, and the second + * of them is the outcome the card warned to expect: + * + * - `data` is **DECLARED** — on `BaseSchema` ("Arbitrary data attached to the + * component"). The `(schema as any)` at that read was hiding a declaration, + * not reaching past its absence, so the cast is gone. This is the same + * shape objectui#8655's body recorded for plugin-detail's `add`. + * - `navigation` is **UNDECLARED** — it survives on `BaseSchema`'s + * `[key: string]: any` alone. + * + * ⚠️ And typing the prop made SIX more reads answerable that `any` had hidden — + * the card calls that the expected outcome, ⛔ not scope creep. Five are + * declared (`objectName` `parentField` `labelField` `fields` + * `defaultExpandedDepth`); one, `filter`, is not. + * + * ## ③ — the family ruling, applied per key, each with its own reading + * + * The ruling (objectui#8327 triage): does `@objectstack/spec` declare this key, + * and on which schema? declared ⇒ align the mirror; not declared ⇒ "declare" is + * off the table and the exit is retire-the-read or route-to-producer. + * + * ⚠️ One reading governs all three, and it is measured below rather than + * assumed: the installed `@objectstack/spec` has **no `ComponentPropsMap` entry + * for `object-tree` at all** (nor for `tree`), while `object-calendar` and + * `object-grid` are present. So no key can be confirmed on "the element schema + * this node maps to" — there is none. Each verdict therefore names the schema + * the spec DOES declare the key on, and says what follows: + * + * - **`navigation` — ⛔ NOT RULED HERE.** objectui#8652's family, maintainer + * ruled option B (declare on the platform element schemas first, then + * mirror), `pm:blocked` on objectstack#17987, unlock criterion = a released + * spec carrying the declaration installable here. Measured: `navigation` is + * declared on exactly ONE element entry, `object-grid` — so the unlock has + * not happened. Ledgered by name, read untouched. + * - **`filter` — DECLARE, and ⛔ not executed on this branch.** The spec + * declares it on `ListView` and on four comparable `object-*` element + * entries; this repo's mirror declares it on every sibling node schema and + * omits it on this one alone. That is align-the-mirror. ⛔ It is not done + * here because `packages/types/src/objectql.ts` and its zod twin are held + * by an in-flight branch (objectui#9309), and the declared handling for + * that breach is to stop and report. Ledgered; the row below reddens the + * day it is declared, which is when this ledger should be retired. + * - **`tree` — ⛔ declare is OFF THE TABLE, and this card does not retire + * it.** The spec declares `tree` on the VIEW (`ListView.tree`, mirrored + * here as `TreeViewConfig`) and on ZERO element entries. Putting a + * view-level block on a node schema is not mirroring the contract, it is + * forking it. Retiring the read is the other exit and it needs a producer + * census this card cannot soundly make: producers deliver these keys by + * SPREADING the block flat, and a text census is structurally blind to a + * key arriving through a spread (objectui#8651 measured exactly that + * failure and had to withdraw a retirement). ⇒ routed, rung untouched. + * + * ## The ceiling, stated so nobody reads this file as claiming more + * + * `BaseSchema` ends in `[key: string]: any`. Typing the prop makes the question + * ANSWERABLE; it refuses nothing at the read site and rejects no misspelling + * (objectui#5155 / objectui#7927). The counter-probe below pins that honestly. + */ +/// +// ⚠️ The triple-slash reference is the route, ⛔ not an oversight. This package's +// `tsconfig.test.json` leaves `types` unset, so naming it there would switch OFF +// automatic `@types/*` inclusion for every other test in the package; the +// per-file reference registers Node's globals for this program alone. Both +// routes are documented by `scripts/tsconfig-test-parity-census.mjs`, which reads +// them off the resolved program rather than off the config text. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { ComponentPropsMap, TreeConfigSchema } from '@objectstack/spec/ui'; +import { + ObjectTreeSchema as ObjectTreeMirror, + ObjectGridSchema as ObjectGridMirror, + ObjectMapSchema as ObjectMapMirror, + ObjectGanttSchema as ObjectGanttMirror, + ObjectCalendarSchema as ObjectCalendarMirror, + ObjectKanbanSchema as ObjectKanbanMirror, + ObjectChartSchema as ObjectChartMirror, + ObjectGallerySchema as ObjectGalleryMirror, + ObjectDataTableSchema as ObjectDataTableMirror, +} from '@object-ui/types/zod'; +import type { ObjectQLComponentSchema } from '@object-ui/types'; + +import type { ObjectTreeProps } from './ObjectTree'; + +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..'); +const TREE_READER = 'packages/plugin-tree/src/ObjectTree.tsx'; + +/** The node the checker now sees at every read, spelled the way the renderer spells it. */ +type ObjectTreeNode = Extract; + +/** A key nothing reads and nothing declares — the both-ways control. */ +const CONTROL_KEY = 'zzplTreeAbsentControl8655' as const; + +/** + * ③'s three answers, by name. Every entry is asserted STILL READ below: a + * ledger whose subject has gone is a hole, not an exemption (objectui#8885). + */ +const LEDGERED_UNDECLARED = ['navigation', 'filter', 'tree'] as const; + +/* ── Instruments ──────────────────────────────────────────────────────────── */ + +function readRepo(rel: string): string { + return readFileSync(join(REPO_ROOT, rel), 'utf8'); +} + +/** + * Every key read off the schema node, in EITHER spelling — the bare + * `schema.KEY` and the cast `(schema as any).KEY`. Both spell the same FACT, + * and reading the fact rather than the spelling is what stops these verdicts + * reddening when a cast is removed (objectui#8832). Borrowed verbatim from + * `plugin-calendar`'s `calendarUnionReads-8651` census. + */ +const SCHEMA_READ = /(?:\bschema\b|\(\s*schema\s+as\s+[^)]*\))\s*\.\s*([A-Za-z_$][\w$]*)/g; + +function schemaReads(source: string): Set { + return new Set([...source.matchAll(SCHEMA_READ)].map((m) => m[1])); +} + +/** The renderer's reads, with prose masked so a key named only in a comment cannot vote. */ +function rendererReads(): Set { + return schemaReads(mask(readRepo(TREE_READER))); +} + +/** Declared membership, off a mirror's OWN shape — never off parse acceptance. */ +function shapeKeys(schema: unknown): string[] { + return Object.keys((schema as { shape: Record }).shape); +} + +/** + * ⚠️ `ComponentPropsMap` entries are read through `Record` on + * purpose: `_def` is a zod INTERNAL for which the spec publishes no type, so a + * hand-written shape for it would be a local assertion about a third-party + * runtime that nothing re-checks. Same decision as objectui#8651's pin. + */ +function specElementKeys(type: string): string[] | null { + const entry = (ComponentPropsMap as unknown as Record)[type]; + if (!entry?._def) return null; + const def = entry._def; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + if (!shape || typeof shape !== 'object') return null; + return Object.keys(shape); +} + +function specElementsDeclaring(key: string): string[] { + return Object.keys(ComponentPropsMap as unknown as Record) + .filter((type) => (specElementKeys(type) ?? []).includes(key)) + .sort(); +} + +/* ── Compile-time pins (compiled by `tsc -p tsconfig.test.json`) ───────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; + +/** + * ⭐ THE instrument of this card, at the type level: the DECLARED member names + * of `T`, with any index signature removed by key remapping. `keyof` alone + * cannot answer — `BaseSchema` ends in `[key: string]: any`, so `keyof` of any + * node schema is `string | number` and every token "extends" it. That is the + * same blindness `checker.getPropertyOfType` avoids by returning a SYMBOL only + * for a real member, and this is its compile-time twin. + */ +type DeclaredKeys = keyof { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; +type Declares = K extends DeclaredKeys ? true : false; + +/** ① — the prop is the published node, derived off the union, and it is not `any`. */ +export type _PropIsThePublishedNode = Expect>; +export type _PropIsNotAnyAnyMore = Expect, false>>; +/** The `Equal` helper can FAIL — synthetic control, so the two rows above count. */ +export type _EqualCanFail = Expect, false>>; + +/** + * ⭐ THE ANSWERABILITY CONTROL, and it is the card's whole argument in two + * lines. Asked of `any`, the instrument gives the SAME answer for a member the + * node certainly declares and for a nonsense token — which is why the + * pre-objectui#8655 reading was "unknown", ⛔ never "undeclared". + */ +export type _ThroughAnyARealMemberIsUnanswerable = + Expect, false>>; +export type _ThroughAnyANonsenseTokenReadsTheSame = + Expect, false>>; +/** …and asked of the NODE, the same instrument separates them. */ +export type _OnTheNodeARealMemberIsDeclared = Expect>; +export type _OnTheNodeANonsenseTokenIsNot = + Expect, false>>; + +/** ② — the per-key readings, one row per key, ⛔ no verdict by analogy. */ +export type _DataIsDeclared = Expect>; +export type _ObjectNameIsDeclared = Expect>; +export type _ParentFieldIsDeclared = Expect>; +export type _LabelFieldIsDeclared = Expect>; +export type _FieldsIsDeclared = Expect>; +export type _DefaultExpandedDepthIsDeclared = + Expect>; +export type _NavigationIsUndeclared = Expect, false>>; +export type _FilterIsUndeclared = Expect, false>>; +export type _TreeIsUndeclared = Expect, false>>; + +/** + * ⚠️ THE CEILING. An undeclared key still compiles and still types `any`, so + * declaring buys VALUE typing and never buys refusal of a misspelling. Both + * halves are pinned: the ledgered keys resolve to `any`, and so does a typo. + */ +export type _UndeclaredStillResolvesToAny = Expect>; +export type _MisspellingStillAdmitted = Expect>; + +/** The node still type-checks the way a host writes it, with no cast. */ +const node: ObjectTreeProps['schema'] = { + type: 'object-tree', + objectName: 'duly_task', + parentField: 'parent_id', + labelField: 'name', + fields: ['owner'], + defaultExpandedDepth: 1, +}; + +/* ── 1. Non-vacuity: the instruments can see the tree ──────────────────────── */ + +describe('objectui#8655 — the instruments are reading something (guards every row below)', () => { + it('the read census sees the renderer, and invents nothing', () => { + const reads = rendererReads(); + expect(reads.size).toBeGreaterThan(5); + expect([...reads]).toContain('objectName'); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); + + it('the census fires on BOTH spellings, so a cast removal cannot mute it', () => { + const probe = schemaReads('const a = schema.alphaKey; const b = (schema as any).betaKey;'); + expect([...probe].sort()).toEqual(['alphaKey', 'betaKey']); + }); + + it('the mirror shape is a real set, not everything', () => { + expect(shapeKeys(ObjectTreeMirror)).toContain('parentField'); + expect(shapeKeys(ObjectTreeMirror)).not.toContain(CONTROL_KEY); + }); + + it('the spec reader resolves entries that exist and refuses one that does not', () => { + expect(specElementKeys('object-grid')).toContain('objectName'); + expect(specElementKeys(CONTROL_KEY)).toBeNull(); + }); + + it('the type-face literal is real', () => { + expect(node.type).toBe('object-tree'); + }); +}); + +/* ── 2. The population: every read is declared, or ledgered by name ─────────── */ + +describe('objectui#8655 — every key read off the node is declared, or ledgered', () => { + it('no read is undeclared outside the three ledgered keys', () => { + const reads = rendererReads(); + const declared = new Set(shapeKeys(ObjectTreeMirror)); + const exempt = new Set(LEDGERED_UNDECLARED); + const undeclared = [...reads] + .filter((key) => !declared.has(key)) + .filter((key) => !exempt.has(key)) + .sort(); + expect(undeclared, `undeclared reads on the object-tree node: ${undeclared.join(', ')}`) + .toEqual([]); + }); + + it('the ledger is not stale: every carve-out is STILL READ', () => { + const reads = rendererReads(); + for (const key of LEDGERED_UNDECLARED) { + expect([...reads], `${key} is ledgered but no longer read — the exception is a hole`) + .toContain(key); + } + }); + + it('`data` is declared — the class-(d) read that changed direction', () => { + // The card warned to expect this: on plugin-detail the same prerequisite + // turned one apparently-undeclared key into a declared one. Here it is + // `data`, declared on `BaseSchema` and reached through the mirror's own + // shape rather than through parse acceptance. + expect(shapeKeys(ObjectTreeMirror)).toContain('data'); + }); +}); + +/* ── 3. ③ — the spec readings each verdict rests on ────────────────────────── */ + +describe('objectui#8655 — the spec has no element schema for this node', () => { + it('no `object-tree` entry, and no `tree` entry (controls: two that ARE there)', () => { + expect(specElementKeys('object-tree')).toBeNull(); + expect(specElementKeys('tree')).toBeNull(); + // FIRING CONTROLS on those two nulls — without them a broken reader and a + // real absence give the same answer. + expect(specElementKeys('object-calendar')).toBeTruthy(); + expect(specElementKeys('object-grid')).toBeTruthy(); + }); +}); + +describe('objectui#8655 — `navigation` is objectui#8652\'s, and the unlock has not happened', () => { + it('the spec declares it on ONE element entry, and it is not this one', () => { + const declaring = specElementsDeclaring('navigation'); + expect(declaring).toContain('object-grid'); + expect(declaring).not.toContain('object-tree'); + expect(declaring).not.toContain('object-calendar'); + }); + + it('⛔ and it stays UNDECLARED here — this card must not rule the family', () => { + expect(shapeKeys(ObjectTreeMirror)).not.toContain('navigation'); + }); +}); + +describe('objectui#8655 — `filter` is the one DECLARE verdict, and it is not executed here', () => { + it('the spec declares it on the comparable `object-*` element faces', () => { + const declaring = specElementsDeclaring('filter'); + for (const el of ['object-grid', 'object-kanban', 'object-calendar', 'object-metric']) { + expect(declaring, `${el} should declare filter`).toContain(el); + } + }); + + it('every sibling node mirror in THIS repo declares it — this one alone does not', () => { + // The align-the-mirror ground, re-derived rather than written down. + const siblings: [string, unknown][] = [ + ['ObjectGridSchema', ObjectGridMirror], + ['ObjectMapSchema', ObjectMapMirror], + ['ObjectGanttSchema', ObjectGanttMirror], + ['ObjectCalendarSchema', ObjectCalendarMirror], + ['ObjectKanbanSchema', ObjectKanbanMirror], + ['ObjectChartSchema', ObjectChartMirror], + ['ObjectGallerySchema', ObjectGalleryMirror], + ['ObjectDataTableSchema', ObjectDataTableMirror], + ]; + for (const [name, mirror] of siblings) { + expect(shapeKeys(mirror), `${name} should declare filter`).toContain('filter'); + } + // ⚠️ This row is the LEDGER, not an endorsement: it reddens the day the + // declare verdict is executed, which is exactly when this ledger is due to + // be retired. ⛔ Do not "fix" it by deleting the ledger — declare the key. + expect(shapeKeys(ObjectTreeMirror)).not.toContain('filter'); + }); +}); + +describe('objectui#8655 — `tree` is a VIEW-level block, so declaring it on the node would fork', () => { + it('the spec declares the block, and its members are the four this resolver reads', () => { + const blockKeys = Object.keys( + (TreeConfigSchema as unknown as { shape: Record }).shape, + ).sort(); + expect(blockKeys).toEqual(['defaultExpandedDepth', 'fields', 'labelField', 'parentField']); + }); + + it('…and NO element face declares a `tree` key (control: one key that IS on four)', () => { + expect(specElementsDeclaring('tree')).toEqual([]); + // FIRING CONTROL on that zero, same instrument, same corpus. + expect(specElementsDeclaring('filter').length).toBeGreaterThan(3); + }); + + it('⛔ so it is not declared here either', () => { + expect(shapeKeys(ObjectTreeMirror)).not.toContain('tree'); + }); +}); + +/* ── 4. The ceiling, at runtime as well as at compile time ─────────────────── */ + +describe('objectui#8655 — the objectui#7927 ceiling is UNCHANGED', () => { + it('a misspelled key still rides through the mirror', () => { + const base = { type: 'object-tree', objectName: 'duly_task' }; + expect(ObjectTreeMirror.safeParse({ ...base, parentFeild: 'parent_id' }).success).toBe(true); + // CONTROL: the mirror is not simply accepting everything — a declared key + // with the wrong VALUE type is refused. + expect(ObjectTreeMirror.safeParse({ ...base, parentField: 42 }).success).toBe(false); + expect(ObjectTreeMirror.safeParse(base).success).toBe(true); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.test.tsx b/packages/plugin-tree/src/ObjectTree.test.tsx index a17336b732..42c2816de1 100644 --- a/packages/plugin-tree/src/ObjectTree.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectTree } from './ObjectTree'; +import { ObjectTree, type ObjectTreeProps } from './ObjectTree'; afterEach(cleanup); @@ -82,19 +82,30 @@ describe('ObjectTree', () => { it('accepts field entries as objects (host columns), not just strings', async () => { // ListView passes columns as field *objects*; feeding those straight into // `.replace()` threw "e.replace is not a function" and failed to render. + // + // ⚠️ OFF-DECLARATION ON PURPOSE, and the double assertion says so out loud + // (objectui#8655). The published node declares `labelField?: string` and + // `fields?: string[]`; this row exists to exercise the READER'S TOLERANCE + // for host column OBJECTS, which `@object-ui/types` documents as a reader's + // resilience and ⛔ not as authoring surface. Typing the prop at the node + // made that mismatch visible for the first time — it compiled silently + // while the prop was `any`. ⛔ The remedy is NOT to widen the declaration so + // this literal type-checks (AGENTS.md #0.1); it is to say at the mount that + // the shape is off-contract, which is what this cast does. + const hostColumnObjectsNode = { + type: 'object-tree', + objectName: 'business_unit', + parentField: 'parent_id', + labelField: { name: 'name', label: 'Name' }, + fields: [ + { name: 'name', label: 'Name' }, + { fieldName: 'head', label: 'Head' }, + ], + data: orgUnits, + } as unknown as ObjectTreeProps['schema']; render( , ); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 5959561e91..14170e33b9 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -20,7 +20,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import type { DataSource, TreeViewConfig } from '@object-ui/types'; +import type { DataSource, ObjectQLComponentSchema, TreeViewConfig } from '@object-ui/types'; import { useNavigationOverlay, useSafeFieldLabel, @@ -78,8 +78,65 @@ const useTreeTranslation = createSafeTranslation( 'detail.recordDetail', ); +/** + * The `object-tree` NODE, taken off the PUBLISHED union rather than written + * out here (objectui#8655 step ①). + * + * ⛔ Not a second declaration of the node's shape. `ObjectTreeSchema` in + * `@object-ui/types` is the one declaration — the interface whose own docblock + * calls itself the "Object Tree (tree-grid) Component Schema", and which + * `views.ts` names as "the node an author writes". Every key and every key TYPE + * arrives from there, so a key added, renamed or retyped on it reaches this + * renderer without an edit here: the property a hand-copied interface cannot + * have, and the same derivation `ResolvedTreeConfig` below already uses against + * `TreeViewConfig`. + * + * ⚠️ `Extract` off `ObjectQLComponentSchema`, and NOT a named import, for a + * measured reason: `ObjectTreeSchema` is declared in `objectql.ts` and re-exported + * from the ZOD barrel (objectui#8784 / PR #8777 repaired that half), but the TS + * barrel's `export type { … } from './objectql.js'` block omits it, while its nine + * siblings — `ObjectMapSchema`, `ObjectGanttSchema`, `ObjectCalendarSchema`, + * `ObjectKanbanSchema`, `ObjectChartSchema`, `ObjectGallerySchema`, + * `ObjectDataTableSchema`, `ObjectGridSchema`, `ObjectFormSchema` — are all on it. + * So the name cannot be imported today. ⛔ That omission is NOT repaired here: it + * is a published-surface addition on another package and belongs to whoever files + * it. `Extract` is the spelling `ObjectQLComponentSchema`'s own docblock teaches, + * and it needs nothing added to any published face. + * + * ⛔ Deliberately NOT spelled `ObjectTreeSchema` locally: a module-local type under + * a published type's name is the two-layers-one-word trap objectui#8651 recorded + * when a local `CalendarSchema` shadowed the published one. + */ +type ObjectTreeNodeSchema = Extract; + export interface ObjectTreeProps { - schema: any; + /** + * The `object-tree` node this renderer draws. + * + * Both of this package's registrations (`object-tree` and `tree`, in this + * package's `index.tsx`) publish one `treeInputs` list and one renderer, so one + * props type covers both tags — the shape `plugin-map` already has under two + * tags, and the shape objectui#8651 gave `ObjectCalendar`. + * + * ## What typing this BUYS, stated rather than assumed (objectui#8655) + * + * It was `any`, and an `any` is not a weaker answer than a type — it is NO + * answer. `checker.getPropertyOfType` through `any` returns `undefined` for + * `objectName`, which this schema certainly declares, exactly as it does for a + * nonsense token; so every "is this key declared?" asked of this prop came back + * "unknown" while LOOKING like "undeclared". That ordering is the card's — type + * FIRST, classify second — and it is objectui#8410's standing ruling that an + * absence returned by an instrument structurally unable to see a declaration is + * not a reading at all. + * + * ## ⚠️ The CEILING, so nobody reads this as more than it is + * + * `BaseSchema` ends in `[key: string]: any`, so an UNDECLARED key read off this + * type still compiles and still types `any`. What the annotation buys is that the + * question becomes ANSWERABLE by the checker, ⛔ not that an undeclared read is + * refused — the same ceiling objectui#5155 / objectui#7927 record for the mirror. + */ + schema: ObjectTreeNodeSchema; dataSource?: DataSource; className?: string; /** @@ -170,12 +227,22 @@ function fieldKey(f: any): string | undefined { * `labelField` to `'name'` before the node is built, so the `??` chain never * fell through. * + * ## Why the PARAMETER is typed, and not left `any` (objectui#8655) + * + * The sole call site hands this function the `schema` PROP. Typing the prop and + * leaving this parameter `any` would move the card's own defect one hop down + * rather than close it: the six reads below would still be asked of `any`, and + * `checker.getPropertyOfType` cannot say declared-or-not through `any` — it + * answers `undefined` for a key this node certainly declares exactly as it does + * for a nonsense token. The annotation is what makes those six ANSWERABLE; it + * refuses nothing, because `BaseSchema` ends in `[key: string]: any`. + * * 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 { +function getTreeConfig(schema: ObjectTreeNodeSchema): ResolvedTreeConfig { const nested = (schema.tree || schema.filter?.tree || {}) as TreeViewConfig; const rawFields = Array.isArray(schema.fields) ? schema.fields @@ -704,7 +771,18 @@ export const ObjectTree: React.FC = ({ } // Otherwise fall back to inline/static data (tests, value provider). - const passed = (rest as any).data ?? (schema as any).data; + // + // ⭐ `schema.data` is read WITHOUT a cast since objectui#8655, and the + // missing cast is the finding rather than a tidy-up. This was one of + // that card's two class-(d) reads — reported "unanswerable", not + // "undeclared", because the prop was `any` and + // `checker.getPropertyOfType` cannot tell those apart through one. + // Typed at the node, the checker answers: `data` IS declared, on + // `BaseSchema` ("Arbitrary data attached to the component"), so the + // `as any` was hiding a declaration rather than reaching past its + // absence. ⛔ The sibling cast on `rest` stays — `rest` is the + // untyped remainder of the props bag, a different question. + const passed = (rest as any).data ?? schema.data; if (Array.isArray(passed)) { if (!cancelled) { setRecords(passed); @@ -821,6 +899,22 @@ export const ObjectTree: React.FC = ({ }); const navigation = useNavigationOverlay({ + // ⛔ The cast STAYS, and it stays on purpose (objectui#8655). This is the + // card's other class-(d) read, and typing the node made it answerable: + // measured with `checker.getPropertyOfType` against the node type, this key + // is UNDECLARED — it survives only on `BaseSchema`'s `[key: string]: any`. + // Dropping the cast would compile through that index signature and type + // `any` with nothing marking it, which is the defect rather than the + // absence of one (objectui#8651 records that shape). + // + // ⛔ And this card does NOT rule it. `navigation` is objectui#8652's + // family: maintainer-ruled option B — declare on the PLATFORM element + // schemas first, then mirror — blocked on objectstack#17987, whose unlock + // criterion is a released `@objectstack/spec` carrying the declaration + // being installable here. Measured on the installed spec: `navigation` is + // declared on exactly one `ComponentPropsMap` entry, `object-grid`, and + // this element has no entry at all. ⇒ not declared here, not retired here, + // read untouched. navigation: (schema as any).navigation, // The record-page URL names the object the ROWS came from, not the block's // bare top-level key (objectui#7638). objectui#6939 published `objectName` 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 a35f2a1a84..825849ec11 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 @@ -187,7 +187,20 @@ describe('the module-local copy is gone, not shadowed (objectui#8253)', () => { it('CONTROL: and the resolver still returns that type', () => { // Proves the derived type is WIRED IN, not merely declared and orphaned. - expect(src).toMatch(/function getTreeConfig\(schema: any\): ResolvedTreeConfig/); + // + // ⚠️ The RETURN type is what this control is about; the PARAMETER is matched + // loosely on purpose. It read `schema: any` until objectui#8655, which typed + // it at the node so `checker.getPropertyOfType` could answer for the six keys + // read in the body — pinning that spelling here would make an unrelated + // package's type repair red this file for no reason it is about. + expect(src).toMatch(/function getTreeConfig\(schema: \w+\): ResolvedTreeConfig/); + }); + + it('CONTROL: …and that parameter is NOT `any` any more (objectui#8655)', () => { + // The loosened matcher above must not read as indifference: `any` is the one + // spelling that makes the census below unanswerable, so it is named and + // refused rather than merely not required. + expect(src).not.toMatch(/function getTreeConfig\(schema: any\)/); }); });