From dcb1cbb4105f82f4325b7d4cacea13a2bfcbf2c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:06:51 +0000 Subject: [PATCH 1/4] feat(plugin-timeline,plugin-list): retire the undeclared timeline `metaFields` reads (objectui#10222) Ruling batch #223 item 5b, letter A. The spec's TimelineConfigSchema is a strict object that declares no `metaFields` member, yet ObjectTimeline read it to pick the chip fields and ListView read it three times (the expand collector, the select collector, and the switch that skipped the status / priority auto-projection). All four reads are deleted. - ObjectTimeline: chips are always the built-in status / priority pair, limited to the fields the object declares. - ListView: status / priority are projected for every timeline view, so a view without the key fetches exactly what it did before. - ListView.speculativeFls-7216 PIN 4 rewritten with the reason (its permitted binding is now `endDateField`; the old `region` control rode the column path). - New pins: render (chips identical with and without the key), projection (the key adds nothing to $select / $expand), and a source scan with firing controls in both packages. - Changeset: minor on both packages, naming the behaviour change for a stored interface-page view carrying `options.timeline.metaFields`. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../10222-retire-timeline-metafields-reads.md | 22 ++ packages/plugin-list/src/ListView.tsx | 26 +- .../ListView.speculativeFls-7216.test.tsx | 26 +- ...w.timelineMetaFieldsRetired-10222.test.tsx | 259 ++++++++++++++++++ ...tTimeline.metaFieldsRetired-10222.test.tsx | 189 +++++++++++++ .../plugin-timeline/src/ObjectTimeline.tsx | 23 +- 6 files changed, 524 insertions(+), 21 deletions(-) create mode 100644 .changeset/10222-retire-timeline-metafields-reads.md create mode 100644 packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx create mode 100644 packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx diff --git a/.changeset/10222-retire-timeline-metafields-reads.md b/.changeset/10222-retire-timeline-metafields-reads.md new file mode 100644 index 0000000000..0b88a1cd0a --- /dev/null +++ b/.changeset/10222-retire-timeline-metafields-reads.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/plugin-list': minor +--- + +Retire the undeclared timeline `metaFields` reads in both packages (objectui#10222). + +The spec's `TimelineConfigSchema` is a strict object that declares no `metaFields` member and +refuses one, yet `ObjectTimeline` read it (through an `as any` cast) to pick the chip fields +beside each title, and `ListView` read it three times: two field collectors put the listed +fields into `$expand` / `$select`, and the status / priority auto-projection was skipped when +the list was present. All four reads are deleted. + +**Behaviour change:** a stored interface-page view carrying `options.timeline.metaFields` falls +back to the default card labels (the built-in `status` / `priority` chips, limited to the fields +the object declares), and the fields that list named are no longer fetched for it. No such view +has been found; the stored rows are not censused. A timeline without the key renders and fetches +exactly what it did before, because `ListView` now projects `status` / `priority` for every +timeline view. + +`cardFields` stays the reserved spelling if authored timeline chip fields are ever asked for; +nothing is declared. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index dcb6ad3722..26ce4d79ea 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1834,10 +1834,11 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + // ⛔ No timeline chip-field list is collected: the spec declares none, + // and the timeline no longer reads one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), - ...(Array.isArray(v.metaFields) ? v.metaFields : []), ]; for (const f of candidates) { if (typeof f === 'string' && f) collected.add(f); @@ -2206,10 +2207,11 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + // ⛔ No timeline chip-field list is collected: the spec declares + // none, and the timeline no longer reads one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), - ...(Array.isArray(v.metaFields) ? v.metaFields : []), ]; for (const f of candidates) addSpeculative(f); }; @@ -2222,14 +2224,20 @@ export const ListView = React.forwardRef(({ collectViewFields(schema.timeline); collectViewFields(schema.options?.timeline); // Timeline plugin shows status / priority chips inline. Auto-include - // them when no explicit metaFields was configured so views like - // `task_timeline` ({ columns: ['subject', 'status'] }) still get - // priority badges out of the box. Gated through addSpeculative: only - // added when the object actually has these fields (a `product` with - // no status/priority must not get them, or the list goes empty). + // them for every timeline view so views like `task_timeline` + // ({ columns: ['subject', 'status'] }) still get priority badges out + // of the box. Gated through addSpeculative: only added when the + // object actually has these fields (a `product` with no + // status/priority must not get them, or the list goes empty). + // + // Unconditional on purpose (objectui#10222, ruling batch #223 item + // 5b, letter A): this used to be skipped when the block carried an + // undeclared chip-field list, which the spec refuses and the + // timeline no longer reads. The chips are always the built-in pair + // now, so their values are always fetched. { - const tCfg: any = schema.timeline ?? schema.options?.timeline; - if (tCfg && !Array.isArray(tCfg.metaFields)) { + const tCfg = schema.timeline ?? schema.options?.timeline; + if (tCfg) { addSpeculative('status'); addSpeculative('priority'); } diff --git a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx index 99e1b63220..ed42640ca5 100644 --- a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx @@ -198,13 +198,29 @@ describe('ListView — speculative view bindings are FLS-gated (objectui#7216)', }); // ── PIN 4: timeline `dateField` ───────────────────────────────────────── + // + // REWRITTEN by objectui#10222 (ruling batch #223 item 5b, letter A), not + // deleted: what it guards, the DENIED `dateField` binding, is unchanged. + // Its fixture used to carry `metaFields: ['region']` as the permitted + // binding beside it. That key is undeclared on the spec's timeline block and + // the projection no longer reads it, so it can no longer be the live + // control. It was never a discriminating one either: `region` is in + // `COLUMNS`, so it reached `$select` through the column path whatever the + // binding did. The permitted binding is now `endDateField: 'end_date'`, + // which only the timeline binding can put in `$select`, the same shape PIN + // 3 uses for gantt. That the retired key projects nothing is pinned in + // `ListView.timelineMetaFieldsRetired-10222.test.tsx`. it('does not project a DENIED timeline date binding', async () => { const select = await selectFor({ columns: COLUMNS, - timeline: { dateField: 'due_date', metaFields: ['region'] }, + timeline: { dateField: 'due_date', endDateField: 'end_date' }, }); expect(select).not.toContain('due_date'); - expect(select).toContain('region'); + expect( + select, + 'both bindings ride the same helper, so the pin is only meaningful if the permitted ' + + 'one survives the same call', + ).toContain('end_date'); }); // ── PIN 5: calendar bindings ──────────────────────────────────────────── @@ -242,8 +258,10 @@ describe('ListView — speculative view bindings are FLS-gated (objectui#7216)', // ── PIN 8: the timeline's AUTO-ADDED status / priority badge fields ───── // - // These two are not authored anywhere — the builder adds them itself when a - // timeline is configured with no explicit `metaFields`. Nothing upstream can + // These two are not authored anywhere — the builder adds them itself for + // every configured timeline. (It used to skip them when the block carried a + // `metaFields` list; that undeclared key is retired and no longer read, + // objectui#10222, ruling batch #223 item 5b, letter A.) Nothing upstream can // have filtered them, so this caller is the one with no other line of // defence at all. it('does not project the auto-added `status` / `priority` badges when denied', async () => { diff --git a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx new file mode 100644 index 0000000000..89af03b9c5 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx @@ -0,0 +1,259 @@ +/** + * 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#10222 — `ListView` reads no timeline `metaFields` list any more. + * + * Ruling batch #223 item 5b, letter A (comment 5824012155 on the card): every + * undeclared `metaFields` read is deleted in both packages. The spec's + * `TimelineConfigSchema` is a strict object that declares no such member, so + * the only way the key reached this projection was a stored view's unjudged + * `options` bag (objectui#10380). `ListView` read it three times: + * + * 1. the `expandFields` collector, which put a lookup named there into + * `$expand` (and so, through the expand roots, into `$select`); + * 2. the `$select` collector, which put every field named there into + * `$select`; + * 3. the status / priority auto-projection, which was SKIPPED whenever the + * block carried the list. + * + * All three are gone. The timeline's chips are now always the built-in + * status / priority pair (`plugin-timeline`'s + * `ObjectTimeline.metaFieldsRetired-10222.test.tsx` pins that half), so the + * projection must always fetch those two for a timeline view. ⛔ `cardFields` + * is the reserved spelling for authored chip fields; nothing is declared here. + * + * ## What each pin discriminates + * + * - IDENTICAL: a timeline view with no `metaFields` projects exactly what it + * projected before this change. The expected array was measured on the base + * tree before any edit and is green there too. + * - PROJECTION: `metaFields: ['region']` no longer adds `region`, and status / + * priority are still projected. On the base tree `region` was added and + * status / priority were dropped, so this pin goes red if read 2 or read 3 + * comes back. + * - EXPAND: a lookup named only in `metaFields` is neither expanded nor + * selected. On the base tree it was both, so this pin goes red if read 1 + * comes back. + * - The nested `options.timeline` spelling is the one the bypass actually + * delivers, so it is pinned too, with and without a `timeline` block beside + * it. + * - SOURCE: no `metaFields` identifier or string key is left in this package's + * non-test source, with a firing control on the same scanner. + * + * `region` is deliberately NOT a column here. In + * `ListView.speculativeFls-7216.test.tsx` it is, which is why that file's + * PIN 4 could not tell the binding path from the column path. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import ts from 'typescript'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const OBJECT = 'duly_task'; + +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + start_date: { name: 'start_date', type: 'date', label: 'Start' }, + region: { name: 'region', type: 'select', label: 'Region' }, + status: { name: 'status', type: 'select', label: 'Status' }, + priority: { name: 'priority', type: 'select', label: 'Priority' }, + account: { name: 'account', type: 'lookup', label: 'Account', reference: 'account' }, + }, +}; + +/** + * `plugin-list` does not depend on `plugin-timeline`, so nothing else answers + * `object-timeline` in this file: every `find` recorded below is `ListView`'s + * own projection, never a second query from a mounted timeline. + */ +ComponentRegistry.register( + 'object-timeline', + () =>
, + { namespace: 'test', label: 'Timeline spy', category: 'view' }, +); + +const TIMELINE = { startDateField: 'start_date', titleField: 'name' }; + +/** Mount a timeline list view and return the params of its last `find`. */ +async function paramsFor(schemaExtra: Record) { + const find = vi.fn(async () => ({ data: [], total: 0 })); + const dataSource: any = { + find, + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + }; + const schema: any = { + type: 'list-view', + objectName: OBJECT, + viewType: 'timeline', + columns: ['name'], + timeline: TIMELINE, + ...schemaExtra, + }; + render( + + + , + ); + await waitFor(() => expect(find).toHaveBeenCalled()); + const call: any = (find.mock.calls.at(-1) as unknown[] | undefined)?.[1] ?? {}; + return { + select: (call.$select ?? []) as string[], + expand: (call.$expand ?? []) as string[], + }; +} + +afterEach(() => cleanup()); + +describe('ListView — the timeline `metaFields` reads are retired (objectui#10222)', () => { + it('IDENTICAL: a timeline view with no `metaFields` projects what it projected before the change', async () => { + const { select } = await paramsFor({}); + expect( + select, + 'measured on the base tree before any edit; the auto-projection became unconditional, ' + + 'so the view that never carried the key must not move', + ).toEqual(['id', 'name', 'start_date', 'status', 'priority']); + }); + + it('PROJECTION: `metaFields` adds nothing, and status / priority are still projected', async () => { + const { select } = await paramsFor({ + timeline: { ...TIMELINE, metaFields: ['region'] }, + }); + expect(select, 'the retired key must not reach `$select`').not.toContain('region'); + expect( + select, + 'the timeline always renders the built-in status / priority chips now, so their values ' + + 'must be fetched even when the block carries the retired key', + ).toEqual(expect.arrayContaining(['status', 'priority'])); + expect(select, 'the key changes nothing at all').toEqual( + ['id', 'name', 'start_date', 'status', 'priority'], + ); + }); + + it('PROJECTION, `options.timeline` beside a `timeline` block: the bypass spelling adds nothing either', async () => { + const { select } = await paramsFor({ + options: { timeline: { metaFields: ['region'] } }, + }); + expect(select).not.toContain('region'); + expect(select).toEqual(expect.arrayContaining(['status', 'priority'])); + }); + + it('PROJECTION, `options.timeline` alone: status / priority are no longer dropped', async () => { + const { select } = await paramsFor({ + timeline: undefined, + options: { timeline: { ...TIMELINE, metaFields: ['region'] } }, + }); + expect(select).not.toContain('region'); + expect( + select, + 'on the base tree this shape fetched no status / priority at all, so its timeline drew ' + + 'no chips once the renderer stopped reading the list', + ).toEqual(expect.arrayContaining(['status', 'priority'])); + }); + + it('EXPAND: a lookup named only in `metaFields` is neither expanded nor selected', async () => { + const { select, expand } = await paramsFor({ + timeline: { ...TIMELINE, metaFields: ['account'] }, + }); + expect(expand, 'the `expandFields` collector read the retired key').not.toContain('account'); + expect(select, 'the expand roots are unioned into `$select`').not.toContain('account'); + }); + + it('EXPAND control: the same lookup named in `columns` IS expanded', async () => { + const { expand } = await paramsFor({ columns: ['name', 'account'] }); + expect( + expand, + 'without this the pin above could pass because nothing is ever expanded here', + ).toContain('account'); + }); +}); + +// ── SOURCE: no `metaFields` read left in this package ───────────────────── + +/** Rooted at THIS file, never at `process.cwd()`. */ +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); +const RETIRED = 'metaFields'; + +/** Every non-test `.ts` / `.tsx` file under `src/`. */ +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name === '__tests__') continue; + out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) { + out.push(full); + } + } + return out; +} + +/** + * Every place `name` is used as CODE: an identifier (`x.name`, `x?.name`, + * `{ name }`, `name:`) or a string literal (`x['name']`, `'name' in x`). + * Comments are trivia, not tokens, so prose naming the retired key is not a read. + */ +function codeUses(text: string, file: string, name: string): number[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hits: number[] = []; + const walk = (node: ts.Node): void => { + if ((ts.isIdentifier(node) || ts.isStringLiteralLike(node)) && node.text === name) { + hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1); + } + ts.forEachChild(node, walk); + }; + walk(sf); + return hits; +} + +describe('SOURCE: no `metaFields` read remains in plugin-list (objectui#10222)', () => { + const files = sourceFiles(SRC); + const rel = (f: string) => relative(SRC, f).replace(/\\/g, '/'); + + it('POPULATION: the scan covers ListView.tsx, the file that held the three reads', () => { + expect(files.map(rel)).toContain('ListView.tsx'); + }); + + it('no non-test source names `metaFields` as code', () => { + // The location string is computed here for the failure message only. + const hits = files.flatMap((f) => + codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${rel(f)} line ${line}`)); + expect(hits, 'a `metaFields` read came back; the key is undeclared (objectui#10222)').toEqual([]); + }); + + it('FIRING CONTROL: the same scanner finds `cardFields`, read beside it in ListView.tsx', () => { + const listView = files.find((f) => rel(f) === 'ListView.tsx')!; + expect(codeUses(readFileSync(listView, 'utf8'), 'ListView.tsx', 'cardFields').length).toBeGreaterThan(0); + }); + + it('FIRING CONTROL: the scanner catches each shape the retired reads took, and skips comments', () => { + const probe = [ + '// metaFields in a comment is prose', + 'const a = (v as any).metaFields;', + 'const b = tCfg?.metaFields;', + "const c = v['metaFields'];", + 'const { metaFields } = v;', + ].join('\n'); + expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3, 4, 5]); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx new file mode 100644 index 0000000000..d123ece8ba --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx @@ -0,0 +1,189 @@ +/** + * 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#10222 — `ObjectTimeline` reads no `timeline.metaFields` list. + * + * Ruling batch #223 item 5b, letter A (comment 5824012155 on the card): the + * undeclared `metaFields` reads are deleted in both packages. The spec's + * `TimelineConfigSchema` is a strict object that declares no such member and + * refuses one, so an author could only reach this read through a stored view's + * unjudged `options` bag (objectui#10380). The chips beside each title are now + * always the built-in `status` / `priority` pair, limited to the fields the + * object declares. ⛔ `cardFields` is the reserved spelling if a producer ever + * asks for authored chip fields; nothing is declared here. + * + * Behaviour change, named in the changeset: a stored interface-page view that + * carries `options.timeline.metaFields` falls back to the default chips. + * `plugin-list`'s `ListView.timelineMetaFieldsRetired-10222.test.tsx` pins the + * projection half, which keeps fetching status / priority for every timeline. + * + * `./renderer` is deliberately NOT mocked: the chips are read off the real + * `TimelineRenderer` output, the surface a user sees. Harness as in + * `__tests__/timeline-shared-safe-field-label-5623.test.tsx`. + */ +import React from 'react'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import ts from 'typescript'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectTimeline } from './ObjectTimeline'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + }; +}); + +afterEach(() => cleanup()); + +const OBJECT_DEF = { + fields: { + status: { type: 'select', options: [{ value: 'open', label: 'Open' }] }, + priority: { type: 'select', options: [{ value: 'high', label: 'High' }] }, + region: { type: 'select', options: [{ value: 'emea', label: 'EMEA' }] }, + }, +}; + +const ROWS = [ + { id: '1', name: 'Item 1', date: '2024-01-01', status: 'open', priority: 'high', region: 'emea' }, +]; + +function makeDataSource() { + return { + find: vi.fn(), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_DEF), + }; +} + +/** `MetaChip`'s own classes; the title, date and markers carry none of them. */ +const chipTexts = (root: HTMLElement): string[] => + Array.from(root.querySelectorAll('span.rounded-full.border.text-xs')).map((el) => el.textContent ?? ''); + +/** Mount with this `timeline` block and return the chip labels once the object definition has landed. */ +async function chipsFor(timeline: Record): Promise { + const props = { + schema: { type: 'object-timeline', objectName: 'lead', titleField: 'name', timeline }, + data: ROWS, + dataSource: makeDataSource(), + } as unknown as React.ComponentProps; + const { container, unmount } = render(); + // The chips appear only after `getObjectSchema` resolves; the title is there + // from the first render, so wait on a chip rather than on `Item 1`. + await waitFor(() => expect(chipTexts(container).length).toBeGreaterThan(0)); + const chips = chipTexts(container); + expect(screen.getByText('Item 1'), 'the row itself rendered').toBeDefined(); + unmount(); + return chips; +} + +const BLOCK = { startDateField: 'date' }; + +describe('ObjectTimeline — the `metaFields` read is retired (objectui#10222)', () => { + it('CONTROL: with no `metaFields`, the built-in status / priority chips render', async () => { + expect(await chipsFor(BLOCK)).toEqual(['Open', 'High']); + }); + + it('RENDER: a block carrying `metaFields` renders the SAME chips as one without', async () => { + const without = await chipsFor(BLOCK); + const withKey = await chipsFor({ ...BLOCK, metaFields: ['region'] }); + expect( + withKey, + 'the key is undeclared on the spec timeline block; the chips must not change with it', + ).toEqual(without); + expect(withKey, 'the field named by the retired key must not become a chip').not.toContain('EMEA'); + }); +}); + +// ── SOURCE: no `metaFields` read left in this package ───────────────────── + +/** Rooted at THIS file, never at `process.cwd()`. */ +const SRC = dirname(fileURLToPath(import.meta.url)); +const RETIRED = 'metaFields'; + +/** Every non-test `.ts` / `.tsx` file under `src/`. */ +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name === '__tests__') continue; + out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) { + out.push(full); + } + } + return out; +} + +/** + * The 1-based lines where `name` is used as CODE: an identifier (`x.name`, + * `x?.name`, `{ name }`, `name:`) or a string literal (`x['name']`, + * `'name' in x`). Comments are trivia, not tokens, so prose naming the retired + * key is not a read. + */ +function codeUses(text: string, file: string, name: string): number[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hits: number[] = []; + const walk = (node: ts.Node): void => { + if ((ts.isIdentifier(node) || ts.isStringLiteralLike(node)) && node.text === name) { + hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1); + } + ts.forEachChild(node, walk); + }; + walk(sf); + return hits; +} + +describe('SOURCE: no `metaFields` read remains in plugin-timeline (objectui#10222)', () => { + const files = sourceFiles(SRC); + const rel = (f: string) => relative(SRC, f).replace(/\\/g, '/'); + + it('POPULATION: the scan covers ObjectTimeline.tsx, the file that held the read', () => { + expect(files.map(rel)).toContain('ObjectTimeline.tsx'); + }); + + it('no non-test source names `metaFields` as code', () => { + // The location string is computed here for the failure message only. + const hits = files.flatMap((f) => + codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${rel(f)} line ${line}`)); + expect(hits, 'a `metaFields` read came back; the key is undeclared (objectui#10222)').toEqual([]); + }); + + it('FIRING CONTROL: the same scanner finds `colorField`, read beside it in ObjectTimeline.tsx', () => { + const timeline = files.find((f) => rel(f) === 'ObjectTimeline.tsx')!; + expect(codeUses(readFileSync(timeline, 'utf8'), 'ObjectTimeline.tsx', 'colorField').length).toBeGreaterThan(0); + }); + + it('FIRING CONTROL: the scanner catches the shape the retired read took, and skips comments', () => { + const probe = [ + '// metaFields in a comment is prose', + 'const a = (timelineConfig as any)?.metaFields;', + "const b = timelineConfig['metaFields'];", + ].join('\n'); + expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3]); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index c53f8dc891..43800ac3ea 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -512,14 +512,21 @@ export const ObjectTimeline: React.FC = ({ return map; }; - /** Which fields appear as inline chips beside the title. - * Spec config: `timeline.metaFields: string[]`. - * Heuristic default: `['status', 'priority']` — limited to fields that - * actually exist in objectDef so non-CRM objects don't render fake - * chips. */ - const metaFieldNames: string[] = Array.isArray((timelineConfig as any)?.metaFields) - ? (timelineConfig as any).metaFields.filter((f: any) => typeof f === 'string' && f) - : ['status', 'priority'].filter((f) => fields[f]); + /** Which fields appear as inline chips beside the title: the built-in + * `['status', 'priority']`, limited to fields that actually exist in + * objectDef so non-CRM objects don't render fake chips. + * + * ⛔ Not authorable, and nothing reads an authored list here + * (objectui#10222, ruling batch #223 item 5b, letter A). The spec's + * `TimelineConfigSchema` is a strict object that declares no chip-field + * member and refuses one, so an undeclared chip-field key read off this + * block reached the renderer only through a stored view's unjudged + * `options` bag (objectui#10380). If a producer ever asks for authored + * chip fields, the reserved spelling is `cardFields` (the kanban / + * gallery spelling), declared on the spec first; it is not declared + * today. `ListView`'s status / priority auto-projection keys on the + * same default, so the two must move together. */ + const metaFieldNames: string[] = ['status', 'priority'].filter((f) => fields[f]); const metaOptionMaps: Record> = {}; for (const f of metaFieldNames) metaOptionMaps[f] = optionMap(f); From 693df69e6c03a944bda07c4f2b1d83d502c4a9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:11:28 +0000 Subject: [PATCH 2/4] docs(plugin-timeline,plugin-list): name the retired `metaFields` key in the retirement comments (objectui#10222) So a reader who greps for the key lands on the explanation. Comments only; the source pins read code tokens, not comments. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- packages/plugin-list/src/ListView.tsx | 12 +++++++----- packages/plugin-timeline/src/ObjectTimeline.tsx | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 26ce4d79ea..37d5565ca8 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1834,8 +1834,9 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). - // ⛔ No timeline chip-field list is collected: the spec declares none, - // and the timeline no longer reads one (objectui#10222). + // ⛔ No timeline chip-field list (the retired `metaFields`) is + // collected: the spec declares none, and the timeline no longer reads + // one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), @@ -2207,8 +2208,9 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). - // ⛔ No timeline chip-field list is collected: the spec declares - // none, and the timeline no longer reads one (objectui#10222). + // ⛔ No timeline chip-field list (the retired `metaFields`) is + // collected: the spec declares none, and the timeline no longer + // reads one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), @@ -2232,7 +2234,7 @@ export const ListView = React.forwardRef(({ // // Unconditional on purpose (objectui#10222, ruling batch #223 item // 5b, letter A): this used to be skipped when the block carried an - // undeclared chip-field list, which the spec refuses and the + // undeclared `metaFields` list, which the spec refuses and the // timeline no longer reads. The chips are always the built-in pair // now, so their values are always fetched. { diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 43800ac3ea..6b21ccaf96 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -519,7 +519,7 @@ export const ObjectTimeline: React.FC = ({ * ⛔ Not authorable, and nothing reads an authored list here * (objectui#10222, ruling batch #223 item 5b, letter A). The spec's * `TimelineConfigSchema` is a strict object that declares no chip-field - * member and refuses one, so an undeclared chip-field key read off this + * member and refuses one, so the retired `metaFields` read off this * block reached the renderer only through a stored view's unjudged * `options` bag (objectui#10380). If a producer ever asks for authored * chip fields, the reserved spelling is `cardFields` (the kanban / From 4c7fc8bf828079b5979bee4dade132e94523010b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:18:27 +0000 Subject: [PATCH 3/4] test(plugin-list,plugin-timeline): move the plugin-timeline source pin beside the plugin-list one (objectui#10222) plugin-timeline's tsconfig.test.json names no `node` types (TypeScript 6 includes none by default), so a `node:fs` import in that package's test does not type-check. The two-package source scan now lives in the plugin-list file, whose test config names them; the plugin-timeline file keeps the render pin only. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...w.timelineMetaFieldsRetired-10222.test.tsx | 72 ++++++++++------- ...tTimeline.metaFieldsRetired-10222.test.tsx | 80 ++----------------- 2 files changed, 51 insertions(+), 101 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx index 89af03b9c5..e9b051ce42 100644 --- a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx @@ -43,8 +43,11 @@ * - The nested `options.timeline` spelling is the one the bypass actually * delivers, so it is pinned too, with and without a `timeline` block beside * it. - * - SOURCE: no `metaFields` identifier or string key is left in this package's - * non-test source, with a firing control on the same scanner. + * - SOURCE: no `metaFields` identifier or string key is left in the non-test + * source of this package OR `plugin-timeline`, with firing controls on the + * same scanner. `plugin-timeline`'s scan lives here, not in that package, + * because only this package's `tsconfig.test.json` names the `node` types a + * disk read needs. * * `region` is deliberately NOT a column here. In * `ListView.speculativeFls-7216.test.tsx` it is, which is why that file's @@ -187,13 +190,17 @@ describe('ListView — the timeline `metaFields` reads are retired (objectui#102 }); }); -// ── SOURCE: no `metaFields` read left in this package ───────────────────── +// ── SOURCE: no `metaFields` read left in either package ─────────────────── +// +// Both packages' scans live here because this package's `tsconfig.test.json` +// names the `node` types a disk read needs and `plugin-timeline`'s does not. /** Rooted at THIS file, never at `process.cwd()`. */ -const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); const RETIRED = 'metaFields'; -/** Every non-test `.ts` / `.tsx` file under `src/`. */ +/** Every non-test `.ts` / `.tsx` file under `dir`. */ function sourceFiles(dir: string): string[] { const out: string[] = []; for (const name of readdirSync(dir)) { @@ -209,9 +216,10 @@ function sourceFiles(dir: string): string[] { } /** - * Every place `name` is used as CODE: an identifier (`x.name`, `x?.name`, - * `{ name }`, `name:`) or a string literal (`x['name']`, `'name' in x`). - * Comments are trivia, not tokens, so prose naming the retired key is not a read. + * The 1-based lines where `name` is used as CODE: an identifier (`x.name`, + * `x?.name`, `{ name }`, `name:`) or a string literal (`x['name']`, + * `'name' in x`). Comments are trivia, not tokens, so prose naming the retired + * key is not a read. */ function codeUses(text: string, file: string, name: string): number[] { const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -226,34 +234,44 @@ function codeUses(text: string, file: string, name: string): number[] { return hits; } -describe('SOURCE: no `metaFields` read remains in plugin-list (objectui#10222)', () => { - const files = sourceFiles(SRC); - const rel = (f: string) => relative(SRC, f).replace(/\\/g, '/'); +/** + * Each package's non-test source, the file that held its read(s), and an + * identifier read beside the retired one in that file (the firing control). + */ +const PACKAGES = [ + { pkg: 'plugin-list', holder: 'ListView.tsx', sibling: 'cardFields' }, + { pkg: 'plugin-timeline', holder: 'ObjectTimeline.tsx', sibling: 'colorField' }, +] as const; + +describe.each(PACKAGES)('SOURCE: no `metaFields` read remains in $pkg (objectui#10222)', ({ pkg, holder, sibling }) => { + const src = join(REPO_ROOT, 'packages', pkg, 'src'); + const files = sourceFiles(src); + const rel = (f: string) => relative(src, f).replace(/\\/g, '/'); - it('POPULATION: the scan covers ListView.tsx, the file that held the three reads', () => { - expect(files.map(rel)).toContain('ListView.tsx'); + it(`POPULATION: the scan covers ${holder}, the file that held the read`, () => { + expect(files.map(rel)).toContain(holder); }); it('no non-test source names `metaFields` as code', () => { // The location string is computed here for the failure message only. const hits = files.flatMap((f) => - codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${rel(f)} line ${line}`)); + codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${pkg}/src/${rel(f)} line ${line}`)); expect(hits, 'a `metaFields` read came back; the key is undeclared (objectui#10222)').toEqual([]); }); - it('FIRING CONTROL: the same scanner finds `cardFields`, read beside it in ListView.tsx', () => { - const listView = files.find((f) => rel(f) === 'ListView.tsx')!; - expect(codeUses(readFileSync(listView, 'utf8'), 'ListView.tsx', 'cardFields').length).toBeGreaterThan(0); + it(`FIRING CONTROL: the same scanner finds \`${sibling}\`, read beside it in ${holder}`, () => { + const file = files.find((f) => rel(f) === holder)!; + expect(codeUses(readFileSync(file, 'utf8'), holder, sibling).length).toBeGreaterThan(0); }); +}); - it('FIRING CONTROL: the scanner catches each shape the retired reads took, and skips comments', () => { - const probe = [ - '// metaFields in a comment is prose', - 'const a = (v as any).metaFields;', - 'const b = tCfg?.metaFields;', - "const c = v['metaFields'];", - 'const { metaFields } = v;', - ].join('\n'); - expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3, 4, 5]); - }); +it('SOURCE FIRING CONTROL: the scanner catches each shape the retired reads took, and skips comments', () => { + const probe = [ + '// metaFields in a comment is prose', + 'const a = (timelineConfig as any)?.metaFields;', + 'const b = tCfg?.metaFields;', + "const c = v['metaFields'];", + 'const { metaFields } = v;', + ].join('\n'); + expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3, 4, 5]); }); diff --git a/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx index d123ece8ba..f7c358e039 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx @@ -26,14 +26,16 @@ * `./renderer` is deliberately NOT mocked: the chips are read off the real * `TimelineRenderer` output, the surface a user sees. Harness as in * `__tests__/timeline-shared-safe-field-label-5623.test.tsx`. + * + * The SOURCE pin for this package (no `metaFields` read left in any non-test + * file under `src/`) lives in that `plugin-list` file beside the projection + * pin, not here: it reads files off disk, and this package's + * `tsconfig.test.json` names no `node` types, so a `node:fs` import here would + * not type-check. It runs in the same `pnpm test`. */ import React from 'react'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import ts from 'typescript'; -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { ObjectTimeline } from './ObjectTimeline'; vi.mock('@object-ui/react', async (importOriginal) => { @@ -117,73 +119,3 @@ describe('ObjectTimeline — the `metaFields` read is retired (objectui#10222)', expect(withKey, 'the field named by the retired key must not become a chip').not.toContain('EMEA'); }); }); - -// ── SOURCE: no `metaFields` read left in this package ───────────────────── - -/** Rooted at THIS file, never at `process.cwd()`. */ -const SRC = dirname(fileURLToPath(import.meta.url)); -const RETIRED = 'metaFields'; - -/** Every non-test `.ts` / `.tsx` file under `src/`. */ -function sourceFiles(dir: string): string[] { - const out: string[] = []; - for (const name of readdirSync(dir)) { - const full = join(dir, name); - if (statSync(full).isDirectory()) { - if (name === '__tests__') continue; - out.push(...sourceFiles(full)); - } else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) { - out.push(full); - } - } - return out; -} - -/** - * The 1-based lines where `name` is used as CODE: an identifier (`x.name`, - * `x?.name`, `{ name }`, `name:`) or a string literal (`x['name']`, - * `'name' in x`). Comments are trivia, not tokens, so prose naming the retired - * key is not a read. - */ -function codeUses(text: string, file: string, name: string): number[] { - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - const hits: number[] = []; - const walk = (node: ts.Node): void => { - if ((ts.isIdentifier(node) || ts.isStringLiteralLike(node)) && node.text === name) { - hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1); - } - ts.forEachChild(node, walk); - }; - walk(sf); - return hits; -} - -describe('SOURCE: no `metaFields` read remains in plugin-timeline (objectui#10222)', () => { - const files = sourceFiles(SRC); - const rel = (f: string) => relative(SRC, f).replace(/\\/g, '/'); - - it('POPULATION: the scan covers ObjectTimeline.tsx, the file that held the read', () => { - expect(files.map(rel)).toContain('ObjectTimeline.tsx'); - }); - - it('no non-test source names `metaFields` as code', () => { - // The location string is computed here for the failure message only. - const hits = files.flatMap((f) => - codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${rel(f)} line ${line}`)); - expect(hits, 'a `metaFields` read came back; the key is undeclared (objectui#10222)').toEqual([]); - }); - - it('FIRING CONTROL: the same scanner finds `colorField`, read beside it in ObjectTimeline.tsx', () => { - const timeline = files.find((f) => rel(f) === 'ObjectTimeline.tsx')!; - expect(codeUses(readFileSync(timeline, 'utf8'), 'ObjectTimeline.tsx', 'colorField').length).toBeGreaterThan(0); - }); - - it('FIRING CONTROL: the scanner catches the shape the retired read took, and skips comments', () => { - const probe = [ - '// metaFields in a comment is prose', - 'const a = (timelineConfig as any)?.metaFields;', - "const b = timelineConfig['metaFields'];", - ].join('\n'); - expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3]); - }); -}); From 8659b926e6df46f399f9f86e33e78741ca77ad5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:34:13 +0000 Subject: [PATCH 4/4] test(plugin-list): type the objectui#10222 projection harness without explicit `any` Casts at the props instead; the file lints with zero eslint messages. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...iew.timelineMetaFieldsRetired-10222.test.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx index e9b051ce42..48160b21f0 100644 --- a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx @@ -95,33 +95,34 @@ const TIMELINE = { startDateField: 'start_date', titleField: 'name' }; /** Mount a timeline list view and return the params of its last `find`. */ async function paramsFor(schemaExtra: Record) { - const find = vi.fn(async () => ({ data: [], total: 0 })); - const dataSource: any = { + const find = vi.fn(async (..._args: unknown[]) => ({ data: [], total: 0 })); + // Cast at the prop: a stub, and a schema that carries a key the type refuses. + const dataSource = { find, findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), getObjectSchema: vi.fn(async () => objectDef), - }; - const schema: any = { + } as unknown as React.ComponentProps['dataSource']; + const schema = { type: 'list-view', objectName: OBJECT, viewType: 'timeline', columns: ['name'], timeline: TIMELINE, ...schemaExtra, - }; + } as unknown as React.ComponentProps['schema']; render( , ); await waitFor(() => expect(find).toHaveBeenCalled()); - const call: any = (find.mock.calls.at(-1) as unknown[] | undefined)?.[1] ?? {}; + const call = (find.mock.calls.at(-1)?.[1] ?? {}) as { $select?: string[]; $expand?: string[] }; return { - select: (call.$select ?? []) as string[], - expand: (call.$expand ?? []) as string[], + select: call.$select ?? [], + expand: call.$expand ?? [], }; }