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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
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
64 changes: 64 additions & 0 deletions .changeset/list-view-field-ref-integrity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve a list view's field references at validate/build (#14107)

Accept-set narrowing, `minor` under the family precedent (#14105, #14148).

A list view names fields in more than twenty places and **none of them was
resolved against the bound object** — not by `os validate`, and not by `os
build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
real app, each mutation applied on its own and confirmed on disk: a
`columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
`kanban.groupByField` and a `gantt.startDateField` naming a field that does not
exist all left `os validate` at `valid: true, warnings: []` and `os build` at
exit 0, `✓ Build complete`.

Each one fails silently at render, in the way ADR-0078 and the
`view/layout-without-binding` rule already treat as worth gating: a bad column
renders blanks, a bad filter key is sent to the engine and matches nothing (an
empty list indistinguishable from a true zero), a bad gantt start date leaves a
blank chart, a bad kanban group-by collapses every card into the uncolumned
bucket. The platform already shipped the *harder* half of this check —
`view/layout-without-binding` warns when a binding block is **absent**; a block
that is present but points at a field that does not exist reaches the identical
end state and got nothing.

The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
on `view` per-write publish snapshots) resolves every field-naming position on a
list view against the object graph:

- `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
`prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
`rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
`filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
- every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
`gallery`, `map` and `tree` blocks.

`sort[]` and `searchableFields[]` are deliberately untouched — they already have
owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
each with a runtime-admissibility verdict on top of existence.

Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
miss changes the data the view returns or collapses the layout it configures
(every position in the card's measured table), `warning` where the renderer
drops one decoration and renders the rest (optional colour/title/tooltip/cover
bindings, a stale `hiddenFields` or `fieldOrder` entry).

Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
second field-resolution implementation — and judges the **head segment** of a
dotted reference rather than walking relationship hops: a list view compiles no
joins, and all three query axes it reaches refuse a dotted path by name
(`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
have passed it.

**Migration.** A list view refused by the new rule names a field the bound
object does not have: correct the spelling (the finding carries a "did you mean"
and the object's field list) or drop the entry. The three standard skips apply —
an object this stack does not define, an object with no readable field map
(ADR-0015 `external`), and registry-injected system columns — plus a fourth on
this surface: a list view whose `data.provider` is not `object`.
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -427,6 +427,22 @@ export type {
SortableFieldSeverity,
} from './validate-sortable-fields.js';

// [#14107] The rest of the list view's field surface — the two rules above own
// `sort` and `searchableFields`; this one owns every OTHER position that names
// a field on the bound object (columns, filter keys, grouping, row colour,
// user filters, and every binding inside the kanban / calendar / gantt /
// timeline / gallery / map / tree blocks). Resolution goes through the shared
// `object-graph.ts` seam (#14105/#14148), on the HEAD segment — see that
// module's dotted-path note.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,10 @@ describe('reference-integrity suite — membership', () => {
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
// [#14107] The rest of the same list view's field surface — every
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down
31 changes: 31 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,16 @@
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* `validateListViewFieldRefs` completes that pair (#14107): the two members
* above judge ONE list-view axis each (`sort`, `searchableFields`), and every
* OTHER field-naming position on the same record — `columns`, filter keys,
* `grouping`, `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
* `fieldOrder`, and every binding inside the kanban / calendar / gantt /
* timeline / gallery / map / tree blocks — was resolved by nothing at all. It
* carries BOTH severities, like `validateFlowTemplatePaths`: a position whose
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand DownExpand Up@@ -79,6 +89,7 @@
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -187,6 +198,26 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
// [#14107] The REST of the same list view's field surface. The two members
// above own `sort` and `searchableFields`; every OTHER field-naming position
// on a list view — `columns`, filter keys, `grouping`, `rowColor`,
// `userFilters`, `filterableFields`, `hiddenFields`, `fieldOrder` and every
// binding inside the kanban / calendar / gantt / timeline / gallery / map /
// tree blocks — was resolved by nothing, on both `os validate` and `os
// build`. Placed directly after its two siblings because the three walk the
// identical rungs (an object's `listViews`, a `defineView` aggregate's
// `list` / `listViews`, and the two standalone `views[]` shapes the
// `PUT /api/v1/meta/view` door carries), so a rung added to one is read
// against the other two.
//
// `runtimeTypes` gains `view` for exactly the #9313 reason its two siblings
// did, and the reason is a property of the SNAPSHOT rather than a
// convenience: this member resolves only against `stack.objects`, which the
// per-write snapshot does carry, so it has no missing-collection
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
62 changes: 62 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';

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

// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.

it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});

it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});

it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});

it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── the granularity wall: exactly two members cross, nothing rides along ──

it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
Expand DownExpand Up@@ -178,9 +230,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// precisely so a fourth crossing has to be argued here; this one's
// false-positive measurement is `runtime-gate.view-page-refs.test.ts`,
// which reproduces the phantom findings the collection removes.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateViewPageRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
Expand Down
Loading
Loading