Skip to content

Commit 58209d5

Browse files
committed
fix(mothership): guard formatted function semantics
1 parent b625977 commit 58209d5

4 files changed

Lines changed: 470 additions & 5 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { spawnSync } from 'node:child_process'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { CodeLanguage } from '@/lib/execution/languages'
7+
import { formatFunctionCode } from '@/lib/workflows/blocks/format-function-code'
8+
import { BlockType } from '@/executor/constants'
9+
import { ExecutionState } from '@/executor/execution/state'
10+
import type { ExecutionContext } from '@/executor/types'
11+
import { VariableResolver } from '@/executor/variables/resolver'
12+
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
13+
14+
vi.mock('@/executor/utils/block-data', () => ({ getBlockSchema: () => ({}) }))
15+
16+
const PRODUCER_OUTPUT = {
17+
result: {
18+
name: 'formatter',
19+
values: [2, 4, 6],
20+
},
21+
}
22+
23+
interface ResolvedFunctionCode {
24+
code: string
25+
contextVariables: Record<string, unknown>
26+
}
27+
28+
interface PythonProgramAnalysis {
29+
ast: string
30+
result: unknown
31+
}
32+
33+
interface PythonProgramComparison {
34+
formatted: PythonProgramAnalysis
35+
original: PythonProgramAnalysis
36+
}
37+
38+
const PYTHON_ANALYSIS_SCRIPT = `
39+
import ast
40+
import json
41+
import sys
42+
import textwrap
43+
44+
payload = json.load(sys.stdin)
45+
46+
def analyze(code):
47+
namespace = dict(payload["contextVariables"])
48+
namespace["json"] = json
49+
source = "def __sim_function__():\\n" + textwrap.indent(code, " ") + "\\n"
50+
tree = ast.parse(source)
51+
exec(compile(tree, "<sim-function>", "exec"), namespace)
52+
return {
53+
"ast": ast.dump(tree, include_attributes=False),
54+
"result": namespace["__sim_function__"](),
55+
}
56+
57+
print(json.dumps({
58+
"formatted": analyze(payload["formattedCode"]),
59+
"original": analyze(payload["originalCode"]),
60+
}, sort_keys=True))
61+
`.trim()
62+
63+
function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock {
64+
return {
65+
id,
66+
metadata: { id: type, name },
67+
position: { x: 0, y: 0 },
68+
config: { tool: type, params },
69+
inputs: {},
70+
outputs: { result: 'json' },
71+
enabled: true,
72+
}
73+
}
74+
75+
async function resolveFunctionCode(
76+
code: string,
77+
language: CodeLanguage.JavaScript | CodeLanguage.Python
78+
): Promise<ResolvedFunctionCode> {
79+
const producer = createBlock('producer', 'Producer', BlockType.API)
80+
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { language })
81+
const workflow: SerializedWorkflow = {
82+
version: '1',
83+
blocks: [producer, functionBlock],
84+
connections: [],
85+
loops: {},
86+
parallels: {},
87+
}
88+
const state = new ExecutionState()
89+
state.setBlockOutput('producer', PRODUCER_OUTPUT)
90+
const context = {
91+
workflowId: 'workflow-1',
92+
blockStates: state.getBlockStates(),
93+
executedBlocks: state.getExecutedBlocks(),
94+
blockLogs: [],
95+
metadata: { duration: 0 },
96+
environmentVariables: {
97+
EXPRESSION: '1 + 2',
98+
OFFSET: '5',
99+
PYTHON_ASSIGNMENT: 'assigned := 7',
100+
},
101+
workflowVariables: {},
102+
decisions: { router: new Map(), condition: new Map() },
103+
completedLoops: new Set(),
104+
activeExecutionPath: new Set(),
105+
} as ExecutionContext
106+
const resolver = new VariableResolver(workflow, {}, state)
107+
const resolved = await resolver.resolveInputsForFunctionBlock(
108+
context,
109+
functionBlock.id,
110+
{ code },
111+
functionBlock
112+
)
113+
114+
expect(typeof resolved.resolvedInputs.code).toBe('string')
115+
return {
116+
code: resolved.resolvedInputs.code as string,
117+
contextVariables: resolved.contextVariables,
118+
}
119+
}
120+
121+
async function canonicalizeResolvedCode(
122+
code: string,
123+
language: CodeLanguage.JavaScript | CodeLanguage.Python
124+
): Promise<string> {
125+
const result = await formatFunctionCode(code, language)
126+
expect(result.error).toBeNull()
127+
return result.code
128+
}
129+
130+
function executeJavaScript(code: string, contextVariables: Record<string, unknown>): unknown {
131+
const execute = new Function('globalThis', code) as (
132+
variables: Record<string, unknown>
133+
) => unknown
134+
return execute(structuredClone(contextVariables))
135+
}
136+
137+
function analyzePythonPrograms(
138+
originalCode: string,
139+
formattedCode: string,
140+
contextVariables: Record<string, unknown>
141+
): PythonProgramComparison {
142+
const input = JSON.stringify({ originalCode, formattedCode, contextVariables })
143+
144+
for (const executable of ['python3', 'python']) {
145+
const analysis = spawnSync(executable, ['-c', PYTHON_ANALYSIS_SCRIPT], {
146+
encoding: 'utf8',
147+
input,
148+
})
149+
if (analysis.error && 'code' in analysis.error && analysis.error.code === 'ENOENT') continue
150+
if (analysis.error) throw analysis.error
151+
if (analysis.status !== 0) {
152+
throw new Error(`${executable} failed to analyze Function code: ${analysis.stderr.trim()}`)
153+
}
154+
return JSON.parse(analysis.stdout) as PythonProgramComparison
155+
}
156+
157+
throw new Error('Python is required to verify formatted Python Function behavior')
158+
}
159+
160+
describe('formatFunctionCode reference resolution integration', () => {
161+
it.each([
162+
'const item=<Producer.result>;const offset={{OFFSET}};return {name:item.name,total:item.values.reduce((sum,value)=>sum+value,offset)};',
163+
"const raw='<Producer.result>';return raw;",
164+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional Function source fixture
165+
'return `item:${String(<Producer.result>.name)}:{{OFFSET}}`;',
166+
"// don't confuse quote tracking\nconst item=<Producer.result>;return item.values.slice(1);",
167+
'return 2*({{EXPRESSION}});',
168+
'return 2*(/* before */{{EXPRESSION}}/* after */);',
169+
])('preserves resolved JavaScript behavior: %s', async (sourceCode) => {
170+
const formatted = await formatFunctionCode(sourceCode, CodeLanguage.JavaScript)
171+
expect(formatted.error).toBeNull()
172+
173+
const [originalResolved, formattedResolved] = await Promise.all([
174+
resolveFunctionCode(sourceCode, CodeLanguage.JavaScript),
175+
resolveFunctionCode(formatted.code, CodeLanguage.JavaScript),
176+
])
177+
178+
expect(formattedResolved.contextVariables).toEqual(originalResolved.contextVariables)
179+
await expect(
180+
canonicalizeResolvedCode(formattedResolved.code, CodeLanguage.JavaScript)
181+
).resolves.toBe(await canonicalizeResolvedCode(originalResolved.code, CodeLanguage.JavaScript))
182+
expect(executeJavaScript(formattedResolved.code, formattedResolved.contextVariables)).toEqual(
183+
executeJavaScript(originalResolved.code, originalResolved.contextVariables)
184+
)
185+
})
186+
187+
it.each([
188+
'item=<Producer.result>\noffset={{OFFSET}}\nreturn {"name":item["name"],"total":sum(item["values"])+offset}',
189+
"raw='<Producer.result>'\nreturn raw",
190+
"prompt='''\nSummary: <Producer.result>\n'''\nreturn prompt",
191+
"# don't confuse quote tracking\nitem=<Producer.result>\nreturn item['values'][1:]",
192+
'value=({{PYTHON_ASSIGNMENT}})\nreturn value',
193+
'value=(\n {{EXPRESSION}}\n)\nreturn value',
194+
'value=(\n # before\n {{EXPRESSION}} # after\n)\nreturn value',
195+
])('preserves resolved Python syntax and references: %s', async (sourceCode) => {
196+
const formatted = await formatFunctionCode(sourceCode, CodeLanguage.Python)
197+
expect(formatted.error).toBeNull()
198+
199+
const [originalResolved, formattedResolved] = await Promise.all([
200+
resolveFunctionCode(sourceCode, CodeLanguage.Python),
201+
resolveFunctionCode(formatted.code, CodeLanguage.Python),
202+
])
203+
204+
expect(formattedResolved.contextVariables).toEqual(originalResolved.contextVariables)
205+
const analysis = analyzePythonPrograms(
206+
originalResolved.code,
207+
formattedResolved.code,
208+
originalResolved.contextVariables
209+
)
210+
expect(analysis.formatted.ast).toBe(analysis.original.ast)
211+
expect(analysis.formatted.result).toEqual(analysis.original.result)
212+
})
213+
})

