Skip to content

Commit aca23ab

Browse files
claude[bot]claude
andauthored
fix(lint): resolve every field reference on a list view at validate and build (#14283)
* feat(lint): resolve a list view's field references at validate/build Every 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`. Resolution goes through the shared object-graph seam, on the HEAD segment: a list view compiles no joins and all three runtime doors refuse a dotted reference, so walking relationship hops here would bless what the runtime refuses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV * test(lint): pin the new list-view field-ref rule at the suite and runtime-gate doors Suite membership plus the runtime publish-gate crossing (refusal + clean pair) for validateListViewFieldRefs, and the changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV * docs(lint): name the filed follow-up in the dotted-path decision note The wider narrowing the module docblock defers — a dotted list-view reference whose head resolves, refused by all three runtime doors — is filed as #14282; name it so the next author can read the decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8125673 commit aca23ab

7 files changed

Lines changed: 1197 additions & 0 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): resolve a list view's field references at validate/build (#14107)
6+
7+
Accept-set narrowing, `minor` under the family precedent (#14105, #14148).
8+
9+
A list view names fields in more than twenty places and **none of them was
10+
resolved against the bound object** — not by `os validate`, and not by `os
11+
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
12+
real app, each mutation applied on its own and confirmed on disk: a
13+
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
14+
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
15+
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
16+
exit 0, `✓ Build complete`.
17+
18+
Each one fails silently at render, in the way ADR-0078 and the
19+
`view/layout-without-binding` rule already treat as worth gating: a bad column
20+
renders blanks, a bad filter key is sent to the engine and matches nothing (an
21+
empty list indistinguishable from a true zero), a bad gantt start date leaves a
22+
blank chart, a bad kanban group-by collapses every card into the uncolumned
23+
bucket. The platform already shipped the *harder* half of this check —
24+
`view/layout-without-binding` warns when a binding block is **absent**; a block
25+
that is present but points at a field that does not exist reaches the identical
26+
end state and got nothing.
27+
28+
The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
29+
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
30+
on `view` per-write publish snapshots) resolves every field-naming position on a
31+
list view against the object graph:
32+
33+
- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
34+
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
35+
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
36+
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
37+
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
38+
`gallery`, `map` and `tree` blocks.
39+
40+
`sort[]` and `searchableFields[]` are deliberately untouched — they already have
41+
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
42+
each with a runtime-admissibility verdict on top of existence.
43+
44+
Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
45+
miss changes the data the view returns or collapses the layout it configures
46+
(every position in the card's measured table), `warning` where the renderer
47+
drops one decoration and renders the rest (optional colour/title/tooltip/cover
48+
bindings, a stale `hiddenFields` or `fieldOrder` entry).
49+
50+
Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
51+
second field-resolution implementation — and judges the **head segment** of a
52+
dotted reference rather than walking relationship hops: a list view compiles no
53+
joins, and all three query axes it reaches refuse a dotted path by name
54+
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
55+
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
56+
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
57+
have passed it.
58+
59+
**Migration.** A list view refused by the new rule names a field the bound
60+
object does not have: correct the spelling (the finding carries a "did you mean"
61+
and the object's field list) or drop the entry. The three standard skips apply —
62+
an object this stack does not define, an object with no readable field map
63+
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
64+
this surface: a list view whose `data.provider` is not `object`.

packages/lint/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,22 @@ export type {
427427
SortableFieldSeverity,
428428
} from './validate-sortable-fields.js';
429429

430+
// [#14107] The rest of the list view's field surface — the two rules above own
431+
// `sort` and `searchableFields`; this one owns every OTHER position that names
432+
// a field on the bound object (columns, filter keys, grouping, row colour,
433+
// user filters, and every binding inside the kanban / calendar / gantt /
434+
// timeline / gallery / map / tree blocks). Resolution goes through the shared
435+
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
436+
// module's dotted-path note.
437+
export {
438+
validateListViewFieldRefs,
439+
LIST_VIEW_FIELD_UNKNOWN,
440+
} from './validate-list-view-field-refs.js';
441+
export type {
442+
ListViewFieldRefFinding,
443+
ListViewFieldRefSeverity,
444+
} from './validate-list-view-field-refs.js';
445+
430446
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
431447
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';
432448

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
1818
'validateObjectReferences',
1919
'validateSearchableFields',
2020
'validateSortableFields',
21+
// [#14107] The rest of the same list view's field surface — every
22+
// field-naming position the two members above do not own. Placed beside
23+
// them because the three walk the identical rungs.
24+
'validateListViewFieldRefs',
2125
'validateActionNameRefs',
2226
'validatePageFieldBindings',
2327
'validateChartBindings',

packages/lint/src/reference-integrity-suite.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@
5050
* because a view's declared sort is its FIRST fetch, the refusal is the whole
5151
* view failing to load, every time, from an authoring typo made long before.
5252
*
53+
* `validateListViewFieldRefs` completes that pair (#14107): the two members
54+
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
55+
* OTHER field-naming position on the same record — `columns`, filter keys,
56+
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
57+
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
58+
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
59+
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
60+
* miss empties or mis-selects the view's data gates, one whose miss drops a
61+
* decoration advises.
62+
*
5363
* Rules that check SHAPE rather than reference (view containers, responsive
5464
* styles, seed replay safety, seed state machines, seed/security posture) stay
5565
* out — they answer a different question and have their own call sites.
@@ -79,6 +89,7 @@
7989
import { validateObjectReferences } from './validate-object-references.js';
8090
import { validateSearchableFields } from './validate-searchable-fields.js';
8191
import { validateSortableFields } from './validate-sortable-fields.js';
92+
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
8293
import { validateActionNameRefs } from './validate-action-name-refs.js';
8394
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
8495
import { validateChartBindings } from './validate-chart-bindings.js';
@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
187198
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
188199
// the refusal is the whole view, on every load, traced to nothing.
189200
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
201+
// [#14107] The REST of the same list view's field surface. The two members
202+
// above own `sort` and `searchableFields`; every OTHER field-naming position
203+
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
204+
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
205+
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
206+
// tree blocks — was resolved by nothing, on both `os validate` and `os
207+
// build`. Placed directly after its two siblings because the three walk the
208+
// identical rungs (an object's `listViews`, a `defineView` aggregate's
209+
// `list` / `listViews`, and the two standalone `views[]` shapes the
210+
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
211+
// against the other two.
212+
//
213+
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
214+
// did, and the reason is a property of the SNAPSHOT rather than a
215+
// convenience: this member resolves only against `stack.objects`, which the
216+
// per-write snapshot does carry, so it has no missing-collection
217+
// false-positive channel. The standalone list view a Studio tenant or an
218+
// MCP/AI author writes goes through that door and no CLI, so a
219+
// build-time-only rule would never reach the author who made the typo.
220+
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
190221
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
191222
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
192223
{ name: 'validateChartBindings', run: validateChartBindings },

packages/lint/src/runtime-gate.view-writes.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
2929
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
3030
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
3131
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
32+
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';
3233

3334
/** The live object universe the gate resolves against (`RuntimeStackContext.objects`). */
3435
const objects = [
@@ -127,6 +128,57 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
127128
expect(errors, JSON.stringify(errors)).toEqual([]);
128129
});
129130

