Skip to content

Commit 8d3f093

Browse files
claude[bot]claude
andauthored
fix(lint): refuse a list view's dotted field reference where the runtime door refuses it (#14368)
#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. Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ec4f6f commit 8d3f093

5 files changed

Lines changed: 544 additions & 20 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)
6+
7+
An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
8+
as `minor`, matching the level that landing and the two family landings before it
9+
(#14105, #14148) were given.
10+
11+
#14107 judges only the HEAD segment of a list-view field reference, so a dotted
12+
path whose head resolves to a real relationship field (`columns: [{ field:
13+
'owner.name' }]`) passed `os validate` and `os build` clean while every query
14+
door a list view reaches refuses it by name. That half was recorded in the rule's
15+
docblock and pinned in tests rather than closed, because its failure mode is the
16+
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
17+
the first fetch. This is the ruled resolution of that half, as a second finding
18+
class with its own id, `list-view-field-dotted`, so one class can be suppressed
19+
or filtered without silencing the other (the convention
20+
`validate-sortable-fields` and `validate-searchable-fields` already follow).
21+
22+
The class is scoped by the DOOR, not by the position table, because some
23+
list-view positions are read client-side out of the fetched row and walk a dotted
24+
path perfectly well:
25+
26+
- **Projection**`columns[]`, in both authored spellings. Clients build the
27+
`$select` projection from them, and both doors refuse a dotted entry
28+
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
29+
`assertProjectionFieldsExist` at the REST ingress).
30+
- **Filter** — the view's `filter`, its `tabs[].filter`, its
31+
`userFilters.tabs[].filter`, and the two positions declaring which names an end
32+
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
33+
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
34+
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
35+
whose type is unreadable — are NOT refused at author time.
36+
37+
Deliberately excluded, each measured rather than assumed:
38+
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
39+
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
40+
(the spec describes the former as "Record field / dot-path", and the measurement
41+
agreed); and every renderer binding that reaches no query door, which stays
42+
unjudged rather than acquiring a verdict nobody measured.
43+
44+
Existing behaviour is untouched: a dotted path whose head resolves to nothing
45+
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
46+
shipped example corpus was measured at zero findings both before and after.

