Repository navigation
fix(lint): field-no-consumers reads an inline grid column name as a field of the child object - #20950
Conversation
…he 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 <noreply@anthropic.com>
…ld-object control Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…read Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…id-column-consumers
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7d069cfe0c37fda398ef01d8e10daea1176aec2d && git checkout 7d069cfe0c37fda398ef01d8e10daea1176aec2d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8460592f0865b0ffef931efaf4b71d5bc968dd29 f849aa53f69db4662d07be35c239a85a08462a5c && git checkout -B drift-repro 8460592f0865b0ffef931efaf4b71d5bc968dd29 && git merge --no-ff f849aa53f69db4662d07be35c239a85a08462a5c
node scripts/docs-audit/affected-docs.mjs --json 8460592f0865b0ffef931efaf4b71d5bc968dd29 |
Contract reviewServed-tier: PR #20950 for card #20929, read against ① Derived judgmentsAccept-set. No schema is touched; nothing an author writes is accepted or refused differently. The one verdict that moves is Public surface of Child-object resolution, per carrier — each read against the spec and the renderer, not the claim's gloss.
The Credit classes vs the scan's own taxonomy. A drawn column is
A column naming a field the child does not declare is counted Author-shown and AI-facing text, sentence by sentence.
Pre-existing, unchanged, named so nobody re-derives it: the general walk still reads a subform column's ② Semver level
③ Boundary flagsDev report
Check-runs on Implemented-by: VERDICT: PASS Adopted and posted by
Generated by Claude Code |
… against the child, and credits a derived inline grid through deriveInlineGridColumns (objectstack-ai#21089) Fixes objectstack-ai#20951 Clause-②: yes (widening) ## What changes `field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`) called two kinds of in-use child field "inert". Both are corrected here, and the second goes through one new derivation the spec owns. **Site 1: a `subforms` entry's child-field keys are read per key.** `amountField` ("Numeric child column summed for the running total") is now read against the entry's `childObject`. `totalField` ("Parent field to receive the rolled-up sum") stays on the parent, which is the context the walk already carries. The read uses the child resolution that PR objectstack-ai#20950 added for `subforms[].columns`: `strName(rec.childObject)` in the `CHILD_COLLECTION_KEYS` branch of `walk`. There is no second child-object lookup. The generic walk now skips these keys on an entry, so a same-named parent field is no longer credited in the child's place. - `relationshipField` ("FK on the child pointing back to the parent") gets the same per-key read. This is a bounded in-place fix: the same defect class as `amountField`, in the same file, with the same gate family. Evidence is in the probe table below (`pr_quote_line.quote`). The renderer loads the child rows with `$filter` on this key and stamps it on save, so the field is read. **Site 2: a derived inline grid credits the columns it draws.** The new `deriveInlineGridColumns` lives in `packages/spec/src/data/inline-grid-columns.ts`, beside `deriveFieldGroupLayout`, and is exported through the `@objectstack/spec/data` barrel. The lint credits exactly what it returns, `defaultHidden` overflow included, because those columns are collapsed into the column chooser and never dropped. Two carriers trigger it: - a relationship field with `inlineEdit` (`true`, `'grid'` or `'form'`; both modes pass the same `columns` to the grid), `type` `master_detail` or `lookup`, a target that resolves, and no authored `inlineColumns` (absent or empty). These are the conditions objectui's `attachInlineSubforms` checks; - a `subforms` entry with no `columns` (absent or empty). The spec documents this second carrier with the same words, "derived from the child object when omitted", and the renderer uses the same derivation for it. ## The spec function and objectui's rule (triage's ⛔, PM hypothesis H2) Signature: `deriveInlineGridColumns(def: unknown, opts?: { relationshipField?: string; exclude?: readonly string[]; maxColumns?: number }): DerivedInlineGridColumn[]`. `DerivedInlineGridColumn` is `{ name: string; defaultHidden?: true }`, which is a valid identity-only `inlineColumns` entry. `DEFAULT_MAX_INLINE_GRID_COLUMNS` is `6`. Import path: `@objectstack/spec/data`. The input discipline matches `deriveFieldGroupLayout`: it takes the child object's definition and tolerates un-parsed input. **The rule, as measured** in objectui `main` at `be5211522412` (`packages/plugin-form/src/deriveMasterDetail.ts`, `deriveColumns` plus `curateColumns`, read over REST): - Every child field is a candidate, in the field map's order. - A field is skipped when: - its name is an identity, audit, tenancy or ownership column (`id`, `_id`, `recordId`, `created_at`/`updated_at`/`created_by`/`updated_by` and their camelCase forms, `organization_id`, `tenant_id`, `space`, `owner`); - its name is a sort-position name (`position`, `sort_order`, `sequence`, `line_no`, `line_number`, `sort`); - it is the relationship field, or a name in `exclude`; - it is flagged `system`, `readonly` or `hidden` (a truthy value is enough); - its type cannot be edited in a cell: `formula`, `summary`, `rollup`, `autonumber`, `auto_number`, `json`, `object`, `grid`, `table`, `location`, `vector`, `html`, `markdown` or `richtext`. - Visible budget: 6. The first name-like column is kept visible (or the first column, when none is name-like), and so is every required column. A computed column is never required. The remaining slots go by cell type: select first, then currency and number, then lookup, then date, datetime and time, then text, and file last. Ties keep field order. Columns past the budget are marked `defaultHidden`. `maxColumns` of `0` or less marks no column hidden. **The differential: 80,004 cases and 0 mismatches.** I ran the spec function against objectui's `deriveColumns`, imported from that `main` file. The cases were objectui's 4 own fixtures, 50,000 random definitions and 30,000 wide definitions built to exercise the budget (29,075 of them produced `defaultHidden` columns). The random inputs included null field definitions, array-shaped `fields`, non-spec type names, truthy and falsy flag values, CEL-envelope expressions, and `NaN`, negative and absent `maxColumns`. Names, order and `defaultHidden` matched in every case. So the spec function reproduces the rule with no behaviour change. objectui's renderer is not touched here. **For objectui's switch (the coordination child; not in this PR):** `hydrateColumns(deriveInlineGridColumns(schema, opts), schema)` equals `deriveColumns(schema, opts)` in every case but one kind. When a derived field's own definition is falsy (`null`), `hydrateColumns` returns the bare `{ name }` where `deriveColumns` builds a text column labelled with the name. That covered 3,889 of the cases, all of that kind. A served schema never carries a null field definition. Still, keeping objectui's own per-column builder over the returned names makes the switch exact by construction. ## Evidence **The door: `os validate --json`, CLI from this branch's source, before vs after.** The probe stack is a `defineStack` app with four parent/child pairs. "Before" rebuilt `@objectstack/lint` from the base commit's source; `ablation-dist-preflight --absent` confirmed the change was gone from `dist/`. Both runs exit 0 with `valid: true`. | field | before | after | why | |:--|:--|:--|:--| | `pr_invoice_line.line_total` | inert | not reported | site 1: `amountField` | | `pr_invoice.line_total` (unused parent twin) | not reported | inert | it was credited in the child's place | | `pr_invoice_line.total` (unused child twin) | inert | inert | control: `totalField` stays on the parent | | `pr_invoice_line.memo` | inert | inert | control: nothing reads it | | `pr_quote_line.quote` (a `lookup` FK) | inert | not reported | `relationshipField`, per key | | `pr_order_item.sku`, `.quantity` | inert | not reported | site 2: derived grid columns | | `pr_ticket_note.body` | inert | not reported | site 2, on a `lookup` relationship | | `pr_order_item.secret` (`hidden`) | inert | inert | lit control: the derivation leaves it out | **A real producer: `examples/app-showcase`, the same door, before vs after.** The finding count went from 57 to 54, and no finding was added. The three removed findings are `showcase_expense_line.category`, `.incurred_at` and `.incurred_on`, which were `carrier-only` before. `showcase_expense_line.expense_report` sets `inlineEdit: 'grid'` with no `inlineColumns`. `examples/app-crm` `opportunity_line_item.opportunity` has the same shape (I read it; I did not run it). **Tests** (final HEAD `513570747`): - `pnpm --filter @objectstack/lint exec vitest run`: 118 files, 5,467 tests passed. This includes the new `[objectstack-ai#20951]` block in `validate-field-consumers.test.ts` (15 tests) and the unchanged `[objectstack-ai#20929]` block. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 585 files, 17,222 passed and 1 todo. That run was at `bf01c7979`; the only later commit is a one-line lint change, and the new `inline-grid-columns.test.ts` (11 tests) was re-run at `513570747`. - `pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck`: both exit 0, and `check:test-typecheck` is OK for both. - The lint import of `deriveInlineGridColumns` compiles only against the rebuilt `.d.ts`, because the name does not exist in the base build. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 238 files and 3,391 tests passed. 2 files (10 tests) are NOT MEASURED; see below. **Reverse verification.** The fix was committed first. Then `validate-field-consumers.ts` was restored to the base blob `4c109d4ef`. With that source, 11 of the 15 new tests fail, and the 4 baselines and controls pass. The restore went through `git checkout HEAD --` and was checked by blob hash (`2ef0118a7`, then equal to HEAD); `git status` was clean afterwards. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` was derived from this diff (8 paths, 86 commands) and every command was run at `513570747`. `--ran` reports "86 derived, 84 run, 2 NOT-MEASURED, 0 UNRUN". 83 exited 0, including `check:generated` (all 15 artefacts up to date after `gen:api-surface` and `gen:export-origins`), `check:api-surface`, `check:export-origins`, `check:entry-nameability`, `check:dual-source-exports`, `check:spec-changes` (inside `check:generated`), `check:nul-bytes` and `check:engine-double-contract`. The rest are NOT MEASURED, listed below. **NOT MEASURED** (none of these are a verdict on this diff): - `check:dts-closure` exited 1. It names 55 packages with missing `.d.ts`. This tree built those packages with `OS_SKIP_DTS=1`, only so that `os validate` could run from source. `spec`, `lint`, `formula` and `sdui-parser` had full builds and are not named. - `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 with `PREREQUISITE NOT MET`: they need the whole workspace built with declarations. - In the CLI unit tier, `published-subpath-console.pin.test.ts` and `published-subpath-hook-body.pin.test.ts` (10 tests) fail with `ENOENT` on `packages/cli/dist/*.d.ts`. That is the same JS-only build. - The CLI `integration` tier and `packages/qa/dogfood` (an importer of `field-group-layout`, whose bytes do not change) are left to CI. - I did not merge `main`. Since the base, 14 commits have landed there, and none of them touches the 8 paths in this diff. ## Acceptance notes - **Exports.** The claim said one new export. There are three: the function, its element type and the budget constant. The constant lets objectui re-export one value instead of keeping a second `6`. No accept set moves. - **Landing sites.** Everything lands at the expected sites. The derived-grid credit also covers `subforms` entries with no `columns`, and `relationshipField` is read per key. Both are named above. - **Boundary: a `subforms` entry that names no `relationshipField`.** The renderer detects the FK itself. The lint keeps no copy of that detection, so the derived list it credits includes the FK. The FK is read anyway, as the join key, so the verdict is unchanged. - **Boundary: an explicit override.** When a parent form has an explicit `form.subforms` entry for the same child, objectui draws that entry instead of the field-derived grid. The lint still credits the field-derived grid, which matches how PR objectstack-ai#20950 already treats `inlineColumns`. This is an over-credit in that case only. - **Not filed; same family, measured at the door after this PR, handed to the seat:** - A `lookup` relationship field that sets `inlineEdit` is the inline grid's join key, yet `pr_ticket_note.ticket` is still reported `inert`. `master_detail` is exempt; `lookup` is not. - Fields drawn only in the per-row expand form (objectui `deriveFormFields`: rich text, JSON, `readonly`) are still reported. `pr_order_item.spec_sheet`, a `richtext` field, is reported `inert`, while the grid offers the expand form because the child has more form fields than grid columns. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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'snameas a reference to the child object's field.namestays inLITERAL_KEYS: it is not dropped wholesale. One helper,creditInlineGridColumns, reads the column position on its own, against the child object each carrier names:inlineColumnsdisplaywhen the field setsinlineEdit, otherwisecarrierform.subforms[].columns, and the same under eachformViewsentrychildObjectdisplayThe subform carrier is keyed by
CHILD_COLLECTION_KEYS(todaysubforms): its entries are{ childObject, columns }. The finding message now also lists an inline grid column among the consumers, and aninlineColumnsentry on a field withoutinlineEditamong the carriers.One correction to the claim: for
inlineColumnsthe child is the declaring object, not the related oneThe claim's scope line glossed the
inlineColumnschild as "the related one"; the seat corrected it in place (ruling5920513410on #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'smaster_detail/lookupfield (whosereferenceis the parent object)".collectHydratedInlineColumnErrors(packages/spec/src/stack.zod.ts): "a relationship field'sinlineColumns— the field sits on the CHILD object, so a column names a field of the object that owns the field".attachInlineSubforms(packages/app-shell/src/providers/MetadataProvider.tsx) turns a field'sinlineColumnsinto a subform withchildObject: child.name, the declaring object, on the form of the field'sreference, the parent.examples/app-showcase/src/data/objects/invoice.object.ts) putsinlineColumnsonshowcase_invoice_line.invoice(reference: 'showcase_invoice'), and all seven columns are fields ofshowcase_invoice_line.The
inlineColumnspin 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
inlineEditgates the relationship carrierThe spec's own form help text (
packages/spec/src/data/field.form.ts) saysinlineColumnsis "used only when this field sets inlineEdit", and objectui skips a field whoseinlineEditis falsy. Without it, the columns name the field and draw nothing. They are recorded as a carrier: the field readscarrier-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 afterThe probe is one parent (
gc_invoice) and four children. On each child,quantityandamountare named only by grid columns, andmemois 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.1571aedce5f849aa53f6gc_line_inlineinvoice.inlineColumnswithinlineEdit: 'grid'gc_line_noeditinvoice.inlineColumns, noinlineEditinlineColumns[i].namepath); memo: inertgc_line_formform.subforms[0].columnsgc_line_formviewformViews.edit.subforms[0].columnsBoth runs exit 0 with
valid: true.field-no-consumersfindings go from 12 to 6. The card had not measured the relationship field'sinlineColumnscarrier, and it had the same blind spot (first row, BEFORE). The AFTER reading was first taken at7ee5c56679and repeated at the merged headf849aa53f6, with identical findings.Pins and ablations
A new
describeblock invalidate-field-consumers.test.tsuses one fixture. The parent isinvand the child isline, related byline.invoice.line.qtyis named only by the column under test.line.memois named nowhere (the control).inv.qtyis a same-named parent field that nothing reads, so a column credited to the wrong object shows up asinv.qtygoing quiet.The pins:
inlineColumnswithinlineEdit;form.subforms[].columns;formViews.edit.subforms[].columns;inlineColumnswithoutinlineEdit:carrier-only, listing the column path;nameanywhere else stays a literal: a dataset measure namedqtyonlinecredits 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 emptygit diff HEAD, with the driver's own trap on EXIT, INT and TERM. The pins import the source relatively, so nodistwas involved. The tree was HEAD7ee5c56679.inlineColumns, no-inlineEdit(2)form,formViews(2)inlineColumnscredited to the related object (referenceTargetOf(field))inlineColumns, no-inlineEdit(2)inner)form,formViews(2)inlineEditgate removed (alwaysdisplay)inlineEdit(1)namedropped fromLITERAL_KEYSwholesaleform,formViews, no-inlineEdit, dataset-measure literal (4)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: blob4c109d4ef9ee== HEAD, andgit diff HEADis 0 bytes.Verification
All on the merged head
f849aa53f6(clean tree), merge base3fbf3ca617:pnpm turbo run build --filter='@objectstack/lint...' --concurrency=2: 4/4 tasks. Thenpnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: 117 files, 5444 tests passed. Thenpnpm --filter @objectstack/lint typecheck(tsc --noEmitpluscheck:test-typecheck, whosetsconfig.test.jsonis the program that compiles the changed test file): OK. The three ran joined by&&underos-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.--ranreconciliation: exit 0, 58 exited 0, and 2 are NOT MEASURED.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debtexited 3,PREREQUISITE NOT MET: each reads every workspace package's build output, and building the whole./packages/*closure islint.yml's own step, declared to CI.eslint --no-inline-config --format jsonon the two changed.tsfiles 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.mjsenables no type-aware linting (noparserOptions.project, noprojectService) and no cross-file rule (noimport/*rule), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.origin/mainhas since moved tof80e2a6dad, which touchespackages/rest. It is not re-merged, because CI builds the merge ref.Changeset
.changeset/20929-field-consumers-inline-grid-columns.mdgrades@objectstack/lintpatch. The package publishesdist(files[]), so a behaviour change owes a changeset, andskip-changesetdoes not apply. The level ispatchbecause 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 readsno, so the level axis ofcheck-changeset-no-majorstands down, and nothing is breaking, so no ADR-0087 marker is owed.Acceptance notes
os validate --json, CLI atf849aa53f6, a second probe):inlineEdit: 'grid', noinlineColumns) draws columns derived from the child's fields (objectuideriveColumns). Those fields still readinert:gc_line_derived.quantityand.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.amountFieldnames 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, readsinert.totalFieldnames a parent field, so this position cannot simply inherit the subform'schildObject.details[].columnsis not addressed here, and finding(spec):ObjectMasterDetailFormPropsSchema.detailsisz.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typedcurrencycolumn withscalepublishes green #20928 remains open. Its entries share the{ childObject, columns }shapeCHILD_COLLECTION_KEYSreads.expr,readonlyWhen,requiredWhen) are still scanned in the view object's context. Their scope is mixed (recordis the child row,parentis the header), so that is an observation, not a mechanical change.Generated by Claude Code