From 3aed196ef3de96fa1929994c8fc918be7316ffd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:15:38 +0000 Subject: [PATCH] fix(lint): refuse a list view's dotted field reference where the runtime door refuses it #14107's rule judges only the HEAD segment of a list-view field reference, so a dotted path whose head resolves to a real relationship field 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. Adds it as a second finding class with its own id, `list-view-field-dotted`, scoped by the DOOR rather than by the position table: - projection (`columns[]`): both doors refuse a dotted entry unconditionally; - filter (`filter`, `tabs[].filter`, `userFilters.tabs[].filter`, `filterableFields`, `userFilters.fields`): judged by the same `classifyDottedFilterHead` the runtime doors ask, so the #8371 carve-outs the doors serve are not refused at author time. `gantt.quickFilters[].field` and `gantt.tooltipFields[]` are excluded: measured client-side, applied in memory over already-fetched rows through walkers that split on `.`, so a dot-path is served there rather than refused. Every renderer binding that reaches no measured door stays unjudged. `GraphField` gains an optional `multiple` flag (additive) so the shared seam can answer the classifier's second input without a second copy of the field read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- .changeset/list-view-dotted-field-refs.md | 46 +++ packages/lint/src/index.ts | 5 + packages/lint/src/object-graph.ts | 14 + .../src/validate-list-view-field-refs.test.ts | 265 +++++++++++++++++- .../lint/src/validate-list-view-field-refs.ts | 234 +++++++++++++++- 5 files changed, 544 insertions(+), 20 deletions(-) create mode 100644 .changeset/list-view-dotted-field-refs.md 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'); }); };