packages/lint/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,14 @@ export type {
435435
// timeline / gallery / map / tree blocks). Resolution goes through the shared
436436
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
437437
// module's dotted-path note.
438+
// [#14282] The same rule's SECOND finding class: a dotted reference at a
439+
// position whose name reaches a query door (the `$select` projection, or the
440+
// compiled filter), where that door refuses it by name — the loud-failing half
441+
// #14107 recorded and left open.
438442
export {
439443
validateListViewFieldRefs,
440444
LIST_VIEW_FIELD_UNKNOWN,
445+
LIST_VIEW_FIELD_DOTTED,
441446
} from './validate-list-view-field-refs.js';
442447
export type {
443448
ListViewFieldRefFinding,

packages/lint/src/object-graph.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ export interface GraphField {
8282
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
8383
*/
8484
reference?: string;
85+
/**
86+
* The declared `multiple: true` flag, when the author wrote one.
87+
*
88+
* Read here because the dotted-path verdict a caller may reach for
89+
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
90+
* function of BOTH `type` and `multiple`: an array-valued head is
91+
* deliberately unjudged there, since a numeric-index dotted path genuinely
92+
* reaches into it on two of three backends. A caller handed only `type`
93+
* would have to re-derive the flag from the raw stack, which is the second
94+
* copy this module exists to prevent. Additive (#14282): every existing
95+
* consumer that ignores the key keeps its verdicts byte-for-byte.
96+
*/
97+
multiple?: boolean;
8598
}
8699

87100
/**
@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
128141
fields.set(n, {
129142
type: typeof f.type === 'string' ? f.type : undefined,
130143
reference: strName(f.reference),
144+
multiple: f.multiple === true ? true : undefined,
131145
});
132146
}
133147
if (names.size === 0) return null;

packages/lint/src/validate-list-view-field-refs.test.ts

Lines changed: 256 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
1515
import {
1616
validateListViewFieldRefs,
1717
LIST_VIEW_FIELD_UNKNOWN,
18+
LIST_VIEW_FIELD_DOTTED,
1819
type ListViewFieldRefFinding,
1920
} from './validate-list-view-field-refs.js';
2021
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
@@ -39,6 +40,12 @@ const OBJECTS = [
3940
{ name: 'cover', type: 'image', label: 'Cover' },
4041
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
4142
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
43+
// [#14282] The three head shapes the FILTER door treats differently.
44+
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
45+
// memory and mongodb); `score` is virtual; `tags` is array-valued.
46+
{ name: 'payload', type: 'json', label: 'Payload' },
47+
{ name: 'score', type: 'formula', label: 'Score' },
48+
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
4249
],
4350
},
4451
{
@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
297304
/**
298305
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
299306
* segment is judged and relationship hops are NOT walked, because a list view
300-
* compiles no joins and all three runtime doors refuse a dotted reference.
301-
* Both halves are pinned — the half that reports, and the half that stays
302-
* deliberately silent — so a later "improvement" that starts walking hops has
303-
* to delete a test that says why.
307+
* compiles no joins and the runtime doors refuse a dotted reference.
308+
*
309+
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
310+
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
311+
* passing clean). #14282 is the card that half was recorded for, and it ruled
312+
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
313+
* cases were rewritten rather than deleted, so the pair still reads as one
314+
* decision — what changed is which class each lands in, not whether the rule
315+
* has an opinion. The `#14282` block below carries the new half in full.
304316
*/
305-
describe('#14107 — dotted paths', () => {
317+
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
306318
it('a dotted path whose HEAD resolves to nothing is reported', () => {
307319
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
308320
expect(findings).toHaveLength(1);
321+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
309322
expect(findings[0].message).toContain('"ownr"');
310323
// The author reads back what they typed, not only the segment judged.
311324
expect(findings[0].message).toContain('ownr.name');
312325
expect(findings[0].message).toContain('compiles');
313326
});
314327

315-
it('a dotted path whose head resolves is left to the runtime doors', () => {
328+
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
329+
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
330+
// would be a `field-unknown` on `duly_person`. It is not: the finding is
331+
// the #14282 dotted class, which never mentions the leaf at all.
332+
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
333+
expect(findings).toHaveLength(1);
334+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
335+
expect(findings[0].message).not.toContain('duly_person');
336+
});
337+
});
338+
339+
/**
340+
* [#14282] The SECOND finding class: a dotted reference at a position whose
341+
* name reaches a query door, where that door refuses it by name.
342+
*
343+
* The scoping is by DOOR, not by position — see the rule's module note. So
344+
* this block pins three things and not one: which positions report, which
345+
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
346+
* first among them), and that the FILTER positions ask the same
347+
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
348+
* the door serves.
349+
*/
350+
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
351+
it('a dotted `columns[].field` whose head resolves is now reported', () => {
316352
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
317-
expect(findings).toEqual([]);
353+
expect(findings).toHaveLength(1);
354+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
355+
expect(findings[0].severity).toBe('error');
356+
expect(findings[0].path).toBe('views[0].list.columns[0].field');
357+
expect(findings[0].message).toContain('owner.name');
358+
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
359+
expect(findings[0].hint).toContain('"owner"');
360+
});
361+
362+
it('the bare-string `columns[]` spelling is judged too', () => {
363+
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
364+
expect(findings).toHaveLength(1);
365+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
366+
expect(findings[0].path).toBe('views[0].list.columns[0]');
318367
});
319368

320-
it('a dotted path through a non-relationship head is also left alone', () => {
321-
// `title` is a text field; `title.x` is refused at query time, not here.
369+
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
370+
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
371+
// `f.includes('.')` alone — the head's type never enters that door.
322372
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
373+
expect(findings).toHaveLength(1);
374+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
375+
});
376+
377+
it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
378+
// The #8371 carve-out is the FILTER door's, not the projection door's.
379+
// Getting this wrong in either direction is the whole point of scoping the
380+
// class by door rather than by "a list view compiles no joins".
381+
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
382+
expect(findings).toHaveLength(1);
383+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
384+
});
385+
386+
it('an undotted column is untouched by the new class', () => {
387+
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
388+
});
389+
});
390+
391+
describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
392+
const filterOn = (field: string): AnyRec => ({
393+
filter: [{ field, operator: 'equals', value: 'x' }],
394+
});
395+
396+
it('a relation head is refused — it stores an id, not an embedded document', () => {
397+
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
398+
expect(findings).toHaveLength(1);
399+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
400+
expect(findings[0].severity).toBe('error');
401+
expect(findings[0].path).toBe('views[0].list.filter[0].field');
402+
expect(findings[0].message).toContain('lookup');
403+
expect(findings[0].message).toContain('can only match zero records');
404+
});
405+
406+
it('a virtual head is refused — nothing materialises a column to reach into', () => {
407+
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
408+
expect(findings).toHaveLength(1);
409+
expect(findings[0].message).toContain('computed');
410+
});
411+
412+
it('a plain scalar head is refused — there is nothing beneath it', () => {
413+
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
414+
expect(findings).toHaveLength(1);
415+
expect(findings[0].message).toContain('single scalar value');
416+
});
417+
418+
it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
419+
// Live on driver-memory and driver-mongodb (2 rows in the #8371
420+
// measurement table). Refusing it at author time would delete a working
421+
// capability on two of three backends — the exact fail-closed drift the
422+
// shared classifier exists to prevent.
423+
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
424+
});
425+
426+
it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
427+
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
428+
});
429+
430+
it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
431+
// `created_at` resolves through skip 3 with no readable type, and
432+
// `classifyDottedFilterHead` answers `null` for an unreadable head.
433+
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
434+
});
435+
436+
it('the tab and user-filter tab presets are judged on the same axis', () => {
437+
const findings = validateListViewFieldRefs(
438+
stackWith(
439+
mutate({
440+
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
441+
userFilters: {
442+
fields: [{ field: 'status' }],
443+
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
444+
},
445+
}),
446+
),
447+
);
448+
expect(idsOf(findings).sort()).toEqual([
449+
'views[0].list.tabs[0].filter[0].field',
450+
'views[0].list.userFilters.tabs[0].filter[0].field',
451+
]);
452+
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
453+
});
454+
455+
it('the two positions that DECLARE end-user filterable names are judged', () => {
456+
// objectui folds the resulting conditions into the fetched query
457+
// (`buildEffectiveFilter`), so these names become filter keys.
458+
const findings = validateListViewFieldRefs(
459+
stackWith(
460+
mutate({
461+
filterableFields: ['owner.name'],
462+
userFilters: { fields: [{ field: 'parent.title' }] },
463+
}),
464+
),
465+
);
466+
expect(idsOf(findings).sort()).toEqual([
467+
'views[0].list.filterableFields[0]',
468+
'views[0].list.userFilters.fields[0].field',
469+
]);
470+
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
471+
});
472+
});
473+
474+
describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
475+
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
476+
// Measured, and it went the other way round from the rest of the card.
477+
// The spec describes the position as "Record field / dot-path", and
478+
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
479+
// already-fetched rows, resolving each through a walker that splits on `.`
480+
// and steps through the record object (`resolveFilterKey`). No query door
481+
// is involved, so nothing refuses it.
482+
const findings = validateListViewFieldRefs(
483+
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
484+
);
323485
expect(findings).toEqual([]);
324486
});
487+
488+
it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
489+
const findings = validateListViewFieldRefs(
490+
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
491+
);
492+
expect(findings).toHaveLength(1);
493+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
494+
});
495+
496+
it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
497+
const findings = validateListViewFieldRefs(
498+
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
499+
);
500+
expect(findings).toEqual([]);
501+
});
502+
503+
it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
504+
// Very likely still wrong (the gantt scalars read `record[field]` flat),
505+
// but "likely wrong" is not a verdict a gate may invent — and the failure
506+
// would be the SILENT class, not this loud one. Recorded as a follow-up.
507+
const findings = validateListViewFieldRefs(
508+
stackWith(
509+
mutate({
510+
rowColor: { field: 'owner.name' },
511+
kanban: { groupByField: 'owner.name' },
512+
calendar: { titleField: 'owner.name' },
513+
gallery: { coverField: 'owner.name' },
514+
tree: { parentField: 'owner.name' },
515+
grouping: { fields: [{ field: 'owner.name' }] },
516+
hiddenFields: ['owner.name'],
517+
fieldOrder: ['owner.name'],
518+
}),
519+
),
520+
);
521+
expect(findings).toEqual([]);
522+
});
523+
524+
it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
525+
const findings = validateListViewFieldRefs(
526+
stackWith(
527+
mutate({
528+
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
529+
}),
530+
),
531+
);
532+
expect(findings).toEqual([]);
533+
});
534+
});
535+
536+
describe('#14282 — the class does not disturb its neighbours', () => {
537+
it('the skips still win over the dotted verdict', () => {
538+
// An object this stack does not define: no graph, no verdict of any kind.
539+
const stack = stackWith(
540+
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
541+
);
542+
expect(validateListViewFieldRefs(stack)).toEqual([]);
543+
});
544+
545+
it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
546+
const findings = validateListViewFieldRefs(
547+
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
548+
);
549+
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
550+
});
551+
552+
it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
553+
const findings = validateListViewFieldRefs(
554+
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
555+
);
556+
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
557+
});
558+
559+
it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
560+
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
561+
for (const command of ['validate', 'build'] as const) {
562+
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
563+
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
564+
}
565+
});
566+
567+
it('the reference-integrity suite carries the new class too', () => {
568+
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
569+
const findings = validateReferenceIntegrity(stack);
570+
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
571+
});
325572
});
326573

327574
describe('#14107 — the skips', () => {

0 commit comments

Comments
 (0)