mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Add workflow SDK validation framework for Instance AI (#35045)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
+8
-1
@@ -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();
|
||||
|
||||
+18
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
+34
@@ -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 };
|
||||
}
|
||||
+1
-1
@@ -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 };
|
||||
+1
-1
@@ -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';
|
||||
+2
-2
@@ -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)) {
|
||||
+3
-3
@@ -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
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
-1
@@ -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) */
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user