From 405e80f77e33b4efa78ad8ed305c56523419c307 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:20:20 +0000 Subject: [PATCH 1/3] fix(lint): field-no-consumers reads an inline grid column's name as the child object's field `name` stays a LITERAL_KEYS literal in general. At an inline grid column it is read as a reference, against the child object its carrier resolves: - a relationship field's `inlineColumns`: the object that declares the field (the child; its `reference` is the parent), and only as a display site when the field sets `inlineEdit`, otherwise as a carrier; - a form view's `subforms[].columns` (on `form` and every `formViews` entry): the entry's `childObject`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/lint/src/validate-field-consumers.ts | 87 ++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 31af2efbd3e..4c109d4ef9e 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -40,7 +40,8 @@ * hook or action body, a dataset dimension or measure, a widget filter, a * sharing-rule condition. * - **display** — the field is drawn: a view column, a form section, a page - * binding, `highlightFields`, `searchableFields`, an index. + * binding, an inline grid column ({@link creditInlineGridColumns}), + * `highlightFields`, `searchableFields`, an index. * - **carrier** — the field is merely carried along: a translation label, a * seed value, an import-mapping column, a field-level permission grant, a * flow's WRITE of the field, prose that names it. These are what a REMOVAL @@ -271,6 +272,15 @@ const WRITE_KEYS: ReadonlySet = new Set([ 'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records', ]); +/** + * [#20929] Keys whose array entries are INLINE CHILD COLLECTIONS: each entry + * names its child object in `childObject` and the child's grid in `columns`. + * Today that is a form view's `subforms` (`FormViewSchema.subforms`, on a view + * container's `form` and on every `formViews` entry), whose `columns` is the + * same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes. + */ +const CHILD_COLLECTION_KEYS: ReadonlySet = new Set(['subforms']); + /** * Keys whose value is a literal from some other vocabulary, never a field * name. Without this list `type: 'summary'` on a roll-up reads as a reference @@ -278,6 +288,11 @@ const WRITE_KEYS: ReadonlySet = new Set([ * `source` is deliberately ABSENT: it is the text of a CEL envelope * (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and * skipping it read every tagged-template formula as reading nothing. + * + * `name` is a literal here and stays one: it is the identity of nearly every + * record in a stack. The one position where it names a field is an inline + * grid column, and that position is read on its own, against the child object, + * by {@link creditInlineGridColumns}, never by dropping `name` from this set. */ const LITERAL_KEYS: ReadonlySet = new Set([ 'type', 'reference', 'accept', 'provider', 'dialect', 'operator', 'aggregate', 'mode', @@ -457,6 +472,49 @@ function scanText( } } +/** + * [#20929] Credit the fields an inline grid's columns name, on the CHILD object. + * + * A column's `name` is the child field the grid reads and writes on every row + * (`InlineGridColumnSchema.name`), and the recommended entry is identity-only + * (`{ name: 'quantity' }`), so the name is often all a column says. The + * general walk skips `name` as a {@link LITERAL_KEYS} literal, which made every + * field a grid draws read as inert. This is the one position where `name` is + * read as a reference, and only against the child object its carrier resolves: + * + * - a relationship field's `inlineColumns`: the field sits ON the child and + * its `reference` names the PARENT, whose form draws the grid. So the child + * is the object that DECLARES the field, not the related one. objectui's + * `attachInlineSubforms` builds `{ childObject: , + * columns: inlineColumns }`, and `collectHydratedInlineColumnErrors` in + * `stack.zod.ts` resolves the carrier the same way. + * - a child collection's `columns` ({@link CHILD_COLLECTION_KEYS}): the child + * is the entry's `childObject`. The object the enclosing view is bound to + * is the parent, so the context the walk carries is the wrong one here. + * + * A name the child does not declare is counted unresolved, like every other + * token that looks like a field and lands on no object. + */ +function creditInlineGridColumns( + ledger: ConsumerLedger, + columns: unknown, + childObject: string | undefined, + kind: SiteKind, + root: string, + columnsPath: string, +): void { + if (!Array.isArray(columns)) return; + columns.forEach((column: unknown, i: number) => { + const field = isRec(column) ? strName(column.name) : undefined; + if (field === undefined || !ledger.objectsByField.has(field)) return; + if (ledger.declares(childObject, field)) { + ledger.record(childObject, field, { root, path: `${columnsPath}[${i}].name`, kind }); + } else { + ledger.unresolved += 1; + } + }); +} + /** The object context a record establishes for its own subtree, if any. */ function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined { const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined); @@ -513,6 +571,11 @@ function walk( } const rec = node as AnyRec; const inner = contextOf(ledger, rec, ctx); + // [#20929] An entry of a child collection: its grid draws `childObject`'s + // fields, whatever object the enclosing view is bound to. + if (CHILD_COLLECTION_KEYS.has(leafKey)) { + creditInlineGridColumns(ledger, rec.columns, strName(rec.childObject), 'display', root, `${path}.columns`); + } for (const [key, value] of Object.entries(rec)) { const childPath = `${path}.${key}`; const childSegments = [...segments, key]; @@ -574,6 +637,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj if (reference && displayField && ledger.declares(reference, displayField)) { ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' }); } + // [#20929] The grid's columns name fields of THIS object, the child. The + // grid exists only where the field sets `inlineEdit`: the spec's help text + // says `inlineColumns` is "used only when this field sets inlineEdit", and + // objectui's `attachInlineSubforms` skips the field otherwise. Without it + // the columns name the field and draw nothing, so they are a carrier a + // removal must clean, not a consumer. + creditInlineGridColumns( + ledger, + field.inlineColumns, + objectName, + field.inlineEdit ? 'display' : 'carrier', + 'objects', + `${fieldPath}.inlineColumns`, + ); for (const [key, value] of Object.entries(field)) { if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key); @@ -720,10 +797,12 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { path, message: `field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` + - `it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` + - `hook or action names it, no declared field group places it on the synthesized layout, and no ` + + `it: no view column, inline grid column, form section, page binding, flow node, dataset, widget, ` + + `formula, validation, hook or action names it, no declared field group places it on the ` + + `synthesized layout, and no ` + `seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` + - `target, a permission grant or a flow that only WRITES it is a carrier, not a consumer. ` + + `target, a permission grant, a flow that only WRITES it, or an \`inlineColumns\` entry on a ` + + `relationship field that does not set \`inlineEdit\` (no grid is drawn) is a carrier, not a consumer. ` + `${verdictClause}${sharedClause}`, hint: `Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` + From 7ee5c5667925d9138d1d0e958ab1018c7eac09bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:24:33 +0000 Subject: [PATCH 2/3] test(lint): pin the inline grid column read per carrier, with the child-object control Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../lint/src/validate-field-consumers.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/lint/src/validate-field-consumers.test.ts b/packages/lint/src/validate-field-consumers.test.ts index 53482b8672a..8675754a919 100644 --- a/packages/lint/src/validate-field-consumers.test.ts +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -589,3 +589,83 @@ describe('[#19289] validateFieldConsumers — a `user` field displays a field on expect(run).toThrow(/`reference` is an object/); }); }); + +/** + * [#20929] An inline grid column's `name` names a field of the CHILD object. + * + * `name` is a `LITERAL_KEYS` literal, so the general walk never read a column's + * `name`, and the recommended identity-only column (`{ name: 'qty' }`) says + * nothing else. `os validate` then warned that a field the grid draws was + * inert. The fix reads `name` at that one position, against the child object + * each carrier resolves. It does not drop `name` from the literals. + * + * One fixture carries every assertion. `inv` is the parent and `line` the + * child, related by `line.invoice`. On `line`, `qty` is named only by the grid + * column under test, and `memo` is named nowhere: `memo` is the control, still + * reported. The parent declares a `qty` of its own that nothing reads, so it is + * reported too. A column credited to the wrong object shows up as `inv.qty` + * going quiet while `line.qty` stays reported. + */ +describe('[#20929] validateFieldConsumers — an inline grid column names a field of the CHILD object', () => { + const data = { provider: 'object', object: 'inv' }; + const columns = [{ name: 'qty' }]; + + const stack = (relationship: AnyRec, view: AnyRec = {}, extra: AnyRec = {}): AnyRec => ({ + objects: [ + { name: 'inv', fields: { name: { type: 'text' }, qty: { type: 'number' } } }, + { + name: 'line', + fields: { + name: { type: 'text' }, + invoice: { type: 'master_detail', reference: 'inv', ...relationship }, + qty: { type: 'number' }, + memo: { type: 'text' }, + }, + }, + ], + views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...view }], + ...extra, + }); + + /** `object.field` → verdict, for every field the rule reports. */ + const verdicts = (s: AnyRec): Record => + Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict])); + + /** The child's `qty` credited; the parent's `qty` and the control still reported. */ + const CREDITED = { 'inv.qty': 'inert', 'line.memo': 'inert' }; + + it('baseline: with no grid anywhere, all three fields are reported inert', () => { + expect(verdicts(stack({}))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' }); + }); + + it("a relationship field's `inlineColumns`: the child is the object that DECLARES the field, not the related one", () => { + expect(verdicts(stack({ inlineEdit: 'grid', inlineColumns: columns }))).toEqual(CREDITED); + }); + + it("a form view's `subforms[].columns`: the child is the entry's `childObject`, not the view's object", () => { + const form = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] }; + expect(verdicts(stack({}, { form }))).toEqual(CREDITED); + }); + + it("each `formViews` entry's `subforms[].columns`, the same way", () => { + const edit = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] }; + expect(verdicts(stack({}, { formViews: { edit } }))).toEqual(CREDITED); + }); + + it('`inlineColumns` on a field that does not set `inlineEdit` draws no grid: a carrier, listed for removal', () => { + const findings = validateFieldConsumers(stack({ inlineColumns: columns })); + expect(Object.fromEntries(findings.map((f) => [`${f.object}.${f.field}`, f.verdict]))).toEqual({ + 'inv.qty': 'inert', + 'line.qty': 'carrier-only', + 'line.memo': 'inert', + }); + expect(findings.find((f) => f.object === 'line' && f.field === 'qty')?.carriers).toEqual([ + 'objects[1].fields.invoice.inlineColumns[0].name', + ]); + }); + + it('`name` anywhere else stays a literal: a dataset measure named like the field credits nothing', () => { + const datasets = [{ name: 'line_stats', object: 'line', measures: [{ name: 'qty', aggregate: 'count' }] }]; + expect(verdicts(stack({}, {}, { datasets }))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' }); + }); +}); From 4e6e5518d2a329dd9071547bb69b06839cde11e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:32:30 +0000 Subject: [PATCH 3/3] chore(changeset): patch @objectstack/lint for the inline grid column read Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20929-field-consumers-inline-grid-columns.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/20929-field-consumers-inline-grid-columns.md diff --git a/.changeset/20929-field-consumers-inline-grid-columns.md b/.changeset/20929-field-consumers-inline-grid-columns.md new file mode 100644 index 00000000000..d85dcb9d292 --- /dev/null +++ b/.changeset/20929-field-consumers-inline-grid-columns.md @@ -0,0 +1,12 @@ +--- +'@objectstack/lint': patch +--- + +`field-no-consumers` no longer calls a field inert when an inline grid column names it + +`os validate`, `os build` and `os lint` warned that a child object's field was inert ("no site of any kind names it") when the only thing naming it was an inline master-detail grid column, such as `{ name: 'quantity' }`. The warning told an author to delete a field the grid draws. A column's `name` is now read as a reference to the child object's field, on both carriers of the column: + +- a relationship field's `inlineColumns`. The field sits on the child object and its `reference` names the parent, so the column names a field of the object that declares the relationship field. The grid is drawn only when that field sets `inlineEdit`. Without it, the columns draw nothing, and the field is reported `carrier-only` with the column listed as a site a removal must clean. +- a form view's `subforms[].columns`, on the view's `form` and on every `formViews` entry. The column names a field of the entry's `childObject`, not of the object the view is bound to. + +`name` anywhere else is still a literal and never a field reference. The rule id, the `warning` severity and the finding's shape are unchanged. The message now also lists an inline grid column among the consumers, and an `inlineColumns` entry on a field without `inlineEdit` among the carriers.