diff --git a/.changeset/list-view-field-ref-integrity.md b/.changeset/list-view-field-ref-integrity.md new file mode 100644 index 0000000000..d8e02333f5 --- /dev/null +++ b/.changeset/list-view-field-ref-integrity.md @@ -0,0 +1,64 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): resolve a list view's field references at validate/build (#14107) + +Accept-set narrowing, `minor` under the family precedent (#14105, #14148). + +A list view names fields in more than twenty places and **none of them was +resolved against the bound object** — not by `os validate`, and not by `os +build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a +real app, each mutation applied on its own and confirmed on disk: a +`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a +`kanban.groupByField` and a `gantt.startDateField` naming a field that does not +exist all left `os validate` at `valid: true, warnings: []` and `os build` at +exit 0, `✓ Build complete`. + +Each one fails silently at render, in the way ADR-0078 and the +`view/layout-without-binding` rule already treat as worth gating: a bad column +renders blanks, a bad filter key is sent to the engine and matches nothing (an +empty list indistinguishable from a true zero), a bad gantt start date leaves a +blank chart, a bad kanban group-by collapses every card into the uncolumned +bucket. The platform already shipped the *harder* half of this check — +`view/layout-without-binding` warns when a binding block is **absent**; a block +that is present but points at a field that does not exist reaches the identical +end state and got nothing. + +The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of +the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and +on `view` per-write publish snapshots) resolves every field-naming position on a +list view against the object graph: + +- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and + `prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`, + `rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys, + `filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`; +- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`, + `gallery`, `map` and `tree` blocks. + +`sort[]` and `searchableFields[]` are deliberately untouched — they already have +owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830), +each with a runtime-admissibility verdict on top of existence. + +Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the +miss changes the data the view returns or collapses the layout it configures +(every position in the card's measured table), `warning` where the renderer +drops one decoration and renders the rest (optional colour/title/tooltip/cover +bindings, a stale `hiddenFields` or `fieldOrder` entry). + +Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no +second field-resolution implementation — and judges the **head segment** of a +dotted reference rather than walking relationship hops: a list view compiles no +joins, and all three query axes it reaches refuse a dotted path by name +(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589, +the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly +wider than "skip dotted paths": `ownr.name` is now reported, where a skip would +have passed it. + +**Migration.** A list view refused by the new rule names a field the bound +object does not have: correct the spelling (the finding carries a "did you mean" +and the object's field list) or drop the entry. The three standard skips apply — +an object this stack does not define, an object with no readable field map +(ADR-0015 `external`), and registry-injected system columns — plus a fourth on +this surface: a list view whose `data.provider` is not `object`. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 7d29f86752..544ab3c974 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -427,6 +427,22 @@ export type { SortableFieldSeverity, } from './validate-sortable-fields.js'; +// [#14107] The rest of the list view's field surface — the two rules above own +// `sort` and `searchableFields`; this one owns every OTHER position that names +// a field on the bound object (columns, filter keys, grouping, row colour, +// user filters, and every binding inside the kanban / calendar / gantt / +// 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. +export { + validateListViewFieldRefs, + LIST_VIEW_FIELD_UNKNOWN, +} from './validate-list-view-field-refs.js'; +export type { + ListViewFieldRefFinding, + ListViewFieldRefSeverity, +} from './validate-list-view-field-refs.js'; + export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js'; export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js'; diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index b50d0f7451..2b872e5c03 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => { 'validateObjectReferences', 'validateSearchableFields', 'validateSortableFields', + // [#14107] The rest of the same list view's field surface — every + // field-naming position the two members above do not own. Placed beside + // them because the three walk the identical rungs. + 'validateListViewFieldRefs', 'validateActionNameRefs', 'validatePageFieldBindings', 'validateChartBindings', diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 8351d2a509..b13e57a477 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -50,6 +50,16 @@ * because a view's declared sort is its FIRST fetch, the refusal is the whole * view failing to load, every time, from an authoring typo made long before. * + * `validateListViewFieldRefs` completes that pair (#14107): the two members + * above judge ONE list-view axis each (`sort`, `searchableFields`), and every + * OTHER field-naming position on the same record — `columns`, filter keys, + * `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`, + * `fieldOrder`, and every binding inside the kanban / calendar / gantt / + * timeline / gallery / map / tree blocks — was resolved by nothing at all. It + * carries BOTH severities, like `validateFlowTemplatePaths`: a position whose + * miss empties or mis-selects the view's data gates, one whose miss drops a + * decoration advises. + * * Rules that check SHAPE rather than reference (view containers, responsive * styles, seed replay safety, seed state machines, seed/security posture) stay * out — they answer a different question and have their own call sites. @@ -79,6 +89,7 @@ import { validateObjectReferences } from './validate-object-references.js'; import { validateSearchableFields } from './validate-searchable-fields.js'; import { validateSortableFields } from './validate-sortable-fields.js'; +import { validateListViewFieldRefs } from './validate-list-view-field-refs.js'; import { validateActionNameRefs } from './validate-action-name-refs.js'; import { validatePageFieldBindings } from './validate-page-field-bindings.js'; import { validateChartBindings } from './validate-chart-bindings.js'; @@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so // the refusal is the whole view, on every load, traced to nothing. { name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields }, + // [#14107] The REST of the same list view's field surface. The two members + // above own `sort` and `searchableFields`; every OTHER field-naming position + // on a list view — `columns`, filter keys, `grouping`, `rowColor`, + // `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every + // binding inside the kanban / calendar / gantt / timeline / gallery / map / + // tree blocks — was resolved by nothing, on both `os validate` and `os + // build`. Placed directly after its two siblings because the three walk the + // identical rungs (an object's `listViews`, a `defineView` aggregate's + // `list` / `listViews`, and the two standalone `views[]` shapes the + // `PUT /api/v1/meta/view` door carries), so a rung added to one is read + // against the other two. + // + // `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings + // did, and the reason is a property of the SNAPSHOT rather than a + // convenience: this member resolves only against `stack.objects`, which the + // per-write snapshot does carry, so it has no missing-collection + // false-positive channel. The standalone list view a Studio tenant or an + // MCP/AI author writes goes through that door and no CLI, so a + // build-time-only rule would never reach the author who made the typo. + { name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs }, { name: 'validateActionNameRefs', run: validateActionNameRefs }, { name: 'validatePageFieldBindings', run: validatePageFieldBindings }, { name: 'validateChartBindings', run: validateChartBindings }, diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index 2266cd4a83..dbd69d3516 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js'; import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js'; import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; +import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js'; /** The live object universe the gate resolves against (`RuntimeStackContext.objects`). */ const objects = [ @@ -127,6 +128,57 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { expect(errors, JSON.stringify(errors)).toEqual([]); }); + // ── [#14107] the rest of the same overlay's field surface ── + // + // The two rules above own `sort` and `searchableFields`; every OTHER + // field-naming position on the same overlay was resolved by nothing, at this + // door as well as at the CLI. Same self rung, same skips, same binding + // order — these are the refusal/clean pair that distinguishes a real + // crossing from a dispatch-only no-op. + + it('REFUSES a top-level `columns` entry that resolves to no field', () => { + const { errors } = gate(overlay({ columns: ['name', 'budgett'] })); + const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].columns[1]'); + expect(f!.where).toContain('flattened list overlay'); + }); + + it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => { + const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } })); + const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].kanban.groupByField'); + expect(f!.message).toContain('Did you mean "status"?'); + }); + + it('REFUSES a top-level `filter` key that resolves to no field', () => { + const { errors } = gate(overlay({ + filter: [{ field: 'budget', operator: 'equals', value: 1 }], + })); + const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].filter[0].field'); + }); + + it('a fully bound overlay publishes clean across every one of those positions', () => { + const result = gate(overlay({ + columns: ['name', { field: 'status' }], + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + grouping: { fields: [{ field: 'status' }] }, + rowColor: { field: 'status' }, + kanban: { groupByField: 'status', columns: ['name'] }, + hiddenFields: ['days_open'], + })); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + }); + + it('a system column in a walked position publishes clean (skip ③)', () => { + const { errors } = gate(overlay({ columns: ['name', 'created_at'] })); + expect(errors, JSON.stringify(errors)).toEqual([]); + }); + // ── the granularity wall: exactly two members cross, nothing rides along ── it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => { @@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { // precisely so a fourth crossing has to be argued here; this one's // false-positive measurement is `runtime-gate.view-page-refs.test.ts`, // which reproduces the phantom findings the collection removes. + // [#14107] The fourth crossing, argued here as this list demands. It is the + // same KIND of crossing as the first two — a list view's field references, + // resolved against `stack.objects`, the one collection every per-write + // snapshot carries — so it has no missing-collection false-positive + // channel to open; the controls directly below are its measurement, on the + // shape the door actually carries. Not crossing it would have been the + // #9313 failure inverted: the standalone list view a Studio tenant or an + // MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so + // a build-time-only rule never reaches the author who made the typo. expect(crossed).toEqual([ 'validateSearchableFields', 'validateSortableFields', + 'validateListViewFieldRefs', 'validateViewPageRefs', ]); // And every member still judges flow snapshots — the #4463 P1 surface is diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts new file mode 100644 index 0000000000..787c8b57fc --- /dev/null +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -0,0 +1,475 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#14107] A list view's field references, resolved against the bound object. +// +// The fixture below is deliberately ONE list view carrying every position the +// rule walks, so a "clean" assertion is a real statement about the whole +// surface rather than about the two keys a test happened to write. Each +// mutation case names exactly one position and asserts the finding's PATH, not +// only its rule id: a rule that reports the right miss at the wrong path is +// unusable as an edit target (`os lint --json`, Studio's finding renderer). + +import { describe, expect, it } from 'vitest'; +import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; +import { validateReferenceIntegrity } from './reference-integrity-suite.js'; +import { + validateListViewFieldRefs, + LIST_VIEW_FIELD_UNKNOWN, + type ListViewFieldRefFinding, +} from './validate-list-view-field-refs.js'; +import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js'; +import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; + +type AnyRec = Record; + +const OBJECTS = [ + { + name: 'duly_task', + label: 'Task', + fields: [ + { name: 'title', type: 'text', label: 'Title' }, + { name: 'status', type: 'select', label: 'Status' }, + { name: 'period_key', type: 'text', label: 'Period' }, + { name: 'visible_from', type: 'date', label: 'Visible from' }, + { name: 'due_at', type: 'date', label: 'Due' }, + { name: 'business_unit', type: 'text', label: 'BU' }, + { name: 'estimate', type: 'number', label: 'Estimate' }, + { name: 'lat', type: 'number', label: 'Lat' }, + { name: 'lng', type: 'number', label: 'Lng' }, + { name: 'cover', type: 'image', label: 'Cover' }, + { name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' }, + { name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' }, + ], + }, + { + name: 'duly_person', + label: 'Person', + fields: [{ name: 'name', type: 'text', label: 'Name' }], + }, +]; + +/** Every field-naming position this rule walks, all bound to real fields. */ +const FULL_LIST_VIEW: AnyRec = { + name: 'all', + type: 'grid', + data: { provider: 'object', object: 'duly_task' }, + columns: [ + 'title', + { field: 'period_key', summary: { field: 'estimate' }, prefix: { field: 'status' } }, + ], + filter: [{ field: 'visible_from', operator: 'equals', value: '2026-01-01' }], + filterableFields: ['status'], + hiddenFields: ['business_unit'], + fieldOrder: ['title', 'status'], + grouping: { fields: [{ field: 'business_unit' }] }, + rowColor: { field: 'status' }, + userFilters: { + fields: [{ field: 'status' }], + tabs: [{ name: 'open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, + tabs: [{ name: 'mine', filter: [{ field: 'business_unit', operator: 'equals', value: 'x' }] }], + kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'] }, + calendar: { + startDateField: 'due_at', + endDateField: 'visible_from', + titleField: 'title', + colorField: 'status', + }, + gantt: { + startDateField: 'visible_from', + endDateField: 'due_at', + titleField: 'title', + progressField: 'estimate', + dependenciesField: 'parent', + colorField: 'status', + parentField: 'parent', + typeField: 'status', + baselineStartField: 'visible_from', + baselineEndField: 'due_at', + groupByField: 'business_unit', + assigneeField: 'owner', + effortField: 'estimate', + tooltipFields: ['status', { field: 'business_unit' }], + quickFilters: [{ field: 'status' }], + }, + timeline: { + startDateField: 'visible_from', + titleField: 'title', + endDateField: 'due_at', + groupByField: 'business_unit', + colorField: 'status', + }, + gallery: { coverField: 'cover', titleField: 'title', visibleFields: ['status'] }, + map: { + latitudeField: 'lat', + longitudeField: 'lng', + locationField: 'business_unit', + titleField: 'title', + descriptionField: 'status', + }, + tree: { parentField: 'parent', labelField: 'title', fields: ['status'] }, +}; + +/** Deep-merge one mutation into the full list view. */ +function mutate(patch: AnyRec): AnyRec { + const next: AnyRec = { ...FULL_LIST_VIEW }; + for (const [k, v] of Object.entries(patch)) { + const base = next[k]; + next[k] = v && typeof v === 'object' && !Array.isArray(v) + && base && typeof base === 'object' && !Array.isArray(base) + ? { ...(base as AnyRec), ...(v as AnyRec) } + : v; + } + return next; +} + +/** A `defineView` aggregate whose default `list` is the list view given. */ +function stackWith(listView: AnyRec, objects: unknown = OBJECTS): AnyRec { + return { + name: 'probe', + objects, + views: [{ name: 'duly_task', object: 'duly_task', list: listView }], + }; +} + +const idsOf = (fs: ListViewFieldRefFinding[]) => fs.map((f) => f.path); + +describe('#14107 — the clean surface reports nothing', () => { + it('every walked position bound to a real field is silent', () => { + expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]); + }); + + it('a stack with no views at all is silent', () => { + expect(validateListViewFieldRefs({ name: 'x', objects: OBJECTS })).toEqual([]); + }); + + it('a non-object input is silent rather than throwing', () => { + expect(validateListViewFieldRefs(undefined as never)).toEqual([]); + expect(validateListViewFieldRefs('nope' as never)).toEqual([]); + }); +}); + +/** + * The card's own measured table. Each of these five passed `os validate` + * (`valid: true, warnings: []`) and `os build` (exit 0, `✓ Build complete`) on + * `@objectstack/cli` 17.2.0 — the reason this card exists. + */ +describe('#14107 — the card\'s five measured positions', () => { + const cases: Array<[string, AnyRec, string]> = [ + ['columns[].field', { columns: [{ field: 'B2_no_such_field' }] }, 'views[0].list.columns[0].field'], + [ + 'filter[].field', + { filter: [{ field: 'A8_no_such_field', operator: 'equals', value: 'x' }] }, + 'views[0].list.filter[0].field', + ], + [ + 'grouping.fields[].field', + { grouping: { fields: [{ field: 'A7_no_such_field' }] } }, + 'views[0].list.grouping.fields[0].field', + ], + ['kanban.groupByField', { kanban: { groupByField: 'A9_no_such_field' } }, 'views[0].list.kanban.groupByField'], + ['gantt.startDateField', { gantt: { startDateField: 'B1_no_such_field' } }, 'views[0].list.gantt.startDateField'], + ]; + + for (const [label, patch, path] of cases) { + it(`${label} is an error, at a path an author can look up`, () => { + const findings = validateListViewFieldRefs(stackWith(mutate(patch))); + expect(idsOf(findings)).toEqual([path]); + expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toContain('is not a field on object "duly_task"'); + expect(findings[0].hint).toContain('Fields on "duly_task"'); + }); + } +}); + +/** + * Every remaining position, with the severity tier it earns. The table is the + * readable half of the rule's own POSITIONS table: a position dropped from the + * rule fails here, and a position added to the rule without a row here leaves + * the count assertion below short. + */ +describe('#14107 — every other walked position', () => { + const BAD = 'nope_field'; + const cases: Array<[AnyRec, string, 'error' | 'warning']> = [ + [{ columns: [BAD, ...(FULL_LIST_VIEW.columns as unknown[]).slice(1)] }, 'views[0].list.columns[0]', 'error'], + [ + { columns: [{ field: 'title', summary: { field: BAD } }] }, + 'views[0].list.columns[0].summary.field', + 'error', + ], + [ + { columns: [{ field: 'title', prefix: { field: BAD } }] }, + 'views[0].list.columns[0].prefix.field', + 'warning', + ], + [{ filterableFields: [BAD] }, 'views[0].list.filterableFields[0]', 'error'], + [{ hiddenFields: [BAD] }, 'views[0].list.hiddenFields[0]', 'warning'], + [{ fieldOrder: [BAD] }, 'views[0].list.fieldOrder[0]', 'warning'], + [{ rowColor: { field: BAD } }, 'views[0].list.rowColor.field', 'warning'], + [{ userFilters: { fields: [{ field: BAD }] } }, 'views[0].list.userFilters.fields[0].field', 'error'], + [ + { userFilters: { tabs: [{ name: 'a', filter: [{ field: BAD, operator: 'equals', value: 1 }] }] } }, + 'views[0].list.userFilters.tabs[0].filter[0].field', + 'error', + ], + [ + { tabs: [{ name: 'a', filter: [{ field: BAD, operator: 'equals', value: 1 }] }] }, + 'views[0].list.tabs[0].filter[0].field', + 'error', + ], + [{ kanban: { summarizeField: BAD } }, 'views[0].list.kanban.summarizeField', 'warning'], + [{ kanban: { columns: [BAD] } }, 'views[0].list.kanban.columns[0]', 'warning'], + [{ calendar: { endDateField: BAD } }, 'views[0].list.calendar.endDateField', 'warning'], + [{ calendar: { titleField: BAD } }, 'views[0].list.calendar.titleField', 'warning'], + [{ calendar: { colorField: BAD } }, 'views[0].list.calendar.colorField', 'warning'], + [{ calendar: { startDateField: BAD } }, 'views[0].list.calendar.startDateField', 'error'], + [{ gantt: { endDateField: BAD } }, 'views[0].list.gantt.endDateField', 'error'], + [{ gantt: { titleField: BAD } }, 'views[0].list.gantt.titleField', 'error'], + [{ gantt: { progressField: BAD } }, 'views[0].list.gantt.progressField', 'warning'], + [{ gantt: { dependenciesField: BAD } }, 'views[0].list.gantt.dependenciesField', 'warning'], + [{ gantt: { colorField: BAD } }, 'views[0].list.gantt.colorField', 'warning'], + [{ gantt: { parentField: BAD } }, 'views[0].list.gantt.parentField', 'warning'], + [{ gantt: { typeField: BAD } }, 'views[0].list.gantt.typeField', 'warning'], + [{ gantt: { baselineStartField: BAD } }, 'views[0].list.gantt.baselineStartField', 'warning'], + [{ gantt: { baselineEndField: BAD } }, 'views[0].list.gantt.baselineEndField', 'warning'], + [{ gantt: { groupByField: BAD } }, 'views[0].list.gantt.groupByField', 'warning'], + [{ gantt: { assigneeField: BAD } }, 'views[0].list.gantt.assigneeField', 'warning'], + [{ gantt: { effortField: BAD } }, 'views[0].list.gantt.effortField', 'warning'], + [{ gantt: { tooltipFields: [BAD] } }, 'views[0].list.gantt.tooltipFields[0]', 'warning'], + [ + { gantt: { tooltipFields: [{ field: BAD }] } }, + 'views[0].list.gantt.tooltipFields[0].field', + 'warning', + ], + [{ gantt: { quickFilters: [{ field: BAD }] } }, 'views[0].list.gantt.quickFilters[0].field', 'error'], + [{ timeline: { startDateField: BAD } }, 'views[0].list.timeline.startDateField', 'error'], + [{ timeline: { titleField: BAD } }, 'views[0].list.timeline.titleField', 'error'], + [{ timeline: { endDateField: BAD } }, 'views[0].list.timeline.endDateField', 'warning'], + [{ timeline: { groupByField: BAD } }, 'views[0].list.timeline.groupByField', 'warning'], + [{ timeline: { colorField: BAD } }, 'views[0].list.timeline.colorField', 'warning'], + [{ gallery: { coverField: BAD } }, 'views[0].list.gallery.coverField', 'warning'], + [{ gallery: { titleField: BAD } }, 'views[0].list.gallery.titleField', 'warning'], + [{ gallery: { visibleFields: [BAD] } }, 'views[0].list.gallery.visibleFields[0]', 'warning'], + [{ map: { latitudeField: BAD } }, 'views[0].list.map.latitudeField', 'error'], + [{ map: { longitudeField: BAD } }, 'views[0].list.map.longitudeField', 'error'], + [{ map: { locationField: BAD } }, 'views[0].list.map.locationField', 'error'], + [{ map: { titleField: BAD } }, 'views[0].list.map.titleField', 'warning'], + [{ map: { descriptionField: BAD } }, 'views[0].list.map.descriptionField', 'warning'], + [{ tree: { parentField: BAD } }, 'views[0].list.tree.parentField', 'error'], + [{ tree: { labelField: BAD } }, 'views[0].list.tree.labelField', 'warning'], + [{ tree: { fields: [BAD] } }, 'views[0].list.tree.fields[0]', 'warning'], + ]; + + for (const [patch, path, severity] of cases) { + it(`${path} reports at \`${severity}\``, () => { + const findings = validateListViewFieldRefs(stackWith(mutate(patch))); + const mine = findings.filter((f) => f.path === path); + expect(mine).toHaveLength(1); + expect(mine[0].severity).toBe(severity); + expect(mine[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); + }); + } + + // A floor, so a position quietly dropped from the rule's table cannot pass + // by simply never being asserted. + it('covers every position the rule walks', () => { + expect(cases.length).toBeGreaterThanOrEqual(46); + }); +}); + +describe('#14107 — the "did you mean" comes from the shared seam', () => { + it('a near-miss names the nearest declared field', () => { + const findings = validateListViewFieldRefs(stackWith(mutate({ kanban: { groupByField: 'statuss' } }))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('Did you mean "status"?'); + }); + + it('a name close to nothing carries no suggestion', () => { + const findings = validateListViewFieldRefs( + stackWith(mutate({ kanban: { groupByField: 'zzzzzzzzzzzzzzzz' } })), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).not.toContain('Did you mean'); + }); +}); + +/** + * 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. + */ +describe('#14107 — dotted paths', () => { + 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].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', () => { + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] }))); + expect(findings).toEqual([]); + }); + + 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. + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] }))); + expect(findings).toEqual([]); + }); +}); + +describe('#14107 — the skips', () => { + it('skips a list view bound to an object this stack does not define', () => { + const stack = stackWith(mutate({ data: { provider: 'object', object: 'sys_elsewhere' } })); + expect(validateListViewFieldRefs(stack)).toEqual([]); + }); + + it('skips an object that declares no readable field map', () => { + const external = [{ name: 'duly_task', label: 'Task', external: true }]; + expect(validateListViewFieldRefs(stackWith(mutate({ columns: ['nope'] }), external))).toEqual([]); + }); + + it('skips a registry-injected system column', () => { + const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['created_at'] }))); + expect(findings).toEqual([]); + }); + + it('skips a list view whose provider is not `object`', () => { + const stack = stackWith(mutate({ data: { provider: 'api', endpoint: '/x' }, columns: ['nope'] })); + expect(validateListViewFieldRefs(stack)).toEqual([]); + }); + + it('one bad object yields ZERO findings here, not one per position', () => { + // `validate-object-references` owns the object name; this rule must not + // repeat the same typo once per field position. + const stack = stackWith(mutate({ data: { provider: 'object', object: 'duly_taskk' } })); + expect(validateListViewFieldRefs(stack)).toEqual([]); + }); +}); + +describe('#14107 — every list-view rung the sort/search twins walk', () => { + const bad = { columns: [{ field: 'nope' }] }; + + it('objects[].listViews.', () => { + const stack = { + name: 'p', + objects: [{ ...OBJECTS[0], listViews: { all: mutate(bad) } }, OBJECTS[1]], + }; + expect(idsOf(validateListViewFieldRefs(stack))).toEqual([ + 'objects[0].listViews.all.columns[0].field', + ]); + }); + + it('views[].list', () => { + expect(idsOf(validateListViewFieldRefs(stackWith(mutate(bad))))).toEqual([ + 'views[0].list.columns[0].field', + ]); + }); + + it('views[].listViews.', () => { + const stack = { + name: 'p', + objects: OBJECTS, + views: [{ name: 'duly_task', object: 'duly_task', listViews: { open: mutate(bad) } }], + }; + expect(idsOf(validateListViewFieldRefs(stack))).toEqual([ + 'views[0].listViews.open.columns[0].field', + ]); + }); + + it('views[] flattened list overlay (#9313)', () => { + const stack = { + name: 'p', + objects: OBJECTS, + views: [{ ...mutate(bad), name: 'all', object: 'duly_task', viewKind: 'list' }], + }; + expect(idsOf(validateListViewFieldRefs(stack))).toEqual(['views[0].columns[0].field']); + }); + + it('views[].config ViewItem record (#10001)', () => { + const stack = { + name: 'p', + objects: OBJECTS, + views: [{ name: 'all', object: 'duly_task', viewKind: 'list', config: mutate(bad) }], + }; + expect(idsOf(validateListViewFieldRefs(stack))).toEqual(['views[0].config.columns[0].field']); + }); +}); + +describe('#14107 — no double-reporting with the two axes that already have owners', () => { + it('`sort` stays with validate-sortable-fields', () => { + const stack = stackWith(mutate({ sort: [{ field: 'nope', order: 'asc' }] })); + expect(validateListViewFieldRefs(stack)).toEqual([]); + expect(validateReferenceIntegrity(stack).map((f) => f.rule)).toContain(SORT_FIELD_UNKNOWN); + }); + + it('`searchableFields` stays with validate-searchable-fields', () => { + const stack = stackWith(mutate({ searchableFields: ['nope'] })); + expect(validateListViewFieldRefs(stack)).toEqual([]); + expect(validateReferenceIntegrity(stack).map((f) => f.rule)).toContain(SEARCHABLE_FIELD_UNKNOWN); + }); +}); + +describe('#14107 — wired into the reference-integrity suite', () => { + it('the suite reports the finding on a whole-stack run', () => { + const stack = stackWith(mutate({ kanban: { groupByField: 'A9_no_such_field' } })); + expect(validateReferenceIntegrity(stack).map((f) => f.rule)).toContain(LIST_VIEW_FIELD_UNKNOWN); + }); + + it('and on a `view` per-write publish snapshot (#9313 axis)', () => { + const stack = { + objects: OBJECTS, + views: [{ ...mutate({ kanban: { groupByField: 'A9_no_such_field' } }), name: 'all', object: 'duly_task', viewKind: 'list' }], + }; + const findings = validateReferenceIntegrity(stack, { runtimeWriteType: 'view' }); + expect(findings.map((f) => f.rule)).toContain(LIST_VIEW_FIELD_UNKNOWN); + }); +}); + +/** + * The card's binding acceptance criterion, pinned end-to-end rather than + * inferred from the registry entry (the #14148 precedent): the measured + * positions must fail `validate` AND `build`. `build` is the publish gate and + * is where these currently ship, so a validate-only fix was not acceptable — + * and nothing else in this file would notice if the suite entry's `commands` + * were narrowed later. + */ +describe('#14107 acceptance — the measured positions gate `validate` AND `build`', () => { + const limbs: Array<[string, AnyRec]> = [ + ['columns[].field', { columns: [{ field: 'B2_no_such_field' }] }], + ['filter[].field', { filter: [{ field: 'A8_no_such_field', operator: 'equals', value: 'x' }] }], + ['grouping.fields[].field', { grouping: { fields: [{ field: 'A7_no_such_field' }] } }], + ['kanban.groupByField', { kanban: { groupByField: 'A9_no_such_field' } }], + ['gantt.startDateField', { gantt: { startDateField: 'B1_no_such_field' } }], + ]; + + for (const command of ['validate', 'build'] as const) { + for (const [label, patch] of limbs) { + it(`${label} fails \`${command}\``, () => { + const normalized = stackWith(mutate(patch)); + const { errors } = splitBySeverity(runAuthoringRules(command, { normalized })); + expect(errors.map((f) => f.rule)).toContain(LIST_VIEW_FIELD_UNKNOWN); + }); + } + + it(`the clean list view passes \`${command}\``, () => { + const { errors, advisories } = splitBySeverity( + runAuthoringRules(command, { normalized: stackWith(FULL_LIST_VIEW) }), + ); + expect([...errors, ...advisories].filter((f) => f.rule === LIST_VIEW_FIELD_UNKNOWN)).toEqual([]); + }); + } + + it('a warning-tier position advises rather than gates', () => { + const normalized = stackWith(mutate({ hiddenFields: ['nope'] })); + const { errors, advisories } = splitBySeverity(runAuthoringRules('build', { normalized })); + expect(errors.map((f) => f.rule)).not.toContain(LIST_VIEW_FIELD_UNKNOWN); + expect(advisories.map((f) => f.rule)).toContain(LIST_VIEW_FIELD_UNKNOWN); + }); +}); diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts new file mode 100644 index 0000000000..4210e52f06 --- /dev/null +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -0,0 +1,545 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14107 — list-view field reference integrity] Every field a list view names + * — its columns, its filter keys, its grouping, its row colour, its user + * filters and every binding inside a `kanban` / `calendar` / `gantt` / + * `timeline` / `gallery` / `map` / `tree` block — must name a field the bound + * object actually has. + * + * ## The state this rule ends + * + * Measured on `@objectstack/cli` 17.2.0 against a real app, each mutation + * applied on its own and confirmed on disk: `os validate` answered + * `valid: true, warnings: []` and `os build` exited 0 with `✓ Build complete` + * for a `columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a + * `kanban.groupByField` and a `gantt.startDateField` that named nothing on the + * bound object. Re-measured on this branch's base (`origin/main`, both family + * landings present) by running the whole reference-integrity suite and the + * whole authoring-rule table over one mutated list view per position: every + * position below reported NOTHING, with two exceptions that already have + * owners and are excluded here — see "What this rule deliberately does NOT + * own". + * + * ## Why a dangling list-view field reference is not merely inert + * + * The platform already ships the HARDER half of this check. + * `view/layout-without-binding` (`packages/spec/src/kernel/ + * functional-completeness.ts`) warns when a binding block is **absent**, + * on the reasoning that the renderer then falls back to literal default field + * names and *the view renders empty while authoring reports success*. A block + * that is PRESENT but names a field that does not exist reaches the identical + * end state and, until this rule, got nothing. This is not a new category of + * check; it is the same check, missing its easier case — the object is named + * right there in `data.object`. + * + * ## Severity: two tiers, the `validateFlowTemplatePaths` precedent + * + * The suite's contract is severity-agnostic and one of its members already + * carries both (`validate-flow-template-paths.ts`: a filter-position miss + * gates, every other position advises). The line is the one + * `validate-searchable-fields.ts` states for this whole family — *a consumer + * that SKIPS an unknown name and renders the rest may be warned about; a + * declaration that selects the wrong set, empties the surface or is refused + * outright must not ship*: + * + * - **`error`** — the miss changes what data the view returns, or collapses + * the layout it configures. A `filter` key that names nothing is compiled + * into the query as written and matches no record, so the list is an empty + * result indistinguishable from a true zero; the same for a user-filter + * field and a gantt quick filter. A `columns` entry is echoed by clients as + * the projection, where the REST ingress answers `400 INVALID_FIELD` for an + * unknown plain column (`assertProjectionFieldsExist`, #7532) — a refused + * first fetch, not a blank cell. A `kanban.groupByField` collapses every + * card into the uncolumned bucket; a `gantt` / `calendar` / `timeline` + * required date field leaves the renderer nothing to place, so the chart is + * blank; a map with no resolvable coordinate field plots no marker. + * - **`warning`** — the renderer drops one decoration and renders the rest: + * an optional colour / title / tooltip / cover binding, a stale + * `hiddenFields` entry that hides nothing, a stale `fieldOrder` entry that + * orders nothing. + * + * Every position in the card's measured table lands in the `error` tier, so + * the rule fails `validate` AND `build` exactly where the card measured them + * passing. + * + * ## Dotted paths — the HEAD segment is judged, hops are NOT walked + * + * The seam ({@link resolveFieldPath}) can walk `relationship.relationship.field` + * hops, and its two other consumers do (#14105's dataset positions, #14148's + * widget filter keys). **This rule deliberately does not**, and the reason is + * the runtime, not economy of effort: + * + * - A list view compiles **no joins**. `ListViewSchema` declares no + * ADR-0021 `include`, so there is no declaration that could make a + * relationship prefix joinable — the clause that makes hop-walking + * meaningful at a dataset position has no counterpart here. + * - All three query axes a list view reaches **refuse** a dotted path by + * name. Projection: `assertProjectionFieldsExist` at the REST ingress + * (#7532) and `assertProjectionHasNoDottedPaths` on the engine's own + * boundary (#7589) — measured byte-identical to *no projection at all* + * before the refusal landed. Filter: the #8371 dotted-head door, "no + * backend serves the path, so the predicate can only match zero records". + * Sort: `assertSortFieldsExist`'s `unknown` > `dotted` > unmaterializable + * ladder (#6994). + * + * So walking hops here would BLESS `owner.name` in a list view's columns — a + * reference all three runtime doors refuse — which is the fail-open direction + * the seam's own docblock reserves to the caller's judgement ("this function + * answers EXISTENCE only"). It would also teach an AI author that a traversal + * works on this surface when nothing implements it. Judging the HEAD segment + * instead is the call `validate-sortable-fields.ts` (#9257) already made on + * this same surface, for the same stated reason: linter and ingress gate must + * agree about which names are "unknown" rather than disagreeing on dotted + * paths. + * + * 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. + * + * ## What this rule deliberately does NOT own + * + * - **`sort[]`** — `validate-sortable-fields.ts` (#9257) owns it, on every + * one of the surfaces walked here, and adds a virtuality verdict this rule + * has no business restating. Re-measured on this base: a bad `sort[].field` + * already reports `sort-field-unknown`. + * - **`searchableFields[]`** — `validate-searchable-fields.ts` (#6674 / + * #4830) owns it, with a runtime-admissibility verdict on top of existence. + * Re-measured: a bad entry already reports `searchable-field-unknown`. + * - **`chart`** (`dataset` / `dimensions` / `values`) — those are ADR-0021 + * dataset, dimension and measure NAMES, not fields on the bound object; + * `validateChartBindings` and `validateDatasetReferences` own that axis. + * - **`rowActions` / `bulkActions` / `columns[].action`** — action names, + * owned by `validateActionNameRefs`. + * - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the + * expression rules. + * - **`pageName` / `tabs[].view` / `addRecord.formView`** — page and view + * names, owned by `validateViewPageRefs` and `lintViewRefs`. + * - **The `data.object` binding itself** — `validateObjectReferences` owns + * object-name reference sites, with the curated cross-package severity + * ladder a local "not in this stack ⇒ error" would not have. When the bound + * object does not resolve, this rule skips the whole list view (skip 1 + * below) so one typo yields one finding rather than one per position. + * + * ## Skips — the same three every field-existence rule in this package takes + * + * Resolution is {@link resolveFieldPath}'s and its `unknowable` verdicts are + * never reported (ADR-0072 D1: one dead finding and authors stop trusting the + * linter): an object this stack does not define, an object that declares no + * readable field map (ADR-0015 `external`, datasource-introspected schemas), + * and a registry-injected system column. A fourth skip is this surface's own: + * a list view whose `data.provider` is not `object` binds to no object graph + * at all, so none of its field names is resolvable here. + */ + +import { walkFilterFieldKeys } from './filter-walk.js'; +import { + describeFieldPathVerdict, + indexObjectGraph, + isUnjudgeable, + resolveFieldPath, + type ObjectGraph, +} from './object-graph.js'; + +/** A list-view field reference that resolves to no field on the bound object. */ +export const LIST_VIEW_FIELD_UNKNOWN = 'list-view-field-unknown'; + +export type ListViewFieldRefSeverity = 'error' | 'warning'; + +export interface ListViewFieldRefFinding { + /** See the two-tier note on this module. */ + severity: ListViewFieldRefSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `view "task" › list › kanban`. */ + where: string; + /** Config path, e.g. `views[0].list.kanban.groupByField`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; +type Sev = ListViewFieldRefSeverity; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** + * The field-naming positions on a list view, grouped by the block they live + * under (`''` = the list view's own top level). + * + * Declarative on purpose: the failure this whole family exists to end is a + * position nobody remembered to walk, and a table can be read against + * `ListViewShapeSchema` key by key. Each entry carries its own severity + * because the tier is a per-position judgement (see the module note), not a + * per-rule one. + * + * - `scalars` — the key holds ONE field name. + * - `lists` — the key holds an array of bare field names. + * - `entries` — the key holds an array whose members are either a bare field + * name or a record with a `field` key (`columns`, `grouping.fields`, + * `userFilters.fields`, `gantt.tooltipFields`, `gantt.quickFilters`). + */ +interface BlockPositions { + scalars?: Record; + lists?: Record; + entries?: Record; +} + +const POSITIONS: Record = { + // ── The list view's own top level ── + '': { + // A stale `hiddenFields` / `fieldOrder` entry hides and orders nothing — + // the inert case, warned rather than gated. + lists: { filterableFields: 'error', hiddenFields: 'warning', fieldOrder: 'warning' }, + entries: { columns: 'error' }, + }, + grouping: { entries: { fields: 'error' } }, + // The colour drops and every row still renders. + rowColor: { scalars: { field: 'warning' } }, + userFilters: { entries: { fields: 'error' } }, + kanban: { + // `columns` here are the fields shown ON a card, not the board's columns: + // a stale entry leaves one blank line on the card and the board renders. + scalars: { groupByField: 'error', summarizeField: 'warning' }, + lists: { columns: 'warning' }, + }, + calendar: { + scalars: { + startDateField: 'error', + endDateField: 'warning', + titleField: 'warning', + colorField: 'warning', + }, + }, + gantt: { + scalars: { + startDateField: 'error', + endDateField: 'error', + titleField: 'error', + progressField: 'warning', + dependenciesField: 'warning', + colorField: 'warning', + parentField: 'warning', + typeField: 'warning', + baselineStartField: 'warning', + baselineEndField: 'warning', + groupByField: 'warning', + assigneeField: 'warning', + effortField: 'warning', + }, + // A quick filter is a FILTER: a stale one filters on a column that does + // not exist and empties the chart. + entries: { tooltipFields: 'warning', quickFilters: 'error' }, + }, + timeline: { + scalars: { + startDateField: 'error', + titleField: 'error', + endDateField: 'warning', + groupByField: 'warning', + colorField: 'warning', + }, + }, + gallery: { + scalars: { coverField: 'warning', titleField: 'warning' }, + lists: { visibleFields: 'warning' }, + }, + map: { + // No resolvable coordinate ⇒ no marker is plotted at all. + scalars: { + latitudeField: 'error', + longitudeField: 'error', + locationField: 'error', + titleField: 'warning', + descriptionField: 'warning', + }, + }, + tree: { + // The parent pointer IS the hierarchy: a stale one flattens the tree. + scalars: { parentField: 'error', labelField: 'warning' }, + lists: { fields: 'warning' }, + }, +}; + +/** 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 + // footer shows a number for a column nobody asked about. + { block: 'summary', key: 'field', severity: 'error' }, + { block: 'prefix', key: 'field', severity: 'warning' }, +]; + +/** Filter positions on a list view, as `[key path from the view, where suffix]`. */ +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.'; + +/** + * Validate every list view's field references against the object graph. + * Returns findings (empty = clean). Pure `(stack) => Finding[]`; no I/O, and + * safe on both the schema-parsed stack and the raw config the `lint` path + * carries. + */ +export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFinding[] { + const findings: ListViewFieldRefFinding[] = []; + if (!isRec(stack)) return findings; + + const graph: ObjectGraph = indexObjectGraph(stack); + if (graph.size === 0) return findings; + + /** + * Judge ONE written reference at one position. + * + * The HEAD segment is what is resolved — see the dotted-path note on this + * module. `written` is carried into the message so the author reads back the + * string they typed rather than the segment the rule judged. + */ + const check = ( + raw: unknown, + object: string, + severity: Sev, + where: string, + path: string, + subject: string, + ): void => { + const written = strName(raw); + if (!written) return; + const head = written.split('.')[0]; + if (!head) return; + const verdict = resolveFieldPath(graph, object, head); + if (isUnjudgeable(verdict) || !verdict) return; + const account = describeFieldPathVerdict(verdict, head, subject); + if (!account) return; + + const dotted = head !== written; + findings.push({ + severity, + rule: LIST_VIEW_FIELD_UNKNOWN, + where, + path, + message: + account.message + + (dotted + ? ` (the head segment of the written reference "${written}"; a list view compiles ` + + 'no joins, so only the head can name a column here)' + : '') + + ` ${SILENT_EMPTY}`, + hint: + `Name a field that exists on "${object}", or drop the entry. ${account.detail}`, + }); + }; + + /** Every position on ONE list view record. */ + const checkListView = (listView: AnyRec, object: string | undefined, where: string, path: string): void => { + if (!isRec(listView)) return; + + // ── Skip 4 (this surface's own): a list view bound to a non-`object` + // provider names no fields on any object graph. + const data = listView.data; + if (isRec(data)) { + const provider = strName(data.provider); + if (provider && provider !== 'object') return; + } + if (!object) return; + + // ── Skips 1 & 2, once for the whole list view ── + // An unresolvable base object is `validate-object-references.ts`' finding; + // resolving anything against it here would turn one typo into a finding + // per position. + if (!graph.has(object) || !graph.get(object)) return; + // Bound to a `const` so the narrowing survives into the nested closures + // below (a parameter's narrowing does not). + const bound: string = object; + + for (const [block, spec] of Object.entries(POSITIONS)) { + const host = block === '' ? listView : listView[block]; + if (!isRec(host)) continue; + const hostPath = block === '' ? path : `${path}.${block}`; + const hostWhere = block === '' ? where : `${where} › ${block}`; + + for (const [key, severity] of Object.entries(spec.scalars ?? {})) { + check(host[key], bound, severity, hostWhere, `${hostPath}.${key}`, key); + } + + for (const [key, severity] of Object.entries(spec.lists ?? {})) { + const list = host[key]; + if (!Array.isArray(list)) continue; + list.forEach((entry, i) => { + check(entry, bound, severity, hostWhere, `${hostPath}.${key}[${i}]`, `${key}[${i}]`); + }); + } + + for (const [key, severity] of Object.entries(spec.entries ?? {})) { + const list = host[key]; + if (!Array.isArray(list)) continue; + list.forEach((entry, i) => { + const entryPath = `${hostPath}.${key}[${i}]`; + if (typeof entry === 'string') { + check(entry, bound, severity, hostWhere, entryPath, `${key}[${i}]`); + return; + } + if (!isRec(entry)) return; + check(entry.field, bound, severity, hostWhere, `${entryPath}.field`, `${key}[${i}].field`); + // The nested positions a `columns[]` entry carries. + if (block !== '' || key !== 'columns') return; + for (const nested of COLUMN_ENTRY_POSITIONS) { + const sub = entry[nested.block]; + if (!isRec(sub)) continue; + check( + sub[nested.key], + bound, + nested.severity, + `${hostWhere} › ${key}[${i}].${nested.block}`, + `${entryPath}.${nested.block}.${nested.key}`, + `${nested.block}.${nested.key}`, + ); + } + }); + } + } + + // ── Filter KEYS: the view's own filter, its tabs' filters, and the tab + // presets inside `userFilters`. `walkFilterFieldKeys` handles all three + // authored filter shapes (Mongo condition object, `{ field, operator, + // value }` rules, `[field, op, value]` triples) so a filter authored one + // way is not judged while another is silently skipped (#3574's own + // failure mode). + 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'); + }); + }; + + checkFilter(listView.filter, `${where} › filter`, `${path}.filter`); + + const checkTabs = (tabs: unknown, tabsWhere: string, tabsPath: string): void => { + if (!Array.isArray(tabs)) return; + tabs.forEach((tab, i) => { + if (!isRec(tab)) return; + const name = strName(tab.name) ?? `#${i}`; + checkFilter(tab.filter, `${tabsWhere}[${i}] "${name}" › filter`, `${tabsPath}[${i}].filter`); + }); + }; + + checkTabs(listView.tabs, `${where} › tabs`, `${path}.tabs`); + if (isRec(listView.userFilters)) { + checkTabs( + listView.userFilters.tabs, + `${where} › userFilters.tabs`, + `${path}.userFilters.tabs`, + ); + } + }; + + /** A list view's own object binding: `data: { provider: 'object', object }`. */ + const listViewObject = (listView: AnyRec): string | undefined => { + const data = listView.data; + return isRec(data) ? strName(data.object) : undefined; + }; + + // ── An object's built-in named list views ── + // The same rungs `validate-searchable-fields` and `validate-sortable-fields` + // walk, so the three field axes on this surface cannot cover different sets + // of list views. + const objects = asArray(stack.objects); + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!isRec(obj)) continue; + const objName = strName(obj.name); + const label = objName ? `object "${objName}"` : `objects[${oi}]`; + if (!isRec(obj.listViews)) continue; + for (const [key, lv] of Object.entries(obj.listViews)) { + if (!isRec(lv)) continue; + checkListView( + lv, + // A built-in list view belongs to its object; an inline `data.object` + // may still retarget it (ADR-0047 allows the explicit binding). + listViewObject(lv) ?? objName, + `${label} › listViews.${key}`, + `objects[${oi}].listViews.${key}`, + ); + } + } + + // ── `defineView` aggregates, plus the two standalone `views[]` shapes the + // `PUT /api/v1/meta/view` door carries and the runtime publish gate + // snapshots. Recognisers mirrored from the sort/search twins, which carry + // the full notes: the flattened list overlay (#9313, `viewKind: 'list'` with + // no nested `config`) and the ViewItem record (#10001, one level down + // inside `config`). + const views = asArray(stack.views); + for (let vi = 0; vi < views.length; vi++) { + const view = views[vi]; + if (!isRec(view)) continue; + const viewLabel = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; + // The aggregate's own binding is the fallback for a list view that + // declares none — the same resolution order `validate-list-view-mode` + // reads. + const viewObject = strName(view.objectName) ?? strName(view.object); + + if (view.viewKind === 'list' && !isRec(view.config)) { + checkListView( + view, + listViewObject(view) ?? viewObject, + `view "${viewLabel}" (flattened list overlay)`, + `views[${vi}]`, + ); + } + + if (view.viewKind === 'list' && isRec(view.config)) { + checkListView( + view.config, + listViewObject(view.config) ?? viewObject, + `view "${viewLabel}" (ViewItem record)`, + `views[${vi}].config`, + ); + } + + if (isRec(view.list)) { + checkListView( + view.list, + listViewObject(view.list) ?? viewObject, + `view "${viewLabel}" › list`, + `views[${vi}].list`, + ); + } + + if (isRec(view.listViews)) { + for (const [key, lv] of Object.entries(view.listViews)) { + if (!isRec(lv)) continue; + checkListView( + lv, + listViewObject(lv) ?? viewObject, + `view "${viewLabel}" › listViews.${key}`, + `views[${vi}].listViews.${key}`, + ); + } + } + } + + return findings; +}