Repository navigation
Commit e07566b
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)
Fixes #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
#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 `[#20951]` block in
`validate-field-consumers.test.ts` (15 tests) and the unchanged
`[#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 #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>1 parent d19da5d commit e07566b
8 files changed
Lines changed: 690 additions & 5 deletions
File tree
- .changeset
- packages
- lint/src
- spec
- api-surface
- export-origins
- src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
669 | 669 | | |
670 | 670 | | |
671 | 671 | | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
0 commit comments