Skip to content

Commit 3693a1b

Browse files
fix(lint): field-no-consumers reads an inline grid column name as a field of the child object (#20950)
Closes #20929 Clause-②: no (a lint verdict changes; no key is added to a published payload, read against the gate's definition in `scripts/check-changeset-no-major.mjs`) ## What changes `field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`) now reads an inline grid column's `name` as a reference to the **child** object's field. `name` stays in `LITERAL_KEYS`: it is not dropped wholesale. One helper, `creditInlineGridColumns`, reads the column position on its own, against the child object each carrier names: | carrier | child object | site kind | |:--|:--|:--| | a relationship field's `inlineColumns` | the object that **declares** the field | `display` when the field sets `inlineEdit`, otherwise `carrier` | | `form.subforms[].columns`, and the same under each `formViews` entry | the entry's `childObject` | `display` | The subform carrier is keyed by `CHILD_COLLECTION_KEYS` (today `subforms`): its entries are `{ childObject, columns }`. The finding message now also lists an inline grid column among the consumers, and an `inlineColumns` entry on a field without `inlineEdit` among the carriers. ## One correction to the claim: for `inlineColumns` the child is the declaring object, not the related one The claim's scope line glossed the `inlineColumns` child as "the related one"; the seat corrected it in place (ruling `5920513410` on #20929). The spec, its own peer check, the renderer and the one real producer all say the opposite, so this PR follows the triage ruling's intent ("a reference to the **child** object's field"): - `FieldSchema.inlineEdit` (`packages/spec/src/data/field.zod.ts`): "On a child's `master_detail`/`lookup` field (whose `reference` is the parent object)". - `collectHydratedInlineColumnErrors` (`packages/spec/src/stack.zod.ts`): "a relationship field's `inlineColumns` — the field sits on the CHILD object, so a column names a field of the object that owns the field". - objectui `attachInlineSubforms` (`packages/app-shell/src/providers/MetadataProvider.tsx`) turns a field's `inlineColumns` into a subform with `childObject: child.name`, the declaring object, on the form of the field's `reference`, the parent. - The showcase invoice (`examples/app-showcase/src/data/objects/invoice.object.ts`) puts `inlineColumns` on `showcase_invoice_line.invoice` (`reference: 'showcase_invoice'`), and all seven columns are fields of `showcase_invoice_line`. The `inlineColumns` pin gives the related (parent) object a same-named field that nothing reads and holds it reported. Crediting the related object turns that pin red (ablation A3a below). ## Why `inlineEdit` gates the relationship carrier The spec's own form help text (`packages/spec/src/data/field.form.ts`) says `inlineColumns` is "used only when this field sets inlineEdit", and objectui skips a field whose `inlineEdit` is falsy. Without it, the columns name the field and draw nothing. They are recorded as a carrier: the field reads `carrier-only`, with the column path listed as a site a removal must clean. That is the rule's existing taxonomy ("credits exactly what a renderer draws"), and it is pinned and ablated (A4). ## Measured at the public door: `os validate --json`, before and after The probe is one parent (`gc_invoice`) and four children. On each child, `quantity` and `amount` are named only by grid columns, and `memo` is named nowhere (the control). The CLI ran from source (`packages/cli/bin/run-dev.js`), with the validate command's dependency closure built at each tree. | child | carrier | BEFORE at `1571aedce5` | AFTER at `f849aa53f6` | |:--|:--|:--|:--| | `gc_line_inline` | `invoice.inlineColumns` with `inlineEdit: 'grid'` | quantity, amount, memo: inert | memo: inert | | `gc_line_noedit` | `invoice.inlineColumns`, no `inlineEdit` | quantity, amount, memo: inert | quantity, amount: carrier-only (each lists its `inlineColumns[i].name` path); memo: inert | | `gc_line_form` | `form.subforms[0].columns` | quantity, amount, memo: inert | memo: inert | | `gc_line_formview` | `formViews.edit.subforms[0].columns` | quantity, amount, memo: inert | memo: inert | Both runs exit 0 with `valid: true`. `field-no-consumers` findings go from 12 to 6. The card had not measured the relationship field's `inlineColumns` carrier, and it had the same blind spot (first row, BEFORE). The AFTER reading was first taken at `7ee5c56679` and repeated at the merged head `f849aa53f6`, with identical findings. ## Pins and ablations A new `describe` block in `validate-field-consumers.test.ts` uses one fixture. The parent is `inv` and the child is `line`, related by `line.invoice`. `line.qty` is named only by the column under test. `line.memo` is named nowhere (the control). `inv.qty` is a same-named parent field that nothing reads, so a column credited to the wrong object shows up as `inv.qty` going quiet. The pins: - baseline: with no grid, all three fields are inert; - `inlineColumns` with `inlineEdit`; - `form.subforms[].columns`; - `formViews.edit.subforms[].columns`; - `inlineColumns` without `inlineEdit`: `carrier-only`, listing the column path; - `name` anywhere else stays a literal: a dataset measure named `qty` on `line` credits nothing. Every leg below went through `scripts/ablation-replace.mjs`. The anchor had to hit exactly once, and the mutation was proven on disk by anchor and replacement counts and a changed blob. The restore was proven by blob == HEAD and an empty `git diff HEAD`, with the driver's own trap on EXIT, INT and TERM. The pins import the source relatively, so no `dist` was involved. The tree was HEAD `7ee5c56679`. | leg | mutation | pins red | |:--|:--|:--| | A1 | the relationship-field credit removed | `inlineColumns`, no-`inlineEdit` (2) | | A2 | the child-collection credit removed | `form`, `formViews` (2) | | A3a | `inlineColumns` credited to the related object (`referenceTargetOf(field)`) | `inlineColumns`, no-`inlineEdit` (2) | | A3b | subform columns credited to the view's object (`inner`) | `form`, `formViews` (2) | | A4 | the `inlineEdit` gate removed (always `display`) | no-`inlineEdit` (1) | | A5 | `name` dropped from `LITERAL_KEYS` wholesale | `form`, `formViews`, no-`inlineEdit`, dataset-measure literal (4) | | A6 | the control: every child field credited once a grid exists | all four carrier pins, through `line.memo` (4) | A4's first attempt was a no-op and does not count. Its replacement text (`'display'`) already occurred in the file, so the token count moved 7 to 7, and the tool refused before running the pins. It was redone with a replacement that did not occur, `(true as boolean) ? 'display' : 'carrier'`, and went red with 1 failed. The final proof after all legs: blob `4c109d4ef9ee` == HEAD, and `git diff HEAD` is 0 bytes. ## Verification All on the merged head `f849aa53f6` (clean tree), merge base `3fbf3ca617`: - `pnpm turbo run build --filter='@objectstack/lint...' --concurrency=2`: 4/4 tasks. Then `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 117 files, 5444 tests passed. Then `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` plus `check:test-typecheck`, whose `tsconfig.test.json` is the program that compiles the changed test file): OK. The three ran joined by `&&` under `os-verify-lock`: `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: exit 0, 60 commands. All 60 ran, each with its exit code captured before any pipe. `--ran` reconciliation: exit 0, 58 exited 0, and 2 are NOT MEASURED. `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt` exited 3, `PREREQUISITE NOT MET`: each reads every workspace package's build output, and building the whole `./packages/*` closure is `lint.yml`'s own step, declared to CI. - eslint, narrowed to the diff: `eslint --no-inline-config --format json` on the two changed `.ts` files exits 0 over 2 files, with 0 errors and 0 warnings. The population is read from eslint's own config (`files: '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'`), and neither file is reported as ignored. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`) and no cross-file rule (no `import/*` rule), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - A control-byte scan of the three changed files found nothing. - `origin/main` has since moved to `f80e2a6dad`, which touches `packages/rest`. It is not re-merged, because CI builds the merge ref. ## Changeset `.changeset/20929-field-consumers-inline-grid-columns.md` grades `@objectstack/lint` `patch`. The package publishes `dist` (`files[]`), so a behaviour change owes a changeset, and `skip-changeset` does not apply. The level is `patch` because nothing on the public surface moves: no new export, the same rule id and severity, and the same finding shape. Only which fields get a warning changes, plus the message wording. The declaration reads `no`, so the level axis of `check-changeset-no-major` stands down, and nothing is breaking, so no ADR-0087 marker is owed. ## Acceptance notes - **Same family, not addressed here, measured at the door** (`os validate --json`, CLI at `f849aa53f6`, a second probe): 1. A grid with **no** explicit columns (`inlineEdit: 'grid'`, no `inlineColumns`) draws columns derived from the child's fields (objectui `deriveColumns`). Those fields still read `inert`: `gc_line_derived.quantity` and `.amount`. The derivation lives in objectui, not in the spec, so crediting it is a design question, like the synthesized field-group layout, whose derivation the spec owns. 2. A subform's `amountField` names a child field (the spec: "Numeric child column summed for the running total"), but the walk reads it in the context of the object the view is bound to, the parent. `gc_line_amt.amount`, named only there, reads `inert`. `totalField` names a parent field, so this position cannot simply inherit the subform's `childObject`. - The master-detail block's `details[].columns` is not addressed here, and #20928 remains open. Its entries share the `{ childObject, columns }` shape `CHILD_COLLECTION_KEYS` reads. - Column expressions under a subform (`expr`, `readonlyWhen`, `requiredWhen`) are still scanned in the view object's context. Their scope is mixed (`record` is the child row, `parent` is the header), so that is an observation, not a mechanical change. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ad65b0 commit 3693a1b

3 files changed

Lines changed: 175 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
`field-no-consumers` no longer calls a field inert when an inline grid column names it
6+
7+
`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:
8+
9+
- 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.
10+
- 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.
11+
12+
`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.

‎packages/lint/src/validate-field-consumers.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,3 +589,83 @@ describe('[#19289] validateFieldConsumers — a `user` field displays a field on
589589
expect(run).toThrow(/`reference` is an object/);
590590
});
591591
});
592+
593+
/**
594+
* [#20929] An inline grid column's `name` names a field of the CHILD object.
595+
*
596+
* `name` is a `LITERAL_KEYS` literal, so the general walk never read a column's
597+
* `name`, and the recommended identity-only column (`{ name: 'qty' }`) says
598+
* nothing else. `os validate` then warned that a field the grid draws was
599+
* inert. The fix reads `name` at that one position, against the child object
600+
* each carrier resolves. It does not drop `name` from the literals.
601+
*
602+
* One fixture carries every assertion. `inv` is the parent and `line` the
603+
* child, related by `line.invoice`. On `line`, `qty` is named only by the grid
604+
* column under test, and `memo` is named nowhere: `memo` is the control, still
605+
* reported. The parent declares a `qty` of its own that nothing reads, so it is
606+
* reported too. A column credited to the wrong object shows up as `inv.qty`
607+
* going quiet while `line.qty` stays reported.
608+
*/
609+
describe('[#20929] validateFieldConsumers — an inline grid column names a field of the CHILD object', () => {
610+
const data = { provider: 'object', object: 'inv' };
611+
const columns = [{ name: 'qty' }];
612+
613+
const stack = (relationship: AnyRec, view: AnyRec = {}, extra: AnyRec = {}): AnyRec => ({
614+
objects: [
615+
{ name: 'inv', fields: { name: { type: 'text' }, qty: { type: 'number' } } },
616+
{
617+
name: 'line',
618+
fields: {
619+
name: { type: 'text' },
620+
invoice: { type: 'master_detail', reference: 'inv', ...relationship },
621+
qty: { type: 'number' },
622+
memo: { type: 'text' },
623+
},
624+
},
625+
],
626+
views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...view }],
627+
...extra,
628+
});
629+
630+
/** `object.field` → verdict, for every field the rule reports. */
631+
const verdicts = (s: AnyRec): Record<string, string> =>
632+
Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict]));
633+
634+
/** The child's `qty` credited; the parent's `qty` and the control still reported. */
635+
const CREDITED = { 'inv.qty': 'inert', 'line.memo': 'inert' };
636+
637+
it('baseline: with no grid anywhere, all three fields are reported inert', () => {
638+
expect(verdicts(stack({}))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' });
639+
});
640+
641+
it("a relationship field's `inlineColumns`: the child is the object that DECLARES the field, not the related one", () => {
642+
expect(verdicts(stack({ inlineEdit: 'grid', inlineColumns: columns }))).toEqual(CREDITED);
643+
});
644+
645+
it("a form view's `subforms[].columns`: the child is the entry's `childObject`, not the view's object", () => {
646+
const form = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] };
647+
expect(verdicts(stack({}, { form }))).toEqual(CREDITED);
648+
});
649+
650+
it("each `formViews` entry's `subforms[].columns`, the same way", () => {
651+
const edit = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] };
652+
expect(verdicts(stack({}, { formViews: { edit } }))).toEqual(CREDITED);
653+
});
654+
655+
it('`inlineColumns` on a field that does not set `inlineEdit` draws no grid: a carrier, listed for removal', () => {
656+
const findings = validateFieldConsumers(stack({ inlineColumns: columns }));
657+
expect(Object.fromEntries(findings.map((f) => [`${f.object}.${f.field}`, f.verdict]))).toEqual({
658+
'inv.qty': 'inert',
659+
'line.qty': 'carrier-only',
660+
'line.memo': 'inert',
661+
});
662+
expect(findings.find((f) => f.object === 'line' && f.field === 'qty')?.carriers).toEqual([
663+
'objects[1].fields.invoice.inlineColumns[0].name',
664+
]);
665+
});
666+
667+
it('`name` anywhere else stays a literal: a dataset measure named like the field credits nothing', () => {
668+
const datasets = [{ name: 'line_stats', object: 'line', measures: [{ name: 'qty', aggregate: 'count' }] }];
669+
expect(verdicts(stack({}, {}, { datasets }))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' });
670+
});
671+
});

