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
15 changes: 15 additions & 0 deletions .changeset/24293-filter-token-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/lint": patch
---

fix(lint): `filter-token-unknown` states what the runtime does with an unresolvable filter placeholder: the data engine refuses the query with `FILTER_TOKEN_UNKNOWN` / 400

Clause-②: no

The finding's message said the value "is sent to the data engine as a literal string, matches no record, and the surface renders empty". The runtime does not do that. The data engine refuses a query that carries an unresolvable placeholder, `FILTER_TOKEN_UNKNOWN` with HTTP 400, rather than compare it as a literal string. The message now reads:

```text
Filter value "{current_user}" is not a resolvable placeholder. The data engine refuses a query that carries it (`FILTER_TOKEN_UNKNOWN`, 400), so the surface never shows the data the filter meant.
```

Unchanged: the rule id, its severity (`error`), the finding's `path`, its `hint` (the `fix:` line), and which values the rule reports. The message's first sentence is unchanged as well. `os explain filter-token-unknown` already stated the refusal and is unchanged.
7 changes: 4 additions & 3 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,9 +914,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
'saves, a rollout decision over stored tenant rows the in-repo corpus does not measure.',
run: (stack) => validateDashboardWidgetOptions(stack),
},
// #3574 — a filter value like `{current_user}` resolves in no vocabulary,
// reaches the data engine as a literal and matches nothing. The surface
// renders empty with no error, and a silent zero is indistinguishable from a
// #3574 — a filter value like `{current_user}` resolves in no vocabulary. The
// data engine refuses a query carrying it (`FILTER_TOKEN_UNKNOWN`, 400). When
// #3574 was filed it reached the engine as a literal and matched nothing: the
// surface rendered empty with no error, a silent zero indistinguishable from a
// genuine one at review time.
{
name: 'validateFilterTokens',
Expand Down
9 changes: 5 additions & 4 deletions packages/lint/src/validate-dataset-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@
*
* ## Why a dataset's dangling reference is worse than most
*
* `filter-token-unknown`'s own wording states the failure mode it exists to
* prevent: the value is *"sent to the data engine as a literal string, matches
* no record, and the surface renders empty."* A dangling field path produces
* the same outcome from the same node.
* `filter-token-unknown` judges the VALUE at that same node, and the value it
* reports does not fail quietly: the data engine refuses the query
* (`FILTER_TOKEN_UNKNOWN`, 400). A dangling field path there is the quiet
* failure. By the severity note below, every outcome it produces reaches a
* human as a chart that rendered successfully.
*
* A dataset is the semantic layer — dashboards and reports bind its dimensions
* and measures BY NAME (ADR-0021) — and the CONSUMER end of that binding is
Expand Down
12 changes: 12 additions & 0 deletions packages/lint/src/validate-filter-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ describe('validateFilterTokens', () => {
expect(findings[0].hint).toContain('{current_user_id}');
});

// [#24293] The message states the runtime's answer: the data engine refuses a
// query carrying the value (`UnknownFilterTokenError` in `@objectstack/core`,
// `FILTER_TOKEN_UNKNOWN` / 400). Held as that code, the named subject, not as
// the prose around it.
it('names the code the data engine refuses the query with, near miss or not', () => {
const findings = validateFilterTokens({
dashboards: [{ name: 'd', widgets: [{ id: 'w', filter: { a: '{current_user}', b: '{whoever_is_watching}' } }] }],
});
expect(findings.map((f) => f.path)).toEqual(['dashboards[0].widgets[0].filter.a', 'dashboards[0].widgets[0].filter.b']);
for (const f of findings) expect(f.message, f.path).toContain('`FILTER_TOKEN_UNKNOWN`');
});

it('accepts the correct spelling alongside date macros', () => {
expect(
validateFilterTokens({
Expand Down
36 changes: 21 additions & 15 deletions packages/lint/src/validate-filter-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,27 @@ import { recordsOf } from './object-graph.js';
* Exactly two vocabularies resolve inside a filter value: context tokens
* (`{current_user_id}`, `{current_org_id}` — see `context-tokens.zod.ts`) and
* date macros (`{today}`, `{30_days_ago}` — see `date-macros.zod.ts`).
* Anything else is passed to the data engine **verbatim**, matches no row, and
* the surface renders an empty result.
* Anything else does not resolve: the data engine refuses a query that carries
* it (`FILTER_TOKEN_UNKNOWN`, 400 — `UnknownFilterTokenError` in
* `@objectstack/core`'s `filter-tokens.ts`), so the surface never shows the
* data the filter meant.
*
* ## Why this is an error, not a warning
*
* The runtime failure mode is silent and indistinguishable from success. A
* metric widget filtered on an unresolved `{current_user}` renders `0`, which
* looks exactly like a metric that is legitimately zero — no console error, no
* server log, nothing for a human reviewer to notice. Issue #3574 found a
* dashboard that had been broken this way since the day it was written.
* The rule is older than that refusal. When issue #3574 was filed, the runtime
* substituted no placeholder: the value reached the data engine verbatim and
* matched no row. A metric widget filtered on an unresolved `{current_user}`
* rendered `0`, which looks exactly like a metric that is legitimately zero —
* no console error, no server log, nothing for a human reviewer to notice.
* Issue #3574 found a dashboard that had been broken this way since the day it
* was written. The runtime now refuses the query instead, so the failure is no
* longer silent, but it still shows only when someone opens the surface.
*
* That failure mode is worse for AI authors than for humans. An AI reads a
* successful query returning `0` as a correct answer and builds on it — it has
* no instinct that the number looks wrong. Its correction loop is
* "author → validate → fix", so a diagnostic only reaches it if the diagnostic
* can fail the build. A runtime warning in a server log is invisible to it.
* Neither form reaches an AI author. An AI read the old `0` as a correct answer
* and built on it — it has no instinct that the number looks wrong. Its
* correction loop is "author → validate → fix", so a diagnostic only reaches it
* if the diagnostic can fail the build. A server log line, or a refusal that
* arrives when a surface is opened after the build passed, is invisible to it.
* Hence: authoring-time error.
*
* The near-miss spellings this catches are not hypothetical. Each is a correct
Expand Down Expand Up @@ -103,7 +108,7 @@ export const FILTER_TOKEN_UNKNOWN = 'filter-token-unknown';
export type FilterTokenSeverity = 'error' | 'warning';

export interface FilterTokenFinding {
/** Always `error` today — an unresolved placeholder silently matches nothing. */
/** Always `error` today — the data engine refuses a query carrying an unresolvable placeholder. */
severity: FilterTokenSeverity;
/** Diagnostic rule id. */
rule: string;
Expand Down Expand Up @@ -233,8 +238,9 @@ function walkFilterValues(
where,
path,
message:
`Filter value "${node}" is not a resolvable placeholder. It is sent to the ` +
`data engine as a literal string, matches no record, and the surface renders empty.`,
`Filter value "${node}" is not a resolvable placeholder. The data engine refuses a ` +
`query that carries it (\`FILTER_TOKEN_UNKNOWN\`, 400), so the surface never shows ` +
`the data the filter meant.`,
hint: suggestion && isRecordContextToken(suggestion)
? `Did you mean "{${suggestion}}"? It resolves only in a filter on a component of a ` +
`\`type: 'record'\` page, to the id of the record in view.`
Expand Down
Loading