131+
// ── [#14107] the rest of the same overlay's field surface ──
132+
//
133+
// The two rules above own `sort` and `searchableFields`; every OTHER
134+
// field-naming position on the same overlay was resolved by nothing, at this
135+
// door as well as at the CLI. Same self rung, same skips, same binding
136+
// order — these are the refusal/clean pair that distinguishes a real
137+
// crossing from a dispatch-only no-op.
138+
139+
it('REFUSES a top-level `columns` entry that resolves to no field', () => {
140+
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
141+
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
142+
expect(f, JSON.stringify(errors)).toBeDefined();
143+
expect(f!.path).toBe('views[0].columns[1]');
144+
expect(f!.where).toContain('flattened list overlay');
145+
});
146+
147+
it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
148+
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
149+
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
150+
expect(f, JSON.stringify(errors)).toBeDefined();
151+
expect(f!.path).toBe('views[0].kanban.groupByField');
152+
expect(f!.message).toContain('Did you mean "status"?');
153+
});
154+
155+
it('REFUSES a top-level `filter` key that resolves to no field', () => {
156+
const { errors } = gate(overlay({
157+
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
158+
}));
159+
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
160+
expect(f, JSON.stringify(errors)).toBeDefined();
161+
expect(f!.path).toBe('views[0].filter[0].field');
162+
});
163+
164+
it('a fully bound overlay publishes clean across every one of those positions', () => {
165+
const result = gate(overlay({
166+
columns: ['name', { field: 'status' }],
167+
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
168+
grouping: { fields: [{ field: 'status' }] },
169+
rowColor: { field: 'status' },
170+
kanban: { groupByField: 'status', columns: ['name'] },
171+
hiddenFields: ['days_open'],
172+
}));
173+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
174+
expect(result.rulesRun).toContain('validateReferenceIntegrity');
175+
});
176+
177+
it('a system column in a walked position publishes clean (skip ③)', () => {
178+
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
179+
expect(errors, JSON.stringify(errors)).toEqual([]);
180+
});
181+
130182
// ── the granularity wall: exactly two members cross, nothing rides along ──
131183

132184
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)', () => {
178230
// precisely so a fourth crossing has to be argued here; this one's
179231
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
180232
// which reproduces the phantom findings the collection removes.
233+
// [#14107] The fourth crossing, argued here as this list demands. It is the
234+
// same KIND of crossing as the first two — a list view's field references,
235+
// resolved against `stack.objects`, the one collection every per-write
236+
// snapshot carries — so it has no missing-collection false-positive
237+
// channel to open; the controls directly below are its measurement, on the
238+
// shape the door actually carries. Not crossing it would have been the
239+
// #9313 failure inverted: the standalone list view a Studio tenant or an
240+
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
241+
// a build-time-only rule never reaches the author who made the typo.
181242
expect(crossed).toEqual([
182243
'validateSearchableFields',
183244
'validateSortableFields',
245+
'validateListViewFieldRefs',
184246
'validateViewPageRefs',
185247
]);
186248
// And every member still judges flow snapshots — the #4463 P1 surface is

0 commit comments

Comments
 (0)