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/flow-filter-token-unknown.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
21 changes: 21 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions packages/lint/src/flow-template-grammar.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
175 changes: 175 additions & 0 deletions packages/lint/src/flow-template-grammar.ts
Original file line number Diff line number Diff line change
@@ -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.<path>` — 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<string> = 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<string> = 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' };
}
Loading
Loading