Skip to content

Commit d61bad0

Browse files
baozhoutaoclaude
andauthored
feat(lint): report a flow-filter token neither the template evaluator nor ObjectQL resolves (#16732)
* feat(lint): report a flow-filter token neither the template evaluator nor ObjectQL resolves `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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c12e47 commit d61bad0

11 files changed

Lines changed: 686 additions & 4 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
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`.
6+
7+
`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.
8+
9+
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.
10+
11+
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.
12+
13+
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.
14+
15+
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.

content/docs/deployment/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ os compile --json # JSON output for CI pipelines
498498
→ Normalizing stack definition...
499499
→ Lowering inline handlers...
500500
→ Validating protocol compliance...
501-
→ Running author-time rules (43)...
501+
→ Running author-time rules (44)...
502502
→ Checking capability providers (#3366)...
503503
→ Collecting package docs (ADR-0046)...
504504
→ Writing artifact...

content/docs/deployment/validating-metadata.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ A clean run walks the registry and reports timing:
555555
Config: /path/to/support-desk/objectstack.config.ts
556556
Load time: 21ms
557557
→ Validating against ObjectStack Protocol...
558-
→ Running author-time rules (43)...
558+
→ Running author-time rules (44)...
559559
→ Checking capability providers (#3366)...
560560
→ Checking package docs (ADR-0046)...
561561

content/docs/getting-started/build-with-claude-code.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ visible: 'status != "resolved"'
263263
◆ Validate
264264
────────────────────────────────────────
265265
→ Validating against ObjectStack Protocol...
266-
→ Running author-time rules (43)...
266+
→ Running author-time rules (44)...
267267
268268
✗ Author-time rules failed (1 issue)
269269
• stack · action 'resolve_ticket' visible: bare reference `status` — a

content/docs/ui/react-pages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ objectstack validate
380380
────────────────────────────────────────
381381
→ Loading configuration...
382382
→ Validating against ObjectStack Protocol...
383-
→ Running author-time rules (43)...
383+
→ Running author-time rules (44)...
384384
→ Checking capability providers (#3366)...
385385
→ Checking package docs (ADR-0046)...
386386

packages/lint/src/authoring-rules.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
105105
import { validateWidgetBindings } from './validate-widget-bindings.js';
106106
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
107107
import { validateFilterTokens } from './validate-filter-tokens.js';
108+
import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js';
108109
import { validatePresetComparands } from './validate-preset-comparands.js';
109110
import { validateEmptyCombinators } from './validate-empty-combinators.js';
110111
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
@@ -612,6 +613,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
612613
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
613614
run: (stack) => validateFilterTokens(stack),
614615
},
616+
// #16096 — the FLOW half of the same question, and a different answer,
617+
// because a flow node's `config.filter` is evaluated by the automation
618+
// template evaluator before ObjectQL ever sees it. Reports only the class
619+
// NEITHER dialect resolves: a call to a name outside the flow template
620+
// dialect's closed function table, where `resolveToken` raises a guard
621+
// refusal and the node cannot run. The open arm (bare/dotted identifiers
622+
// addressing the run's VariableMap) is deliberately left silent — judging it
623+
// against the ObjectQL vocabulary reports 7 findings on this repo's own
624+
// examples, all 7 false positives. Reads `flows` alone, so the per-write
625+
// snapshot carries everything it needs.
626+
{
627+
name: 'validateFlowFilterTokens',
628+
tier: 'gating',
629+
input: 'parsed',
630+
commands: ALL,
631+
source: 'packages/lint/src/validate-flow-filter-tokens.ts',
632+
surfaces: CLI_AND_RUNTIME,
633+
runtimeTypes: ['flow'],
634+
run: (stack) => validateFlowFilterTokens(stack),
635+
},
615636
// #8793 (the ruled C half of #8690) — a declared dashboard date-range preset
616637
// name (`last_30_days`, …) authored as a bare ORDERING comparand resolves in
617638
// no layer: the engine refuses it on a declared temporal field at query time
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The CROSS-PACKAGE DRIFT PIN for `flow-template-grammar.ts` (#16096).
4+
//
5+
// That module MIRRORS the automation template evaluator's whole-token dispatch,
6+
// because `@objectstack/lint` depends on `@objectstack/spec` and never on a
7+
// runtime, so the dialect cannot be imported from the package that owns it. A
8+
// mirror nobody checks is the "N copies, the next author fixes one of N" shape
9+
// `filter-walk.ts` was written against — so this file reads the ORIGINAL from
10+
// disk and fails when any mirrored piece stops matching it.
11+
//
12+
// The read escapes this package, spelled so `check:cross-package-test-inputs`
13+
// can see it, and `$TURBO_ROOT$/packages/services/service-automation/src/**` is
14+
// already a declared input of `@objectstack/lint#test` in turbo.json.
15+
16+
import { existsSync, readFileSync } from 'node:fs';
17+
import { dirname, join } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
import { describe, expect, it } from 'vitest';
20+
21+
import {
22+
classifyFlowTemplateToken,
23+
DATE_FUNCTION_RE,
24+
VARIABLE_PATH_RE,
25+
SAFE_EXPRESSION_RE,
26+
IDENTIFIER_SCAN_RE,
27+
CALL_POSITION_RE,
28+
FLOW_TEMPLATE_DATE_FUNCTIONS,
29+
FLOW_TEMPLATE_VALUE_FUNCTIONS,
30+
} from './flow-template-grammar.js';
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
34+
/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */
35+
function findUp(predicate: (dir: string) => boolean): string {
36+
let dir = HERE;
37+
for (;;) {
38+
if (predicate(dir)) return dir;
39+
const parent = dirname(dir);
40+
if (parent === dir) throw new Error('workspace root not found from ' + HERE);
41+
dir = parent;
42+
}
43+
}
44+
const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml')));
45+
46+
const ORIGINAL = join(REPO, 'packages/services/service-automation/src/builtin/template.ts');
47+
// Loud absence: if this file moves, the mirror is unpinned, and an unpinned
48+
// mirror is the defect this test exists to prevent. Failing to read IS the
49+
// regression — never a skip.
50+
const source = readFileSync(ORIGINAL, 'utf8');
51+
52+
describe('the mirrored grammar still matches the evaluator that owns it', () => {
53+
const mirrored: Array<[string, RegExp]> = [
54+
['the NOW()/TODAY() ± N day form', DATE_FUNCTION_RE],
55+
['the variable / dotted-path form', VARIABLE_PATH_RE],
56+
['the arithmetic character set', SAFE_EXPRESSION_RE],
57+
['the identifier scan', IDENTIFIER_SCAN_RE],
58+
['the call-position lookahead', CALL_POSITION_RE],
59+
];
60+
for (const [label, re] of mirrored) {
61+
it(`${label} appears verbatim in template.ts`, () => {
62+
expect(source).toContain(re.source);
63+
});
64+
}
65+
66+
it('mirrors the value-function table exactly — no name added, none dropped', () => {
67+
const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source);
68+
expect(block, 'EXPRESSION_FUNCTION_ARITY not found in template.ts').toBeTruthy();
69+
const names = [...block![1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]);
70+
expect(names.sort()).toEqual([...FLOW_TEMPLATE_VALUE_FUNCTIONS].sort());
71+
});
72+
73+
it('mirrors the two whole-token date function names', () => {
74+
for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(DATE_FUNCTION_RE.source).toContain(name);
75+
// And the evaluator still keeps them OUT of the value table — the reason
76+
// `{TODAY() - 45 - 10}` is refused while `{TODAY() - 45}` is not.
77+
const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source);
78+
for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(block![1]).not.toContain(name);
79+
});
80+
81+
it('the evaluator still REFUSES an unknown call rather than resolving it to null', () => {
82+
// The mirror only means something while the runtime still throws here.
83+
expect(source).toContain('throw unknownFunctionError(match, trimmed)');
84+
});
85+
86+
it('the filter position still hands an unresolved KNOWN filter token to the engine', () => {
87+
// The layer-one/layer-two split the rule is built on.
88+
expect(source).toContain('isKnownFilterToken');
89+
});
90+
});
91+
92+
describe('dispatch ORDER — the property the negative control depends on', () => {
93+
it('classifies {TODAY() - 45} as a date function, never as a call', () => {
94+
expect(classifyFlowTemplateToken('TODAY() - 45')).toEqual({ kind: 'date-function', name: 'TODAY' });
95+
});
96+
97+
it('classifies TOMORROW() as an unknown function', () => {
98+
expect(classifyFlowTemplateToken('TOMORROW()')).toEqual({ kind: 'unknown-function', name: 'TOMORROW' });
99+
});
100+
101+
it('classifies the open arm as variable-path, never as a finding', () => {
102+
expect(classifyFlowTemplateToken('recordId')).toEqual({ kind: 'variable-path', head: 'recordId' });
103+
expect(classifyFlowTemplateToken('record.id')).toEqual({ kind: 'variable-path', head: 'record' });
104+
});
105+
106+
it('classifies $User.* as user context', () => {
107+
expect(classifyFlowTemplateToken('$User.Id')).toEqual({ kind: 'user-context' });
108+
});
109+
110+
it('classifies a junk shape as unresolvable rather than as a call', () => {
111+
expect(classifyFlowTemplateToken('30 days ago')).toEqual({ kind: 'unresolvable-shape' });
112+
expect(classifyFlowTemplateToken('')).toEqual({ kind: 'unresolvable-shape' });
113+
});
114+
115+
it('never reports a reserved literal in call position', () => {
116+
expect(classifyFlowTemplateToken('null(1)').kind).not.toBe('unknown-function');
117+
});
118+
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @module flow-template-grammar
5+
*
6+
* **Which `{…}` dialect owns a whole-string token in a FLOW node's filter, and
7+
* which spellings neither dialect can resolve** (#16096).
8+
*
9+
* A filter value position inside a flow node is the one place two `{…}`
10+
* vocabularies meet, and `interpolateFilter`
11+
* (`@objectstack/service-automation`, `src/builtin/template.ts`, #3810) is the
12+
* function that arbitrates them. Its own header states the split:
13+
*
14+
* > A whole-string token that (a) no flow variable resolves and (b) IS a
15+
* > recognised filter placeholder is passed through **verbatim** for the engine
16+
* > to expand. That is a transfer of ownership, not a lenient fallback.
17+
*
18+
* So a token in this position falls in exactly one of three classes:
19+
*
20+
* | class | resolved by | example | judged here? |
21+
* |---|---|---|---|
22+
* | flow template dialect | the automation template evaluator, BEFORE the query | `{TODAY() - 45}`, `{record.id}`, `{$User.Id}`, `{round(x)}` | ⛔ no |
23+
* | filter placeholder dialect | ObjectQL, after hand-off (`isKnownFilterToken`) | `{current_user_id}`, `{30_days_ago}` | ⛔ no — `filter-token-unknown` owns it |
24+
* | **neither** | nothing — the run fails or the condition collapses | `{TOMORROW()}`, `{ROUND(x)}` | ✅ the third class, and only it |
25+
*
26+
* ## Why only the CALL-POSITION half of the third class is decidable here
27+
*
28+
* The flow dialect's vocabulary is closed in three of its four arms and OPEN in
29+
* the fourth:
30+
*
31+
* - `NOW()` / `TODAY()` with an optional `± N` day offset — closed, two names.
32+
* - `$User.<path>` — closed prefix.
33+
* - `round` / `floor` / `ceil` / `abs` / `min` / `max` in CALL position —
34+
* closed by maintainer ruling on #11060 ("exactly … every name and semantic
35+
* mirrored **1:1 from the CEL stdlib**, ⛔ no second semantics invented").
36+
* - a bare or dotted identifier (`{recordId}`, `{record.id}`, `{status}`) —
37+
* **OPEN**: it addresses the run's `VariableMap`, which holds the flow's
38+
* declared variables, every node's `outputVariable`, and — via
39+
* `seedRunVariables` — the trigger record's own fields flattened to top
40+
* level. None of that is decidable from authored metadata alone, and a flow
41+
* bound to an object another package defines cannot be resolved here at all.
42+
*
43+
* That asymmetry is the whole reason this module reports the call-position arm
44+
* and nothing else. Measured on this repo's own examples, judging the OPEN arm
45+
* against the ObjectQL vocabulary — the shape #16096 calls "the obvious fix" —
46+
* reports **7 findings at `error`, all 7 false positives** (`{recordId}` ×3,
47+
* `{record.id}` ×3, `{currentTask.id}` ×1, across app-todo / app-crm /
48+
* app-showcase). Every one is a legitimate flow variable that resolves at run
49+
* time. A reference set that reds working sweeps is worse than the silence
50+
* #16096 reports, so the open arm stays unjudged and says so.
51+
*
52+
* ## Dispatch ORDER is load-bearing, not incidental
53+
*
54+
* `resolveToken` tries the date-function form BEFORE it scans for call
55+
* positions. `{TODAY() - 45}` therefore never reaches the scan — which is the
56+
* only reason the legitimate spelling stays silent, because `TODAY` sitting in
57+
* front of a `(` is otherwise indistinguishable from `TOMORROW`. This module
58+
* mirrors that order exactly and `flow-template-grammar.test.ts` pins the
59+
* negative control against it.
60+
*
61+
* ## This is a MIRROR, and the drift is pinned
62+
*
63+
* `@objectstack/lint` depends on `@objectstack/spec` and never on a runtime
64+
* (its own package description), so the dialect cannot be imported from the
65+
* package that owns it. The five regexes and the function table below are
66+
* therefore copied, and `flow-template-grammar.test.ts` reads
67+
* `packages/services/service-automation/src/builtin/template.ts` from disk and
68+
* fails when any of them stops matching the original — a cross-package test
69+
* input already declared on `@objectstack/lint#test` in `turbo.json`, so the
70+
* graph can see it. ⛔ Do not "simplify" a regex here: it is not this module's
71+
* to choose, and an equivalent-looking rewrite breaks the pin that keeps the
72+
* two readers honest.
73+
*/
74+
75+
/**
76+
* The two whole-token date functions, with their `± N day` offset grammar.
77+
* Verbatim from `resolveToken`'s `dateFnMatch`.
78+
*/
79+
export const DATE_FUNCTION_RE = /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/;
80+
81+
/** Direct variable / dotted-path lookup, numeric segments included (#1872). */
82+
export const VARIABLE_PATH_RE = /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/;
83+
84+
/**
85+
* The character set `resolveToken` will attempt arithmetic on. A token outside
86+
* it resolves to `undefined` without ever reaching the call-position scan.
87+
*/
88+
export const SAFE_EXPRESSION_RE = /^[\w\s+\-*/%().,?:<>=!&|"'$]+$/;
89+
90+
/** Identifier / dotted-identifier occurrences inside a mixed expression. */
91+
export const IDENTIFIER_SCAN_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
92+
93+
/** An identifier is in CALL position when a `(` follows it. */
94+
export const CALL_POSITION_RE = /^\s*\(/;
95+
96+
/** Literals `resolveToken` never substitutes, checked BEFORE call position. */
97+
const RESERVED_LITERALS: ReadonlySet<string> = new Set(['true', 'false', 'null', 'undefined']);
98+
99+
/** The two names legal only as a whole token (`{TODAY() + 7}`), never in a call. */
100+
export const FLOW_TEMPLATE_DATE_FUNCTIONS: readonly string[] = ['NOW', 'TODAY'];
101+
102+
/**
103+
* The value-expression function table — the CEL stdlib's numeric six, by the
104+
* #11060 ruling. Mirrors `EXPRESSION_FUNCTION_ARITY`'s key set.
105+
*/
106+
export const FLOW_TEMPLATE_VALUE_FUNCTIONS: readonly string[] = [
107+
'round', 'floor', 'ceil', 'abs', 'min', 'max',
108+
];
109+
110+
const VALUE_FUNCTION_SET: ReadonlySet<string> = new Set(FLOW_TEMPLATE_VALUE_FUNCTIONS);
111+
112+
/** What the flow template dialect does with one whole-string `{…}` token. */
113+
export type FlowTemplateTokenVerdict =
114+
/** `{NOW()}` / `{TODAY() - 45}` — the evaluator resolves it. Legitimate. */
115+
| { kind: 'date-function'; name: string }
116+
/** `{$User.Id}` — the evaluator resolves it from the run context. */
117+
| { kind: 'user-context' }
118+
/**
119+
* `{recordId}` / `{record.id}` — a `VariableMap` lookup, and the position
120+
* from which an unresolved name is handed to the filter dialect. OPEN: not
121+
* decidable from authored metadata, so never a finding.
122+
*/
123+
| { kind: 'variable-path'; head: string }
124+
/**
125+
* A call to a name in NEITHER table. `resolveToken` throws
126+
* `FlowExpressionFunctionError` here (a guard refusal — a `fault` edge must
127+
* not swallow it), so the node cannot run. THIS is the finding.
128+
*/
129+
| { kind: 'unknown-function'; name: string }
130+
/**
131+
* Anything else — junk shapes (`{30 days ago}`) and arithmetic over names
132+
* this module cannot resolve. `resolveToken` answers `undefined` and the
133+
* CRUD collapse guard (#3810) reports it at run time. Open, not judged.
134+
*/
135+
| { kind: 'unresolvable-shape' };
136+
137+
/**
138+
* Classify the INSIDE of one whole-string `{…}` filter token — `inner` is the
139+
* text between the braces, exactly as authored.
140+
*
141+
* Mirrors `resolveToken`'s dispatch order (see the module header). Holds no
142+
* severity and knows nothing about where the token was found.
143+
*/
144+
export function classifyFlowTemplateToken(inner: string): FlowTemplateTokenVerdict {
145+
const trimmed = inner.trim();
146+
if (!trimmed) return { kind: 'unresolvable-shape' };
147+
148+
// 1. Whole-token date functions, BEFORE any call-position reasoning.
149+
const dateMatch = DATE_FUNCTION_RE.exec(trimmed);
150+
if (dateMatch) return { kind: 'date-function', name: dateMatch[1] };
151+
152+
// 2. `$User.*` shortcuts.
153+
if (trimmed.startsWith('$User.')) return { kind: 'user-context' };
154+
155+
// 3. Direct variable / dotted path — the open arm.
156+
if (VARIABLE_PATH_RE.test(trimmed)) {
157+
return { kind: 'variable-path', head: trimmed.split('.')[0] };
158+
}
159+
160+
// 4. Outside the arithmetic character set: `undefined`, no throw.
161+
if (!SAFE_EXPRESSION_RE.test(trimmed)) return { kind: 'unresolvable-shape' };
162+
163+
// 5. The call-position scan. `resolveToken` throws on the FIRST unknown name
164+
// it reaches, so the first is what an author sees and what is reported.
165+
for (const match of trimmed.matchAll(IDENTIFIER_SCAN_RE)) {
166+
const name = match[0];
167+
if (RESERVED_LITERALS.has(name)) continue;
168+
const rest = trimmed.slice((match.index ?? 0) + name.length);
169+
if (!CALL_POSITION_RE.test(rest)) continue;
170+
if (VALUE_FUNCTION_SET.has(name)) continue;
171+
return { kind: 'unknown-function', name };
172+
}
173+
174+
return { kind: 'unresolvable-shape' };
175+
}

0 commit comments

Comments
 (0)