Skip to content

Commit ab04dda

Browse files
committed
fix(lint): flow-bare-dollar-reference names the CEL envelope in a value slot
The hint prescribed `{source.id}` and `{$User.Id}` everywhere, and a value slot refuses both: the single-brace dialect since the first pass, the run user since this one. In a value slot the hint now names the CEL value envelope the spec's value-slot judge writes for the token the reference names, asked of the judge rather than re-spelled: `current_user.id` with its guard for `$User.Id`, the user-record read for any other `$User` path, `vars["$error"].message` for a `$` root the engine binds, and the flow's own variable without the `$` for any other. Whether a string sits in a value slot is asked of the judge too, so the two legacy `assignment` shapes are covered. Text slots keep the `{{ }}` hole, and every other position keeps the single-brace hint. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent caeaab9 commit ab04dda

2 files changed

Lines changed: 292 additions & 23 deletions

File tree

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation';
5+
// [#19939] The value-slot judge, asked by the `flow-bare-dollar-reference` pins
6+
// below for the spelling its hint must carry and for the verdict on it.
7+
import { flowNodeValueTemplateRefusals, VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation';
58
import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
69
// [#5659] The shared identity reduction, asserted beside the rule that consumes
710
// it — the rule's verdict and the drivers' verdict are one object now.
@@ -2561,6 +2564,141 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
25612564
expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!);
25622565
expect(fnds[0].hint).not.toContain('{{ $User.Id }}');
25632566
});
2567+
// [#19939] What the hint prescribes in a text slot, put back in the
2568+
// slot, is refused by no judge the slot has: the hole for an
2569+
// engine-bound reference, and for the run user the judge's own remedy —
2570+
// its id computed by an `assignment` node's CEL envelope, written as a hole.
2571+
it('each spelling it prescribes passes the text-slot judge and the build door with 0 refusals', () => {
2572+
const [errorRef] = notifyText('Failed: $error.message');
2573+
const hole = /`(\{\{ [^`]* \}\})`/.exec(errorRef!.hint!)?.[1];
2574+
expect(hole).toBe('{{ $error.message }}');
2575+
const [userRef] = notifyText('Closed by $User.Id');
2576+
const computed = /assignments: \{ v: \{ dialect: 'cel', source: '([^']*)' \} \}/.exec(userRef!.hint!)?.[1];
2577+
expect(computed).toBe('current_user.id');
2578+
const title = `Failed: ${hole}, closed by {{ v }}`;
2579+
expect(textSlotTemplateRefusal(title)).toBeUndefined();
2580+
const stack = {
2581+
flows: [{
2582+
name: 'fault_notice', label: 'Fault notice', type: 'autolaunched',
2583+
nodes: [
2584+
{ id: 'start', type: 'start', label: 'Start' },
2585+
{ id: 'who', type: 'assignment', label: 'Who', config: { assignments: { v: CEL(computed!) } } },
2586+
{ id: 'tell', type: 'notify', label: 'Tell', config: { recipients: ['u1'], title } },
2587+
{ id: 'done', type: 'end', label: 'Done' },
2588+
],
2589+
edges: [
2590+
{ id: 'e1', source: 'start', target: 'who' },
2591+
{ id: 'e2', source: 'who', target: 'tell' },
2592+
{ id: 'e3', source: 'tell', target: 'done' },
2593+
],
2594+
}],
2595+
};
2596+
expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]);
2597+
expect(lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP)).toEqual([]);
2598+
});
2599+
});
2600+
2601+
// [#19939] A value slot (`create_record` / `update_record` `fields.*`,
2602+
// `assignment` values) reads the CEL value envelope, and refuses every
2603+
// `{…}` token but the date macros — `{$User.*}` since the second pass. So
2604+
// the hint there names the envelope the spec's value-slot judge writes for
2605+
// the token the reference names, asked of the judge and never re-spelled,
2606+
// and each envelope it prescribes, put back in the slot, passes every
2607+
// judge the slot has: the value-slot judge, the build door, the flow
2608+
// contract and this rule.
2609+
describe('in a value slot', () => {
2610+
/** A start → write → end flow, the whole shape the build door judges. */
2611+
function valueFlow(type: string, config: Record<string, unknown>) {
2612+
return {
2613+
flows: [{
2614+
name: 'stamp_owner', label: 'Stamp owner', type: 'autolaunched',
2615+
nodes: [
2616+
{ id: 'start', type: 'start', label: 'Start' },
2617+
{ id: 'write', type, label: 'Write', config },
2618+
{ id: 'done', type: 'end', label: 'Done' },
2619+
],
2620+
edges: [{ id: 'e1', source: 'start', target: 'write' }, { id: 'e2', source: 'write', target: 'done' }],
2621+
}],
2622+
};
2623+
}
2624+
const bareDollar = (stack: ReturnType<typeof valueFlow>) =>
2625+
lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF);
2626+
/** The value-slot judge's own CEL spelling of a `{…}` token — what the hint carries word for word. */
2627+
const judgeSpelling = (token: string) =>
2628+
valueSlotTemplateRefusals(token)[0]!.message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length).trim();
2629+
/** Every concrete envelope source a hint prescribes; the `'…'` of its lead sentence is a placeholder. */
2630+
function prescribedSources(hint: string): string[] {
2631+
const out: string[] = [];
2632+
for (const m of hint.matchAll(/\{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/g)) {
2633+
const source = m[1] ?? (JSON.parse(m[2]!) as string);
2634+
if (source !== '…') out.push(source);
2635+
}
2636+
return out;
2637+
}
2638+
/** The refusals of the slot's judges on `stack`'s write node. */
2639+
function refusalsOf(stack: ReturnType<typeof valueFlow>) {
2640+
const node = stack.flows[0]!.nodes[1]!;
2641+
const parsed = FlowSchema.safeParse(stack.flows[0]);
2642+
return {
2643+
valueJudge: flowNodeValueTemplateRefusals(node.type, node.config).length,
2644+
buildDoor: validateStackExpressions(stack).filter((i) => i.severity === 'error').length,
2645+
contract: parsed.success ? 0 : parsed.error.issues.length,
2646+
lint: lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP).length,
2647+
};
2648+
}
2649+
const NONE = { valueJudge: 0, buildDoor: 0, contract: 0, lint: 0 };
2650+
2651+
it('names the CEL envelope, not a `{…}` token the slot refuses (`fields: { owner: \'$User.Id\', who: \'$source.id\' }`)', () => {
2652+
const fnds = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { owner: '$User.Id', who: '$source.id' } }));
2653+
expect(fnds).toHaveLength(2);
2654+
for (const f of fnds) {
2655+
expect(f.message).toContain('in the create_record field value');
2656+
expect(f.hint).toContain('A value slot reads a CEL value envelope');
2657+
expect(f.hint).not.toContain('Wrap it and bind a variable');
2658+
}
2659+
const [owner, who] = fnds;
2660+
// The run user: the judge's remedy for `{$User.Id}` — `current_user.id`, and the guard.
2661+
expect(owner!.hint).toContain(judgeSpelling('{$User.Id}'));
2662+
expect(prescribedSources(owner!.hint!)).toEqual(['current_user.id', 'current_user != null ? current_user.id : null']);
2663+
// `$source` is no `$` variable the engine binds: the flow's own `source`, written without the `$`.
2664+
expect(who!.hint).toContain(judgeSpelling('{source.id}'));
2665+
expect(prescribedSources(who!.hint!)).toEqual(['source.id']);
2666+
});
2667+
2668+
it('reads a `$` root the engine binds as that variable (`$error.message`)', () => {
2669+
const [f] = bareDollar(valueFlow('update_record', { objectName: 'task', filter: { id: '{record.id}' }, fields: { note: '$error.message' } }));
2670+
expect(f!.message).toContain('in the update_record field value');
2671+
expect(f!.hint).toContain(judgeSpelling('{$error.message}'));
2672+
expect(prescribedSources(f!.hint!)).toEqual(['vars["$error"].message']);
2673+
});
2674+
2675+
it.each([
2676+
['$User.Id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })],
2677+
['$source.id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })],
2678+
['$User.Email', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })],
2679+
['$error.message', 'update_record', (v: unknown) => ({ objectName: 'task', filter: { id: '{record.id}' }, fields: { v } })],
2680+
['$User.Id', 'assignment', (v: unknown) => ({ assignments: { v } })],
2681+
] as const)('`%s` in a %s value slot: each envelope it prescribes passes every judge with 0 refusals', (ref, type, configOf) => {
2682+
const fnds = bareDollar(valueFlow(type, configOf(ref)));
2683+
expect(fnds).toHaveLength(1);
2684+
const prescribed = prescribedSources(fnds[0]!.hint!);
2685+
expect(prescribed.length, fnds[0]!.hint).toBeGreaterThan(0);
2686+
for (const source of prescribed) {
2687+
expect(refusalsOf(valueFlow(type, configOf(CEL(source)))), `${ref} → ${source}`).toEqual(NONE);
2688+
}
2689+
});
2690+
2691+
it('control: both spellings the hint used to prescribe are refused in the same slot', () => {
2692+
for (const old of ['{source.id}', '{$User.Id}']) {
2693+
expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { v: old } })).valueJudge, old).toBe(1);
2694+
}
2695+
});
2696+
2697+
it('control: outside the value and text slots the single brace still resolves, and the hint still names it', () => {
2698+
const [f] = lintFlowPatterns(httpFlow({ ticket: '$source.id' })).filter((x) => x.rule === FLOW_BARE_DOLLAR_REF);
2699+
expect(f!.hint).toContain('Wrap it and bind a variable: `{source.id}`');
2700+
expect(flowNodeValueTemplateRefusals('http', httpPushConfig({ ticket: '{source.id}' }))).toEqual([]);
2701+
});
25642702
});
25652703
});
25662704

0 commit comments

Comments
 (0)