apps/sim/lib/workflows/blocks/format-function-code.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,35 @@ return result`,
209209
expect(result).toEqual({ code, changed: false, error: null })
210210
})
211211

212+
it('preserves JavaScript grouping without confusing call or control parentheses', async () => {
213+
const code =
214+
'const value=combine({{EXPRESSION}});if({{FLAG}}){return value}return 2*({{EXPRESSION}});'
215+
216+
await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({
217+
code: `const value = combine({{EXPRESSION}});
218+
if ({{FLAG}}) {
219+
return value;
220+
}
221+
return 2 * ({{EXPRESSION}});`,
222+
changed: true,
223+
error: null,
224+
})
225+
})
226+
227+
it('preserves Python grouping without confusing call or control parentheses', async () => {
228+
const code =
229+
'value=combine({{EXPRESSION}})\nif ({{FLAG}}):\n return value\nreturn ({{EXPRESSION}})'
230+
231+
await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({
232+
code: `value = combine({{EXPRESSION}})
233+
if ({{FLAG}}):
234+
return value
235+
return ({{EXPRESSION}})`,
236+
changed: true,
237+
error: null,
238+
})
239+
})
240+
212241
it('keeps Python boundary keywords language-specific', async () => {
213242
const cases = [
214243
['and', 'or'],

0 commit comments

Comments
 (0)