From c70a04570af343a4614d1adbcf12442cd13d3a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 01:54:56 +0000 Subject: [PATCH] fix(plugin-grid): scope the relational copy-set derivation to the cell that is fed the bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derivation gate extracted a read set from three consumers and collapsed it into a UNION, then licensed a copy-set entry by membership in it. Only one of the three — `LookupCellRenderer` — is handed the bag `applyRelationalMeta` writes; `ObjectGrid.renderCellEditor` feeds the two inline editor widgets `{ name, ...fieldDef }` off the object schema instead. So membership meant "some consumer reads this key" and never "this bag is how that consumer gets it", while a copy-set entry asserts the second. That conflation shipped two wrong verdicts (objectui#6875) which objectui#7166 then had to undo, and the gate was green through both. - Record the reader axis PER CONSUMER on every entry (`readers`), checked against that consumer's own source in both directions. - Derive the copy set from `CONSUMERS_FED_THIS_BAG` — the cell alone — with one self-naming exception (`copiedWithoutCellReader`) confined to keys `FieldSchema` does not declare, so no authorable key can take it. - Assert that bound on `ObjectGrid.tsx` itself: `renderCellEditor` must spread the schema def and must never name `fieldMeta`. - Remove the `deferred` verdict — it stated a fact that is now measured. Its seven keys are `spec` and stay off the copy set because no consumer fed the bag reads them. The copy set is unchanged: the same seven keys, in the same order. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../7187-relational-copy-set-cell-scoped.md | 33 ++ .../relationalMetaCopySet.derivation.test.ts | 283 ++++++++++++------ .../plugin-grid/src/relationalMetaKeys.ts | 208 +++++++++---- 3 files changed, 381 insertions(+), 143 deletions(-) create mode 100644 .changeset/7187-relational-copy-set-cell-scoped.md diff --git a/.changeset/7187-relational-copy-set-cell-scoped.md b/.changeset/7187-relational-copy-set-cell-scoped.md new file mode 100644 index 0000000000..12fb10de22 --- /dev/null +++ b/.changeset/7187-relational-copy-set-cell-scoped.md @@ -0,0 +1,33 @@ +--- +--- + +Scope `ObjectGrid`'s relational copy-set derivation to the consumer the copied bag actually +reaches (objectui#7187). No published behaviour changes: the copy set is byte-identical +before and after — the same seven keys, in the same order. + +The gate in `packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts` +re-extracted a read set from three consumers and collapsed it into a UNION. Only one of the +three, `LookupCellRenderer`, is handed the bag `applyRelationalMeta` writes; the two inline +editor widgets are fed `{ name, ...fieldDef }` straight off the object schema. So membership +in that union meant "some consumer reads this key" and never "this bag is how that consumer +gets it" — and a copy-set entry asserts the second. The union was read as the first claim's +evidence for the second twice, and both verdicts shipped: objectui#6875 put `descriptionField` +and `lookupColumns` on the copy set on it, and objectui#7166 had to take them back off. + +Three changes make that mistake unavailable: + +- The reader axis is now recorded PER CONSUMER on every entry (`readers`), and the gate checks + each declared list against that consumer's own source in both directions — a hand-widened + declaration is an orphan, a new spelling in a chain is unclassified, and both are red. +- The copy set is derived from `CONSUMERS_FED_THIS_BAG` — the cell alone. The one deliberate + exception, the three snake_case spellings kept on an unanswered producer-side question, has + to name itself per key (`copiedWithoutCellReader`), and the gate confines that exit to keys + `FieldSchema` does not declare, so no authorable key can take it. +- The `deferred` verdict is gone. It meant "read only by an editor widget", which is now + measured rather than declared; its seven keys are `spec` (which is what they are) and stay + off the copy set because no consumer fed the bag reads them. + +Consequence worth stating: the hand-written non-membership pin that had been holding +objectui#7166's retirement on its own is now redundant as a guard — re-adding any of the three +retired keys turns the gate red on a derived assertion under every available verdict. The pin +is kept so a regression is reported by name, not because it is the only hold. diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index d0b067ef95..613bfd772b 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -9,6 +9,8 @@ /** * objectui#6875 — the gate that DERIVES `ObjectGrid`'s relational copy set from * its consumers instead of trusting a hand-kept literal. + * objectui#7187 — scoped that derivation to the consumer the bag actually + * reaches, which is what makes it able to judge a COPY. * * ## What went wrong, and why a longer literal would not have fixed it * @@ -29,6 +31,28 @@ * spelling in any chain is unclassified → red. A key deleted from a chain is an * orphan in the table → red. * + * ## ⛔ THE DEFECT objectui#7187 FIXED — a union cannot judge a copy + * + * Until objectui#7187 the extraction was collapsed into ONE set, the UNION over + * three consumers, and a copy-set entry was licensed by membership in it. Only + * the FIRST consumer is fed the copied bag. So membership meant "some consumer + * reads this key" and never "this bag is how that consumer gets it" — and a + * copy-set entry asserts the second. objectui#6875 read the first as the second + * and shipped `descriptionField` and `lookupColumns` onto a bag their only + * reader never consults; objectui#7166 measured that and retired three keys, + * and this gate stayed green through both the wrong verdict and its undoing. + * + * ⇒ The union is gone. The reader axis is recorded PER CONSUMER on each entry + * (`readers`), checked against that consumer's own source in both directions, + * and the copy set is derived from `CONSUMERS_FED_THIS_BAG` alone. The three + * retired keys can no longer be re-added under ANY verdict: + * + * - as `spec` / `adapter-stamped` — the cell does not read them, so the + * derived copy set does not contain them and the copy-set assertion is red; + * - as `legacy-alias` — that exit needs `copiedWithoutCellReader`, which is + * confined to keys `FieldSchema` does NOT declare, and all three are + * spec-declared. + * * ## The three consumers, and how each is read * * `generateColumns()` hands `fieldMeta` to `CellRenderer` as the `field` prop. @@ -38,35 +62,20 @@ * inline editor renders `LookupField` (receiver `fieldMeta`) and `UserField` * (receiver `meta`), which use optional-chained member reads. * - * ⚠️ Those two are swept, but they are NOT fed this bag — `renderCellEditor` + * ⚠️ Those two are swept, and they are NOT fed this bag — `renderCellEditor` * spreads the schema def into them directly (objectui#7154, measured in - * `lookupPickerKeys-7154.test.tsx`). So a key that only they read is - * classified here without ever being a candidate for copying, and the verdict - * column is where that decision is recorded. + * `lookupPickerKeys-7154.test.tsx`). objectui#7187 stopped taking that on + * trust: `CONSUMERS_FED_THIS_BAG` is checked against `ObjectGrid.tsx`'s own + * `renderCellEditor`, which must spread the schema def and must never name + * `fieldMeta`. * * ⚠️ `UserField` is swept even though it forwards its whole meta into * `LookupField` via a spread. A delegating consumer is exactly where a false * zero hides: a key it read and did NOT forward would be invisible in * `LookupField`'s own source. Its extracted set being a subset is a RESULT - * here, not an assumption. - * - * ## ⛔ THE LIMIT THAT MATTERS MOST — this gate cannot judge a COPY - * - * The read set below is a UNION over three consumers, and only the FIRST is fed - * the copied bag. So a key's presence in it means "some consumer reads this - * key" and never "this bag is how that consumer gets it". A copy-set entry - * asserts the second. objectui#6875 read the first as the second and shipped - * two keys onto a bag their only reader never consults; objectui#7166 measured - * that and retired three (`descriptionField`, `lookupColumns`, `lookupFilters`). - * - * ⚠️ Every one of them is STILL in the extracted read set, so every derived - * assertion here passes whichever verdict they carry — this gate would not go - * red if they were put back. Their absence is pinned by a hand-written - * assertion below and, behaviourally, by - * `relationalMetaCopySet-7166.test.tsx`. Re-scoping the derivation around the - * cell alone (editor widgets classified separately) is what would make this - * mechanical; that is a design change to objectui#6875's mechanism, filed - * rather than made. + * here, not an assumption — and under the split it is asserted as one, since + * dropping `UserField` because "it forwards anyway" is precisely the assumption + * that would hide such a key. * * ## ⛔ The extractor is bounded, and says so * @@ -85,17 +94,30 @@ import path from 'node:path'; import { FieldSchema } from '@objectstack/spec/data'; -import { RELATIONAL_META_READ_SET, RELATIONAL_META_KEYS } from '../relationalMetaKeys'; +import { + RELATIONAL_META_READ_SET, + RELATIONAL_META_KEYS, + CONSUMERS_FED_THIS_BAG, + type RelationalMetaConsumer, +} from '../relationalMetaKeys'; -const FIELDS_SRC = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../../fields/src', -); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIELDS_SRC = path.resolve(HERE, '../../../fields/src'); +const GRID_SRC = path.resolve(HERE, '..'); function read(rel: string): string { return readFileSync(path.join(FIELDS_SRC, rel), 'utf8'); } +/** The source file each consumer id is extracted from, for failure messages. */ +const CONSUMER_SOURCE: Readonly> = { + cell: 'fields/src/index.tsx#LookupCellRenderer', + 'lookup-editor': 'fields/src/widgets/LookupField.tsx', + 'user-editor': 'fields/src/widgets/UserField.tsx', +}; + +const CONSUMERS = Object.keys(CONSUMER_SOURCE) as RelationalMetaConsumer[]; + /** * The body of `LookupCellRenderer`, bounded by its declaration and the first * column-0 `}` after it. Bounding matters: `fields/src/index.tsx` holds every @@ -112,6 +134,26 @@ function lookupCellRendererBody(): string { return lines.slice(start, end + 1).join('\n'); } +/** + * The `renderCellEditor` property of the `DataTable` props `ObjectGrid` builds, + * bounded by its own line and the next property at the same indentation. This + * is the seam the whole cell/editor split rests on, so it is measured here + * rather than asserted in a docblock. + */ +function renderCellEditorProperty(): string { + const lines = readFileSync(path.join(GRID_SRC, 'ObjectGrid.tsx'), 'utf8').split('\n'); + const starts = lines + .map((l, i) => (l.startsWith(' renderCellEditor:') ? i : -1)) + .filter((i) => i >= 0); + if (starts.length !== 1) { + throw new Error(`expected exactly 1 renderCellEditor property, found ${starts.length} — extractor is stale`); + } + const start = starts[0]; + const end = lines.findIndex((l, i) => i > start && /^ {4}[A-Za-z_$][\w$]*[:(]/.test(l)); + if (end < 0) throw new Error('renderCellEditor end not found — extractor is stale'); + return lines.slice(start, end).join('\n'); +} + /** `recv?.key` / `recv.key` member reads off one named receiver. */ function memberReads(src: string, receiver: string): Set { const out = new Set(); @@ -130,21 +172,22 @@ function castReads(src: string, receiver: string): Set { return out; } -interface Extraction { - readonly all: Set; - readonly perConsumer: Readonly>>; -} +type Extraction = Readonly>>; function extractReadSet(): Extraction { - const cell = lookupCellRendererBody(); - const perConsumer = { - 'index.tsx#LookupCellRenderer': castReads(cell, 'field'), - 'widgets/LookupField.tsx': memberReads(read('widgets/LookupField.tsx'), 'fieldMeta'), - 'widgets/UserField.tsx': memberReads(read('widgets/UserField.tsx'), 'meta'), + return { + cell: castReads(lookupCellRendererBody(), 'field'), + 'lookup-editor': memberReads(read('widgets/LookupField.tsx'), 'fieldMeta'), + 'user-editor': memberReads(read('widgets/UserField.tsx'), 'meta'), }; - const all = new Set(); - for (const set of Object.values(perConsumer)) for (const k of set) all.add(k); - return { all, perConsumer }; +} + +/** The keys the table declares for one consumer, sorted. */ +function declaredReaders(consumer: RelationalMetaConsumer): string[] { + return Object.entries(RELATIONAL_META_READ_SET) + .filter(([, e]) => e.readers.includes(consumer)) + .map(([k]) => k) + .sort(); } /** @@ -155,11 +198,11 @@ function extractReadSet(): Extraction { * whatever the copy set says. */ function assertExtractorFoundKnownChains(x: Extraction): void { - expect(x.perConsumer['index.tsx#LookupCellRenderer']).toContain('display_field'); - expect(x.perConsumer['index.tsx#LookupCellRenderer']).toContain('displayField'); - expect(x.perConsumer['widgets/LookupField.tsx']).toContain('lookup_columns'); - expect(x.perConsumer['widgets/LookupField.tsx']).toContain('lookupColumns'); - expect(x.perConsumer['widgets/UserField.tsx']).toContain('reference_field'); + expect(x.cell).toContain('display_field'); + expect(x.cell).toContain('displayField'); + expect(x['lookup-editor']).toContain('lookup_columns'); + expect(x['lookup-editor']).toContain('lookupColumns'); + expect(x['user-editor']).toContain('reference_field'); } const specProps = new Set(Object.keys((FieldSchema as any).shape)); @@ -170,31 +213,76 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest assertExtractorFoundKnownChains(x); // Every consumer contributes; a zero from any one of them is a broken sweep, // not a consumer that reads nothing. - for (const [name, set] of Object.entries(x.perConsumer)) { - expect(set.size, `${name} contributed no reads`).toBeGreaterThan(0); + for (const consumer of CONSUMERS) { + expect(x[consumer].size, `${CONSUMER_SOURCE[consumer]} contributed no reads`).toBeGreaterThan(0); } }); - it('classifies every key the consumers read — no unclassified spelling', () => { - const { all } = extractReadSet(); - const unclassified = [...all].filter((k) => !(k in RELATIONAL_META_READ_SET)).sort(); + it('classifies every key each consumer reads — no unclassified spelling', () => { + const x = extractReadSet(); + for (const consumer of CONSUMERS) { + const unclassified = [...x[consumer]].filter( + (k) => !(k in RELATIONAL_META_READ_SET) || !RELATIONAL_META_READ_SET[k].readers.includes(consumer), + ).sort(); + expect( + unclassified, + `${CONSUMER_SOURCE[consumer]} reads these off the field meta and the table does not record ` + + `it as a reader of them. Add each to RELATIONAL_META_READ_SET with a verdict, or add ` + + `'${consumer}' to its \`readers\` — that decision is the fix objectui#6875 exists to ` + + 'make unforgettable, and objectui#7187 the reason it has to be made per consumer.', + ).toEqual([]); + } + }); + + it('carries no orphan — every declared reader is still a real read', () => { + const x = extractReadSet(); + for (const consumer of CONSUMERS) { + const orphans = declaredReaders(consumer).filter((k) => !x[consumer].has(k)); + expect( + orphans, + `The table says ${CONSUMER_SOURCE[consumer]} reads these and it does not any more. A key ` + + 'written from the schema def on every column build and read by nothing is what ' + + 'objectui#6711 and objectui#6874 retired — and a stale reader entry is how a key ' + + 'keeps a copy licence it has stopped earning.', + ).toEqual([]); + } + }); + + it('⭐ objectui#7187 — only the CELL is fed this bag, measured on ObjectGrid.tsx', () => { + expect(CONSUMERS_FED_THIS_BAG).toEqual(['cell']); + const body = renderCellEditorProperty(); + // Controls first: these three prove the bounded region is the real editor + // seam. Without them the zero below could come from an empty slice. + expect(body).toContain('...fieldDef'); + expect(body).toContain('objectSchema'); + expect(body).toContain('FieldEditWidget'); + // LIT CONTROL for the zero: the identifier IS all over this file, so the + // instrument can see it. Only the editor seam is free of it. + const wholeFile = readFileSync(path.join(GRID_SRC, 'ObjectGrid.tsx'), 'utf8'); + expect(wholeFile.split('fieldMeta').length - 1).toBeGreaterThan(10); expect( - unclassified, - 'A consumer reads these off the field meta and the table does not classify them. ' - + 'Add each to RELATIONAL_META_READ_SET with a verdict — that decision is the fix ' - + 'objectui#6875 exists to make unforgettable.', - ).toEqual([]); + body.split('fieldMeta').length - 1, + '`renderCellEditor` now names `fieldMeta`. If the inline editor is fed the copied bag after ' + + 'all, then the editor widgets ARE fed it, CONSUMERS_FED_THIS_BAG is wrong, and every ' + + 'copy verdict resting on "the editor gets it off the schema def" needs re-measuring.', + ).toBe(0); }); - it('carries no orphan — every classified key is still read by a consumer', () => { - const { all } = extractReadSet(); - const orphans = Object.keys(RELATIONAL_META_READ_SET).filter((k) => !all.has(k)).sort(); + it('⭐ objectui#7187 — `UserField` forwards, and that is a RESULT, not an assumption', () => { + const x = extractReadSet(); + // Control: both sides are populated, so the subset claim is a reading. + expect(x['user-editor'].size).toBeGreaterThan(0); + expect(x['lookup-editor'].size).toBeGreaterThan(0); + const notForwarded = [...x['user-editor']].filter((k) => !x['lookup-editor'].has(k)).sort(); expect( - orphans, - 'These are classified but no consumer reads them any more. A key written from the ' - + 'schema def on every column build and read by nothing is what objectui#6711 and ' - + 'objectui#6874 retired.', + notForwarded, + '`UserField` reads these off its meta and `LookupField` does not — so its whole-meta spread ' + + 'is no longer the reason it can be treated as a subset. Each needs classifying on its ' + + 'own; this is the false zero the sweep keeps `UserField` in scope to catch.', ).toEqual([]); + // ⚠️ Subset or not, `UserField` is an EDITOR: it licenses no copy either way. + expect(CONSUMERS_FED_THIS_BAG).not.toContain('user-editor'); + expect(CONSUMERS_FED_THIS_BAG).not.toContain('lookup-editor'); }); it('proves each `no-producer` verdict against the installed spec, not against prose', () => { @@ -229,9 +317,19 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); - it('the copy set is exactly the copied verdicts, and still carries the one key objectui#6875 delivered', () => { + it('⭐ objectui#7187 — the copy set is exactly: producer-licensed AND read by a consumer fed this bag', () => { + const cellRead = extractReadSet().cell; + // Control: the sweep found the cell's chain, so "not read by the cell" below + // is a measurement and not an empty extraction. + expect(cellRead.has('displayField')).toBe(true); + expect(cellRead.size).toBeGreaterThan(3); + // ⭐ Derived from the EXTRACTED cell set, not from the table's own `readers` + // — so this and `RELATIONAL_META_KEYS` reach the same list by two + // independent routes, and a hand-edited `readers` cannot carry both. const expected = Object.entries(RELATIONAL_META_READ_SET) - .filter(([, e]) => e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'legacy-alias') + .filter(([key, e]) => + (e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'legacy-alias') + && (cellRead.has(key) || e.copiedWithoutCellReader !== undefined)) .map(([k]) => k); expect([...RELATIONAL_META_KEYS].sort()).toEqual(expected.sort()); // objectui#6875 shipped three keys; ONE of them is genuinely delivered on @@ -245,22 +343,29 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); - it('proves each `deferred` verdict is spec-declared — the class is "reaches the editor anyway", not "unproducible"', () => { - const deferred = Object.entries(RELATIONAL_META_READ_SET) - .filter(([, e]) => e.verdict === 'deferred') - .map(([k]) => k); + it('⭐ objectui#7187 — the one exit from the cell-reader rule names itself, and only a non-authorable key may take it', () => { + const exits = Object.entries(RELATIONAL_META_READ_SET).filter(([, e]) => e.copiedWithoutCellReader !== undefined); // Control: the bucket is populated, so the loop below is a reading. - expect(deferred.length).toBeGreaterThan(0); - for (const key of deferred) { + expect(exits.length).toBeGreaterThan(0); + for (const [key, e] of exits) { expect( - specProps.has(key), - `${key} is classified deferred but FieldSchema does not declare it — a key no producer ` - + 'can emit belongs under `no-producer`, whose verdict carries the opposite proof.', - ).toBe(true); + e.verdict, + `${key} is copied without a cell reader under verdict '${e.verdict}'. That exit exists for the ` + + 'snake_case runtime spellings kept on an unanswered PRODUCER question, and nothing else — ' + + 'a spec-declared key taking it would be objectui#6875 happening again.', + ).toBe('legacy-alias'); + expect(specProps.has(key), `${key} is spec-declared — it cannot rest on "no producer can be surveyed"`).toBe(false); + expect(e.copiedWithoutCellReader!.length, `${key}'s exit has no reason`).toBeGreaterThan(20); + } + // ⛔ And no stale flags: the exit is only legal where it is actually needed. + const cellRead = extractReadSet().cell; + for (const [key, e] of Object.entries(RELATIONAL_META_READ_SET)) { + if (e.copiedWithoutCellReader === undefined) continue; + expect(cellRead.has(key), `${key} IS read by the cell — it does not need the exit; drop the field`).toBe(false); } }); - it('⛔ objectui#7166 — the three retired keys stay OUT of the copy set, and the derivation cannot enforce that', () => { + it('⛔ objectui#7166 — the three retired keys stay OUT of the copy set, and objectui#7187 makes that DERIVED', () => { const retired = ['descriptionField', 'lookupColumns', 'lookupFilters']; // Control: the copy set is populated, so "not contained" is a reading. expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); @@ -274,16 +379,26 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest ).not.toContain(key); } - // ⭐ THE POINT OF THIS ASSERTION, and why it is hand-written rather than - // derived. All three are STILL in the extracted read set — the editor - // widgets do read them — so every derived assertion in this file passes - // whichever verdict they carry. Read-set membership means "a consumer reads - // this key"; it never meant "this bag is how that consumer gets it", and - // only the second claim justifies a copy. Mistaking the first for the - // second is what put two of these three here (objectui#6875). - const readSet = [...extractReadSet().all]; + // ⭐ WHAT CHANGED IN objectui#7187, and why this test is no longer the hold. + // These three used to be absent from the copy set only because a verdict + // said so, while every DERIVED assertion in this file passed whichever + // verdict they carried — the union they sit in is read by the editor + // widgets, and read-set membership never meant "this bag delivers it". + // Now the fact that licenses a copy is the CELL's read set, and it does not + // contain them. This test survives to name them in a regression, not to + // carry the retirement alone. + const cellRead = extractReadSet().cell; + expect(cellRead.has('displayField')).toBe(true); // control: the cell sweep is lit for (const key of retired) { - expect(readSet, `${key} left the read set — then this pin is stale, not load-bearing`).toContain(key); + expect( + cellRead.has(key), + `${key} is now read by the CELL. Then it is a copy candidate again and objectui#7166's ` + + 'reader-side measurement is stale — re-measure before changing the verdict.', + ).toBe(false); + expect( + RELATIONAL_META_READ_SET[key].readers, + `${key} left the editor read set — then this pin is stale, not load-bearing`, + ).not.toEqual([]); } }); diff --git a/packages/plugin-grid/src/relationalMetaKeys.ts b/packages/plugin-grid/src/relationalMetaKeys.ts index 000988fadf..bd4f805756 100644 --- a/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/packages/plugin-grid/src/relationalMetaKeys.ts @@ -101,21 +101,30 @@ * one: does any host outside them put these spellings on a field def? Closing * it needs a producer survey, not another sweep of this repo. * - * ## ⛔ THE DERIVATION GATE CANNOT SEE THIS CLASS — its green is not cover - * - * The gate re-extracts a read set that is the UNION over three consumers, two - * of which are not fed this bag. Membership there means "some consumer reads - * this key" — never "this bag is how that consumer gets it". Those are - * different claims, and the second is the one a copy-set entry asserts. All - * three retired keys REMAIN in the extracted read set, because the editor - * widgets still read them, so the derivation stays green whichever verdict they - * carry: it would not go red if someone put them back tomorrow. - * - * ⇒ What guards this retirement is the rendering test plus the explicit - * non-membership pin in the gate — not the derivation. Re-scoping the - * derivation around the cell alone, with the editor widgets classified - * separately, would close that gap; it is a design change to objectui#6875's - * mechanism and is filed rather than made here. + * ## ⭐ THE DERIVATION IS SCOPED TO THE CELL — objectui#7187 + * + * That gap is closed. Until objectui#7187 the gate extracted ONE set, the UNION + * over the three consumers, and a copy-set entry was licensed by membership in + * it. Membership there means "some consumer reads this key" and never "this bag + * is how that consumer gets it" — different claims, and only the second + * justifies a copy. Reading the first as the second is what put + * `descriptionField` and `lookupColumns` on this table (objectui#6875) and what + * objectui#7166 then had to undo. + * + * So the reader axis is now recorded PER CONSUMER on every entry + * ({@link RelationalMetaEntry.readers}), and the gate checks each declared list + * against that consumer's own source in BOTH directions. The copy set is + * derived from {@link CONSUMERS_FED_THIS_BAG} alone. A key only the editor + * widgets read can no longer be copied by editing a verdict, because no verdict + * says "copy this" any more — copying follows the measured cell reader, and + * the one deliberate exception has to name itself per key + * ({@link RelationalMetaEntry.copiedWithoutCellReader}). + * + * ⇒ Putting any of the three retired keys back now turns the gate red on a + * DERIVED assertion. The named-key pin in + * `__tests__/relationalMetaCopySet.derivation.test.ts` and the rendering test + * `__tests__/relationalMetaCopySet-7166.test.tsx` are kept so a regression is + * reported by name — they are no longer the only hold. * * ## ⭐ Why a key can be READ and still not be worth copying * @@ -191,7 +200,20 @@ * repo's own contract is what these verdicts are about. */ -/** What the grid does with a key its consumers read off the field meta. */ +/** + * What the grid does with a key its consumers read off the field meta. + * + * ⚠️ This enum records the PRODUCER axis — who can put the key on a field def + * — plus one mechanism verdict. It deliberately does NOT record the READER + * axis; that lives on {@link RelationalMetaEntry.readers}, measured per + * consumer. objectui#7187 removed a `deferred` member that meant "read only by + * an editor widget", because a hand-written verdict restating a measurable fact + * is exactly the shape this table's own history warns about: read-set + * membership was taken for a licence to copy (objectui#6875) and had to be + * undone (objectui#7166). Those seven keys are `spec` now — which is what they + * are — and they stay off the copy set because no consumer fed this bag reads + * them, which is measured rather than declared. + */ export type RelationalMetaVerdict = /** Spec-declared on `FieldSchema`. The spelling a live `getObjectSchema` serves. */ | 'spec' @@ -202,16 +224,50 @@ export type RelationalMetaVerdict = /** Read, but no producer can emit it — copying it would reach nothing. */ | 'no-producer' /** Producible and read, but written onto the meta by another block already. */ - | 'handled-elsewhere' - /** - * Producible and read, but read ONLY by an editor widget — which - * `ObjectGrid.renderCellEditor` feeds from the schema def, not from this bag. - * Copying such a key onto `fieldMeta` reaches nothing. See `note` per key. - */ - | 'deferred'; + | 'handled-elsewhere'; + +/** + * The three consumers the gate sweeps, as this table names them. + * + * `cell` is `LookupCellRenderer` in `@object-ui/fields/src/index.tsx`, reached + * through ``. `lookup-editor` and `user-editor` + * are the inline-edit widgets in `@object-ui/fields/src/widgets/`, which + * `ObjectGrid.renderCellEditor` feeds from the object schema instead. + */ +export type RelationalMetaConsumer = 'cell' | 'lookup-editor' | 'user-editor'; + +/** + * ⭐ THE ASYMMETRY THIS WHOLE TABLE TURNS ON, as data rather than as prose. + * + * `applyRelationalMeta` writes onto the `fieldMeta` that `generateColumns` + * hands to ``, and nowhere else. The two editor widgets are fed + * `{ name: ctx.column.accessorKey, ...fieldDef }` straight off the object + * schema (`ObjectGrid.renderCellEditor`), so a key only they read gains nothing + * from being copied here. The gate asserts that bound against + * `ObjectGrid.tsx`'s own source rather than trusting this comment. + */ +export const CONSUMERS_FED_THIS_BAG: readonly RelationalMetaConsumer[] = Object.freeze([ + 'cell', +]); + +/** + * The four reader shapes the sweep actually finds. Naming them keeps the table + * scannable; the gate checks every entry's list against the consumer sources in + * both directions, so an alias can no more drift than a literal could. + */ +const ALL_THREE: readonly RelationalMetaConsumer[] = Object.freeze(['cell', 'lookup-editor', 'user-editor']); +const CELL_AND_LOOKUP_EDITOR: readonly RelationalMetaConsumer[] = Object.freeze(['cell', 'lookup-editor']); +const BOTH_EDITORS: readonly RelationalMetaConsumer[] = Object.freeze(['lookup-editor', 'user-editor']); +const LOOKUP_EDITOR_ONLY: readonly RelationalMetaConsumer[] = Object.freeze(['lookup-editor']); -/** Verdicts whose keys ARE copied. Everything else is deliberately skipped. */ -const COPIED_VERDICTS: ReadonlySet = new Set([ +/** + * Verdicts under which a key MAY be copied — the producer half of the licence. + * + * ⚠️ Necessary, never sufficient: a key is copied only if a consumer that is + * actually handed this bag reads it, or it names a reason not to need one. See + * {@link RELATIONAL_META_KEYS}. + */ +const PRODUCER_LICENSED_VERDICTS: ReadonlySet = new Set([ 'spec', 'adapter-stamped', 'legacy-alias', @@ -219,6 +275,22 @@ const COPIED_VERDICTS: ReadonlySet = new Set([ export interface RelationalMetaEntry { readonly verdict: RelationalMetaVerdict; + /** + * Which consumers read this key off a field meta. MEASURED — the gate + * re-extracts each consumer's set from its own source and requires this list + * to match, per consumer and in both directions. + */ + readonly readers: readonly RelationalMetaConsumer[]; + /** + * Present ONLY on a key copied although NO consumer fed this bag reads it — + * the one exit from the cell-reader rule, and it has to state its own reason. + * + * ⛔ The gate confines it to `legacy-alias`, which is separately proved + * non-authorable. So a spec-declared key can never take this exit: it is not + * a widening of the rule, it is the producer-side argument the snake_case + * spellings were kept on, written where it can be read. + */ + readonly copiedWithoutCellReader?: string; readonly note: string; } @@ -231,62 +303,79 @@ export interface RelationalMetaEntry { */ export const RELATIONAL_META_READ_SET: Readonly> = { // ── The relational target ──────────────────────────────────────────────── - reference: { verdict: 'spec', note: "FieldSchema.reference — the served spelling for a lookup's target object." }, - reference_to: { verdict: 'adapter-stamped', note: 'normalizeSchemaReferenceKeys stamps it from `reference` at the getObjectSchema choke point.' }, - reference_field: { verdict: 'no-producer', note: 'Third leg of the display-field chain. Not on FieldSchema; zero occurrences in the producer repo (control: `displayField`, 68 files). objectui#6875.' }, + reference: { verdict: 'spec', readers: ALL_THREE, note: "FieldSchema.reference — the served spelling for a lookup's target object." }, + reference_to: { verdict: 'adapter-stamped', readers: ALL_THREE, note: 'normalizeSchemaReferenceKeys stamps it from `reference` at the getObjectSchema choke point.' }, + reference_field: { verdict: 'no-producer', readers: ALL_THREE, note: 'Third leg of the display-field chain. Not on FieldSchema; zero occurrences in the producer repo (control: `displayField`, 68 files). objectui#6875.' }, // ── The display value ─────────────────────────────────────────────────── - displayField: { verdict: 'spec', note: 'FieldSchema.displayField. ⭐ Added by objectui#6875 — the only display spelling a spec-compliant producer can emit, and the one that never arrived.' }, - display_field: { verdict: 'legacy-alias', note: 'Runtime spelling, first leg of every display chain. Not on FieldSchema; kept for back-compat.' }, + displayField: { verdict: 'spec', readers: ALL_THREE, note: 'FieldSchema.displayField. ⭐ Added by objectui#6875 — the only display spelling a spec-compliant producer can emit, and the one that never arrived.' }, + display_field: { verdict: 'legacy-alias', readers: ALL_THREE, note: 'Runtime spelling, first leg of every display chain. Not on FieldSchema; kept for back-compat.' }, // ── The picker's secondary line ───────────────────────────────────────── // ⛔ The camel spelling LEFT the copy set in objectui#7166 while its snake // twin stayed. That asymmetry is deliberate and is explained under "the two // populations" in this file's docblock: the retirement is a READER-side // finding, and only the snake spellings carry a producer-side argument. - descriptionField: { verdict: 'deferred', note: "FieldSchema.descriptionField. Added by objectui#6875, RETIRED from the copy set by objectui#7166: its only reader is LookupField, which the inline editor feeds from the schema def, so the copy reached nothing. Measured by rendering — the picker's secondary line still appears with this table unchanged." }, - description_field: { verdict: 'legacy-alias', note: 'Runtime spelling. Not on FieldSchema; kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag either — it survives on the UNANSWERED producer question below, not on a measured reader.' }, + descriptionField: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: "FieldSchema.descriptionField. Added by objectui#6875, RETIRED from the copy set by objectui#7166: its only reader is LookupField, which the inline editor feeds from the schema def, so the copy reached nothing. Measured by rendering — the picker's secondary line still appears with this table unchanged." }, + description_field: { verdict: 'legacy-alias', readers: LOOKUP_EDITOR_ONLY, copiedWithoutCellReader: 'Copied with NO reader on this bag. It survives on the UNANSWERED producer question (objectui#7166): a host DataSource outside these two repos may hand-feed this runtime spelling. Closing that needs a producer survey, not another reader sweep.', note: 'Runtime spelling. Not on FieldSchema; kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag either — it survives on the UNANSWERED producer question below, not on a measured reader.' }, // ── The picker's table ────────────────────────────────────────────────── - lookupColumns: { verdict: 'deferred', note: "FieldSchema.lookupColumns. Added by objectui#6875, RETIRED from the copy set by objectui#7166 on the same measurement as `descriptionField`. Measured by rendering — the declared columns still shape the picker with this table unchanged." }, - lookup_columns: { verdict: 'no-producer', note: 'Runtime twin of `lookupColumns`, read but never producible. Not on FieldSchema. objectui#6875.' }, + lookupColumns: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: "FieldSchema.lookupColumns. Added by objectui#6875, RETIRED from the copy set by objectui#7166 on the same measurement as `descriptionField`. Measured by rendering — the declared columns still shape the picker with this table unchanged." }, + lookup_columns: { verdict: 'no-producer', readers: LOOKUP_EDITOR_ONLY, note: 'Runtime twin of `lookupColumns`, read but never producible. Not on FieldSchema. objectui#6875.' }, // ── The picker's base scoping ─────────────────────────────────────────── - lookupFilters: { verdict: 'deferred', note: "FieldSchema.lookupFilters. RETIRED from the copy set by objectui#7166: read off a field meta only by LookupField and UserField, both fed by the editor's schema spread. Measured by rendering — the declared filter still scopes the picker's candidates with this table unchanged." }, - lookup_filters: { verdict: 'legacy-alias', note: 'Runtime spelling. Not on FieldSchema; kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag; retained on the unanswered producer question, not on a measured reader.' }, + lookupFilters: { verdict: 'spec', readers: BOTH_EDITORS, note: "FieldSchema.lookupFilters. RETIRED from the copy set by objectui#7166: read off a field meta only by LookupField and UserField, both fed by the editor's schema spread. Measured by rendering — the declared filter still scopes the picker's candidates with this table unchanged." }, + lookup_filters: { verdict: 'legacy-alias', readers: BOTH_EDITORS, copiedWithoutCellReader: 'Copied with NO reader on this bag. It survives on the UNANSWERED producer question (objectui#7166): a host DataSource outside these two repos may hand-feed this runtime spelling. Closing that needs a producer survey, not another reader sweep.', note: 'Runtime spelling. Not on FieldSchema; kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag; retained on the unanswered producer question, not on a measured reader.' }, // ── The picker's id column ────────────────────────────────────────────── - id_field: { verdict: 'legacy-alias', note: 'Picker id column. Neither spelling is on FieldSchema (`idField` is absent too); kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag; retained on the unanswered producer question, not on a measured reader.' }, + id_field: { verdict: 'legacy-alias', readers: LOOKUP_EDITOR_ONLY, copiedWithoutCellReader: 'Copied with NO reader on this bag. It survives on the UNANSWERED producer question (objectui#7166): a host DataSource outside these two repos may hand-feed this runtime spelling. Closing that needs a producer survey, not another reader sweep.', note: 'Picker id column. Neither spelling is on FieldSchema (`idField` is absent too); kept for back-compat. Reader-side verdict (objectui#7166): NO reader on this bag; retained on the unanswered producer question, not on a measured reader.' }, // ── Read by the EDITOR widgets, producible, and NOT copied ────────────── - // Found by objectui#6875's re-sweep and left `deferred`. objectui#7154 then - // measured WHY copying them would reach nothing: their only reader is - // `LookupField`, which the grid's inline editor feeds from the schema def - // directly (see this file's docblock). All four already take effect in the - // picker with this table unchanged — pinned in - // `__tests__/lookupPickerKeys-7154.test.tsx`. `deferred` keeps the gate - // watching them; it is no longer a promise to copy them later. - multiple: { verdict: 'deferred', note: 'FieldSchema.multiple — picker cardinality. Read only by LookupField, which the grid feeds from the schema def, not from this bag: measured accumulating two picks in the inline picker with this table unchanged (objectui#7154).' }, - allowCreate: { verdict: 'deferred', note: 'FieldSchema.allowCreate — picker quick-create affordance. Same route as `multiple`: `allowCreate: false` measured removing the create entry the control column offers (objectui#7154).' }, - lookupPageSize: { verdict: 'deferred', note: 'FieldSchema.lookupPageSize — picker page size. Same route: a declared 3 measured scoping the picker dialog to 3 rows against a control of 10 (objectui#7154).' }, - dependsOn: { verdict: 'deferred', note: 'FieldSchema.dependsOn — cascading picker filter. Same route, and it ARRIVES: the declared column renders the gated trigger. The grid supplies no dependent values, so that gate is permanent — objectui#2215’s grid-side residue, filed separately (objectui#7154).' }, + // Found by objectui#6875's re-sweep. objectui#7154 measured WHY copying them + // would reach nothing: their only reader is `LookupField`, which the grid's + // inline editor feeds from the schema def directly (see this file's + // docblock). All four already take effect in the picker with this table + // unchanged — pinned in `__tests__/lookupPickerKeys-7154.test.tsx`. + // ⭐ objectui#7187: they carried a `deferred` verdict until that fact became + // measurable. `readers` states it now, the gate checks it against the widget + // sources, and the copy set follows from it — so `spec` is free to mean what + // it says (the producer can emit this), and the exclusion is no longer a word. + multiple: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: 'FieldSchema.multiple — picker cardinality. Read only by LookupField, which the grid feeds from the schema def, not from this bag: measured accumulating two picks in the inline picker with this table unchanged (objectui#7154).' }, + allowCreate: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: 'FieldSchema.allowCreate — picker quick-create affordance. Same route as `multiple`: `allowCreate: false` measured removing the create entry the control column offers (objectui#7154).' }, + lookupPageSize: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: 'FieldSchema.lookupPageSize — picker page size. Same route: a declared 3 measured scoping the picker dialog to 3 rows against a control of 10 (objectui#7154).' }, + dependsOn: { verdict: 'spec', readers: LOOKUP_EDITOR_ONLY, note: 'FieldSchema.dependsOn — cascading picker filter. Same route, and it ARRIVES: the declared column renders the gated trigger. The grid supplies no dependent values, so that gate is permanent — objectui#2215’s grid-side residue, filed separately (objectui#7154).' }, // ── Read on this path, no producer ────────────────────────────────────── - allow_create: { verdict: 'no-producer', note: 'Runtime twin of `allowCreate`. Not on FieldSchema.' }, - lookup_page_size: { verdict: 'no-producer', note: 'Runtime twin of `lookupPageSize`. Not on FieldSchema.' }, - depends_on: { verdict: 'no-producer', note: 'Runtime twin of `dependsOn`. Not on FieldSchema.' }, - picker: { verdict: 'no-producer', note: 'PeoplePicker variant opt-in. Not on FieldSchema.' }, - subtitle: { verdict: 'no-producer', note: 'PeoplePicker subtitle fields. Not on FieldSchema.' }, - avatarField: { verdict: 'no-producer', note: 'PeoplePicker avatar field. Not on FieldSchema.' }, - avatar_field: { verdict: 'no-producer', note: 'Runtime twin of `avatarField`. Not on FieldSchema.' }, + allow_create: { verdict: 'no-producer', readers: LOOKUP_EDITOR_ONLY, note: 'Runtime twin of `allowCreate`. Not on FieldSchema.' }, + lookup_page_size: { verdict: 'no-producer', readers: LOOKUP_EDITOR_ONLY, note: 'Runtime twin of `lookupPageSize`. Not on FieldSchema.' }, + depends_on: { verdict: 'no-producer', readers: LOOKUP_EDITOR_ONLY, note: 'Runtime twin of `dependsOn`. Not on FieldSchema.' }, + picker: { verdict: 'no-producer', readers: BOTH_EDITORS, note: 'PeoplePicker variant opt-in. Not on FieldSchema.' }, + subtitle: { verdict: 'no-producer', readers: BOTH_EDITORS, note: 'PeoplePicker subtitle fields. Not on FieldSchema.' }, + avatarField: { verdict: 'no-producer', readers: BOTH_EDITORS, note: 'PeoplePicker avatar field. Not on FieldSchema.' }, + avatar_field: { verdict: 'no-producer', readers: BOTH_EDITORS, note: 'Runtime twin of `avatarField`. Not on FieldSchema.' }, // ── Written by another block of the same column build ─────────────────── - options: { verdict: 'handled-elsewhere', note: 'Written by generateColumns as `translateOptions(...)`, which localises the labels; a raw copy would undo that.' }, - dataSource: { verdict: 'handled-elsewhere', note: 'Not a schema key — LookupField reads its own `props.dataSource` fallback off the meta bag.' }, + options: { verdict: 'handled-elsewhere', readers: CELL_AND_LOOKUP_EDITOR, note: 'Written by generateColumns as `translateOptions(...)`, which localises the labels; a raw copy would undo that.' }, + dataSource: { verdict: 'handled-elsewhere', readers: LOOKUP_EDITOR_ONLY, note: 'Not a schema key — LookupField reads its own `props.dataSource` fallback off the meta bag.' }, }; +/** Does a consumer that is actually handed this bag read this key? */ +function readByAFedConsumer(entry: RelationalMetaEntry): boolean { + return entry.readers.some((consumer) => CONSUMERS_FED_THIS_BAG.includes(consumer)); +} + /** - * The copy set, DERIVED from {@link RELATIONAL_META_READ_SET}. + * The copy set, DERIVED from {@link RELATIONAL_META_READ_SET} on TWO conditions + * — objectui#7187. + * + * 1. the PRODUCER half: the verdict licenses a copy at all, and + * 2. the READER half: a consumer fed this bag reads the key — or the entry + * names why it is copied without one. + * + * ⭐ (2) is the condition objectui#6875's mechanism did not have, and its + * absence is what let two keys onto this table whose only reader is an editor + * widget the bag never reaches. It is checked against the consumer sources, so + * no verdict edit can manufacture it. * * Order is the table's, which groups a chain's spellings together — it does not * matter to `applyRelationalMeta` (each key is written independently), but it @@ -294,7 +383,8 @@ export const RELATIONAL_META_READ_SET: Readonly COPIED_VERDICTS.has(entry.verdict)) + .filter(([, entry]) => PRODUCER_LICENSED_VERDICTS.has(entry.verdict) + && (readByAFedConsumer(entry) || entry.copiedWithoutCellReader !== undefined)) .map(([key]) => key), );