|
47 | 47 | * must clean up; none of them is evidence that anything reads the field. |
48 | 48 | * A seeded value nothing reads is precisely the shape being hunted. |
49 | 49 | * |
| 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 | + * |
50 | 78 | * A field with at least one behaviour OR display site is consumed and gets no |
51 | 79 | * finding — a field that is only drawn is the ordinary state of most fields |
52 | 80 | * (`phone` on a contact), not a defect; HotCRM's ledger listed `display-only` |
|
91 | 119 | * maintainer's call, not this rule's. |
92 | 120 | */ |
93 | 121 |
|
94 | | -import { resolveDisplayField } from '@objectstack/spec/data'; |
| 122 | +import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data'; |
95 | 123 | import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; |
96 | 124 | import { collectionEntries } from './collection-entries.js'; |
97 | 125 | import { recordsOf } from './object-graph.js'; |
@@ -175,6 +203,23 @@ export const CONSUMER_ROOTS: readonly string[] = [ |
175 | 203 | */ |
176 | 204 | export const CARRIER_ROOTS: readonly string[] = ['translations', 'data', 'mappings', 'permissions']; |
177 | 205 |
|
| 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 | + |
178 | 223 | /** Roots whose sites are display by default; `BEHAVIOUR_SEGMENTS` earn behaviour back. */ |
179 | 224 | const DISPLAY_ROOTS: ReadonlySet<string> = new Set(['views', 'pages', 'apps']); |
180 | 225 |
|
@@ -345,7 +390,12 @@ class ConsumerLedger { |
345 | 390 | } |
346 | 391 |
|
347 | 392 | 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 | + } |
349 | 399 | if (PROSE_KEYS.has(leafKey)) return 'carrier'; |
350 | 400 | if (segments.some((s) => BEHAVIOUR_SEGMENTS.has(s))) return 'behaviour'; |
351 | 401 | if (DISPLAY_ROOTS.has(root)) return 'display'; |
@@ -511,14 +561,61 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj |
511 | 561 | } |
512 | 562 | } |
513 | 563 |
|
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> { |
516 | 566 | const map: Record<string, AnyRec> = {}; |
517 | 567 | for (const { rec } of fields) { |
518 | 568 | const n = strName(rec.name); |
519 | 569 | if (n) map[n] = rec; |
520 | 570 | } |
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 | + } |
522 | 619 | } |
523 | 620 |
|
524 | 621 | function listPaths(paths: readonly string[]): string { |
@@ -547,6 +644,7 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { |
547 | 644 | const fields = collectionEntries(obj.fields, `${objPath}.fields`); |
548 | 645 | const injected = injectedColumnsFor(obj); |
549 | 646 | const titleField = resolveDisplayField(displayMetaOf(obj, fields)); |
| 647 | + creditFieldGroupLayout(ledger, obj, objectName, fields); |
550 | 648 | for (const { rec: field, path: fieldPath } of fields) { |
551 | 649 | const fieldName = strName(field.name); |
552 | 650 | if (!fieldName) continue; |
@@ -603,11 +701,14 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { |
603 | 701 | message: |
604 | 702 | `field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` + |
605 | 703 | `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}`, |
608 | 708 | hint: |
609 | 709 | `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` + |
611 | 712 | (carriers.length > 0 ? ` together with its ${carriers.length} carrier site(s) listed above` : '') + |
612 | 713 | `. Ignore this if the field is read only by an API client, by a hook or package this stack does not ` + |
613 | 714 | `carry, or by a Studio-authored view. Roots scanned: ${CONSUMER_ROOTS.join(', ')} (consumers) · ` + |
|
0 commit comments