‎packages/lint/src/validate-field-consumers.ts‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
* hook or action body, a dataset dimension or measure, a widget filter, a
4141
* sharing-rule condition.
4242
* - **display** — the field is drawn: a view column, a form section, a page
43-
* binding, `highlightFields`, `searchableFields`, an index.
43+
* binding, an inline grid column ({@link creditInlineGridColumns}),
44+
* `highlightFields`, `searchableFields`, an index.
4445
* - **carrier** — the field is merely carried along: a translation label, a
4546
* seed value, an import-mapping column, a field-level permission grant, a
4647
* flow's WRITE of the field, prose that names it. These are what a REMOVAL
@@ -271,13 +272,27 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([
271272
'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records',
272273
]);
273274

275+
/**
276+
* [#20929] Keys whose array entries are INLINE CHILD COLLECTIONS: each entry
277+
* names its child object in `childObject` and the child's grid in `columns`.
278+
* Today that is a form view's `subforms` (`FormViewSchema.subforms`, on a view
279+
* container's `form` and on every `formViews` entry), whose `columns` is the
280+
* same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes.
281+
*/
282+
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']);
283+
274284
/**
275285
* Keys whose value is a literal from some other vocabulary, never a field
276286
* name. Without this list `type: 'summary'` on a roll-up reads as a reference
277287
* to a field named `summary`, and `accept: ['image/png']` as one to `image`.
278288
* `source` is deliberately ABSENT: it is the text of a CEL envelope
279289
* (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and
280290
* skipping it read every tagged-template formula as reading nothing.
291+
*
292+
* `name` is a literal here and stays one: it is the identity of nearly every
293+
* record in a stack. The one position where it names a field is an inline
294+
* grid column, and that position is read on its own, against the child object,
295+
* by {@link creditInlineGridColumns}, never by dropping `name` from this set.
281296
*/
282297
const LITERAL_KEYS: ReadonlySet<string> = new Set([
283298
'type', 'reference', 'accept', 'provider', 'dialect', 'operator', 'aggregate', 'mode',
@@ -457,6 +472,49 @@ function scanText(
457472
}
458473
}
459474

475+
/**
476+
* [#20929] Credit the fields an inline grid's columns name, on the CHILD object.
477+
*
478+
* A column's `name` is the child field the grid reads and writes on every row
479+
* (`InlineGridColumnSchema.name`), and the recommended entry is identity-only
480+
* (`{ name: 'quantity' }`), so the name is often all a column says. The
481+
* general walk skips `name` as a {@link LITERAL_KEYS} literal, which made every
482+
* field a grid draws read as inert. This is the one position where `name` is
483+
* read as a reference, and only against the child object its carrier resolves:
484+
*
485+
* - a relationship field's `inlineColumns`: the field sits ON the child and
486+
* its `reference` names the PARENT, whose form draws the grid. So the child
487+
* is the object that DECLARES the field, not the related one. objectui's
488+
* `attachInlineSubforms` builds `{ childObject: <declaring object>,
489+
* columns: inlineColumns }`, and `collectHydratedInlineColumnErrors` in
490+
* `stack.zod.ts` resolves the carrier the same way.
491+
* - a child collection's `columns` ({@link CHILD_COLLECTION_KEYS}): the child
492+
* is the entry's `childObject`. The object the enclosing view is bound to
493+
* is the parent, so the context the walk carries is the wrong one here.
494+
*
495+
* A name the child does not declare is counted unresolved, like every other
496+
* token that looks like a field and lands on no object.
497+
*/
498+
function creditInlineGridColumns(
499+
ledger: ConsumerLedger,
500+
columns: unknown,
501+
childObject: string | undefined,
502+
kind: SiteKind,
503+
root: string,
504+
columnsPath: string,
505+
): void {
506+
if (!Array.isArray(columns)) return;
507+
columns.forEach((column: unknown, i: number) => {
508+
const field = isRec(column) ? strName(column.name) : undefined;
509+
if (field === undefined || !ledger.objectsByField.has(field)) return;
510+
if (ledger.declares(childObject, field)) {
511+
ledger.record(childObject, field, { root, path: `${columnsPath}[${i}].name`, kind });
512+
} else {
513+
ledger.unresolved += 1;
514+
}
515+
});
516+
}
517+
460518
/** The object context a record establishes for its own subtree, if any. */
461519
function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined {
462520
const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined);
@@ -513,6 +571,11 @@ function walk(
513571
}
514572
const rec = node as AnyRec;
515573
const inner = contextOf(ledger, rec, ctx);
574+
// [#20929] An entry of a child collection: its grid draws `childObject`'s
575+
// fields, whatever object the enclosing view is bound to.
576+
if (CHILD_COLLECTION_KEYS.has(leafKey)) {
577+
creditInlineGridColumns(ledger, rec.columns, strName(rec.childObject), 'display', root, `${path}.columns`);
578+
}
516579
for (const [key, value] of Object.entries(rec)) {
517580
const childPath = `${path}.${key}`;
518581
const childSegments = [...segments, key];
@@ -574,6 +637,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
574637
if (reference && displayField && ledger.declares(reference, displayField)) {
575638
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });
576639
}
640+
// [#20929] The grid's columns name fields of THIS object, the child. The
641+
// grid exists only where the field sets `inlineEdit`: the spec's help text
642+
// says `inlineColumns` is "used only when this field sets inlineEdit", and
643+
// objectui's `attachInlineSubforms` skips the field otherwise. Without it
644+
// the columns name the field and draw nothing, so they are a carrier a
645+
// removal must clean, not a consumer.
646+
creditInlineGridColumns(
647+
ledger,
648+
field.inlineColumns,
649+
objectName,
650+
field.inlineEdit ? 'display' : 'carrier',
651+
'objects',
652+
`${fieldPath}.inlineColumns`,
653+
);
577654
for (const [key, value] of Object.entries(field)) {
578655
if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue;
579656
walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key);
@@ -720,10 +797,12 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
720797
path,
721798
message:
722799
`field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` +
723-
`it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` +
724-
`hook or action names it, no declared field group places it on the synthesized layout, and no ` +
800+
`it: no view column, inline grid column, form section, page binding, flow node, dataset, widget, ` +
801+
`formula, validation, hook or action names it, no declared field group places it on the ` +
802+
`synthesized layout, and no ` +
725803
`seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` +
726-
`target, a permission grant or a flow that only WRITES it is a carrier, not a consumer. ` +
804+
`target, a permission grant, a flow that only WRITES it, or an \`inlineColumns\` entry on a ` +
805+
`relationship field that does not set \`inlineEdit\` (no grid is drawn) is a carrier, not a consumer. ` +
727806
`${verdictClause}${sharedClause}`,
728807
hint:
729808
`Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` +

0 commit comments

Comments
 (0)