Skip to content

Commit 184460a

Browse files
Vikhyath Mondreticlaude
andcommitted
fix(executor): read what a closing parenthesis closed
`)` ends a value in `(a + b) / 2` and a control-flow head in `if (a) /re/.test(b)`, and the character alone does not say which. Treating it as one or the other unconditionally misreads the other: as a value, a statement-position regex is scanned as code, and a quote inside it decides how every reference sharing that line is spliced; as a head, ordinary division opens a regex that swallows the quotes after it. The scan now records which kind of parenthesis each `)` closed, by reading the keyword in front of its opener, and answers from that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 47da972 commit 184460a

2 files changed

Lines changed: 78 additions & 3 deletions

File tree

‎apps/sim/executor/variables/resolver.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,34 @@ describe('VariableResolver function block inputs', () => {
885885
expect(result.contextVariables).toEqual({ __blockRef_0: ref })
886886
})
887887

888+
it('reads the context of a reference that follows a statement-position regex', async () => {
889+
// `)` ends a value in `(a + b) / 2` and a control-flow head in `if (a) /re/.test(b)`, and
890+
// the closing parenthesis alone does not say which. Guessing either way misreads one of
891+
// them, and a quote inside the regex then decides how every later reference is spliced.
892+
const { block, ctx, resolver } = createResolver('javascript')
893+
894+
// Both cases stay on one line: a string mode ends at a newline, so only a reference sharing
895+
// the line with the misread slash sees the wrong context.
896+
const result = await resolver.resolveInputsForFunctionBlock(
897+
ctx,
898+
'function',
899+
{
900+
code: [
901+
`if (params.a) /['"]/.test('<producer.result>')`,
902+
`const divided = (params.c + 1) / 2 + Number('<producer.result>')`,
903+
'return divided',
904+
].join('\n'),
905+
},
906+
block
907+
)
908+
909+
const code = result.resolvedInputs.code as string
910+
// Statement-position regex: the reference after it is inside the author's quotes.
911+
expect(code).toContain(`.test('' + JSON.stringify(globalThis["__blockRef_0"]) + '')`)
912+
// Division after a value: the slash must not open a regex that swallows the quotes.
913+
expect(code).toContain(`Number('' + JSON.stringify(globalThis["__blockRef_1"]) + '')`)
914+
})
915+
888916
it('binds a workflow variable carrying quote characters instead of splicing it into code', async () => {
889917
// A Variables block can assign trigger data at runtime, so a variable's value is not
890918
// necessarily the author's. Inlined as a literal it closed the string it landed in.

‎apps/sim/executor/variables/resolver.ts‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,17 @@ type CodeScanMode =
172172
* a string, and every reference past that point was then formatted for the wrong context.
173173
* Division always follows a value — an identifier, literal, `)`, or `]` — so anything else
174174
* ending the preceding token means a regex may start.
175+
*
176+
* `)` is the one that is not decidable from the character alone: it ends a value in
177+
* `(a + b) / 2` and a control-flow head in `if (a) /re/.test(b)`. Reading it as either one
178+
* unconditionally breaks the other, so the scan remembers which kind of parenthesis each `)`
179+
* closed rather than guessing — see {@link CONTROL_FLOW_HEAD_KEYWORDS}.
175180
*/
176181
const JAVASCRIPT_REGEX_ALLOWED_AFTER = new Set('(,=:[!&|?{};+-*%^~<>/'.split(''))
177182

183+
/** Keywords whose parenthesized head is followed by a statement, where a regex may start. */
184+
const CONTROL_FLOW_HEAD_KEYWORDS = new Set(['if', 'while', 'for', 'switch', 'catch', 'with'])
185+
178186
const WHITESPACE_CHAR = /\s/
179187

180188
/** Keywords a regex may directly follow, where the preceding token is a word rather than punctuation. */
@@ -1118,11 +1126,18 @@ export class VariableResolver {
11181126
* anything else means a regex may start. Guessing wrong is not silent — the scan would swallow
11191127
* text up to the next `/` — so the check reads the actual preceding token rather than assuming.
11201128
*/
1121-
private canStartJavaScriptRegex(template: string, previousSignificantIndex: number): boolean {
1129+
private canStartJavaScriptRegex(
1130+
template: string,
1131+
previousSignificantIndex: number,
1132+
controlHeadParenCloses: ReadonlySet<number>
1133+
): boolean {
11221134
if (previousSignificantIndex < 0) {
11231135
return true
11241136
}
11251137
const previous = template[previousSignificantIndex]
1138+
if (previous === ')') {
1139+
return controlHeadParenCloses.has(previousSignificantIndex)
1140+
}
11261141
if (JAVASCRIPT_REGEX_ALLOWED_AFTER.has(previous)) {
11271142
return true
11281143
}
@@ -1139,6 +1154,26 @@ export class VariableResolver {
11391154
)
11401155
}
11411156

1157+
/**
1158+
* Whether the `(` at this index opens a control-flow head rather than a value.
1159+
*
1160+
* What follows the matching `)` differs entirely between the two — a statement, where a regex
1161+
* literal may begin, versus an operator, where a `/` divides — and the closing parenthesis
1162+
* carries no trace of which it was. Reading the keyword in front of the opening one is what
1163+
* lets `if (a) /re/.test(b)` and `(a + b) / 2` both scan correctly.
1164+
*/
1165+
private opensControlFlowHead(template: string, index: number): boolean {
1166+
let end = index
1167+
while (end > 0 && WHITESPACE_CHAR.test(template[end - 1])) {
1168+
end--
1169+
}
1170+
let start = end
1171+
while (start > 0 && this.isJavaScriptIdentifierChar(template[start - 1])) {
1172+
start--
1173+
}
1174+
return CONTROL_FLOW_HEAD_KEYWORDS.has(template.slice(start, end))
1175+
}
1176+
11421177
private matchesKeywordAt(template: string, index: number, keyword: string): boolean {
11431178
if (!template.startsWith(keyword, index)) {
11441179
return false
@@ -1267,6 +1302,8 @@ export class VariableResolver {
12671302
const isPython = language === 'python'
12681303
const modes: CodeScanMode[] = [{ type: 'normal' }]
12691304
let lastSignificantIndex = -1
1305+
const openParenIsControlHead: boolean[] = []
1306+
const controlHeadParenCloses = new Set<number>()
12701307

12711308
for (let i = 0; i < index; i++) {
12721309
const char = template[i]
@@ -1369,10 +1406,15 @@ export class VariableResolver {
13691406
if (!WHITESPACE_CHAR.test(char)) {
13701407
lastSignificantIndex = i
13711408
}
1409+
if (char === '(') {
1410+
openParenIsControlHead.push(this.opensControlFlowHead(template, i))
1411+
} else if (char === ')') {
1412+
if (openParenIsControlHead.pop()) controlHeadParenCloses.add(i)
1413+
}
13721414
if (
13731415
!isPython &&
13741416
char === '/' &&
1375-
this.canStartJavaScriptRegex(template, previousSignificantIndex)
1417+
this.canStartJavaScriptRegex(template, previousSignificantIndex, controlHeadParenCloses)
13761418
) {
13771419
modes.push({ type: 'regex', inCharacterClass: false })
13781420
continue
@@ -1430,10 +1472,15 @@ export class VariableResolver {
14301472
if (!WHITESPACE_CHAR.test(char)) {
14311473
lastSignificantIndex = i
14321474
}
1475+
if (char === '(') {
1476+
openParenIsControlHead.push(this.opensControlFlowHead(template, i))
1477+
} else if (char === ')') {
1478+
if (openParenIsControlHead.pop()) controlHeadParenCloses.add(i)
1479+
}
14331480
if (
14341481
!isPython &&
14351482
char === '/' &&
1436-
this.canStartJavaScriptRegex(template, previousSignificantIndex)
1483+
this.canStartJavaScriptRegex(template, previousSignificantIndex, controlHeadParenCloses)
14371484
) {
14381485
modes.push({ type: 'regex', inCharacterClass: false })
14391486
continue

0 commit comments

Comments
 (0)