Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/list-view-dotted-field-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": minor
---

fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)

An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
as `minor`, matching the level that landing and the two family landings before it
(#14105, #14148) were given.

#14107 judges only the HEAD segment of a list-view field reference, so a dotted
path whose head resolves to a real relationship field (`columns: [{ field:
'owner.name' }]`) passed `os validate` and `os build` clean while every query
door a list view reaches refuses it by name. That half was recorded in the rule's
docblock and pinned in tests rather than closed, because its failure mode is the
opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
the first fetch. This is the ruled resolution of that half, as a second finding
class with its own id, `list-view-field-dotted`, so one class can be suppressed
or filtered without silencing the other (the convention
`validate-sortable-fields` and `validate-searchable-fields` already follow).

The class is scoped by the DOOR, not by the position table, because some
list-view positions are read client-side out of the fetched row and walk a dotted
path perfectly well:

- **Projection** — `columns[]`, in both authored spellings. Clients build the
`$select` projection from them, and both doors refuse a dotted entry
unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
`assertProjectionFieldsExist` at the REST ingress).
- **Filter** — the view's `filter`, its `tabs[].filter`, its
`userFilters.tabs[].filter`, and the two positions declaring which names an end
user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
whose type is unreadable — are NOT refused at author time.

Deliberately excluded, each measured rather than assumed:
`gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
resolves IN MEMORY over already-fetched rows through walkers that split on `.`
(the spec describes the former as "Record field / dot-path", and the measurement
agreed); and every renderer binding that reaches no query door, which stays
unjudged rather than acquiring a verdict nobody measured.

Existing behaviour is untouched: a dotted path whose head resolves to nothing
still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
shipped example corpus was measured at zero findings both before and after.
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,9 +434,14 @@ export type {
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
// [#14282] The same rule's SECOND finding class: a dotted reference at a
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,19 @@ export interface GraphField {
* tolerant consumer Prime Directive #12 refuses, so only `reference` is read.
*/
reference?: string;
/**
* The declared `multiple: true` flag, when the author wrote one.
*
* Read here because the dotted-path verdict a caller may reach for
* ({@link classifyDottedFilterHead} in `@objectstack/spec/data`) is a
* function of BOTH `type` and `multiple`: an array-valued head is
* deliberately unjudged there, since a numeric-index dotted path genuinely
* reaches into it on two of three backends. A caller handed only `type`
* would have to re-derive the flag from the raw stack, which is the second
* copy this module exists to prevent. Additive (#14282): every existing
* consumer that ignores the key keeps its verdicts byte-for-byte.
*/
multiple?: boolean;
}

/**
Expand DownExpand Up@@ -128,6 +141,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null {
fields.set(n, {
type: typeof f.type === 'string' ? f.type : undefined,
reference: strName(f.reference),
multiple: f.multiple === true ? true : undefined,
});
}
if (names.size === 0) return null;
Expand Down
265 changes: 256 additions & 9 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
type ListViewFieldRefFinding,
} from './validate-list-view-field-refs.js';
import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js';
Expand All@@ -39,6 +40,12 @@ const OBJECTS = [
{ name: 'cover', type: 'image', label: 'Cover' },
{ name: 'parent', type: 'lookup', reference: 'duly_task', label: 'Parent' },
{ name: 'owner', type: 'lookup', reference: 'duly_person', label: 'Owner' },
// [#14282] The three head shapes the FILTER door treats differently.
// `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
// memory and mongodb); `score` is virtual; `tags` is array-valued.
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
],
},
{
Expand DownExpand Up@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
/**
* The recorded dotted-path decision (see the rule's module docblock): the HEAD
* segment is judged and relationship hops are NOT walked, because a list view
* compiles no joins and all three runtime doors refuse a dotted reference.
* Both halves are pinned — the half that reports, and the half that stays
* deliberately silent — so a later "improvement" that starts walking hops has
* to delete a test that says why.
* compiles no joins and the runtime doors refuse a dotted reference.
*
* ⚠️ This block used to pin BOTH halves — the half that reports, and a half
* that stayed deliberately silent (`owner.name` and `title.x` in `columns`
* passing clean). #14282 is the card that half was recorded for, and it ruled
* the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
* cases were rewritten rather than deleted, so the pair still reads as one
* decision — what changed is which class each lands in, not whether the rule
* has an opinion. The `#14282` block below carries the new half in full.
*/
describe('#14107 — dotted paths', () => {
describe('#14107 — dotted paths, HEAD-segment resolution', () => {
it('a dotted path whose HEAD resolves to nothing is reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'ownr.name' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].message).toContain('"ownr"');
// The author reads back what they typed, not only the segment judged.
expect(findings[0].message).toContain('ownr.name');
expect(findings[0].message).toContain('compiles');
});

it('a dotted path whose head resolves is left to the runtime doors', () => {
it('hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf', () => {
// `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
// would be a `field-unknown` on `duly_person`. It is not: the finding is
// the #14282 dotted class, which never mentions the leaf at all.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.nope' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].message).not.toContain('duly_person');
});
});

/**
* [#14282] The SECOND finding class: a dotted reference at a position whose
* name reaches a query door, where that door refuses it by name.
*
* The scoping is by DOOR, not by position — see the rule's module note. So
* this block pins three things and not one: which positions report, which
* deliberately do not (the measured client-side ones, `gantt.quickFilters`
* first among them), and that the FILTER positions ask the same
* `classifyDottedFilterHead` the runtime door asks, rather than refusing what
* the door serves.
*/
describe('#14282 — a dotted reference the PROJECTION door refuses', () => {
it('a dotted `columns[].field` whose head resolves is now reported', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'owner.name' }] })));
expect(findings).toEqual([]);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.columns[0].field');
expect(findings[0].message).toContain('owner.name');
expect(findings[0].message).toContain('assertProjectionHasNoDottedPaths');
expect(findings[0].hint).toContain('"owner"');
});

