feat: syntax-check JavaScript code fields after find/replace patches (v2.72.0) - #1014
Conversation
…(v2.72.0) After patchNodeField (and the legacy __patch_find_replace path in updateNode) applies its patches to parameters.jsCode or functionCode, the result is parsed as an async function body — matching n8n's own wrapping, so top-level return/await are valid. A patch that leaves the field unparseable fails the operation with the parser's message instead of saving broken code. Expressions (leading =), pythonCode, and non-code fields are skipped; the check parses only and never executes. Proposed by @NextLevelManagementAdvisors in #1012. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a safety guard to the workflow diff engine so that find/replace-style patches against JavaScript code fields (e.g. Code node jsCode, legacy functionCode) are syntax-checked before being committed, preventing silent corruption of live workflows during partial updates.
Changes:
- Parse patched
jsCode/functionCodevalues via an async-function constructor and reject patches that introduceSyntaxErrorbefore mutating workflow state. - Add unit tests covering syntax-guard behavior for
patchNodeFieldand the legacy__patch_find_replaceupdate path. - Bump version to
2.72.0and document the new behavior in tool docs and the changelog.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/services/workflow-diff-engine.test.ts | Adds unit coverage for rejecting syntactically broken patched JS code and ensuring atomicity (no mutation on failure). |
| src/services/workflow-diff-engine.ts | Introduces getJsSyntaxError() and integrates it into patchNodeField and __patch_find_replace application flow. |
| src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts | Documents the new syntax-checking behavior for patch operations on JS code fields. |
| package.runtime.json | Bumps runtime package version to 2.72.0. |
| package.json | Bumps main package version to 2.72.0. |
| package-lock.json | Syncs lockfile version fields to 2.72.0. |
| CHANGELOG.md | Adds a 2.72.0 entry describing the new syntax-check guard. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| describe('JavaScript syntax guard on code fields', () => { | ||
| const codeWorkflow = (jsCode: string) => { | ||
| const workflow = JSON.parse(JSON.stringify(baseWorkflow)); | ||
| workflow.nodes.push({ | ||
| id: 'code-1', | ||
| name: 'Code', | ||
| type: 'n8n-nodes-base.code', | ||
| typeVersion: 1, | ||
| position: [900, 300], | ||
| parameters: { jsCode } | ||
| }); | ||
| return workflow; | ||
| }; |
| 'patchNodeField literal mode (regex not set) inserts replace verbatim, so $ needs no escaping. With regex: true, replace supports JS replacement patterns: $1 for a capture group, $$ for a literal $', | ||
| 'Patches to parameters.jsCode or functionCode are parsed as JavaScript after applying: a patch that leaves invalid syntax fails the operation. Only the final result of one operation\'s patches array is checked, so apply dependent edits in a single operation. Values starting with = (expressions) and pythonCode are not checked', |
|
|
||
| ### Added | ||
|
|
||
| - **Patches to JavaScript code fields are now syntax-checked before saving.** After `patchNodeField` (and the older `__patch_find_replace` path in `updateNode`) applies its find/replace patches to `parameters.jsCode` or `functionCode`, the engine parses the result as the body of an async function — matching how n8n wraps Code-node JavaScript, so top-level `return` and `await` are valid. A patch that leaves the field unparseable now fails the operation with the parser's message instead of saving broken code to the workflow — live, if the workflow is active. Only the final result of one operation's patches array is checked, so several dependent edits that pass through an invalid intermediate state can ride a single operation. Values starting with `=` (n8n expressions), `pythonCode`, and non-code fields are not checked; the check parses only and never executes the code. Proposed by @NextLevelManagementAdvisors in the #1012 report. (#1012) |
… pre-patch validity Simplifier pass: single assertPatchedJsSyntax helper shared by both patch paths, one parameterized error message (both now carry the combine-edits hint), shorter AsyncFunction derivation, merged duplicate tests. Behavior refinement from the same review: the guard now blocks only regressions the patch introduces — a code field that was already unparseable before patching stays patchable, so pre-existing corruption can be repaired incrementally instead of being blamed on the patch. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ejection Both suggested by the code review pass. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results Summary📊 ArtifactsGenerated at Tue, 18 Aug 2026 22:04:25 GMT |
…roken JS An "=" original is an unchecked expression, not broken JavaScript — it must not open the incremental-repair gate. Also covers the repair allowance on the __patch_find_replace path with a test. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/unit/services/workflow-diff-engine.test.ts:1441
- This test claims the workflow is unchanged on failure, but applyDiff() always clones the input workflow, so checking the original
workflowobject doesn't validate atomicity of the failed patch against the returned workflow state. Using continueOnError lets you assertresult.workflowremained unchanged while still expecting success=false.
expect(codeNode?.parameters.jsCode).toBe('const x = 2;');
});
describe('JavaScript syntax guard on code fields', () => {
const codeWorkflow = (jsCode: string) => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
tests/unit/services/workflow-diff-engine.test.ts:662
- These assertions check that the input workflow object wasn't mutated, but applyDiff() always deep-clones the workflow at the start. That makes this test redundant/misleading for verifying that a failed __patch_find_replace doesn't partially update the returned workflow. Consider running this in continueOnError mode so you can assert against result.workflow while still expecting success=false.
This issue also appears on line 1436 of the same file.
const codeNode = result.workflow!.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const x = (;\nreturn x + 1;');
});
it('should reject __patch_find_replace patches that leave jsCode with a syntax error', async () => {
… tests Codex review findings on #1014: the synchronous AsyncFunction parse had no size ceiling (a 60MB body costs ~1s on the event loop), so the guard now steps aside above 1MB. New tests pin the ceiling, the RangeError fail-open on pathological nesting, that the checked code is never executed, that regex-mode patch results are guarded too, and a genuine broken-to-fixed repair. Tool docs note the updateNode escape hatch for syntax newer than the MCP host's Node.js. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/services/workflow-diff-engine.ts:230
- The error message says "The workflow was not modified.", but in continueOnError mode later operations can still modify the workflow, making this statement misleading. Consider wording it as "No changes were applied for this operation" (or similar) so it's always accurate.
if (!syntaxError) return;
// An "=" original is an expression — unchecked but not broken JS. Only a
// plain-JS original that itself fails to parse opens the repair gate, so
// patching an expression into broken plain JS is still caught.
if (!original.startsWith('=') && jsSyntaxErrorOf(original) !== undefined) return;
…l docs Copilot review comments on #1014. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jsCode and functionCode are noDataExpression fields: n8n strips one
leading "=" and executes the rest as code (node-helpers), so "=" values
are not expressions there. The previous exemption let a patch smuggle
broken code past the guard as "=return (". The guard now parses exactly
what n8n will run, for the patched value and the repair-gate baseline
alike.
Found by the Codex review on #1014.
Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex finding on #1014: the guard returned "no error" both when code parsed clean and when it could not be checked (over 1MB, or a RangeError from pathological nesting), so a verifiably-valid field could be patched into an unverifiable blob unnoticed. checkJsSyntax now reports valid/invalid/uncheckable; an uncheckable result from a valid baseline is rejected with a pointer to updateNode, while broken or unverifiable baselines still open the repair gate. CSP environments where the Function constructor is blocked stay neutral (both sides uncheckable). Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/services/workflow-diff-engine.ts:209
- jsSyntaxErrorOf() currently returns
undefinedboth when code is syntactically valid and when the guard could not check (size > MAX_SYNTAX_CHECKED_LENGTH or a non-SyntaxError like EvalError/RangeError). In assertPatchedJsSyntax(), that makes an uncheckableoriginallook “valid”, so a patch that leaves a SyntaxError can be rejected even though the guard explicitly intends to fail-open whenever it cannot judge (and should not treat unknown-original as a confirmed regression).
type JsSyntaxCheck =
| { status: 'valid' }
| { status: 'invalid'; message: string }
// Could not judge the code either way: oversized, or the parser gave up
// with a non-SyntaxError (a CSP EvalError, a RangeError from pathological
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/services/workflow-diff-engine.ts:1169
- The error message for invalid JS syntax uses the hard-coded operation label passed here. Using "__patch_find_replace" can be confusing for callers who invoked an updateNode operation (the outer error wrapper will become
Failed to apply operation: __patch_find_replace: ...). Consider labeling it as updateNode + the patch mode so the message matches the user-facing operation.
assertPatchedJsSyntax('__patch_find_replace', path, current, original);
src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts:466
- This doc string says the syntax guard applies only to
parameters.jsCode/parameters.functionCode, but the implementation checks the last path segment (jsCodeorfunctionCode) regardless of prefix. To avoid surprising users (e.g. nested paths ending in.jsCode), update the wording to match the actual behavior.
'Patches to parameters.jsCode or parameters.functionCode are parsed as JavaScript after applying: a patch that breaks previously-valid code fails the operation. Only the final result of one operation\'s patches array is checked, so apply dependent edits in a single operation. A leading = is stripped before parsing, matching how n8n runs these noDataExpression fields. Fields that were already invalid before patching and pythonCode are not checked; code over 1MB is never parsed, and patching valid code into something unverifiable is rejected — set the full value via updateNode (unchecked) if that is intended. The parse runs on the MCP server\'s Node.js: in the rare case it rejects newer syntax your n8n runtime accepts, set the full field value via updateNode instead (not guarded)',
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Summary
Implements the expansion proposed by @NextLevelManagementAdvisors in #1012: after a find/replace patch lands on a JavaScript code field, the engine now parses the result and fails the operation if the patch left the code syntactically broken — instead of saving corrupted code to the workflow (live, if the workflow is active).
How it works
patchNodeField(and the legacy__patch_find_replacepath inupdateNode) applies its patches to a field whose last path segment isjsCodeorfunctionCode, the final value is parsed via theAsyncFunctionconstructor — parse-only, the code is never executed.returnandawaitparse fine.SyntaxError, the operation throws with the parser's message before any mutation lands on the node (patchNodeFieldvalidates beforesetNestedProperty;__patch_find_replacevalidates before the draft swap), preserving atomicity.$bug) can be repaired incrementally instead of being blamed on the patch.SyntaxErrorparse failure (CSPEvalError,RangeErrorfrom pathological nesting) lets the patch through — the guard is a best-effort seatbelt, and it steps aside whenever it cannot judge. If it ever rejects newer syntax that the n8n runtime's V8 accepts, the tool docs point toupdateNodefull-field set as the unguarded escape hatch.Scope decisions
patchesarray are fine; the error message tells agents to combine dependent edits into a single operation.=(n8n expressions embed{{ }}, not plain JS),pythonCode, non-code fields, and non-SyntaxErrorconstructor failures (e.g. environments whereFunctionconstruction is blocked) — the guard never blocks what it cannot check.updateNodesets ofjsCodeare intentionally out of scope: there the agent supplies the whole value deliberately; the corruption risk this guards against is specific to find/replace splicing.Testing
parameters.functionCode), already-broken field still patchable on both paths, genuine broken-to-fixed repair, expression-prefix stripping still guarded, top-levelreturn/awaitaccepted, multi-patch operation with invalid intermediate state accepted, regex-mode results guarded,=-expressions/pythonCode/non-code fields skipped, checked code never executed (sentinel), 1MB ceiling and RangeError nesting fail open, andcontinueOnErrorlets later operations proceed after a guard rejection.Version
2.72.0(minor — new validation behavior), changelog entry added, runtime version synced.Conceived by Romuald Członkowski - www.aiadvisors.pl/en
🤖 Generated with Claude Code