diff --git a/.changeset/list-view-dotted-field-refs.md b/.changeset/list-view-dotted-field-refs.md new file mode 100644 index 0000000000..f33f6d99b4 --- /dev/null +++ b/.changeset/list-view-dotted-field-refs.md @@ -0,0 +1,46 @@ +--- +"@objectstack/lint": minor +--- + +fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282) + +An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped +as `minor`, matching the level that landing and the two family landings before it +(#14105, #14148) were given. + +#14107 judges only the HEAD segment of a list-view field reference, so a dotted +path whose head resolves to a real relationship field (`columns: [{ field: +'owner.name' }]`) passed `os validate` and `os build` clean while every query +door a list view reaches refuses it by name. That half was recorded in the rule's +docblock and pinned in tests rather than closed, because its failure mode is the +opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on +the first fetch. This is the ruled resolution of that half, as a second finding +class with its own id, `list-view-field-dotted`, so one class can be suppressed +or filtered without silencing the other (the convention +`validate-sortable-fields` and `validate-searchable-fields` already follow). + +The class is scoped by the DOOR, not by the position table, because some +list-view positions are read client-side out of the fetched row and walk a dotted +path perfectly well: + +- **Projection** — `columns[]`, in both authored spellings. Clients build the + `$select` projection from them, and both doors refuse a dotted entry + unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary, + `assertProjectionFieldsExist` at the REST ingress). +- **Filter** — the view's `filter`, its `tabs[].filter`, its + `userFilters.tabs[].filter`, and the two positions declaring which names an end + user may filter on (`filterableFields`, `userFilters.fields`). Here the rule + asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371 + carve-outs the doors serve — structured/JSON heads, array-valued heads, heads + whose type is unreadable — are NOT refused at author time. + +Deliberately excluded, each measured rather than assumed: +`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer +resolves IN MEMORY over already-fetched rows through walkers that split on `.` +(the spec describes the former as "Record field / dot-path", and the measurement +agreed); and every renderer binding that reaches no query door, which stays +unjudged rather than acquiring a verdict nobody measured. + +Existing behaviour is untouched: a dotted path whose head resolves to nothing +still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the +shipped example corpus was measured at zero findings both before and after. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f76e13717e..0a75743900 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -434,9 +434,14 @@ export type { // timeline / gallery / map / tree blocks). Resolution goes through the shared // `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that // module's dotted-path note. +// [#14282] The same rule's SECOND finding class: a dotted reference at a +// position whose name reaches a query door (the `$select` projection, or the +// compiled filter), where that door refuses it by name — the loud-failing half +// #14107 recorded and left open. export { validateListViewFieldRefs, LIST_VIEW_FIELD_UNKNOWN, + LIST_VIEW_FIELD_DOTTED, } from './validate-list-view-field-refs.js'; export type { ListViewFieldRefFinding, diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index f9ca4791d0..c384fff45d 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -82,6 +82,19 @@ export interface GraphField { * tolerant consumer Prime Directive #12 refuses, so only `reference` is read. */ reference?: string; + /** + * The declared `multiple: true` flag, when the author wrote one. + * + * Read here because the dotted-path verdict a caller may reach for + * ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a + * function of BOTH `type` and `multiple`: an array-valued head is + * deliberately unjudged there, since a numeric-index dotted path genuinely + * reaches into it on two of three backends. A caller handed only `type` + * would have to re-derive the flag from the raw stack, which is the second + * copy this module exists to prevent. Additive (#14282): every existing + * consumer that ignores the key keeps its verdicts byte-for-byte. + */ + multiple?: boolean; } /** @@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null { fields.set(n, { type: typeof f.type === 'string' ? f.type : undefined, reference: strName(f.reference), + multiple: f.multiple === true ? true : undefined, }); } if (names.size === 0) return null; diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index 787c8b57fc..c073df1849 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js'; import { validateListViewFieldRefs, LIST_VIEW_FIELD_UNKNOWN, + LIST_VIEW_FIELD_DOTTED, type ListViewFieldRefFinding, } from './validate-list-view-field-refs.js'; import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js'; @@ -39,6 +40,12 @@ const OBJECTS = [ { name: 'cover', type: 'image', label: 'Cover' }, { name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' }, { name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' }, + // [#14282] The three head shapes the FILTER door treats differently. + // `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on + // memory and mongodb); `score` is virtual; `tags` is array-valued. + { name: 'payload', type: 'json', label: 'Payload' }, + { name: 'score', type: 'formula', label: 'Score' }, + { name: 'tags', type: 'text', multiple: true, label: 'Tags' }, ], }, { @@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => { /** * The recorded dotted-path decision (see the rule's module docblock): the HEAD * segment is judged and relationship hops are NOT walked, because a list view - * compiles no joins and all three runtime doors refuse a dotted reference. - * Both halves are pinned — the half that reports, and the half that stays - * deliberately silent — so a later "improvement" that starts walking hops has - * to delete a test that says why. + * compiles no joins and the runtime doors refuse a dotted reference. + * + * ⚠️ This block used to pin BOTH halves — the half that reports, and a half + * that stayed deliberately silent (`owner.name` and `title.x` in `columns` + * passing clean). #14282 is the card that half was recorded for, and it ruled + * the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The + * cases were rewritten rather than deleted, so the pair still reads as one + * decision — what changed is which class each lands in, not whether the rule + * has an opinion. The `#14282` block below carries the new half in full. */ -describe('#14107 — dotted paths', () => { +describe('#14107 — dotted paths, HEAD-segment resolution', () => { it('a dotted path whose HEAD resolves to nothing is reported', () => { const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] }))); expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); expect(findings[0].message).toContain('"ownr"'); // The author reads back what they typed, not only the segment judged. expect(findings[0].message).toContain('ownr.name'); expect(findings[0].message).toContain('compiles'); }); - it('a dotted path whose head resolves is left to the runtime doors', () => { + it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => { + // `owner` resolves, `duly_person` has no `nope`. Were hops walked, this + // would be a `field-unknown` on `duly_person`. It is not: the finding is + // the #14282 dotted class, which never mentions the leaf at all. + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] }))); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + expect(findings[0].message).not.toContain('duly_person'); + }); +}); + +/** + * [#14282] The SECOND finding class: a dotted reference at a position whose + * name reaches a query door, where that door refuses it by name. + * + * The scoping is by DOOR, not by position — see the rule's module note. So + * this block pins three things and not one: which positions report, which + * deliberately do not (the measured client-side ones, `gantt.quickFilters` + * first among them), and that the FILTER positions ask the same + * `classifyDottedFilterHead` the runtime door asks, rather than refusing what + * the door serves. + */ +describe('#14282 — a dotted reference the PROJECTION door refuses', () => { + it('a dotted `columns[].field` whose head resolves is now reported', () => { const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] }))); - expect(findings).toEqual([]); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].list.columns[0].field'); + expect(findings[0].message).toContain('owner.name'); + expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths'); + expect(findings[0].hint).toContain('"owner"'); + }); + + it('the bare-string `columns[]` spelling is judged too', () => { + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] }))); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + expect(findings[0].path).toBe('views[0].list.columns[0]'); }); - it('a dotted path through a non-relationship head is also left alone', () => { - // `title` is a text field; `title.x` is refused at query time, not here. + it('the projection door has NO head carve-out, so a scalar head reports too', () => { + // `title` is a text field. `assertProjectionHasNoDottedPaths` filters on + // `f.includes('.')` alone — the head's type never enters that door. const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] }))); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + }); + + it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => { + // The #8371 carve-out is the FILTER door's, not the projection door's. + // Getting this wrong in either direction is the whole point of scoping the + // class by door rather than by "a list view compiles no joins". + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] }))); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + }); + + it('an undotted column is untouched by the new class', () => { + expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]); + }); +}); + +describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => { + const filterOn = (field: string): AnyRec => ({ + filter: [{ field, operator: 'equals', value: 'x' }], + }); + + it('a relation head is refused — it stores an id, not an embedded document', () => { + const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name')))); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].list.filter[0].field'); + expect(findings[0].message).toContain('lookup'); + expect(findings[0].message).toContain('can only match zero records'); + }); + + it('a virtual head is refused — nothing materialises a column to reach into', () => { + const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x')))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('computed'); + }); + + it('a plain scalar head is refused — there is nothing beneath it', () => { + const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x')))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('single scalar value'); + }); + + it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => { + // Live on driver-memory and driver-mongodb (2 rows in the #8371 + // measurement table). Refusing it at author time would delete a working + // capability on two of three backends — the exact fail-closed drift the + // shared classifier exists to prevent. + expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]); + }); + + it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => { + expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]); + }); + + it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => { + // `created_at` resolves through skip 3 with no readable type, and + // `classifyDottedFilterHead` answers `null` for an unreadable head. + expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]); + }); + + it('the tab and user-filter tab presets are judged on the same axis', () => { + const findings = validateListViewFieldRefs( + stackWith( + mutate({ + tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }], + userFilters: { + fields: [{ field: 'status' }], + tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }], + }, + }), + ), + ); + expect(idsOf(findings).sort()).toEqual([ + 'views[0].list.tabs[0].filter[0].field', + 'views[0].list.userFilters.tabs[0].filter[0].field', + ]); + expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true); + }); + + it('the two positions that DECLARE end-user filterable names are judged', () => { + // objectui folds the resulting conditions into the fetched query + // (`buildEffectiveFilter`), so these names become filter keys. + const findings = validateListViewFieldRefs( + stackWith( + mutate({ + filterableFields: ['owner.name'], + userFilters: { fields: [{ field: 'parent.title' }] }, + }), + ), + ); + expect(idsOf(findings).sort()).toEqual([ + 'views[0].list.filterableFields[0]', + 'views[0].list.userFilters.fields[0].field', + ]); + expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true); + }); +}); + +describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => { + it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => { + // Measured, and it went the other way round from the rest of the card. + // The spec describes the position as "Record field / dot-path", and + // objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the + // already-fetched rows, resolving each through a walker that splits on `.` + // and steps through the record object (`resolveFilterKey`). No query door + // is involved, so nothing refuses it. + const findings = validateListViewFieldRefs( + stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })), + ); expect(findings).toEqual([]); }); + + it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => { + const findings = validateListViewFieldRefs( + stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); + }); + + it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => { + const findings = validateListViewFieldRefs( + stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })), + ); + expect(findings).toEqual([]); + }); + + it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => { + // Very likely still wrong (the gantt scalars read `record[field]` flat), + // but "likely wrong" is not a verdict a gate may invent — and the failure + // would be the SILENT class, not this loud one. Recorded as a follow-up. + const findings = validateListViewFieldRefs( + stackWith( + mutate({ + rowColor: { field: 'owner.name' }, + kanban: { groupByField: 'owner.name' }, + calendar: { titleField: 'owner.name' }, + gallery: { coverField: 'owner.name' }, + tree: { parentField: 'owner.name' }, + grouping: { fields: [{ field: 'owner.name' }] }, + hiddenFields: ['owner.name'], + fieldOrder: ['owner.name'], + }), + ), + ); + expect(findings).toEqual([]); + }); + + it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => { + const findings = validateListViewFieldRefs( + stackWith( + mutate({ + columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }], + }), + ), + ); + expect(findings).toEqual([]); + }); +}); + +describe('#14282 — the class does not disturb its neighbours', () => { + it('the skips still win over the dotted verdict', () => { + // An object this stack does not define: no graph, no verdict of any kind. + const stack = stackWith( + mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }), + ); + expect(validateListViewFieldRefs(stack)).toEqual([]); + }); + + it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => { + const findings = validateListViewFieldRefs( + stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })), + ); + expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]); + }); + + it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => { + const findings = validateListViewFieldRefs( + stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })), + ); + expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]); + }); + + it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => { + const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] })); + for (const command of ['validate', 'build'] as const) { + const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack })); + expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED); + } + }); + + it('the reference-integrity suite carries the new class too', () => { + const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] })); + const findings = validateReferenceIntegrity(stack); + expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true); + }); }); describe('#14107 — the skips', () => { diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 4210e52f06..70560db7dc 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -96,11 +96,83 @@ * The result is strictly WIDER than the card's suggestion ("skip dotted * paths, as the chart rule already does"): a dotted path whose head resolves * to nothing (`ownr.name`) is reported here, where a skip would have passed - * it. What stays unreported is a dotted path whose head DOES resolve — a - * separate, larger accept-set narrowing ("a list view compiles no joins, so - * any dotted reference is refused at query time") whose failure mode is a LOUD - * 400 rather than the silent-empty class this card gates. Filed as #14282 - * rather than folded in. + * it. + * + * ## [#14282] The SECOND finding class — a dotted reference the doors REFUSE + * + * #14107 left one half unreported: a dotted path whose head DOES resolve + * (`owner.name`, `title.x`). That is a larger accept-set narrowing whose + * failure mode is the OPPOSITE one — a loud `400 INVALID_FIELD` on the first + * fetch rather than the silent blank this rule's first class gates — so it + * was filed as #14282 and ruled there rather than folded in. This is that + * ruling, landed: {@link LIST_VIEW_FIELD_DOTTED}. + * + * The class is scoped by the DOOR, not by the position table. A list view + * compiles no joins, but that alone does not make every dotted binding a + * defect: some list-view positions are read CLIENT-SIDE out of the fetched + * row, and those walk a dotted path perfectly well when the payload carries + * an embedded record. So only the positions whose name reaches a query door + * are judged, and each is judged by the door's OWN verdict: + * + * - **Projection** — `columns[]`. objectui's `ListView.tsx` builds the + * `$select` projection out of `schema.columns` ("Build a `$select` + * projection from the columns the listview actually shows"). Both doors + * refuse a dotted entry UNCONDITIONALLY, with no carve-out: + * `assertProjectionHasNoDottedPaths` filters `fields` on + * `typeof f === 'string' && f.includes('.')` and throws + * `INVALID_FIELD` / 400 (`packages/objectql/src/engine.ts`, #7589), and + * `assertProjectionFieldsExist` answers the same at the REST ingress + * (#7532). So every dotted column is reported, whatever its head's type. + * - **Filter** — the view's own `filter`, its `tabs[].filter`, its + * `userFilters.tabs[].filter`, and the two positions that DECLARE which + * names the end user may filter on (`filterableFields`, spelled by the + * spec as "bare field names enabled for end-user filtering", and + * `userFilters.fields`; objectui folds the resulting conditions into the + * fetched query through `buildEffectiveFilter`). Here the door does NOT + * refuse everything: `assertFilterIsMaterializable` judges a dotted key + * only when `classifyDottedFilterHead` classifies its head as `relation`, + * `virtual` or `scalar`, and deliberately passes structured/JSON heads + * (the #8371 ruling's carve-out — live on memory and mongodb), array + * (`multiple: true`) heads, file heads and heads it cannot read. This rule + * therefore asks that SAME classifier — imported, never re-listed — so the + * linter cannot refuse at author time what the door serves at run time. + * + * Positions deliberately NOT in this class, each for a measured reason: + * + * - **`gantt.quickFilters[].field`** — the card's own named exception, and + * the measurement went the other way. The spec describes it as "Record + * field / dot-path", and objectui's `ObjectGantt.tsx` applies these filters + * IN MEMORY over the already-fetched rows ("Apply the active filters in + * memory"), resolving each through a walker that splits on `.` and steps + * through the record object (`resolveFilterKey`). No query door is + * involved, so a dot-path here is served, not refused. Excluded, and + * pinned. + * - **`gantt.tooltipFields[]`** — the same measurement: read client-side + * through `resolvePath(record, fieldName)`, which walks dots. Excluded. + * - **Every renderer binding** (`kanban` / `calendar` / `timeline` / + * `gallery` / `map` / `tree` scalars, `rowColor.field`, `hiddenFields`, + * `fieldOrder`, `grouping.fields`, `columns[].summary.field`, + * `columns[].prefix.field`). These reach no door this card measured. A + * dotted name at one of them is very likely still wrong — the gantt + * scalars, for instance, read `record[startDateField]` flat — but "likely + * wrong" is not the verdict a gate may invent (ADR-0072 D1), and the + * failure would be the SILENT class rather than this loud one. Recorded as + * a follow-up rather than guessed at here. + * + * `sort[]` keeps its owner: `validate-sortable-fields.ts` records that it + * "deliberately does not add a third finding" for a dotted name because the + * dotted verdict is "a posture shared with the FILTER and PROJECTION axes". + * This card is that posture being ruled for the LIST-VIEW surface, on the two + * axes this rule owns; the sort axis is untouched, and no finding here + * duplicates one of its. + * + * A new rule id rather than a second use of the first one, per the family's + * own convention: `validate-sortable-fields` ships `sort-field-unknown` / + * `sort-field-unsortable` / `sort-field-unprovisioned`, and + * `validate-searchable-fields` and `validate-dataset-references` do the same + * — one id per finding CLASS, because `suppressWarnings: ['']` and + * Studio's renderer filter on that string and must be able to name one class + * without silencing the other. * * ## What this rule deliberately does NOT own * @@ -137,6 +209,8 @@ * at all, so none of its field names is resolvable here. */ +import { classifyDottedFilterHead } from '@objectstack/spec/data'; + import { walkFilterFieldKeys } from './filter-walk.js'; import { describeFieldPathVerdict, @@ -149,6 +223,21 @@ import { /** A list-view field reference that resolves to no field on the bound object. */ export const LIST_VIEW_FIELD_UNKNOWN = 'list-view-field-unknown'; +/** + * [#14282] A list-view field reference written as a DOTTED path at a position + * whose name reaches a query door — where the door refuses it by name. See the + * second-class section on this module for the scope and the measurements. + */ +export const LIST_VIEW_FIELD_DOTTED = 'list-view-field-dotted'; + +/** + * Which query door a position's written name reaches, for the #14282 verdict. + * `undefined` (the default for every position) means "no door this card + * measured" — the position is judged for head existence only, exactly as + * before. + */ +type DottedAxis = 'projection' | 'filter' | undefined; + export type ListViewFieldRefSeverity = 'error' | 'warning'; export interface ListViewFieldRefFinding { @@ -284,6 +373,31 @@ const POSITIONS: Record = { }, }; +/** + * [#14282] The positions whose written name reaches a query DOOR, and which + * door — the scope of {@link LIST_VIEW_FIELD_DOTTED}. + * + * Deliberately a SEPARATE table from {@link POSITIONS} rather than a third key + * on `BlockPositions`: `POSITIONS` is the surface map (read key-by-key against + * `ListViewShapeSchema`, and every field-naming key belongs in it), whereas + * this is the much smaller set of positions whose runtime destination was + * measured. Keeping them apart means a position added to the surface map does + * NOT silently acquire a dotted verdict nobody measured — it defaults to + * unjudged, the fail-open direction the seam and both doors document. + * + * `gantt.quickFilters` is the conspicuous absence and the card's named + * exception: measured client-side, it ACCEPTS a dot-path. See the module note. + */ +const DOTTED_AXIS: Record> = { + '': { columns: 'projection', filterableFields: 'filter' }, + userFilters: { fields: 'filter' }, +}; + +/** The door a position reaches, or `undefined` (unjudged for dotted paths). */ +function dottedAxisAt(block: string, key: string): DottedAxis { + return DOTTED_AXIS[block]?.[key]; +} + /** The nested field positions inside one `columns[]` entry record. */ const COLUMN_ENTRY_POSITIONS: Array<{ block: string; key: string; severity: Sev }> = [ // `summary` aggregates a column; a stale name aggregates nothing and the @@ -297,6 +411,69 @@ const SILENT_EMPTY = 'Nothing resolves the name at render time: the view renders successfully with a blank, ' + 'empty or wrong result, and no gate reports the miss.'; +/** + * [#14282] The message and hint for a dotted reference at a position whose + * name reaches a query door, or `undefined` when the door itself leaves the + * path unjudged. + * + * The wording deliberately borrows the DOORS' own sentences ("no backend + * serves the path, so the predicate can only match zero records"; + * "denormalise the value onto '' … and name that"). One vocabulary + * across the doors is a stated value of both refusals — an author refused at + * author time and one refused at request time must not be sent two different + * ways about one string. + */ +function describeDottedRefusal( + axis: Exclude, + written: string, + head: string, + object: string, + meta: { type?: string; multiple?: boolean } | undefined, + subject: string, +): { message: string; hint: string } | undefined { + const denormalise = + `Denormalise the value onto "${object}" (a stored field, written when the source changes) ` + + 'and name that.'; + + if (axis === 'projection') { + // No classification: `assertProjectionHasNoDottedPaths` filters on + // `f.includes('.')` and refuses every dotted entry, whatever the head is. + return { + message: + `${subject} "${written}" is a dotted path. A list view declares no ADR-0021 \`include\`, ` + + `so it compiles no joins, and the columns are sent as the query's projection — where a ` + + `dotted entry is refused by name: \`assertProjectionHasNoDottedPaths\` on the engine ` + + `boundary and \`assertProjectionFieldsExist\` at the REST ingress, both ` + + `\`400 INVALID_FIELD\`. The view's FIRST fetch is refused, not rendered.`, + hint: + `Name a whole column of "${object}" — "${head}" — and read into its value in the client, ` + + `or read the related record with \`expand\`. ${denormalise}`, + }; + } + + // The FILTER door judges by head class and deliberately serves the rest. + const headClass = classifyDottedFilterHead(meta); + if (headClass === null) return undefined; + const type = meta?.type ? `\`${meta.type}\`` : 'an unreadable'; + const because = + headClass === 'relation' + ? `whose head "${head}" is a ${type} field on object "${object}" — it stores the related ` + + "record's id, not an embedded document, and a list view compiles no joins to traverse it" + : headClass === 'virtual' + ? `whose head "${head}" is a ${type} field on object "${object}" — its value is computed ` + + 'on read, so no driver materialises a column for the path to reach into' + : `whose head "${head}" is a ${type} field on object "${object}" that stores a single ` + + 'scalar value — there is nothing beneath it for a path to reach'; + return { + message: + `${subject} "${written}" is a dotted path ${because}. No backend serves the path, so the ` + + 'predicate can only match zero records: the query is REFUSED rather than answered with an ' + + 'empty list (`assertFilterIsMaterializable` and the REST ingress both answer ' + + '`400 INVALID_FIELD`).', + hint: `Filter on a column of "${object}" itself. ${denormalise}`, + }; +} + /** * Validate every list view's field references against the object graph. * Returns findings (empty = clean). Pure `(stack) => Finding[]`; no I/O, and @@ -324,6 +501,7 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin where: string, path: string, subject: string, + axis: DottedAxis = undefined, ): void => { const written = strName(raw); if (!written) return; @@ -332,9 +510,41 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin const verdict = resolveFieldPath(graph, object, head); if (isUnjudgeable(verdict) || !verdict) return; const account = describeFieldPathVerdict(verdict, head, subject); - if (!account) return; - const dotted = head !== written; + + if (!account) { + // The head resolves, so the FIRST class (#14107) has nothing to say. + // The SECOND class (#14282) does, at the positions whose name reaches a + // door — see the module note. + if (!dotted || !axis) return; + // `meta` is absent for a leaf resolved as a registry-injected column; + // the filter classifier answers `null` for it, which is the fail-open + // the seam documents. The projection door needs no head type at all. + const refusal = describeDottedRefusal( + axis, + written, + head, + object, + verdict.kind === 'ok' ? verdict.meta : undefined, + subject, + ); + if (!refusal) return; + findings.push({ + // Always `error`, and not the position's own tier: the family's + // severity line puts "refused outright" in the gating tier, and every + // position in `DOTTED_AXIS` is refused outright by its door. (It + // coincides with each of their declared tiers today; a position added + // to that table with a `warning` tier would need this re-read.) + severity: 'error', + rule: LIST_VIEW_FIELD_DOTTED, + where, + path, + message: refusal.message, + hint: refusal.hint, + }); + return; + } + findings.push({ severity, rule: LIST_VIEW_FIELD_UNKNOWN, @@ -387,22 +597,24 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin for (const [key, severity] of Object.entries(spec.lists ?? {})) { const list = host[key]; if (!Array.isArray(list)) continue; + const axis = dottedAxisAt(block, key); list.forEach((entry, i) => { - check(entry, bound, severity, hostWhere, `${hostPath}.${key}[${i}]`, `${key}[${i}]`); + check(entry, bound, severity, hostWhere, `${hostPath}.${key}[${i}]`, `${key}[${i}]`, axis); }); } for (const [key, severity] of Object.entries(spec.entries ?? {})) { const list = host[key]; if (!Array.isArray(list)) continue; + const axis = dottedAxisAt(block, key); list.forEach((entry, i) => { const entryPath = `${hostPath}.${key}[${i}]`; if (typeof entry === 'string') { - check(entry, bound, severity, hostWhere, entryPath, `${key}[${i}]`); + check(entry, bound, severity, hostWhere, entryPath, `${key}[${i}]`, axis); return; } if (!isRec(entry)) return; - check(entry.field, bound, severity, hostWhere, `${entryPath}.field`, `${key}[${i}].field`); + check(entry.field, bound, severity, hostWhere, `${entryPath}.field`, `${key}[${i}].field`, axis); // The nested positions a `columns[]` entry carries. if (block !== '' || key !== 'columns') return; for (const nested of COLUMN_ENTRY_POSITIONS) { @@ -430,7 +642,7 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin const checkFilter = (filter: unknown, filterWhere: string, filterPath: string): void => { if (filter === undefined || filter === null) return; walkFilterFieldKeys(filter, filterPath, ({ field, path: at }) => { - check(field, bound, 'error', filterWhere, at, 'filter key'); + check(field, bound, 'error', filterWhere, at, 'filter key', 'filter'); }); };