it('the bare-string `columns[]` spelling is judged too', () => {
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: ['owner.name'] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].path).toBe('views[0].list.columns[0]');
});

it('a dotted path through a non-relationship head is also left alone', () => {
// `title` is a text field; `title.x` is refused at query time, not here.
it('the projection door has NO head carve-out, so a scalar head reports too', () => {
// `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
// `f.includes('.')` alone — the head's type never enters that door.
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'title.x' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('a structured/JSON head is reported at a COLUMN even though the filter door serves it', () => {
// The #8371 carve-out is the FILTER door's, not the projection door's.
// Getting this wrong in either direction is the whole point of scoping the
// class by door rather than by "a list view compiles no joins".
const findings = validateListViewFieldRefs(stackWith(mutate({ columns: [{ field: 'payload.theme' }] })));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
});

it('an undotted column is untouched by the new class', () => {
expect(validateListViewFieldRefs(stackWith(FULL_LIST_VIEW))).toEqual([]);
});
});

describe('#14282 — a dotted key the FILTER door refuses, and the ones it serves', () => {
const filterOn = (field: string): AnyRec => ({
filter: [{ field, operator: 'equals', value: 'x' }],
});

it('a relation head is refused — it stores an id, not an embedded document', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner.name'))));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.filter[0].field');
expect(findings[0].message).toContain('lookup');
expect(findings[0].message).toContain('can only match zero records');
});

it('a virtual head is refused — nothing materialises a column to reach into', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('score.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('computed');
});

it('a plain scalar head is refused — there is nothing beneath it', () => {
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('title.x'))));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('single scalar value');
});

it('⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out', () => {
// Live on driver-memory and driver-mongodb (2 rows in the #8371
// measurement table). Refusing it at author time would delete a working
// capability on two of three backends — the exact fail-closed drift the
// shared classifier exists to prevent.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('payload.theme'))))).toEqual([]);
});

it('⛔ an array-valued head is NOT refused — a numeric-index path reaches it', () => {
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
});

it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
// `created_at` resolves through skip 3 with no readable type, and
// `classifyDottedFilterHead` answers `null` for an unreadable head.
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
},
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () => {
it('⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception', () => {
// Measured, and it went the other way round from the rest of the card.
// The spec describes the position as "Record field / dot-path", and
// objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
// already-fetched rows, resolving each through a walker that splits on `.`
// and steps through the record object (`resolveFilterKey`). No query door
// is involved, so nothing refuses it.
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'owner.name' }] } })),
);
expect(findings).toEqual([]);
});

it('the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { quickFilters: [{ field: 'ownr.name' }] } })),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
});

it('⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ gantt: { tooltipFields: ['owner.name', { field: 'parent.title' }] } })),
);
expect(findings).toEqual([]);
});

it('⛔ renderer bindings reach no door this card measured, so they stay unjudged', () => {
// Very likely still wrong (the gantt scalars read `record[field]` flat),
// but "likely wrong" is not a verdict a gate may invent — and the failure
// would be the SILENT class, not this loud one. Recorded as a follow-up.
const findings = validateListViewFieldRefs(
stackWith(
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
gallery: { coverField: 'owner.name' },
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
fieldOrder: ['owner.name'],
}),
),
);
expect(findings).toEqual([]);
});

it('a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
columns: [{ field: 'title', summary: { field: 'owner.name' }, prefix: { field: 'parent.title' } }],
}),
),
);
expect(findings).toEqual([]);
});
});

describe('#14282 — the class does not disturb its neighbours', () => {
it('the skips still win over the dotted verdict', () => {
// An object this stack does not define: no graph, no verdict of any kind.
const stack = stackWith(
mutate({ data: { provider: 'object', object: 'sys_elsewhere' }, columns: [{ field: 'owner.name' }] }),
);
expect(validateListViewFieldRefs(stack)).toEqual([]);
});

it('`sort[]` keeps its owner — no dotted finding is minted for it here', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ sort: [{ field: 'owner.name', order: 'asc' }] })),
);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('the two classes carry DIFFERENT rule ids, so one can be suppressed alone', () => {
const findings = validateListViewFieldRefs(
stackWith(mutate({ columns: [{ field: 'ownr.name' }, { field: 'owner.name' }] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]);
});

it('the dotted class gates `validate` and `build`, like the rest of the error tier', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
for (const command of ['validate', 'build'] as const) {
const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
expect(errors.map((e) => e.rule)).toContain(LIST_VIEW_FIELD_DOTTED);
}
});

it('the reference-integrity suite carries the new class too', () => {
const stack = stackWith(mutate({ columns: [{ field: 'owner.name' }] }));
const findings = validateReferenceIntegrity(stack);
expect(findings.some((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});
});

describe('#14107 — the skips', () => {
Expand Down
Loading
Loading