fix(lint,spec): field-no-consumers reads an inline relationship's join key, its per-row expand form, a detail entry's formFields and a record:line_items block against the child (#21091) - #21256
objectstack-fleet[bot] merged 12 commits into
Conversation
…row expand form Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…il entry's formFields Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…ns and child keys against its childObject Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…join key Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…w-form exports Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…eld-consumers-inline-closeout
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 137 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 44ea63c259adbd29d820abcdf7959833789ce8a1 && git checkout 44ea63c259adbd29d820abcdf7959833789ce8a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c7396f1a99bf3ec6358186796a9a880a762a2efd 3acb46c64755069bf6db5c62398309c67731158c && git checkout -B drift-repro c7396f1a99bf3ec6358186796a9a880a762a2efd && git merge --no-ff 3acb46c64755069bf6db5c62398309c67731158c
node scripts/docs-audit/affected-docs.mjs --json c7396f1a99bf3ec6358186796a9a880a762a2efd
|
Contract reviewServed-tier: Inputs: card #21091 (body and all five comments: the triage grade, the two pointers, the claim, the dev report), PR #21256 (body, the eight-file list, the net diff ① Derived judgmentsThe four lint positions, each against the pinned renderer.
F1 — one derived judgment is WRONG as stated (position 3, the omitted- The two spec exports.
② Semver level
③ Boundary flags
Check-runs on the head (polled once a minute until every run concluded; final read 2026-10-01T23:24Z): 35 runs, 32 Implemented-by: VERDICT: FAIL The four positions, the two exports, the artefact moves and both semver levels are right; the FAIL is on the closeout's completeness (flags A and B, a class (a) candidate and a mis-context read, both unmeasured and carrier-less on the family's closeout card) and on one derived judgment the diff states and pins against what the pinned renderer does (F1). |
…ffer predicate, and read a record:line_items block's sort and filter against its childObject The default object form a row opens with no field list stays uncredited, and is pinned as the boundary. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…eld-consumers-inline-closeout
…s row form, and a record:line_items block's sort and filter Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
…eld-consumers-inline-closeout
…form its landed props row declares Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Second review of this PR. Inputs: card #21091 (body and all seven comments: the triage grade, the two pointers, the claim, both dev reports and the seat's response to the first record), #21281 (the point card for flag A), PR #21256 (body, the eight-file list, both comments including the round-1 record ① Derived judgmentsThe round-2 delta. Between the round-1 head
Round-1 RIGHT judgments, re-confirmed on this head. Carried on the evidence that the spec-side files are byte-identical to
② Semver level
③ Boundary flags
Check-runs on the head (final read 2026-10-02T01:29Z): 42 runs, all Implemented-by: VERDICT: PASS The three round-1 failure points are resolved on this head: F1 is decided exactly by the renderer's own predicate and stated truthfully on both paths; flag B is measured, read against the child in both filter forms and pinned; flag A is measured, confirmed, bounded in the module note and a pinned test, carved out of the enumeration pin's sentence, and carried by #21281. The four positions, the two exports, the artefact moves and both semver levels stand as judged in round 1. Notes for the seat, none a condition: the changeset's declared-mode carrier clause could be narrowed to "where the grid can be counted"; the card's closing comment should quote the narrowed pin sentence and name #21281; the detail entry's |
…can be counted Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Third review of this PR. Inputs: card #21091 (body and all eight comments: the triage grade, the two pointers, the claim, the three dev reports and the seat's response to the first record), PR #21256 (body, the eight-file list, its three comments: the docs-drift advisory ① Derived judgmentsThe round-3 delta is the changeset alone: RIGHT, as the dev states. The two edited sentences, each against the code on this head.
Carried from the second record ( Accept-set and public-surface changes this diff implies. No Zod schema moves on any head of this PR, so no accept set changes: the changeset's "no schema accepts anything new or refuses anything new" is TRUE. The public surface gains exactly two function exports on ② Semver level
③ Boundary flags
Check-runs on the head (polled from the first read at 2026-10-02T01:38Z until every run concluded; final read 2026-10-02T01:59Z): 35 runs, all Implemented-by: VERDICT: PASS The round-3 delta is two sentences of changeset prose, both now TRUE against the code on this head; the seven other paths are byte-identical to the head the second record passed, so its ①②③ carry unchanged; both semver levels and the |
Fixes #21091
Clause-②: yes
What changes
field-no-consumers(packages/lint/src/validate-field-consumers.ts) called several kinds of in-use child field "inert". This PR corrects them. The per-row expand form goes through a new derivation the spec owns, asderiveInlineGridColumns(PR #21089) did for the grid.lookup's inline-grid join key. Alookupormaster_detailfield that setsinlineEdit(with a resolvablereference) is now recorded as a behaviour read at itsinlineEdit, whether the grid's columns are authored or derived. The renderer loads the child rows filtered on it and stamps it on save (objectuiMasterDetailForm.tsx1321 and 552, at the.objectui-shapin31971ff1e28f).master_detailwas already exempt;lookupnow reads the same.@objectstack/spec/dataexports live inpackages/spec/src/data/inline-grid-columns.ts. They sit in the same module asderiveInlineGridColumnsbecause they share its system-name and sort-name sets.deriveInlineRowFormFields(def, { relationshipField?, exclude? }): string[]is objectui'sderiveFormFieldsstated as the spec's rule. It skips the same names as the grid, plus the relationship field,exclude,system/hiddenfields and the computed types (formula,summary,rollup,autonumber,auto_number). It keepsreadonlyfields and every type a cell cannot edit.isInlineRowFormOffered({ inlineMode?, formFields?, columns? }): booleanis the renderer's offer condition atMasterDetailForm.tsx:847:inlineMode === 'form', or more form fields than grid columns.inlineColumns, or asubforms/detailsentry with nocolumns. Adetailsentry is excluded when it authorsformFields, because an authored list replaces the derived one. No copy of objectui's rule lives in the lint.5936875973): a detail entry's authoredformFields. These names are read against the entry'schildObject; the general walk no longer reads them against the parent.isInlineRowFormOffereddecides whether the list is drawn, and a list the form is never offered for is a carrier. The renderer resolves an entry one of two ways, and the lint feeds the predicate what each way feeds the expand control (round 2, F1):relationshipFieldand at least one column (MasterDetailForm.tsx967, 1048–1052). Nothing is derived. The form factor is the declaredinlineMode, or none at all, so the predicate decides exactly. With an omittedinlineMode, the form is offered only when the list is longer than the grid.inlineModeis kept. An omitted one is resolved from the relationship'sinlineEdit, else from the child's shape. The lint does not reproduce that resolution, so with an omitted mode the list is credited as drawn. With a declared mode, the predicate decides whenever the grid can be counted.5940763140): arecord:line_itemsblock. Its rawpropertiesare read as one child entry: authoredcolumns[].name,relationshipFieldandamountFieldagainstchildObject, withtotalFieldleft on the parent. objectuiLineItemsPanel.tsxat the pin reads these keys this way. It derives no grid and offers no row form.RecordLineItemsPropsis not imported. Round 2, flag B: the block'ssortandfilterare now walked in thechildObject's context.LineItemsPanelapplies them to the child query (366–379, 516–521). Since PR feat(spec)!: record:line_items gets its ComponentPropsMap row, its columns are the inline grid column contract, and the showcase Tasks grid binds by name (#21142) #21244 landedRecordLineItemsProps, the contract declaresfilteras the ViewFilterRule array. The panel's lowering also takes the field-keyed map, and the lint reads whichever is authored. Both forms are pinned.Fixture triage (round 1). Six tests in the
[#20951]site-2 block pinned that a derived carrier leaves thejsonandreadonlychild fields inert. The derived row form now draws them, so their expected sets were re-judged:DERIVEDkeeps only thehiddenfield, andNO_ROW_FORMkeeps the old set for the three cases that draw no derived row form.Round 2: the contract review
5942628181(FAIL) and what this head does about itformFieldslist as drawn wheneverinlineModewas omitted. On the kept-as-authored path that is false: the renderer leaves the mode undefined, and line 847's count decides. The lint now decides that path withisInlineRowFormOffered({ inlineMode: undefined, formFields, columns }). The docblock and test titles state both paths. The test's own fixture (relationshipFieldand two columns, one form field) now pinsitm.notesascarrier-only.sort/filterwere inert, and the same-named parent fields were credited in their place. It is pinned with two enumeration rows (sort[].field,filter[].field) and three unit tests.Flag A: a row form opened with no field list
This happens when an authored grid is in the
formfactor and has noformFields. That covers authoredinlineColumnswithinlineEdit: 'form', or withinlineEdit: trueand a child the smart default sends toform, and a detail entry kept as authored withinlineMode: 'form'. The renderer then opens the child'sObjectFormwith nofields(MasterDetailForm.tsx1821). That form draws the child's generated field set (ObjectForm.tsx961) throughfilterSystemFields(autoLayout.ts231): every field except the server-owned names,hiddenfields andreadonlyfields, laid out byfieldGroupswhen the child declares any.Probe reading (all three heads below):
pg_line.note_g,ph_line.body_h,ph_line.note_handpi_line.note_iare reported inert, and the renderer draws them.pg_line.ro_g(readonly) andpg_line.hid_g(hidden) are reported inert, and the renderer does not draw them either. The reach is confirmed.Why it does not close here:
ObjectForm's generated set, the server-owned roster from objectuisanitize.ts, thehiddenandreadonlyfilters, and thefieldGroupslayout. That is a new cross-repo contract with its own differential and its own objectui consumer.inlineEdit: truearm also needs the smart default (resolveInlineMode: the form-only types, the two-rich-field threshold and the eight-field threshold) promoted to the spec.creditFieldGroupLayout: only a KEYED section counts), because the platform's default form draws every visible field of every object. The probe's own controlpa_order.buyeris drawn bypa_order's default form and reported by design. Crediting the same form when a parent opens it as a row editor makes the verdict depend on which door opens it. That is a decision about the rule's contract, not an omission in this diff.So the module note and a pinned boundary test state the position: an authored grid in the
formfactor with noformFieldskeeps those child fields reported. The enumeration pin's sentence now reads "the form the spec derives, and an authoredformFieldslist the form is offered for". The position goes to a point card the seat files. The report carries the options.The spec functions against objectui's rule (round 1, unchanged)
The differential ran the spec functions against
deriveFormFieldsand line 847's expression, both read from the pinned files (deriveMasterDetail.tsblob90aa44c9,MasterDetailForm.tsxblob7a96a130). The offer expression was evaluated from the source text.deriveInlineRowFormFields: 100,004 cases, 0 mismatches. The cases were objectui's 4 fixtures plus 100,000 random definitions: null and string field definitions, array-shapedfields, non-spec type names, truthy and falsy flags, prototype-ish names, and randomrelationshipField/exclude.isInlineRowFormOffered: 300,012 cases, 0 mismatches.Evidence
The door:
os validate --jsonon adefineStackprobe stack. Three heads were measured, each built from source:a7d9768e, the card's base, in a separate worktree;1d1258a5, the round-1 head;a87f03e1, this head.All three were run with the same probe file (its
filterblocks in the rule-array form). All three exit 0 withvalid: true.field-no-consumersfindings: 32, 17, 17.pa_order_note.order/pa_ticket_line.ticket/pb_case_comment.case_ref/ph_line.header(lookup+inlineEdit)pb_invoice_line.notes/.config/.frozen,pb_case_comment.body,pb_memo_line.long_notepc_line.memo(detailformFields,inlineMode: 'form')pc_header.memo(parent twin)pd_line.memo2(declaredgrid, 1 field vs 2 columns)pf_line.memo_f(kept as authored, noinlineMode, 1 field vs 2 columns)pe_line.qty_e/.note_e/.header/.amt(record:line_itemscolumns and keys)pe_header.amt(parent twin)pk_line.srt_k/.flt_k/.flt2_k(blocksort, two blocks'filter)pk_header.srt_k/.flt_k(parent twins)pg_line.note_g,ph_line.body_h/.note_h,pi_line.note_i(default form)pg_line.ro_g/.hid_g(readonly/hidden)pc_line.position(detailsortField)pa_order.buyer,pb_invoice_line.secret,pe_line.unused_e,pk_line.unused_k(— means not reported.)
A real producer:
examples/app-showcase. There are 52 findings ata7d9768eand 52 ata87f03e1, with identical verdict sets. PR #21244 changed itsrecord:line_itemspage in between, and that block has nosortorfilter.Tests at
a87f03e1(the head of this PR):pnpm --filter @objectstack/lint exec vitest run: 119 files, 5,572 tests passed. Thevalidate-field-consumers.test.tsfile has 126 tests, including the[#21091]block: positions 1 to 4, the flag-A boundary, and the enumeration pin's 13 rows, each paired with a control.pnpm --filter @objectstack/spec exec vitest run --project local: 597 files, 17,483 passed and 1 todo.pnpm --filter @objectstack/cli exec vitest run --project unit: 243 files, 3,439 passed, with the CLI closure built with declarations. The integration tier is declared to CI.pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck: both exit 0, andcheck:test-typecheckis OK for both.@objectstack/spec,@objectstack/lint, and the downstream lint consumer@objectstack/cli.Reverse verification and ablations. Each was committed first, made through
scripts/ablation-replace.mjsor a blob restore, and restored to the HEAD blob withgit diff HEADempty. All were predicted red, and all were red.3efd1236failed 29 of 115 tests. The spec row form made to dropreadonlyfailed 2 of 20.a87f03e1, flag B: the panel'ssort/filterread switched off failed exactly the 5 flag-B tests (3 tests and 2 pin rows).a87f03e1, F1: the kept-as-authored decision switched off failed exactly the F1 carrier test.Gates.
node scripts/pm/dispatch-gates.mjs --commandsderived 8 paths and 86 commands ata87f03e1. Every one was run.--ranreports "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN", and all 86 exited 0.check:generated: all 15 artefacts are up to date. The two spec shards gain exactly the two names each.Base.
origin/mainmoved under generated files three times and was merged each time throughscripts/pm/os-regen-merge.sh: at1d1258a5,ee505255and6084ce01. The last merge brought PR #21244'sRecordLineItemsProps. No merge owed a regeneration, and the delta againstorigin/mainis exactly this PR's 8 paths. Since then,origin/mainhas moved by 4 commits, none of which touches a generated artefact or one of the 8 paths.Acceptance notes
deriveInlineRowFormFieldsandisInlineRowFormOffered. No schema accepts or refuses anything new.deriveFormFields(childSchema, opts)equalsderiveInlineRowFormFields(childSchema, opts)on every measured input.isInlineRowFormOffered({ inlineMode: d.inlineMode, formFields: d.formFields, columns: d.columns }).sortField(pointer position 3), probe reading.pc_line.positionis inert at all three heads. At the pin the renderer only stamps it (GridField.tsx:735). It loads rows with$filterand$topand no ordering, so it never reads the field. objectui0a3e5409fretired the authored key after the pin, and no lint read was added. The general walk still readsdetails[].sortFieldagainst the parent. That reading leaves with the key at the next.objectui-shabump.inlineModeon the DERIVED path (the renderer's smart default), and a derived grid with norelationshipField, both credit an authored list as drawn.form.subformsoverride, and asubformsentry with norelationshipField.@objectstack/spec: minor, becauseClause-②: yestakes at least minor.@objectstack/lint: patchfollows PR fix(lint,spec): field-no-consumers reads a subform entry's child keys against the child, and credits a derived inline grid through deriveInlineGridColumns #21089 and PR feat(spec)!: an object-master-detail-form block's detail entries are a strict shape whose columns are the inline grid column contract (#20928) #21215. The lint bullets now state the round-2 reads. The rule's message and hint text are unchanged.Generated by Claude Code