Skip to content

Commit c3a95d9

Browse files
fix(lint): field-no-consumers reads the synthesized layout and the upsert identity (#18381)
Fixes #17135 Clause-②: yes The rule's first run on a real application reported **12 fields, and all 12 were on screen or load-bearing that day**. Triage ruled the direction (`#17135` comment `5620725440`): *"⇒ Teach the rule about both consumer paths"*, with the guardrail *"⛔ Do not fix it by exempting `hidden` fields wholesale — a `hidden` field with genuinely no consumer is still a finding; the point is that a seeder-only identity column **has** one."* Both consumers are now read off the spec, the way the rule's existing two exemptions (`injectedColumnsFor`, `resolveDisplayField`) already are. ## 1 · The synthesized layout is a display consumer `deriveFieldGroupLayout` (`@objectstack/spec/data`, ADR-0085 §5) is the single derivation every renderer applies — form, detail, drawer, designer. It places a field by its `group` membership in the object's declared `fieldGroups`, so **no `fields: [...]` array anywhere names it**. A field the derivation puts in a **keyed** section is drawn, and is now credited as a `display` site. This follows the **previews-count-as-consumers ruling** (2026-08-10, [#7131 comment 5237224148](#7131 (comment))), which #17135 pointed at through #7427: *"A designer preview that renders a key to a human is a runtime consumer."* **The trailing untitled bucket is deliberately NOT credited**, and this is the load-bearing line in the change. `deriveFieldGroupLayout` collects every visible field that named no declared group into a trailing keyless section, and returns `null` — "callers fall back to their existing flat/auto layout" — for an object declaring no groups at all. So *every visible field renders somewhere*. Crediting "renders on the synthesized layout" in full would leave `field-no-consumers` able to report `hidden` fields and nothing else, which is switching the gate off by another name. What is credited is the author's **placement**; what is not is the fallback that would draw anything. ⚠️ **Reach, measured, for the maintainer.** On `hotcrm@965933b`, **326 of 331 declared fields** (98%) sit in a declared group. The 12 findings were 11 of them, so the acceptance set widens by 11 fields on this application — but on an app that groups everything, the display half of this rule now rests on the author's grouping decision. That is the price of the ruled direction and it is stated rather than buried. ## 2 · An upsert identity inside a carrier root is a read `bucketFor` returned `carrier` for a carrier root before anything else could ask. But a seed's `externalId` (`SeedSchema`: *"Field (or composite list of fields) matched for the uniqueness check"*) and an import mapping's `upsertKey` name the column the loader **matches on** — it reads that column on every row to decide insert from update. A seeded *value* is still a carrier; the *identity* is the one read a carrier root holds. `crm_forecast.seed_key` is exactly the shape triage described: `hidden: true`, `readonly: true`, *"Written only by the seed loader; empty on every real snapshot"*, and `src/data/forecast.seed.ts` declares `externalId: 'seed_key'`. It is consumed **by being an identity**. ⛔ **Nothing exempts `hidden` as a category.** The derivation never draws a hidden field, so path 1 gives it nothing; path 2 rescues it only when an upsert actually matches on it. The matched-pair control below is the proof. ## Acceptance evidence — the false-positive rate on a real app ⭐ Triage: *"The acceptance evidence is the false-positive rate on a real app, ⛔ not that the unit tests pass — the unit tests passed when it shipped."* **Instrument.** `hotcrm` has no checkout here, so one was cloned (`objectstack-ai/hotcrm`, public, read-only) and its authored stack dumped to JSON, with functions replaced by their source text — which is exactly what the rule does with a function node (`Function.prototype.toString.call(node)` → `scanText`), so the dump is behaviourally equivalent for this rule. **The instrument is calibrated, not assumed**: at `hotcrm@965933b` the app's own `npx objectstack lint --json` (its pinned `@objectstack/lint@17.4.0`) answers `errors: 0 · warnings: 13 · suggestions: 12` — the card's reading byte for byte — with 12 `field-no-consumers` rows, and the harness over the same tree reproduces **the same 12, same objects, same declaration paths**. **Tree.** `objectstack-ai/hotcrm@965933b` — `feat(platform): migrate onto the @objectstack/* 17.4.0 line (hotcrm#1814)`, the commit the 12 were reported on. (Not `v3.0.0`: the app removed `crm_product.tax_rate` and later gave four fields consumers and the contact form its mailing block, so neither the tag nor `main` is the tree the card measured.) ### 12 → 0, per field | # | object · field | before | cleared by | the consumer it really has | |---|---|---|---|---| | 1 | `crm_account.logo` | carrier-only, 4 | layout | `group: 'branding'` | | 2 | `crm_article_feedback.comment` | carrier-only, 4 | layout | `group: 'basic'` | | 3 | `crm_campaign.description` | carrier-only, 11 | layout | `group: 'basic'` | | 4 | `crm_campaign_member.added_date` | carrier-only, **57** | layout | `group: 'basic'` | | 5 | `crm_contact.mailing_street` | carrier-only, 5 | layout | `group: 'mailing_address'` | | 6 | `crm_contact.mailing_city` | carrier-only, 5 | layout | `group: 'mailing_address'` | | 7 | `crm_contact.mailing_state` | carrier-only, 5 | layout | `group: 'mailing_address'` | | 8 | `crm_contact.mailing_postal_code` | carrier-only, 5 | layout | `group: 'mailing_address'` | | 9 | `crm_contact.mailing_country` | carrier-only, 5 | layout | `group: 'mailing_address'` | | 10 | `crm_contract.description` | carrier-only, 9 | **either** | `group: 'basic'`, and the seed matches on it (`externalId: 'description'`) | | 11 | `crm_forecast.seed_key` | carrier-only, 12 | **identity only** | `data` seed `externalId: 'seed_key'`; `hidden`, so the layout gives it nothing | | 12 | `crm_quote_line_item.line_number` | carrier-only, 23 | layout | `group: 'basic'` | **Nothing remains: 12 → 0.** Every one of the 12 carries a `group` naming a declared `fieldGroups` entry, which is what the app's own CHANGELOG says about them — *"every one of them still renders to users through its `fieldGroup` on the synthesized detail layout, which the rule does not resolve."* The per-field column is not asserted, it is **ablated**. Each path was removed from the committed file by anchored replacement (absent or non-unique anchor exits non-zero), the mutation was proven on disk by literal counts before reading any verdict, and each leg restored with `git checkout HEAD -- PATH` proven by blob identity against the `HEAD` blob plus an empty `git diff HEAD`: | leg | live paths | findings | |---|---|---| | `main` | neither | **12** | | ABLATED-A | identity only | **10** (the layout accounts for 11, overlapping on #10) | | ABLATED-B | layout only | **1** — `crm_forecast.seed_key` (the identity accounts for 2) | | HEAD | both | **0** | ### ⭐ The other half: does a real defect still get reported? ⛔ Showing the 12 disappear proves nothing on its own — switching the gate off does that too. Six deliberately unconsumed fields were injected into **the same real hotcrm stack**, nothing reading, displaying, seeding, translating or granting any of them: | control | shape | reported? | |---|---|---| | `crm_contact.ctl_ungrouped` | visible, no `group` → trailing flat bucket | ✅ reported | | `crm_contact.ctl_undeclared_group` | `group` naming a group the object never declared | ✅ reported | | `crm_contact.ctl_hidden_grouped` | `hidden`, in a **declared** group | ✅ reported | | `crm_contact.ctl_hidden_readonly` | `hidden` + `readonly`, in a declared group | ✅ reported | | `ctl_plain.ctl_orphan` | visible, on an object declaring **no** `fieldGroups` | ✅ reported | | `ctl_seeded.ctl_not_seed_key` | `hidden` + `readonly`, **no upsert matches on it** | ✅ reported | | `ctl_seeded.ctl_seed_key` | byte-identical to the row above, **matched by a seed `externalId`** | silent — correctly | The last two are a **matched pair** on the same object: identical declarations, one variable between them. Only the one an upsert matches on goes quiet. `hidden` is exempt nowhere. ## Tests, gates - `pnpm --filter @objectstack/lint test` — **103 files, 3831 passed, 5 skipped**, including 11 new pins on both paths and on each boundary that keeps the rule judging (undeclared group, flat bucket, hidden field, an object with no groups, array-shaped field maps, composite `externalId`, mapping `upsertKey`, the matched pair). - `pnpm --filter @objectstack/lint typecheck` — exit 0. - `pnpm --filter '@objectstack/lint^...' build` — exit 0 (dependency closure). - Changeset: `patch` on `@objectstack/lint`. Measured, not assumed — the new message text is present in all four published bundles named by `files[]` (`dist/index.js`, `dist/index.cjs`, `dist/runtime.js`, `dist/runtime.cjs`) alongside a pre-existing-text positive control. ## Acceptance notes Observed while measuring, **not filed** — no PR or author is heading for either file: - `SeedSchema` aliases `key` / `externalkey` / `naturalkey` onto `externalId`, and `MappingSchema` aliases `key` / `matchOn` / `externalId` / `externalIdField` onto `upsertKey`. On a **pre-parse** stack an author using an alias gets no credit from path 2. `key` in particular is in `LITERAL_KEYS` and is skipped outright, for good reasons unrelated to this rule. Post-parse — which is what `os lint` feeds — aliases are already normalized, so the live surface is covered. - `SeedSchema.externalId` defaults to `'name'`. On a post-parse stack every seeded object therefore credits its `name` column, which is usually already exempt as the ADR-0079 title field and is in any case genuinely matched by the loader. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b81ab6 commit c3a95d9

3 files changed

Lines changed: 239 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
`field-no-consumers` now reads two consumers that name the field nowhere in metadata — a declared field group placing it on the synthesized layout, and the column a seed or import mapping matches on (#17135).
6+
7+
The rule's first run on a real application reported 12 fields, and all 12 were on screen or load-bearing that day. Both misses are now read off the spec rather than off a hand-kept list, the way the rule's other two exemptions already are:
8+
9+
- **The synthesized layout.** `deriveFieldGroupLayout` (ADR-0085 §5) is the one derivation every renderer applies — form, detail, drawer and designer — and it places a field by its `group` membership, not by naming it in a `fields: [...]` array. A field the derivation puts in a **declared** group is therefore drawn, and is credited as a display site. The derivation's trailing untitled bucket is deliberately **not** credited: it collects everything the author did not place, so crediting it would hand the display verdict to every visible field in every app.
10+
- **An upsert identity.** A carrier root holds values that are written and labels that are carried, and the root decided the bucket before anything else could ask. But a seed's `externalId` and an import mapping's `upsertKey` name the column the loader **matches on** — it reads that column on every row to decide insert from update. A seeder-only identity column is consumed by being an identity.
11+
12+
⛔ Nothing exempts `hidden` as a category. A `hidden` field no upsert matches on and nothing reads is still reported, and a `hidden` field in a declared group earns nothing from the layout, because the derivation never draws one.
13+
14+
Measured on `hotcrm@965933b` (the tree the 12 were reported on): **12 findings → 0**, with the synthesized layout accounting for 11 and the upsert identity for 2 (they overlap on one field). Against the same application with six deliberately unconsumed fields injected — ungrouped, undeclared-group, hidden-in-a-group, hidden + readonly, a field on an object declaring no groups, and the matched pair of a seeded identity against an identical declaration nothing matches on — all six are still reported and only the identity goes quiet.

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,122 @@ describe('validateFieldConsumers (#15922)', () => {
331331
});
332332
});
333333

