From bbd7b7db885dd6a5b08055a99d3f5380c685c1fb Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 30 Jul 2026 17:18:11 +0200 Subject: [PATCH] feat(core): Add workflow SDK validation framework for Instance AI (#35045) Co-authored-by: Cursor --- .../workflow-builder-no-credential-ask.json | 2 +- .../reference/workflow-builder-guardrails.md | 29 +- .../skills/workflow-builder/SKILL.md | 118 +- .../skills/__tests__/runtime-skills.test.ts | 22 +- .../__tests__/build-workflow.tool.test.ts | 10 +- .../workflow-source-compiler.test.ts | 9 +- .../workflow-validation-warnings.test.ts | 23 +- .../tools/workflows/build-workflow.tool.ts | 13 +- .../workflows/workflow-source-compiler.ts | 6 + .../workflows/workflow-validation-warnings.ts | 37 +- packages/@n8n/workflow-sdk/package.json | 7 +- .../workflow-sdk/src/ast-interpreter/index.ts | 1 + packages/@n8n/workflow-sdk/src/cli/index.ts | 30 +- .../src/cli/node-definition-dirs.test.ts | 75 + .../src/cli/node-definition-dirs.ts | 88 ++ .../@n8n/workflow-sdk/src/cli/validate.ts | 291 ++++ .../src/codegen/codegen-roundtrip.test.ts | 52 +- .../src/codegen/emit-instance-ai.test.ts | 3 + .../generate-zod-schemas.test.ts | 2 +- packages/@n8n/workflow-sdk/src/index.ts | 7 + .../@n8n/workflow-sdk/src/lint/ast-walk.ts | 51 + .../src/lint/code-node/code-node.test.ts | 67 + .../lint/code-node/extract-snippets.test.ts | 51 + .../src/lint/code-node/extract-snippets.ts | 129 ++ .../workflow-sdk/src/lint/code-node/js.ts | 228 +++ .../workflow-sdk/src/lint/code-node/python.ts | 42 + packages/@n8n/workflow-sdk/src/lint/index.ts | 26 + .../src/lint/lint-workflow-source.test.ts | 61 + .../src/lint/lint-workflow-source.ts | 60 + .../src/lint/sdk/workflow-sdk-lint.test.ts | 218 +++ .../src/lint/sdk/workflow-sdk-lint.ts | 394 +++++ packages/@n8n/workflow-sdk/src/lint/types.ts | 28 + packages/@n8n/workflow-sdk/src/validation.ts | 13 +- .../@n8n/workflow-sdk/src/validation/index.ts | 1347 +--------------- .../informational-validation-codes.test.ts | 23 + .../informational-validation-codes.ts | 9 + ...test.ts => input-index-validation.test.ts} | 34 + .../src/validation/issue-severity.ts | 36 + .../resolve-schema.test.ts | 0 .../resolve-schema.ts | 2 +- .../schema-helpers.test.ts | 0 .../schema-helpers.ts | 2 +- .../schema-validation-integration.test.ts | 4 +- .../schema-validator.test.ts | 0 .../schema-validator.ts | 0 .../test-schema-setup.ts | 6 +- .../resolve-main-input-count.test.ts} | 2 +- .../resolve-main-input-count.ts} | 0 .../resolve-main-output-count.ts} | 0 ...est.ts => output-index-validation.test.ts} | 0 .../validate-workflow-builder.test.ts | 80 + .../validation/validate-workflow-builder.ts | 244 +++ ...tion.test.ts => validate-workflow.test.ts} | 114 +- .../src/validation/validate-workflow.ts | 1367 +++++++++++++++++ .../src/workflow-builder-plugins.test.ts | 4 +- .../@n8n/workflow-sdk/src/workflow-builder.ts | 1 + .../src/workflow-builder/plugins/types.ts | 9 +- .../validators/disconnected-node-validator.ts | 2 +- .../validators/missing-trigger-validator.ts | 2 +- .../test-fixtures/committed-workflows/11.json | 29 + .../test-fixtures/committed-workflows/12.json | 49 + .../test-fixtures/committed-workflows/13.json | 30 + .../committed-workflows/manifest.json | 18 + pnpm-lock.yaml | 3 + 64 files changed, 4144 insertions(+), 1466 deletions(-) create mode 100644 packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.ts create mode 100644 packages/@n8n/workflow-sdk/src/cli/validate.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/ast-walk.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/code-node/code-node.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/code-node/js.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/code-node/python.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/index.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.ts create mode 100644 packages/@n8n/workflow-sdk/src/lint/types.ts create mode 100644 packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.ts rename packages/@n8n/workflow-sdk/src/validation/{input-validation.test.ts => input-index-validation.test.ts} (92%) create mode 100644 packages/@n8n/workflow-sdk/src/validation/issue-severity.ts rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/resolve-schema.test.ts (100%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/resolve-schema.ts (99%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/schema-helpers.test.ts (100%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/schema-helpers.ts (98%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/schema-validation-integration.test.ts (99%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/schema-validator.test.ts (100%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/schema-validator.ts (100%) rename packages/@n8n/workflow-sdk/src/validation/{ => node-parameter-schema}/test-schema-setup.ts (93%) rename packages/@n8n/workflow-sdk/src/validation/{input-resolver.test.ts => node-port-resolvers/resolve-main-input-count.test.ts} (97%) rename packages/@n8n/workflow-sdk/src/validation/{input-resolver.ts => node-port-resolvers/resolve-main-input-count.ts} (100%) rename packages/@n8n/workflow-sdk/src/validation/{output-resolver.ts => node-port-resolvers/resolve-main-output-count.ts} (100%) rename packages/@n8n/workflow-sdk/src/validation/{output-validation.test.ts => output-index-validation.test.ts} (100%) create mode 100644 packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.ts rename packages/@n8n/workflow-sdk/src/validation/{validation.test.ts => validate-workflow.test.ts} (96%) create mode 100644 packages/@n8n/workflow-sdk/src/validation/validate-workflow.ts create mode 100644 packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/11.json create mode 100644 packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/12.json create mode 100644 packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/13.json diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json b/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json index 127ca64414f..5bbbd2cf793 100644 --- a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json +++ b/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json @@ -24,5 +24,5 @@ ] }, "rationale": "Regression coverage for INS-204. A clear single-workflow build should route through the workflow-builder skill directly, may use credentials(action=\"list\") for discovery, and must not ask the user which credential/account to use when the builder can auto-select or mock unresolved credentials. It should also use contextual timezone data rather than asking for it.", - "maxSteps": 12 + "maxSteps": 24 } diff --git a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md index a6fc69bdcce..2e25783114c 100644 --- a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md +++ b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md @@ -4,8 +4,10 @@ Use these guardrails for workflow builds with multiple external systems, multiple requested effects, digests or reports, non-trivial branching, or Code nodes. They are a runtime checklist, not extra user-facing output. -Do not add sticky notes unless the user explicitly asks for them. Prefer chat -explanations over canvas stickies. +Code-node runtime limits (no network, forbidden imports, nested template +literals) and unsolicited stickies are enforced by `workflow-sdk validate` — +fix those findings before `build-workflow`. Prefer built-in nodes for simple +split, map, filter, merge, and aggregate work. ## Preserve Source Data @@ -116,22 +118,9 @@ node after a side-effect needs the original data, reference it by node name (`$('Compute Change').item.json.status`) or wire it in parallel from the data-producing node instead of chaining through the send. -## Keep Code Nodes Parseable +## Code Nodes -Prefer built-in nodes for simple split, map, filter, merge, and aggregate work. -When a Code node is necessary, use real n8n item APIs such as `$input.all()` and -return explicit `json` objects. - -Code nodes run in a restricted runtime. Do not `require()` or `import` -unavailable modules such as `luxon` or `openai`; use JavaScript `Date`, `Intl`, -`$now`, `$today`, existing workflow data, or dedicated AI nodes. - -Code nodes have no network access. `fetch()`, `axios`, `XMLHttpRequest`, and -`require` of http modules all fail at runtime, in JavaScript and Python alike. -Make every HTTP/API call with the HTTP Request node and transform its output in -the Code node, even when the user asks to fetch inside a Code node. - -Keep embedded Code node source parseable after saving. Avoid nested template -literals, raw newlines inside quoted strings, and escape-heavy regex literals. -Prefer arrays joined with a runtime separator such as -`const LF = String.fromCharCode(10);`. +When a Code node is necessary, use real n8n item APIs such as `$input.all()` / +`$input.item` and return explicit `json` objects. Prefer arrays joined with a +runtime separator (e.g. `const LF = String.fromCharCode(10);`) over escape-heavy +multi-line string construction. diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index 3ef6b0927f2..67145fb3bab 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -3,15 +3,17 @@ name: workflow-builder description: >- Load before calling build-workflow. Default path for all single-workflow work: new one-off workflows, existing-workflow edits, verification repairs, - and workflow-local data tables. Write or edit a workspace source file, then - call build-workflow with filePath. When the workflow creates or writes Data - Tables, load data-table-manager first, then this skill. Do not load planning - or create-tasks first. Load planning only when multiple coordinated workflows + and workflow-local data tables. Write or edit a workspace source file, run + workflow-sdk validate via workspace_execute_command, then call build-workflow + with filePath. When the workflow creates or writes Data Tables, load + data-table-manager first, then this skill. Do not load planning or + create-tasks first. Load planning only when multiple coordinated workflows or shared cross-task data tables require a dependency-aware task graph. recommended_tools: - read_file - write_file - edit_file + - execute_command - build-workflow - workflows - nodes @@ -32,13 +34,12 @@ You are an expert n8n workflow builder. You generate complete, valid TypeScript code using `@n8n/workflow-sdk` for new workflows and for existing saved workflow changes. -For new single-workflow requests, build directly with -`build-workflow({ filePath, sourceCode })` — the complete TypeScript SDK -source in `sourceCode`; the tool writes the file and builds in one call. For +Always write the complete TypeScript SDK source with +`workspace_write_file` first, then call `build-workflow({ filePath })`. For existing saved workflow edits, call `workflows(action="get-as-code", -workflowId)`, apply the edit to the returned code, then call -`build-workflow({ filePath, workflowId, sourceCode })` the first time — all -edits go through a workspace source file and `build-workflow`. Do not load +workflowId)`, apply the edit to the returned code, write it to the file, then +call `build-workflow({ filePath, workflowId })` the first time — all edits go +through a workspace source file and `build-workflow`. Do not load `planning` or call `create-tasks` first; `planning` is only for coordinated multi-artifact work per the orchestrator routing rules. Do not create a plan just for verification. @@ -60,16 +61,14 @@ editing anything — never guess at the cause or change the node on a hunch. When called with failure details for an existing workflow, start from the workspace source file if one is available in the conversation or tool output. If you only have a saved n8n workflow ID, use `workflows(action="get-as-code")`, -make the smallest requested edit to the returned code, then call -`build-workflow` once with `filePath` (a stable -`src/workflows/.workflow.ts` path), `workflowId`, and the full edited -code as `sourceCode`. Later repairs should reuse the same `filePath`; +make the smallest requested edit to the returned code, write it to a stable +`src/workflows/.workflow.ts` path, then call `build-workflow` once with +`filePath` and `workflowId`. Later repairs should reuse the same `filePath`; `build-workflow` remembers the bound workflow ID. For repairs, prefer editing the workspace file directly with file tools (`workspace_str_replace_file`) and calling `build-workflow` again with the same -`filePath` alone — cheaper than resending full source. `sourceCode` must always -be the complete source when used; never send string patches or fragments. +`filePath`. ## Escalation @@ -138,7 +137,7 @@ For workflows with multiple external systems, multiple requested effects, digests or reports, non-trivial branching, or Code nodes, read `knowledge-base/reference/workflow-builder-guardrails.md` before writing code. Use it as the build checklist for source preservation, fan-out/fan-in, -effect-specific gating, list itemization, and Code-node safety. +effect-specific gating, and list itemization. When mapping downstream fields from an OpenAI node, read `knowledge-base/reference/open-ai-output-shape.md` (v2+ text/response uses @@ -184,12 +183,9 @@ build → publish → assign steps. Do not create one before the user opts in. `workflows(action="get-as-code", workflowId)`, apply your edit to the returned code, and pass the n8n `workflowId` only on the first `build-workflow` call. -6. Produce complete TypeScript SDK code. For a new or fully rewritten source - file, do NOT write it with `workspace_write_file` — pass it directly as - `sourceCode` on the `build-workflow` call (the tool writes `filePath` and - builds in one step; a separate write call wastes a full round-trip). Use - file tools only to selectively edit an existing `.workflow.ts` for - follow-up changes and repairs. Do not put secrets in the source file. +6. Produce complete TypeScript SDK code and write it with + `workspace_write_file` (new/full rewrite) or `workspace_str_replace_file` + (targeted edit). Do not put secrets in the source file. Before building, decide whether verification needs branch fixtures. When a live or nondeterministic upstream node (such as HTTP Request, search/list lookups, weather feeds, or AI classifiers) feeds IF/Switch logic and @@ -198,12 +194,21 @@ build → publish → assign steps. Do not create one before the user opts in. and later `fixtureOverrides` can exercise those scenarios. Do not simulate every external read by default; use this when branch coverage or deterministic proof depends on controlling the upstream data. -7. Call `build-workflow` with `filePath` (plus `sourceCode` for new or fully - rewritten source). +7. Before the first `build-workflow` (and again after substantive edits), run + SDK validation on the workspace source file via + `workspace_execute_command`: + `node --import tsx node_modules/@n8n/workflow-sdk/dist/cli/index.js validate ` + Output is lint-style (`line severity code message`); fix every `error` + row. Warnings do not block the save and the command may still exit 0, but + they flag defects that surface at run time — resolve or consciously dismiss + each one. A clean validate run does not guarantee `build-workflow` will + succeed (no full node-type registry in the sandbox CLI), so still call + `build-workflow`. +8. Call `build-workflow` with the `filePath` you wrote. For planned build follow-ups where `buildTask.isSupportingWorkflow === true`, pass `isSupportingWorkflow: true`; that saved supporting workflow is the task's final deliverable. -8. Trace wiring before declaring done. For IF, Switch, Merge, AI-agent, loop, or +9. Trace wiring before declaring done. For IF, Switch, Merge, AI-agent, loop, or multi-workflow wiring, trace each branch from source to target. Confirm IF branches are wired on the workflow builder (`.to(ifNode).onTrue(...).onFalse(...)` or `.to(ifNode.onTrue(...).onFalse(...))`), not as standalone calls on the IF @@ -213,15 +218,15 @@ build → publish → assign steps. Do not create one before the user opts in. every requested side effect is on a wired branch. Switch outputs use zero-based `.onCase(index, target)`, Merge modes match the data shape, and sub-nodes are attached to the correct parent. -9. Fix errors by editing the same workspace source file and calling - `build-workflow` again with the same `filePath`. Save again before any - verification step. -10. Modify existing workflows by editing the workspace `.workflow.ts` source +10. Fix errors by editing the same workspace source file, re-running + `workflow-sdk validate` on that file, then calling `build-workflow` again + with the same `filePath`. Save again before any verification step. +11. Modify existing workflows by editing the workspace `.workflow.ts` source file. If the file was created from `workflows(action="get-as-code")`, pass the real n8n `workflowId` on the first `build-workflow` call so the file is bound to the saved workflow. Never pass local SDK workflow IDs as n8n workflow IDs. -11. After a successful direct `build-workflow` result, if the tool output +12. After a successful direct `build-workflow` result, if the tool output contains `postBuildFlow.required: true`, follow the inlined `postBuildFlow.instructions` from that output (do not load `post-build-flow` separately) before verification, setup, error-workflow follow-up, @@ -432,29 +437,24 @@ never from `$now.weekday == N`, which silently no-ops on other days. ## SDK Code Rules +`workflow-sdk validate` (step 7 in the build loop) enforces common SDK and +Code-node defects: network calls / forbidden imports in Code nodes, nested +template literals in `jsCode`, TypeScript-only syntax such as `as const`, +statements after `export default`, `placeholder()` wrapped in `expr()`, +unsolicited `sticky()`, forbidden builder constructs (e.g. `.map()`), and +repeated `.onTrue()` / `.onFalse()` overwrites on the same IF variable. Fix +every reported error and warning before calling `build-workflow`. + +- Code nodes need not always be necessary. You can use other n8n nodes to do the same thing. - SDK builder code is a restricted subset of TypeScript that builds a static - graph; it is not a Code node and does not run. Only SDK builder methods chain - on SDK objects. Native array/string methods (`.join()`, `.map()`), loops, arrow - functions, `new`, and globals like `Math`, `Date`, and `Object` are - unavailable. Build strings with template literals or explicit lines; do runtime - joining, aggregation, or transforms in a Code node or an n8n expression - (`expr()`). Full allowed/forbidden list: + graph; it is not a Code node and does not run. Build strings with template + literals; do runtime joining, aggregation, or transforms in a Code node or + `expr()`. Full allowed/forbidden list: `knowledge-base/reference/workflow-sdk-language.md`. - -- Code nodes have NO network access at runtime: `fetch()`, `axios`, - `XMLHttpRequest`, and `require` of http modules all fail in the sandbox. Make - every HTTP/API call with the HTTP Request node and transform its output in a - Code node, even when the user asks to fetch inside a Code node. - - Use `@n8n/workflow-sdk`. -- `export default workflow(...)...` must be the last statement in the file, with - all wiring composed inside that chain. Statements after it (e.g. - `ifNode.onTrue(...)`) do not reach the builder and their nodes are dropped. - Do not specify node positions. They are auto-calculated by the layout engine. - Use `expr('{{ $json.field }}')` for n8n expressions. Variables must be inside `{{ }}`. `$json` is only the current item from the immediate predecessor. -- Do not use TypeScript-only syntax that the workflow parser cannot interpret, - such as `as const`. - Use string values directly for discriminator fields like `resource` and `operation`, for example `resource: 'message'`. - When editing a pre-loaded workflow, remove `position` arrays from node @@ -577,12 +577,6 @@ Follow these rules strictly when generating workflows: match time units broadly (day/days, week/weeks…), and give every classifier an explicit fallback bucket — a one-phrasing regex silently misroutes every other phrasing. -7. Do not add sticky notes (`sticky(...)` / `n8n-nodes-base.stickyNote`) unless - the user explicitly asks for canvas notes. They add visual noise and are - often poorly positioned. Put explanations in your chat reply instead. Even - when the SDK language reference documents `sticky()`, do not use it by - default. When editing a workflow, do not add or reintroduce stickies unless the user - explicitly asks for them. ## Tool Naming Rules @@ -685,8 +679,6 @@ export default workflow('id', 'name') For IF, each branch is a complete processing path. Wire branches on the workflow builder, not as standalone calls on the IF node variable. Chain steps inside a branch with `.to()`, or pass an array for parallel fan-out. -Never call `.onFalse()` more than once (same for `.onTrue()`); each repeat -overwrites the previous target. ```ts const isImportant = ifElse({ @@ -714,16 +706,14 @@ export default workflow('id', 'name') // Parallel fan-out on a branch: .onFalse([a, b, c]) ``` -Do NOT wire branches as standalone statements. -Then branch nodes are omitted from the saved graph, and repeated `.onFalse()` -calls keep only the last target. +Do NOT wire branches as standalone statements after `export default` — those +calls never reach the builder (`workflow-sdk validate` flags this). ```ts // WRONG export default workflow('id', 'name').add(startTrigger).to(isImportant); isImportant.onTrue(handleImportant); // never reaches the builder -isImportant.onFalse(sendHolding); // overwritten -isImportant.onFalse(alertSlack); // only this one would wire +isImportant.onFalse(sendHolding); ``` For Switch, wire cases the same way — `.to(switchNode).onCase(0, a).onCase(1, b)` @@ -743,10 +733,8 @@ For AI Agent workflows: ## Additional SDK Functions -- `placeholder('hint')`: marks a parameter value for user input. -- `sticky('content', nodes?, config?)`: opt-in only when the user explicitly - asks for a sticky note on the canvas. Do not import or call it otherwise. - When used, it must still be added to the workflow. +- `placeholder('hint')`: marks a parameter value for user input (use directly as + the parameter value; `workflow-sdk validate` flags wrapping it in `expr()`). - `.output(n)`: selects a zero-based output index. - `.onError(handler)`: connects a node's error output to a handler. Requires `onError: 'continueErrorOutput'` in the node config. diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts index 9cf5b3e8cf5..b5432a2e341 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts @@ -24,18 +24,14 @@ describe('Instance AI runtime skills', () => { expect(skill).toContain('knowledge-base/reference/workflow-sdk-language.md'); }); - it('tells the workflow-builder not to add sticky notes by default', () => { + it('defers sticky and other SDK defects to workflow-sdk validate', () => { const skill = readFileSync( join(INSTANCE_AI_SKILLS_DIR, 'workflow-builder', 'SKILL.md'), 'utf-8', ); - expect(skill).toContain( - 'Do not add sticky notes (`sticky(...)` / `n8n-nodes-base.stickyNote`) unless', - ); + expect(skill).toContain('unsolicited `sticky()`'); + expect(skill).toContain('workflow-sdk validate'); expect(skill).not.toMatch(/import \{\n(?:[^\n]*\n)*?\s*sticky,/); - expect(skill).toMatch( - /opt-in only when the user explicitly\s+asks for a sticky note on the canvas/, - ); }); it('loads the bundled data-table-manager skill and its linked files', async () => { @@ -263,6 +259,7 @@ describe('Instance AI runtime skills', () => { 'read_file', 'write_file', 'edit_file', + 'execute_command', 'build-workflow', 'workflows', 'nodes', @@ -273,6 +270,7 @@ describe('Instance AI runtime skills', () => { ]); expect(skill?.description).toContain('Load before calling build-workflow'); expect(skill?.description).toContain('Default path for all single-workflow work'); + expect(skill?.description).toContain('workflow-sdk validate'); expect(skill?.description).toContain('load data-table-manager first'); expect(skill?.description).toContain('Do not load planning or create-tasks first'); @@ -280,6 +278,10 @@ describe('Instance AI runtime skills', () => { expect(loaded?.instructions).toContain('## Routing'); expect(loaded?.instructions).toContain('build-workflow'); expect(loaded?.instructions).toContain('filePath'); + expect(loaded?.instructions).toContain('workspace_write_file'); + expect(loaded?.instructions).toContain( + 'node --import tsx node_modules/@n8n/workflow-sdk/dist/cli/index.js validate', + ); expect(loaded?.instructions).toContain('workspace source file'); expect(loaded?.instructions).toContain('nodes(action="suggested")'); expect(loaded?.instructions).toContain('nodes(action="search")'); @@ -317,8 +319,10 @@ describe('Instance AI runtime skills', () => { expect(loaded?.instructions).toContain('`planning` or call `create-tasks` first'); expect(loaded?.instructions).toContain('.to(isImportant)'); expect(loaded?.instructions).toContain('.onTrue(handleImportant)'); - expect(loaded?.instructions).toContain('Never call `.onFalse()` more than once'); - expect(loaded?.instructions).toContain('branch nodes are omitted from the saved graph'); + expect(loaded?.instructions).toContain( + 'Do NOT wire branches as standalone statements after `export default`', + ); + expect(loaded?.instructions).toContain('never reaches the builder'); }); it('loads the bundled planning skill', async () => { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts index 0a8fb7c109f..575a57a5a49 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts @@ -27,7 +27,7 @@ vi.mock('../../../tracing/langsmith-tracing', async () => { }); vi.mock('../workflow-validation-warnings', () => ({ - partitionWarnings: vi.fn((warnings: unknown[]) => ({ errors: [], informational: warnings })), + partitionWarnings: vi.fn((warnings: unknown[]) => ({ blocking: [], informational: warnings })), })); const generatedWorkflow = { @@ -200,7 +200,7 @@ describe('createBuildWorkflowTool', () => { compiler: 'sandbox-tsx', }); vi.mocked(partitionWarnings).mockImplementation((warnings: ValidationWarning[]) => ({ - errors: [], + blocking: [], informational: warnings, })); vi.mocked(analyzeWorkflow).mockResolvedValue([]); @@ -1087,7 +1087,7 @@ describe('createBuildWorkflowTool', () => { compiler: 'sandbox-tsx', }); vi.mocked(partitionWarnings).mockReturnValueOnce({ - errors: [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown config key "recipient"' }], + blocking: [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown config key "recipient"' }], informational: [], }); @@ -1117,7 +1117,7 @@ describe('createBuildWorkflowTool', () => { compiler: 'sandbox-tsx' as const, }; const partitionedWarnings = { - errors: [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown config key "recipient"' }], + blocking: [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown config key "recipient"' }], informational: [], }; vi.mocked(compileWorkflowSource) @@ -1153,7 +1153,7 @@ describe('createBuildWorkflowTool', () => { compiler: 'sandbox-tsx' as const, }; const partitionedWarnings = { - errors: validationResult.warnings, + blocking: validationResult.warnings, informational: [], }; vi.mocked(compileWorkflowSource) diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts index cf5525525c7..267e549ce5e 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-compiler.test.ts @@ -187,7 +187,14 @@ describe('compileWorkflowSource', () => { }); vi.mocked(validateWorkflow).mockReturnValue({ valid: false, - errors: [{ code: 'INVALID_PARAMETER', message: 'Bad parameter', nodeName: 'Manual Trigger' }], + errors: [ + { + code: 'INVALID_PARAMETER', + message: 'Bad parameter', + nodeName: 'Manual Trigger', + severity: 'error', + }, + ], warnings: [], }); const context = makeContext(); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-validation-warnings.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-validation-warnings.test.ts index c7df00f507a..7dcc4046f34 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-validation-warnings.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-validation-warnings.test.ts @@ -1,16 +1,29 @@ import { partitionWarnings, type ValidationWarning } from '../workflow-validation-warnings'; describe('partitionWarnings', () => { - it('keeps graph reachability warnings informational and treats other issues as blocking', () => { + it('keeps informational severity soft and treats other issues as blocking', () => { const warnings: ValidationWarning[] = [ - { code: 'MISSING_TRIGGER', message: 'No trigger' }, - { code: 'DISCONNECTED_NODE', message: 'Node is disconnected' }, - { code: 'INVALID_PARAMETER', message: 'Bad parameter', nodeName: 'HTTP Request' }, + { code: 'MISSING_TRIGGER', message: 'No trigger', severity: 'informational' }, + { code: 'DISCONNECTED_NODE', message: 'Node is disconnected', severity: 'informational' }, + { + code: 'INVALID_PARAMETER', + message: 'Bad parameter', + nodeName: 'HTTP Request', + severity: 'warning', + }, ]; expect(partitionWarnings(warnings)).toEqual({ informational: warnings.slice(0, 2), - errors: [warnings[2]], + blocking: [warnings[2]], + }); + }); + + it('treats missing severity as blocking', () => { + const warnings: ValidationWarning[] = [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown key' }]; + expect(partitionWarnings(warnings)).toEqual({ + informational: [], + blocking: warnings, }); }); }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index 56e967e2a4c..62fba59e9de 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -112,7 +112,7 @@ export const buildWorkflowInputSchema = z .string() .optional() .describe( - 'Full source to write to filePath before building — use this instead of a separate workspace_write_file call when creating or fully rewriting the source. Omit to build the existing file content (preferred for targeted edits made with file tools).', + 'Full source to write to filePath before building — use this instead of a separate workspace_write_file call when creating or fully rewriting the source. Omit to build the existing file content (preferred for targeted edits made with file tools, and required before `workflow-sdk validate`).', ), workflowId: z .string() @@ -286,8 +286,8 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { 'Load `workflow-builder` via `load_skill` before calling this tool. ' + 'When the workflow creates or writes Data Tables, also load `data-table-manager` first. ' + 'Use TypeScript SDK source for new workflows, or WorkflowJSON .json source for existing workflow edits. ' + - 'For new or fully rewritten source, pass it in `sourceCode` (the tool writes filePath and builds in one call — ' + - 'do not spend a separate workspace_write_file call). Pass filePath alone only after editing an existing file with file tools.', + 'Prefer writing the file with `workspace_write_file` / `workspace_str_replace_file` so `workflow-sdk validate` can run on it, then call this tool with filePath. ' + + 'For a one-shot create/rewrite you may pass `sourceCode` instead (the tool writes filePath and builds).', ) .input(buildWorkflowInputSchema) .output( @@ -498,7 +498,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { const remediation = createCodeFixableRemediation({ reason: 'workflow_source_read_failed', guidance: - 'The workflow source file could not be read. Recreate or edit the returned filePath, then call build-workflow again with the same filePath.', + 'The workflow source file could not be read. Write it with `workspace_write_file`, then call build-workflow again with the same filePath.', }); trackWorkflowSourceBuild(context, { result: 'failure', @@ -588,6 +588,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { { code: 'auto_imported_sdk_symbols', message: `Auto-added missing @n8n/workflow-sdk import(s): ${recovery.symbols.join(', ')}. Include them in future source.`, + severity: 'informational', }, ], } @@ -641,9 +642,9 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { const partitionedWarnings = partitionWarnings(compiled.warnings); informational = partitionedWarnings.informational; - if (partitionedWarnings.errors.length > 0) { + if (partitionedWarnings.blocking.length > 0) { const formattedErrors = withEscalation( - partitionedWarnings.errors.map( + partitionedWarnings.blocking.map( (e) => `[${e.code}]${e.nodeName ? ` (${e.nodeName})` : ''}: ${e.message}`, ), ); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts index f99028e487c..11787285de1 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts @@ -136,6 +136,12 @@ function parseSandboxWarnings(value: unknown): ValidationWarning[] { code: warning.code, message: warning.message, nodeName: typeof warning.nodeName === 'string' ? warning.nodeName : undefined, + severity: + warning.severity === 'informational' || + warning.severity === 'warning' || + warning.severity === 'error' + ? warning.severity + : undefined, }); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-validation-warnings.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-validation-warnings.ts index 13a3c3b5e9d..3cc62228228 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-validation-warnings.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-validation-warnings.ts @@ -1,11 +1,20 @@ +import { partitionValidationIssues, type IssueSeverity } from '@n8n/workflow-sdk'; + export interface ValidationWarning { code: string; message: string; nodeName?: string; + /** Set at the creation site; `informational` never blocks save. */ + severity?: IssueSeverity; } export function collectValidationIssues( - issues: Array<{ code: string; message: string; nodeName?: string }>, + issues: Array<{ + code: string; + message: string; + nodeName?: string; + severity?: IssueSeverity; + }>, allWarnings: ValidationWarning[], ): void { for (const issue of issues) { @@ -13,31 +22,17 @@ export function collectValidationIssues( code: issue.code, message: issue.message, nodeName: issue.nodeName, + severity: issue.severity, }); } } export function partitionWarnings(warnings: ValidationWarning[]): { - errors: ValidationWarning[]; + blocking: ValidationWarning[]; informational: ValidationWarning[]; } { - // auto_imported_sdk_symbols marks a recovered build; it must not fail validation. - const informationalCodes = new Set([ - 'MISSING_TRIGGER', - 'DISCONNECTED_NODE', - 'auto_imported_sdk_symbols', - ]); - - const errors: ValidationWarning[] = []; - const informational: ValidationWarning[] = []; - - for (const warning of warnings) { - if (informationalCodes.has(warning.code)) { - informational.push(warning); - } else { - errors.push(warning); - } - } - - return { errors, informational }; + // Severity is set where each issue is created (SDK validators / lint / + // Instance AI host detectors). CLI validate and this save gate share + // {@link partitionValidationIssues}. + return partitionValidationIssues(warnings); } diff --git a/packages/@n8n/workflow-sdk/package.json b/packages/@n8n/workflow-sdk/package.json index 5fb08bee631..cf9704eb86d 100644 --- a/packages/@n8n/workflow-sdk/package.json +++ b/packages/@n8n/workflow-sdk/package.json @@ -58,7 +58,8 @@ "fetch-workflows": "npx tsx scripts/fetch-test-workflows.ts", "create-workflows-zip": "npx tsx scripts/create-workflows-zip.ts", "json-to-code": "npx tsx src/cli/index.ts json-to-code", - "code-to-json": "npx tsx src/cli/index.ts code-to-json" + "code-to-json": "npx tsx src/cli/index.ts code-to-json", + "validate": "npx tsx src/cli/index.ts validate" }, "main": "dist/index.js", "module": "src/index.ts", @@ -85,6 +86,9 @@ "files": [ "dist/**/*" ], + "bin": { + "workflow-sdk": "./dist/cli/index.js" + }, "devDependencies": { "@n8n/eslint-plugin-community-nodes": "workspace:*", "@n8n/typescript-config": "workspace:*", @@ -99,6 +103,7 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.4", + "@n8n/constants": "workspace:*", "@n8n/utils": "workspace:*", "acorn": "8.14.0", "lodash": "catalog:", diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts index e0c8b1c48f9..01778c6b9b1 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts @@ -40,6 +40,7 @@ export { BUILDER_BLOCKED_GLOBALS, SDK_INLINE_CONSTRAINTS, DANGEROUS_GLOBALS, + getSafeJSONMethod, isAllowedSDKFunction, isAllowedMethod, } from './validators'; diff --git a/packages/@n8n/workflow-sdk/src/cli/index.ts b/packages/@n8n/workflow-sdk/src/cli/index.ts index c395508eec6..040690b0303 100644 --- a/packages/@n8n/workflow-sdk/src/cli/index.ts +++ b/packages/@n8n/workflow-sdk/src/cli/index.ts @@ -1,22 +1,38 @@ #!/usr/bin/env node import { codeToJson } from './code-to-json'; import { jsonToCode } from './json-to-code'; +import { validateCommand } from './validate'; -const [command, filePath] = process.argv.slice(2); +const [command, ...rest] = process.argv.slice(2); -if (command === 'json-to-code') { - jsonToCode(filePath); -} else if (command === 'code-to-json') { - codeToJson(filePath); -} else { - console.error('Usage: workflow-sdk '); +async function main(): Promise { + if (command === 'json-to-code') { + jsonToCode(rest[0]); + return; + } + if (command === 'code-to-json') { + codeToJson(rest[0]); + return; + } + if (command === 'validate') { + await validateCommand(rest); + return; + } + + console.error('Usage: workflow-sdk [--json]'); console.error(''); console.error('Commands:'); console.error(' json-to-code Convert workflow JSON to SDK TypeScript code'); console.error(' code-to-json Convert SDK TypeScript code to workflow JSON'); + console.error(' validate Run graph + schema validation on an SDK TypeScript file'); console.error(''); console.error('Examples:'); console.error(' pnpm json-to-code ./workflow.json'); console.error(' pnpm code-to-json ./workflow.ts'); + console.error( + ' node --import tsx node_modules/@n8n/workflow-sdk/dist/cli/index.js validate src/workflow.ts', + ); process.exit(1); } + +void main(); diff --git a/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.test.ts b/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.test.ts new file mode 100644 index 00000000000..8ac783690cd --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { NODE_DEFINITION_DIRS_ENV_VAR, resolveNodeDefinitionDirs } from './node-definition-dirs'; + +let tmpRoot: string; + +/** A dir only counts as a node-definition dir if it holds a `nodes/` tree. */ +function makeDefinitionDir(name: string): string { + const dir = path.join(tmpRoot, name); + fs.mkdirSync(path.join(dir, 'nodes'), { recursive: true }); + return dir; +} + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-def-dirs-')); + delete process.env[NODE_DEFINITION_DIRS_ENV_VAR]; +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + delete process.env[NODE_DEFINITION_DIRS_ENV_VAR]; +}); + +describe('resolveNodeDefinitionDirs', () => { + it('accepts explicit dirs', () => { + const first = makeDefinitionDir('base'); + const second = makeDefinitionDir('langchain'); + + expect( + resolveNodeDefinitionDirs({ + explicit: [first, second], + workflowDir: tmpRoot, + }), + ).toEqual([first, second]); + }); + + it('drops paths that are missing or lack a nodes/ tree', () => { + const valid = makeDefinitionDir('base'); + const empty = path.join(tmpRoot, 'empty'); + fs.mkdirSync(empty); + + expect( + resolveNodeDefinitionDirs({ + explicit: [valid, empty, path.join(tmpRoot, 'does-not-exist')], + workflowDir: tmpRoot, + }), + ).toEqual([valid]); + }); + + it('reads the env var as a delimiter-separated list', () => { + const first = makeDefinitionDir('base'); + const second = makeDefinitionDir('langchain'); + process.env[NODE_DEFINITION_DIRS_ENV_VAR] = [first, second].join(path.delimiter); + + expect(resolveNodeDefinitionDirs({ workflowDir: tmpRoot })).toEqual([first, second]); + }); + + it('prefers explicit dirs over the env var', () => { + const fromFlag = makeDefinitionDir('flag'); + process.env[NODE_DEFINITION_DIRS_ENV_VAR] = makeDefinitionDir('env'); + + expect( + resolveNodeDefinitionDirs({ + explicit: [fromFlag], + workflowDir: tmpRoot, + }), + ).toEqual([fromFlag]); + }); + + it('returns no dirs when the builtin node packages are not resolvable', () => { + expect(resolveNodeDefinitionDirs({ workflowDir: tmpRoot, cwd: tmpRoot })).toEqual([]); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.ts b/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.ts new file mode 100644 index 00000000000..a991e121f8a --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/cli/node-definition-dirs.ts @@ -0,0 +1,88 @@ +/** + * Locate the generated node-definition directories that back Zod parameter + * validation. + * + * `validateNodeConfig` silently passes when no schema is found, so a CLI run + * without these dirs reports "no issues" for parameter problems the server + * would reject. Resolution mirrors the server (`/dist/node-definitions/` + * for each builtin nodes package). In the Instance AI sandbox those packages + * are not installed — graph + source lint still run; parameter schemas do not. + */ + +import { BUILTIN_NODES_PACKAGES } from '@n8n/constants'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +export const NODE_DEFINITION_DIRS_ENV_VAR = 'N8N_NODE_DEFINITION_DIRS'; + +/** A node-definition dir always holds a `nodes/` tree; anything else is a mistyped path. */ +function looksLikeNodeDefinitionDir(dir: string): boolean { + return fs.existsSync(path.join(dir, 'nodes')); +} + +function splitEnvValue(value: string): string[] { + return value + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function keepExisting(candidates: string[]): string[] { + const dirs: string[] = []; + for (const candidate of candidates) { + const resolved = path.resolve(candidate); + if (!dirs.includes(resolved) && looksLikeNodeDefinitionDir(resolved)) { + dirs.push(resolved); + } + } + return dirs; +} + +/** + * Resolve `/dist/node-definitions` for each builtin nodes package, trying + * each start directory in turn. `createRequire` is anchored to a file path so + * resolution follows the workflow file's own `node_modules` chain first. + */ +function resolveFromPackages(startDirs: string[]): string[] { + const dirs: string[] = []; + for (const startDir of startDirs) { + const requireFrom = createRequire(path.join(startDir, 'noop.js')); + for (const packageId of BUILTIN_NODES_PACKAGES) { + let packageJsonPath: string; + try { + packageJsonPath = requireFrom.resolve(`${packageId}/package.json`); + } catch { + continue; + } + const candidate = path.join(path.dirname(packageJsonPath), 'dist', 'node-definitions'); + if (!dirs.includes(candidate) && looksLikeNodeDefinitionDir(candidate)) { + dirs.push(candidate); + } + } + } + return dirs; +} + +/** + * Precedence: explicit `--node-types` flags, then the env var, then package + * resolution from the workflow file's directory and the cwd. + */ +export function resolveNodeDefinitionDirs(options: { + explicit?: string[]; + workflowDir: string; + cwd?: string; +}): string[] { + const { explicit = [], workflowDir, cwd = process.cwd() } = options; + + if (explicit.length > 0) { + return keepExisting(explicit); + } + + const envValue = process.env[NODE_DEFINITION_DIRS_ENV_VAR]; + if (envValue) { + return keepExisting(splitEnvValue(envValue)); + } + + return resolveFromPackages([workflowDir, cwd]); +} diff --git a/packages/@n8n/workflow-sdk/src/cli/validate.ts b/packages/@n8n/workflow-sdk/src/cli/validate.ts new file mode 100644 index 00000000000..a7639a55ab4 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/cli/validate.ts @@ -0,0 +1,291 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { resolveNodeDefinitionDirs, NODE_DEFINITION_DIRS_ENV_VAR } from './node-definition-dirs'; +import type { WorkflowJSON } from '../types/base'; +import { + buildUncheckedNotes, + validateWorkflowBuilder, + type CollectedValidationIssue, + type ValidationResult, +} from '../validation'; + +export interface ValidateCliOptions { + json?: boolean; + nodeTypes?: string[]; +} + +interface WorkflowBuilderLike { + validate: () => ValidationResult; + toJSON: (options?: { tidyUp?: boolean }) => WorkflowJSON; +} + +/** Blocking issues report as `error`, informational ones as `warning`. */ +interface ReportEntry { + line?: number; + column?: number; + severity: 'error' | 'warning'; + code: string; + message: string; +} + +function buildTrailer(unchecked: string[]): string { + return `note: errors block a build-workflow save, warnings do not — but warnings flag likely run-time defects, so resolve each one instead of shipping past it. Not checked here: ${unchecked.join('; ')}.`; +} + +function usageAndExit(): never { + console.error('Usage: workflow-sdk validate [--json] [--node-types ]'); + console.error(''); + console.error('Load a workflow SDK TypeScript file via dynamic import, run graph'); + console.error('validators (wf.validate), schema validateWorkflow, and source lint.'); + console.error('Exit non-zero only for issues that would block a build-workflow save.'); + console.error(''); + console.error('Node parameter validation needs generated node definitions. They are'); + console.error('resolved from the workflow file and cwd; override with --node-types'); + console.error(`(repeatable) or ${NODE_DEFINITION_DIRS_ENV_VAR} (${path.delimiter}-separated).`); + process.exit(1); +} + +function parseArgs(argv: string[]): { filePath: string; options: ValidateCliOptions } { + let filePath: string | undefined; + let json = false; + const nodeTypes: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--json') { + json = true; + } else if (arg.startsWith('--node-types=')) { + nodeTypes.push(arg.slice('--node-types='.length)); + } else if (arg === '--node-types') { + const value = argv[++i]; + if (!value) { + console.error('--node-types requires a directory'); + usageAndExit(); + } + nodeTypes.push(value); + } else if (arg.startsWith('-')) { + console.error(`Unknown option: ${arg}`); + usageAndExit(); + } else if (!filePath) { + filePath = arg; + } else { + console.error(`Unexpected argument: ${arg}`); + usageAndExit(); + } + } + + if (!filePath) { + usageAndExit(); + } + + return { filePath, options: { json, nodeTypes } }; +} + +function toReportEntry( + issue: CollectedValidationIssue, + severity: ReportEntry['severity'], +): ReportEntry { + // Most validator messages already name the node; only prefix when they don't. + const message = + issue.nodeName && !issue.message.includes(issue.nodeName) + ? `${issue.nodeName}: ${issue.message}` + : issue.message; + return { + line: issue.line, + column: issue.column, + severity, + code: issue.code, + message, + }; +} + +function byLocation(a: ReportEntry, b: ReportEntry): number { + const lineDiff = (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER); + if (lineDiff !== 0) return lineDiff; + return (a.column ?? Number.MAX_SAFE_INTEGER) - (b.column ?? Number.MAX_SAFE_INTEGER); +} + +function pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +/** ESLint-stylish location: `line:column`, or `-` when unknown. */ +function formatLocation(entry: Pick): string { + if (entry.line === undefined) return '-'; + if (entry.column === undefined) return String(entry.line); + return `${entry.line}:${entry.column}`; +} + +/** ESLint-stylish layout: one file header, then `line:col severity code message` rows. */ +function formatReport(file: string, entries: ReportEntry[], unchecked: string[]): string { + if (entries.length === 0) { + return [file, ' no issues found', '', buildTrailer(unchecked)].join('\n'); + } + + const sorted = [...entries].sort(byLocation); + const locations = sorted.map(formatLocation); + const locationWidth = Math.max(...locations.map((location) => location.length)); + const severityWidth = Math.max(...sorted.map((entry) => entry.severity.length)); + const codeWidth = Math.max(...sorted.map((entry) => entry.code.length)); + + const rows = sorted.map((entry, index) => { + const location = locations[index].padStart(locationWidth); + const severity = entry.severity.padEnd(severityWidth); + const code = entry.code.padEnd(codeWidth); + return ` ${location} ${severity} ${code} ${entry.message}`; + }); + + const errorCount = sorted.filter((entry) => entry.severity === 'error').length; + const warningCount = sorted.length - errorCount; + const summary = `${pluralize(sorted.length, 'problem')} (${pluralize(errorCount, 'error')}, ${pluralize(warningCount, 'warning')})`; + + return [file, ...rows, '', summary, buildTrailer(unchecked)].join('\n'); +} + +function formatText( + file: string, + blocking: CollectedValidationIssue[], + informational: CollectedValidationIssue[], + unchecked: string[], +): string { + return formatReport( + file, + [ + ...blocking.map((issue) => toReportEntry(issue, 'error')), + ...informational.map((issue) => toReportEntry(issue, 'warning')), + ], + unchecked, + ); +} + +function failAndExit( + options: ValidateCliOptions, + unchecked: string[], + code: string, + message: string, + file: string, +): never { + if (options.json) { + console.log( + JSON.stringify({ + ok: false, + file, + blocking: [{ code, message }], + informational: [], + unchecked, + }), + ); + } else { + console.error(formatReport(file, [{ severity: 'error', code, message }], unchecked)); + } + process.exit(1); +} + +function isWorkflowBuilder(value: unknown): value is WorkflowBuilderLike { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as { validate?: unknown; toJSON?: unknown }; + return typeof candidate.validate === 'function' && typeof candidate.toJSON === 'function'; +} + +/** + * Resolve `export default workflow(...)` across CJS/ESM interop shapes. + * + * The validate CLI is published as CommonJS. When it dynamically imports a + * TypeScript workflow via tsx in a package without `"type": "module"` (the + * Instance AI sandbox), Node can surface the builder as `mod.default.default` + * instead of `mod.default`. build.mjs is ESM and usually gets the unwrapped + * shape, which is why validate was failing while build-workflow still worked. + */ +function resolveWorkflowExport(mod: { default?: unknown }): unknown { + const exported = mod.default; + if (isWorkflowBuilder(exported)) { + return exported; + } + if (typeof exported === 'object' && exported !== null && 'default' in exported) { + const nested = (exported as { default: unknown }).default; + if (isWorkflowBuilder(nested)) { + return nested; + } + } + return exported; +} + +/** + * Validate a workflow SDK TypeScript source file. + * + * Thin CLI wrapper around {@link validateWorkflowBuilder} with `lint: true`. + */ +export async function validateCommand(argv: string[] = process.argv.slice(3)): Promise { + const { filePath, options } = parseArgs(argv); + const absolutePath = path.resolve(filePath); + const displayPath = path.relative(process.cwd(), absolutePath) || absolutePath; + const importUrl = pathToFileURL(absolutePath).href; + + const nodeDefinitionDirs = resolveNodeDefinitionDirs({ + explicit: options.nodeTypes, + workflowDir: path.dirname(absolutePath), + }); + + let source = ''; + try { + source = fs.readFileSync(absolutePath, 'utf8'); + } catch { + // Load failure below reports a clearer error; lint simply skips. + } + + const earlyUnchecked = buildUncheckedNotes({ + schemasLoaded: nodeDefinitionDirs.length > 0, + hasNodeTypesProvider: false, + }); + + let mod: { default?: unknown }; + try { + mod = (await import(importUrl)) as { default?: unknown }; + } catch (error) { + failAndExit( + options, + earlyUnchecked, + 'LOAD_FAILED', + `Failed to load workflow: ${error instanceof Error ? error.message : String(error)}`, + displayPath, + ); + } + + const workflowExport = resolveWorkflowExport(mod); + if (!isWorkflowBuilder(workflowExport)) { + failAndExit( + options, + earlyUnchecked, + 'INVALID_DEFAULT_EXPORT', + 'Default export is not a workflow. Make sure your file has: export default workflow(...)', + displayPath, + ); + } + + const result = validateWorkflowBuilder(workflowExport, { + lint: true, + source, + nodeDefinitionDirs, + }); + + if (options.json) { + console.log( + JSON.stringify({ + ok: result.ok, + file: displayPath, + blocking: result.blocking, + informational: result.informational, + unchecked: result.unchecked, + nodeDefinitionDirs: result.nodeDefinitionDirs, + }), + ); + } else { + console.log(formatText(displayPath, result.blocking, result.informational, result.unchecked)); + } + + process.exit(result.ok ? 0 : 1); +} diff --git a/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts b/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts index a00654b0907..c61e950e2e5 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts @@ -12,7 +12,7 @@ import { } from '../__tests__/fixtures-download'; import type { WorkflowJSON } from '../types/base'; import { foldLegacyErrorConnections, normalizeConnections } from '../types/base'; -import { validateWorkflow } from '../validation'; +import { validateWorkflow, validateWorkflowBuilder } from '../validation'; import { escapeNewlinesInExpressionStrings, isPlaceholderValue, @@ -28,6 +28,11 @@ interface ExpectedError { nodeName?: string; } +interface ExpectedLintIssue { + code: string; + nodeName?: string; +} + interface TestWorkflow { id: string; name: string; @@ -39,6 +44,11 @@ interface TestWorkflow { expectedValidationWarnings?: ExpectedWarning[]; /** Errors expected from WorkflowBuilder.validate() (plugin validator pipeline). */ expectedBuilderErrors?: ExpectedError[]; + /** + * When present (including `[]`), codegen source is linted via + * `validateWorkflowBuilder({ lint: true })` and must match exactly. + */ + expectedLintIssues?: ExpectedLintIssue[]; } function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { @@ -59,6 +69,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { expectedErrors?: ExpectedError[]; expectedValidationWarnings?: ExpectedWarning[]; expectedBuilderErrors?: ExpectedError[]; + expectedLintIssues?: ExpectedLintIssue[]; }>; }; @@ -78,6 +89,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void { expectedErrors: entry.expectedErrors, expectedValidationWarnings: entry.expectedValidationWarnings, expectedBuilderErrors: entry.expectedBuilderErrors, + expectedLintIssues: entry.expectedLintIssues, }); } } @@ -2910,6 +2922,44 @@ describe('Committed workflows — builder validator errors', () => { } }); +describe('Committed workflows — source lint via validateWorkflowBuilder', () => { + const normalizeLint = (issue: ExpectedLintIssue): string => + `${issue.code}:${issue.nodeName ?? ''}`; + + const workflowsWithLintExpectations = workflows.filter((w) => w.expectedLintIssues !== undefined); + + if (workflowsWithLintExpectations.length === 0) { + it('has at least one fixture with expectedLintIssues declared', () => { + expect(workflowsWithLintExpectations.length).toBeGreaterThan(0); + }); + } else { + workflowsWithLintExpectations.forEach(({ id, name, json, expectedLintIssues }) => { + it(`emits expected lint issues for workflow ${id}: "${name}"`, () => { + const code = generateWorkflowCode(json); + const builder = parseWorkflowCodeToBuilder(code); + const result = validateWorkflowBuilder(builder, { + lint: true, + source: code, + allowDisconnectedNodes: true, + }); + + const actual: ExpectedLintIssue[] = result.lint + .map((issue) => ({ code: issue.code, nodeName: issue.nodeName })) + .sort((a, b) => normalizeLint(a).localeCompare(normalizeLint(b))); + + const expected = (expectedLintIssues ?? []) + .slice() + .sort((a, b) => normalizeLint(a).localeCompare(normalizeLint(b))); + + expect(actual).toEqual(expected); + // Lint findings are informational — they must not block ok. + expect(result.ok).toBe(true); + expect(result.lint.every((issue) => issue.severity === 'informational')).toBe(true); + }); + }); + } +}); + describe('Committed workflows — schema validation errors', () => { // Mirror the relevant builderHint.inputs / builderHint.outputs declarations from the // real node types so validateWorkflow can resolve required AI inputs and emit diff --git a/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts b/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts index fb03f78cd67..6369e100816 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts @@ -254,6 +254,9 @@ describe('emit-instance-ai', () => { 'validateWorkflow', 'getSchemaBaseDirs', 'setSchemaBaseDirs', + 'isInformationalIssue', + 'partitionValidationIssues', + 'validateWorkflowBuilder', // Pin-data + schema discovery 'discoverOutputSchemaForNode', 'discoverSchemasForNode', diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts index 26a2d26555c..885ffdd0b73 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts @@ -14,7 +14,7 @@ import { mergeDisplayOptions, extractDefaultsForDisplayOptions, } from './generate-zod-schemas'; -import * as schemaHelpers from '../validation/schema-helpers'; +import * as schemaHelpers from '../validation/node-parameter-schema/schema-helpers'; describe('mapPropertyToZodSchema for resourceLocator', () => { it('returns resourceLocatorValueSchema when no modes are specified', () => { diff --git a/packages/@n8n/workflow-sdk/src/index.ts b/packages/@n8n/workflow-sdk/src/index.ts index b2229e4e84b..e189144890a 100644 --- a/packages/@n8n/workflow-sdk/src/index.ts +++ b/packages/@n8n/workflow-sdk/src/index.ts @@ -159,6 +159,13 @@ export { type ValidationErrorCode, validateNodeConfig, type SchemaValidationResult, + type IssueSeverity, + isInformationalIssue, + partitionValidationIssues, + validateWorkflowBuilder, + type ValidateWorkflowBuilderOptions, + type ValidateWorkflowBuilderResult, + type CollectedValidationIssue, } from './validation'; // Code generation diff --git a/packages/@n8n/workflow-sdk/src/lint/ast-walk.ts b/packages/@n8n/workflow-sdk/src/lint/ast-walk.ts new file mode 100644 index 00000000000..02e6630eb91 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/ast-walk.ts @@ -0,0 +1,51 @@ +import type { Node } from 'estree'; + +import type { SourceLintIssue } from './types'; + +function isEstreeNode(value: unknown): value is Node { + return typeof value === 'object' && value !== null && 'type' in value; +} + +/** + * Depth-first AST walk. `visit` runs before children. When `skipChildren` + * returns true, descendants are not visited (the node itself still is). + */ +export function walkAst( + node: Node, + visit: (n: Node, parent: Node | undefined) => void, + options: { + parent?: Node; + skipChildren?: (n: Node, parent: Node | undefined) => boolean; + } = {}, +): void { + const { parent, skipChildren } = options; + visit(node, parent); + if (skipChildren?.(node, parent)) return; + + for (const key of Object.keys(node) as Array) { + if (key === 'loc' || key === 'range') continue; + const value = node[key]; + if (!value || typeof value !== 'object') continue; + if (Array.isArray(value)) { + for (const entry of value) { + if (isEstreeNode(entry)) { + walkAst(entry, visit, { parent: node, skipChildren }); + } + } + } else if (isEstreeNode(value)) { + walkAst(value, visit, { parent: node, skipChildren }); + } + } +} + +export function dedupeSourceLintIssues(issues: SourceLintIssue[]): SourceLintIssue[] { + const seen = new Set(); + const out: SourceLintIssue[] = []; + for (const issue of issues) { + const key = `${issue.lintTarget}|${issue.code}|${issue.line ?? ''}|${issue.column ?? ''}|${issue.message}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(issue); + } + return out; +} diff --git a/packages/@n8n/workflow-sdk/src/lint/code-node/code-node.test.ts b/packages/@n8n/workflow-sdk/src/lint/code-node/code-node.test.ts new file mode 100644 index 00000000000..7b222b4a33f --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/code-node/code-node.test.ts @@ -0,0 +1,67 @@ +import { hasNestedTemplateLiterals, lintJsCode } from './js'; +import { lintPythonCode } from './python'; + +describe('lintJsCode', () => { + it('flags fetch in jsCode', () => { + expect(lintJsCode('await fetch("https://example.com");').map((i) => i.code)).toEqual([ + 'CODE_NODE_NETWORK_CALL', + ]); + }); + + it('does not flag fetch mentioned only in a comment', () => { + expect( + lintJsCode('// await fetch("https://example.com");\nreturn [];').map((i) => i.code), + ).toEqual([]); + }); + + it('flags nested template literals via AST', () => { + expect(hasNestedTemplateLiterals('const x = `outer ${`inner`} `;')).toBe(true); + expect(hasNestedTemplateLiterals('const x = `plain`;')).toBe(false); + }); + + it.each(['first', 'last', 'all', 'itemMatching'] as const)( + 'flags $input.%s() in runOnceForEachItem mode', + (method) => { + const issues = lintJsCode(`return $input.${method}();`, { mode: 'runOnceForEachItem' }); + const misuse = issues.filter((i) => i.code === 'CODE_MODE_API_MISUSE'); + expect(misuse).toHaveLength(1); + expect(misuse[0].message).toContain(`$input.${method}()`); + }, + ); + + it('does not flag $input.item in runOnceForEachItem mode', () => { + expect( + lintJsCode('return $input.item.json;', { mode: 'runOnceForEachItem' }).map((i) => i.code), + ).toEqual([]); + }); + + it('does not flag $input.all() in runOnceForAllItems mode', () => { + expect( + lintJsCode('return $input.all();', { mode: 'runOnceForAllItems' }).map((i) => i.code), + ).toEqual([]); + }); +}); + +describe('lintPythonCode', () => { + it('flags requests imports in pythonCode', () => { + expect(lintPythonCode('import requests').map((i) => i.code)).toEqual([ + 'CODE_NODE_NETWORK_CALL', + ]); + }); + + it('does not flag a variable merely named requests', () => { + expect(lintPythonCode('requests = []\nreturn requests').map((i) => i.code)).toEqual([]); + }); + + it('flags import http.client in pythonCode', () => { + expect(lintPythonCode('import http.client').map((i) => i.code)).toEqual([ + 'CODE_NODE_NETWORK_CALL', + ]); + }); + + it('flags from http import client in pythonCode', () => { + expect(lintPythonCode('from http import client').map((i) => i.code)).toEqual([ + 'CODE_NODE_NETWORK_CALL', + ]); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.test.ts b/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.test.ts new file mode 100644 index 00000000000..e85f6788657 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.test.ts @@ -0,0 +1,51 @@ +import { extractEmbeddedCodeSnippetsFromSource } from './extract-snippets'; + +describe('extractEmbeddedCodeSnippetsFromSource', () => { + it('extracts jsCode from a Code node config', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + mode: 'runOnceForEachItem', + jsCode: 'return $input.all();', + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + const snippets = extractEmbeddedCodeSnippetsFromSource(source); + expect(snippets).toEqual([ + expect.objectContaining({ + parameter: 'jsCode', + code: 'return $input.all();', + mode: 'runOnceForEachItem', + }), + ]); + }); + + it('extracts pythonCode from a Code node config', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + language: 'pythonNative', + pythonCode: 'import requests', + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + expect(extractEmbeddedCodeSnippetsFromSource(source)).toEqual([ + expect.objectContaining({ + parameter: 'pythonCode', + code: 'import requests', + }), + ]); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.ts b/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.ts new file mode 100644 index 00000000000..1446defd6fc --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/code-node/extract-snippets.ts @@ -0,0 +1,129 @@ +import type { Node, ObjectExpression, Program, Property } from 'estree'; + +import { parseSDKCode } from '../../ast-interpreter'; +import { walkAst } from '../ast-walk'; + +export type CodeExecutionMode = 'runOnceForAllItems' | 'runOnceForEachItem'; + +export interface EmbeddedCodeSnippet { + parameter: 'jsCode' | 'pythonCode'; + code: string; + /** 1-based line of the property value in the prepared source. */ + line?: number; + /** 1-based column of the property value in the prepared source. */ + column?: number; + mode?: CodeExecutionMode; +} + +function locationOf(node: Node): { line?: number; column?: number } { + if (!node.loc) return {}; + return { line: node.loc.start.line, column: node.loc.start.column + 1 }; +} + +function propertyKeyName(key: Property['key']): string | undefined { + if (key.type === 'Identifier') return key.name; + if (key.type === 'Literal' && typeof key.value === 'string') return key.value; + return undefined; +} + +function stringFromNode(node: Node, source: string): string | undefined { + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join(''); + } + if ( + 'start' in node && + 'end' in node && + typeof node.start === 'number' && + typeof node.end === 'number' + ) { + const raw = source.slice(node.start, node.end); + if (node.type === 'TemplateLiteral') { + return raw.slice(1, raw.endsWith('`') ? -1 : undefined); + } + } + return undefined; +} + +function modeFromObject(obj: ObjectExpression): CodeExecutionMode | undefined { + for (const prop of obj.properties) { + if (prop.type !== 'Property' || prop.computed) continue; + if (propertyKeyName(prop.key) !== 'mode') continue; + if (prop.value.type === 'Literal' && typeof prop.value.value === 'string') { + if (prop.value.value === 'runOnceForEachItem') return 'runOnceForEachItem'; + if (prop.value.value === 'runOnceForAllItems') return 'runOnceForAllItems'; + } + } + return undefined; +} + +function enclosingObjectExpression( + node: Node, + parents: ReadonlyMap, +): ObjectExpression | undefined { + let current: Node | undefined = node; + while (current) { + if (current.type === 'ObjectExpression') return current; + current = parents.get(current); + } + return undefined; +} + +/** Whether SDK source lint should skip descending into this node. */ +export function isEmbeddedCodePropertyValue(node: Node, parent: Node | undefined): boolean { + if (parent?.type !== 'Property' || parent.computed) return false; + const key = propertyKeyName(parent.key); + if (key !== 'jsCode' && key !== 'pythonCode') return false; + return node === parent.value; +} + +export function buildParentMap(ast: Program): Map { + const parents = new Map(); + walkAst(ast, (node, parent) => { + if (parent) parents.set(node, parent); + }); + return parents; +} + +/** + * Extract jsCode / pythonCode string values from a parsed SDK workflow AST. + */ +export function extractEmbeddedCodeSnippets( + ast: Program, + source: string, + parents: ReadonlyMap = buildParentMap(ast), +): EmbeddedCodeSnippet[] { + const snippets: EmbeddedCodeSnippet[] = []; + + walkAst(ast, (node) => { + if (node.type !== 'Property' || node.computed) return; + const key = propertyKeyName(node.key); + if (key !== 'jsCode' && key !== 'pythonCode') return; + + const code = stringFromNode(node.value, source); + if (code === undefined || code.length === 0) return; + + const enclosing = enclosingObjectExpression(node, parents); + snippets.push({ + parameter: key, + code, + ...locationOf(node.value), + mode: enclosing ? modeFromObject(enclosing) : undefined, + }); + }); + + return snippets; +} + +/** Parse prepared SDK source and extract embedded Code node snippets. */ +export function extractEmbeddedCodeSnippetsFromSource(source: string): EmbeddedCodeSnippet[] { + let ast: Program; + try { + ast = parseSDKCode(source); + } catch { + return []; + } + return extractEmbeddedCodeSnippets(ast, source, buildParentMap(ast)); +} diff --git a/packages/@n8n/workflow-sdk/src/lint/code-node/js.ts b/packages/@n8n/workflow-sdk/src/lint/code-node/js.ts new file mode 100644 index 00000000000..4d08ee72681 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/code-node/js.ts @@ -0,0 +1,228 @@ +import * as acorn from 'acorn'; +import type { CallExpression, Node, Program } from 'estree'; + +import { walkAst } from '../ast-walk'; +import type { CodeExecutionMode } from './extract-snippets'; +import { lintIssue, type SourceLintIssue } from '../types'; + +const NETWORK_CALLEE_NAMES = new Set(['fetch', 'axios', 'XMLHttpRequest']); + +const HTTP_MODULES = new Set([ + 'http', + 'https', + 'http2', + 'node-fetch', + 'axios', + 'got', + 'undici', + 'node:http', + 'node:https', + 'node:http2', +]); + +const FORBIDDEN_MODULE_PREFIXES = ['luxon', 'openai', '@openai/', 'langchain', '@langchain/']; + +/** + * $input methods rejected at runtime in runOnceForEachItem mode — mirrors + * validateNoDisallowedMethodsInRunForEach in nodes-base/Code. + */ +const EACH_ITEM_DISALLOWED_INPUT_METHODS = new Set(['first', 'last', 'all', 'itemMatching']); + +export interface LintJsCodeOptions { + mode?: CodeExecutionMode; + nodeName?: string; +} + +function parseJs(code: string): Program | undefined { + const opts = { + ecmaVersion: 'latest' as const, + locations: true, + // Code-node bodies are function bodies; agents often start with `return …`. + allowReturnOutsideFunction: true, + }; + try { + return acorn.parse(code, { ...opts, sourceType: 'script' }) as unknown as Program; + } catch { + try { + return acorn.parse(code, { ...opts, sourceType: 'module' }) as unknown as Program; + } catch { + return undefined; + } + } +} + +function stringLiteralArg(node: Node | undefined): string | undefined { + if (!node) return undefined; + if (node.type === 'Literal' && typeof node.value === 'string') return node.value; + return undefined; +} + +function isRequireCall(call: CallExpression): boolean { + return call.callee.type === 'Identifier' && call.callee.name === 'require'; +} + +function moduleSpecifierFromCall(call: CallExpression): string | undefined { + if (isRequireCall(call)) { + return stringLiteralArg(call.arguments[0] as Node | undefined); + } + return undefined; +} + +function moduleIsForbidden(specifier: string): boolean { + return FORBIDDEN_MODULE_PREFIXES.some( + (prefix) => specifier === prefix || specifier.startsWith(prefix), + ); +} + +function hasNestedTemplateLiteral(ast: Program): boolean { + let found = false; + walkAst(ast, (node) => { + if (found || node.type !== 'TemplateLiteral') return; + for (const expr of node.expressions) { + let nested = false; + walkAst(expr, (inner) => { + if (inner.type === 'TemplateLiteral') nested = true; + }); + if (nested) { + found = true; + return; + } + } + }); + return found; +} + +/** + * Lint JavaScript written for a Code node (`jsCode` parameter). + * Uses acorn so comments/strings do not trigger false positives. + */ +export function lintJsCode(jsCode: string, options: LintJsCodeOptions = {}): SourceLintIssue[] { + if (jsCode.length === 0) return []; + + const ast = parseJs(jsCode); + if (!ast) return []; + + const issues: SourceLintIssue[] = []; + const namePrefix = options.nodeName ? `'${options.nodeName}' ` : ''; + let sawNetwork = false; + let sawForbiddenImport = false; + let disallowedInputMethod: string | undefined; + + walkAst(ast, (node) => { + if (node.type === 'ImportDeclaration') { + const source = node.source.type === 'Literal' ? String(node.source.value) : ''; + if (moduleIsForbidden(source)) sawForbiddenImport = true; + if (HTTP_MODULES.has(source)) sawNetwork = true; + return; + } + + if (node.type === 'ImportExpression') { + const mod = stringLiteralArg(node.source); + if (mod) { + if (HTTP_MODULES.has(mod)) sawNetwork = true; + if (moduleIsForbidden(mod)) sawForbiddenImport = true; + } + return; + } + + if (node.type === 'CallExpression') { + const call = node; + + if (call.callee.type === 'Identifier' && NETWORK_CALLEE_NAMES.has(call.callee.name)) { + sawNetwork = true; + } + if ( + call.callee.type === 'MemberExpression' && + !call.callee.computed && + call.callee.property.type === 'Identifier' && + EACH_ITEM_DISALLOWED_INPUT_METHODS.has(call.callee.property.name) && + call.callee.object.type === 'Identifier' && + call.callee.object.name === '$input' && + disallowedInputMethod === undefined + ) { + disallowedInputMethod = call.callee.property.name; + } + + const required = moduleSpecifierFromCall(call); + if (required) { + if (HTTP_MODULES.has(required)) sawNetwork = true; + if (moduleIsForbidden(required)) sawForbiddenImport = true; + } + } + + if ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'XMLHttpRequest' + ) { + sawNetwork = true; + } + }); + + if (sawNetwork) { + issues.push( + lintIssue({ + code: 'CODE_NODE_NETWORK_CALL', + message: + `${namePrefix}Code node calls fetch/axios/XMLHttpRequest or requires an HTTP module. ` + + 'Code nodes have no network access at runtime — make the HTTP/API call with an HTTP Request node ' + + 'and transform its output in the Code node instead.', + lintTarget: 'jsCode', + nodeName: options.nodeName, + parameterPath: 'jsCode', + }), + ); + } + + if (sawForbiddenImport) { + issues.push( + lintIssue({ + code: 'CODE_NODE_FORBIDDEN_IMPORT', + message: + `${namePrefix}Code node imports a module unavailable in the sandbox (luxon, openai, langchain, …). ` + + 'Use JavaScript `Date`/`Intl`, `$now`/`$today`, existing workflow data, or dedicated AI nodes instead.', + lintTarget: 'jsCode', + nodeName: options.nodeName, + parameterPath: 'jsCode', + }), + ); + } + + if (options.mode === 'runOnceForEachItem' && disallowedInputMethod !== undefined) { + issues.push( + lintIssue({ + code: 'CODE_MODE_API_MISUSE', + message: + `${namePrefix}uses mode: 'runOnceForEachItem' but calls $input.${disallowedInputMethod}(). ` + + `$input.${disallowedInputMethod}() is only available in runOnceForAllItems (the default). ` + + 'Switch mode to runOnceForAllItems, or use $input.item / $json for per-item work.', + lintTarget: 'jsCode', + nodeName: options.nodeName, + parameterPath: 'jsCode', + }), + ); + } + + if (hasNestedTemplateLiteral(ast)) { + issues.push( + lintIssue({ + code: 'CODE_NESTED_TEMPLATE_LITERAL', + message: + `${namePrefix}Code node uses nested template literals, which often break after save. ` + + 'Build multi-line strings with arrays joined by a runtime separator, e.g. ' + + '`const LF = String.fromCharCode(10); return lines.join(LF);`.', + lintTarget: 'jsCode', + nodeName: options.nodeName, + parameterPath: 'jsCode', + }), + ); + } + + return issues; +} + +/** @deprecated Use lintJsCode — nested templates are detected via AST there. */ +export function hasNestedTemplateLiterals(jsCode: string): boolean { + const ast = parseJs(jsCode); + return ast ? hasNestedTemplateLiteral(ast) : false; +} diff --git a/packages/@n8n/workflow-sdk/src/lint/code-node/python.ts b/packages/@n8n/workflow-sdk/src/lint/code-node/python.ts new file mode 100644 index 00000000000..b62433d2eb5 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/code-node/python.ts @@ -0,0 +1,42 @@ +import { lintIssue, type SourceLintIssue } from '../types'; + +/** Match real import/from lines — not identifiers that merely contain "requests". */ +const PYTHON_NETWORK_IMPORT = + /(?:^|\n)\s*(?:import|from)\s+(?:requests|urllib(?:\.[\w.]+)?|httpx|aiohttp|http\.client)\b/m; + +/** `from http import client` is equivalent to `import http.client`. */ +const PYTHON_NETWORK_FROM_IMPORT = /(?:^|\n)\s*from\s+http\s+import\s+client\b/m; + +export interface LintPythonCodeOptions { + nodeName?: string; +} + +/** + * Lint Python written for a Code node (`pythonCode` parameter). + */ +export function lintPythonCode( + pythonCode: string, + options: LintPythonCodeOptions = {}, +): SourceLintIssue[] { + if (pythonCode.length === 0) return []; + + const issues: SourceLintIssue[] = []; + const namePrefix = options.nodeName ? `'${options.nodeName}' ` : ''; + + if (PYTHON_NETWORK_IMPORT.test(pythonCode) || PYTHON_NETWORK_FROM_IMPORT.test(pythonCode)) { + issues.push( + lintIssue({ + code: 'CODE_NODE_NETWORK_CALL', + message: + `${namePrefix}Code node uses requests/urllib/httpx or another HTTP library. ` + + 'Code nodes have no network access at runtime — make the HTTP/API call with an HTTP Request node ' + + 'and process its output in this node instead.', + lintTarget: 'pythonCode', + nodeName: options.nodeName, + parameterPath: 'pythonCode', + }), + ); + } + + return issues; +} diff --git a/packages/@n8n/workflow-sdk/src/lint/index.ts b/packages/@n8n/workflow-sdk/src/lint/index.ts new file mode 100644 index 00000000000..be671c39dda --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/index.ts @@ -0,0 +1,26 @@ +/** + * Source lint for workflow SDK TypeScript files. + * + * Public entry: `lintWorkflowSource`. Prefer importing from this barrel + * (`../lint`) rather than deep paths. + */ + +export type { LintTarget, SourceLintIssue } from './types'; +export { lintIssue } from './types'; +export { walkAst, dedupeSourceLintIssues } from './ast-walk'; +export { lintWorkflowSource } from './lint-workflow-source'; +export { + prepareSourceForLint, + lintWorkflowSdkSource, + lintWorkflowSdkAst, +} from './sdk/workflow-sdk-lint'; +export { lintJsCode, hasNestedTemplateLiterals } from './code-node/js'; +export { lintPythonCode } from './code-node/python'; +export { + extractEmbeddedCodeSnippets, + extractEmbeddedCodeSnippetsFromSource, + isEmbeddedCodePropertyValue, + buildParentMap, + type CodeExecutionMode, + type EmbeddedCodeSnippet, +} from './code-node/extract-snippets'; diff --git a/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.test.ts b/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.test.ts new file mode 100644 index 00000000000..4493ae98234 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.test.ts @@ -0,0 +1,61 @@ +import { lintWorkflowSource } from './lint-workflow-source'; + +describe('lintWorkflowSource embedded code', () => { + it('lints jsCode separately from SDK rules', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + mode: 'runOnceForEachItem', + jsCode: 'return $input.all();', + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + const issues = lintWorkflowSource(source); + expect(issues.some((i) => i.lintTarget === 'sdk' && i.message.includes("'.map()'"))).toBe( + false, + ); + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + lintTarget: 'jsCode', + code: 'CODE_MODE_API_MISUSE', + }), + ]), + ); + }); + + it('lints pythonCode separately', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + language: 'pythonNative', + pythonCode: 'import requests\\nrequests.get("https://example.com")', + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + const issues = lintWorkflowSource(source); + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + lintTarget: 'pythonCode', + code: 'CODE_NODE_NETWORK_CALL', + }), + ]), + ); + expect(issues.every((i) => i.lintTarget !== 'sdk' || !i.message.includes('requests'))).toBe( + true, + ); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.ts b/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.ts new file mode 100644 index 00000000000..dd4609b10e5 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/lint-workflow-source.ts @@ -0,0 +1,60 @@ +import { parseSDKCode } from '../ast-interpreter'; +import { dedupeSourceLintIssues } from './ast-walk'; +import { buildParentMap, extractEmbeddedCodeSnippets } from './code-node/extract-snippets'; +import { lintJsCode } from './code-node/js'; +import { lintPythonCode } from './code-node/python'; +import { lintWorkflowSdkAst, prepareSourceForLint } from './sdk/workflow-sdk-lint'; +import { lintIssue, type SourceLintIssue } from './types'; + +/** + * Run SDK, embedded JavaScript, and embedded Python linters on a workflow source file. + * Parses the prepared source once and shares the AST across passes. + */ +export function lintWorkflowSource(source: string): SourceLintIssue[] { + const { code, asConstMatches } = prepareSourceForLint(source); + + let ast; + try { + ast = parseSDKCode(code); + } catch { + // Still surface `as const` findings when the file does not parse. + return dedupeSourceLintIssues( + asConstMatches.map((match) => + lintIssue({ + code: 'SDK_AS_CONST', + message: + '`as const` is TypeScript-only and the workflow parser cannot interpret it. Remove the assertion.', + line: match.line, + column: match.column + 1, + lintTarget: 'sdk' as const, + }), + ), + ); + } + + const sdkIssues = lintWorkflowSdkAst(ast, asConstMatches); + const parents = buildParentMap(ast); + const snippets = extractEmbeddedCodeSnippets(ast, code, parents); + + const embeddedIssues: SourceLintIssue[] = []; + for (const snippet of snippets) { + const base = { line: snippet.line, column: snippet.column }; + if (snippet.parameter === 'jsCode') { + embeddedIssues.push( + ...lintJsCode(snippet.code, { mode: snippet.mode }).map((issue) => ({ + ...issue, + ...base, + })), + ); + } else { + embeddedIssues.push( + ...lintPythonCode(snippet.code).map((issue) => ({ + ...issue, + ...base, + })), + ); + } + } + + return dedupeSourceLintIssues([...sdkIssues, ...embeddedIssues]); +} diff --git a/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.test.ts b/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.test.ts new file mode 100644 index 00000000000..577487982bb --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.test.ts @@ -0,0 +1,218 @@ +import { lintWorkflowSdkSource, prepareSourceForLint } from './workflow-sdk-lint'; + +describe('lintWorkflowSdkSource', () => { + it('flags statements after export default', () => { + const source = ` +import { workflow, node, trigger, ifElse } from '@n8n/workflow-sdk'; +const start = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } }); +const branch = ifElse({ version: 2.2, config: { name: 'Check', parameters: {} } }); +const yes = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'Yes' } }); +export default workflow('id', 'name').add(start).to(branch); +branch.onTrue(yes); +`; + const codes = lintWorkflowSdkSource(source).map((i) => i.code); + expect(codes).toContain('SDK_CODE_AFTER_EXPORT_DEFAULT'); + expect(lintWorkflowSdkSource(source).every((i) => i.lintTarget === 'sdk')).toBe(true); + }); + + it('flags repeated onFalse overwrites on the same IF identifier', () => { + const issues = lintWorkflowSdkSource(` +const start = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } }); +const a = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'A' } }); +const b = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'B' } }); +const c = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'C' } }); +const branch = ifElse({ version: 2.2, config: { name: 'Check', parameters: {} } }); +branch.onFalse(b); +branch.onFalse(c); +export default workflow('id', 'name').add(start).to(branch).onTrue(a); +`); + expect(issues.map((i) => i.code)).toContain('SDK_REPEATED_BRANCH_WIRING'); + }); + + it('does not flag fluent onTrue/onFalse across different IF nodes on the workflow chain', () => { + const source = ` +const start = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } }); +const a = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'A' } }); +const b = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'B' } }); +const c = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'C' } }); +const d = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'D' } }); +const if1 = ifElse({ version: 2.2, config: { name: 'Check1', parameters: {} } }); +const if2 = ifElse({ version: 2.2, config: { name: 'Check2', parameters: {} } }); +export default workflow('id', 'name') + .add(start) + .to(if1) + .onTrue(a) + .onFalse(b) + .to(if2) + .onTrue(c) + .onFalse(d); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).not.toContain( + 'SDK_REPEATED_BRANCH_WIRING', + ); + }); + + it('flags as const', () => { + const source = ` +const mode = 'list' as const; +export default workflow('id', 'name').add(start); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).toContain('SDK_AS_CONST'); + }); + + it('does not flag as const inside jsCode template literals', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + jsCode: \` +// cast the value as const before returning +return $input.all(); +\`.trim(), + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).not.toContain('SDK_AS_CONST'); + }); + + it('does not flag as const inside string literals', () => { + const source = ` +const note = 'avoid as const in workflow files'; +export default workflow('id', 'name').add(start); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).not.toContain('SDK_AS_CONST'); + }); + + it('flags placeholder wrapped in expr', () => { + const source = ` +const n = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.3, + config: { name: 'Fetch', parameters: { url: expr(placeholder('API URL')) } }, +}); +export default workflow('id', 'name').add(n); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).toContain('SDK_PLACEHOLDER_WRAPPED'); + }); + + it('flags sticky() calls', () => { + const source = ` +const note = sticky('## Notes'); +const start = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } }); +export default workflow('id', 'name').add(start).add(note); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).toContain('SDK_UNSOLICITED_STICKY'); + }); + + it('flags .map() in builder code', () => { + const source = ` +const names = ['a', 'b'].map((x) => x); +export default workflow('id', 'name').add(start); +`; + const codes = lintWorkflowSdkSource(source).map((i) => i.code); + expect(codes).toContain('SDK_FORBIDDEN_CONSTRUCT'); + }); + + it('does not flag .map inside jsCode template literals', () => { + const source = ` +const transform = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Transform', + parameters: { + jsCode: \` +return $input.all().map(item => ({ json: item.json })); +\`.trim(), + }, + }, +}); +export default workflow('id', 'name').add(transform); +`; + const sdkMapIssues = lintWorkflowSdkSource(source).filter((i) => + i.message.includes("'.map()'"), + ); + expect(sdkMapIssues).toHaveLength(0); + }); + + it('does not flag .map inside expr string literals', () => { + const source = ` +const n = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { + name: 'Set', + parameters: { values: { string: [{ name: 'x', value: expr('={{ $json.items.map(i => i) }}') }] } }, + }, +}); +export default workflow('id', 'name').add(n); +`; + const mapIssues = lintWorkflowSdkSource(source).filter((i) => i.message.includes("'.map()'")); + expect(mapIssues).toHaveLength(0); + }); + + it('does not flag JSON.stringify in builder code', () => { + const source = ` +const payload = JSON.stringify({ a: 1 }); +export default workflow('id', 'name').add(start); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).not.toContain( + 'SDK_FORBIDDEN_CONSTRUCT', + ); + }); + + it('still flags raw JSON identifier access', () => { + const source = ` +const payload = JSON; +export default workflow('id', 'name').add(start); +`; + const issue = lintWorkflowSdkSource(source).find((i) => i.code === 'SDK_FORBIDDEN_CONSTRUCT'); + expect(issue).toMatchObject({ line: 2, column: 17 }); + }); + + it('reports 1-based column for as const', () => { + const source = "const mode = 'list' as const;\nexport default workflow('id', 'name');\n"; + const issue = lintWorkflowSdkSource(source).find((i) => i.code === 'SDK_AS_CONST'); + // prepareSourceForLint records 0-based column 20; issues expose 1-based 21. + expect(issue).toMatchObject({ line: 1, column: 21 }); + }); + + it('still flags JSON.parse', () => { + const source = ` +const payload = JSON.parse('{"x":42}'); +export default workflow('id', 'name').add(start); +`; + expect(lintWorkflowSdkSource(source).map((i) => i.code)).toContain('SDK_FORBIDDEN_CONSTRUCT'); + }); +}); + +describe('prepareSourceForLint', () => { + it('strips imports while preserving line numbers for later statements', () => { + const source = `import { + workflow, + node, +} from '@n8n/workflow-sdk'; +const mode = 'list' as const; +export default workflow('id', 'name'); +`; + const prepared = prepareSourceForLint(source); + expect(prepared.code.includes('import')).toBe(false); + expect(prepared.asConstMatches).toEqual([{ line: 5, column: 20 }]); + const asConstLine = prepared.code.split(/\r?\n/)[4]; + expect(asConstLine).toContain("const mode = 'list'"); + }); + + it('does not mangle ternaries when stripping type annotations', () => { + const source = ` +const value = flag ? 'a' : 'b'; +export default workflow('id', 'name'); +`; + const prepared = prepareSourceForLint(source); + expect(prepared.code).toContain("flag ? 'a' : 'b'"); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.ts b/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.ts new file mode 100644 index 00000000000..cf5f5436514 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/sdk/workflow-sdk-lint.ts @@ -0,0 +1,394 @@ +/** + * Source-level lint for workflow SDK TypeScript (builder code only). + * + * Does not inspect jsCode / pythonCode embedded in Code node configs — those + * are linted separately by code-node/js and code-node/python. + */ + +import type { CallExpression, MemberExpression, Node, Program } from 'estree'; + +import { + FORBIDDEN_NODE_TYPES, + DANGEROUS_GLOBALS, + getSafeJSONMethod, + parseSDKCode, +} from '../../ast-interpreter'; +import { dedupeSourceLintIssues, walkAst } from '../ast-walk'; +import { isEmbeddedCodePropertyValue } from '../code-node/extract-snippets'; +import { lintIssue, type SourceLintIssue } from '../types'; + +const NATIVE_ARRAY_METHODS = new Set([ + 'map', + 'join', + 'filter', + 'reduce', + 'forEach', + 'flatMap', + 'find', + 'some', + 'every', +]); + +const SDK_FLUENT_METHODS = new Set([ + 'to', + 'add', + 'onTrue', + 'onFalse', + 'onCase', + 'onDone', + 'onEachBatch', + 'onError', + 'input', + 'output', + 'settings', + 'update', + 'then', + 'group', +]); + +/** An `as const` occurrence in prepared source (1-based line, 0-based column). */ +export interface AsConstMatch { + line: number; + column: number; +} + +/** + * Strip imports and common TS-only syntax so acorn can parse agent source. + * Replacements preserve line count (and roughly column positions) so AST + * `loc` values still match the original file. + * + * `as const` matches are collected after the strips above run, so their + * coordinates share the AST's coordinate space — this lets callers tell a + * real assertion apart from text inside a string/template (jsCode, sticky). + */ +export function prepareSourceForLint(source: string): { + code: string; + asConstMatches: AsConstMatch[]; +} { + let code = source; + const blankSameLines = (match: string): string => '\n'.repeat((match.match(/\n/g) ?? []).length); + const spaces = (match: string): string => ' '.repeat(match.length); + + code = code.replace(/^\s*import\s[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, blankSameLines); + code = code.replace(/^\s*import\s+type\s[\s\S]*?;?\s*$/gm, blankSameLines); + // Narrower than a blanket `: Type` strip — avoids mangling ternaries (`a ? b : c`). + // These two are the only non-length-preserving strips; everything below them + // shares coordinates with the collected matches and the parsed AST. + code = code.replace( + /\b((?:const|let|var)\s+[A-Za-z_$][\w$]*)\s*:\s*[A-Za-z_$][\w$.|<>[\]\s,&?]*(?=\s*=)/g, + '$1', + ); + code = code.replace( + /([,(]\s*[A-Za-z_$][\w$]*)\s*:\s*[A-Za-z_$][\w$.|<>[\]\s,&?]*(?=\s*[,)=])/g, + '$1', + ); + + const asConstMatches: AsConstMatch[] = []; + const asConstPattern = /\bas\s+const\b/g; + const lines = code.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + asConstPattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = asConstPattern.exec(lines[i] ?? '')) !== null) { + asConstMatches.push({ line: i + 1, column: match.index }); + } + } + + code = code.replace(/\bas\s+const\b/g, spaces); + code = code.replace(/\bas\s+[A-Za-z_$][\w$.<>,\s|&[\]?]*/g, spaces); + code = code.replace(/\bsatisfies\s+[A-Za-z_$][\w$.|<>[\]\s,&?]*/g, spaces); + + return { code, asConstMatches }; +} + +/** 1-based line/column from an ESTree loc (Acorn columns are 0-based). */ +function locationOf(node: Node): { line?: number; column?: number } { + if (!node.loc) return {}; + return { line: node.loc.start.line, column: node.loc.start.column + 1 }; +} + +function isPlaceholderCall(node: CallExpression): boolean { + return node.callee.type === 'Identifier' && node.callee.name === 'placeholder'; +} + +function isExprCall(node: CallExpression): boolean { + return node.callee.type === 'Identifier' && node.callee.name === 'expr'; +} + +/** + * Direct receiver of a method call when it is a simple identifier + * (`branch.onTrue(...)`). Fluent chains on `workflow()` are ignored — those + * do not overwrite a previous branch target on the same IF node. + */ +function directReceiverName(member: MemberExpression): string | undefined { + if (member.object.type === 'Identifier') { + return member.object.name; + } + return undefined; +} + +interface SourceRange { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +/** + * Ranges covered by string literals and template literals. An `as const` + * match inside one is string content (jsCode snippets, sticky text), not the + * TS assertion SDK_AS_CONST targets. + */ +function stringContentRanges(ast: Program): SourceRange[] { + const ranges: SourceRange[] = []; + walkAst(ast, (node) => { + const isString = + node.type === 'TemplateLiteral' || + (node.type === 'Literal' && typeof node.value === 'string'); + if (!isString || !node.loc) return; + ranges.push({ + startLine: node.loc.start.line, + startColumn: node.loc.start.column, + endLine: node.loc.end.line, + endColumn: node.loc.end.column, + }); + }); + return ranges; +} + +function rangeContains(range: SourceRange, line: number, column: number): boolean { + if (line < range.startLine || line > range.endLine) return false; + if (line === range.startLine && column < range.startColumn) return false; + if (line === range.endLine && column >= range.endColumn) return false; + return true; +} + +/** Lint a prepared, parsed SDK AST (imports/TS already stripped). */ +export function lintWorkflowSdkAst( + ast: Program, + asConstMatches: AsConstMatch[] = [], +): SourceLintIssue[] { + const issues: SourceLintIssue[] = []; + + const stringRanges = stringContentRanges(ast); + for (const match of asConstMatches) { + if (stringRanges.some((range) => rangeContains(range, match.line, match.column))) continue; + issues.push( + lintIssue({ + code: 'SDK_AS_CONST', + message: + '`as const` is TypeScript-only and the workflow parser cannot interpret it. Remove the assertion.', + line: match.line, + column: match.column + 1, + lintTarget: 'sdk', + }), + ); + } + + const exportIndex = ast.body.findIndex((stmt) => stmt.type === 'ExportDefaultDeclaration'); + if (exportIndex >= 0) { + for (let i = exportIndex + 1; i < ast.body.length; i++) { + const stmt = ast.body[i]; + if (!stmt || stmt.type === 'EmptyStatement') continue; + issues.push( + lintIssue({ + code: 'SDK_CODE_AFTER_EXPORT_DEFAULT', + message: + 'Statement after `export default workflow(...)` never reaches the builder — nodes/wiring here are dropped. ' + + 'Compose all `.to()` / `.onTrue()` / `.onFalse()` / `.onCase()` inside the export default chain.', + ...locationOf(stmt), + lintTarget: 'sdk', + }), + ); + } + } + + const branchCounts = new Map(); + + walkAst( + ast, + (node, parent) => { + if (node.type === 'ImportDeclaration') return; + + const forbidden = FORBIDDEN_NODE_TYPES[node.type]; + if (forbidden) { + issues.push( + lintIssue({ + code: 'SDK_FORBIDDEN_CONSTRUCT', + message: forbidden, + ...locationOf(node), + lintTarget: 'sdk', + }), + ); + } + + if (node.type === 'Identifier' && DANGEROUS_GLOBALS.has(node.name)) { + const isPropertyName = + parent?.type === 'MemberExpression' && parent.property === node && !parent.computed; + const isObjectKey = parent?.type === 'Property' && parent.key === node; + // Mirrors the interpreter: safe global methods (e.g. JSON.stringify) are allowed. + const isSafeMethodObject = + parent?.type === 'MemberExpression' && + parent.object === node && + !parent.computed && + parent.property.type === 'Identifier' && + getSafeJSONMethod(node.name, parent.property.name) !== undefined; + if (!isPropertyName && !isObjectKey && !isSafeMethodObject) { + issues.push( + lintIssue({ + code: 'SDK_FORBIDDEN_CONSTRUCT', + message: `Global '${node.name}' is unavailable in SDK builder code. Move runtime logic to a Code node or expr().`, + ...locationOf(node), + lintTarget: 'sdk', + }), + ); + } + } + + if (node.type !== 'CallExpression') return; + const call = node; + + if (call.callee.type === 'Identifier' && call.callee.name === 'sticky') { + issues.push( + lintIssue({ + code: 'SDK_UNSOLICITED_STICKY', + message: + 'Do not add sticky() / stickyNote nodes unless the user explicitly asked for canvas notes. ' + + 'Put explanations in the chat reply instead.', + ...locationOf(call), + lintTarget: 'sdk', + }), + ); + } + + if ( + call.callee.type === 'MemberExpression' && + !call.callee.computed && + call.callee.property.type === 'Identifier' + ) { + const method = call.callee.property.name; + + if (method === 'onTrue' || method === 'onFalse') { + // Only count direct `ifNode.onTrue(...)` / `ifNode.onFalse(...)`. + // Fluent `workflow().to(if1).onTrue(...).to(if2).onTrue(...)` is fine. + const receiver = directReceiverName(call.callee); + if (receiver) { + const key = `${receiver}.${method}`; + const prev = branchCounts.get(key); + if (prev) { + prev.count += 1; + } else { + branchCounts.set(key, { count: 1, ...locationOf(call) }); + } + } + } + + if (!SDK_FLUENT_METHODS.has(method) && NATIVE_ARRAY_METHODS.has(method)) { + issues.push( + lintIssue({ + code: 'SDK_FORBIDDEN_CONSTRUCT', + message: + `'.${method}()' is not available on SDK builder objects. Build strings with template ` + + 'literals, or do transforms in a Code node / expr().', + ...locationOf(call), + lintTarget: 'sdk', + }), + ); + } + } + + if (isExprCall(call)) { + for (const arg of call.arguments) { + if (arg.type === 'SpreadElement') continue; + if (arg.type === 'CallExpression' && isPlaceholderCall(arg)) { + issues.push( + lintIssue({ + code: 'SDK_PLACEHOLDER_WRAPPED', + message: + "Do not wrap placeholder() in expr(). Use placeholder('hint') directly as the parameter value.", + ...locationOf(call), + lintTarget: 'sdk', + }), + ); + } + if (arg.type === 'TemplateLiteral') { + for (const expr of arg.expressions) { + if (expr.type === 'CallExpression' && isPlaceholderCall(expr)) { + issues.push( + lintIssue({ + code: 'SDK_PLACEHOLDER_WRAPPED', + message: + 'Do not embed placeholder() inside expr()/template strings. Use placeholder() as the direct parameter value.', + ...locationOf(call), + lintTarget: 'sdk', + }), + ); + } + } + } + if (arg.type === 'ArrayExpression') { + for (const el of arg.elements) { + if (el && el.type === 'CallExpression' && isPlaceholderCall(el)) { + issues.push( + lintIssue({ + code: 'SDK_PLACEHOLDER_WRAPPED', + message: + 'Do not wrap placeholder() in an array unless the node definition expects an array and placeholder is a direct element value with no expr() wrapper.', + ...locationOf(call), + lintTarget: 'sdk', + }), + ); + } + } + } + } + } + }, + { skipChildren: isEmbeddedCodePropertyValue }, + ); + + for (const [key, info] of branchCounts) { + if (info.count < 2) continue; + const [, method] = key.split('.'); + issues.push( + lintIssue({ + code: 'SDK_REPEATED_BRANCH_WIRING', + message: + `Repeated \`.${method}()\` (${info.count} times) — each call overwrites the previous target. ` + + 'Wire once on the workflow builder chain.', + line: info.line, + column: info.column, + lintTarget: 'sdk', + }), + ); + } + + return dedupeSourceLintIssues(issues); +} + +/** + * Lint workflow SDK builder source. Skips jsCode / pythonCode property values. + */ +export function lintWorkflowSdkSource(source: string): SourceLintIssue[] { + const { code, asConstMatches } = prepareSourceForLint(source); + + let ast: Program; + try { + ast = parseSDKCode(code); + } catch { + return dedupeSourceLintIssues( + asConstMatches.map((match) => + lintIssue({ + code: 'SDK_AS_CONST', + message: + '`as const` is TypeScript-only and the workflow parser cannot interpret it. Remove the assertion.', + line: match.line, + column: match.column + 1, + lintTarget: 'sdk' as const, + }), + ), + ); + } + + return lintWorkflowSdkAst(ast, asConstMatches); +} diff --git a/packages/@n8n/workflow-sdk/src/lint/types.ts b/packages/@n8n/workflow-sdk/src/lint/types.ts new file mode 100644 index 00000000000..c2052838471 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/lint/types.ts @@ -0,0 +1,28 @@ +import type { IssueSeverity } from '../validation/issue-severity'; + +export type LintTarget = 'sdk' | 'jsCode' | 'pythonCode'; + +/** + * Source-lint finding. Severity is set at the rule site — today all lint + * rules are informational (do not block save / CLI exit). + */ +export interface SourceLintIssue { + code: string; + message: string; + severity: Extract; + /** 1-based line in the workflow source file, when resolvable. */ + line?: number; + /** 1-based column in the workflow source file, when resolvable. */ + column?: number; + lintTarget: LintTarget; + /** Set when the issue comes from an embedded Code node snippet in SDK source. */ + nodeName?: string; + parameterPath?: 'jsCode' | 'pythonCode'; +} + +/** Build a source-lint issue with informational severity at the rule site. */ +export function lintIssue( + issue: Omit & { severity?: 'informational' }, +): SourceLintIssue { + return { severity: 'informational', ...issue }; +} diff --git a/packages/@n8n/workflow-sdk/src/validation.ts b/packages/@n8n/workflow-sdk/src/validation.ts index 6f8f43411eb..7aafc0aae40 100644 --- a/packages/@n8n/workflow-sdk/src/validation.ts +++ b/packages/@n8n/workflow-sdk/src/validation.ts @@ -8,6 +8,17 @@ export { type ValidationResult, type ValidationOptions, type ValidationErrorCode, + type IssueSeverity, + isInformationalIssue, + partitionValidationIssues, + validateWorkflowBuilder, + buildUncheckedNotes, + type ValidateWorkflowBuilderOptions, + type ValidateWorkflowBuilderResult, + type CollectedValidationIssue, } from './validation/index'; -export { validateNodeConfig, type SchemaValidationResult } from './validation/schema-validator'; +export { + validateNodeConfig, + type SchemaValidationResult, +} from './validation/node-parameter-schema/schema-validator'; diff --git a/packages/@n8n/workflow-sdk/src/validation/index.ts b/packages/@n8n/workflow-sdk/src/validation/index.ts index 421388783ea..5a3939d9922 100644 --- a/packages/@n8n/workflow-sdk/src/validation/index.ts +++ b/packages/@n8n/workflow-sdk/src/validation/index.ts @@ -1,1314 +1,47 @@ -import { isRecord } from '@n8n/utils/is-record'; -import get from 'lodash/get'; -import type { INodeTypes, IConnections as N8nIConnections, IDisplayOptions } from 'n8n-workflow'; -import { mapConnectionsByDestination } from 'n8n-workflow'; +/** + * Workflow validation public API. + * + * Prefer importing from this barrel (`../validation` or `@n8n/workflow-sdk`) + * rather than deep paths. + */ -import { matchesDisplayOptions } from './display-options'; -import type { DisplayOptions, DisplayOptionsContext } from './display-options'; -import { resolveMainInputCount } from './input-resolver'; -import { resolveMainOutputCount } from './output-resolver'; -import { validateNodeConfig } from './schema-validator'; -import { isStickyNoteType, isHttpRequestType } from '../constants/node-types'; -import type { WorkflowBuilder, WorkflowJSON } from '../types/base'; -import { containsPlaceholderMarker } from '../workflow-builder/string-utils'; +export { + validateWorkflow, + ValidationError, + ValidationWarning, + type ValidationResult, + type ValidationOptions, + type ValidationErrorCode, +} from './validate-workflow'; export { getSchemaBaseDirs, setSchemaBaseDirs, validateNodeConfig, type SchemaValidationResult, -} from './schema-validator'; - -/** - * Validation error codes - */ -export type ValidationErrorCode = - | 'NO_NODES' - | 'MISSING_TRIGGER' - | 'DISCONNECTED_NODE' - | 'MISSING_PARAMETER' - | 'INVALID_CONNECTION' - | 'CIRCULAR_REFERENCE' - | 'INVALID_EXPRESSION' - | 'AGENT_STATIC_PROMPT' - | 'AGENT_NO_SYSTEM_MESSAGE' - | 'HARDCODED_CREDENTIALS' - | 'SET_CREDENTIAL_FIELD' - | 'MERGE_SINGLE_INPUT' - | 'TOOL_NO_PARAMETERS' - | 'FROM_AI_IN_NON_TOOL' - | 'MISSING_EXPRESSION_PREFIX' - | 'INVALID_PARAMETER' - | 'INVALID_INPUT_INDEX' - | 'INVALID_OUTPUT_INDEX' - | 'SUBNODE_NOT_CONNECTED' - | 'SUBNODE_PARAMETER_MISMATCH' - | 'UNSUPPORTED_SUBNODE_INPUT' - | 'MISSING_REQUIRED_INPUT' - | 'INVALID_OUTPUT_FOR_MODE' - | 'SWITCH_NO_OUTPUT_CONNECTIONS' - | 'SWITCH_FALLBACK_OUTPUT_DISABLED' - | 'MAX_NODES_EXCEEDED' - | 'INVALID_EXPRESSION_PATH' - | 'PARTIAL_EXPRESSION_PATH' - | 'INVALID_DATE_METHOD' - | 'UNKNOWN_CONFIG_KEY'; - -/** - * Validation error class - */ -export class ValidationError { - readonly code: ValidationErrorCode; - readonly message: string; - readonly nodeName?: string; - readonly parameterName?: string; - /** Violation level for evaluation scoring (defaults to 'minor' if not set) */ - readonly violationLevel?: 'critical' | 'major' | 'minor'; - - constructor( - code: ValidationErrorCode, - message: string, - nodeName?: string, - parameterName?: string, - violationLevel?: 'critical' | 'major' | 'minor', - ) { - this.code = code; - this.message = message; - this.nodeName = nodeName; - this.parameterName = parameterName; - this.violationLevel = violationLevel; - } -} - -/** - * Validation warning class (non-fatal issues) - */ -export class ValidationWarning { - readonly code: ValidationErrorCode; - readonly message: string; - readonly nodeName?: string; - readonly parameterPath?: string; - readonly originalName?: string; - /** Violation level for evaluation scoring (defaults to 'minor' if not set) */ - readonly violationLevel?: 'critical' | 'major' | 'minor'; - - constructor( - code: ValidationErrorCode, - message: string, - nodeName?: string, - parameterPath?: string, - originalName?: string, - violationLevel?: 'critical' | 'major' | 'minor', - ) { - this.code = code; - this.message = message; - this.nodeName = nodeName; - this.parameterPath = parameterPath; - this.originalName = originalName; - this.violationLevel = violationLevel; - } -} - -/** - * Validation result - */ -export interface ValidationResult { - /** Whether the workflow is valid */ - valid: boolean; - /** Fatal errors that prevent the workflow from running */ - errors: ValidationError[]; - /** Warnings about potential issues */ - warnings: ValidationWarning[]; -} - -/** - * Validation options - */ -export interface ValidationOptions { - /** Enable strict mode with more warnings */ - strictMode?: boolean; - /** Skip disconnected node warnings */ - allowDisconnectedNodes?: boolean; - /** Skip trigger requirement */ - allowNoTrigger?: boolean; - /** Enable/disable Zod schema validation (default: true) */ - validateSchema?: boolean; - /** Optional node types provider for dynamic input index validation */ - nodeTypesProvider?: INodeTypes; -} - -/** - * Check if a node type is a trigger - */ -function isTriggerNode(type: string): boolean { - return ( - type.includes('Trigger') || - type.includes('trigger') || - type.includes('Webhook') || - type.includes('webhook') || - type.includes('Schedule') || - type.includes('schedule') || - type.includes('Poll') || - type.includes('poll') - ); -} - -/** - * AI connection types used by subnodes to connect to their parent nodes - */ -const AI_CONNECTION_TYPES = [ - 'ai_languageModel', - 'ai_memory', - 'ai_tool', - 'ai_outputParser', - 'ai_embedding', - 'ai_vectorStore', - 'ai_retriever', - 'ai_document', - 'ai_textSplitter', - 'ai_reranker', -]; - -/** - * Mapping from AI connection type to subnodes field name - */ -const AI_CONNECTION_TO_SUBNODE_FIELD: Record = { - ai_languageModel: 'model', - ai_memory: 'memory', - ai_tool: 'tools', - ai_outputParser: 'outputParser', - ai_embedding: 'embedding', - ai_vectorStore: 'vectorStore', - ai_retriever: 'retriever', - ai_document: 'documentLoader', - ai_textSplitter: 'textSplitter', - ai_reranker: 'reranker', -}; - -/** - * AI connection types that should always be arrays in subnodes - */ -const AI_ARRAY_TYPES = new Set(['ai_tool']); - -interface NodeJSON { - id?: string; - name?: string; - type: string; - typeVersion?: number | string; - position?: [number, number]; - parameters?: Record; - onError?: string; -} - -/** - * Reconstruct subnodes object from AI connections in the workflow. - * When SDK code defines subnodes, they get serialized as separate nodes with AI connections. - * This function reverses that transformation for validation purposes. - */ -function reconstructSubnodesFromConnections( - targetNodeName: string, - json: WorkflowJSON, -): Record | undefined { - const subnodes: Record = {}; - const nodesByName = new Map(); - - // Build a map of node name -> node for quick lookup - for (const node of json.nodes) { - if (node.name) { - nodesByName.set(node.name, node); - } - } - - // Scan all nodes' connections to find AI connections TO this target node - for (const [sourceNodeName, nodeConnections] of Object.entries(json.connections)) { - for (const connType of AI_CONNECTION_TYPES) { - const aiConns = nodeConnections[connType as keyof typeof nodeConnections]; - if (!aiConns || !Array.isArray(aiConns)) continue; - - for (const outputs of aiConns) { - if (!outputs) continue; - for (const conn of outputs) { - if (conn.node === targetNodeName) { - // Found an AI connection to our target node - const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connType]; - if (!subnodeField) continue; - - const sourceNode = nodesByName.get(sourceNodeName); - if (!sourceNode) continue; - - // Build a minimal subnode config for validation - const subnodeConfig = { - type: sourceNode.type, - version: sourceNode.typeVersion, - parameters: sourceNode.parameters ?? {}, - }; - - // For array types (like tools), collect into array - if (AI_ARRAY_TYPES.has(connType)) { - const existing = subnodes[subnodeField]; - if (Array.isArray(existing)) { - existing.push(subnodeConfig); - } else { - subnodes[subnodeField] = [subnodeConfig]; - } - } else { - // For single-value types, just set directly - subnodes[subnodeField] = subnodeConfig; - } - } - } - } - } - } - - // Return undefined if no subnodes were found - return Object.keys(subnodes).length > 0 ? subnodes : undefined; -} - -/** - * Check if a node has AI connections to a parent node (making it a connected subnode) - */ -function hasAiConnectionToParent(nodeName: string, json: WorkflowJSON): boolean { - const nodeConnections = json.connections[nodeName]; - if (!nodeConnections) return false; - - for (const connType of AI_CONNECTION_TYPES) { - const aiConns = nodeConnections[connType as keyof typeof nodeConnections]; - if (aiConns && Array.isArray(aiConns)) { - for (const outputs of aiConns) { - if (outputs && outputs.length > 0) { - return true; // Has AI connection to parent - } - } - } - } - return false; -} - -/** - * Check if a node is used as a tool (connected via ai_tool connection type) - */ -function isToolSubnode(nodeName: string, json: WorkflowJSON): boolean { - const nodeConnections = json.connections[nodeName]; - if (!nodeConnections) return false; - - const toolConns = nodeConnections.ai_tool as unknown as Array>; - if (toolConns && Array.isArray(toolConns)) { - for (const outputs of toolConns) { - if (outputs && outputs.length > 0) { - return true; // Connected as a tool - } - } - } - return false; -} - -/** - * Find disconnected nodes (nodes that don't receive input from any other node) - */ -function findDisconnectedNodes(json: WorkflowJSON): string[] { - const hasIncoming = new Set(); - - // Find all nodes that have incoming connections - for (const [_sourceName, nodeConnections] of Object.entries(json.connections)) { - if (nodeConnections.main) { - for (const outputs of nodeConnections.main) { - if (outputs) { - for (const connection of outputs) { - hasIncoming.add(connection.node); - } - } - } - } - } - - // Find nodes without incoming connections (excluding triggers, sticky notes, and connected subnodes) - const disconnected: string[] = []; - for (const node of json.nodes) { - // Skip nodes without names (e.g., some sticky notes) - if (!node.name) continue; - - // Skip if node has incoming connection - if (hasIncoming.has(node.name)) continue; - - // Skip trigger nodes - they don't need incoming connections - if (isTriggerNode(node.type)) continue; - - // Skip sticky notes - they don't participate in data flow - if (isStickyNoteType(node.type)) continue; - - // Skip subnodes - they connect TO their parent via AI connections - if (hasAiConnectionToParent(node.name, json)) continue; - - disconnected.push(node.name); - } - - return disconnected; -} - -/** - * Validate a workflow - * - * Checks for: - * - Presence of trigger node (warning if missing) - * - Disconnected nodes (warning) - * - Required parameters (in strict mode) - * - * @param workflow - The workflow to validate (WorkflowBuilder or WorkflowJSON) - * @param options - Validation options - * @returns Validation result with errors and warnings - * - * @example - * ```typescript - * const wf = workflow('id', 'Test').add(trigger(...)).to(node(...)); - * const result = validateWorkflow(wf); - * - * if (!result.valid) { - * console.error('Errors:', result.errors); - * } - * if (result.warnings.length > 0) { - * console.warn('Warnings:', result.warnings); - * } - * ``` - */ -export function validateWorkflow( - workflowOrJson: WorkflowBuilder | WorkflowJSON, - options: ValidationOptions = {}, -): ValidationResult { - // Get JSON representation - const json: WorkflowJSON = 'toJSON' in workflowOrJson ? workflowOrJson.toJSON() : workflowOrJson; - - const errors: ValidationError[] = []; - const warnings: ValidationWarning[] = []; - - // Check for trigger node - if (!options.allowNoTrigger) { - const hasTrigger = json.nodes.some((node) => isTriggerNode(node.type)); - if (!hasTrigger) { - warnings.push( - new ValidationWarning( - 'MISSING_TRIGGER', - 'Workflow has no trigger node. It will need to be started manually.', - ), - ); - } - } - - // Check for disconnected nodes - if (!options.allowDisconnectedNodes) { - const disconnected = findDisconnectedNodes(json); - for (const nodeName of disconnected) { - warnings.push( - new ValidationWarning( - 'DISCONNECTED_NODE', - `Node '${nodeName}' is not connected to any input. It will not receive data.`, - nodeName, - ), - ); - } - } - - // Strict mode validations - if (options.strictMode) { - // Check for potentially missing required parameters - for (const node of json.nodes) { - // HTTP Request should have a URL - if (isHttpRequestType(node.type)) { - if (!node.parameters?.url && !node.parameters?.requestUrl) { - warnings.push( - new ValidationWarning( - 'MISSING_PARAMETER', - `HTTP Request node '${node.name}' may be missing URL parameter`, - node.name, - ), - ); - } - } - } - } - - // Schema validation (enabled by default) - if (options.validateSchema !== false) { - for (const node of json.nodes) { - // Get version number (handle both number and string versions) - const version = - typeof node.typeVersion === 'string' - ? parseFloat(node.typeVersion) - : (node.typeVersion ?? 1); - - // Build config object for validation - const config: { parameters?: unknown; subnodes?: unknown } = {}; - if (node.parameters !== undefined) { - config.parameters = node.parameters; - } - // Include subnodes if present (for AI nodes) - const nodeWithSubnodes = node as typeof node & { subnodes?: unknown }; - if (nodeWithSubnodes.subnodes !== undefined) { - config.subnodes = nodeWithSubnodes.subnodes; - } else if (node.name) { - // Try to reconstruct subnodes from AI connections in the workflow - // This handles the case where subnodes were serialized as separate nodes - const reconstructed = reconstructSubnodesFromConnections(node.name, json); - if (reconstructed) { - config.subnodes = reconstructed; - } - } - - // Determine if this node is being used as a tool (for @tool displayOptions) - // A node is a tool if it's connected via ai_tool connection type - const isToolNode = node.name ? isToolSubnode(node.name, json) : false; - - const schemaResult = validateNodeConfig(node.type, version, config, { isToolNode }); - - if (!schemaResult.valid) { - for (const error of schemaResult.errors) { - let message = error.message; - - // Enhance subnode errors with valid options when nodeTypesProvider is available - if ( - error.path === 'subnodes' && - message.includes('Unknown field') && - options.nodeTypesProvider - ) { - const nodeType = options.nodeTypesProvider.getByNameAndVersion(node.type, version); - const validInputs = nodeType?.description?.builderHint?.inputs; - if (validInputs) { - const validSubnodes = Object.keys(validInputs) - .map((k) => AI_CONNECTION_TO_SUBNODE_FIELD[k]) - .filter(Boolean); - if (validSubnodes.length > 0) { - // Transform message from "Unknown field(s) at "subnodes": "x", "y"." - // to "Invalid subnode(s) "x", "y". This node only accepts: a, b." - message = message.replace( - /Unknown field\(s\) at "subnodes": (.+)\./, - `Invalid subnode(s) $1. This node only accepts: ${validSubnodes.join(', ')}.`, - ); - } - } - } - - // Report as WARNING (non-blocking) to maintain backwards compatibility - warnings.push( - new ValidationWarning( - 'INVALID_PARAMETER', - `Node "${node.name}": ${message}`, - node.name, - ), - ); - } - } - } - } - - // Input index validation (only if provider is given) - if (options.nodeTypesProvider) { - checkNodeInputIndices(json, options.nodeTypesProvider, warnings); - // Validate that connections originate from output ports that actually exist - checkNodeOutputIndices(json, options.nodeTypesProvider, warnings); - // Validate subnode parameters match parent's displayOptions requirements - validateSubnodeParameters(json, options.nodeTypesProvider, warnings); - // Validate parent nodes actually support their connected AI input types - validateParentSupportsInputs(json, options.nodeTypesProvider, warnings); - // Validate required AI inputs on parent nodes are actually connected - validateRequiredInputsConnected(json, options.nodeTypesProvider, errors); - // Validate that emitted connection types are actually exposed by the source node's mode - validateOutputUsage(json, options.nodeTypesProvider, warnings); - // Reject placeholder() in slots that opt out via builderHint.placeholderSupported === false - validatePlaceholderSlots(json, options.nodeTypesProvider, errors); - } - - // Switch fallback output validation does not need node metadata. It is derived from - // the Switch node's dynamic output contract in rules mode. - validateSwitchHasOutgoingConnections(json, warnings); - validateSwitchFallbackOutputConnections(json, warnings); - - // Merge node input-count consistency - checkMergeNodeInputCount(json, warnings); - - return { - valid: errors.length === 0, - errors, - warnings, - }; -} - -/** - * Validate that the Merge node's `numberInputs` parameter is consistent with - * the input indices actually used by incoming connections. - * - * The Merge node has expression-based inputs (count derived from - * `numberInputs`, default 2), so checkNodeInputIndices can't resolve the count - * statically. Without this check, a workflow with three branches wired into a - * Merge node that still has `numberInputs=2` passes validation and silently - * drops the third branch at runtime. - */ -function checkMergeNodeInputCount(json: WorkflowJSON, warnings: ValidationWarning[]): void { - const connectionsByDest = mapConnectionsByDestination( - json.connections as unknown as N8nIConnections, - ); - - for (const node of json.nodes) { - if (!node.name) continue; - if (node.type !== 'n8n-nodes-base.merge') continue; - - const numberInputsParam = node.parameters?.numberInputs; - const declaredInputs = typeof numberInputsParam === 'number' ? numberInputsParam : 2; - - const incomingMain = connectionsByDest[node.name]?.main; - if (!incomingMain) continue; - - let maxConnectedIndex = -1; - for (let i = 0; i < incomingMain.length; i++) { - const slot = incomingMain[i]; - if (Array.isArray(slot) && slot.length > 0) { - maxConnectedIndex = i; - } - } - - if (maxConnectedIndex >= declaredInputs) { - warnings.push( - new ValidationWarning( - 'INVALID_INPUT_INDEX', - `Merge node '${node.name}' has a connection to input index ${maxConnectedIndex} but 'numberInputs' is ${declaredInputs}. Set 'numberInputs' to ${maxConnectedIndex + 1} so every branch is accepted.`, - node.name, - 'numberInputs', - undefined, - 'major', - ), - ); - } - } -} - -/** - * Mapping from AI connection type to SDK function name (for error messages) - */ -const AI_CONNECTION_TO_SDK_FUNCTION: Record = { - ai_languageModel: 'languageModel()', - ai_memory: 'memory()', - ai_tool: 'tool()', - ai_outputParser: 'outputParser()', - ai_embedding: 'embeddings()', - ai_vectorStore: 'vectorStore()', - ai_retriever: 'retriever()', - ai_document: 'documentLoader()', - ai_textSplitter: 'textSplitter()', - ai_reranker: 'reranker()', -}; - -/** - * Check if a subnode's parameters satisfy displayOptions conditions - */ -function checkDisplayOptionsMatch( - subnodeParams: Record, - displayOptions: IDisplayOptions, -): { - matches: boolean; - mismatches: Array<{ param: string; expected: unknown[]; actual: unknown }>; -} { - const mismatches: Array<{ param: string; expected: unknown[]; actual: unknown }> = []; - - if (!displayOptions.show) return { matches: true, mismatches }; - - for (const [paramName, expectedValues] of Object.entries(displayOptions.show)) { - if (!expectedValues) continue; // Skip undefined values - const actualValue = subnodeParams[paramName]; - if (!expectedValues.includes(actualValue as never)) { - mismatches.push({ - param: paramName, - expected: expectedValues as unknown[], - actual: actualValue, - }); - } - } - - return { matches: mismatches.length === 0, mismatches }; -} - -/** - * Validate that subnodes connected to parent nodes have parameters - * matching the displayOptions conditions in builderHint.inputs. - * - * For example, if an Agent's ai_tool input has displayOptions.show = { mode: ['retrieve-as-tool'] }, - * then any node connected via ai_tool must have mode='retrieve-as-tool'. - */ -function validateSubnodeParameters( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - warnings: ValidationWarning[], -): void { - // Build a map of node name -> node for quick lookup - const nodesByName = new Map(); - for (const node of json.nodes) { - if (node.name) { - nodesByName.set(node.name, node); - } - } - - // Invert connections to find incoming connections by destination - // Cast to n8n-workflow IConnections since our local type has string for connection type - const connectionsByDest = mapConnectionsByDestination( - json.connections as unknown as N8nIConnections, - ); - - // Check each node that might be a parent with AI inputs - for (const parentNode of json.nodes) { - if (!parentNode.name) continue; - - // Try to get the node type to check for builderHint.inputs - const parentNodeType = nodeTypesProvider.getByNameAndVersion( - parentNode.type, - typeof parentNode.typeVersion === 'string' - ? parseFloat(parentNode.typeVersion) - : (parentNode.typeVersion ?? 1), - ); - const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; - if (!builderHintInputs) continue; - - // For each AI input type the parent accepts - for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { - if (!connectionType.startsWith('ai_')) continue; - if (!inputConfig?.displayOptions?.show) continue; - - // Find subnodes connected via this type - const incomingConnections = connectionsByDest[parentNode.name]?.[connectionType]; - if (!incomingConnections) continue; - - for (const connList of incomingConnections) { - if (!connList) continue; - for (const conn of connList) { - const subnodeName = conn.node; - const subnode = nodesByName.get(subnodeName); - if (!subnode?.parameters) continue; - - // Check if subnode params match displayOptions conditions - const { matches, mismatches } = checkDisplayOptionsMatch( - subnode.parameters, - inputConfig.displayOptions, - ); - - if (!matches) { - // `displayOptions` on `builderHint.inputs[type]` can describe - // either subnode-relative params (e.g. ai_vectorStore wants - // vector-store mode='retrieve-as-tool') or parent-relative - // params (e.g. ai_memory wants chatTrigger mode='hostedChat'). - // If every mismatched param is absent from the subnode, those - // params don't belong to the subnode at all — blaming it is a - // false-positive misdirect. Defer to validateParentSupportsInputs. - const subnodeOwnsAnyParam = mismatches.some((m) => m.actual !== undefined); - if (!subnodeOwnsAnyParam) continue; - - const sdkFn = AI_CONNECTION_TO_SDK_FUNCTION[connectionType] || connectionType; - - // Build error message with actual parameter names from displayOptions - const mismatchDetails = mismatches - .map( - (m) => - `${m.param}='${String(m.actual)}' (expected: ${m.expected.map((v) => `'${String(v)}'`).join(' or ')})`, - ) - .join(', '); - - warnings.push( - new ValidationWarning( - 'SUBNODE_PARAMETER_MISMATCH', - `'${subnodeName}' is connected to '${parentNode.name}' using ${sdkFn} but has ${mismatchDetails}. Update parameters to match the SDK function used.`, - subnodeName, - mismatches[0]?.param, - ), - ); - } - } - } - } - } -} - -/** - * Build a human-readable summary of which displayOptions conditions are not met. - */ -function buildConditionSummary( - displayOptions: IDisplayOptions, - parentParams: Record, -): string { - if (!displayOptions.show) return ''; - - const parts: string[] = []; - for (const [paramName, expectedValues] of Object.entries(displayOptions.show)) { - if (!expectedValues) continue; - // Use lodash get so nested paths (e.g. 'options.loadPreviousSession') - // resolve correctly — direct property access would read the literal - // dotted key and report 'undefined' even when the nested value is set. - const actual = get(parentParams, paramName); - const expectedStr = (expectedValues as unknown[]).map((v) => `'${String(v)}'`).join(' or '); - parts.push(`${paramName} should be ${expectedStr} (currently '${String(actual)}')`); - } - - return parts.length > 0 ? `Required: ${parts.join(', ')}.` : ''; -} - -/** - * Build a description of which parameters TRIGGERED a requirement. - * Used by `MISSING_REQUIRED_INPUT` where the displayOptions conditions are - * already satisfied (that's why the requirement applies) — the agent needs - * to know which params caused it so it can choose between satisfying the - * requirement or backing out by changing those params. - */ -function buildTriggeringConditionSummary( - displayOptions: IDisplayOptions, - parentParams: Record, -): string { - if (!displayOptions.show) return ''; - - const parts: string[] = []; - for (const [paramName, _expectedValues] of Object.entries(displayOptions.show)) { - const actual = get(parentParams, paramName); - parts.push(`${paramName}='${String(actual)}'`); - } - - return parts.join(', '); -} - -/** - * Validate that parent nodes actually support their connected AI input types - * based on the parent's own parameters and builderHint.inputs displayOptions. - * - * For example, if a vector store has mode='retrieve' but a documentLoader is connected, - * this produces a warning because ai_document requires mode='insert'. - */ -function validateParentSupportsInputs( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - warnings: ValidationWarning[], -): void { - const nodesByName = new Map(); - for (const node of json.nodes) { - if (node.name) { - nodesByName.set(node.name, node); - } - } - - const connectionsByDest = mapConnectionsByDestination( - json.connections as unknown as N8nIConnections, - ); - - for (const parentNode of json.nodes) { - if (!parentNode.name) continue; - - const version = - typeof parentNode.typeVersion === 'string' - ? parseFloat(parentNode.typeVersion) - : (parentNode.typeVersion ?? 1); - - const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version); - const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; - if (!builderHintInputs) continue; - - const parentContext: DisplayOptionsContext = { - parameters: (parentNode.parameters ?? {}) as Record, - nodeVersion: version, - rootParameters: (parentNode.parameters ?? {}) as Record, - }; - - for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { - if (!connectionType.startsWith('ai_')) continue; - if (!inputConfig?.displayOptions) continue; - - const parentSupportsInput = matchesDisplayOptions( - parentContext, - inputConfig.displayOptions as DisplayOptions, - ); - - if (parentSupportsInput) continue; - - const incomingConnections = connectionsByDest[parentNode.name]?.[connectionType]; - if (!incomingConnections) continue; - - for (const connList of incomingConnections) { - if (!connList) continue; - for (const conn of connList) { - const subnodeName = conn.node; - const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType; - const conditionDetails = buildConditionSummary( - inputConfig.displayOptions, - (parentNode.parameters ?? {}) as Record, - ); - - warnings.push( - new ValidationWarning( - 'UNSUPPORTED_SUBNODE_INPUT', - `'${parentNode.name}' has a ${subnodeField} subnode ('${subnodeName}') connected, but its current configuration does not accept one. ${conditionDetails} These parameters must be set on '${parentNode.name}' itself, NOT on the ${subnodeField} subnode. Alternatively, remove the ${subnodeField} connection if this capability isn't needed.`, - parentNode.name, - undefined, - undefined, - 'major', - ), - ); - } - } - } - } -} - -/** - * Validate that required AI inputs declared in a parent node's builderHint.inputs - * are actually connected. - * - * For each parent node with a builderHint.inputs entry that has `required: true`, - * check whether its displayOptions (if any) match the parent's current parameters; - * if so, require that a connection of that AI type terminates at the parent. - * Emits a fatal error when the connection is missing — without it, the workflow - * silently passes validation but breaks at runtime (see INS-136: chat trigger - * with `loadPreviousSession: 'memory'` but no memory subnode connected). - */ -function validateRequiredInputsConnected( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - errors: ValidationError[], -): void { - const connectionsByDest = mapConnectionsByDestination( - json.connections as unknown as N8nIConnections, - ); - - for (const parentNode of json.nodes) { - if (!parentNode.name) continue; - - const version = - typeof parentNode.typeVersion === 'string' - ? parseFloat(parentNode.typeVersion) - : (parentNode.typeVersion ?? 1); - - const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version); - const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; - if (!builderHintInputs) continue; - - const parentContext: DisplayOptionsContext = { - parameters: (parentNode.parameters ?? {}) as Record, - nodeVersion: version, - rootParameters: (parentNode.parameters ?? {}) as Record, - }; - - for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { - if (!connectionType.startsWith('ai_')) continue; - if (!inputConfig?.required) continue; - - if (inputConfig.displayOptions) { - const conditionsMet = matchesDisplayOptions( - parentContext, - inputConfig.displayOptions as DisplayOptions, - ); - if (!conditionsMet) continue; - } - - const incoming = connectionsByDest[parentNode.name]?.[connectionType]; - const hasConnection = - Array.isArray(incoming) && incoming.some((slot) => Array.isArray(slot) && slot.length > 0); - if (hasConnection) continue; - - const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType; - const triggerDetails = inputConfig.displayOptions - ? ` (triggered by ${buildTriggeringConditionSummary( - inputConfig.displayOptions, - (parentNode.parameters ?? {}) as Record, - )})` - : ''; - const alternative = inputConfig.displayOptions - ? ` Either connect a ${subnodeField} subnode, or change those parameters to remove the requirement.` - : ''; - - errors.push( - new ValidationError( - 'MISSING_REQUIRED_INPUT', - `'${parentNode.name}' requires a ${subnodeField} subnode connected to its ${connectionType} input${triggerDetails}, but none is connected.${alternative}`, - parentNode.name, - undefined, - 'major', - ), - ); - } - } -} - -/** - * Render an outgoing connection type as the SDK syntax that produces it, so warning - * messages speak the LLM agent's vocabulary instead of raw `main` / `ai_*` types. - * - * `target` flips the phrasing between describing the wiring already used by the source - * (e.g. `wired with .to()`) and describing where the source SHOULD attach instead - * (e.g. `subnodes.tools`). - */ -function describeOutputWiring(connectionType: string, target = false): string { - if (connectionType === 'main') return target ? '.to(...)' : 'wired with .to()'; - const field = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType]; - if (field) return target ? `subnodes.${field}` : `attached as subnodes.${field}`; - return target ? connectionType : `connected via ${connectionType}`; -} - -/** - * Return the connection type from `outputsHint` whose displayOptions match the source - * node's current parameters — i.e. the output the node actually exposes given how it's - * configured. Used to suggest the correct wiring fix in `INVALID_OUTPUT_FOR_MODE`. - */ -function findEnabledAlternativeOutput( - outputsHint: Record, - ctx: DisplayOptionsContext, - excludeType: string, -): string | undefined { - for (const [type, cfg] of Object.entries(outputsHint)) { - if (type === excludeType) continue; - if (!cfg) continue; - if (cfg.displayOptions && !matchesDisplayOptions(ctx, cfg.displayOptions as DisplayOptions)) { - continue; - } - return type; - } - return undefined; -} - -/** - * Validate that connections leaving a node use connection types the node's current - * parameters actually expose. Driven by `builderHint.outputs` declared on the source node. - * - * Example: a vector store in `mode: 'retrieve'` exposes only `ai_vectorStore`. If the workflow - * has a `main` connection out of that node, this emits `INVALID_OUTPUT_FOR_MODE` because - * `builderHint.outputs.main.displayOptions` requires `mode` ∈ ['insert', 'load', 'update']. - */ -function validateOutputUsage( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - warnings: ValidationWarning[], -): void { - for (const sourceNode of json.nodes) { - if (!sourceNode.name) continue; - - const outgoing = json.connections[sourceNode.name]; - if (!outgoing) continue; - - const version = - typeof sourceNode.typeVersion === 'string' - ? parseFloat(sourceNode.typeVersion) - : (sourceNode.typeVersion ?? 1); - - const nodeType = nodeTypesProvider.getByNameAndVersion(sourceNode.type, version); - const outputsHint = nodeType?.description?.builderHint?.outputs; - if (!outputsHint) continue; - - const ctx: DisplayOptionsContext = { - parameters: (sourceNode.parameters ?? {}) as Record, - nodeVersion: version, - rootParameters: (sourceNode.parameters ?? {}) as Record, - }; - - for (const [connectionType, cfg] of Object.entries(outputsHint)) { - // No displayOptions => assume the node always emits this connection type. - if (!cfg?.displayOptions) continue; - - const edges = outgoing[connectionType]; - if (!edges) continue; - const hasEdges = edges.some((slot) => Array.isArray(slot) && slot.length > 0); - if (!hasEdges) continue; - - const enabled = matchesDisplayOptions(ctx, cfg.displayOptions as DisplayOptions); - if (enabled) continue; - - const conditionDetails = buildConditionSummary( - cfg.displayOptions, - (sourceNode.parameters ?? {}) as Record, - ); - const usedWiring = describeOutputWiring(connectionType); - const enabledAlt = findEnabledAlternativeOutput(outputsHint, ctx, connectionType); - const altSuggestion = enabledAlt - ? ` To use this node as-is, attach it as ${describeOutputWiring(enabledAlt, true)} of a parent (it exposes ${enabledAlt} in this configuration).` - : ''; - - warnings.push( - new ValidationWarning( - 'INVALID_OUTPUT_FOR_MODE', - `'${sourceNode.name}' is ${usedWiring} but its current parameters disable that output. ${conditionDetails}${altSuggestion}`, - sourceNode.name, - undefined, - undefined, - 'major', - ), - ); - } - } -} - -function getSwitchRulesCount(parameters: Record | undefined): number { - const rules = parameters?.rules; - if (!isRecord(rules)) return 0; - - const values = rules.values; - if (Array.isArray(values)) return values.length; - - const legacyRules = rules.rules; - if (Array.isArray(legacyRules)) return legacyRules.length; - - return 0; -} - -function getSwitchFallbackOutput(parameters: Record | undefined): unknown { - const options = parameters?.options; - if (!isRecord(options)) return undefined; - - return options.fallbackOutput; -} - -function hasOutputConnections( - outputs: Array | null>, - outputIndex: number, -): boolean { - const output = outputs[outputIndex]; - return Array.isArray(output) && output.length > 0; -} - -function hasAnyMainOutputConnection(nodeConnections: unknown): boolean { - if (!isRecord(nodeConnections)) return false; - const main = nodeConnections.main; - if (!Array.isArray(main)) return false; - - return main.some((slot) => Array.isArray(slot) && slot.length > 0); -} - -/** - * A Switch with no outgoing branches is almost always an incomplete router: - * every matched item is dropped and downstream side effects never run. - */ -function validateSwitchHasOutgoingConnections( - json: WorkflowJSON, - warnings: ValidationWarning[], -): void { - for (const sourceNode of json.nodes) { - if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue; - if (hasAnyMainOutputConnection(json.connections[sourceNode.name])) continue; - - warnings.push( - new ValidationWarning( - 'SWITCH_NO_OUTPUT_CONNECTIONS', - `Switch node '${sourceNode.name}' has no outgoing connections. Connect at least one output branch to downstream action nodes, or remove the Switch node.`, - sourceNode.name, - 'connections', - undefined, - 'major', - ), - ); - } -} - -/** - * Validate that Switch fallback branches are only connected when the node - * actually exposes an extra fallback output. - */ -function validateSwitchFallbackOutputConnections( - json: WorkflowJSON, - warnings: ValidationWarning[], -): void { - for (const sourceNode of json.nodes) { - if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue; - - const mode = sourceNode.parameters?.mode; - if (mode !== undefined && mode !== 'rules') continue; - - const outgoing = json.connections[sourceNode.name]; - const mainOutputs = outgoing?.main; - if (!Array.isArray(mainOutputs)) continue; - - const rulesCount = getSwitchRulesCount(sourceNode.parameters); - const fallbackOutput = getSwitchFallbackOutput(sourceNode.parameters); - if (fallbackOutput === 'extra') continue; - - for (let outputIndex = rulesCount; outputIndex < mainOutputs.length; outputIndex++) { - if (!hasOutputConnections(mainOutputs, outputIndex)) continue; - - const isErrorOutput = - sourceNode.onError === 'continueErrorOutput' && outputIndex === rulesCount; - if (isErrorOutput) continue; - - warnings.push( - new ValidationWarning( - 'SWITCH_FALLBACK_OUTPUT_DISABLED', - `Switch node '${sourceNode.name}' has a connection from output ${outputIndex}, but rules mode only creates fallback output ${rulesCount} when options.fallbackOutput is set to 'extra'. Set options.fallbackOutput to 'extra' before wiring a catch-all branch, or route unmatched items to an existing rule output with a numeric fallbackOutput value.`, - sourceNode.name, - 'options.fallbackOutput', - undefined, - 'major', - ), - ); - } - } -} - -/** - * Reject `placeholder()` markers found in parameter slots whose property - * description carries `builderHint.placeholderSupported === false`. - * - * This is the runtime side of the type-level signal that used to live in the - * generated `string | Expression` union (which previously omitted - * `PlaceholderValue`). Now that `placeholder()` returns a plain `string`, the - * type system can no longer block placement; this validator does at runtime. - * - * Uses `containsPlaceholderMarker` (not `isPlaceholderValue`) so that the - * marker is rejected anywhere in the value — including `expr(placeholder())`, - * which produces `=<__PLACEHOLDER_VALUE__…__>`, and placeholders embedded - * inside `={{ … }}` expressions. - * - * Walks top-level properties only — the known declarations - * (webhook `path`, langchain agent `text`) are top-level fields. Nested - * collection / fixedCollection support can be added later if a node opts out - * of placeholders for a nested field. - */ -function validatePlaceholderSlots( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - errors: ValidationError[], -): void { - for (const node of json.nodes) { - if (!node.name || !node.parameters) continue; - - const version = - typeof node.typeVersion === 'string' ? parseFloat(node.typeVersion) : (node.typeVersion ?? 1); - - const nodeType = nodeTypesProvider.getByNameAndVersion(node.type, version); - const properties = nodeType?.description?.properties; - if (!properties) continue; - - const params = node.parameters as Record; - for (const prop of properties) { - if (prop.builderHint?.placeholderSupported !== false) continue; - const value = params[prop.name]; - if (!containsPlaceholderMarker(value)) continue; - - errors.push( - new ValidationError( - 'INVALID_PARAMETER', - `Node "${node.name}": placeholder() is not supported for parameter '${prop.name}'. Use a literal value or expr() instead.`, - node.name, - prop.name, - ), - ); - } - } -} - -/** - * Check if connections use valid input indices for their target nodes. - * Reports warnings for connections to input indices that don't exist. - */ -/** - * Validate that every main connection originates from an output port the - * source node actually has. The legal slots are the node type's natural main - * outputs, plus one trailing error pin when the node sets - * `onError: 'continueErrorOutput'`. Connections from any higher index render - * as impossible edges on the canvas (INS-425). - */ -function checkNodeOutputIndices( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - warnings: ValidationWarning[], -): void { - const nodesByName = new Map(); - for (const node of json.nodes) { - if (node.name) { - nodesByName.set(node.name, node); - } - } - - for (const [sourceName, nodeConnections] of Object.entries(json.connections)) { - const mainConnections = nodeConnections.main; - if (!mainConnections || !Array.isArray(mainConnections)) continue; - - const sourceNode = nodesByName.get(sourceName); - if (!sourceNode) continue; - - const version = - typeof sourceNode.typeVersion === 'string' - ? parseFloat(sourceNode.typeVersion) - : (sourceNode.typeVersion ?? 1); - - const mainOutputCount = resolveMainOutputCount(nodeTypesProvider, sourceNode.type, version); - - // If we couldn't resolve (dynamic outputs or unknown node), skip validation - if (mainOutputCount === undefined) continue; - - const errorPinCount = sourceNode.onError === 'continueErrorOutput' ? 1 : 0; - const allowedOutputCount = mainOutputCount + errorPinCount; - - for (let outputIndex = 0; outputIndex < mainConnections.length; outputIndex++) { - const outputs = mainConnections[outputIndex]; - if (!outputs || outputs.length === 0) continue; - - if (outputIndex >= allowedOutputCount) { - warnings.push( - new ValidationWarning( - 'INVALID_OUTPUT_INDEX', - `Connection from '${sourceName}' uses output index ${outputIndex}, but node only has ${allowedOutputCount} output(s) (indices 0-${allowedOutputCount - 1}). To route the error output, set onError: 'continueErrorOutput' on the node and use .onError(target).`, - sourceName, - ), - ); - } - } - } -} - -function checkNodeInputIndices( - json: WorkflowJSON, - nodeTypesProvider: INodeTypes, - warnings: ValidationWarning[], -): void { - // Build a map of node name -> node for quick lookup - const nodesByName = new Map(); - for (const node of json.nodes) { - if (node.name) { - nodesByName.set(node.name, node); - } - } - - // Track which (nodeName, inputIndex) pairs we've already warned about - // to avoid duplicate warnings when multiple sources connect to the same invalid input - const warnedInputs = new Set(); - - // Scan all connections to check input indices - for (const [_sourceName, nodeConnections] of Object.entries(json.connections)) { - // Only check main connections (not AI connections) - const mainConnections = nodeConnections.main; - if (!mainConnections || !Array.isArray(mainConnections)) continue; - - for (const outputs of mainConnections) { - if (!outputs) continue; - for (const conn of outputs) { - const targetNodeName = conn.node; - const targetInputIndex = conn.index; - - const targetNode = nodesByName.get(targetNodeName); - if (!targetNode) continue; - - // Get version number - const version = - typeof targetNode.typeVersion === 'string' - ? parseFloat(targetNode.typeVersion) - : (targetNode.typeVersion ?? 1); - - // Resolve the number of main inputs for this node type - const mainInputCount = resolveMainInputCount(nodeTypesProvider, targetNode.type, version); - - // If we couldn't resolve (dynamic inputs or unknown node), skip validation - if (mainInputCount === undefined) continue; - - // Check if the input index is valid - if (targetInputIndex >= mainInputCount) { - const warnKey = `${targetNodeName}:${targetInputIndex}`; - if (!warnedInputs.has(warnKey)) { - warnedInputs.add(warnKey); - warnings.push( - new ValidationWarning( - 'INVALID_INPUT_INDEX', - `Connection to '${targetNodeName}' uses input index ${targetInputIndex}, but node only has ${mainInputCount} input(s) (indices 0-${mainInputCount - 1})`, - targetNodeName, - ), - ); - } - } - } - } - } -} +} from './node-parameter-schema/schema-validator'; + +export { + type IssueSeverity, + isInformationalIssue, + partitionValidationIssues, +} from './issue-severity'; + +export { + validateWorkflowBuilder, + buildUncheckedNotes, + type ValidateWorkflowBuilderOptions, + type ValidateWorkflowBuilderResult, + type CollectedValidationIssue, + type ValidationIssueSource, +} from './validate-workflow-builder'; + +export { + matchesDisplayOptions, + checkConditions, + type DisplayOptions, + type DisplayOptionsContext, +} from './display-options'; + +export { resolveMainInputCount } from './node-port-resolvers/resolve-main-input-count'; +export { resolveMainOutputCount } from './node-port-resolvers/resolve-main-output-count'; diff --git a/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.test.ts b/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.test.ts new file mode 100644 index 00000000000..180901edb11 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.test.ts @@ -0,0 +1,23 @@ +import { partitionValidationIssues } from './issue-severity'; + +describe('partitionValidationIssues', () => { + it('separates blocking from informational by severity on the issue', () => { + const issues = [ + { code: 'MISSING_TRIGGER', message: 'No trigger', severity: 'informational' as const }, + { code: 'INVALID_PARAMETER', message: 'Bad parameter', severity: 'warning' as const }, + { code: 'SDK_AS_CONST', message: 'Avoid as const', severity: 'informational' as const }, + ]; + expect(partitionValidationIssues(issues)).toEqual({ + informational: [issues[0], issues[2]], + blocking: [issues[1]], + }); + }); + + it('treats missing severity as blocking', () => { + const issues = [{ code: 'UNKNOWN_CONFIG_KEY', message: 'Unknown key' }]; + expect(partitionValidationIssues(issues)).toEqual({ + informational: [], + blocking: [issues[0]], + }); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.ts b/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.ts new file mode 100644 index 00000000000..a947fbb678c --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/informational-validation-codes.ts @@ -0,0 +1,9 @@ +/** + * @deprecated Import from `./issue-severity` (or the validation barrel) instead. + * Re-exported here so older deep imports keep working. + */ +export { + type IssueSeverity, + isInformationalIssue, + partitionValidationIssues, +} from './issue-severity'; diff --git a/packages/@n8n/workflow-sdk/src/validation/input-validation.test.ts b/packages/@n8n/workflow-sdk/src/validation/input-index-validation.test.ts similarity index 92% rename from packages/@n8n/workflow-sdk/src/validation/input-validation.test.ts rename to packages/@n8n/workflow-sdk/src/validation/input-index-validation.test.ts index cce912ea390..72f7b93efe2 100644 --- a/packages/@n8n/workflow-sdk/src/validation/input-validation.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/input-index-validation.test.ts @@ -125,6 +125,40 @@ describe('input index validation', () => { const invalidInputWarnings = result.warnings.filter((w) => w.code === 'INVALID_INPUT_INDEX'); expect(invalidInputWarnings.length).toBe(2); }); + + it('warns for negative input index', () => { + const result = validateWorkflow( + { + id: 'test-id', + name: 'Test', + nodes: [ + { + id: '1', + name: 'Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + }, + { + id: 'a', + name: 'A', + type: 'n8n-nodes-base.aggregate', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + ], + connections: { + Trigger: { main: [[{ node: 'A', type: 'main', index: -1 }]] }, + }, + }, + { nodeTypesProvider: mockNodeTypesProvider }, + ); + + const warning = result.warnings.find((w) => w.code === 'INVALID_INPUT_INDEX'); + expect(warning).toBeDefined(); + expect(warning?.message).toContain('input index -1'); + }); }); describe('merge node input-count validation', () => { diff --git a/packages/@n8n/workflow-sdk/src/validation/issue-severity.ts b/packages/@n8n/workflow-sdk/src/validation/issue-severity.ts new file mode 100644 index 00000000000..6922e2dfd7d --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/issue-severity.ts @@ -0,0 +1,36 @@ +/** + * Severity for validation / lint findings. + * + * - `error` — fatal for `ValidationResult.valid` + * - `warning` — non-fatal for `valid`, but blocks CLI exit / build-workflow save + * - `informational` — never blocks CLI exit / build-workflow save + * + * Set severity where the issue is created (validator plugin, ValidationWarning, + * or source-lint rule). Do not maintain a parallel code allowlist. + */ +export type IssueSeverity = 'error' | 'warning' | 'informational'; + +export function isInformationalIssue(issue: unknown): boolean { + if (typeof issue !== 'object' || issue === null || !('severity' in issue)) { + return false; + } + return Reflect.get(issue, 'severity') === 'informational'; +} + +export function partitionValidationIssues(issues: readonly T[]): { + blocking: T[]; + informational: T[]; +} { + const blocking: T[] = []; + const informational: T[] = []; + + for (const issue of issues) { + if (isInformationalIssue(issue)) { + informational.push(issue); + } else { + blocking.push(issue); + } + } + + return { blocking, informational }; +} diff --git a/packages/@n8n/workflow-sdk/src/validation/resolve-schema.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/resolve-schema.test.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/resolve-schema.test.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/resolve-schema.test.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/resolve-schema.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/resolve-schema.ts similarity index 99% rename from packages/@n8n/workflow-sdk/src/validation/resolve-schema.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/resolve-schema.ts index ae76a091a5a..f711536d6f5 100644 --- a/packages/@n8n/workflow-sdk/src/validation/resolve-schema.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/resolve-schema.ts @@ -4,7 +4,7 @@ import { matchesDisplayOptions as matchesDisplayOptionsCore, type DisplayOptions, type DisplayOptionsContext, -} from './display-options'; +} from '../display-options'; // Re-export types from display-options for backward compatibility export type { DisplayOptions, DisplayOptionsContext }; diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-helpers.test.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/schema-helpers.test.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-helpers.test.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-helpers.ts similarity index 98% rename from packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-helpers.ts index 778f8b4751a..1330d8f5e0f 100644 --- a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-helpers.ts @@ -27,7 +27,7 @@ export { literalUnion, optionsWithExpression, multiOptionsSchema, -} from '../generate-types/zod-helpers'; +} from '../../generate-types/zod-helpers'; // Re-export resolveSchema and types from resolve-schema export { resolveSchema, resolveOneOfSchemas } from './resolve-schema'; diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-validation-integration.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts similarity index 99% rename from packages/@n8n/workflow-sdk/src/validation/schema-validation-integration.test.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts index ad1f9dfa14a..847b04136fe 100644 --- a/packages/@n8n/workflow-sdk/src/validation/schema-validation-integration.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validation-integration.test.ts @@ -10,8 +10,8 @@ import { validateNodeConfig, loadSchema } from './schema-validator'; import { setupTestSchemas, teardownTestSchemas } from './test-schema-setup'; -import { parseWorkflowCode } from '../codegen/parse-workflow-code'; -import { validateWorkflow } from '../validation'; +import { parseWorkflowCode } from '../../codegen/parse-workflow-code'; +import { validateWorkflow } from '../../validation'; function requireSchema(nodeType: string, version: number): void { if (!loadSchema(nodeType, version)) { diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-validator.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.test.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/schema-validator.test.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.test.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-validator.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/schema-validator.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/test-schema-setup.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/test-schema-setup.ts similarity index 93% rename from packages/@n8n/workflow-sdk/src/validation/test-schema-setup.ts rename to packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/test-schema-setup.ts index 23166858466..dde8fdce10e 100644 --- a/packages/@n8n/workflow-sdk/src/validation/test-schema-setup.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/test-schema-setup.ts @@ -12,7 +12,7 @@ import * as os from 'os'; import * as path from 'path'; import { setSchemaBaseDirs, getSchemaBaseDirs } from './schema-validator'; -import { generateNodeDefinitions } from '../generate-types/generate-node-defs-cli'; +import { generateNodeDefinitions } from '../../generate-types/generate-node-defs-cli'; // Use a worker-specific directory to prevent race conditions when multiple test // workers run schema-using tests in parallel (they would otherwise concurrently @@ -31,7 +31,7 @@ let originalBaseDirs: string[] | undefined; * cached schemas in the temp directory are stale. */ function computeGeneratorHash(): string { - const generatorPath = path.resolve(__dirname, '../generate-types/generate-zod-schemas.ts'); + const generatorPath = path.resolve(__dirname, '../../generate-types/generate-zod-schemas.ts'); try { const content = fs.readFileSync(generatorPath, 'utf-8'); return crypto.createHash('md5').update(content).digest('hex'); @@ -73,7 +73,7 @@ export async function setupTestSchemas(): Promise { fs.rmSync(SCHEMA_TEST_DIR, { recursive: true, force: true }); } - const repoRoot = path.resolve(__dirname, '../../../../..'); + const repoRoot = path.resolve(__dirname, '../../../../../..'); const nodesBaseJson = path.join(repoRoot, 'packages/nodes-base/dist/types/nodes.json'); if (fs.existsSync(nodesBaseJson)) { diff --git a/packages/@n8n/workflow-sdk/src/validation/input-resolver.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-input-count.test.ts similarity index 97% rename from packages/@n8n/workflow-sdk/src/validation/input-resolver.test.ts rename to packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-input-count.test.ts index d373bd45b55..77ee059017e 100644 --- a/packages/@n8n/workflow-sdk/src/validation/input-resolver.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-input-count.test.ts @@ -1,6 +1,6 @@ import type { INodeTypes } from 'n8n-workflow'; -import { resolveMainInputCount } from './input-resolver'; +import { resolveMainInputCount } from './resolve-main-input-count'; describe('resolveMainInputCount', () => { const createMockProvider = (inputs: unknown): INodeTypes => diff --git a/packages/@n8n/workflow-sdk/src/validation/input-resolver.ts b/packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-input-count.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/input-resolver.ts rename to packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-input-count.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/output-resolver.ts b/packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-output-count.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/output-resolver.ts rename to packages/@n8n/workflow-sdk/src/validation/node-port-resolvers/resolve-main-output-count.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/output-validation.test.ts b/packages/@n8n/workflow-sdk/src/validation/output-index-validation.test.ts similarity index 100% rename from packages/@n8n/workflow-sdk/src/validation/output-validation.test.ts rename to packages/@n8n/workflow-sdk/src/validation/output-index-validation.test.ts diff --git a/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.test.ts b/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.test.ts new file mode 100644 index 00000000000..55636cda6bd --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.test.ts @@ -0,0 +1,80 @@ +import { node, trigger, workflow } from '../index'; +import { getSchemaBaseDirs, setSchemaBaseDirs } from './node-parameter-schema/schema-validator'; +import { validateWorkflowBuilder } from './validate-workflow-builder'; + +describe('validateWorkflowBuilder', () => { + it('does not reuse a prior call schema dirs when nodeDefinitionDirs is omitted', () => { + const previous = getSchemaBaseDirs(); + setSchemaBaseDirs(['/tmp/stale-node-definitions']); + const t = trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }); + const wf = workflow('id', 'name').add(t); + + try { + const result = validateWorkflowBuilder(wf); + // Call scoped to [] for validation, then restores the prior registry. + expect(result.nodeDefinitionDirs).toEqual([]); + expect(result.unchecked.some((note) => note.includes('no node definitions'))).toBe(true); + expect(getSchemaBaseDirs()).toEqual(['/tmp/stale-node-definitions']); + } finally { + setSchemaBaseDirs(previous); + } + }); + + it('runs graph + schema and partitions informational severity', () => { + const orphan = node({ + type: 'n8n-nodes-base.set', + version: 3, + config: { name: 'Orphan' }, + }); + const wf = workflow('id', 'name').add(orphan); + + const result = validateWorkflowBuilder(wf); + + expect(result.valid).toBe(true); + expect(result.ok).toBe(true); + expect(result.informational.some((issue) => issue.code === 'MISSING_TRIGGER')).toBe(true); + expect(result.informational.some((issue) => issue.code === 'DISCONNECTED_NODE')).toBe(true); + expect(result.blocking).toHaveLength(0); + }); + + it('includes source lint when lint: true and source is provided', () => { + const t = trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }); + const wf = workflow('id', 'name').add(t); + const source = ` +import { sticky, trigger, workflow } from '@n8n/workflow-sdk'; +const t = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } }); +const note = sticky('hi'); +export default workflow('id', 'name').add(t).add(note); +`; + + const result = validateWorkflowBuilder(wf, { lint: true, source }); + + expect(result.lint.some((issue) => issue.code === 'SDK_UNSOLICITED_STICKY')).toBe(true); + expect(result.informational.some((issue) => issue.code === 'SDK_UNSOLICITED_STICKY')).toBe( + true, + ); + expect(result.ok).toBe(true); + }); + + it('skips lint when lint is not requested', () => { + const t = trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }); + const wf = workflow('id', 'name').add(t); + const source = "const note = sticky('hi'); export default workflow('id','n').add(note);"; + + const result = validateWorkflowBuilder(wf, { source }); + + expect(result.lint).toHaveLength(0); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.ts b/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.ts new file mode 100644 index 00000000000..a09355f0cbe --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/validate-workflow-builder.ts @@ -0,0 +1,244 @@ +import { lintWorkflowSource, type SourceLintIssue } from '../lint'; +import { type IssueSeverity, partitionValidationIssues } from './issue-severity'; +import { getSchemaBaseDirs, setSchemaBaseDirs } from './node-parameter-schema/schema-validator'; +import { + validateWorkflow, + type ValidationError, + type ValidationOptions, + type ValidationResult, + type ValidationWarning, +} from './validate-workflow'; +import type { WorkflowJSON } from '../types/base'; + +export type ValidationIssueSource = 'graph' | 'schema' | 'sdk' | 'jsCode' | 'pythonCode'; + +export interface CollectedValidationIssue { + code: string; + message: string; + /** Severity set at the issue creation site. */ + severity: IssueSeverity; + nodeName?: string; + parameterPath?: string; + /** 1-based line in the workflow source file, when resolvable. */ + line?: number; + /** 1-based column in the workflow source file, when resolvable. */ + column?: number; + source: ValidationIssueSource; +} + +export interface ValidateWorkflowBuilderOptions extends ValidationOptions { + /** + * When true, also run source lint (SDK builder + embedded Code-node rules). + * Requires {@link source}. + */ + lint?: boolean; + /** TypeScript source of the workflow file (needed for lint + line mapping). */ + source?: string; + /** + * Directories for Zod parameter schemas (`setSchemaBaseDirs`). + * These do **not** build an `INodeTypes` provider — pass {@link nodeTypesProvider} + * separately when AI-input / port-bound checks are needed. + */ + nodeDefinitionDirs?: string[]; +} + +export interface ValidateWorkflowBuilderResult { + /** True when graph+schema report no fatal errors (`ValidationResult.valid`). */ + valid: boolean; + /** True when no blocking (non-informational) issues remain after partition. */ + ok: boolean; + issues: CollectedValidationIssue[]; + blocking: CollectedValidationIssue[]; + informational: CollectedValidationIssue[]; + graph: ValidationResult; + schema: ValidationResult; + lint: SourceLintIssue[]; + unchecked: string[]; + nodeDefinitionDirs: string[]; +} + +interface WorkflowBuilderLike { + validate: (options?: ValidationOptions) => ValidationResult; + toJSON: (options?: { tidyUp?: boolean }) => WorkflowJSON; +} + +const UNCHECKED_ALWAYS = [ + 'wrong-kind resource locator values', + // IF/Switch/SIB/Merge bounds are covered by connection-index-validator; + // other node types still need a full nodeTypesProvider. + 'input/output index bounds for non-control-flow nodes', + 'AI input type / required-input support', + 'n8n credits aiGateway constraints (needs Instance AI metadata)', +] as const; + +const UNCHECKED_WITHOUT_SCHEMAS = + 'node parameter names and values (no node definitions found — pass --node-types or set N8N_NODE_DEFINITION_DIRS)'; + +const UNCHECKED_WITHOUT_PROVIDER = + 'full nodeTypesProvider checks (node-definition dirs only supply Zod parameter schemas, not INodeTypes)'; + +/** Notes about checks the unified validator does not cover (CLI trailer / JSON). */ +export function buildUncheckedNotes(options: { + schemasLoaded: boolean; + hasNodeTypesProvider: boolean; +}): string[] { + const unchecked: string[] = [...UNCHECKED_ALWAYS]; + if (!options.schemasLoaded) { + unchecked.push(UNCHECKED_WITHOUT_SCHEMAS); + } + if (!options.hasNodeTypesProvider) { + unchecked.push(UNCHECKED_WITHOUT_PROVIDER); + } + return unchecked; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Best-effort: locate the `config.name` assignment for a node in the source. + * Validators don't carry AST locations, so we map nodeName → source line/column. + */ +function findNodeLocation( + sourceLines: string[], + nodeName: string, +): { line: number; column: number } | undefined { + const pattern = new RegExp(`\\bname:\\s*['"]${escapeRegExp(nodeName)}['"]`); + for (let i = 0; i < sourceLines.length; i++) { + const match = pattern.exec(sourceLines[i] ?? ''); + if (match) { + return { line: i + 1, column: match.index + 1 }; + } + } + return undefined; +} + +function toCollected( + issues: ReadonlyArray, + source: 'graph' | 'schema', + sourceLines: string[] | undefined, +): CollectedValidationIssue[] { + return issues.map((issue) => { + const parameterPath = + 'parameterPath' in issue && typeof issue.parameterPath === 'string' + ? issue.parameterPath + : 'parameterName' in issue && typeof issue.parameterName === 'string' + ? issue.parameterName + : undefined; + const location = + issue.nodeName && sourceLines ? findNodeLocation(sourceLines, issue.nodeName) : undefined; + return { + code: issue.code, + message: issue.message, + severity: issue.severity, + nodeName: issue.nodeName, + parameterPath, + line: location?.line, + column: location?.column, + source, + }; + }); +} + +function sourceLintToCollected(issue: SourceLintIssue): CollectedValidationIssue { + return { + code: issue.code, + message: issue.message, + severity: issue.severity, + line: issue.line, + column: issue.column, + source: issue.lintTarget, + nodeName: issue.nodeName, + parameterPath: issue.parameterPath, + }; +} + +/** + * Dedupe overlapping graph+schema findings (e.g. DISCONNECTED_NODE) without + * requiring identical messages — plugins and validateWorkflow phrase them differently. + * + * Keep `parameterPath` so distinct INVALID_PARAMETER (etc.) findings on the same + * node are not collapsed into one. + */ +function dedupeIssues(issues: CollectedValidationIssue[]): CollectedValidationIssue[] { + const seen = new Set(); + const deduped: CollectedValidationIssue[] = []; + for (const issue of issues) { + const key = `${issue.code}|${issue.nodeName ?? ''}|${issue.parameterPath ?? ''}|${issue.line ?? ''}|${issue.column ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(issue); + } + return deduped; +} + +/** + * Run graph validators (`wf.validate`), JSON/schema validation, and optionally + * source lint — the single path used by the CLI and fixture tests. + * + * Pass `{ lint: true, source }` to include SDK / Code-node source lint. + * Pass `nodeTypesProvider` when the host already has live node types; definition + * dirs alone cannot synthesize one (they only back Zod parameter schemas). + */ +export function validateWorkflowBuilder( + workflow: WorkflowBuilderLike, + options: ValidateWorkflowBuilderOptions = {}, +): ValidateWorkflowBuilderResult { + const nodeDefinitionDirs = options.nodeDefinitionDirs ?? []; + // Scope schema dirs to this call only — omit/empty must not reuse a prior + // call's dirs, and we must not leak empty/cleared dirs into later callers + // (tests and long-lived hosts share the process-level schema registry). + const previousSchemaDirs = getSchemaBaseDirs(); + setSchemaBaseDirs(nodeDefinitionDirs); + + try { + const unchecked = buildUncheckedNotes({ + schemasLoaded: nodeDefinitionDirs.length > 0, + hasNodeTypesProvider: options.nodeTypesProvider !== undefined, + }); + + const validationOptions: ValidationOptions = { + strictMode: options.strictMode, + allowDisconnectedNodes: options.allowDisconnectedNodes, + allowNoTrigger: options.allowNoTrigger, + validateSchema: options.validateSchema, + nodeTypesProvider: options.nodeTypesProvider, + }; + + const graph = workflow.validate(validationOptions); + const schema = validateWorkflow(workflow.toJSON({ tidyUp: true }), validationOptions); + + const source = options.source ?? ''; + const sourceLines = source.length > 0 ? source.split(/\r?\n/) : undefined; + const lint = + options.lint === true && source.length > 0 + ? lintWorkflowSource(source) + : ([] as SourceLintIssue[]); + + const allIssues = dedupeIssues([ + ...toCollected(graph.errors, 'graph', sourceLines), + ...toCollected(graph.warnings, 'graph', sourceLines), + ...toCollected(schema.errors, 'schema', sourceLines), + ...toCollected(schema.warnings, 'schema', sourceLines), + ...lint.map(sourceLintToCollected), + ]); + + const { blocking, informational } = partitionValidationIssues(allIssues); + + return { + valid: graph.valid && schema.valid, + ok: blocking.length === 0, + issues: allIssues, + blocking, + informational, + graph, + schema, + lint, + unchecked, + nodeDefinitionDirs, + }; + } finally { + setSchemaBaseDirs(previousSchemaDirs); + } +} diff --git a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts b/packages/@n8n/workflow-sdk/src/validation/validate-workflow.test.ts similarity index 96% rename from packages/@n8n/workflow-sdk/src/validation/validation.test.ts rename to packages/@n8n/workflow-sdk/src/validation/validate-workflow.test.ts index 4ccbbe0e0ac..a81f59cf4aa 100644 --- a/packages/@n8n/workflow-sdk/src/validation/validation.test.ts +++ b/packages/@n8n/workflow-sdk/src/validation/validate-workflow.test.ts @@ -1,5 +1,5 @@ import { validateWorkflow, ValidationError } from '.'; -import { setupTestSchemas, teardownTestSchemas } from './test-schema-setup'; +import { setupTestSchemas, teardownTestSchemas } from './node-parameter-schema/test-schema-setup'; import type { NodeInstance, WorkflowJSON } from '../types/base'; import { workflow } from '../workflow-builder'; import { node, trigger, sticky } from '../workflow-builder/node-builders/node-builder'; @@ -128,6 +128,118 @@ describe('Validation', () => { // Subnodes should NOT be flagged as disconnected (they connect TO their parent via AI connections) expect(disconnectedWarnings).toHaveLength(0); }); + + it.each(['n8n-nodes-base.cron', 'n8n-nodes-base.start', 'n8n-nodes-base.emailReadImap'])( + 'should treat %s as a trigger (canonical trigger detection)', + (type) => { + const result = validateWorkflow({ + id: 'test-id', + name: 'Test', + nodes: [{ id: '1', name: 'Entry', type, typeVersion: 1, position: [0, 0] }], + connections: {}, + }); + + expect(result.warnings.filter((w) => w.code === 'MISSING_TRIGGER')).toHaveLength(0); + expect(result.warnings.filter((w) => w.code === 'DISCONNECTED_NODE')).toHaveLength(0); + }, + ); + + it('should warn when two sources connect to the same single-value AI input', () => { + const result = validateWorkflow({ + id: 'test-id', + name: 'Test', + nodes: [ + { + id: '1', + name: 'Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + }, + { + id: '2', + name: 'Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + { + id: '3', + name: 'Model A', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [0, 200], + parameters: {}, + }, + { + id: '4', + name: 'Model B', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + ], + connections: { + Trigger: { main: [[{ node: 'Agent', type: 'main', index: 0 }]] }, + 'Model A': { + ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]], + }, + 'Model B': { + ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]], + }, + }, + }); + + const duplicateWarnings = result.warnings.filter( + (w) => w.code === 'DUPLICATE_SUBNODE_CONNECTION', + ); + expect(duplicateWarnings).toHaveLength(1); + expect(duplicateWarnings[0].message).toContain('ai_languageModel'); + expect(duplicateWarnings[0].message).toContain('Model A'); + expect(duplicateWarnings[0].message).toContain('Model B'); + }); + + it('should not warn for a single connection to a single-value AI input', () => { + const result = validateWorkflow({ + id: 'test-id', + name: 'Test', + nodes: [ + { + id: '1', + name: 'Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + }, + { + id: '2', + name: 'Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + { + id: '3', + name: 'Model', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [0, 200], + parameters: {}, + }, + ], + connections: { + Trigger: { main: [[{ node: 'Agent', type: 'main', index: 0 }]] }, + Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] }, + }, + }); + + expect(result.warnings.filter((w) => w.code === 'DUPLICATE_SUBNODE_CONNECTION')).toHaveLength( + 0, + ); + }); }); describe('ValidationError', () => { diff --git a/packages/@n8n/workflow-sdk/src/validation/validate-workflow.ts b/packages/@n8n/workflow-sdk/src/validation/validate-workflow.ts new file mode 100644 index 00000000000..42857217059 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/validate-workflow.ts @@ -0,0 +1,1367 @@ +import { isRecord } from '@n8n/utils/is-record'; +import get from 'lodash/get'; +import type { INodeTypes, IConnections as N8nIConnections, IDisplayOptions } from 'n8n-workflow'; +import { mapConnectionsByDestination } from 'n8n-workflow'; + +import { matchesDisplayOptions } from './display-options'; +import type { DisplayOptions, DisplayOptionsContext } from './display-options'; +import { validateNodeConfig } from './node-parameter-schema/schema-validator'; +import { resolveMainInputCount } from './node-port-resolvers/resolve-main-input-count'; +import { resolveMainOutputCount } from './node-port-resolvers/resolve-main-output-count'; +import { isStickyNoteType, isHttpRequestType } from '../constants/node-types'; +import type { WorkflowBuilder, WorkflowJSON } from '../types/base'; +import { isTriggerNodeType } from '../utils/trigger-detection'; +import { containsPlaceholderMarker } from '../workflow-builder/string-utils'; + +/** + * Validation error codes + */ +export type ValidationErrorCode = + | 'NO_NODES' + | 'MISSING_TRIGGER' + | 'DISCONNECTED_NODE' + | 'MISSING_PARAMETER' + | 'INVALID_CONNECTION' + | 'CIRCULAR_REFERENCE' + | 'INVALID_EXPRESSION' + | 'AGENT_STATIC_PROMPT' + | 'AGENT_NO_SYSTEM_MESSAGE' + | 'HARDCODED_CREDENTIALS' + | 'SET_CREDENTIAL_FIELD' + | 'MERGE_SINGLE_INPUT' + | 'TOOL_NO_PARAMETERS' + | 'FROM_AI_IN_NON_TOOL' + | 'MISSING_EXPRESSION_PREFIX' + | 'INVALID_PARAMETER' + | 'INVALID_INPUT_INDEX' + | 'INVALID_OUTPUT_INDEX' + | 'SUBNODE_NOT_CONNECTED' + | 'DUPLICATE_SUBNODE_CONNECTION' + | 'SUBNODE_PARAMETER_MISMATCH' + | 'UNSUPPORTED_SUBNODE_INPUT' + | 'MISSING_REQUIRED_INPUT' + | 'INVALID_OUTPUT_FOR_MODE' + | 'SWITCH_NO_OUTPUT_CONNECTIONS' + | 'SWITCH_FALLBACK_OUTPUT_DISABLED' + | 'MAX_NODES_EXCEEDED' + | 'INVALID_EXPRESSION_PATH' + | 'PARTIAL_EXPRESSION_PATH' + | 'INVALID_DATE_METHOD' + | 'UNKNOWN_CONFIG_KEY'; + +/** + * Validation error class + */ +export class ValidationError { + readonly code: ValidationErrorCode; + readonly message: string; + readonly nodeName?: string; + readonly parameterName?: string; + /** Violation level for evaluation scoring (defaults to 'minor' if not set) */ + readonly violationLevel?: 'critical' | 'major' | 'minor'; + readonly severity = 'error' as const; + + constructor( + code: ValidationErrorCode, + message: string, + nodeName?: string, + parameterName?: string, + violationLevel?: 'critical' | 'major' | 'minor', + ) { + this.code = code; + this.message = message; + this.nodeName = nodeName; + this.parameterName = parameterName; + this.violationLevel = violationLevel; + } +} + +/** + * Validation warning class (non-fatal for `ValidationResult.valid`). + * + * Save/CLI gating uses {@link severity}: `informational` never blocks; + * `warning` blocks unless the caller chooses otherwise. + */ +export class ValidationWarning { + readonly code: ValidationErrorCode; + readonly message: string; + readonly nodeName?: string; + readonly parameterPath?: string; + readonly originalName?: string; + /** Violation level for evaluation scoring (defaults to 'minor' if not set) */ + readonly violationLevel?: 'critical' | 'major' | 'minor'; + readonly severity: 'warning' | 'informational'; + + constructor( + code: ValidationErrorCode, + message: string, + nodeName?: string, + parameterPath?: string, + originalName?: string, + violationLevel?: 'critical' | 'major' | 'minor', + severity: 'warning' | 'informational' = 'warning', + ) { + this.code = code; + this.message = message; + this.nodeName = nodeName; + this.parameterPath = parameterPath; + this.originalName = originalName; + this.violationLevel = violationLevel; + this.severity = severity; + } + + /** Soft graph findings that must not block save / CLI exit. */ + static informational( + code: ValidationErrorCode, + message: string, + nodeName?: string, + parameterPath?: string, + originalName?: string, + violationLevel?: 'critical' | 'major' | 'minor', + ): ValidationWarning { + return new ValidationWarning( + code, + message, + nodeName, + parameterPath, + originalName, + violationLevel, + 'informational', + ); + } +} + +/** + * Validation result + */ +export interface ValidationResult { + /** Whether the workflow is valid */ + valid: boolean; + /** Fatal errors that prevent the workflow from running */ + errors: ValidationError[]; + /** Warnings about potential issues */ + warnings: ValidationWarning[]; +} + +/** + * Validation options + */ +export interface ValidationOptions { + /** Enable strict mode with more warnings */ + strictMode?: boolean; + /** Skip disconnected node warnings */ + allowDisconnectedNodes?: boolean; + /** Skip trigger requirement */ + allowNoTrigger?: boolean; + /** Enable/disable Zod schema validation (default: true) */ + validateSchema?: boolean; + /** Optional node types provider for dynamic input index validation */ + nodeTypesProvider?: INodeTypes; +} + +/** + * AI connection types used by subnodes to connect to their parent nodes + */ +const AI_CONNECTION_TYPES = [ + 'ai_languageModel', + 'ai_memory', + 'ai_tool', + 'ai_outputParser', + 'ai_embedding', + 'ai_vectorStore', + 'ai_retriever', + 'ai_document', + 'ai_textSplitter', + 'ai_reranker', +]; + +/** + * Mapping from AI connection type to subnodes field name + */ +const AI_CONNECTION_TO_SUBNODE_FIELD: Record = { + ai_languageModel: 'model', + ai_memory: 'memory', + ai_tool: 'tools', + ai_outputParser: 'outputParser', + ai_embedding: 'embedding', + ai_vectorStore: 'vectorStore', + ai_retriever: 'retriever', + ai_document: 'documentLoader', + ai_textSplitter: 'textSplitter', + ai_reranker: 'reranker', +}; + +/** + * AI connection types that should always be arrays in subnodes + */ +const AI_ARRAY_TYPES = new Set(['ai_tool']); + +interface NodeJSON { + id?: string; + name?: string; + type: string; + typeVersion?: number | string; + position?: [number, number]; + parameters?: Record; + onError?: string; +} + +/** + * Reconstruct subnodes object from AI connections in the workflow. + * When SDK code defines subnodes, they get serialized as separate nodes with AI connections. + * This function reverses that transformation for validation purposes. + */ +function reconstructSubnodesFromConnections( + targetNodeName: string, + json: WorkflowJSON, +): Record | undefined { + const subnodes: Record = {}; + const nodesByName = new Map(); + + // Build a map of node name -> node for quick lookup + for (const node of json.nodes) { + if (node.name) { + nodesByName.set(node.name, node); + } + } + + // Scan all nodes' connections to find AI connections TO this target node + for (const [sourceNodeName, nodeConnections] of Object.entries(json.connections)) { + for (const connType of AI_CONNECTION_TYPES) { + const aiConns = nodeConnections[connType as keyof typeof nodeConnections]; + if (!aiConns || !Array.isArray(aiConns)) continue; + + for (const outputs of aiConns) { + if (!outputs) continue; + for (const conn of outputs) { + if (conn.node === targetNodeName) { + // Found an AI connection to our target node + const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connType]; + if (!subnodeField) continue; + + const sourceNode = nodesByName.get(sourceNodeName); + if (!sourceNode) continue; + + // Build a minimal subnode config for validation + const subnodeConfig = { + type: sourceNode.type, + version: sourceNode.typeVersion, + parameters: sourceNode.parameters ?? {}, + }; + + // For array types (like tools), collect into array + if (AI_ARRAY_TYPES.has(connType)) { + const existing = subnodes[subnodeField]; + if (Array.isArray(existing)) { + existing.push(subnodeConfig); + } else { + subnodes[subnodeField] = [subnodeConfig]; + } + } else { + // Single-value types keep the last connection; duplicates are + // surfaced by checkDuplicateSingleValueAiConnections instead of + // being silently dropped here. + subnodes[subnodeField] = subnodeConfig; + } + } + } + } + } + } + + // Return undefined if no subnodes were found + return Object.keys(subnodes).length > 0 ? subnodes : undefined; +} + +/** + * Single-value AI inputs (model, memory, embedding, …) accept exactly one connection + * at runtime. Subnode reconstruction keeps only the last source, so surface duplicates + * here instead of letting an earlier malformed subnode escape validation. + */ +function checkDuplicateSingleValueAiConnections( + json: WorkflowJSON, + warnings: ValidationWarning[], +): void { + const firstSourceByInput = new Map(); + const warnedInputs = new Set(); + + for (const [sourceNodeName, nodeConnections] of Object.entries(json.connections)) { + for (const connType of AI_CONNECTION_TYPES) { + if (AI_ARRAY_TYPES.has(connType)) continue; + const aiConns = nodeConnections[connType as keyof typeof nodeConnections]; + if (!aiConns || !Array.isArray(aiConns)) continue; + + for (const outputs of aiConns) { + if (!outputs) continue; + for (const conn of outputs) { + const key = `${conn.node}:${connType}`; + const firstSource = firstSourceByInput.get(key); + if (firstSource === undefined) { + firstSourceByInput.set(key, sourceNodeName); + continue; + } + if (firstSource === sourceNodeName || warnedInputs.has(key)) continue; + warnedInputs.add(key); + warnings.push( + new ValidationWarning( + 'DUPLICATE_SUBNODE_CONNECTION', + `'${conn.node}' has multiple '${connType}' connections ('${firstSource}' and '${sourceNodeName}'), but this input accepts only one. Remove the extra connection.`, + conn.node, + ), + ); + } + } + } + } +} + +/** + * Check if a node has AI connections to a parent node (making it a connected subnode) + */ +function hasAiConnectionToParent(nodeName: string, json: WorkflowJSON): boolean { + const nodeConnections = json.connections[nodeName]; + if (!nodeConnections) return false; + + for (const connType of AI_CONNECTION_TYPES) { + const aiConns = nodeConnections[connType as keyof typeof nodeConnections]; + if (aiConns && Array.isArray(aiConns)) { + for (const outputs of aiConns) { + if (outputs && outputs.length > 0) { + return true; // Has AI connection to parent + } + } + } + } + return false; +} + +/** + * Check if a node is used as a tool (connected via ai_tool connection type) + */ +function isToolSubnode(nodeName: string, json: WorkflowJSON): boolean { + const nodeConnections = json.connections[nodeName]; + if (!nodeConnections) return false; + + const toolConns = nodeConnections.ai_tool as unknown as Array>; + if (toolConns && Array.isArray(toolConns)) { + for (const outputs of toolConns) { + if (outputs && outputs.length > 0) { + return true; // Connected as a tool + } + } + } + return false; +} + +/** + * Find disconnected nodes (nodes that don't receive input from any other node) + */ +function findDisconnectedNodes(json: WorkflowJSON): string[] { + const hasIncoming = new Set(); + + // Find all nodes that have incoming connections + for (const [_sourceName, nodeConnections] of Object.entries(json.connections)) { + if (nodeConnections.main) { + for (const outputs of nodeConnections.main) { + if (outputs) { + for (const connection of outputs) { + hasIncoming.add(connection.node); + } + } + } + } + } + + // Find nodes without incoming connections (excluding triggers, sticky notes, and connected subnodes) + const disconnected: string[] = []; + for (const node of json.nodes) { + // Skip nodes without names (e.g., some sticky notes) + if (!node.name) continue; + + // Skip if node has incoming connection + if (hasIncoming.has(node.name)) continue; + + // Skip trigger nodes - they don't need incoming connections + if (isTriggerNodeType(node.type)) continue; + + // Skip sticky notes - they don't participate in data flow + if (isStickyNoteType(node.type)) continue; + + // Skip subnodes - they connect TO their parent via AI connections + if (hasAiConnectionToParent(node.name, json)) continue; + + disconnected.push(node.name); + } + + return disconnected; +} + +/** + * Validate a workflow + * + * Checks for: + * - Presence of trigger node (warning if missing) + * - Disconnected nodes (warning) + * - Required parameters (in strict mode) + * + * @param workflow - The workflow to validate (WorkflowBuilder or WorkflowJSON) + * @param options - Validation options + * @returns Validation result with errors and warnings + * + * @example + * ```typescript + * const wf = workflow('id', 'Test').add(trigger(...)).to(node(...)); + * const result = validateWorkflow(wf); + * + * if (!result.valid) { + * console.error('Errors:', result.errors); + * } + * if (result.warnings.length > 0) { + * console.warn('Warnings:', result.warnings); + * } + * ``` + */ +export function validateWorkflow( + workflowOrJson: WorkflowBuilder | WorkflowJSON, + options: ValidationOptions = {}, +): ValidationResult { + // Get JSON representation + const json: WorkflowJSON = 'toJSON' in workflowOrJson ? workflowOrJson.toJSON() : workflowOrJson; + + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // Check for trigger node + if (!options.allowNoTrigger) { + const hasTrigger = json.nodes.some((node) => isTriggerNodeType(node.type)); + if (!hasTrigger) { + warnings.push( + ValidationWarning.informational( + 'MISSING_TRIGGER', + 'Workflow has no trigger node. It will need to be started manually.', + ), + ); + } + } + + // Check for disconnected nodes + if (!options.allowDisconnectedNodes) { + const disconnected = findDisconnectedNodes(json); + for (const nodeName of disconnected) { + warnings.push( + ValidationWarning.informational( + 'DISCONNECTED_NODE', + `Node '${nodeName}' is not connected to any input. It will not receive data.`, + nodeName, + ), + ); + } + } + + // Strict mode validations + if (options.strictMode) { + // Check for potentially missing required parameters + for (const node of json.nodes) { + // HTTP Request should have a URL + if (isHttpRequestType(node.type)) { + if (!node.parameters?.url && !node.parameters?.requestUrl) { + warnings.push( + new ValidationWarning( + 'MISSING_PARAMETER', + `HTTP Request node '${node.name}' may be missing URL parameter`, + node.name, + ), + ); + } + } + } + } + + // Schema validation (enabled by default) + if (options.validateSchema !== false) { + for (const node of json.nodes) { + // Get version number (handle both number and string versions) + const version = + typeof node.typeVersion === 'string' + ? parseFloat(node.typeVersion) + : (node.typeVersion ?? 1); + + // Build config object for validation + const config: { parameters?: unknown; subnodes?: unknown } = {}; + if (node.parameters !== undefined) { + config.parameters = node.parameters; + } + // Include subnodes if present (for AI nodes) + const nodeWithSubnodes = node as typeof node & { subnodes?: unknown }; + if (nodeWithSubnodes.subnodes !== undefined) { + config.subnodes = nodeWithSubnodes.subnodes; + } else if (node.name) { + // Try to reconstruct subnodes from AI connections in the workflow + // This handles the case where subnodes were serialized as separate nodes + const reconstructed = reconstructSubnodesFromConnections(node.name, json); + if (reconstructed) { + config.subnodes = reconstructed; + } + } + + // Determine if this node is being used as a tool (for @tool displayOptions) + // A node is a tool if it's connected via ai_tool connection type + const isToolNode = node.name ? isToolSubnode(node.name, json) : false; + + const schemaResult = validateNodeConfig(node.type, version, config, { isToolNode }); + + if (!schemaResult.valid) { + for (const error of schemaResult.errors) { + let message = error.message; + + // Enhance subnode errors with valid options when nodeTypesProvider is available + if ( + error.path === 'subnodes' && + message.includes('Unknown field') && + options.nodeTypesProvider + ) { + const nodeType = options.nodeTypesProvider.getByNameAndVersion(node.type, version); + const validInputs = nodeType?.description?.builderHint?.inputs; + if (validInputs) { + const validSubnodes = Object.keys(validInputs) + .map((k) => AI_CONNECTION_TO_SUBNODE_FIELD[k]) + .filter(Boolean); + if (validSubnodes.length > 0) { + // Transform message from "Unknown field(s) at "subnodes": "x", "y"." + // to "Invalid subnode(s) "x", "y". This node only accepts: a, b." + message = message.replace( + /Unknown field\(s\) at "subnodes": (.+)\./, + `Invalid subnode(s) $1. This node only accepts: ${validSubnodes.join(', ')}.`, + ); + } + } + } + + // Report as WARNING (non-blocking) to maintain backwards compatibility + warnings.push( + new ValidationWarning( + 'INVALID_PARAMETER', + `Node "${node.name}": ${message}`, + node.name, + ), + ); + } + } + } + } + + // Input index validation (only if provider is given) + if (options.nodeTypesProvider) { + checkNodeInputIndices(json, options.nodeTypesProvider, warnings); + // Validate that connections originate from output ports that actually exist + checkNodeOutputIndices(json, options.nodeTypesProvider, warnings); + // Validate subnode parameters match parent's displayOptions requirements + validateSubnodeParameters(json, options.nodeTypesProvider, warnings); + // Validate parent nodes actually support their connected AI input types + validateParentSupportsInputs(json, options.nodeTypesProvider, warnings); + // Validate required AI inputs on parent nodes are actually connected + validateRequiredInputsConnected(json, options.nodeTypesProvider, errors); + // Validate that emitted connection types are actually exposed by the source node's mode + validateOutputUsage(json, options.nodeTypesProvider, warnings); + // Reject placeholder() in slots that opt out via builderHint.placeholderSupported === false + validatePlaceholderSlots(json, options.nodeTypesProvider, errors); + } + + // Switch fallback output validation does not need node metadata. It is derived from + // the Switch node's dynamic output contract in rules mode. + validateSwitchHasOutgoingConnections(json, warnings); + validateSwitchFallbackOutputConnections(json, warnings); + + // Merge node input-count consistency + checkMergeNodeInputCount(json, warnings); + + // Duplicate connections to single-value AI inputs + checkDuplicateSingleValueAiConnections(json, warnings); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate that the Merge node's `numberInputs` parameter is consistent with + * the input indices actually used by incoming connections. + * + * The Merge node has expression-based inputs (count derived from + * `numberInputs`, default 2), so checkNodeInputIndices can't resolve the count + * statically. Without this check, a workflow with three branches wired into a + * Merge node that still has `numberInputs=2` passes validation and silently + * drops the third branch at runtime. + */ +function checkMergeNodeInputCount(json: WorkflowJSON, warnings: ValidationWarning[]): void { + const connectionsByDest = mapConnectionsByDestination( + json.connections as unknown as N8nIConnections, + ); + + for (const node of json.nodes) { + if (!node.name) continue; + if (node.type !== 'n8n-nodes-base.merge') continue; + + const numberInputsParam = node.parameters?.numberInputs; + const declaredInputs = typeof numberInputsParam === 'number' ? numberInputsParam : 2; + + const incomingMain = connectionsByDest[node.name]?.main; + if (!incomingMain) continue; + + let maxConnectedIndex = -1; + for (let i = 0; i < incomingMain.length; i++) { + const slot = incomingMain[i]; + if (Array.isArray(slot) && slot.length > 0) { + maxConnectedIndex = i; + } + } + + if (maxConnectedIndex >= declaredInputs) { + warnings.push( + new ValidationWarning( + 'INVALID_INPUT_INDEX', + `Merge node '${node.name}' has a connection to input index ${maxConnectedIndex} but 'numberInputs' is ${declaredInputs}. Set 'numberInputs' to ${maxConnectedIndex + 1} so every branch is accepted.`, + node.name, + 'numberInputs', + undefined, + 'major', + ), + ); + } + } +} + +/** + * Mapping from AI connection type to SDK function name (for error messages) + */ +const AI_CONNECTION_TO_SDK_FUNCTION: Record = { + ai_languageModel: 'languageModel()', + ai_memory: 'memory()', + ai_tool: 'tool()', + ai_outputParser: 'outputParser()', + ai_embedding: 'embeddings()', + ai_vectorStore: 'vectorStore()', + ai_retriever: 'retriever()', + ai_document: 'documentLoader()', + ai_textSplitter: 'textSplitter()', + ai_reranker: 'reranker()', +}; + +/** + * Check if a subnode's parameters satisfy displayOptions conditions + */ +function checkDisplayOptionsMatch( + subnodeParams: Record, + displayOptions: IDisplayOptions, +): { + matches: boolean; + mismatches: Array<{ param: string; expected: unknown[]; actual: unknown }>; +} { + const mismatches: Array<{ param: string; expected: unknown[]; actual: unknown }> = []; + + if (!displayOptions.show) return { matches: true, mismatches }; + + for (const [paramName, expectedValues] of Object.entries(displayOptions.show)) { + if (!expectedValues) continue; // Skip undefined values + const actualValue = subnodeParams[paramName]; + if (!expectedValues.includes(actualValue as never)) { + mismatches.push({ + param: paramName, + expected: expectedValues as unknown[], + actual: actualValue, + }); + } + } + + return { matches: mismatches.length === 0, mismatches }; +} + +/** + * Validate that subnodes connected to parent nodes have parameters + * matching the displayOptions conditions in builderHint.inputs. + * + * For example, if an Agent's ai_tool input has displayOptions.show = { mode: ['retrieve-as-tool'] }, + * then any node connected via ai_tool must have mode='retrieve-as-tool'. + */ +function validateSubnodeParameters( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + warnings: ValidationWarning[], +): void { + // Build a map of node name -> node for quick lookup + const nodesByName = new Map(); + for (const node of json.nodes) { + if (node.name) { + nodesByName.set(node.name, node); + } + } + + // Invert connections to find incoming connections by destination + // Cast to n8n-workflow IConnections since our local type has string for connection type + const connectionsByDest = mapConnectionsByDestination( + json.connections as unknown as N8nIConnections, + ); + + // Check each node that might be a parent with AI inputs + for (const parentNode of json.nodes) { + if (!parentNode.name) continue; + + // Try to get the node type to check for builderHint.inputs + const parentNodeType = nodeTypesProvider.getByNameAndVersion( + parentNode.type, + typeof parentNode.typeVersion === 'string' + ? parseFloat(parentNode.typeVersion) + : (parentNode.typeVersion ?? 1), + ); + const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; + if (!builderHintInputs) continue; + + // For each AI input type the parent accepts + for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { + if (!connectionType.startsWith('ai_')) continue; + if (!inputConfig?.displayOptions?.show) continue; + + // Find subnodes connected via this type + const incomingConnections = connectionsByDest[parentNode.name]?.[connectionType]; + if (!incomingConnections) continue; + + for (const connList of incomingConnections) { + if (!connList) continue; + for (const conn of connList) { + const subnodeName = conn.node; + const subnode = nodesByName.get(subnodeName); + if (!subnode?.parameters) continue; + + // Check if subnode params match displayOptions conditions + const { matches, mismatches } = checkDisplayOptionsMatch( + subnode.parameters, + inputConfig.displayOptions, + ); + + if (!matches) { + // `displayOptions` on `builderHint.inputs[type]` can describe + // either subnode-relative params (e.g. ai_vectorStore wants + // vector-store mode='retrieve-as-tool') or parent-relative + // params (e.g. ai_memory wants chatTrigger mode='hostedChat'). + // If every mismatched param is absent from the subnode, those + // params don't belong to the subnode at all — blaming it is a + // false-positive misdirect. Defer to validateParentSupportsInputs. + const subnodeOwnsAnyParam = mismatches.some((m) => m.actual !== undefined); + if (!subnodeOwnsAnyParam) continue; + + const sdkFn = AI_CONNECTION_TO_SDK_FUNCTION[connectionType] || connectionType; + + // Build error message with actual parameter names from displayOptions + const mismatchDetails = mismatches + .map( + (m) => + `${m.param}='${String(m.actual)}' (expected: ${m.expected.map((v) => `'${String(v)}'`).join(' or ')})`, + ) + .join(', '); + + warnings.push( + new ValidationWarning( + 'SUBNODE_PARAMETER_MISMATCH', + `'${subnodeName}' is connected to '${parentNode.name}' using ${sdkFn} but has ${mismatchDetails}. Update parameters to match the SDK function used.`, + subnodeName, + mismatches[0]?.param, + ), + ); + } + } + } + } + } +} + +/** + * Build a human-readable summary of which displayOptions conditions are not met. + */ +function buildConditionSummary( + displayOptions: IDisplayOptions, + parentParams: Record, +): string { + if (!displayOptions.show) return ''; + + const parts: string[] = []; + for (const [paramName, expectedValues] of Object.entries(displayOptions.show)) { + if (!expectedValues) continue; + // Use lodash get so nested paths (e.g. 'options.loadPreviousSession') + // resolve correctly — direct property access would read the literal + // dotted key and report 'undefined' even when the nested value is set. + const actual = get(parentParams, paramName); + const expectedStr = (expectedValues as unknown[]).map((v) => `'${String(v)}'`).join(' or '); + parts.push(`${paramName} should be ${expectedStr} (currently '${String(actual)}')`); + } + + return parts.length > 0 ? `Required: ${parts.join(', ')}.` : ''; +} + +/** + * Build a description of which parameters TRIGGERED a requirement. + * Used by `MISSING_REQUIRED_INPUT` where the displayOptions conditions are + * already satisfied (that's why the requirement applies) — the agent needs + * to know which params caused it so it can choose between satisfying the + * requirement or backing out by changing those params. + */ +function buildTriggeringConditionSummary( + displayOptions: IDisplayOptions, + parentParams: Record, +): string { + if (!displayOptions.show) return ''; + + const parts: string[] = []; + for (const [paramName, _expectedValues] of Object.entries(displayOptions.show)) { + const actual = get(parentParams, paramName); + parts.push(`${paramName}='${String(actual)}'`); + } + + return parts.join(', '); +} + +/** + * Validate that parent nodes actually support their connected AI input types + * based on the parent's own parameters and builderHint.inputs displayOptions. + * + * For example, if a vector store has mode='retrieve' but a documentLoader is connected, + * this produces a warning because ai_document requires mode='insert'. + */ +function validateParentSupportsInputs( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + warnings: ValidationWarning[], +): void { + const nodesByName = new Map(); + for (const node of json.nodes) { + if (node.name) { + nodesByName.set(node.name, node); + } + } + + const connectionsByDest = mapConnectionsByDestination( + json.connections as unknown as N8nIConnections, + ); + + for (const parentNode of json.nodes) { + if (!parentNode.name) continue; + + const version = + typeof parentNode.typeVersion === 'string' + ? parseFloat(parentNode.typeVersion) + : (parentNode.typeVersion ?? 1); + + const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version); + const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; + if (!builderHintInputs) continue; + + const parentContext: DisplayOptionsContext = { + parameters: (parentNode.parameters ?? {}) as Record, + nodeVersion: version, + rootParameters: (parentNode.parameters ?? {}) as Record, + }; + + for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { + if (!connectionType.startsWith('ai_')) continue; + if (!inputConfig?.displayOptions) continue; + + const parentSupportsInput = matchesDisplayOptions( + parentContext, + inputConfig.displayOptions as DisplayOptions, + ); + + if (parentSupportsInput) continue; + + const incomingConnections = connectionsByDest[parentNode.name]?.[connectionType]; + if (!incomingConnections) continue; + + for (const connList of incomingConnections) { + if (!connList) continue; + for (const conn of connList) { + const subnodeName = conn.node; + const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType; + const conditionDetails = buildConditionSummary( + inputConfig.displayOptions, + (parentNode.parameters ?? {}) as Record, + ); + + warnings.push( + new ValidationWarning( + 'UNSUPPORTED_SUBNODE_INPUT', + `'${parentNode.name}' has a ${subnodeField} subnode ('${subnodeName}') connected, but its current configuration does not accept one. ${conditionDetails} These parameters must be set on '${parentNode.name}' itself, NOT on the ${subnodeField} subnode. Alternatively, remove the ${subnodeField} connection if this capability isn't needed.`, + parentNode.name, + undefined, + undefined, + 'major', + ), + ); + } + } + } + } +} + +/** + * Validate that required AI inputs declared in a parent node's builderHint.inputs + * are actually connected. + * + * For each parent node with a builderHint.inputs entry that has `required: true`, + * check whether its displayOptions (if any) match the parent's current parameters; + * if so, require that a connection of that AI type terminates at the parent. + * Emits a fatal error when the connection is missing — without it, the workflow + * silently passes validation but breaks at runtime (see INS-136: chat trigger + * with `loadPreviousSession: 'memory'` but no memory subnode connected). + */ +function validateRequiredInputsConnected( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + errors: ValidationError[], +): void { + const connectionsByDest = mapConnectionsByDestination( + json.connections as unknown as N8nIConnections, + ); + + for (const parentNode of json.nodes) { + if (!parentNode.name) continue; + + const version = + typeof parentNode.typeVersion === 'string' + ? parseFloat(parentNode.typeVersion) + : (parentNode.typeVersion ?? 1); + + const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version); + const builderHintInputs = parentNodeType?.description?.builderHint?.inputs; + if (!builderHintInputs) continue; + + const parentContext: DisplayOptionsContext = { + parameters: (parentNode.parameters ?? {}) as Record, + nodeVersion: version, + rootParameters: (parentNode.parameters ?? {}) as Record, + }; + + for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) { + if (!connectionType.startsWith('ai_')) continue; + if (!inputConfig?.required) continue; + + if (inputConfig.displayOptions) { + const conditionsMet = matchesDisplayOptions( + parentContext, + inputConfig.displayOptions as DisplayOptions, + ); + if (!conditionsMet) continue; + } + + const incoming = connectionsByDest[parentNode.name]?.[connectionType]; + const hasConnection = + Array.isArray(incoming) && incoming.some((slot) => Array.isArray(slot) && slot.length > 0); + if (hasConnection) continue; + + const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType; + const triggerDetails = inputConfig.displayOptions + ? ` (triggered by ${buildTriggeringConditionSummary( + inputConfig.displayOptions, + (parentNode.parameters ?? {}) as Record, + )})` + : ''; + const alternative = inputConfig.displayOptions + ? ` Either connect a ${subnodeField} subnode, or change those parameters to remove the requirement.` + : ''; + + errors.push( + new ValidationError( + 'MISSING_REQUIRED_INPUT', + `'${parentNode.name}' requires a ${subnodeField} subnode connected to its ${connectionType} input${triggerDetails}, but none is connected.${alternative}`, + parentNode.name, + undefined, + 'major', + ), + ); + } + } +} + +/** + * Render an outgoing connection type as the SDK syntax that produces it, so warning + * messages speak the LLM agent's vocabulary instead of raw `main` / `ai_*` types. + * + * `target` flips the phrasing between describing the wiring already used by the source + * (e.g. `wired with .to()`) and describing where the source SHOULD attach instead + * (e.g. `subnodes.tools`). + */ +function describeOutputWiring(connectionType: string, target = false): string { + if (connectionType === 'main') return target ? '.to(...)' : 'wired with .to()'; + const field = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType]; + if (field) return target ? `subnodes.${field}` : `attached as subnodes.${field}`; + return target ? connectionType : `connected via ${connectionType}`; +} + +/** + * Return the connection type from `outputsHint` whose displayOptions match the source + * node's current parameters — i.e. the output the node actually exposes given how it's + * configured. Used to suggest the correct wiring fix in `INVALID_OUTPUT_FOR_MODE`. + */ +function findEnabledAlternativeOutput( + outputsHint: Record, + ctx: DisplayOptionsContext, + excludeType: string, +): string | undefined { + for (const [type, cfg] of Object.entries(outputsHint)) { + if (type === excludeType) continue; + if (!cfg) continue; + if (cfg.displayOptions && !matchesDisplayOptions(ctx, cfg.displayOptions as DisplayOptions)) { + continue; + } + return type; + } + return undefined; +} + +/** + * Validate that connections leaving a node use connection types the node's current + * parameters actually expose. Driven by `builderHint.outputs` declared on the source node. + * + * Example: a vector store in `mode: 'retrieve'` exposes only `ai_vectorStore`. If the workflow + * has a `main` connection out of that node, this emits `INVALID_OUTPUT_FOR_MODE` because + * `builderHint.outputs.main.displayOptions` requires `mode` ∈ ['insert', 'load', 'update']. + */ +function validateOutputUsage( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + warnings: ValidationWarning[], +): void { + for (const sourceNode of json.nodes) { + if (!sourceNode.name) continue; + + const outgoing = json.connections[sourceNode.name]; + if (!outgoing) continue; + + const version = + typeof sourceNode.typeVersion === 'string' + ? parseFloat(sourceNode.typeVersion) + : (sourceNode.typeVersion ?? 1); + + const nodeType = nodeTypesProvider.getByNameAndVersion(sourceNode.type, version); + const outputsHint = nodeType?.description?.builderHint?.outputs; + if (!outputsHint) continue; + + const ctx: DisplayOptionsContext = { + parameters: (sourceNode.parameters ?? {}) as Record, + nodeVersion: version, + rootParameters: (sourceNode.parameters ?? {}) as Record, + }; + + for (const [connectionType, cfg] of Object.entries(outputsHint)) { + // No displayOptions => assume the node always emits this connection type. + if (!cfg?.displayOptions) continue; + + const edges = outgoing[connectionType]; + if (!edges) continue; + const hasEdges = edges.some((slot) => Array.isArray(slot) && slot.length > 0); + if (!hasEdges) continue; + + const enabled = matchesDisplayOptions(ctx, cfg.displayOptions as DisplayOptions); + if (enabled) continue; + + const conditionDetails = buildConditionSummary( + cfg.displayOptions, + (sourceNode.parameters ?? {}) as Record, + ); + const usedWiring = describeOutputWiring(connectionType); + const enabledAlt = findEnabledAlternativeOutput(outputsHint, ctx, connectionType); + const altSuggestion = enabledAlt + ? ` To use this node as-is, attach it as ${describeOutputWiring(enabledAlt, true)} of a parent (it exposes ${enabledAlt} in this configuration).` + : ''; + + warnings.push( + new ValidationWarning( + 'INVALID_OUTPUT_FOR_MODE', + `'${sourceNode.name}' is ${usedWiring} but its current parameters disable that output. ${conditionDetails}${altSuggestion}`, + sourceNode.name, + undefined, + undefined, + 'major', + ), + ); + } + } +} + +function getSwitchRulesCount(parameters: Record | undefined): number { + const rules = parameters?.rules; + if (!isRecord(rules)) return 0; + + const values = rules.values; + if (Array.isArray(values)) return values.length; + + const legacyRules = rules.rules; + if (Array.isArray(legacyRules)) return legacyRules.length; + + return 0; +} + +function getSwitchFallbackOutput(parameters: Record | undefined): unknown { + const options = parameters?.options; + if (!isRecord(options)) return undefined; + + return options.fallbackOutput; +} + +function hasOutputConnections( + outputs: Array | null>, + outputIndex: number, +): boolean { + const output = outputs[outputIndex]; + return Array.isArray(output) && output.length > 0; +} + +function hasAnyMainOutputConnection(nodeConnections: unknown): boolean { + if (!isRecord(nodeConnections)) return false; + const main = nodeConnections.main; + if (!Array.isArray(main)) return false; + + return main.some((slot) => Array.isArray(slot) && slot.length > 0); +} + +/** + * A Switch with no outgoing branches is almost always an incomplete router: + * every matched item is dropped and downstream side effects never run. + */ +function validateSwitchHasOutgoingConnections( + json: WorkflowJSON, + warnings: ValidationWarning[], +): void { + for (const sourceNode of json.nodes) { + if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue; + if (hasAnyMainOutputConnection(json.connections[sourceNode.name])) continue; + + warnings.push( + new ValidationWarning( + 'SWITCH_NO_OUTPUT_CONNECTIONS', + `Switch node '${sourceNode.name}' has no outgoing connections. Connect at least one output branch to downstream action nodes, or remove the Switch node.`, + sourceNode.name, + 'connections', + undefined, + 'major', + ), + ); + } +} + +/** + * Validate that Switch fallback branches are only connected when the node + * actually exposes an extra fallback output. + */ +function validateSwitchFallbackOutputConnections( + json: WorkflowJSON, + warnings: ValidationWarning[], +): void { + for (const sourceNode of json.nodes) { + if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue; + + const mode = sourceNode.parameters?.mode; + if (mode !== undefined && mode !== 'rules') continue; + + const outgoing = json.connections[sourceNode.name]; + const mainOutputs = outgoing?.main; + if (!Array.isArray(mainOutputs)) continue; + + const rulesCount = getSwitchRulesCount(sourceNode.parameters); + const fallbackOutput = getSwitchFallbackOutput(sourceNode.parameters); + if (fallbackOutput === 'extra') continue; + + for (let outputIndex = rulesCount; outputIndex < mainOutputs.length; outputIndex++) { + if (!hasOutputConnections(mainOutputs, outputIndex)) continue; + + const isErrorOutput = + sourceNode.onError === 'continueErrorOutput' && outputIndex === rulesCount; + if (isErrorOutput) continue; + + warnings.push( + new ValidationWarning( + 'SWITCH_FALLBACK_OUTPUT_DISABLED', + `Switch node '${sourceNode.name}' has a connection from output ${outputIndex}, but rules mode only creates fallback output ${rulesCount} when options.fallbackOutput is set to 'extra'. Set options.fallbackOutput to 'extra' before wiring a catch-all branch, or route unmatched items to an existing rule output with a numeric fallbackOutput value.`, + sourceNode.name, + 'options.fallbackOutput', + undefined, + 'major', + ), + ); + } + } +} + +/** + * Reject `placeholder()` markers found in parameter slots whose property + * description carries `builderHint.placeholderSupported === false`. + * + * This is the runtime side of the type-level signal that used to live in the + * generated `string | Expression` union (which previously omitted + * `PlaceholderValue`). Now that `placeholder()` returns a plain `string`, the + * type system can no longer block placement; this validator does at runtime. + * + * Uses `containsPlaceholderMarker` (not `isPlaceholderValue`) so that the + * marker is rejected anywhere in the value — including `expr(placeholder())`, + * which produces `=<__PLACEHOLDER_VALUE__…__>`, and placeholders embedded + * inside `={{ … }}` expressions. + * + * Walks top-level properties only — the known declarations + * (webhook `path`, langchain agent `text`) are top-level fields. Nested + * collection / fixedCollection support can be added later if a node opts out + * of placeholders for a nested field. + */ +function validatePlaceholderSlots( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + errors: ValidationError[], +): void { + for (const node of json.nodes) { + if (!node.name || !node.parameters) continue; + + const version = + typeof node.typeVersion === 'string' ? parseFloat(node.typeVersion) : (node.typeVersion ?? 1); + + const nodeType = nodeTypesProvider.getByNameAndVersion(node.type, version); + const properties = nodeType?.description?.properties; + if (!properties) continue; + + const params = node.parameters as Record; + for (const prop of properties) { + if (prop.builderHint?.placeholderSupported !== false) continue; + const value = params[prop.name]; + if (!containsPlaceholderMarker(value)) continue; + + errors.push( + new ValidationError( + 'INVALID_PARAMETER', + `Node "${node.name}": placeholder() is not supported for parameter '${prop.name}'. Use a literal value or expr() instead.`, + node.name, + prop.name, + ), + ); + } + } +} + +/** + * Check if connections use valid input indices for their target nodes. + * Reports warnings for connections to input indices that don't exist. + */ +/** + * Validate that every main connection originates from an output port the + * source node actually has. The legal slots are the node type's natural main + * outputs, plus one trailing error pin when the node sets + * `onError: 'continueErrorOutput'`. Connections from any higher index render + * as impossible edges on the canvas (INS-425). + */ +function checkNodeOutputIndices( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + warnings: ValidationWarning[], +): void { + const nodesByName = new Map(); + for (const node of json.nodes) { + if (node.name) { + nodesByName.set(node.name, node); + } + } + + for (const [sourceName, nodeConnections] of Object.entries(json.connections)) { + const mainConnections = nodeConnections.main; + if (!mainConnections || !Array.isArray(mainConnections)) continue; + + const sourceNode = nodesByName.get(sourceName); + if (!sourceNode) continue; + + const version = + typeof sourceNode.typeVersion === 'string' + ? parseFloat(sourceNode.typeVersion) + : (sourceNode.typeVersion ?? 1); + + const mainOutputCount = resolveMainOutputCount(nodeTypesProvider, sourceNode.type, version); + + // If we couldn't resolve (dynamic outputs or unknown node), skip validation + if (mainOutputCount === undefined) continue; + + const errorPinCount = sourceNode.onError === 'continueErrorOutput' ? 1 : 0; + const allowedOutputCount = mainOutputCount + errorPinCount; + + for (let outputIndex = 0; outputIndex < mainConnections.length; outputIndex++) { + const outputs = mainConnections[outputIndex]; + if (!outputs || outputs.length === 0) continue; + + if (outputIndex >= allowedOutputCount) { + warnings.push( + new ValidationWarning( + 'INVALID_OUTPUT_INDEX', + `Connection from '${sourceName}' uses output index ${outputIndex}, but node only has ${allowedOutputCount} output(s) (indices 0-${allowedOutputCount - 1}). To route the error output, set onError: 'continueErrorOutput' on the node and use .onError(target).`, + sourceName, + ), + ); + } + } + } +} + +function checkNodeInputIndices( + json: WorkflowJSON, + nodeTypesProvider: INodeTypes, + warnings: ValidationWarning[], +): void { + // Build a map of node name -> node for quick lookup + const nodesByName = new Map(); + for (const node of json.nodes) { + if (node.name) { + nodesByName.set(node.name, node); + } + } + + // Track which (nodeName, inputIndex) pairs we've already warned about + // to avoid duplicate warnings when multiple sources connect to the same invalid input + const warnedInputs = new Set(); + + // Scan all connections to check input indices + for (const [_sourceName, nodeConnections] of Object.entries(json.connections)) { + // Only check main connections (not AI connections) + const mainConnections = nodeConnections.main; + if (!mainConnections || !Array.isArray(mainConnections)) continue; + + for (const outputs of mainConnections) { + if (!outputs) continue; + for (const conn of outputs) { + const targetNodeName = conn.node; + const targetInputIndex = conn.index; + + const targetNode = nodesByName.get(targetNodeName); + if (!targetNode) continue; + + // Get version number + const version = + typeof targetNode.typeVersion === 'string' + ? parseFloat(targetNode.typeVersion) + : (targetNode.typeVersion ?? 1); + + // Resolve the number of main inputs for this node type + const mainInputCount = resolveMainInputCount(nodeTypesProvider, targetNode.type, version); + + // If we couldn't resolve (dynamic inputs or unknown node), skip validation + if (mainInputCount === undefined) continue; + + // Check if the input index is valid + if (targetInputIndex < 0 || targetInputIndex >= mainInputCount) { + const warnKey = `${targetNodeName}:${targetInputIndex}`; + if (!warnedInputs.has(warnKey)) { + warnedInputs.add(warnKey); + warnings.push( + new ValidationWarning( + 'INVALID_INPUT_INDEX', + `Connection to '${targetNodeName}' uses input index ${targetInputIndex}, but node only has ${mainInputCount} input(s) (indices 0-${mainInputCount - 1})`, + targetNodeName, + ), + ); + } + } + } + } + } +} diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts index 63d616152e6..ec50b195eb1 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder-plugins.test.ts @@ -1083,7 +1083,7 @@ describe('WorkflowBuilder plugin integration', () => { }); describe('Phase 11.2: missingTriggerValidator plugin', () => { - it('validateWorkflow returns warning when no trigger node exists', () => { + it('validateWorkflow returns informational when no trigger node exists', () => { // Nodes map with only non-trigger nodes const nodesMap = new Map(); nodesMap.set('Set', { @@ -1102,7 +1102,7 @@ describe('WorkflowBuilder plugin integration', () => { expect(issues.length).toBe(1); expect(issues[0].code).toBe('MISSING_TRIGGER'); - expect(issues[0].severity).toBe('warning'); + expect(issues[0].severity).toBe('informational'); }); it('validateWorkflow returns empty array when trigger node exists', () => { diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.ts index bdb33ddcfc3..4417686e322 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.ts @@ -747,6 +747,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder { issue.parameterPath, issue.originalName, issue.violationLevel, + issue.severity === 'informational' ? 'informational' : 'warning', ), ); } diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts index e0ce27a4507..81d417e6802 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts @@ -62,8 +62,13 @@ export interface ValidationIssue { readonly code: string; /** Human-readable message describing the issue */ readonly message: string; - /** Severity level: 'error' for fatal issues, 'warning' for non-fatal */ - readonly severity: 'error' | 'warning'; + /** + * Severity at the creation site: + * - `error` — fatal for `ValidationResult.valid` + * - `warning` — non-fatal for `valid`, blocks save / CLI exit + * - `informational` — never blocks save / CLI exit + */ + readonly severity: 'error' | 'warning' | 'informational'; /** Violation level for evaluation scoring (defaults to 'minor' if not set) */ readonly violationLevel?: 'critical' | 'major' | 'minor'; /** Name of the node where the issue was found (optional) */ diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/disconnected-node-validator.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/disconnected-node-validator.ts index d8f2c282b50..09a8fab0a23 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/disconnected-node-validator.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/disconnected-node-validator.ts @@ -215,7 +215,7 @@ export const disconnectedNodeValidator: ValidatorPlugin = { issues.push({ code: 'DISCONNECTED_NODE', message: `${nodeRef} is not connected to any input. It will not receive data.`, - severity: 'warning', + severity: 'informational', violationLevel: 'major', nodeName: displayName, originalName: origForWarning, diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/missing-trigger-validator.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/missing-trigger-validator.ts index 1e739c869ec..e469e63ce61 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/missing-trigger-validator.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/missing-trigger-validator.ts @@ -40,7 +40,7 @@ export const missingTriggerValidator: ValidatorPlugin = { { code: 'MISSING_TRIGGER', message: 'Workflow has no trigger node. It will need to be started manually.', - severity: 'warning', + severity: 'informational', }, ]; } diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/11.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/11.json new file mode 100644 index 00000000000..e74a7e93b84 --- /dev/null +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/11.json @@ -0,0 +1,29 @@ +{ + "id": "lint-code-network", + "name": "Lint: Code node network call", + "nodes": [ + { + "parameters": {}, + "id": "trigger-1", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "jsCode": "const res = await fetch('https://example.com');\nreturn [{ json: { ok: true } }];" + }, + "id": "code-1", + "name": "Fetch Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [220, 0] + } + ], + "connections": { + "Manual Trigger": { + "main": [[{ "node": "Fetch Data", "type": "main", "index": 0 }]] + } + } +} diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/12.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/12.json new file mode 100644 index 00000000000..76344deb770 --- /dev/null +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/12.json @@ -0,0 +1,49 @@ +{ + "id": "lint-unsolicited-sticky", + "name": "Lint: unsolicited sticky note", + "nodes": [ + { + "parameters": {}, + "id": "trigger-1", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "content": "Please do not add this unless asked" + }, + "id": "sticky-1", + "name": "Sticky Note", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [220, -120] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "id-1", + "name": "value", + "value": "ok", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "set-1", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [220, 0] + } + ], + "connections": { + "Manual Trigger": { + "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] + } + } +} diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/13.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/13.json new file mode 100644 index 00000000000..266c66509a5 --- /dev/null +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/13.json @@ -0,0 +1,30 @@ +{ + "id": "lint-python-network", + "name": "Lint: Python Code node network import", + "nodes": [ + { + "parameters": {}, + "id": "trigger-1", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "language": "pythonNative", + "pythonCode": "import requests\nreturn [{ \"json\": { \"ok\": True } }]" + }, + "id": "code-1", + "name": "Python Fetch", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [220, 0] + } + ], + "connections": { + "Manual Trigger": { + "main": [[{ "node": "Python Fetch", "type": "main", "index": 0 }]] + } + } +} diff --git a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json index 11072ccdab2..7efc26fe78e 100644 --- a/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json +++ b/packages/@n8n/workflow-sdk/test-fixtures/committed-workflows/manifest.json @@ -109,6 +109,24 @@ "id": 10, "name": "Node groups - linear chain with a grouped section", "success": true + }, + { + "id": 11, + "name": "Lint: Code node network call", + "success": true, + "expectedLintIssues": [{ "code": "CODE_NODE_NETWORK_CALL" }] + }, + { + "id": 12, + "name": "Lint: unsolicited sticky note", + "success": true, + "expectedLintIssues": [{ "code": "SDK_UNSOLICITED_STICKY" }] + }, + { + "id": 13, + "name": "Lint: Python Code node network import", + "success": true, + "expectedLintIssues": [{ "code": "CODE_NODE_NETWORK_CALL" }] } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbf238e984..08687c4652d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3978,6 +3978,9 @@ importers: '@dagrejs/dagre': specifier: ^1.1.4 version: 1.1.4 + '@n8n/constants': + specifier: workspace:* + version: link:../constants '@n8n/utils': specifier: workspace:* version: link:../utils