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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
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
62 changes: 62 additions & 0 deletions .changeset/widget-filter-sortby-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@objectstack/lint': minor
---

Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build

A dashboard widget could filter by a column that does not exist, and order by a name
it never selected, and `objectstack validate` exited 0 with "Validation passed";
`build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
widget then rendered **empty**.

The surrounding surface was already covered, which is what made the two misses so
narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
`filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
same dashboard. On the very same node, the filter TOKEN was checked and the filter
COLUMN was not — `filter-token-unknown` fires path-precise at
`…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
already knew the widget's dataset. Only the key resolution was missing. And
`options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
the contract in its own prose — *"must be one this widget actually selects"* — and
nothing enforced it.

Why this class of miss is expensive rather than untidy, in the reporter's words: the
dashboard it was measured on leads with a "not moving" tile — open work untouched more
than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
number reads as zero, and zero is the answer the manager is hoping for."* The failure is
silent in the direction the reader wants to believe.

Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
`dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
end-to-end, not inferred from the registry entry):

- `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
column on the bound dataset's object graph. Reported path-precise at
`dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
that same subtree.
- `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
out of the query's reach.
- `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
`values[]` entry of the widget. A name the dataset declares but the widget did not
select gets its own message, because the fix is a selection rather than a spelling.

**A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
`filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
carries only the joins `include` declared — so the same two clauses the dataset rule
applies one level down (existence, then joinability) apply here. The runtime is not a
backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
and `measures` only, never over `runtimeFilter`.

Built on the seams that shipped with the dataset-level sibling rather than a second
implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
`indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
`joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
now exported, because both are answers this position asks identically and copying either
would have been the second implementation the seam exists to prevent.

Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
names a column or an order that does not exist now fails the build — which is the point.
The three skips every field-existence rule in this package takes are unchanged, so an
object the stack does not define, an ADR-0015 `external` object with no readable field
map, and a registry-injected system column are never reported.
19 changes: 18 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,13 @@ export {
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
// `options.sortBy` against what the widget selects.
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
} from './validate-widget-bindings.js';
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';

