From 4c7d5c5223382ff4b9d92bd9b674f6aa8e558f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:41:08 +0000 Subject: [PATCH 1/2] feat(lint): report a flow-filter token neither the template evaluator nor ObjectQL resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter fails the build while the identical string in a flow node's `config.filter` is silent — although this package's sibling filter rules have reached `flows` all along. Adding `flows` to that rule's root list is the wrong close. A flow node's filter is interpolated by the automation template evaluator BEFORE ObjectQL sees it, and only what the evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary reports every legitimate `{record.id}` / `{recordId}`: 7 findings on this repo's own example apps, all 7 false positives. So this is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s declared surface list is untouched. Only the class NEITHER dialect resolves is reported — a call to a name outside the flow template dialect's closed function table, where the evaluator already raises a guard refusal, so the node cannot run at all. The open arm (bare and dotted identifiers addressing the run's variable map) stays silent and says why. `flow-template-grammar.ts` mirrors the evaluator's whole-token dispatch because this package may not depend on a runtime; its drift is pinned by a test that reads the original from disk. Finding delta on this repo's example apps: 0. Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 Co-Authored-By: Claude Opus 5 --- .changeset/flow-filter-token-unknown.md | 15 ++ packages/lint/src/authoring-rules.ts | 21 ++ .../lint/src/flow-template-grammar.test.ts | 118 ++++++++++++ packages/lint/src/flow-template-grammar.ts | 175 +++++++++++++++++ packages/lint/src/index.ts | 4 + .../src/validate-flow-filter-tokens.test.ts | 168 ++++++++++++++++ .../lint/src/validate-flow-filter-tokens.ts | 181 ++++++++++++++++++ 7 files changed, 682 insertions(+) create mode 100644 .changeset/flow-filter-token-unknown.md create mode 100644 packages/lint/src/flow-template-grammar.test.ts create mode 100644 packages/lint/src/flow-template-grammar.ts create mode 100644 packages/lint/src/validate-flow-filter-tokens.test.ts create mode 100644 packages/lint/src/validate-flow-filter-tokens.ts diff --git a/.changeset/flow-filter-token-unknown.md b/.changeset/flow-filter-token-unknown.md new file mode 100644 index 0000000000..e8f1827a1c --- /dev/null +++ b/.changeset/flow-filter-token-unknown.md @@ -0,0 +1,15 @@ +--- +"@objectstack/lint": minor +--- + +New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`. + +`filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along. + +The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched. + +Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position. + +Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata. + +Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 68f22d4490..0a5476b3a3 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js'; import { validateWidgetBindings } from './validate-widget-bindings.js'; import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js'; import { validateFilterTokens } from './validate-filter-tokens.js'; +import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js'; import { validatePresetComparands } from './validate-preset-comparands.js'; import { validateEmptyCombinators } from './validate-empty-combinators.js'; import { validateReferenceIntegrity } from './reference-integrity-suite.js'; @@ -612,6 +613,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateFilterTokens(stack), }, + // #16096 — the FLOW half of the same question, and a different answer, + // because a flow node's `config.filter` is evaluated by the automation + // template evaluator before ObjectQL ever sees it. Reports only the class + // NEITHER dialect resolves: a call to a name outside the flow template + // dialect's closed function table, where `resolveToken` raises a guard + // refusal and the node cannot run. The open arm (bare/dotted identifiers + // addressing the run's VariableMap) is deliberately left silent — judging it + // against the ObjectQL vocabulary reports 7 findings on this repo's own + // examples, all 7 false positives. Reads `flows` alone, so the per-write + // snapshot carries everything it needs. + { + name: 'validateFlowFilterTokens', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-flow-filter-tokens.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['flow'], + run: (stack) => validateFlowFilterTokens(stack), + }, // #8793 (the ruled C half of #8690) — a declared dashboard date-range preset // name (`last_30_days`, …) authored as a bare ORDERING comparand resolves in // no layer: the engine refuses it on a declared temporal field at query time diff --git a/packages/lint/src/flow-template-grammar.test.ts b/packages/lint/src/flow-template-grammar.test.ts new file mode 100644 index 0000000000..340bc347ff --- /dev/null +++ b/packages/lint/src/flow-template-grammar.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The CROSS-PACKAGE DRIFT PIN for `flow-template-grammar.ts` (#16096). +// +// That module MIRRORS the automation template evaluator's whole-token dispatch, +// because `@objectstack/lint` depends on `@objectstack/spec` and never on a +// runtime, so the dialect cannot be imported from the package that owns it. A +// mirror nobody checks is the "N copies, the next author fixes one of N" shape +// `filter-walk.ts` was written against — so this file reads the ORIGINAL from +// disk and fails when any mirrored piece stops matching it. +// +// The read escapes this package, spelled so `check:cross-package-test-inputs` +// can see it, and `$TURBO_ROOT$/packages/services/service-automation/src/**` is +// already a declared input of `@objectstack/lint#test` in turbo.json. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + classifyFlowTemplateToken, + DATE_FUNCTION_RE, + VARIABLE_PATH_RE, + SAFE_EXPRESSION_RE, + IDENTIFIER_SCAN_RE, + CALL_POSITION_RE, + FLOW_TEMPLATE_DATE_FUNCTIONS, + FLOW_TEMPLATE_VALUE_FUNCTIONS, +} from './flow-template-grammar.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */ +function findUp(predicate: (dir: string) => boolean): string { + let dir = HERE; + for (;;) { + if (predicate(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error('workspace root not found from ' + HERE); + dir = parent; + } +} +const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); + +const ORIGINAL = join(REPO, 'packages/services/service-automation/src/builtin/template.ts'); +// Loud absence: if this file moves, the mirror is unpinned, and an unpinned +// mirror is the defect this test exists to prevent. Failing to read IS the +// regression — never a skip. +const source = readFileSync(ORIGINAL, 'utf8'); + +describe('the mirrored grammar still matches the evaluator that owns it', () => { + const mirrored: Array<[string, RegExp]> = [ + ['the NOW()/TODAY() ± N day form', DATE_FUNCTION_RE], + ['the variable / dotted-path form', VARIABLE_PATH_RE], + ['the arithmetic character set', SAFE_EXPRESSION_RE], + ['the identifier scan', IDENTIFIER_SCAN_RE], + ['the call-position lookahead', CALL_POSITION_RE], + ]; + for (const [label, re] of mirrored) { + it(`${label} appears verbatim in template.ts`, () => { + expect(source).toContain(re.source); + }); + } + + it('mirrors the value-function table exactly — no name added, none dropped', () => { + const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source); + expect(block, 'EXPRESSION_FUNCTION_ARITY not found in template.ts').toBeTruthy(); + const names = [...block![1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]); + expect(names.sort()).toEqual([...FLOW_TEMPLATE_VALUE_FUNCTIONS].sort()); + }); + + it('mirrors the two whole-token date function names', () => { + for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(DATE_FUNCTION_RE.source).toContain(name); + // And the evaluator still keeps them OUT of the value table — the reason + // `{TODAY() - 45 - 10}` is refused while `{TODAY() - 45}` is not. + const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source); + for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(block![1]).not.toContain(name); + }); + + it('the evaluator still REFUSES an unknown call rather than resolving it to null', () => { + // The mirror only means something while the runtime still throws here. + expect(source).toContain('throw unknownFunctionError(match, trimmed)'); + }); + + it('the filter position still hands an unresolved KNOWN filter token to the engine', () => { + // The layer-one/layer-two split the rule is built on. + expect(source).toContain('isKnownFilterToken'); + }); +}); + +describe('dispatch ORDER — the property the negative control depends on', () => { + it('classifies {TODAY() - 45} as a date function, never as a call', () => { + expect(classifyFlowTemplateToken('TODAY() - 45')).toEqual({ kind: 'date-function', name: 'TODAY' }); + }); + + it('classifies TOMORROW() as an unknown function', () => { + expect(classifyFlowTemplateToken('TOMORROW()')).toEqual({ kind: 'unknown-function', name: 'TOMORROW' }); + }); + + it('classifies the open arm as variable-path, never as a finding', () => { + expect(classifyFlowTemplateToken('recordId')).toEqual({ kind: 'variable-path', head: 'recordId' }); + expect(classifyFlowTemplateToken('record.id')).toEqual({ kind: 'variable-path', head: 'record' }); + }); + + it('classifies $User.* as user context', () => { + expect(classifyFlowTemplateToken('$User.Id')).toEqual({ kind: 'user-context' }); + }); + + it('classifies a junk shape as unresolvable rather than as a call', () => { + expect(classifyFlowTemplateToken('30 days ago')).toEqual({ kind: 'unresolvable-shape' }); + expect(classifyFlowTemplateToken('')).toEqual({ kind: 'unresolvable-shape' }); + }); + + it('never reports a reserved literal in call position', () => { + expect(classifyFlowTemplateToken('null(1)').kind).not.toBe('unknown-function'); + }); +}); diff --git a/packages/lint/src/flow-template-grammar.ts b/packages/lint/src/flow-template-grammar.ts new file mode 100644 index 0000000000..f317e4d279 --- /dev/null +++ b/packages/lint/src/flow-template-grammar.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module flow-template-grammar + * + * **Which `{…}` dialect owns a whole-string token in a FLOW node's filter, and + * which spellings neither dialect can resolve** (#16096). + * + * A filter value position inside a flow node is the one place two `{…}` + * vocabularies meet, and `interpolateFilter` + * (`@objectstack/service-automation`, `src/builtin/template.ts`, #3810) is the + * function that arbitrates them. Its own header states the split: + * + * > A whole-string token that (a) no flow variable resolves and (b) IS a + * > recognised filter placeholder is passed through **verbatim** for the engine + * > to expand. That is a transfer of ownership, not a lenient fallback. + * + * So a token in this position falls in exactly one of three classes: + * + * | class | resolved by | example | judged here? | + * |---|---|---|---| + * | flow template dialect | the automation template evaluator, BEFORE the query | `{TODAY() - 45}`, `{record.id}`, `{$User.Id}`, `{round(x)}` | ⛔ no | + * | filter placeholder dialect | ObjectQL, after hand-off (`isKnownFilterToken`) | `{current_user_id}`, `{30_days_ago}` | ⛔ no — `filter-token-unknown` owns it | + * | **neither** | nothing — the run fails or the condition collapses | `{TOMORROW()}`, `{ROUND(x)}` | ✅ the third class, and only it | + * + * ## Why only the CALL-POSITION half of the third class is decidable here + * + * The flow dialect's vocabulary is closed in three of its four arms and OPEN in + * the fourth: + * + * - `NOW()` / `TODAY()` with an optional `± N` day offset — closed, two names. + * - `$User.` — closed prefix. + * - `round` / `floor` / `ceil` / `abs` / `min` / `max` in CALL position — + * closed by maintainer ruling on #11060 ("exactly … every name and semantic + * mirrored **1:1 from the CEL stdlib**, ⛔ no second semantics invented"). + * - a bare or dotted identifier (`{recordId}`, `{record.id}`, `{status}`) — + * **OPEN**: it addresses the run's `VariableMap`, which holds the flow's + * declared variables, every node's `outputVariable`, and — via + * `seedRunVariables` — the trigger record's own fields flattened to top + * level. None of that is decidable from authored metadata alone, and a flow + * bound to an object another package defines cannot be resolved here at all. + * + * That asymmetry is the whole reason this module reports the call-position arm + * and nothing else. Measured on this repo's own examples, judging the OPEN arm + * against the ObjectQL vocabulary — the shape #16096 calls "the obvious fix" — + * reports **7 findings at `error`, all 7 false positives** (`{recordId}` ×3, + * `{record.id}` ×3, `{currentTask.id}` ×1, across app-todo / app-crm / + * app-showcase). Every one is a legitimate flow variable that resolves at run + * time. A reference set that reds working sweeps is worse than the silence + * #16096 reports, so the open arm stays unjudged and says so. + * + * ## Dispatch ORDER is load-bearing, not incidental + * + * `resolveToken` tries the date-function form BEFORE it scans for call + * positions. `{TODAY() - 45}` therefore never reaches the scan — which is the + * only reason the legitimate spelling stays silent, because `TODAY` sitting in + * front of a `(` is otherwise indistinguishable from `TOMORROW`. This module + * mirrors that order exactly and `flow-template-grammar.test.ts` pins the + * negative control against it. + * + * ## This is a MIRROR, and the drift is pinned + * + * `@objectstack/lint` depends on `@objectstack/spec` and never on a runtime + * (its own package description), so the dialect cannot be imported from the + * package that owns it. The five regexes and the function table below are + * therefore copied, and `flow-template-grammar.test.ts` reads + * `packages/services/service-automation/src/builtin/template.ts` from disk and + * fails when any of them stops matching the original — a cross-package test + * input already declared on `@objectstack/lint#test` in `turbo.json`, so the + * graph can see it. ⛔ Do not "simplify" a regex here: it is not this module's + * to choose, and an equivalent-looking rewrite breaks the pin that keeps the + * two readers honest. + */ + +/** + * The two whole-token date functions, with their `± N day` offset grammar. + * Verbatim from `resolveToken`'s `dateFnMatch`. + */ +export const DATE_FUNCTION_RE = /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/; + +/** Direct variable / dotted-path lookup, numeric segments included (#1872). */ +export const VARIABLE_PATH_RE = /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/; + +/** + * The character set `resolveToken` will attempt arithmetic on. A token outside + * it resolves to `undefined` without ever reaching the call-position scan. + */ +export const SAFE_EXPRESSION_RE = /^[\w\s+\-*/%().,?:<>=!&|"'$]+$/; + +/** Identifier / dotted-identifier occurrences inside a mixed expression. */ +export const IDENTIFIER_SCAN_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g; + +/** An identifier is in CALL position when a `(` follows it. */ +export const CALL_POSITION_RE = /^\s*\(/; + +/** Literals `resolveToken` never substitutes, checked BEFORE call position. */ +const RESERVED_LITERALS: ReadonlySet = new Set(['true', 'false', 'null', 'undefined']); + +/** The two names legal only as a whole token (`{TODAY() + 7}`), never in a call. */ +export const FLOW_TEMPLATE_DATE_FUNCTIONS: readonly string[] = ['NOW', 'TODAY']; + +/** + * The value-expression function table — the CEL stdlib's numeric six, by the + * #11060 ruling. Mirrors `EXPRESSION_FUNCTION_ARITY`'s key set. + */ +export const FLOW_TEMPLATE_VALUE_FUNCTIONS: readonly string[] = [ + 'round', 'floor', 'ceil', 'abs', 'min', 'max', +]; + +const VALUE_FUNCTION_SET: ReadonlySet = new Set(FLOW_TEMPLATE_VALUE_FUNCTIONS); + +/** What the flow template dialect does with one whole-string `{…}` token. */ +export type FlowTemplateTokenVerdict = + /** `{NOW()}` / `{TODAY() - 45}` — the evaluator resolves it. Legitimate. */ + | { kind: 'date-function'; name: string } + /** `{$User.Id}` — the evaluator resolves it from the run context. */ + | { kind: 'user-context' } + /** + * `{recordId}` / `{record.id}` — a `VariableMap` lookup, and the position + * from which an unresolved name is handed to the filter dialect. OPEN: not + * decidable from authored metadata, so never a finding. + */ + | { kind: 'variable-path'; head: string } + /** + * A call to a name in NEITHER table. `resolveToken` throws + * `FlowExpressionFunctionError` here (a guard refusal — a `fault` edge must + * not swallow it), so the node cannot run. THIS is the finding. + */ + | { kind: 'unknown-function'; name: string } + /** + * Anything else — junk shapes (`{30 days ago}`) and arithmetic over names + * this module cannot resolve. `resolveToken` answers `undefined` and the + * CRUD collapse guard (#3810) reports it at run time. Open, not judged. + */ + | { kind: 'unresolvable-shape' }; + +/** + * Classify the INSIDE of one whole-string `{…}` filter token — `inner` is the + * text between the braces, exactly as authored. + * + * Mirrors `resolveToken`'s dispatch order (see the module header). Holds no + * severity and knows nothing about where the token was found. + */ +export function classifyFlowTemplateToken(inner: string): FlowTemplateTokenVerdict { + const trimmed = inner.trim(); + if (!trimmed) return { kind: 'unresolvable-shape' }; + + // 1. Whole-token date functions, BEFORE any call-position reasoning. + const dateMatch = DATE_FUNCTION_RE.exec(trimmed); + if (dateMatch) return { kind: 'date-function', name: dateMatch[1] }; + + // 2. `$User.*` shortcuts. + if (trimmed.startsWith('$User.')) return { kind: 'user-context' }; + + // 3. Direct variable / dotted path — the open arm. + if (VARIABLE_PATH_RE.test(trimmed)) { + return { kind: 'variable-path', head: trimmed.split('.')[0] }; + } + + // 4. Outside the arithmetic character set: `undefined`, no throw. + if (!SAFE_EXPRESSION_RE.test(trimmed)) return { kind: 'unresolvable-shape' }; + + // 5. The call-position scan. `resolveToken` throws on the FIRST unknown name + // it reaches, so the first is what an author sees and what is reported. + for (const match of trimmed.matchAll(IDENTIFIER_SCAN_RE)) { + const name = match[0]; + if (RESERVED_LITERALS.has(name)) continue; + const rest = trimmed.slice((match.index ?? 0) + name.length); + if (!CALL_POSITION_RE.test(rest)) continue; + if (VALUE_FUNCTION_SET.has(name)) continue; + return { kind: 'unknown-function', name }; + } + + return { kind: 'unresolvable-shape' }; +} diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 5f125bb4bc..a836e8ace5 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -366,6 +366,10 @@ export type { } from './validate-dashboard-action-refs.js'; export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js'; +export { validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN } from './validate-flow-filter-tokens.js'; +export type { FlowFilterTokenFinding } from './validate-flow-filter-tokens.js'; +export { classifyFlowTemplateToken, FLOW_TEMPLATE_DATE_FUNCTIONS, FLOW_TEMPLATE_VALUE_FUNCTIONS } from './flow-template-grammar.js'; +export type { FlowTemplateTokenVerdict } from './flow-template-grammar.js'; export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js'; export { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js'; export type { PresetComparandFinding, PresetComparandSeverity } from './validate-preset-comparands.js'; diff --git a/packages/lint/src/validate-flow-filter-tokens.test.ts b/packages/lint/src/validate-flow-filter-tokens.test.ts new file mode 100644 index 0000000000..765ad6b0fe --- /dev/null +++ b/packages/lint/src/validate-flow-filter-tokens.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN } from './validate-flow-filter-tokens.js'; +import { validateFilterTokens } from './validate-filter-tokens.js'; + +/** A flow whose `query_stalled` node filters on `value` — #16096's own shape. */ +function flowStack(value: unknown): Record { + return { + flows: [ + { + name: 'opportunity_stagnation', + nodes: [ + { id: 'query_stalled', type: 'query_records', config: { objectName: 'opportunity', filter: { close_date: { $lt: value } } } }, + ], + }, + ], + }; +} + +/** The card's CONTROL: the identical string in a list view's filter. */ +function viewStack(value: unknown): Record { + return { + views: [ + { name: 'account_list', object: 'account', filter: [{ field: 'close_date', operator: 'lt', value }] }, + ], + }; +} + +describe('#16096 — the reach gap, reproduced with the card\'s own control', () => { + it('reports an unresolvable {TOMORROW()} in a flow node config.filter', () => { + const findings = validateFlowFilterTokens(flowStack('{TOMORROW()}')); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_FILTER_TOKEN_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].where).toBe('flow "opportunity_stagnation"'); + expect(findings[0].path).toBe('flows[0].nodes[0].config.filter.close_date.$lt'); + expect(findings[0].message).toContain('TOMORROW'); + // The consequence claim is the flow one, NOT the view one. A message that + // said "renders empty" here would be false: the node refuses to run. + expect(findings[0].message).toContain('cannot run'); + expect(findings[0].message).not.toContain('renders empty'); + }); + + it('CONTROL — the same string in a view filter still fires the ORIGINAL rule, unchanged', () => { + const control = validateFilterTokens(viewStack('{TOMORROW()}')); + expect(control).toHaveLength(1); + expect(control[0].rule).toBe('filter-token-unknown'); + expect(control[0].severity).toBe('error'); + }); + + it('the original rule STILL does not reach flows — this rule did not widen it', () => { + expect(validateFilterTokens(flowStack('{TOMORROW()}'))).toEqual([]); + }); + + it('and the new rule does not reach views — the two surfaces stay disjoint', () => { + expect(validateFlowFilterTokens(viewStack('{TOMORROW()}'))).toEqual([]); + }); +}); + +describe('the NEGATIVE CONTROL — legitimate flow template tokens stay silent', () => { + // Triage's binding instruction: a legitimate `{TODAY() - 45}` must stay + // silent, and it is the easiest thing to regress. It survives only because + // the date-function form is tried BEFORE the call-position scan. + const legitimate = [ + '{TODAY() - 45}', '{TODAY()}', '{TODAY() + 90}', '{NOW()}', '{NOW() - 1}', + '{TODAY()-45}', '{ TODAY() + 7 }', + '{$User.Id}', '{$User.Email}', + '{record.id}', '{recordId}', '{currentTask.id}', '{record.target_channels.0}', + '{round(amount)}', '{max(a, b)}', '{floor(x / 2)}', + '{current_user_id}', '{current_org_id}', '{today}', '{30_days_ago}', '{week_start}', + 'closed', 42, true, null, 'acme {x} deal', '{a}{b}', '{{x}}', + ]; + for (const value of legitimate) { + it(`stays silent on ${JSON.stringify(value)}`, () => { + expect(validateFlowFilterTokens(flowStack(value))).toEqual([]); + }); + } +}); + +describe('the third class — a call to a name NEITHER dialect knows', () => { + const unresolvable: Array<[string, string]> = [ + ['{TOMORROW()}', 'TOMORROW'], + ['{YESTERDAY()}', 'YESTERDAY'], + ['{ROUND(x)}', 'ROUND'], + ['{DATEADD(day, -45)}', 'DATEADD'], + ['{Math.round(x)}', 'Math.round'], + ['{upper(name)}', 'upper'], + ]; + for (const [value, name] of unresolvable) { + it(`reports ${value} naming '${name}'`, () => { + const findings = validateFlowFilterTokens(flowStack(value)); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain(name); + }); + } + + it("prescribes the whole-token form when TODAY() is called in an expression it cannot carry", () => { + // `{TODAY() - 45 - 10}` misses the `± N` grammar, so the runtime reaches + // the scan and refuses TODAY in call position. The hint must say why. + const findings = validateFlowFilterTokens(flowStack('{TODAY() - 45 - 10}')); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('whole token'); + expect(findings[0].hint).toContain('± N day offset'); + }); + + it("suggests the supported spelling for a case mistake", () => { + const findings = validateFlowFilterTokens(flowStack('{ROUND(x)}')); + expect(findings[0].hint).toContain("Did you mean 'round'?"); + }); + + it('reports the FIRST unknown call, the one the runtime throws on', () => { + const findings = validateFlowFilterTokens(flowStack('{FOO(1) + BAR(2)}')); + expect(findings).toHaveLength(1); + // Assert on the NAMED call, not on the echoed value — the message quotes + // the whole authored string, which contains both names. + expect(findings[0].message).toContain("calls 'FOO'"); + expect(findings[0].message).not.toContain("calls 'BAR'"); + }); +}); + +describe('walk reach', () => { + it('reaches a filter nested inside a container node (try/catch region)', () => { + const stack = { + flows: [{ + name: 'resilient_sync', + nodes: [{ + id: 'guard', type: 'try_catch', + config: { catch: { nodes: [{ id: 'purge', type: 'delete_record', config: { filter: { at: '{TOMORROW()}' } } }] } }, + }], + }], + }; + const findings = validateFlowFilterTokens(stack); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('flows[0].nodes[0].config.catch.nodes[0].config.filter.at'); + }); + + it('judges every authored filter shape — triples and rule objects too', () => { + const triple = { flows: [{ name: 'f', nodes: [{ id: 'n', config: { filter: [['close_date', '<', '{TOMORROW()}']] } }] }] }; + const ruleObj = { flows: [{ name: 'f', nodes: [{ id: 'n', config: { filter: [{ field: 'close_date', operator: 'lt', value: '{TOMORROW()}' }] } }] }] }; + expect(validateFlowFilterTokens(triple)).toHaveLength(1); + expect(validateFlowFilterTokens(ruleObj)).toHaveLength(1); + }); + + it('is a strict SUBSET of what a bare `flows` root addition would report', () => { + // Every finding this rule emits is one `classifyFilterToken` also calls + // unknown — the layering the rule is built on, asserted rather than + // assumed. The converse does NOT hold, which is the whole point. + for (const value of ['{TOMORROW()}', '{ROUND(x)}', '{recordId}', '{TODAY() - 45}']) { + const mine = validateFlowFilterTokens(flowStack(value)); + const naive = validateFilterTokens({ ...flowStack(value), views: [{ name: 'v', filter: [{ field: 'f', operator: 'lt', value }] }] }); + if (mine.length > 0) expect(naive.length).toBeGreaterThan(0); + } + }); + + it('tolerates an absent / malformed flows collection', () => { + expect(validateFlowFilterTokens(null)).toEqual([]); + expect(validateFlowFilterTokens({})).toEqual([]); + expect(validateFlowFilterTokens({ flows: 'nope' })).toEqual([]); + }); + + it('guards a cyclic graph', () => { + const cyclic: Record = { filter: {} }; + (cyclic.filter as Record).self = cyclic; + expect(() => validateFlowFilterTokens({ flows: [{ name: 'f', nodes: [cyclic] }] })).not.toThrow(); + }); +}); diff --git a/packages/lint/src/validate-flow-filter-tokens.ts b/packages/lint/src/validate-flow-filter-tokens.ts new file mode 100644 index 0000000000..6cca2adeb4 --- /dev/null +++ b/packages/lint/src/validate-flow-filter-tokens.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module validate-flow-filter-tokens + * + * **A `{…}` filter token in a flow node that NEITHER `{…}` dialect can + * resolve** (#16096). + * + * `filter-token-unknown` (`validate-filter-tokens.ts`) walks seven + * presentation collections and deliberately not `flows`, so `{TOMORROW()}` in a + * list view's filter fails the build while the identical string in a flow + * node's `config.filter` is silent. Its siblings already reach flows + * (`validate-empty-combinators.ts`, `validate-preset-comparands.ts`, + * `lint-liveness-properties.ts`), so the gap is a root-list inconsistency, not + * a position the package holds an opinion about. + * + * ## Why this is a SECOND rule id and not `flows` added to that root list + * + * The two positions do not share a vocabulary or a consequence, and + * `filter-token-unknown`'s message is a factual claim about the run — + * *"sent to the data engine as a literal string, matches no record, and the + * surface renders empty"* — that is **false** here in both halves: + * + * - **Vocabulary.** A flow filter is evaluated by the automation template + * evaluator FIRST, and only what that evaluator cannot resolve is handed on + * to ObjectQL (`interpolateFilter`, #3810). Judging a flow filter against the + * ObjectQL vocabulary reports every legitimate `{record.id}` — measured at + * **7 findings, all 7 false positives**, on this repo's own examples. + * `flow-template-grammar.ts` carries that measurement and the boundary. + * - **Consequence.** For the class reported here the run does not query and + * render empty: `resolveToken` throws `FlowExpressionFunctionError`, a guard + * refusal a `fault` edge may not swallow. The node **cannot run at all**. + * + * Widening the published id would make its meaning depend on the position it + * fired in, which a machine consumer keyed on the id cannot see. So the id is + * new and the old rule's declared surface list is untouched. + * + * ## Severity: `error` + * + * The same axis `validate-flow-template-paths.ts` already applies at this exact + * position — an unresolvable token inside a filter-guarded CRUD node is not + * "the output will be blank", it is "this node cannot run", so the build is + * shipping a flow whose runtime is already decided. Gating, not advisory. + * + * ## What it deliberately leaves silent + * + * Everything whose oracle is open: bare and dotted identifiers (`{recordId}`, + * `{record.id}`) address a run-time `VariableMap`, and junk shapes collapse + * into the CRUD guard's own run-time report. Only the CALL-POSITION arm has a + * closed reference set, and only it is reported. `flow-template-grammar.ts` + * states that boundary in full. + */ + +import { nearestName } from '@objectstack/formula'; +import { classifyFilterToken } from '@objectstack/spec/data'; + +import { + classifyFlowTemplateToken, + FLOW_TEMPLATE_DATE_FUNCTIONS, + FLOW_TEMPLATE_VALUE_FUNCTIONS, +} from './flow-template-grammar.js'; +import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; + +/** Diagnostic rule id. */ +export const FLOW_FILTER_TOKEN_UNKNOWN = 'flow-filter-token-unknown'; + +export interface FlowFilterTokenFinding { + /** Always `error` — the node throws a guard refusal instead of running. */ + severity: 'error'; + rule: string; + /** Human-readable location, e.g. `flow "opportunity_stagnation"`. */ + where: string; + /** Config path, e.g. `flows[2].nodes[1].config.filter.close_date.$lt`. */ + path: string; + message: string; + hint: string; +} + +/** + * The one collection this rule walks. Declared here rather than shared, for the + * reason `validate-filter-tokens.ts` states about its own list: a shared + * surface constant would let another rule's widening land in a gating rule + * silently. + */ +const FLOW_FILTER_SURFACES: readonly FilterSurface[] = [{ key: 'flows', kind: 'flow' }]; + +/** + * Compose the hint, mirroring `unknownFunctionError`'s own prescription so the + * authoring-time wording and the run-time fault read as one system. + */ +function hintFor(name: string): string { + if (FLOW_TEMPLATE_DATE_FUNCTIONS.includes(name)) { + return `${name}() is supported only as the whole token, with an optional ± N day offset — write {${name}() + 7}.`; + } + const last = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name; + const lower = last.toLowerCase(); + const suggestion = FLOW_TEMPLATE_VALUE_FUNCTIONS.includes(lower) + ? lower + : nearestName(lower, FLOW_TEMPLATE_VALUE_FUNCTIONS); + const didYouMean = suggestion + ? ` Did you mean '${suggestion}'?${name.includes('.') ? ' Method/namespace call syntax is not supported — write the bare form.' : ''}` + : ''; + return ( + `Flow filter values resolve in two vocabularies: the flow template dialect — ` + + `{NOW()} / {TODAY()} with an optional ± N day offset, {$User.Id}, {record.}, ` + + `and the value functions ${FLOW_TEMPLATE_VALUE_FUNCTIONS.join(', ')} — and, for a token ` + + `the evaluator does not resolve, the filter placeholders ({current_user_id}, {today}, ` + + `{30_days_ago}).${didYouMean}` + ); +} + +/** Judge every string inside one authored filter subtree. */ +function walkValues( + node: unknown, + path: string, + where: string, + out: FlowFilterTokenFinding[], + seen: Set, +): void { + if (node === null || node === undefined) return; + + if (typeof node === 'string') { + // Layer one: ask the FILTER dialect first, through the same classifier + // `filter-token-unknown` uses. `null` means the value is not a placeholder + // attempt at all; `context` / `date-macro` mean ObjectQL owns and resolves + // it after the hand-off. Only its `unknown` verdict can possibly be a token + // neither layer knows — which makes every finding here a strict SUBSET of + // what a bare `flows` root addition would report, and `token` is the raw + // text between the braces, exactly as authored. + const filterVerdict = classifyFilterToken(node); + if (filterVerdict?.kind !== 'unknown') return; + // Layer two: ask the FLOW dialect what it does with that same text. + const verdict = classifyFlowTemplateToken(filterVerdict.token); + if (verdict.kind !== 'unknown-function') return; + out.push({ + severity: 'error', + rule: FLOW_FILTER_TOKEN_UNKNOWN, + where, + path, + message: + `Filter value "${node}" calls '${verdict.name}', which is not a function in the flow ` + + `template dialect and is not a filter placeholder either. The node does not query with ` + + `an unresolved condition — the template evaluator raises a guard refusal, so this node ` + + `cannot run at all.`, + hint: hintFor(verdict.name), + }); + return; + } + + if (typeof node !== 'object') return; + // Metadata graphs can be cyclic once normalized; guard the walk. + if (seen.has(node)) return; + seen.add(node); + + if (Array.isArray(node)) { + node.forEach((v, i) => walkValues(v, `${path}[${i}]`, where, out, seen)); + return; + } + + for (const [k, v] of Object.entries(node as Record)) { + walkValues(v, `${path}.${k}`, where, out, seen); + } +} + +/** + * Validate flow-node filter placeholders across a schema-parsed stack. + * + * Pure `(stack) => Finding[]`; no I/O. + */ +export function validateFlowFilterTokens( + stack: Record | undefined | null, +): FlowFilterTokenFinding[] { + if (!stack || typeof stack !== 'object') return []; + const out: FlowFilterTokenFinding[] = []; + + walkAuthoredFilters(stack, FLOW_FILTER_SURFACES, ({ value, path, where }) => { + walkValues(value, path, where, out, new Set()); + }); + + return out; +} From de9b551431d80e4e396664dc23167b1eeb4d3617 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:16:55 +0000 Subject: [PATCH 2/2] docs: bump the author-time rule count the CLI transcripts pin (43 -> 44) `flow-filter-token-unknown` is the 44th registered author-time rule, and `check:docs-transcript-drift` derives that count from the registry for the four `os build` / `os validate` transcripts under content/docs that quote it as a literal. The inherited commit registered the rule without moving the literals, so the gate went red on this branch; this is the other half. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index e74e62943b..92a2ffe8df 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -498,7 +498,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (43)... + → Running author-time rules (44)... → Checking capability providers (#3366)... → Collecting package docs (ADR-0046)... → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 4fbf080795..31b29ed8c2 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -555,7 +555,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (43)... + → Running author-time rules (44)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 12f27dbf07..9c05c75122 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -263,7 +263,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (43)... + → Running author-time rules (44)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 62f2f99017..ccb3259959 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -380,7 +380,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (43)... + → Running author-time rules (44)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)...