334+
describe('the synthesized layout is a display consumer (#17135)', () => {
335+
/** An object whose only consumer root is an empty view — nothing NAMES a field. */
336+
const grouped = (fields: AnyRec, fieldGroups: unknown): AnyRec => ({
337+
objects: [{ name: 'o', fieldGroups, fields }],
338+
views: [{ list: { data: { object: 'o' }, columns: [] } }],
339+
});
340+
const reported = (stack: AnyRec): string[] => validateFieldConsumers(stack).map((f) => f.field);
341+
342+
it('a field a declared group places on the layout is drawn, so it is not reported', () => {
343+
expect(
344+
validateFieldConsumers(
345+
grouped(
346+
{ name: { type: 'text' }, street: { type: 'text', group: 'address' } },
347+
[{ key: 'address', label: 'Address' }],
348+
),
349+
),
350+
).toEqual([]);
351+
});
352+
353+
it('only a DECLARED group places anything — an undeclared key is not a placement', () => {
354+
expect(
355+
reported(
356+
grouped(
357+
{ name: { type: 'text' }, street: { type: 'text', group: 'no_such_group' } },
358+
[{ key: 'address', label: 'Address' }],
359+
),
360+
),
361+
).toEqual(['street']);
362+
});
363+
364+
it('never the trailing flat bucket: an ungrouped field beside a grouped one is still judged', () => {
365+
expect(
366+
reported(
367+
grouped(
368+
{ name: { type: 'text' }, street: { type: 'text', group: 'address' }, loose: { type: 'text' } },
369+
[{ key: 'address', label: 'Address' }],
370+
),
371+
),
372+
).toEqual(['loose']);
373+
});
374+
375+
it('an object declaring no field groups keeps every verdict it had', () => {
376+
expect(reported(grouped({ name: { type: 'text' }, loose: { type: 'text' } }, undefined))).toEqual(['loose']);
377+
expect(reported(grouped({ name: { type: 'text' }, loose: { type: 'text' } }, []))).toEqual(['loose']);
378+
});
379+
380+
it('a hidden field earns nothing here — the derivation never draws one', () => {
381+
expect(
382+
reported(
383+
grouped(
384+
{
385+
name: { type: 'text' },
386+
street: { type: 'text', group: 'address' },
387+
secret: { type: 'text', group: 'address', hidden: true },
388+
},
389+
[{ key: 'address', label: 'Address' }],
390+
),
391+
),
392+
).toEqual(['secret']);
393+
});
394+
395+
it('reaches the array-shaped field map too', () => {
396+
expect(
397+
validateFieldConsumers({
398+
objects: [
399+
{
400+
name: 'o',
401+
fieldGroups: [{ key: 'address', label: 'Address' }],
402+
fields: [{ name: 'name', type: 'text' }, { name: 'street', type: 'text', group: 'address' }],
403+
},
404+
],
405+
views: [{ list: { data: { object: 'o' }, columns: [] } }],
406+
}),
407+
).toEqual([]);
408+
});
409+
});
410+
411+
describe('an upsert identity inside a carrier root is a read (#17135)', () => {
412+
/** `x` is declared, `hidden` and `readonly` — the seeder-only identity shape. */
413+
const seeded = (extra: AnyRec): AnyRec => ({
414+
objects: [{ name: 'o', fields: { name: { type: 'text' }, x: { type: 'text', hidden: true, readonly: true } } }],
415+
views: [{ list: { data: { object: 'o' }, columns: [] } }],
416+
...extra,
417+
});
418+
const reported = (stack: AnyRec): string[] => validateFieldConsumers(stack).map((f) => f.field);
419+
420+
it("a seed's externalId names the column the loader matches on", () => {
421+
expect(reported(seeded({ data: [{ object: 'o', mode: 'upsert', externalId: 'x', records: [{ x: 'k1' }] }] }))).toEqual([]);
422+
});
423+
424+
it('a composite externalId credits every member', () => {
425+
expect(
426+
validateFieldConsumers({
427+
objects: [{ name: 'o', fields: { name: { type: 'text' }, a: { type: 'text' }, b: { type: 'text' } } }],
428+
views: [{ list: { data: { object: 'o' }, columns: [] } }],
429+
data: [{ object: 'o', mode: 'upsert', externalId: ['a', 'b'], records: [{ a: '1', b: '2' }] }],
430+
}),
431+
).toEqual([]);
432+
});
433+
434+
it("an import mapping's upsertKey is the same read", () => {
435+
expect(
436+
reported(seeded({ mappings: [{ name: 'm', targetObject: 'o', mode: 'upsert', upsertKey: ['x'] }] })),
437+
).toEqual([]);
438+
});
439+
440+
it('⛔ hidden is not exempt — the matched pair differs only in the identity role', () => {
441+
// Same declaration, same object, no upsert matching on it.
442+
expect(reported(seeded({ data: [{ object: 'o', records: [{ x: 'k1' }] }] }))).toEqual(['x']);
443+
// A seeded VALUE stays a carrier even when an upsert matches on ANOTHER column.
444+
expect(
445+
reported(seeded({ data: [{ object: 'o', mode: 'upsert', externalId: 'name', records: [{ x: 'k1' }] }] })),
446+
).toEqual(['x']);
447+
});
448+
});
449+
334450
describe('registry wiring', () => {
335451
const entry = AUTHORING_RULES.find((r) => r.name === 'validateFieldConsumers');
336452

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

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@
4747
* must clean up; none of them is evidence that anything reads the field.
4848
* A seeded value nothing reads is precisely the shape being hunted.
4949
*
50+
* ## Two consumers that name the field nowhere
51+
*
52+
* A metadata reference is not the only way a field is read, and the first real
53+
* app to take this rule reported 12 fields that were all on screen or
54+
* load-bearing. Both paths are read off the SPEC, not off a hand-kept list:
55+
*
56+
* - **The synthesized layout** ({@link deriveFieldGroupLayout}, ADR-0085 §5).
57+
* An object's `fieldGroups` plus a field's `group` membership are what the
58+
* form, detail, drawer and designer surfaces all render when no `*.page.ts`
59+
* names the field. The author DID place the field; the placement is spelled
60+
* as a group key, so no `fields: [...]` array anywhere mentions it. The
61+
* derivation is the platform's own, so this rule credits exactly what a
62+
* renderer draws — including the hidden-field exclusion, which is why a
63+
* `hidden` field earns nothing here. Only a KEYED section counts: the
64+
* derivation's trailing untitled bucket is the flat fallback that collects
65+
* everything the author did NOT place, so crediting it would credit every
66+
* visible field on every object and leave the rule judging nothing.
67+
* - **A seed's or an import mapping's upsert identity**
68+
* ({@link CARRIER_IDENTITY_SEGMENTS}). A carrier root is where written and
69+
* carried values live, but `externalId` / `upsertKey` names the column the
70+
* loader MATCHES ON — it reads every row's value to decide insert from
71+
* update (`SeedSchema.externalId`: "Field (or composite list of fields)
72+
* matched for the uniqueness check"). A seeder-only identity column is
73+
* therefore consumed BY BEING an identity: it is `hidden` and `readonly`
74+
* precisely so no real row can acquire one, and a consumer anywhere else
75+
* would defeat it. Nothing here exempts `hidden` as such — a `hidden` field
76+
* that no upsert matches on and nothing reads is still reported.
77+
*
5078
* A field with at least one behaviour OR display site is consumed and gets no
5179
* finding — a field that is only drawn is the ordinary state of most fields
5280
* (`phone` on a contact), not a defect; HotCRM's ledger listed `display-only`
@@ -91,7 +119,7 @@
91119
* maintainer's call, not this rule's.
92120
*/
93121

94-
import { resolveDisplayField } from '@objectstack/spec/data';
122+
import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data';
95123
import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
96124
import { collectionEntries } from './collection-entries.js';
97125
import { recordsOf } from './object-graph.js';
@@ -175,6 +203,23 @@ export const CONSUMER_ROOTS: readonly string[] = [
175203
*/
176204
export const CARRIER_ROOTS: readonly string[] = ['translations', 'data', 'mappings', 'permissions'];
177205

206+
/**
207+
* The one thing inside a carrier root that is a READ: the column an upsert
208+
* MATCHES ON. `externalId` is the canonical spelling on a seed
209+
* (`SeedSchema.externalId`, "Field (or composite list of fields) matched for
210+
* the uniqueness check") and `upsertKey` the canonical spelling on an import
211+
* mapping (`MappingSchema`, which aliases `externalId`/`matchOn`/`key` onto
212+
* it). Both are already in {@link BEHAVIOUR_SEGMENTS}; a carrier root just
213+
* never got to ask, because the root decided the bucket first.
214+
*
215+
* A seeded VALUE is still a carrier — the loader writes it and nothing reads
216+
* it back. The identity is the opposite: every replay reads the column on
217+
* every row to decide insert from update. That is what makes a seeder-only
218+
* identity column consumed while being invisible: it has exactly one reader,
219+
* and the reader is the loader.
220+
*/
221+
const CARRIER_IDENTITY_SEGMENTS: ReadonlySet<string> = new Set(['externalId', 'upsertKey']);
222+
178223
/** Roots whose sites are display by default; `BEHAVIOUR_SEGMENTS` earn behaviour back. */
179224
const DISPLAY_ROOTS: ReadonlySet<string> = new Set(['views', 'pages', 'apps']);
180225

@@ -345,7 +390,12 @@ class ConsumerLedger {
345390
}
346391

347392
function bucketFor(root: string, segments: readonly string[], leafKey: string): SiteKind {
348-
if (CARRIER_ROOTS.includes(root)) return 'carrier';
393+
// An upsert identity inside a carrier root is the one read there: the loader
394+
// matches rows on that column. Everything else a carrier root holds is a
395+
// value it writes or a label it carries.
396+
if (CARRIER_ROOTS.includes(root)) {
397+
return segments.some((s) => CARRIER_IDENTITY_SEGMENTS.has(s)) ? 'behaviour' : 'carrier';
398+
}
349399
if (PROSE_KEYS.has(leafKey)) return 'carrier';
350400
if (segments.some((s) => BEHAVIOUR_SEGMENTS.has(s))) return 'behaviour';
351401
if (DISPLAY_ROOTS.has(root)) return 'display';
@@ -511,14 +561,61 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
511561
}
512562
}
513563

514-
/** Build the display-name meta the spec's ladder reads, whatever shape `fields` was authored in. */
515-
function displayMetaOf(obj: AnyRec, fields: { rec: AnyRec; path: string }[]): DisplayNameObjectMeta {
564+
/** The field map the spec helpers read, whatever shape `fields` was authored in. */
565+
function fieldMapOf(fields: readonly { rec: AnyRec; path: string }[]): Record<string, AnyRec> {
516566
const map: Record<string, AnyRec> = {};
517567
for (const { rec } of fields) {
518568
const n = strName(rec.name);
519569
if (n) map[n] = rec;
520570
}
521-
return { nameField: strName(obj.nameField), displayNameField: strName(obj.displayNameField), fields: map };
571+
return map;
572+
}
573+
574+
/** Build the display-name meta the spec's ladder reads, whatever shape `fields` was authored in. */
575+
function displayMetaOf(obj: AnyRec, fields: { rec: AnyRec; path: string }[]): DisplayNameObjectMeta {
576+
return {
577+
nameField: strName(obj.nameField),
578+
displayNameField: strName(obj.displayNameField),
579+
fields: fieldMapOf(fields),
580+
};
581+
}
582+
583+
/**
584+
* Credit every field the SYNTHESIZED layout places in a declared group.
585+
*
586+
* `deriveFieldGroupLayout` is the platform's own derivation (ADR-0085 §5) — the
587+
* one implementation every renderer applies — so what it returns is what a form
588+
* or detail surface draws when no authored page names the field. Running it
589+
* here rather than re-reading `fieldGroups` by hand is the same discipline the
590+
* other two exemptions follow: the verdict moves when the renderer moves.
591+
*
592+
* Only a KEYED section is a site. The derivation's trailing untitled bucket
593+
* collects what the author did NOT place — every visible field that named no
594+
* group, plus, on an object declaring no groups at all, every field there is.
595+
* Crediting it would hand the display verdict to every visible field in every
596+
* app and leave this rule able to report `hidden` fields only.
597+
*/
598+
function creditFieldGroupLayout(
599+
ledger: ConsumerLedger,
600+
obj: AnyRec,
601+
objectName: string,
602+
fields: readonly { rec: AnyRec; path: string }[],
603+
): void {
604+
const sections = deriveFieldGroupLayout({ fieldGroups: obj.fieldGroups, fields: fieldMapOf(fields) });
605+
if (sections === null) return;
606+
const pathOf = new Map<string, string>();
607+
for (const { rec, path } of fields) {
608+
const n = strName(rec.name);
609+
if (n !== undefined) pathOf.set(n, path);
610+
}
611+
for (const section of sections) {
612+
if (section.key === undefined) continue;
613+
for (const field of section.fields) {
614+
const path = pathOf.get(field);
615+
if (path === undefined) continue;
616+
ledger.record(objectName, field, { root: 'objects', path: `${path}.group`, kind: 'display' });
617+
}
618+
}
522619
}
523620

524621
function listPaths(paths: readonly string[]): string {
@@ -547,6 +644,7 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
547644
const fields = collectionEntries(obj.fields, `${objPath}.fields`);
548645
const injected = injectedColumnsFor(obj);
549646
const titleField = resolveDisplayField(displayMetaOf(obj, fields));
647+
creditFieldGroupLayout(ledger, obj, objectName, fields);
550648
for (const { rec: field, path: fieldPath } of fields) {
551649
const fieldName = strName(field.name);
552650
if (!fieldName) continue;
@@ -603,11 +701,14 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
603701
message:
604702
`field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` +
605703
`it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` +
606-
`hook or action names it. A translation label, a seed value, an import mapping, a permission grant ` +
607-
`or a flow that only WRITES it is a carrier, not a consumer. ${verdictClause}${sharedClause}`,
704+
`hook or action names it, no declared field group places it on the synthesized layout, and no ` +
705+
`seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` +
706+
`target, a permission grant or a flow that only WRITES it is a carrier, not a consumer. ` +
707+
`${verdictClause}${sharedClause}`,
608708
hint:
609709
`Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` +
610-
`validation, a flow node, a dataset dimension — or remove the declaration` +
710+
`validation, a flow node, a dataset dimension, or a \`group\` naming one of this object's ` +
711+
`declared \`fieldGroups\` so the synthesized layout draws it — or remove the declaration` +
611712
(carriers.length > 0 ? ` together with its ${carriers.length} carrier site(s) listed above` : '') +
612713
`. Ignore this if the field is read only by an API client, by a hook or package this stack does not ` +
613714
`carry, or by a Studio-authored view. Roots scanned: ${CONSUMER_ROOTS.join(', ')} (consumers) · ` +

0 commit comments

Comments
 (0)