Expand DownExpand Up@@ -485,16 +492,26 @@ export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-r
// positions) must reuse ONE mechanism rather than growing a second hop-walker
// and a second filter-key reader. Both hold mechanism only — no rule ids, no
// severities, no findings; the judgement stays with the rule that asks.
// [#14148] `joinablePrefixes` and `describeFieldPathVerdict` joined them when
// the widget limb landed: both were local to the dataset rule, and both are
// answers to questions the widget position asks identically — how ADR-0021
// expands an `include` into joinable prefixes, and how one verdict reads in
// prose. Copying either would have been the second implementation this seam
// exists to prevent, one release after it was written to prevent it.
export {
indexObjectGraph,
resolveFieldPath,
isUnjudgeable,
joinablePrefixes,
describeFieldPathVerdict,
nearestName,
suggestName,
listNames,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
export type { ObjectGraph, GraphObject, GraphField, FieldPathVerdict } from './object-graph.js';
export type {
ObjectGraph, GraphObject, GraphField, FieldPathVerdict, FieldPathAccount,
} from './object-graph.js';
export { walkFilterFieldKeys } from './filter-walk.js';
export type { FilterFieldKey } from './filter-walk.js';

Expand Down
83 changes: 83 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -248,6 +248,89 @@ export function resolveFieldPath(
return { kind: 'field-unknown', object: current, field: leaf, candidates: obj.names };
}

/**
* The relationship prefixes a document declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*
* Here rather than in a rule because the SAME `include` governs positions two
* different rules judge: a dataset's own `dimensions[].field` / `measures[].field`
* / filter keys (#14105), and a dashboard widget's `filter` keys (#14148), whose
* condition is ANDed into that same dataset's compiled query as `runtimeFilter`.
* Two copies of the prefix expansion would let the two positions drift apart on
* a clause that is one sentence of one ADR.
*/
export function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/** The two halves of a rendered verdict: the finding's message, and its detail. */
export interface FieldPathAccount {
/** What is wrong, in prose, carrying the "did you mean" when there is one. */
message: string;
/** The supporting field list, for the finding's hint. */
detail: string;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by every position that resolves a field PATH — a dataset dimension, a
* measure, a dataset filter key (#14105), a widget filter key (#14148) — so
* they cannot drift into N different accounts of the same miss. The caller
* supplies `subject` (how the position is named in prose) and owns the rule id,
* the severity, the path and the hint's prescription; this function holds none
* of them, matching the rest of this module.
*/
export function describeFieldPathVerdict(
verdict: FieldPathVerdict,
path: string,
subject: string,
): FieldPathAccount | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/**
* True when the verdict is one no rule may report — the graph could not answer.
* Callers spell the skip through this predicate rather than re-listing the
Expand Down
75 changes: 4 additions & 71 deletions packages/lint/src/validate-dataset-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,12 +118,11 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
RELATIONSHIP_FIELD_TYPES,
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
listNames,
joinablePrefixes,
resolveFieldPath,
suggestName,
type FieldPathVerdict,
type ObjectGraph,
} from './object-graph.js';

Expand DownExpand Up@@ -172,72 +171,6 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

/**
* The relationship prefixes a dataset declared as joinable.
*
* ADR-0021: *"Declaring `a.b` implicitly includes the intermediate `a`."* So
* every PREFIX of every declared path is joinable, not only the paths as
* written — which is why this expands rather than reading `include` verbatim.
*/
function joinablePrefixes(include: unknown): ReadonlySet<string> {
const prefixes = new Set<string>();
if (!Array.isArray(include)) return prefixes;
for (const entry of include) {
if (typeof entry !== 'string' || !entry) continue;
const segments = entry.split('.');
for (let i = 1; i <= segments.length; i++) {
prefixes.add(segments.slice(0, i).join('.'));
}
}
return prefixes;
}

/**
* Turn a resolution verdict into the message half of an existence finding, or
* `undefined` when the verdict is one no rule may report.
*
* Shared by the three positions that resolve a field PATH (dimension, measure,
* filter key) so they cannot drift into three different accounts of the same
* miss. The caller supplies `subject` — how the position is named in prose —
* and owns the rule id, the path and the hint's prescription.
*/
function existenceMessage(
verdict: FieldPathVerdict,
path: string,
subject: string,
): { message: string; detail: string } | undefined {
switch (verdict.kind) {
case 'ok':
case 'unknowable':
case 'hop-untargeted':
return undefined;
case 'hop-unknown':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is not a field on object ` +
`"${verdict.object}".${suggestName(verdict.segment, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
case 'hop-not-relationship':
return {
message:
`${subject} "${path}" traverses "${verdict.segment}", which is a` +
`${verdict.type ? ` \`${verdict.type}\`` : 'n ordinary'} field on object ` +
`"${verdict.object}" and not a relationship — there is nothing to join through.`,
detail:
`Only ${[...RELATIONSHIP_FIELD_TYPES].sort().join(' / ')} fields are traversable ` +
`(ADR-0021 derives every join from the object graph; you never write an ON clause).`,
};
case 'field-unknown':
return {
message:
`${subject} "${path}" is not a field on object "${verdict.object}".` +
`${suggestName(verdict.field, verdict.candidates)}`,
detail: `Fields on "${verdict.object}": ${listNames(verdict.candidates)}.`,
};
}
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
Expand DownExpand Up@@ -309,7 +242,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
return;
}

const account = existenceMessage(verdict, entry, `include[${ii}]`);
const account = describeFieldPathVerdict(verdict, entry, `include[${ii}]`);
if (!account) return;
findings.push({
severity: 'error',
Expand DownExpand Up@@ -342,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
const verdict = resolveFieldPath(graph, object, written);
if (isUnjudgeable(verdict) || !verdict) return;

const account = existenceMessage(verdict, written, subject);
const account = describeFieldPathVerdict(verdict, written, subject);
if (account) {
findings.push({
severity: 'error',
Expand Down
Loading
Loading