feat(core): Add workflow SDK validation framework for Instance AI (#35045)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir
2026-07-30 17:18:11 +02:00
committed by GitHub
parent 3ca4828a20
commit bbd7b7db88
64 changed files with 4144 additions and 1466 deletions
@@ -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
}
@@ -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.
@@ -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/<name>.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/<name>.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 <filePath>`
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.
@@ -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 () => {
@@ -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)
@@ -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();
@@ -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,
});
});
});
@@ -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}`,
),
);
@@ -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,
});
}
@@ -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);
}
+6 -1
View File
@@ -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:",
@@ -40,6 +40,7 @@ export {
BUILDER_BLOCKED_GLOBALS,
SDK_INLINE_CONSTRAINTS,
DANGEROUS_GLOBALS,
getSafeJSONMethod,
isAllowedSDKFunction,
isAllowedMethod,
} from './validators';
+23 -7
View File
@@ -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 <json-to-code|code-to-json> <file-path>');
async function main(): Promise<void> {
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-to-code|code-to-json|validate> <file-path> [--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();
@@ -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([]);
});
});
@@ -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 (`<pkg>/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 `<pkg>/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]);
}
@@ -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 <file-path> [--json] [--node-types <dir>]');
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<ReportEntry, 'line' | 'column'>): 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<void> {
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);
}
@@ -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
@@ -254,6 +254,9 @@ describe('emit-instance-ai', () => {
'validateWorkflow',
'getSchemaBaseDirs',
'setSchemaBaseDirs',
'isInformationalIssue',
'partitionValidationIssues',
'validateWorkflowBuilder',
// Pin-data + schema discovery
'discoverOutputSchemaForNode',
'discoverSchemasForNode',
@@ -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', () => {
+7
View File
@@ -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
@@ -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<keyof Node>) {
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<string>();
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;
}
@@ -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',
]);
});
});
@@ -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',
}),
]);
});
});
@@ -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<Node, Node>,
): 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<Node, Node> {
const parents = new Map<Node, Node>();
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<Node, Node> = 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));
}
@@ -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;
}
@@ -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;
}
@@ -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';
@@ -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,
);
});
});
@@ -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]);
}
@@ -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'");
});
});
@@ -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<string, { count: number; line?: number; column?: number }>();
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);
}
@@ -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<IssueSeverity, 'informational'>;
/** 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<SourceLintIssue, 'severity'> & { severity?: 'informational' },
): SourceLintIssue {
return { severity: 'informational', ...issue };
}
+12 -1
View File
@@ -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';
File diff suppressed because it is too large Load Diff
@@ -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]],
});
});
});
@@ -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';
@@ -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', () => {
@@ -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<T>(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 };
}
@@ -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 };
@@ -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';
@@ -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)) {
@@ -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<void> {
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)) {
@@ -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 =>
@@ -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);
});
});
@@ -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 <dir> 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<ValidationError | ValidationWarning>,
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<string>();
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);
}
}
@@ -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', () => {
File diff suppressed because it is too large Load Diff
@@ -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', () => {
@@ -747,6 +747,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
issue.parameterPath,
issue.originalName,
issue.violationLevel,
issue.severity === 'informational' ? 'informational' : 'warning',
),
);
}
@@ -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) */
@@ -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,
@@ -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',
},
];
}
@@ -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 }]]
}
}
}
@@ -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 }]]
}
}
}
@@ -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 }]]
}
}
}
@@ -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" }]
}
]
}
+3
View File
@@ -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