From 3b36063c97fcc0a576deeff70961ed49de93f829 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:31:41 +0000 Subject: [PATCH 1/2] fix(plugin-dashboard): type ObjectDataTable's column emit against the slot it fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enrich()` returned `NormalizedColumn` (`[key: string]: any`), so nothing checked what this producer wrote into `DataTableSchema.columns: TableColumn[]`. `{ ...col, ...fieldMeta }` wrote six keys `TableColumn` does not declare — `label`, `options`, `referenceTo`, `format`, `currency`, `decimals`. All six retire from the emit: the consumer's measured read set contains none of them, and declaring a key nothing reads is the same `declared != enforced` defect facing the other way. Rendering is unchanged — every one of those values still reaches the cell through the `FieldMeta` the `cell` closure captures. `type` is objectui#5853's and unchanged. `name` is objectui#5120's, still held, still written, now declared at the seam instead of arriving inside a spread. The emit type carries ADR-0049 `?: never` tombstones rather than being a bare `TableColumn` annotation: measured on this program, a bare annotation raises no error here at all, because excess-property checking exempts spread properties. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6373-datatable-emit-boundary.md | 28 ++ .../plugin-dashboard/src/ObjectDataTable.tsx | 112 ++++++- .../__tests__/ObjectDataTable.cells.test.tsx | 13 +- ...ObjectDataTable.emitBoundary-6373.test.tsx | 276 ++++++++++++++++++ 4 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 .changeset/6373-datatable-emit-boundary.md create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx diff --git a/.changeset/6373-datatable-emit-boundary.md b/.changeset/6373-datatable-emit-boundary.md new file mode 100644 index 0000000000..5da8a51593 --- /dev/null +++ b/.changeset/6373-datatable-emit-boundary.md @@ -0,0 +1,28 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +`ObjectDataTable` no longer writes six undeclared keys into the `data-table` columns slot +(objectui#6373). `enrich()` returned `NormalizedColumn`, whose `[key: string]: any` accepts +anything, so nothing checked the producer's output against +`DataTableSchema.columns: TableColumn[]`: `{ ...col, ...fieldMeta }` spread `label`, +`options`, `referenceTo`, `format`, `currency` and `decimals` onto every emitted column, and +`TableColumn` declares none of them. + +The measured read set of the consumer (`data-table.tsx`, comments stripped) contains none of +the six, so all six retire from the emit rather than being declared — declaring a key nothing +reads is the same `declared != enforced` defect facing the other way. Rendering is unchanged +because none of those keys was the live path for its own value: the `FieldMeta` the `cell` +closure captures is what this widget's type-aware rendering has always read, and it is +untouched. Authored spellings still pass through, so a column the author wrote as +`{ format: '$0,0' }` keeps its `format` exactly as before. + +`type` is unchanged — objectui#5853's fold at this seam still applies. `name` is unchanged +and still written: `data-table` reads `col.accessorKey || col.name` and objectui#5120 holds +that alias while two published skill guides still teach a `{ name, label }` column. The hold +is now declared at the seam instead of arriving anonymously inside a spread. + +The seam's emit type carries ADR-0049 `?: never` tombstones for the retired keys rather than +being a bare `TableColumn` annotation. Measured before the shape was chosen: a bare annotation +raises no error at all here, because TypeScript's excess-property check exempts properties +that arrive through a spread — it would have type-checked the boundary without enforcing it. diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index f23cf63034..9cd31ad709 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -14,11 +14,12 @@ import { columnIdentity, columnHeader, } from '@object-ui/core'; -import type { DrillDownConfig } from '@object-ui/types'; +import type { DrillDownConfig, TableColumn } from '@object-ui/types'; import { normalizeTableColumnType } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n'; import { resolveFilterPlaceholders, humanizeFieldKey } from './utils'; +import type { FieldMeta } from './recordFields'; import { buildFieldMeta, renderFieldValue, @@ -55,6 +56,79 @@ interface NormalizedColumn { [key: string]: any; } +/** + * What this widget's column producer is allowed to EMIT (objectui#6373). + * + * `enrich` below hands its result to `data-table`, whose columns slot is + * `DataTableSchema.columns: TableColumn[]`. It used to return + * `NormalizedColumn`, whose `[key: string]: any` accepts anything, so nothing + * checked the emit against the slot's declaration at all: `{ ...col, + * ...fieldMeta }` wrote SEVEN keys `TableColumn` does not declare — `name`, + * `label`, `options`, `referenceTo`, `format`, `currency`, `decimals`. + * + * ## ⭐ Why this is not just `: TableColumn` + * + * Measured on this program before choosing the shape: annotating the return + * `TableColumn` and leaving the spread in place raises NO error. TypeScript's + * excess-property check is a FRESHNESS check on the properties an object + * literal WRITES OUT; properties arriving through a spread are exempt. So the + * plain annotation the card suggested is blind to the exact defect the card is + * about — it would have type-checked the seam without enforcing it, converting + * a surfaced-key census into a silenced one. + * + * The `?: never` members are what make the annotation able to fail. They are + * ADR-0049 retirement tombstones — this repo's convention for a key that is + * refused rather than merely absent (`StaticTableColumn` in + * `@object-ui/types`, `crud.ts` `confirm`) — and they bite by ASSIGNABILITY, + * not freshness: `FieldMeta['label']` is `string`, which is not assignable to + * `undefined`, so re-introducing `{ ...fieldMeta }` here is a compile error + * (TS2322) naming the first offending key. Writing one out explicitly is the + * other error (TS2353). Both directions were run before this type was written. + * + * ## The rule, so the next producer gets the same answer + * + * A producer may write into a `TableColumn[]` slot only keys the CONSUMER of + * that slot reads, and the consumer's read set is MEASURED from the consumer's + * source, never assumed. Then: a key the consumer reads and `TableColumn` + * declares is written; a key the consumer reads and `TableColumn` does not + * declare is held as an alias only where a ruling already holds it; a key the + * consumer does not read is RETIRED from the emit — never declared, because + * declaring a key nothing reads is the same `declared != enforced` defect + * facing the other way (objectui#5453's forwarded `wrap` key). + * + * Measured read set of the consumer (`data-table.tsx`, comments stripped): + * `accessorKey`, `width`, `align`, `header`, `className`, `cellClassName`, + * `sortable`, `resizable`, `editable`, `type`, `cell`, `headerIcon`, + * `fitContent`, and `name`. Not one of `label`, `options`, `referenceTo`, + * `format`, `currency`, `decimals` appears — so all six retire here. + * + * Retiring them is behaviour-preserving because none of them was the live path + * for its own value: everything they carried is read off `fieldMeta` by the + * `cell` closure below, which is where this widget's type-aware rendering + * actually happens. That check — does the value still reach its consumer by + * another road? — is part of the rule, not an aside: a key with no second road + * is not inert, and retiring it would change behaviour. + * + * `type` is not adjudicated here. objectui#5853 already settled it at this + * seam, and its fold (`normalizeTableColumnType`) stands unchanged. + * + * `name` is HELD, not retired, and not adjudicated here either: it is + * objectui#5120's, still open. `data-table` reads `col.accessorKey || col.name` + * and holds that alias deliberately, because two PUBLISHED skill guides teach a + * `data-table` column spelled `{ name, label }`. So this producer keeps writing + * it, byte for byte what it wrote before, and the hold is now DECLARED at the + * seam instead of arriving anonymously inside a spread. When #5120 retires the + * consumer alias, this member becomes a tombstone with it. + */ +export type EnrichedColumn = + TableColumn + /** HELD alias, objectui#5120 — see above. Not declared by `TableColumn`. */ + & { name?: string } + /** RETIRED at this emit seam, objectui#6373 — derived, never hand-listed, so + * a future `FieldMeta` member is tombstoned by default and has to be + * adjudicated to escape. */ + & { [K in Exclude]?: never }; + /** * Shared empty fallback for the resolved row list (objectui#4629). * @@ -389,13 +463,22 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // is set, prefer translated field labels via the convention-based hook so that // headers automatically pick up i18n bundles. // - // Each column is also enriched with `type/options/referenceTo/format` from - // the bound object schema and gets a `cell:` render function that delegates - // to `getCellRenderer` from `@object-ui/fields`. This produces the same - // type-aware rendering as ObjectGrid / list views and the report viewer - // (Badge for select, link for lookup, ✓/✗ for boolean, mailto:/tel: links, - // currency/percent/date formatting honouring the column's `format` prop). - const derivedColumns = useMemo(() => { + // Each column is also enriched from the bound object schema — `options`, + // `referenceTo`, `format`, `currency`, `decimals` — and gets a `cell:` render + // function that delegates to `getCellRenderer` from `@object-ui/fields`. This + // produces the same type-aware rendering as ObjectGrid / list views and the + // report viewer (Badge for select, link for lookup, ✓/✗ for boolean, + // mailto:/tel: links, currency/percent/date formatting honouring the column's + // `format` prop). + // + // ⭐ THAT ENRICHMENT REACHES THE CELL, NOT THE COLUMN (objectui#6373). Those + // five values live on the `FieldMeta` the `cell` closure captures, which is + // the only thing that reads them. They used to ALSO be spread onto the column + // object handed to `data-table`, which declares none of them and reads none of + // them — five keys that were inert wherever they landed. The emit is checked + // against `EnrichedColumn` now; see its docstring for the rule and for why + // annotating `TableColumn` alone could not have enforced it. + const derivedColumns = useMemo(() => { const objectName = schema.objectName; const fieldsByName: Record = {}; if (objectSchema?.fields) { @@ -436,7 +519,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo return objectName ? fieldLabel(objectName, k, humanized) : humanized; }; - const enrich = (col: NormalizedColumn): NormalizedColumn => { + const enrich = (col: NormalizedColumn): EnrichedColumn => { // Build the shared FieldMeta (translated select options, resolved // referenceTo / currency / decimals). Column-level props override the // schema-derived values. Lookup fields just pass `referenceTo` through — @@ -465,8 +548,11 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // ⭐ THE SECOND EMIT SEAM (objectui#5853). `buildFieldMeta` returns // `type: overrides.type ?? meta?.type` — the OBJECT SCHEMA's field type — - // and spreading `...fieldMeta` writes it straight into the column's - // `type`, the same verbatim forwarding `ObjectGrid` does at its own seam. + // which the `...fieldMeta` spread that used to stand here wrote straight + // into the column's `type`, the same verbatim forwarding `ObjectGrid` does + // at its own seam. (The spread itself has since retired — objectui#6373 — + // but this fold is unchanged and still load-bearing: `type` is written out + // explicitly below, so the value still has to be folded before it lands.) // The card's census named ObjectGrid as the only inference producer; this // is the second one, and it gets the same fold so `TableColumn.type` only // ever holds a value that type declares. An out-of-union type drops the @@ -474,11 +560,11 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // which reads `fieldMeta`, not `col.type`, so it is unaffected. const columnType = normalizeTableColumnType(fieldMeta.type); - if (typeof col.cell === 'function') return { ...col, ...fieldMeta, type: columnType, align: inferredAlign }; + if (typeof col.cell === 'function') return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign }; // Tenant-default currency backstops a currency column with no explicit code. const cell = (value: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale); - return { ...col, ...fieldMeta, type: columnType, align: inferredAlign, cell }; + return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell }; }; if (schema.columns && schema.columns.length > 0) { diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx index c924475214..08dafe503e 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx @@ -1,8 +1,15 @@ /** * Smoke tests for type-aware cell rendering in dashboard list (table) widgets. - * Verifies ObjectDataTable hydrates each column with type/options/format from - * the bound object schema and provides a `cell` render function delegating to - * the shared `getCellRenderer` registry from `@object-ui/fields`. + * Verifies ObjectDataTable resolves each column's `type` / `options` / `format` + * from the bound object schema and provides a `cell` render function delegating + * to the shared `getCellRenderer` registry from `@object-ui/fields`. + * + * ⚠️ That resolution reaches the CELL, not the column (objectui#6373). The + * `FieldMeta` those values live on is captured by the `cell` closure; it used to + * be spread onto the column object as well, writing six keys `TableColumn` does + * not declare and nothing reads. The assertions below were always about the + * rendered output and are unchanged by that retirement — the emitted column's + * own key set is pinned in `ObjectDataTable.emitBoundary-6373.test.tsx`. * * The underlying `data-table` renderer is mocked so the test focuses on * column enrichment without pulling the full component registry. diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx new file mode 100644 index 0000000000..3b2a7c1f4c --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx @@ -0,0 +1,276 @@ +/** + * 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. + */ + +/** + * The PRODUCER half of `declared = enforced` for the `data-table` columns slot + * (objectui#6373). `table-declared-equals-enforced.test.tsx` in + * `@object-ui/components` is the consumer-side twin: it derives what a renderer + * READS and compares it against what the slot declares. This file asks the same + * question from the other end — what does a producer WRITE? + * + * `ObjectDataTable.enrich()` returned `NormalizedColumn` (`[key: string]: any`), + * so nothing checked its output against `DataTableSchema.columns: TableColumn[]`. + * `{ ...col, ...fieldMeta }` wrote seven keys `TableColumn` does not declare: + * `name`, `label`, `options`, `referenceTo`, `format`, `currency`, `decimals`. + * + * ## Why this file is a runtime census and not only a type + * + * ⚠️ The obvious remedy — annotate the return `TableColumn` — is BLIND to this + * defect, measured on this program before the fix was written: TypeScript's + * excess-property check is a freshness check on the properties a literal writes + * OUT, and properties arriving through a spread are exempt. `{ ...col, + * ...fieldMeta }` type-checks clean against `TableColumn`. The seam's type + * (`EnrichedColumn`) closes that with ADR-0049 `?: never` tombstones, which bite + * by assignability instead — both directives at the bottom of this file pin that, + * and `tsc -p tsconfig.test.json` (chained from this package's `type-check`) is + * what checks them. + * + * The census below is the half a type cannot express at all: it reads the keys + * of the objects the widget actually hands over at run time, so a future spread, + * a computed write, or an `as any` detour is caught by what LANDED rather than + * by what was declared. + * + * ## The declared set is derived, never listed here + * + * `TableColumnSchema` (`@object-ui/types/zod`) is `TableColumn`'s hand-written + * mirror, and `zod-mirror-parity.test.ts` keeps the two in step. Reading its + * `.shape` is therefore reading the declaration, with no key list in this file to + * drift. A key list is the artefact this defect class keeps producing. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { TableColumnSchema } from '@object-ui/types/zod'; +// The REAL renderers, imported at module scope (never behind a lazy boundary +// inside a bounded test window — AGENTS.md §测试纪律). `@object-ui/components` +// registers `data-table` as an import side effect. +import '@object-ui/components'; + +/** Every column list this widget handed to `data-table`, newest last. */ +const emitted: any[][] = []; + +vi.mock('@object-ui/react', async () => { + const actual: any = await vi.importActual('@object-ui/react'); + return { + ...actual, + // Capture AND delegate to the REAL registered renderer. A stand-in would + // let a key look harmless because the stand-in never needed it; the point + // of rendering through the real consumer is that the census and the + // rendering are measured on the same objects. + SchemaRenderer: ({ schema }: any) => { + if (schema?.type === 'data-table') emitted.push(schema.columns ?? []); + const Cmp = ComponentRegistry.get(schema.type) as any; + if (!Cmp) throw new Error(`${schema.type} not registered`); + return ; + }, + useDataScope: () => undefined, + SchemaRendererContext: actual.SchemaRendererContext, + }; +}); + +import { ObjectDataTable } from '../ObjectDataTable'; +import type { EnrichedColumn } from '../ObjectDataTable'; +import type { FieldMeta } from '../recordFields'; + +/* ── the declared set, read off the mirror ───────────────────────────────── */ + +/** zod 4 one-hop shape read, the spelling `table-declared-equals-enforced` uses. */ +function shapeOf(schema: unknown): Record { + const carrier = schema as { shape?: unknown; _def?: { shape?: unknown } }; + const shape = carrier?.shape ?? carrier?._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + return (resolved ?? {}) as Record; +} + +const DECLARED = new Set(Object.keys(shapeOf(TableColumnSchema))); + +/** + * The one key this producer writes that `TableColumn` does not declare, and the + * one it is allowed to: `data-table` reads `col.accessorKey || col.name`, and + * objectui#5120 HOLDS that alias — two published skill guides still teach a + * `data-table` column spelled `{ name, label }`. Retiring it is #5120's step, + * not this file's. + */ +const HELD_ALIAS = 'name'; + +/** + * Retired from the emit by objectui#6373. Listed here as the card's VERDICTS, + * not as the census's input: the census above is derived, and these names are + * what the verdict table in the PR body has to stay true to. + */ +const RETIRED = ['label', 'options', 'referenceTo', 'format', 'currency', 'decimals'] as const; + +/* ── fixtures ────────────────────────────────────────────────────────────── */ + +const accountSchema = { + fields: { + name: { type: 'text', label: 'Name' }, + industry: { + type: 'select', + label: 'Industry', + options: [ + { value: 'tech', label: 'Technology', color: 'blue' }, + { value: 'finance', label: 'Finance', color: 'green' }, + ], + }, + amount: { type: 'currency', label: 'Amount', currency: 'USD', scale: 2 }, + owner: { type: 'lookup', label: 'Owner', referenceTo: 'user' }, + }, +}; + +const ROWS = [{ name: 'Acme', industry: 'tech', amount: 1500, owner: { id: 'u1', name: 'Ada' } }]; + +function makeDataSource() { + return { find: async () => ({ data: ROWS }), getObjectSchema: async () => accountSchema }; +} + +/** + * Render and wait until the widget has handed a column list to `data-table`. + * + * The wait is on the emit itself, not on any rendered text: the widget returns + * its empty state WITHOUT reaching `SchemaRenderer` while the fetch is in + * flight, so an emit having happened already means the rows arrived. Waiting on + * a particular cell's text instead would silently constrain every caller to a + * column list that contains that cell. + */ +async function emit(schema: Record): Promise { + emitted.length = 0; + render(); + await waitFor(() => expect(emitted.length).toBeGreaterThan(0), { timeout: 2000 }); + const last = emitted[emitted.length - 1]; + expect(last, 'the widget never emitted a data-table column list').toBeTruthy(); + return last!; +} + +afterEach(() => { + cleanup(); + emitted.length = 0; +}); + +/* ── the instrument's own premises ───────────────────────────────────────── */ + +describe('the census reads a real declaration (#6373)', () => { + it('resolves the mirror shape', () => { + // Without this, a `.shape` read that silently answered `{}` would make + // "none of the retired keys is declared" pass for the wrong reason while + // the real assertion below failed for a misleading one. + expect(DECLARED.size).toBeGreaterThan(5); + expect(DECLARED.has('accessorKey')).toBe(true); + expect(DECLARED.has('type')).toBe(true); + }); + + it('is measuring keys the slot genuinely does not declare', () => { + // The verdicts below are "retire" BECAUSE the slot declares none of them. + // If one ever becomes declared, that is the rule's other branch and this + // seam has to be revisited rather than left silently inconsistent. + for (const key of RETIRED) expect(DECLARED.has(key)).toBe(false); + expect(DECLARED.has(HELD_ALIAS)).toBe(false); + }); +}); + +/* ── the census ──────────────────────────────────────────────────────────── */ + +describe('ObjectDataTable emits only what the columns slot declares (#6373)', () => { + it('auto-derived columns carry no undeclared key but the held alias', async () => { + // Auto-derive: the author supplied no columns, so EVERY key on these + // objects was written by `enrich`. That makes the census exact rather than + // "the author's keys plus ours". + const cols = await emit({ type: 'object-data-table', objectName: 'account' }); + expect(cols.length).toBeGreaterThan(0); + + const allowed = new Set([...DECLARED, HELD_ALIAS]); + for (const col of cols) { + const undeclared = Object.keys(col).filter((k) => !allowed.has(k)); + expect(undeclared, `column ${col.accessorKey} wrote undeclared keys`).toEqual([]); + } + }); + + it('names the six keys that retired, and keeps the one that is held', async () => { + const cols = await emit({ type: 'object-data-table', objectName: 'account' }); + for (const col of cols) { + // `Object.keys`, not a value read: `buildFieldMeta` always returns all + // eight members, so before this card every one of these keys EXISTED on + // every emitted column — carrying `undefined` where the schema said + // nothing, which is its own small lie about the shape. + for (const key of RETIRED) { + expect(Object.keys(col), `${col.accessorKey}.${key}`).not.toContain(key); + } + // #5120's alias, byte for byte what the spread used to write. + expect(col.name).toBe(col.accessorKey); + } + }); + + it('still renders every retired value, through the cell closure', async () => { + // The load-bearing half of the retirement rule: a key is inert only if its + // VALUE still reaches its consumer by another road. Here that road is the + // `FieldMeta` the `cell` closure captures. `options` is the visible proof — + // the select label resolves to the option's label, from the object schema, + // with `options` no longer on the column at all. + await emit({ type: 'object-data-table', objectName: 'account' }); + await waitFor(() => expect(screen.getByText('Acme')).toBeInTheDocument(), { timeout: 2000 }); + expect(screen.getByText('Technology')).toBeInTheDocument(); + expect(screen.queryByText('tech')).not.toBeInTheDocument(); + // `referenceTo` / the lookup road: the expanded record renders its display + // name, not the raw FK id. + expect(screen.getByText('Ada')).toBeInTheDocument(); + expect(screen.queryByText('u1')).not.toBeInTheDocument(); + }); + + it('does not strip keys the AUTHOR spelled — retirement is about what the producer writes', async () => { + // `normalizeColumns` states the rule this pins: "the authored spelling is + // left in place, so a host reading `field` / `name` back off these columns + // keeps working". `enrich` retiring its own writes must not reach through + // and delete the author's. It also must not ADD anything new on this path. + const authored = { accessorKey: 'amount', header: 'Amount', format: '$0,0', currency: 'EUR', field: 'amount' }; + const cols = await emit({ type: 'object-data-table', objectName: 'account', columns: [authored] }); + const col = cols[0]; + expect(col.format).toBe('$0,0'); + expect(col.currency).toBe('EUR'); + expect(col.field).toBe('amount'); + + const allowed = new Set([...DECLARED, HELD_ALIAS, ...Object.keys(authored)]); + expect(Object.keys(col).filter((k) => !allowed.has(k))).toEqual([]); + }); +}); + +/* ── the type-level half ─────────────────────────────────────────────────── */ + +describe("the emit type can FAIL — otherwise the annotation is decoration (#6373)", () => { + it('accepts exactly what the producer emits', () => { + // The positive control. Without it the two directives below could be + // satisfied by a type that refuses everything, which would pin nothing. + const accepted: EnrichedColumn = { + header: 'Amount', + accessorKey: 'amount', + name: 'amount', + type: 'currency', + align: 'right', + cell: (value: any) => value, + }; + expect(accepted.accessorKey).toBe('amount'); + }); + + it('refuses a whole FieldMeta spread — the defect this card is about', () => { + // Checked by `tsc -p tsconfig.test.json`, which this package's `type-check` + // script chains and CI's Type Check job runs. If the tombstones are ever + // removed, tsc reports this directive UNUSED (TS2578) and the gate goes red + // — an expired tombstone cannot sit here looking like coverage. + // @ts-expect-error objectui#6373 — TS2322: FieldMeta's `label: string` is not assignable to `never`. + const spreadRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', ...({} as FieldMeta) }; + expect(spreadRefused.accessorKey).toBe('a'); + }); + + it('refuses a retired key written out by hand', () => { + // @ts-expect-error objectui#6373 — `format` retired from this emit seam. + const writtenRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', format: '$0,0' }; + expect(writtenRefused.accessorKey).toBe('a'); + }); +}); From c2fcae536b1abeb82953382b7ad7a45b987ce678 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:35:00 +0000 Subject: [PATCH 2/2] test(plugin-dashboard): isolate what the emit tombstones actually enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-`FieldMeta` spread pin is refused for two independent reasons — the tombstones AND `FieldMeta.type?: string` not fitting the union objectui#5853 narrowed `TableColumn.type` to. Measured by removing the tombstones: that directive stays used, so on its own it pinned "the spread is refused" without pinning why, and would have survived the enforcement being deleted. Adds the isolating pin (`Omit` — exactly the six retired members) and states which machinery refuses which case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- ...ObjectDataTable.emitBoundary-6373.test.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx index 3b2a7c1f4c..6e123453c4 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx @@ -260,15 +260,36 @@ describe("the emit type can FAIL — otherwise the annotation is decoration (#63 it('refuses a whole FieldMeta spread — the defect this card is about', () => { // Checked by `tsc -p tsconfig.test.json`, which this package's `type-check` - // script chains and CI's Type Check job runs. If the tombstones are ever - // removed, tsc reports this directive UNUSED (TS2578) and the gate goes red - // — an expired tombstone cannot sit here looking like coverage. - // @ts-expect-error objectui#6373 — TS2322: FieldMeta's `label: string` is not assignable to `never`. + // script chains and CI's Type Check job runs. A directive whose error stops + // happening is reported UNUSED (TS2578), so these cannot decay into + // decoration the way an uncompiled tombstone would. + // @ts-expect-error objectui#6373 — re-adding `{ ...fieldMeta }` at the emit seam is a compile error. const spreadRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', ...({} as FieldMeta) }; expect(spreadRefused.accessorKey).toBe('a'); }); + it('refuses the retired members even when nothing else about them is wrong', () => { + // ⚠️ MEASURED, and the reason this second spread pin exists. The one above + // is refused for TWO independent reasons: the tombstones, AND `FieldMeta`'s + // `type?: string` not fitting the `TableColumnType` union objectui#5853 + // narrowed this key to. Deleting the tombstones therefore leaves it erroring + // — so on its own it pins "the spread is refused" without pinning WHY, and + // would have gone on passing after the enforcement was removed. + // + // `Omit` is exactly the retired six: `name` is + // the held alias, `type` carries #5853's own refusal. Nothing but the + // tombstones refuses this one — verified by removing them and watching this + // directive, and only this one, turn TS2578. + // @ts-expect-error objectui#6373 — the six retired members are refused by the tombstones alone. + const retiredRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', ...({} as Omit) }; + expect(retiredRefused.accessorKey).toBe('a'); + }); + it('refuses a retired key written out by hand', () => { + // This one needs no tombstone: a hand-written property is subject to the + // excess-property check, which `TableColumn` alone already fails. Kept + // because it is the OTHER way a key gets added back, and separated from the + // spread pins because the two are enforced by different machinery. // @ts-expect-error objectui#6373 — `format` retired from this emit seam. const writtenRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', format: '$0,0' }; expect(writtenRefused.accessorKey).toBe('a');