From a5e1c3d87e0639fff6a24c350788b0c505134829 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:11:10 -0700 Subject: [PATCH 1/5] feat(mothership): format generated function code --- .../server/workflow/edit-workflow/engine.ts | 5 +- .../function-code-formatting.test.ts | 190 ++++++++++++++ .../edit-workflow/function-code-formatting.ts | 79 ++++++ .../server/workflow/edit-workflow/index.ts | 27 ++ .../workflow/edit-workflow/operations.test.ts | 100 ++++++++ .../workflow/edit-workflow/operations.ts | 15 ++ .../server/workflow/edit-workflow/types.ts | 2 + .../blocks/format-function-code.test.ts | 212 ++++++++++++++++ .../workflows/blocks/format-function-code.ts | 236 ++++++++++++++++++ apps/sim/next.config.ts | 2 + apps/sim/package.json | 2 + bun.lock | 4 + 12 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.ts create mode 100644 apps/sim/lib/workflows/blocks/format-function-code.test.ts create mode 100644 apps/sim/lib/workflows/blocks/format-function-code.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts index 11405480bf4..48b4ecb656f 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts @@ -164,6 +164,8 @@ export function applyOperationsToWorkflowState( // Collect skipped items across all operations const skippedItems: SkippedItem[] = [] + const functionCodeBlockIds = new Set() + // Normalize block IDs to UUIDs before processing const { normalizedOperations } = normalizeBlockIdsInOperations(operations) @@ -184,6 +186,7 @@ export function applyOperationsToWorkflowState( skippedItems, validationErrors, permissionConfig, + functionCodeBlockIds, deferredConnections: [], } @@ -288,7 +291,7 @@ export function applyOperationsToWorkflowState( ) } - return { state: modifiedState, validationErrors, skippedItems } + return { state: modifiedState, validationErrors, skippedItems, functionCodeBlockIds } } /** diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.test.ts new file mode 100644 index 00000000000..92371bb974d --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { formatChangedFunctionCode } from '@/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting' +import { CodeLanguage } from '@/lib/execution/languages' + +function functionBlock( + code: string, + language: CodeLanguage = CodeLanguage.JavaScript +): Record { + return { + type: 'function', + subBlocks: { + code: { value: code }, + language: { value: language }, + }, + } +} + +describe('formatChangedFunctionCode', () => { + it('formats changed JavaScript and Python Function blocks without mutating previous state', async () => { + const previousWorkflowState = { + blocks: { + 'existing-function': functionBlock('return 0;'), + 'python-function': functionBlock('value={"foo": 1}\nreturn value', CodeLanguage.Python), + }, + } + const modifiedWorkflowState = { + blocks: { + 'existing-function': functionBlock('const value=1;return value;'), + 'new-function': functionBlock('const value=;return {value};'), + 'python-function': functionBlock('value={"foo":1};return value', CodeLanguage.Python), + agent: { + type: 'agent', + subBlocks: { code: { value: 'const value=1;return value;' } }, + }, + }, + } + + const result = await formatChangedFunctionCode( + new Set(['existing-function', 'new-function', 'python-function']), + modifiedWorkflowState + ) + + expect(modifiedWorkflowState.blocks['existing-function'].subBlocks.code.value).toBe( + 'const value = 1;\nreturn value;' + ) + expect(modifiedWorkflowState.blocks['new-function'].subBlocks.code.value).toBe( + 'const value = ;\nreturn { value };' + ) + expect(modifiedWorkflowState.blocks['python-function'].subBlocks.code.value).toBe( + 'value = {"foo": 1}\nreturn value' + ) + expect(modifiedWorkflowState.blocks.agent.subBlocks.code.value).toBe( + 'const value=1;return value;' + ) + expect(previousWorkflowState.blocks['existing-function'].subBlocks.code.value).toBe('return 0;') + expect(result).toEqual({ + changedBlockIds: ['existing-function', 'new-function', 'python-function'], + errors: [], + }) + }) + + it('formats using the final block language after all operations are applied', async () => { + const modifiedWorkflowState = { + blocks: { + 'function-1': functionBlock('value={"foo":1};return value', CodeLanguage.Python), + }, + } + + await formatChangedFunctionCode(new Set(['function-1']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['function-1'].subBlocks.code.value).toBe( + 'value = {"foo": 1}\nreturn value' + ) + }) + + it('formats a replacement Function block from the final state', async () => { + const modifiedWorkflowState = { + blocks: { + replacement: functionBlock('const value=1;return value;', CodeLanguage.JavaScript), + }, + } + + await formatChangedFunctionCode(new Set(['replacement']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks.replacement.subBlocks.code.value).toBe( + 'const value = 1;\nreturn value;' + ) + }) + + it('formats same-source nested edits after the engine resolves child identity', async () => { + const code = 'const value=1;return value;' + const modifiedWorkflowState = { + blocks: { 'existing-child': functionBlock(code, CodeLanguage.JavaScript) }, + } + + await formatChangedFunctionCode(new Set(['existing-child']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['existing-child'].subBlocks.code.value).toBe( + 'const value = 1;\nreturn value;' + ) + }) + + it('does not format untouched JavaScript Function blocks', async () => { + const untouchedCode = 'const untouched=1;return untouched;' + const modifiedWorkflowState = { + blocks: { + 'changed-function': functionBlock('const changed=1;return changed;'), + 'untouched-function': functionBlock(untouchedCode), + }, + } + + await formatChangedFunctionCode(new Set(['changed-function']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['changed-function'].subBlocks.code.value).toBe( + 'const changed = 1;\nreturn changed;' + ) + expect(modifiedWorkflowState.blocks['untouched-function'].subBlocks.code.value).toBe( + untouchedCode + ) + }) + + it('does not format an untouched Function block with identical submitted code', async () => { + const code = 'const value=1;return value;' + const modifiedWorkflowState = { + blocks: { + 'changed-function': functionBlock(code), + 'untouched-function': functionBlock(code), + }, + } + + await formatChangedFunctionCode(new Set(['changed-function']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['changed-function'].subBlocks.code.value).toBe( + 'const value = 1;\nreturn value;' + ) + expect(modifiedWorkflowState.blocks['untouched-function'].subBlocks.code.value).toBe(code) + }) + + it('keeps invalid submitted JavaScript unchanged', async () => { + const code = 'if (' + const modifiedWorkflowState = { + blocks: { 'function-1': functionBlock(code) }, + } + + const result = await formatChangedFunctionCode(new Set(['function-1']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['function-1'].subBlocks.code.value).toBe(code) + expect(result).toEqual({ + changedBlockIds: [], + errors: [ + { + blockId: 'function-1', + language: CodeLanguage.JavaScript, + error: expect.any(String), + }, + ], + }) + }) + + it('keeps invalid submitted Python unchanged', async () => { + const code = 'if (' + const modifiedWorkflowState = { + blocks: { 'function-1': functionBlock(code, CodeLanguage.Python) }, + } + + const result = await formatChangedFunctionCode(new Set(['function-1']), modifiedWorkflowState) + + expect(modifiedWorkflowState.blocks['function-1'].subBlocks.code.value).toBe(code) + expect(result).toEqual({ + changedBlockIds: [], + errors: [ + { + blockId: 'function-1', + language: CodeLanguage.Python, + error: expect.any(String), + }, + ], + }) + }) + + it('returns before inspecting workflow state when no code blocks changed', async () => { + await expect(formatChangedFunctionCode(new Set(), null)).resolves.toEqual({ + changedBlockIds: [], + errors: [], + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.ts new file mode 100644 index 00000000000..45da2138e1a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting.ts @@ -0,0 +1,79 @@ +import { isRecordLike } from '@sim/utils/object' +import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' +import { + type FormattableCodeLanguage, + formatFunctionCode, + isFormattableCodeLanguage, +} from '@/lib/workflows/blocks/format-function-code' + +interface FunctionCodeField { + code: string + codeSubBlock: Record + language: string +} + +export interface ChangedFunctionCodeFormatResult { + changedBlockIds: string[] + errors: Array<{ + blockId: string + language: FormattableCodeLanguage + error: string + }> +} + +function createEmptyFormatResult(): ChangedFunctionCodeFormatResult { + return { changedBlockIds: [], errors: [] } +} + +function readFunctionCodeField(block: unknown): FunctionCodeField | undefined { + if (!isRecordLike(block) || block.type !== 'function' || !isRecordLike(block.subBlocks)) { + return undefined + } + + const codeSubBlock = block.subBlocks.code + if (!isRecordLike(codeSubBlock) || typeof codeSubBlock.value !== 'string') return undefined + + const languageSubBlock = block.subBlocks.language + const language = + isRecordLike(languageSubBlock) && typeof languageSubBlock.value === 'string' + ? languageSubBlock.value + : DEFAULT_CODE_LANGUAGE + + return { code: codeSubBlock.value, codeSubBlock, language } +} + +/** + * Formats changed JavaScript and Python Function code after the workflow engine resolves operation + * ordering, nested block identity, and the final code language. + */ +export async function formatChangedFunctionCode( + functionCodeBlockIds: ReadonlySet, + modifiedWorkflowState: unknown +): Promise { + if (functionCodeBlockIds.size === 0) return createEmptyFormatResult() + if (!isRecordLike(modifiedWorkflowState) || !isRecordLike(modifiedWorkflowState.blocks)) { + return createEmptyFormatResult() + } + + const blocks = modifiedWorkflowState.blocks + const targets = [...functionCodeBlockIds].flatMap((blockId) => { + const currentField = readFunctionCodeField(blocks[blockId]) + if (!currentField || !isFormattableCodeLanguage(currentField.language)) return [] + return [{ blockId, ...currentField, language: currentField.language }] + }) + + const results = await Promise.all( + targets.map(async ({ blockId, code, codeSubBlock, language }) => { + const result = await formatFunctionCode(code, language) + if (result.changed) codeSubBlock.value = result.code + return { blockId, language, result } + }) + ) + + return { + changedBlockIds: results.filter(({ result }) => result.changed).map(({ blockId }) => blockId), + errors: results.flatMap(({ blockId, language, result }) => + result.error ? [{ blockId, language, error: result.error }] : [] + ), + } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 23aa7e4fe41..3d9d5761f55 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -13,6 +13,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { formatChangedFunctionCode } from '@/lib/copilot/tools/server/workflow/edit-workflow/function-code-formatting' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { @@ -161,8 +162,25 @@ export const editWorkflowServerTool: BaseServerTool state: modifiedWorkflowState, validationErrors, skippedItems, + functionCodeBlockIds, } = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig) + const codeFormatting = await formatChangedFunctionCode( + functionCodeBlockIds, + modifiedWorkflowState + ) + const codeFormattingFailures = codeFormatting.errors.map(({ blockId, language }) => ({ + blockId, + language, + })) + if (codeFormattingFailures.length > 0) { + logger.warn('Function code formatting failed open', { + workflowId, + failureCount: codeFormattingFailures.length, + blocks: codeFormattingFailures, + }) + } + // Add credential validation errors validationErrors.push(...credentialErrors) @@ -396,6 +414,15 @@ export const editWorkflowServerTool: BaseServerTool workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, workflowLint, ...(workflowLintMessage && { workflowLintMessage }), + ...(functionCodeBlockIds.size > 0 && { + codeFormatting: { + changedBlockIds: codeFormatting.changedBlockIds, + failures: codeFormattingFailures, + }, + ...(codeFormattingFailures.length > 0 && { + codeFormattingMessage: `${codeFormattingFailures.length} Function block(s) could not be formatted, so their original code was kept unchanged.`, + }), + }), ...(inputErrors && { inputValidationErrors: inputErrors, inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index 7914f4eaf59..03d1ce137e1 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { isRecordLike } from '@sim/utils/object' import { describe, expect, it, vi } from 'vitest' import { applyOperationsToWorkflowState } from './engine' @@ -56,6 +58,12 @@ vi.mock('@/blocks/registry', () => ({ }, })) +function findBlockIdByName(blocks: unknown, name: string): string | undefined { + if (!isRecordLike(blocks)) return undefined + + return Object.entries(blocks).find(([, block]) => isRecordLike(block) && block.name === name)?.[0] +} + function makeLoopWorkflow() { return { blocks: { @@ -186,6 +194,98 @@ function makeNestedLoopWorkflow() { } describe('handleEditOperation nestedNodes merge', () => { + it('tracks top-level Function IDs for submitted add and edit code', () => { + const workflow = makeLoopWorkflow() + workflow.blocks['existing-function'] = { + id: 'existing-function', + type: 'function', + name: 'Existing Function', + position: { x: 200, y: 200 }, + enabled: true, + subBlocks: { + code: { id: 'code', type: 'code', value: 'return 0;' }, + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + }, + outputs: {}, + data: {}, + } + + const { state, functionCodeBlockIds } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'existing-function', + params: { inputs: { code: 'const edited=1;return edited;' } }, + }, + { + operation_type: 'add', + block_id: 'added-function', + params: { + type: 'function', + name: 'Added Function', + inputs: { code: 'const added=1;return added;' }, + }, + }, + ]) + + const addedFunctionId = findBlockIdByName(state.blocks, 'Added Function') + expect(addedFunctionId).toBeDefined() + expect(functionCodeBlockIds).toEqual(new Set([addedFunctionId, 'existing-function'])) + }) + + it('tracks a Function ID when code is submitted during subflow insertion', () => { + const { state, functionCodeBlockIds } = applyOperationsToWorkflowState(makeLoopWorkflow(), [ + { + operation_type: 'insert_into_subflow', + block_id: 'inserted-function', + params: { + subflowId: 'loop-1', + type: 'function', + name: 'Inserted Function', + inputs: { code: 'const inserted=1;return inserted;' }, + }, + }, + ]) + + const insertedFunctionId = findBlockIdByName(state.blocks, 'Inserted Function') + expect(insertedFunctionId).toBeDefined() + expect(functionCodeBlockIds).toEqual(new Set([insertedFunctionId])) + }) + + it('tracks resolved Function block IDs with submitted code', () => { + const workflow = makeLoopWorkflow() + workflow.blocks['existing-function'] = { + id: 'existing-function', + type: 'function', + name: 'Existing Function', + position: { x: 200, y: 200 }, + enabled: true, + subBlocks: { + code: { id: 'code', type: 'code', value: 'return 0;' }, + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + }, + outputs: {}, + data: { parentId: 'loop-1', extent: 'parent' }, + } + + const { functionCodeBlockIds } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'loop-1', + params: { + nestedNodes: { + 'incoming-function': { + type: 'function', + name: 'Existing Function', + inputs: { code: 'const value=1;return value;' }, + }, + }, + }, + }, + ]) + + expect(functionCodeBlockIds).toEqual(new Set(['existing-function'])) + }) + it('preserves existing child block IDs when editing a loop with nestedNodes', () => { const workflow = makeLoopWorkflow() diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index 015791daad1..03e8bc1265e 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -24,6 +24,14 @@ import { const logger = createLogger('EditWorkflowServerTool') +function recordSubmittedCodeInput( + blockId: string, + inputs: Record | undefined, + ctx: OperationContext +): void { + if (typeof inputs?.code === 'string') ctx.functionCodeBlockIds.add(blockId) +} + /** * Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.). */ @@ -111,6 +119,7 @@ function processNestedNodesForParent( applyLoopOrParallelContainerData(childBlockState, childBlock) } modifiedState.blocks[childId] = childBlockState + recordSubmittedCodeInput(childId, childBlock.inputs, ctx) if (childBlock.connections) { deferredConnections.push({ @@ -204,6 +213,7 @@ function mergeNestedNodesForParent( existingId ) validationErrors.push(...childValidation.errors) + recordSubmittedCodeInput(existingId, childValidation.validInputs, ctx) Object.entries(childValidation.validInputs).forEach(([key, value]) => { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return @@ -279,6 +289,7 @@ function mergeNestedNodesForParent( applyLoopOrParallelContainerData(childBlockState, childBlock) } modifiedState.blocks[childId] = childBlockState + recordSubmittedCodeInput(childId, childBlock.inputs, ctx) if (childBlock.connections) { deferredConnections.push({ @@ -428,6 +439,7 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon // Validate inputs against block configuration const validationResult = validateInputsForBlock(block.type, params.inputs, block_id) validationErrors.push(...validationResult.errors) + recordSubmittedCodeInput(block_id, validationResult.validInputs, ctx) Object.entries(validationResult.validInputs).forEach(([inputKey, value]) => { // Normalize common field name variations (LLM may use plural/singular inconsistently) @@ -795,6 +807,7 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont // Add parent block FIRST before adding children // This ensures children can reference valid parentId modifiedState.blocks[block_id] = newBlock + recordSubmittedCodeInput(block_id, params.inputs, ctx) // Handle nested nodes (for loops/parallels created from scratch) if (params.nestedNodes) { @@ -900,6 +913,7 @@ export function handleInsertIntoSubflowOperation( // Validate inputs against block configuration const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id) validationErrors.push(...validationResult.errors) + recordSubmittedCodeInput(block_id, validationResult.validInputs, ctx) Object.entries(validationResult.validInputs).forEach(([key, value]) => { // Skip runtime subblock IDs (webhookId, triggerPath) @@ -986,6 +1000,7 @@ export function handleInsertIntoSubflowOperation( skippedItems ) modifiedState.blocks[block_id] = newBlock + recordSubmittedCodeInput(block_id, params.inputs, ctx) if (params.type === 'loop' || params.type === 'parallel') { applyLoopOrParallelContainerData(newBlock, params) } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts index c12e16c4add..534c377c0d3 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts @@ -137,6 +137,7 @@ export interface ApplyOperationsResult { state: any validationErrors: ValidationError[] skippedItems: SkippedItem[] + functionCodeBlockIds: Set } export interface OperationContext { @@ -144,6 +145,7 @@ export interface OperationContext { skippedItems: SkippedItem[] validationErrors: ValidationError[] permissionConfig: PermissionGroupConfig | null + functionCodeBlockIds: Set deferredConnections: Array<{ blockId: string connections: Record diff --git a/apps/sim/lib/workflows/blocks/format-function-code.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.test.ts new file mode 100644 index 00000000000..6e1067bc44c --- /dev/null +++ b/apps/sim/lib/workflows/blocks/format-function-code.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' +import { formatFunctionCode } from '@/lib/workflows/blocks/format-function-code' + +describe('formatFunctionCode', () => { + it('formats compact JavaScript while preserving Sim references', async () => { + const code = + 'const items=;return items.map((item)=>({id:item.id,description:item.description,status:item.status,source:item.source,value:{{DEFAULT_VALUE}}}));' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: `const items = ; +return items.map((item) => ({ + id: item.id, + description: item.description, + status: item.status, + source: item.source, + value: {{DEFAULT_VALUE}}, +}));`, + changed: true, + error: null, + }) + }) + + it('reports invalid JavaScript without changing it', async () => { + const code = 'if (' + const result = await formatFunctionCode(code, CodeLanguage.JavaScript) + + expect(result.code).toBe(code) + expect(result.changed).toBe(false) + expect(result.error).toEqual(expect.any(String)) + }) + + it('reports when JavaScript is already formatted', async () => { + const code = 'const value = 1;\nreturn value;' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code, + changed: false, + error: null, + }) + }) + + it('preserves whitespace-only code', async () => { + const code = ' \n\t' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code, + changed: false, + error: null, + }) + }) + + it('does not confuse JavaScript comparisons with Sim references', async () => { + const cases = [ + ['a c', 'a < b > c'], + ['a -1', 'a < b > -1'], + ["a 'value'", "a < b > 'value'"], + ['a !c', 'a < b > !c'], + ["'left' value", "'left' < b > value"], + ['`left` value', '`left` < b > value'], + ['π δ', 'π < b > δ'], + ] + + for (const [comparison, expectedComparison] of cases) { + const code = `const input=;const result=${comparison};return {input,result};` + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: `const input = ; +const result = ${expectedComparison}; +return { input, result };`, + changed: true, + error: null, + }) + } + }) + + it('preserves indexed and hyphenated Sim references', async () => { + const code = 'const item=;return {item};' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'const item = ;\nreturn { item };', + changed: true, + error: null, + }) + }) + + it('restores large reference sets without placeholder collisions', async () => { + const referenceCount = 1_300 + const code = `return [${Array.from({ length: referenceCount }, () => '').join(',')}];` + + const result = await formatFunctionCode(code, CodeLanguage.JavaScript) + + expect(result.error).toBeNull() + expect(result.code.match(//g)).toHaveLength(referenceCount) + expect(result.code).not.toMatch(/[0-9a-z_]/) + }) + + it('does not confuse bit shifts with Sim references', async () => { + const code = 'const input=;return a<>c;' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'const input = ;\nreturn (a << b) >> c;', + changed: true, + error: null, + }) + }) + + it('preserves statement boundaries around Sim references', async () => { + const code = 'const items=;[1,2].forEach(console.log);' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'const items = ;\n[1, 2].forEach(console.log);', + changed: true, + error: null, + }) + }) + + it('preserves Sim references after JavaScript grammar keywords', async () => { + const cases = [ + [ + 'for (const item of .values) {console.log(item);}', + 'for (const item of .values) {\n console.log(item);\n}', + ], + [ + 'if (item in .values) {console.log(item);}', + 'if (item in .values) {\n console.log(item);\n}', + ], + [ + 'if (item instanceof .value) {console.log(item);}', + 'if (item instanceof .value) {\n console.log(item);\n}', + ], + [ + 'if (ok) doThing(); else .value();', + 'if (ok) doThing();\nelse .value();', + ], + ['do .value(); while (ready);', 'do .value();\nwhile (ready);'], + [ + 'class Child extends .value {run(){return true;}}', + 'class Child extends .value {\n run() {\n return true;\n }\n}', + ], + ] + + for (const [code, expectedCode] of cases) { + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: expectedCode, + changed: expectedCode !== code, + error: null, + }) + } + }) + + it('formats Python Function bodies while preserving Sim references', async () => { + const code = + 'value=\nresult={"value":value,"fallback":{{DEFAULT_VALUE}},"items":[1,2,3]}\nreturn result' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code: `value = +result = {"value": value, "fallback": {{DEFAULT_VALUE}}, "items": [1, 2, 3]} +return result`, + changed: true, + error: null, + }) + }) + + it('reports invalid Python without changing it', async () => { + const code = 'if (' + const result = await formatFunctionCode(code, CodeLanguage.Python) + + expect(result.code).toBe(code) + expect(result.changed).toBe(false) + expect(result.error).toEqual(expect.any(String)) + }) + + it('reports when Python is already formatted', async () => { + const code = 'value = {"foo": 1}\nreturn value' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code, + changed: false, + error: null, + }) + }) + + it('preserves Python references around boolean and conditional keywords', async () => { + const code = 'return if and not else ' + + const result = await formatFunctionCode(code, CodeLanguage.Python) + + expect(result).toEqual({ code, changed: false, error: null }) + }) + + it('keeps Python boundary keywords language-specific', async () => { + const cases = [ + ['and', 'or'], + ['assert', 'raise'], + ['del', 'after'], + ] + + for (const [left, right] of cases) { + await expect( + formatFunctionCode(`return ${left} ${right};`, CodeLanguage.JavaScript) + ).resolves.toEqual({ + code: `return ${left} < item.value > ${right};`, + changed: true, + error: null, + }) + } + }) +}) diff --git a/apps/sim/lib/workflows/blocks/format-function-code.ts b/apps/sim/lib/workflows/blocks/format-function-code.ts new file mode 100644 index 00000000000..4aebfdbfc25 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/format-function-code.ts @@ -0,0 +1,236 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' +import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation' + +export type FormattableCodeLanguage = CodeLanguage.JavaScript | CodeLanguage.Python + +const FUNCTION_CODE_FORMAT_OPTIONS = { + parser: 'babel', + printWidth: 100, + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', +} as const + +const PYTHON_CODE_FORMAT_OPTIONS = { + indent_style: 'space', + indent_width: 4, + line_ending: 'lf', + line_width: 100, + magic_trailing_comma: 'respect', + quote_style: 'double', +} as const + +const PYTHON_WRAPPER_NAME = '__sim_format_function__' + +const JAVASCRIPT_EXPRESSION_PREFIX_KEYWORDS = new Set([ + 'await', + 'case', + 'delete', + 'do', + 'else', + 'extends', + 'new', + 'return', + 'throw', + 'typeof', + 'void', + 'yield', +]) +const JAVASCRIPT_EXPRESSION_OPERATOR_KEYWORDS = new Set(['in', 'instanceof', 'of']) +const PYTHON_EXPRESSION_PREFIX_KEYWORDS = new Set([ + 'and', + 'as', + 'assert', + 'await', + 'case', + 'del', + 'elif', + 'else', + 'except', + 'for', + 'from', + 'if', + 'in', + 'is', + 'match', + 'not', + 'or', + 'raise', + 'return', + 'while', + 'with', + 'yield', +]) +const PYTHON_EXPRESSION_OPERATOR_KEYWORDS = new Set([ + 'and', + 'as', + 'else', + 'for', + 'if', + 'in', + 'is', + 'not', + 'or', +]) +const EXPRESSION_END_CHARACTER = /[\p{ID_Continue}$)\]}'"`]/u +const EXPRESSION_START_CHARACTER = /[\p{ID_Start}\p{Number}_$([{'"`!+\-~/.]/u +const IDENTIFIER_AT_END = /[A-Za-z_$][\w$]*$/ +const IDENTIFIER_AT_START = /^[A-Za-z_$][\w$]*/ + +interface ProtectedReference { + token: string + value: string +} + +export interface FunctionCodeFormatResult { + code: string + changed: boolean + error: string | null +} + +export function isFormattableCodeLanguage(language: string): language is FormattableCodeLanguage { + return language === CodeLanguage.JavaScript || language === CodeLanguage.Python +} + +function looksLikeLanguageComparison( + index: number, + length: number, + source: string, + language: FormattableCodeLanguage +): boolean { + if (source[index - 1] === '<' || source[index + length] === '>') return true + + const sourceBeforeReference = source.slice(0, index).trimEnd() + const sourceAfterReference = source.slice(index + length).trimStart() + const previousCharacter = sourceBeforeReference.at(-1) + const nextCharacter = sourceAfterReference.at(0) + const previousIdentifier = sourceBeforeReference.match(IDENTIFIER_AT_END)?.[0] + const nextIdentifier = sourceAfterReference.match(IDENTIFIER_AT_START)?.[0] + const prefixKeywords = + language === CodeLanguage.Python + ? PYTHON_EXPRESSION_PREFIX_KEYWORDS + : JAVASCRIPT_EXPRESSION_PREFIX_KEYWORDS + const operatorKeywords = + language === CodeLanguage.Python + ? PYTHON_EXPRESSION_OPERATOR_KEYWORDS + : JAVASCRIPT_EXPRESSION_OPERATOR_KEYWORDS + + const previousTokenEndsExpression = + !!previousCharacter && + EXPRESSION_END_CHARACTER.test(previousCharacter) && + !prefixKeywords.has(previousIdentifier ?? '') && + !operatorKeywords.has(previousIdentifier ?? '') + const nextTokenStartsExpression = + !!nextCharacter && + EXPRESSION_START_CHARACTER.test(nextCharacter) && + !operatorKeywords.has(nextIdentifier ?? '') + + return previousTokenEndsExpression && nextTokenStartsExpression +} + +function protectSimReferences( + code: string, + language: FormattableCodeLanguage +): { + code: string + references: ProtectedReference[] +} { + const references: ProtectedReference[] = [] + + const replaceReference = (value: string): string => { + let tokenIndex = references.length + let token = '' + do { + const tokenPrefix = `_${tokenIndex.toString(36)}` + token = tokenPrefix.padEnd(Math.max(value.length, tokenPrefix.length), '_') + tokenIndex += 1 + } while (code.includes(token) || references.some((reference) => reference.token === token)) + references.push({ token, value }) + return token + } + + const withProtectedWorkflowReferences = replaceValidReferences(code, (value, index, source) => + looksLikeLanguageComparison(index, value.length, source, language) + ? value + : replaceReference(value) + ) + const protectedCode = withProtectedWorkflowReferences.replace( + createEnvVarPattern(), + replaceReference + ) + + return { code: protectedCode, references } +} + +function restoreSimReferences(code: string, references: ProtectedReference[]): string { + return [...references] + .sort((a, b) => b.token.length - a.token.length) + .reduce( + (restoredCode, reference) => restoredCode.replaceAll(reference.token, reference.value), + code + ) +} + +function wrapPythonFunctionBody(code: string): string { + const indentedCode = code + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + return `def ${PYTHON_WRAPPER_NAME}():\n${indentedCode}\n` +} + +function unwrapPythonFunctionBody(code: string): string { + const [declaration, ...bodyLines] = code.trimEnd().split('\n') + if (declaration !== `def ${PYTHON_WRAPPER_NAME}():`) { + throw new Error('Python formatter returned an unexpected wrapper') + } + + return bodyLines.map((line) => (line.startsWith(' ') ? line.slice(4) : line)).join('\n') +} + +async function formatJavaScript(code: string): Promise { + const { format } = await import('prettier') + return format(code, FUNCTION_CODE_FORMAT_OPTIONS) +} + +async function formatPython(code: string): Promise { + const { format } = await import('@wasm-fmt/ruff_fmt/node') + const wrappedCode = wrapPythonFunctionBody(code) + const formattedCode = format(wrappedCode, 'function.py', PYTHON_CODE_FORMAT_OPTIONS) + return unwrapPythonFunctionBody(formattedCode) +} + +/** + * Formats a JavaScript or Python Function block body while preserving Sim's reference syntax. + * Parse failures are returned with the original code so callers can choose how to surface them. + */ +export async function formatFunctionCode( + code: string, + language: FormattableCodeLanguage +): Promise { + if (!code.trim()) return { code, changed: false, error: null } + + const protectedSource = protectSimReferences(code, language) + + try { + const formattedCode = + language === CodeLanguage.JavaScript + ? await formatJavaScript(protectedSource.code) + : await formatPython(protectedSource.code) + const restoredCode = restoreSimReferences(formattedCode.trimEnd(), protectedSource.references) + + return { + code: restoredCode, + changed: restoredCode !== code, + error: null, + } + } catch (error) { + return { + code, + changed: false, + error: getErrorMessage(error, `Unable to format ${getLanguageDisplayName(language)}`), + } + } +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 4d1ce2b9104..b803fdfbf21 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -131,12 +131,14 @@ const nextConfig: NextConfig = { '@e2b/code-interpreter', 'e2b', '@earendil-works/pi-coding-agent', + '@wasm-fmt/ruff_fmt', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], '/*': [ './node_modules/sharp/**/*', './node_modules/@img/**/*', + './node_modules/@wasm-fmt/ruff_fmt/**/*', './lib/execution/sandbox/bundles/*.cjs', ], }, diff --git a/apps/sim/package.json b/apps/sim/package.json index a7800a9f904..0c7800d925f 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -126,6 +126,7 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@wasm-fmt/ruff_fmt": "0.15.20", "ajv": "8.18.0", "better-auth": "1.6.13", "binary-extensions": "3.1.0", @@ -188,6 +189,7 @@ "posthog-js": "1.364.4", "posthog-node": "5.28.9", "pptxgenjs": "4.0.1", + "prettier": "3.8.4", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/bun.lock b/bun.lock index f3ad75a78dd..e45cc90da39 100644 --- a/bun.lock +++ b/bun.lock @@ -194,6 +194,7 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@wasm-fmt/ruff_fmt": "0.15.20", "ajv": "8.18.0", "better-auth": "1.6.13", "binary-extensions": "3.1.0", @@ -256,6 +257,7 @@ "posthog-js": "1.364.4", "posthog-node": "5.28.9", "pptxgenjs": "4.0.1", + "prettier": "3.8.4", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -2081,6 +2083,8 @@ "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@wasm-fmt/ruff_fmt": ["@wasm-fmt/ruff_fmt@0.15.20", "", {}, "sha512-PYij4wTiDSTx6MyBGHSY0nWDjSiTbOB3moPJiZTBL8F0JZLEfHAi4lYAn9SC9Hzm8tVeTnL7Qyf1iPW0TKHVEg=="], + "@webgpu/types": ["@webgpu/types@0.1.70", "", {}, "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], From b820933a45ea9686d1e059a3a181f87555667340 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:15:05 -0700 Subject: [PATCH 2/5] Address PR review feedback (#5742) - Preserve Sim references adjacent to right-shift operators - Add regression coverage for signed and unsigned shifts Note: pre-existing type-check failures in audit exports and linked workspace dependencies were not addressed by this PR. --- .../blocks/format-function-code.test.ts | 17 +++++++++++++++++ .../workflows/blocks/format-function-code.ts | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/blocks/format-function-code.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.test.ts index 6e1067bc44c..5032df8e267 100644 --- a/apps/sim/lib/workflows/blocks/format-function-code.test.ts +++ b/apps/sim/lib/workflows/blocks/format-function-code.test.ts @@ -108,6 +108,23 @@ return { input, result };`, }) }) + it('preserves Sim references followed by right-shift operators', async () => { + const cases = [ + ['>>2', ' >> 2'], + ['>>>2', ' >>> 2'], + ] + + for (const [shift, expectedShift] of cases) { + const code = `const shifted=${shift};return shifted;` + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: `const shifted = ${expectedShift};\nreturn shifted;`, + changed: true, + error: null, + }) + } + }) + it('preserves statement boundaries around Sim references', async () => { const code = 'const items=;[1,2].forEach(console.log);' diff --git a/apps/sim/lib/workflows/blocks/format-function-code.ts b/apps/sim/lib/workflows/blocks/format-function-code.ts index 4aebfdbfc25..fd9a25602d4 100644 --- a/apps/sim/lib/workflows/blocks/format-function-code.ts +++ b/apps/sim/lib/workflows/blocks/format-function-code.ts @@ -100,7 +100,12 @@ function looksLikeLanguageComparison( source: string, language: FormattableCodeLanguage ): boolean { - if (source[index - 1] === '<' || source[index + length] === '>') return true + const followsLeftAngleBracket = source[index - 1] === '<' + const followedByRightAngleBracket = source[index + length] === '>' + const followedByRightShiftOperator = source.startsWith('>>', index + length) + if (followsLeftAngleBracket || (followedByRightAngleBracket && !followedByRightShiftOperator)) { + return true + } const sourceBeforeReference = source.slice(0, index).trimEnd() const sourceAfterReference = source.slice(index + length).trimStart() From a57e88a71a9744f87df071735d854e48c34c25ca Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:13:37 -0700 Subject: [PATCH 3/5] test(mothership): harden function formatter coverage --- .../format-function-code.safety.test.ts | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts diff --git a/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts new file mode 100644 index 00000000000..4051ddb3b8b --- /dev/null +++ b/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts @@ -0,0 +1,446 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' +import { + type FormattableCodeLanguage, + formatFunctionCode, +} from '@/lib/workflows/blocks/format-function-code' +import { createCombinedPattern } from '@/executor/utils/reference-validation' + +interface SyntaxCase { + name: string + code: string + preservedFragments?: string[] +} + +interface JavaScriptRuntimeCase { + name: string + code: string + input: unknown +} + +const JAVASCRIPT_SYNTAX_CASES: SyntaxCase[] = [ + { + name: 'nested destructuring and rest properties', + code: "const {user:{name='unknown',roles=[]}={},...rest}=input;return {name,roles,rest};", + }, + { + name: 'async callbacks and await expressions', + code: 'const responses=await Promise.all(urls.map(async(url)=>{const response=await fetch(url);return response.json()}));return responses;', + }, + { + name: 'optional chaining and nullish coalescing', + code: "const city=user?.profile?.address?.city??'unknown';return city;", + }, + { + name: 'private class fields and default parameters', + code: 'class Counter{#value=0;increment(step=1){this.#value+=step;return this.#value}}return new Counter().increment();', + }, + { + name: 'try catch switch and finally blocks', + code: "try{switch(status){case 'ok':return {ok:true};default:throw new Error('bad')}}catch(error){return {ok:false,message:error instanceof Error?error.message:String(error)}}finally{cleanup?.()}", + }, + { + name: 'generator functions and loop control', + code: 'function* chunks(items,size){for(let index=0;indexitem.enabled)/* retain enabled entries */.map((item)=>item.value);// preserve source order\nreturn active;', + preservedFragments: ['/* retain enabled entries */', '// preserve source order'], + }, + { + name: 'nested ternaries and logical assignment', + code: "let label=config.label;label??=fallback;return status==='ready'?label:status==='pending'?'waiting':'unknown';", + }, + { + name: 'unicode identifiers and numeric separators', + code: 'const π=3.141_592_653_589_793;const 半径=diameter/2;return π*半径**2;', + }, + { + name: 'bigint and bitwise operators', + code: 'const shifted=(BigInt(value)<<8n)|0xffn;return shifted>>2n;', + }, + { + name: 'dynamic imports', + code: "const module=await import('module-name');return module.default??module;", + }, + { + name: 'labeled loops', + code: 'outer:for(const row of rows){for(const cell of row){if(cell===target){break outer}}}return target;', + }, + { + name: 'multiline object and method definitions', + code: 'const service={value:1,get current(){return this.value},set current(value){this.value=value},run(){return this.current}};return service.run();', + }, +] + +const PYTHON_SYNTAX_CASES: SyntaxCase[] = [ + { + name: 'nested comprehensions', + code: 'pairs={key:[item.value for item in items if item.enabled] for key,items in groups.items()}\nreturn pairs', + }, + { + name: 'await expressions', + code: 'response=await client.fetch(url,timeout=30)\nreturn await response.json()', + }, + { + name: 'async for loops', + code: 'results=[]\nasync for item in stream:\n results.append(await transform(item))\nreturn results', + }, + { + name: 'async context managers', + code: 'async with client.session() as session:\n result=await session.run(query)\nreturn result', + }, + { + name: 'structural pattern matching', + code: 'match event:\n case {"type":"created","payload":payload}: return payload\n case {"type":"deleted","id":item_id}: return {"deleted":item_id}\n case _: return None', + }, + { + name: 'try except else and finally blocks', + code: 'try:\n value=run()\nexcept (ValueError,TypeError) as error:\n return {"ok":False,"error":str(error)}\nelse:\n return {"ok":True,"value":value}\nfinally:\n cleanup()', + }, + { + name: 'nested typed functions', + code: 'def normalize(value:str|None,default:str="") -> str:\n return value.strip() if value else default\nreturn normalize(raw_value)', + }, + { + name: 'generators and yield from', + code: 'def flatten(groups):\n for group in groups:\n yield from group\nreturn list(flatten(values))', + }, + { + name: 'f strings and conversion flags', + code: 'message=f"{user.name!r} has {len(items):03d} items"\nreturn message', + }, + { + name: 'assignment expressions', + code: 'if (count:=len(items))>limit:\n return {"count":count,"overflow":True}\nreturn {"count":count,"overflow":False}', + }, + { + name: 'multiple context managers', + code: 'with open(source) as input_file,open(destination,"w") as output_file:\n output_file.write(input_file.read())\nreturn destination', + }, + { + name: 'nested classes and properties', + code: 'class Counter:\n def __init__(self,start=0): self._value=start\n @property\n def value(self): return self._value\n def increment(self,step=1):\n self._value+=step\n return self.value\nreturn Counter().increment()', + }, + { + name: 'comments and multiline strings', + code: 'message="""line one\nline two"""\n# preserve this explanation\nreturn message.strip()', + preservedFragments: ['# preserve this explanation', 'line one\nline two'], + }, + { + name: 'slices lambdas and sorted keys', + code: 'middle=values[1:-1:2]\nreturn sorted(middle,key=lambda item:(item.priority,item.name.casefold()))', + }, +] + +const JAVASCRIPT_RUNTIME_CASES: JavaScriptRuntimeCase[] = [ + { + name: 'preserves arithmetic precedence', + code: 'const scaled=(input.left+input.right*2)/(input.divisor||1);return {scaled,rounded:Math.round(scaled)};', + input: { left: 3, right: 7, divisor: 2 }, + }, + { + name: 'preserves destructuring defaults', + code: "const {name='unknown',tags=[]}=input.user??{};return {name,tags:[...tags].sort()};", + input: { user: { tags: ['beta', 'alpha'] } }, + }, + { + name: 'preserves control flow', + code: 'let total=0;for(const value of input.values){if(value<0)continue;if(value>input.limit)break;total+=value}return total;', + input: { values: [-1, 2, 4, 20, 1], limit: 10 }, + }, + { + name: 'preserves optional and nullish access', + code: "return input.user?.profile?.name??input.fallback??'anonymous';", + input: { user: { profile: null }, fallback: 'fallback-name' }, + }, + { + name: 'preserves regex and template behavior', + code: `const slug=input.title.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');return \`\${input.prefix}:\${slug}\`;`, + input: { prefix: 'item', title: ' Hello, Formatter! ' }, + }, + { + name: 'preserves array transformation behavior', + code: 'return input.values.filter((value)=>value%2===0).map((value,index)=>({index,value:value**2}));', + input: { values: [1, 2, 3, 4, 5, 6] }, + }, + { + name: 'preserves bitwise behavior', + code: 'const packed=((input.red&255)<<16)|((input.green&255)<<8)|(input.blue&255);return {packed,unsigned:packed>>>0};', + input: { red: 250, green: 128, blue: 5 }, + }, + { + name: 'preserves switch and exception behavior', + code: "try{switch(input.status){case 'ready':return {ok:true,value:input.value};case 'error':throw new Error(input.message);default:return {ok:false,value:null}}}catch(error){return {ok:false,error:error.message}}", + input: { status: 'error', message: 'expected failure', value: 42 }, + }, +] + +const WORKFLOW_REFERENCES = [ + '', + '', + '', + '', + '', +] as const + +const ENV_REFERENCES = ['{{API_KEY}}', '{{ DEFAULT_VALUE }}', '{{nested_value_2}}'] as const +const COLLIDING_REFERENCE = '' +const COLLIDING_TOKEN = '_0'.padEnd(COLLIDING_REFERENCE.length, '_') + +const JAVASCRIPT_REFERENCE_CONTEXTS = [ + (reference: string, envReference: string) => + `const value=${reference};const fallback=${envReference};return value??fallback;`, + (reference: string, envReference: string) => + `return {primary:${reference},fallback:${envReference}};`, + (reference: string, envReference: string) => + `const values=[${reference},${envReference}];return values;`, + (reference: string, envReference: string) => + `if(${reference}){return ${envReference}}return null;`, + (reference: string, envReference: string) => + `return await Promise.resolve(${reference}??${envReference});`, + (reference: string, envReference: string) => + `const lookup=new Map([['value',${reference}]]);return lookup.get('value')??${envReference};`, +] as const + +const PYTHON_REFERENCE_CONTEXTS = [ + (reference: string, envReference: string) => + `value=${reference}\nfallback=${envReference}\nreturn value if value is not None else fallback`, + (reference: string, envReference: string) => + `return {"primary":${reference},"fallback":${envReference}}`, + (reference: string, envReference: string) => + `values=[${reference},${envReference}]\nreturn values`, + (reference: string, envReference: string) => + `if ${reference}:\n return ${envReference}\nreturn None`, + (reference: string, envReference: string) => + `return ${reference} if ${reference} is not None else ${envReference}`, + (reference: string, envReference: string) => + `value=await resolve(${reference})\nreturn value if value is not None else ${envReference}`, + (reference: string, envReference: string) => + `for item in ${reference}:\n print(item)\nreturn ${envReference}`, +] as const + +function extractPlaceholders(code: string): string[] { + return code.match(createCombinedPattern()) ?? [] +} + +async function expectStableFormatting( + code: string, + language: FormattableCodeLanguage +): Promise { + const firstResult = await formatFunctionCode(code, language) + + expect(firstResult.error).toBeNull() + expect(firstResult.changed).toBe(firstResult.code !== code) + + const secondResult = await formatFunctionCode(firstResult.code, language) + expect(secondResult).toEqual({ + code: firstResult.code, + changed: false, + error: null, + }) + + return firstResult.code +} + +function executeJavaScriptBody(code: string, input: unknown): unknown { + const execute = new Function('input', code) as (value: unknown) => unknown + return execute(structuredClone(input)) +} + +async function executeAsyncJavaScriptBody(code: string, input: unknown): Promise { + const execute = new Function('input', `return (async () => {${code}})();`) as ( + value: unknown + ) => Promise + return execute(structuredClone(input)) +} + +describe('formatFunctionCode safety', () => { + describe('JavaScript syntax corpus', () => { + it.each(JAVASCRIPT_SYNTAX_CASES)('formats and stabilizes $name', async (testCase) => { + const formattedCode = await expectStableFormatting(testCase.code, CodeLanguage.JavaScript) + + for (const fragment of testCase.preservedFragments ?? []) { + expect(formattedCode).toContain(fragment) + } + }) + + it.each(JAVASCRIPT_RUNTIME_CASES)('$name', async ({ code, input }: JavaScriptRuntimeCase) => { + const expectedResult = executeJavaScriptBody(code, input) + const formattedCode = await expectStableFormatting(code, CodeLanguage.JavaScript) + + expect(executeJavaScriptBody(formattedCode, input)).toEqual(expectedResult) + }) + + it('preserves async runtime behavior', async () => { + const code = + 'const values=await Promise.all(input.values.map(async(value)=>await Promise.resolve(value*input.multiplier)));return values;' + const input = { values: [1, 3, 5], multiplier: 4 } + const expectedResult = await executeAsyncJavaScriptBody(code, input) + const formattedCode = await expectStableFormatting(code, CodeLanguage.JavaScript) + + await expect(executeAsyncJavaScriptBody(formattedCode, input)).resolves.toEqual( + expectedResult + ) + }) + + it.each([ + ['chained comparison', 'return leftright;', 'return left < value > right;'], + [ + 'logical comparison', + 'return leftright;', + 'return left < value && value > right;', + ], + ['signed shifts', 'return (left<>right;', 'return (left << bits) >> right;'], + ['unsigned shift', 'return value>>>bits;', 'return value >>> bits;'], + ])('keeps %s as JavaScript syntax', async (_name, code, expected) => { + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: expected, + changed: code !== expected, + error: null, + }) + }) + }) + + describe('Python syntax corpus', () => { + it.each(PYTHON_SYNTAX_CASES)('formats and stabilizes $name', async (testCase) => { + const formattedCode = await expectStableFormatting(testCase.code, CodeLanguage.Python) + + for (const fragment of testCase.preservedFragments ?? []) { + expect(formattedCode).toContain(fragment) + } + }) + + it.each([ + ['chained comparison', 'return leftright', 'return left < value > right'], + ['signed shifts', 'return (left<>right', 'return (left << bits) >> right'], + ['less than or equal', 'return left<=value', 'return left <= value'], + ])('keeps %s as Python syntax', async (_name, code, expected) => { + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code: expected, + changed: code !== expected, + error: null, + }) + }) + }) + + describe('Sim placeholder preservation', () => { + it('preserves every JavaScript reference across varied grammar contexts', async () => { + for (const [referenceIndex, reference] of WORKFLOW_REFERENCES.entries()) { + const envReference = ENV_REFERENCES[referenceIndex % ENV_REFERENCES.length] + + for (const buildCode of JAVASCRIPT_REFERENCE_CONTEXTS) { + const code = buildCode(reference, envReference) + const formattedCode = await expectStableFormatting(code, CodeLanguage.JavaScript) + + expect(extractPlaceholders(formattedCode)).toEqual(extractPlaceholders(code)) + } + } + }) + + it('preserves every Python reference across varied grammar contexts', async () => { + for (const [referenceIndex, reference] of WORKFLOW_REFERENCES.entries()) { + const envReference = ENV_REFERENCES[referenceIndex % ENV_REFERENCES.length] + + for (const buildCode of PYTHON_REFERENCE_CONTEXTS) { + const code = buildCode(reference, envReference) + const formattedCode = await expectStableFormatting(code, CodeLanguage.Python) + + expect(extractPlaceholders(formattedCode)).toEqual(extractPlaceholders(code)) + } + } + }) + + it.each([ + [ + CodeLanguage.JavaScript, + `const literal=" {{API_KEY}}";// {{COMMENT_ENV}}\nreturn \`prefix:\${literal}::{{TEMPLATE_ENV}}\`;`, + ], + [ + CodeLanguage.Python, + 'literal=" {{API_KEY}}"\n# {{COMMENT_ENV}}\nreturn f"prefix:{literal}::{{TEMPLATE_ENV}}"', + ], + ] as const)( + 'preserves placeholders inside strings, templates, and comments for %s', + async (language, code) => { + const formattedCode = await expectStableFormatting(code, language) + + expect(extractPlaceholders(formattedCode)).toEqual(extractPlaceholders(code)) + } + ) + + it.each([ + [ + CodeLanguage.JavaScript, + `const collision='${COLLIDING_TOKEN}';return {collision,value:${COLLIDING_REFERENCE},env:{{TOKEN}}};`, + ], + [ + CodeLanguage.Python, + `collision="${COLLIDING_TOKEN}"\nreturn {"collision":collision,"value":${COLLIDING_REFERENCE},"env":{{TOKEN}}}`, + ], + ] as const)('avoids placeholder-token collisions for %s', async (language, code) => { + const formattedCode = await expectStableFormatting(code, language) + + expect(extractPlaceholders(formattedCode)).toEqual(extractPlaceholders(code)) + expect(formattedCode).toContain(COLLIDING_TOKEN) + }) + + it.each([CodeLanguage.JavaScript, CodeLanguage.Python] as const)( + 'restores a large mixed reference set for %s', + async (language) => { + const referenceCount = 512 + const entries = Array.from({ length: referenceCount }, (_, index) => [ + ``, + `{{ENV_${index}}}`, + ]) + const code = + language === CodeLanguage.JavaScript + ? `return [${entries.flat().join(',')}];` + : `return [${entries.flat().join(',')}]` + + const formattedCode = await expectStableFormatting(code, language) + expect(extractPlaceholders(formattedCode)).toEqual(extractPlaceholders(code)) + } + ) + }) + + describe('fail-open behavior', () => { + it.each([ + 'if (', + 'const = 1', + 'return {', + 'const value = "unterminated', + 'function broken( {', + 'return /unterminated', + ])('returns invalid JavaScript unchanged: %j', async (code) => { + const result = await formatFunctionCode(code, CodeLanguage.JavaScript) + + expect(result.code).toBe(code) + expect(result.changed).toBe(false) + expect(result.error).toEqual(expect.any(String)) + }) + + it.each([ + 'if (', + 'def broken(', + 'return {"value":', + 'try:\n run()', + 'value = "unterminated', + 'for item in:\n pass', + ])('returns invalid Python unchanged: %j', async (code) => { + const result = await formatFunctionCode(code, CodeLanguage.Python) + + expect(result.code).toBe(code) + expect(result.changed).toBe(false) + expect(result.error).toEqual(expect.any(String)) + }) + }) +}) From b62597702242376533ad509f4a2275c3f0575563 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:19:28 -0700 Subject: [PATCH 4/5] fix(ci): avoid utility scanner false positive in formatter fixture --- .../lib/workflows/blocks/format-function-code.safety.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts index 4051ddb3b8b..92acf730eab 100644 --- a/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts +++ b/apps/sim/lib/workflows/blocks/format-function-code.safety.test.ts @@ -40,7 +40,7 @@ const JAVASCRIPT_SYNTAX_CASES: SyntaxCase[] = [ }, { name: 'try catch switch and finally blocks', - code: "try{switch(status){case 'ok':return {ok:true};default:throw new Error('bad')}}catch(error){return {ok:false,message:error instanceof Error?error.message:String(error)}}finally{cleanup?.()}", + code: "try{switch(status){case 'ok':return {ok:true};default:throw new Error('bad')}}catch(error){return {ok:false,message:String(error)}}finally{cleanup?.()}", }, { name: 'generator functions and loop control', From 58209d59ce790db972658abd24dd7d7eca7dd9f8 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:07:40 -0700 Subject: [PATCH 5/5] fix(mothership): guard formatted function semantics --- .../format-function-code.integration.test.ts | 213 ++++++++++++++++++ .../blocks/format-function-code.test.ts | 29 +++ .../workflows/blocks/format-function-code.ts | 138 +++++++++++- .../format-function-code.validation.test.ts | 95 ++++++++ 4 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/workflows/blocks/format-function-code.integration.test.ts create mode 100644 apps/sim/lib/workflows/blocks/format-function-code.validation.test.ts diff --git a/apps/sim/lib/workflows/blocks/format-function-code.integration.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.integration.test.ts new file mode 100644 index 00000000000..8da3d10ce50 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/format-function-code.integration.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { spawnSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' +import { formatFunctionCode } from '@/lib/workflows/blocks/format-function-code' +import { BlockType } from '@/executor/constants' +import { ExecutionState } from '@/executor/execution/state' +import type { ExecutionContext } from '@/executor/types' +import { VariableResolver } from '@/executor/variables/resolver' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +vi.mock('@/executor/utils/block-data', () => ({ getBlockSchema: () => ({}) })) + +const PRODUCER_OUTPUT = { + result: { + name: 'formatter', + values: [2, 4, 6], + }, +} + +interface ResolvedFunctionCode { + code: string + contextVariables: Record +} + +interface PythonProgramAnalysis { + ast: string + result: unknown +} + +interface PythonProgramComparison { + formatted: PythonProgramAnalysis + original: PythonProgramAnalysis +} + +const PYTHON_ANALYSIS_SCRIPT = ` +import ast +import json +import sys +import textwrap + +payload = json.load(sys.stdin) + +def analyze(code): + namespace = dict(payload["contextVariables"]) + namespace["json"] = json + source = "def __sim_function__():\\n" + textwrap.indent(code, " ") + "\\n" + tree = ast.parse(source) + exec(compile(tree, "", "exec"), namespace) + return { + "ast": ast.dump(tree, include_attributes=False), + "result": namespace["__sim_function__"](), + } + +print(json.dumps({ + "formatted": analyze(payload["formattedCode"]), + "original": analyze(payload["originalCode"]), +}, sort_keys=True)) +`.trim() + +function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock { + return { + id, + metadata: { id: type, name }, + position: { x: 0, y: 0 }, + config: { tool: type, params }, + inputs: {}, + outputs: { result: 'json' }, + enabled: true, + } +} + +async function resolveFunctionCode( + code: string, + language: CodeLanguage.JavaScript | CodeLanguage.Python +): Promise { + const producer = createBlock('producer', 'Producer', BlockType.API) + const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { language }) + const workflow: SerializedWorkflow = { + version: '1', + blocks: [producer, functionBlock], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + state.setBlockOutput('producer', PRODUCER_OUTPUT) + const context = { + workflowId: 'workflow-1', + blockStates: state.getBlockStates(), + executedBlocks: state.getExecutedBlocks(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: { + EXPRESSION: '1 + 2', + OFFSET: '5', + PYTHON_ASSIGNMENT: 'assigned := 7', + }, + workflowVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + completedLoops: new Set(), + activeExecutionPath: new Set(), + } as ExecutionContext + const resolver = new VariableResolver(workflow, {}, state) + const resolved = await resolver.resolveInputsForFunctionBlock( + context, + functionBlock.id, + { code }, + functionBlock + ) + + expect(typeof resolved.resolvedInputs.code).toBe('string') + return { + code: resolved.resolvedInputs.code as string, + contextVariables: resolved.contextVariables, + } +} + +async function canonicalizeResolvedCode( + code: string, + language: CodeLanguage.JavaScript | CodeLanguage.Python +): Promise { + const result = await formatFunctionCode(code, language) + expect(result.error).toBeNull() + return result.code +} + +function executeJavaScript(code: string, contextVariables: Record): unknown { + const execute = new Function('globalThis', code) as ( + variables: Record + ) => unknown + return execute(structuredClone(contextVariables)) +} + +function analyzePythonPrograms( + originalCode: string, + formattedCode: string, + contextVariables: Record +): PythonProgramComparison { + const input = JSON.stringify({ originalCode, formattedCode, contextVariables }) + + for (const executable of ['python3', 'python']) { + const analysis = spawnSync(executable, ['-c', PYTHON_ANALYSIS_SCRIPT], { + encoding: 'utf8', + input, + }) + if (analysis.error && 'code' in analysis.error && analysis.error.code === 'ENOENT') continue + if (analysis.error) throw analysis.error + if (analysis.status !== 0) { + throw new Error(`${executable} failed to analyze Function code: ${analysis.stderr.trim()}`) + } + return JSON.parse(analysis.stdout) as PythonProgramComparison + } + + throw new Error('Python is required to verify formatted Python Function behavior') +} + +describe('formatFunctionCode reference resolution integration', () => { + it.each([ + 'const item=;const offset={{OFFSET}};return {name:item.name,total:item.values.reduce((sum,value)=>sum+value,offset)};', + "const raw='';return raw;", + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional Function source fixture + 'return `item:${String(.name)}:{{OFFSET}}`;', + "// don't confuse quote tracking\nconst item=;return item.values.slice(1);", + 'return 2*({{EXPRESSION}});', + 'return 2*(/* before */{{EXPRESSION}}/* after */);', + ])('preserves resolved JavaScript behavior: %s', async (sourceCode) => { + const formatted = await formatFunctionCode(sourceCode, CodeLanguage.JavaScript) + expect(formatted.error).toBeNull() + + const [originalResolved, formattedResolved] = await Promise.all([ + resolveFunctionCode(sourceCode, CodeLanguage.JavaScript), + resolveFunctionCode(formatted.code, CodeLanguage.JavaScript), + ]) + + expect(formattedResolved.contextVariables).toEqual(originalResolved.contextVariables) + await expect( + canonicalizeResolvedCode(formattedResolved.code, CodeLanguage.JavaScript) + ).resolves.toBe(await canonicalizeResolvedCode(originalResolved.code, CodeLanguage.JavaScript)) + expect(executeJavaScript(formattedResolved.code, formattedResolved.contextVariables)).toEqual( + executeJavaScript(originalResolved.code, originalResolved.contextVariables) + ) + }) + + it.each([ + 'item=\noffset={{OFFSET}}\nreturn {"name":item["name"],"total":sum(item["values"])+offset}', + "raw=''\nreturn raw", + "prompt='''\nSummary: \n'''\nreturn prompt", + "# don't confuse quote tracking\nitem=\nreturn item['values'][1:]", + 'value=({{PYTHON_ASSIGNMENT}})\nreturn value', + 'value=(\n {{EXPRESSION}}\n)\nreturn value', + 'value=(\n # before\n {{EXPRESSION}} # after\n)\nreturn value', + ])('preserves resolved Python syntax and references: %s', async (sourceCode) => { + const formatted = await formatFunctionCode(sourceCode, CodeLanguage.Python) + expect(formatted.error).toBeNull() + + const [originalResolved, formattedResolved] = await Promise.all([ + resolveFunctionCode(sourceCode, CodeLanguage.Python), + resolveFunctionCode(formatted.code, CodeLanguage.Python), + ]) + + expect(formattedResolved.contextVariables).toEqual(originalResolved.contextVariables) + const analysis = analyzePythonPrograms( + originalResolved.code, + formattedResolved.code, + originalResolved.contextVariables + ) + expect(analysis.formatted.ast).toBe(analysis.original.ast) + expect(analysis.formatted.result).toEqual(analysis.original.result) + }) +}) diff --git a/apps/sim/lib/workflows/blocks/format-function-code.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.test.ts index 5032df8e267..6668ac36efa 100644 --- a/apps/sim/lib/workflows/blocks/format-function-code.test.ts +++ b/apps/sim/lib/workflows/blocks/format-function-code.test.ts @@ -209,6 +209,35 @@ return result`, expect(result).toEqual({ code, changed: false, error: null }) }) + it('preserves JavaScript grouping without confusing call or control parentheses', async () => { + const code = + 'const value=combine({{EXPRESSION}});if({{FLAG}}){return value}return 2*({{EXPRESSION}});' + + await expect(formatFunctionCode(code, CodeLanguage.JavaScript)).resolves.toEqual({ + code: `const value = combine({{EXPRESSION}}); +if ({{FLAG}}) { + return value; +} +return 2 * ({{EXPRESSION}});`, + changed: true, + error: null, + }) + }) + + it('preserves Python grouping without confusing call or control parentheses', async () => { + const code = + 'value=combine({{EXPRESSION}})\nif ({{FLAG}}):\n return value\nreturn ({{EXPRESSION}})' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code: `value = combine({{EXPRESSION}}) +if ({{FLAG}}): + return value +return ({{EXPRESSION}})`, + changed: true, + error: null, + }) + }) + it('keeps Python boundary keywords language-specific', async () => { const cases = [ ['and', 'or'], diff --git a/apps/sim/lib/workflows/blocks/format-function-code.ts b/apps/sim/lib/workflows/blocks/format-function-code.ts index fd9a25602d4..2b6d291b7e7 100644 --- a/apps/sim/lib/workflows/blocks/format-function-code.ts +++ b/apps/sim/lib/workflows/blocks/format-function-code.ts @@ -1,4 +1,6 @@ +import { isDeepStrictEqual } from 'node:util' import { getErrorMessage } from '@sim/utils/errors' +import type { ParserOptions } from 'prettier' import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation' @@ -23,6 +25,19 @@ const PYTHON_CODE_FORMAT_OPTIONS = { } as const const PYTHON_WRAPPER_NAME = '__sim_format_function__' +const JAVASCRIPT_AST_METADATA_KEYS = new Set([ + 'comments', + 'end', + 'errors', + 'extra', + 'innerComments', + 'leadingComments', + 'loc', + 'range', + 'start', + 'tokens', + 'trailingComments', +]) const JAVASCRIPT_EXPRESSION_PREFIX_KEYWORDS = new Set([ 'await', @@ -135,6 +150,39 @@ function looksLikeLanguageComparison( return previousTokenEndsExpression && nextTokenStartsExpression } +function createParenthesizedEnvVarPattern(language: FormattableCodeLanguage): RegExp { + const triviaPattern = + language === CodeLanguage.JavaScript + ? String.raw`(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*(?:\n|$))*` + : String.raw`(?:\s|#[^\n]*(?:\n|$))*` + return new RegExp(`\\(${triviaPattern}${createEnvVarPattern().source}${triviaPattern}\\)`, 'g') +} + +function isParenthesizedEnvExpression( + index: number, + source: string, + language: FormattableCodeLanguage +): boolean { + const sourceBeforeReference = source.slice(0, index).trimEnd() + const previousCharacter = sourceBeforeReference.at(-1) + const previousIdentifier = sourceBeforeReference.match(IDENTIFIER_AT_END)?.[0] + const prefixKeywords = + language === CodeLanguage.Python + ? PYTHON_EXPRESSION_PREFIX_KEYWORDS + : JAVASCRIPT_EXPRESSION_PREFIX_KEYWORDS + const operatorKeywords = + language === CodeLanguage.Python + ? PYTHON_EXPRESSION_OPERATOR_KEYWORDS + : JAVASCRIPT_EXPRESSION_OPERATOR_KEYWORDS + + return ( + !previousCharacter || + (!EXPRESSION_END_CHARACTER.test(previousCharacter) && previousCharacter !== '.') || + prefixKeywords.has(previousIdentifier ?? '') || + operatorKeywords.has(previousIdentifier ?? '') + ) +} + function protectSimReferences( code: string, language: FormattableCodeLanguage @@ -161,7 +209,12 @@ function protectSimReferences( ? value : replaceReference(value) ) - const protectedCode = withProtectedWorkflowReferences.replace( + const withProtectedParenthesizedEnvReferences = withProtectedWorkflowReferences.replace( + createParenthesizedEnvVarPattern(language), + (value, _envName, index, source) => + isParenthesizedEnvExpression(index, source, language) ? replaceReference(value) : value + ) + const protectedCode = withProtectedParenthesizedEnvReferences.replace( createEnvVarPattern(), replaceReference ) @@ -178,6 +231,30 @@ function restoreSimReferences(code: string, references: ProtectedReference[]): s ) } +function haveEqualReferences( + expectedReferences: ProtectedReference[], + actualReferences: ProtectedReference[] +): boolean { + return ( + expectedReferences.length === actualReferences.length && + expectedReferences.every( + (reference, index) => reference.value === actualReferences[index]?.value + ) + ) +} + +function normalizeJavaScriptAst(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeJavaScriptAst) + if (value === null || typeof value !== 'object') return value + + const normalized: Record = {} + for (const [key, nestedValue] of Object.entries(value)) { + if (JAVASCRIPT_AST_METADATA_KEYS.has(key)) continue + normalized[key] = normalizeJavaScriptAst(nestedValue) + } + return normalized +} + function wrapPythonFunctionBody(code: string): string { const indentedCode = code .split('\n') @@ -200,6 +277,29 @@ async function formatJavaScript(code: string): Promise { return format(code, FUNCTION_CODE_FORMAT_OPTIONS) } +async function parseJavaScript(code: string): Promise { + const { parsers } = await import('prettier/plugins/babel') + const parser = parsers.babel + const options = { + ...FUNCTION_CODE_FORMAT_OPTIONS, + locEnd: parser.locEnd, + locStart: parser.locStart, + originalText: code, + } as ParserOptions + return parser.parse(code, options) +} + +async function assertEquivalentJavaScriptSyntax(source: string, formattedCode: string) { + const [sourceAst, formattedAst] = await Promise.all([ + parseJavaScript(source), + parseJavaScript(formattedCode), + ]) + + if (!isDeepStrictEqual(normalizeJavaScriptAst(sourceAst), normalizeJavaScriptAst(formattedAst))) { + throw new Error('JavaScript formatter changed the code syntax tree') + } +} + async function formatPython(code: string): Promise { const { format } = await import('@wasm-fmt/ruff_fmt/node') const wrappedCode = wrapPythonFunctionBody(code) @@ -207,6 +307,33 @@ async function formatPython(code: string): Promise { return unwrapPythonFunctionBody(formattedCode) } +async function formatProtectedCode( + code: string, + language: FormattableCodeLanguage +): Promise { + return language === CodeLanguage.JavaScript ? formatJavaScript(code) : formatPython(code) +} + +async function validateFormattedCode( + formattedCode: string, + language: FormattableCodeLanguage, + expectedReferences: ProtectedReference[] +): Promise { + const protectedFormattedCode = protectSimReferences(formattedCode, language) + if (!haveEqualReferences(expectedReferences, protectedFormattedCode.references)) { + throw new Error(`${getLanguageDisplayName(language)} formatter changed Sim references`) + } + + const secondFormattedCode = await formatProtectedCode(protectedFormattedCode.code, language) + const secondRestoredCode = restoreSimReferences( + secondFormattedCode.trimEnd(), + protectedFormattedCode.references + ) + if (secondRestoredCode !== formattedCode) { + throw new Error(`${getLanguageDisplayName(language)} formatter did not produce stable output`) + } +} + /** * Formats a JavaScript or Python Function block body while preserving Sim's reference syntax. * Parse failures are returned with the original code so callers can choose how to surface them. @@ -220,11 +347,12 @@ export async function formatFunctionCode( const protectedSource = protectSimReferences(code, language) try { - const formattedCode = - language === CodeLanguage.JavaScript - ? await formatJavaScript(protectedSource.code) - : await formatPython(protectedSource.code) + const formattedCode = await formatProtectedCode(protectedSource.code, language) + if (language === CodeLanguage.JavaScript) { + await assertEquivalentJavaScriptSyntax(protectedSource.code, formattedCode) + } const restoredCode = restoreSimReferences(formattedCode.trimEnd(), protectedSource.references) + await validateFormattedCode(restoredCode, language, protectedSource.references) return { code: restoredCode, diff --git a/apps/sim/lib/workflows/blocks/format-function-code.validation.test.ts b/apps/sim/lib/workflows/blocks/format-function-code.validation.test.ts new file mode 100644 index 00000000000..e0709634415 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/format-function-code.validation.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' +import { formatFunctionCode } from '@/lib/workflows/blocks/format-function-code' + +const { mockFormatJavaScript, mockFormatPython } = vi.hoisted(() => ({ + mockFormatJavaScript: vi.fn(), + mockFormatPython: vi.fn(), +})) + +vi.mock('prettier', () => ({ format: mockFormatJavaScript })) +vi.mock('@wasm-fmt/ruff_fmt/node', () => ({ format: mockFormatPython })) + +describe('formatFunctionCode validation', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('keeps JavaScript unchanged when formatting changes its syntax tree', async () => { + mockFormatJavaScript.mockResolvedValue('return 2;\n') + + await expect(formatFunctionCode('return 1;', CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'return 1;', + changed: false, + error: expect.stringContaining('syntax'), + }) + }) + + it('keeps JavaScript unchanged when a second formatting pass is not identical', async () => { + mockFormatJavaScript + .mockResolvedValueOnce('return 1;\n') + .mockResolvedValueOnce('return 1; // changed again\n') + + await expect(formatFunctionCode('return 1', CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'return 1', + changed: false, + error: expect.stringContaining('stable'), + }) + }) + + it('accepts JavaScript only after an identical second formatting pass', async () => { + mockFormatJavaScript.mockResolvedValue('return 1;\n') + + await expect(formatFunctionCode('return 1', CodeLanguage.JavaScript)).resolves.toEqual({ + code: 'return 1;', + changed: true, + error: null, + }) + expect(mockFormatJavaScript).toHaveBeenCalledTimes(2) + }) + + it('keeps Python unchanged when formatting loses a Sim reference', async () => { + mockFormatPython.mockImplementation((code: string) => code.replace(/_0_+/, 'missing_reference')) + const code = 'value=\nreturn value' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code, + changed: false, + error: expect.stringContaining('references'), + }) + }) + + it('keeps Python unchanged when formatting reorders Sim references', async () => { + mockFormatPython.mockImplementation((formattedCode: string) => { + const [firstToken, secondToken] = formattedCode.match(/_[01]_+/g) ?? [] + if (!firstToken || !secondToken) throw new Error('Expected two protected Sim references') + return formattedCode + .replaceAll(firstToken, '__sim_swap__') + .replaceAll(secondToken, firstToken) + .replaceAll('__sim_swap__', secondToken) + }) + const code = 'first=\nsecond=\nreturn first,second' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code, + changed: false, + error: expect.stringContaining('references'), + }) + }) + + it('keeps Python unchanged when a second formatting pass is not identical', async () => { + mockFormatPython + .mockReturnValueOnce('def __sim_format_function__():\n value = 1\n return value\n') + .mockReturnValueOnce('def __sim_format_function__():\n value = 2\n return value\n') + const code = 'value=1\nreturn value' + + await expect(formatFunctionCode(code, CodeLanguage.Python)).resolves.toEqual({ + code, + changed: false, + error: expect.stringContaining('stable'), + }) + }) +})