diff --git a/.changeset/21439-field-consumers-analytics-paths.md b/.changeset/21439-field-consumers-analytics-paths.md new file mode 100644 index 00000000000..f4d331929ef --- /dev/null +++ b/.changeset/21439-field-consumers-analytics-paths.md @@ -0,0 +1,16 @@ +--- +'@objectstack/lint': patch +--- + +`field-no-consumers` no longer calls a field "inert" when a dataset or cube member reads it through a relationship path (#21439). + +Clause-②: no + +`os validate`, `os build` and `os lint` warned "Verdict: inert — no site of any kind names it" for every field an analytics member reached through a path such as `account.revenue`, so an author following the warning would delete a column a measure reads. The four slots that name a column are a dataset dimension's and measure's `field` and a cube dimension's and measure's `sql`. Each one now credits every field its path reads: the lookup on the base object, each intermediate lookup, and the column on the object the last hop reaches. + +- **Hops resolve the way the analytics door resolves them.** A cube hop goes through the join the cube declares for it, else the lookup's `reference`. A dataset hop goes through the `reference` its compiler joins through, and only where the dataset's `include` declares the join. +- **A bare cube column is credited too.** Before, a cube member's `sql: 'amount'` credited nothing, because a cube names its object in its own `sql`. +- **A path the door refuses reads nothing.** Examples: a join the dataset's `include` does not declare, a hop that names no relationship, a column the last object does not have. Each field such a path names is now listed as a carrier site that a removal must clean (`carrier-only`), not as a reader. +- **A path the object graph cannot judge** credits the fields it does resolve. An example is a lookup to an object this stack does not define. + +Nothing new is refused, and the rule stays a warning. One warning can appear where there was none: a path the door refuses through a lookup named after its target object (`account.revenue`, with `account` a lookup to the object `account`). The old text scan credited its column as read. It is now reported `carrier-only`, beside the error the refused path already carries. diff --git a/packages/lint/src/validate-dataset-measure-aggregates.ts b/packages/lint/src/validate-dataset-measure-aggregates.ts index 0d56d082134..d9a92d45a6d 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.ts @@ -465,8 +465,14 @@ function refusedMeasureFinding( * ⛔ No hop is walked here: this only picks the object the shared walk starts * on. A bare column and the row wildcard `'*'` have no hop, and `'*'` resolves * to nothing. + * + * [#21439] Exported for `field-no-consumers` (`validate-field-consumers.ts`), + * which credits every field a cube member's path reads by asking this + * function about each prefix of the path — so a cube hop is resolved one way + * in this package, and that is the door's way. Not re-exported from the + * package barrel. */ -function resolveCubeColumn( +export function resolveCubeColumn( graph: ObjectGraph, cube: AnyRec, baseObject: string, diff --git a/packages/lint/src/validate-field-consumers.test.ts b/packages/lint/src/validate-field-consumers.test.ts index 0bffff8e49c..7f47613122d 100644 --- a/packages/lint/src/validate-field-consumers.test.ts +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -1,6 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { CubeSchema } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; import { CARRIER_ROOTS, CONSUMER_ROOTS, @@ -1263,3 +1266,223 @@ describe('[#21091] validateFieldConsumers — an inline collection reads its joi }); }); }); + +describe('[#21439] validateFieldConsumers — an analytics member credits every field its column path reads', () => { + /** + * `fx_ledger.account` → `fx_account`, `fx_account.region` → `fx_region`: each + * lookup is named apart from its target, so a hop resolves through the + * relationship's `reference` and never through an object that happens to + * share its name. `name` is each object's title field, so it is exempt. + */ + const OBJECTS = [ + { name: 'fx_ledger', fields: { name: { type: 'text' }, amount: { type: 'currency' }, account: { type: 'lookup', reference: 'fx_account' } } }, + { name: 'fx_account', fields: { name: { type: 'text' }, revenue: { type: 'currency' }, region: { type: 'lookup', reference: 'fx_region' } } }, + { name: 'fx_region', fields: { name: { type: 'text' }, zone: { type: 'number' } } }, + ]; + /** Every field the rule judges on {@link OBJECTS}, before anything reads one. */ + const JUDGED = [ + 'fx_ledger.amount', 'fx_ledger.account', 'fx_account.revenue', 'fx_account.region', 'fx_region.zone', + ]; + + /** `object.field` → verdict, for every field the rule reports. */ + const verdicts = (s: AnyRec): Record => + Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict])); + const reportedOf = (s: AnyRec): string[] => Object.keys(verdicts(s)).sort(); + const inertExcept = (read: readonly string[]): string[] => JUDGED.filter((k) => !read.includes(k)).sort(); + + /** + * The `include` every pin dataset declares: `account.region` declares both + * joins (ADR-0021: "a.b" implicitly includes "a"), and as a dotted path it + * names no field the general walk credits — the bare-column cell is the + * control that shows it credits nothing on its own. (A bare entry, + * `include: ['account']`, is a field name the general walk has always + * credited, which would hide the member's own credit of the lookup.) + */ + const INCLUDE = ['account.region']; + + /** + * One builder per analytics slot that names a column, keyed by where the spec + * declares it — the census below holds this key set equal to the spec's. + */ + const SLOTS: Record AnyRec> = { + 'datasets › dimensions › field': (column) => ({ + datasets: [{ name: 'ds', object: 'fx_ledger', include: INCLUDE, dimensions: [{ name: 'dim', field: column, type: 'number' }], measures: [] }], + }), + 'datasets › measures › field': (column) => ({ + datasets: [{ name: 'ds', object: 'fx_ledger', include: INCLUDE, dimensions: [], measures: [{ name: 'mea', aggregate: 'max', field: column }] }], + }), + 'analyticsCubes › dimensions › sql': (column) => ({ + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', dimensions: { dim: { label: 'Dim', type: 'number', sql: column } }, measures: {} }], + }), + 'analyticsCubes › measures › sql': (column) => ({ + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', dimensions: {}, measures: { mea: { label: 'Mea', type: 'max', sql: column } } }], + }), + }; + + /** Each shape a column takes, with every field the analytics door reads for it. */ + const SHAPES: [string, string, string[]][] = [ + ['a bare column', 'amount', ['fx_ledger.amount']], + ['a one-hop path', 'account.revenue', ['fx_ledger.account', 'fx_account.revenue']], + ['a two-hop path', 'account.region.zone', ['fx_ledger.account', 'fx_account.region', 'fx_region.zone']], + ]; + + /** + * The column slots the spec declares on the two analytics roots, read off its + * own schemas: every string whose pattern is the analytics column path (the + * `[A-Za-z_]…(?:\.…)*` path half both slot patterns share). A slot the spec + * adds there lands in this list and fails the equality below until it has a + * builder — and a reader in `creditAnalyticsColumns`. + */ + const specColumnSlots = (): string[] => { + const COLUMN_PATH_SOURCE = /\[A-Za-z_\]\[A-Za-z0-9_\]\*\(\?:\\\.\[A-Za-z_\]\[A-Za-z0-9_\]\*\)\*/; + const out: string[] = []; + const walk = (doc: AnyRec, node: unknown, trail: readonly string[], seen: Set): void => { + if (!node || typeof node !== 'object' || seen.has(node)) return; + seen.add(node); + const rec = node as AnyRec; + if (typeof rec.pattern === 'string' && COLUMN_PATH_SOURCE.test(rec.pattern)) out.push(trail.join(' › ')); + // A reused shape is emitted once under `$defs` and pointed at. + if (typeof rec.$ref === 'string' && rec.$ref.startsWith('#/$defs/')) { + walk(doc, (doc.$defs as AnyRec | undefined)?.[rec.$ref.slice('#/$defs/'.length)], trail, seen); + } + // Only `properties` names an authored key; every other edge keeps the trail. + for (const [key, value] of Object.entries((rec.properties as AnyRec | undefined) ?? {})) { + walk(doc, value, [...trail, key], new Set(seen)); + } + for (const edge of ['items', 'additionalProperties']) walk(doc, rec[edge], trail, seen); + for (const edge of ['anyOf', 'oneOf', 'allOf']) { + if (Array.isArray(rec[edge])) for (const arm of rec[edge] as unknown[]) walk(doc, arm, trail, seen); + } + }; + for (const [root, schema] of [['analyticsCubes', CubeSchema], ['datasets', DatasetSchema]] as const) { + const doc = z.toJSONSchema(schema as unknown as z.ZodType, { io: 'input', unrepresentable: 'any' }) as AnyRec; + walk(doc, doc, [root], new Set()); + } + return [...new Set(out)].sort(); + }; + + it('the pin covers every column slot the spec declares on a dataset and a cube', () => { + const slots = specColumnSlots(); + expect(slots.length).toBeGreaterThanOrEqual(4); + expect(slots).toEqual(Object.keys(SLOTS).sort()); + }); + + it('baseline: with no analytics member reading them, every judged field is inert', () => { + expect(verdicts({ objects: OBJECTS, views: [{ list: { data: { object: 'fx_ledger' }, columns: [] } }] })).toEqual( + Object.fromEntries(JUDGED.map((k) => [k, 'inert'])), + ); + }); + + describe('the enumeration pin: every slot × every shape credits each field the path reads', () => { + const cells = Object.entries(SLOTS).flatMap(([slot, build]) => + SHAPES.map(([shape, column, read]): [string, string, AnyRec, string[]] => [slot, shape, { objects: OBJECTS, ...build(column) }, read]), + ); + + it('pins 4 slots × 3 shapes', () => { + expect(cells).toHaveLength(12); + }); + + it.each(cells)('%s × %s: exactly the fields it reads leave the report', (_slot, _shape, s, read) => { + expect(reportedOf(s)).toEqual(inertExcept(read)); + }); + }); + + describe('a cube hop resolves through its declared join first, else the reference', () => { + const ALT = { name: 'fx_account_alt', fields: { name: { type: 'text' }, revenue: { type: 'currency' } } }; + const cube = (joins: AnyRec | undefined): AnyRec => ({ + objects: [...OBJECTS, ALT], + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', ...(joins ? { joins } : {}), dimensions: {}, measures: { mea: { label: 'M', type: 'sum', sql: 'account.revenue' } } }], + }); + + it('a declared join reaches its own object, and the reference target stays unread', () => { + const v = verdicts(cube({ account: { name: 'fx_account_alt' } })); + expect(v['fx_ledger.account']).toBeUndefined(); + expect(v['fx_account_alt.revenue']).toBeUndefined(); + expect(v['fx_account.revenue']).toBe('inert'); + }); + + it('with no join for the hop, the reference target is read and the other object is not', () => { + const v = verdicts(cube(undefined)); + expect(v['fx_account.revenue']).toBeUndefined(); + expect(v['fx_account_alt.revenue']).toBe('inert'); + }); + }); + + describe('a path the door refuses names its fields as carriers, never as reads', () => { + const dataset = (field: string, include: string[]): AnyRec => ({ + objects: OBJECTS, + datasets: [{ name: 'ds', object: 'fx_ledger', include, dimensions: [], measures: [{ name: 'mea', aggregate: 'max', field }] }], + }); + + it('a dataset path whose join `include` does not declare: every field it names is carrier-only', () => { + const findings = byPath(validateFieldConsumers(dataset('account.revenue', []))); + for (const declaration of ['objects[0].fields.account', 'objects[1].fields.revenue']) { + expect(findings[declaration].verdict).toBe('carrier-only'); + expect(findings[declaration].carriers).toEqual(['datasets[0].measures[0].field']); + } + }); + + it('declaring the first hop does not declare the second: the two-hop path is still refused', () => { + // `account` stays read here, by the bare `include` entry the general walk + // credits — not by the refused member path. + const v = verdicts(dataset('account.region.zone', ['account'])); + expect(v).toEqual({ 'fx_ledger.amount': 'inert', 'fx_account.revenue': 'inert', 'fx_account.region': 'carrier-only', 'fx_region.zone': 'carrier-only' }); + }); + + it('declared, the same paths are read', () => { + expect(reportedOf(dataset('account.revenue', INCLUDE))).toEqual(inertExcept(['fx_ledger.account', 'fx_account.revenue'])); + expect(reportedOf(dataset('account.region.zone', INCLUDE))).toEqual( + inertExcept(['fx_ledger.account', 'fx_account.region', 'fx_region.zone']), + ); + }); + + it.each([ + ['a column the last hop does not declare', 'account.revnue', { 'fx_ledger.account': 'carrier-only', 'fx_account.revenue': 'inert' }], + ['a hop that names no field', 'acount.revenue', { 'fx_ledger.account': 'inert', 'fx_account.revenue': 'inert' }], + ['a hop through a field that is not a relationship', 'amount.revenue', { 'fx_ledger.amount': 'carrier-only', 'fx_account.revenue': 'inert' }], + ])('a cube member through %s', (_label, sql, expected) => { + const v = verdicts({ + objects: OBJECTS, + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', dimensions: { dim: { label: 'D', type: 'string', sql } }, measures: {} }], + }); + expect(v).toMatchObject(expected); + }); + + it('a lookup named like its target object gains no read from a refused path (the slot is not text-scanned)', () => { + const v = verdicts({ + objects: [ + { name: 'fx_ledger', fields: { name: { type: 'text' }, fx_account: { type: 'lookup', reference: 'fx_account' } } }, + { name: 'fx_account', fields: { name: { type: 'text' }, revenue: { type: 'currency' } } }, + ], + datasets: [{ name: 'ds', object: 'fx_ledger', dimensions: [], measures: [{ name: 'mea', aggregate: 'max', field: 'fx_account.revenue' }] }], + }); + expect(v).toEqual({ 'fx_ledger.fx_account': 'carrier-only', 'fx_account.revenue': 'carrier-only' }); + }); + }); + + describe('a path the graph cannot judge credits the fields it resolves', () => { + it('a hop to an object this stack does not define: the lookup on the base object is read', () => { + const v = verdicts({ + objects: [{ name: 'fx_ledger', fields: { name: { type: 'text' }, manager: { type: 'lookup', reference: 'ext_person' } } }], + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', dimensions: { dim: { label: 'D', type: 'string', sql: 'manager.email' } }, measures: {} }], + }); + expect(v).toEqual({}); + }); + + it('the row wildcard reads no field and credits none', () => { + const v = verdicts({ + objects: OBJECTS, + analyticsCubes: [{ name: 'cube', sql: 'fx_ledger', dimensions: {}, measures: { rows: { label: 'Rows', type: 'count', sql: '*' } } }], + }); + expect(Object.keys(v).sort()).toEqual([...JUDGED].sort()); + }); + + it('a base object this stack does not define leaves the slot to the general walk, as before', () => { + const v = verdicts({ + objects: OBJECTS, + analyticsCubes: [{ name: 'cube', sql: 'ext_ledger', dimensions: { dim: { label: 'D', type: 'number', sql: 'account.revenue' } }, measures: {} }], + }); + expect(Object.keys(v).sort()).toEqual([...JUDGED].sort()); + }); + }); +}); diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 0feea678abb..4b4654ff79f 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -37,8 +37,9 @@ * * - **behaviour** — the field makes something happen: a formula or roll-up, * a validation predicate, a view FILTER / sort / grouping, a flow node, a - * hook or action body, a dataset dimension or measure, a widget filter, a - * sharing-rule condition. + * hook or action body, a dataset or cube dimension or measure (every + * field its column path reads — {@link creditAnalyticsColumns}), a widget + * filter, a sharing-rule condition. * - **display** — the field is drawn: a view column, a form section, a page * binding, an inline grid column ({@link creditInlineGridColumns}), * `highlightFields`, `searchableFields`, an index. @@ -116,6 +117,26 @@ * (no site of any kind). One id, one fix sentence — an author acts the same * way on both, and a split would invite reading `carrier-only` as fine. * + * ## An analytics member's column path reads every field on it + * + * [#21439] A dataset dimension's or measure's `field` and a cube dimension's or + * measure's `sql` name a COLUMN, and a column path names more than one field: + * `account.region.code` reads the `account` lookup on the base object, the + * `region` lookup on the object it reaches, and `code` on the object the last + * hop reaches — the field-level read gate in `service-analytics` + * (`fieldsOfColumnSql`) names exactly those. A text scan sees one dotted + * token and credits none of them — and on a cube not even a bare column, since + * a cube names its object in its own `sql`, which {@link contextOf} does not + * read as an object context. So {@link creditAnalyticsColumns} owns these four + * slots, bare names included, and the general walk skips them. Each hop is + * resolved the way the analytics door resolves it, by the resolvers this + * package already shares: a cube's by `resolveCubeColumn` (its declared join + * for the hop, else the lookup's `reference`), a dataset's by + * `resolveFieldPath` (the `reference` its compiler joins through). A path the + * door reads credits every field on it; a path the door refuses is a carrier + * for each field it names, and one the graph cannot judge credits the fields + * it can resolve — {@link creditColumnPath} says which is which. + * * ## Advisory, deliberately — and the boundaries, stated * * A consumer can legitimately live outside this stack: an API client, a hook @@ -161,8 +182,16 @@ import { import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; import { referenceTargetOf } from '@objectstack/spec/data'; import { collectionEntries } from './collection-entries.js'; -import { recordsOf } from './object-graph.js'; +import { + indexObjectGraph, + isUnjudgeable, + joinablePrefixes, + recordsOf, + resolveFieldPath, + type FieldPathVerdict, +} from './object-graph.js'; import { injectedColumnsFor } from './system-fields.js'; +import { resolveCubeColumn } from './validate-dataset-measure-aggregates.js'; export const FIELD_NO_CONSUMERS = 'field-no-consumers'; @@ -472,6 +501,12 @@ class ConsumerLedger { readonly fieldMapByObject = new Map>(); /** `object.field` → sites */ readonly sites = new Map(); + /** + * [#21439] Config paths of the analytics column slots + * {@link creditAnalyticsColumns} read. The general walk skips them: one + * account of a slot, the one that resolves its path. + */ + readonly analyticsColumns = new Set(); /** Tokens that looked like a field but resolved to no object — counted, never dropped. */ unresolved = 0; private mentionRe: RegExp | undefined; @@ -852,6 +887,8 @@ function walk( return; } if (typeof node === 'string') { + // [#21439] An analytics column slot was read whole, path and all. + if (ledger.analyticsColumns.has(path)) return; scanText(ledger, node, ctx, root, path, segments, leafKey); return; } @@ -1061,6 +1098,128 @@ function creditFieldGroupLayout( } } +/** + * [#21439] The two analytics member kinds, on a dataset and on a cube alike. + * Each names its column in one slot: a dataset member's `field`, a cube + * member's `sql` — the same value at two depths, since the dataset compiler + * copies `field` into the `sql` of the cube member it compiles to. + */ +const ANALYTICS_MEMBER_KINDS = ['dimensions', 'measures'] as const; + +/** The row wildcard a `count` measure aggregates: it reads no field value. */ +const ROW_WILDCARD = '*'; + +/** + * [#21439] Credit every field one analytics column path reads. + * + * The fields a path reads are the ones the analytics door's field-level read + * gate names for it (`fieldsOfColumnSql`, `service-analytics`): each hop's + * relationship field on the object before it, and the column on the object + * the last hop reaches. Each is the LEAF of one prefix of the path, so + * `resolve` — the door's own resolution, see {@link creditAnalyticsColumns} — + * is asked about every prefix: `account`, `account.region`, + * `account.region.code`. No hop is walked here. + * + * How the fields are recorded depends on whether the door reads the path: + * + * - **Read** — every prefix resolves, and on a dataset the relationship + * prefix is declared in `include`: each field is a consumer, bucketed as + * any dimension or measure is. + * - **Refused** — a prefix resolves to nothing the graph declares (a hop + * that names no field or a field that is not a relationship, a column + * that does not exist), or the dataset's `include` does not declare the + * join (`compileDataset` refuses the dataset, `dataset-field-not-included` + * reports it). The door reads nothing, so each field the path does name + * is a carrier a removal must clean — the rule's word for a site that + * names a field and reads it nowhere, as an `inlineColumns` entry with no + * `inlineEdit` is — and none is credited as read. + * - **Not judgeable** — the graph cannot answer for some prefix (an object + * this stack does not define, a hop through an injected column, a + * relationship with no target), and none is refused. The fields it does + * resolve are credited as read: the door joins through them before it + * reaches the part the graph cannot see, and "cannot answer" is never + * evidence that nothing reads them. + * + * Only fields this stack declares are recorded; an injected column it does + * not declare is no declaration to judge. + */ +function creditColumnPath( + ledger: ConsumerLedger, + column: string, + resolve: (prefix: string) => FieldPathVerdict | undefined, + joinDeclared: boolean, + root: string, + path: string, + segments: readonly string[], + leafKey: string, +): void { + ledger.analyticsColumns.add(path); + if (column === ROW_WILDCARD) return; + const hops = column.split('.'); + const named: { object: string; field: string }[] = []; + let refused = !joinDeclared; + for (let i = 1; i <= hops.length; i++) { + const verdict = resolve(hops.slice(0, i).join('.')); + if (verdict?.kind === 'ok') named.push({ object: verdict.object, field: verdict.field }); + else if (!isUnjudgeable(verdict)) refused = true; + } + const kind: SiteKind = refused ? 'carrier' : bucketFor(root, segments, leafKey); + for (const { object, field } of named) { + if (ledger.declares(object, field)) ledger.record(object, field, { root, path, kind }); + } +} + +/** + * [#21439] Credit every field the analytics members of this stack read + * through their column slots — a dataset dimension's and measure's `field`, a + * cube dimension's and measure's `sql` — bare names and relationship paths + * alike, through {@link creditColumnPath}. Each door is read the way it reads + * the path, with the resolver `validate-dataset-measure-aggregates.ts` already + * judges the same slots with: + * + * - a **dataset** joins only what its `include` declares (ADR-0021 D-C, + * prefixes included — {@link joinablePrefixes}), and its compiler reaches + * each hop through the relationship's `reference`: `resolveFieldPath` on + * the dataset's `object`; + * - a **cube** joins every hop of a member's path, through the join it + * declares for that hop, else the relationship's `reference`: + * `resolveCubeColumn` on the object its `sql` names. + * + * A dataset or cube whose base object this stack does not define with a field + * map is skipped, as that rule skips it, and its slots stay with the general + * walk. + */ +function creditAnalyticsColumns(ledger: ConsumerLedger, stack: AnyRec): void { + const graph = indexObjectGraph(stack); + for (const { rec: ds, path: dsPath } of collectionEntries(stack.datasets, 'datasets')) { + const object = strName(ds.object); + if (!object || !graph.get(object)) continue; + const joinable = joinablePrefixes(ds.include); + for (const kind of ANALYTICS_MEMBER_KINDS) { + for (const { rec: member, path } of collectionEntries(ds[kind], `${dsPath}.${kind}`)) { + const column = strName(member.field); + if (column === undefined) continue; + const cut = column.lastIndexOf('.'); + const joinDeclared = cut < 0 || joinable.has(column.slice(0, cut)); + const resolve = (prefix: string) => resolveFieldPath(graph, object, prefix); + creditColumnPath(ledger, column, resolve, joinDeclared, 'datasets', `${path}.field`, [kind, 'field'], 'field'); + } + } + } + for (const { rec: cube, path: cubePath } of collectionEntries(stack.analyticsCubes, 'analyticsCubes')) { + const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!object || !graph.get(object)) continue; + for (const kind of ANALYTICS_MEMBER_KINDS) { + for (const { rec: member, path } of collectionEntries(cube[kind], `${cubePath}.${kind}`)) { + const column = strName(member.sql); + if (column === undefined) continue; + const resolve = (prefix: string) => resolveCubeColumn(graph, cube, object, prefix); + creditColumnPath(ledger, column, resolve, true, 'analyticsCubes', `${path}.sql`, [kind, 'sql'], 'sql'); + } + } + } +} + function listPaths(paths: readonly string[]): string { return paths.join(', '); } @@ -1107,6 +1266,9 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { if (name && object) ledger.datasetObject.set(name, object); } + // [#21439] Before the general walk, which skips the slots this reads. + creditAnalyticsColumns(ledger, stack); + for (const { rec: obj, path: objPath } of objectEntries) { const objectName = strName(obj.name); if (!objectName || !ledger.fieldsByObject.has(objectName)) continue; @@ -1146,12 +1308,14 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { path, message: `field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` + - `it: no view column, inline grid column, form section, page binding, flow node, dataset, widget, ` + - `formula, validation, hook or action names it, no declared field group places it on the ` + + `it: no view column, inline grid column, form section, page binding, flow node, dataset or cube ` + + `member, widget, formula, validation, hook or action names it, no declared field group places it on the ` + `synthesized layout, and no ` + `seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` + - `target, a permission grant, a flow that only WRITES it, or an \`inlineColumns\` entry on a ` + - `relationship field that does not set \`inlineEdit\` (no grid is drawn) is a carrier, not a consumer. ` + + `target, a permission grant, a flow that only WRITES it, an \`inlineColumns\` entry on a ` + + `relationship field that does not set \`inlineEdit\` (no grid is drawn), or a dataset or cube ` + + `member path the analytics door refuses (a hop or column that does not resolve, or a join the ` + + `dataset's \`include\` does not declare) is a carrier, not a consumer. ` + `${verdictClause}${sharedClause}`, hint: `Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` +