feat(validator): detect IF/Switch/Filter conditional branch fan-out misuse

Add CONDITIONAL_BRANCH_FANOUT warning when conditional nodes have all
connections on main[0] with higher outputs empty, indicating both
branches execute together instead of being split by condition.

Extract getShortNodeType() and getConditionalOutputInfo() helpers to
deduplicate conditional node detection logic.

Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2026-03-08 08:10:04 +01:00
parent 2e472ebc6c
commit 2081645cfa
10 changed files with 688 additions and 29 deletions

View File

@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.36.1] - 2026-03-08
### Added
- **Conditional branch fan-out detection** (`CONDITIONAL_BRANCH_FANOUT`): Warns when IF, Filter, or Switch nodes have all connections crammed into `main[0]` with higher-index outputs empty, which usually means all target nodes execute together on one branch while other branches have no effect
- Detects IF nodes with both true/false targets on `main[0]`
- Detects Filter nodes with both matched/unmatched targets on `main[0]`
- Detects Switch nodes with all targets on output 0 and other outputs unused
- Skips warning when fan-out is legitimate (higher outputs also have connections)
- Skips warning for single connections (intentional true-only/matched-only usage)
### Changed
- **Refactored output index validation**: Extracted `getShortNodeType()` and `getConditionalOutputInfo()` helpers to eliminate duplicated conditional node detection logic between `validateOutputIndexBounds` and the new `validateConditionalBranchUsage`
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.36.0] - 2026-03-07 ## [2.36.0] - 2026-03-07
### Added ### Added

View File

@@ -85,7 +85,12 @@ export declare class WorkflowValidator {
private validateErrorOutputConfiguration; private validateErrorOutputConfiguration;
private validateAIToolConnection; private validateAIToolConnection;
private validateAIToolSource; private validateAIToolSource;
private getNodeOutputTypes;
private validateNotAISubNode;
private getShortNodeType;
private getConditionalOutputInfo;
private validateOutputIndexBounds; private validateOutputIndexBounds;
private validateConditionalBranchUsage;
private validateInputIndexBounds; private validateInputIndexBounds;
private flagOrphanedNodes; private flagOrphanedNodes;
private validateTriggerReachability; private validateTriggerReachability;

View File

@@ -1 +1 @@
{"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"} {"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"}

View File

@@ -422,6 +422,9 @@ class WorkflowValidator {
if (outputKey === 'ai_tool') { if (outputKey === 'ai_tool') {
this.validateAIToolSource(sourceNode, result); this.validateAIToolSource(sourceNode, result);
} }
if (outputKey === 'main') {
this.validateNotAISubNode(sourceNode, result);
}
this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey); this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey);
} }
} }
@@ -443,6 +446,7 @@ class WorkflowValidator {
if (outputType === 'main' && sourceNode) { if (outputType === 'main' && sourceNode) {
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result); this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
this.validateOutputIndexBounds(sourceNode, outputs, result); this.validateOutputIndexBounds(sourceNode, outputs, result);
this.validateConditionalBranchUsage(sourceNode, outputs, result);
} }
outputs.forEach((outputConnections, outputIndex) => { outputs.forEach((outputConnections, outputIndex) => {
if (!outputConnections) if (!outputConnections)
@@ -644,6 +648,57 @@ class WorkflowValidator {
code: 'INVALID_AI_TOOL_SOURCE' code: 'INVALID_AI_TOOL_SOURCE'
}); });
} }
getNodeOutputTypes(nodeType) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo || !nodeInfo.outputs)
return null;
const outputs = nodeInfo.outputs;
if (!Array.isArray(outputs))
return null;
for (const output of outputs) {
if (typeof output === 'string' && output.startsWith('={{')) {
return null;
}
}
return outputs;
}
validateNotAISubNode(sourceNode, result) {
const outputTypes = this.getNodeOutputTypes(sourceNode.type);
if (!outputTypes)
return;
const hasMainOutput = outputTypes.some(t => t === 'main');
if (hasMainOutput)
return;
const aiTypes = outputTypes.filter(t => t !== 'main');
const expectedType = aiTypes[0] || 'ai_languageModel';
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` +
`It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`,
code: 'AI_SUBNODE_MAIN_CONNECTION'
});
}
getShortNodeType(sourceNode) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
return normalizedType.replace(/^(n8n-)?nodes-base\./, '');
}
getConditionalOutputInfo(sourceNode) {
const shortType = this.getShortNodeType(sourceNode);
if (shortType === 'if' || shortType === 'filter') {
return { shortType, expectedOutputs: 2 };
}
if (shortType === 'switch') {
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
if (Array.isArray(rules)) {
return { shortType, expectedOutputs: rules.length + 1 };
}
return null;
}
return null;
}
validateOutputIndexBounds(sourceNode, outputs, result) { validateOutputIndexBounds(sourceNode, outputs, result) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
const nodeInfo = this.nodeRepository.getNode(normalizedType); const nodeInfo = this.nodeRepository.getNode(normalizedType);
@@ -658,18 +713,12 @@ class WorkflowValidator {
} }
if (mainOutputCount === 0) if (mainOutputCount === 0)
return; return;
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, ''); const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (shortType === 'switch') { if (conditionalInfo) {
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules; mainOutputCount = conditionalInfo.expectedOutputs;
if (Array.isArray(rules)) {
mainOutputCount = rules.length + 1;
}
else {
return;
}
} }
if (shortType === 'if' || shortType === 'filter') { else if (this.getShortNodeType(sourceNode) === 'switch') {
mainOutputCount = 2; return;
} }
if (sourceNode.onError === 'continueErrorOutput') { if (sourceNode.onError === 'continueErrorOutput') {
mainOutputCount += 1; mainOutputCount += 1;
@@ -691,6 +740,44 @@ class WorkflowValidator {
} }
} }
} }
validateConditionalBranchUsage(sourceNode, outputs, result) {
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (!conditionalInfo || conditionalInfo.expectedOutputs < 2)
return;
const { shortType, expectedOutputs } = conditionalInfo;
const main0Count = outputs[0]?.length || 0;
if (main0Count < 2)
return;
const hasHigherIndexConnections = outputs.slice(1).some(conns => conns && conns.length > 0);
if (hasHigherIndexConnections)
return;
let message;
if (shortType === 'if' || shortType === 'filter') {
const isFilter = shortType === 'filter';
const displayName = isFilter ? 'Filter' : 'IF';
const trueLabel = isFilter ? 'matched' : 'true';
const falseLabel = isFilter ? 'unmatched' : 'false';
message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` +
`but no connections on the "${falseLabel}" branch (main[1]). ` +
`All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` +
`while the "${falseLabel}" branch has no effect. ` +
`Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`;
}
else {
message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` +
`but no connections on any other outputs (1-${expectedOutputs - 1}). ` +
`All ${main0Count} target nodes execute together on output 0, ` +
`while other switch branches have no effect. ` +
`Distribute connections across outputs to match switch rules.`;
}
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message,
code: 'CONDITIONAL_BRANCH_FANOUT'
});
}
validateInputIndexBounds(sourceName, targetNode, connection, result) { validateInputIndexBounds(sourceName, targetNode, connection, result) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type); const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
const nodeInfo = this.nodeRepository.getNode(normalizedType); const nodeInfo = this.nodeRepository.getNode(normalizedType);

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.35.6", "version": "2.36.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.35.6", "version": "2.36.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.20.1", "@modelcontextprotocol/sdk": "1.20.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.36.0", "version": "2.36.1",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -650,6 +650,11 @@ export class WorkflowValidator {
this.validateAIToolSource(sourceNode, result); this.validateAIToolSource(sourceNode, result);
} }
// Validate that AI sub-nodes are not connected via main
if (outputKey === 'main') {
this.validateNotAISubNode(sourceNode, result);
}
this.validateConnectionOutputs( this.validateConnectionOutputs(
sourceName, sourceName,
outputConnections, outputConnections,
@@ -695,6 +700,7 @@ export class WorkflowValidator {
if (outputType === 'main' && sourceNode) { if (outputType === 'main' && sourceNode) {
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result); this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
this.validateOutputIndexBounds(sourceNode, outputs, result); this.validateOutputIndexBounds(sourceNode, outputs, result);
this.validateConditionalBranchUsage(sourceNode, outputs, result);
} }
outputs.forEach((outputConnections, outputIndex) => { outputs.forEach((outputConnections, outputIndex) => {
@@ -987,6 +993,85 @@ export class WorkflowValidator {
}); });
} }
/**
* Get the static output types for a node from the database.
* Returns null if outputs contain expressions (dynamic) or node not found.
*/
private getNodeOutputTypes(nodeType: string): string[] | null {
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo || !nodeInfo.outputs) return null;
const outputs = nodeInfo.outputs;
if (!Array.isArray(outputs)) return null;
// Skip if any output is an expression (dynamic — can't determine statically)
for (const output of outputs) {
if (typeof output === 'string' && output.startsWith('={{')) {
return null;
}
}
return outputs;
}
/**
* Validate that AI sub-nodes (nodes that only output AI connection types)
* are not connected via "main" connections.
*/
private validateNotAISubNode(
sourceNode: WorkflowNode,
result: WorkflowValidationResult
): void {
const outputTypes = this.getNodeOutputTypes(sourceNode.type);
if (!outputTypes) return; // Unknown or dynamic — skip
// Check if the node outputs ONLY AI types (no 'main')
const hasMainOutput = outputTypes.some(t => t === 'main');
if (hasMainOutput) return; // Node can legitimately output main
// All outputs are AI types — this node should not be connected via main
const aiTypes = outputTypes.filter(t => t !== 'main');
const expectedType = aiTypes[0] || 'ai_languageModel';
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` +
`It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`,
code: 'AI_SUBNODE_MAIN_CONNECTION'
});
}
/**
* Derive the short node type name (e.g., "if", "switch", "set") from a workflow node.
*/
private getShortNodeType(sourceNode: WorkflowNode): string {
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
return normalizedType.replace(/^(n8n-)?nodes-base\./, '');
}
/**
* Get the expected main output count for a conditional node (IF, Filter, Switch).
* Returns null for non-conditional nodes or when the count cannot be determined.
*/
private getConditionalOutputInfo(sourceNode: WorkflowNode): { shortType: string; expectedOutputs: number } | null {
const shortType = this.getShortNodeType(sourceNode);
if (shortType === 'if' || shortType === 'filter') {
return { shortType, expectedOutputs: 2 };
}
if (shortType === 'switch') {
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
if (Array.isArray(rules)) {
return { shortType, expectedOutputs: rules.length + 1 }; // rules + fallback
}
return null; // Cannot determine dynamic output count
}
return null;
}
/** /**
* Validate that output indices don't exceed what the node type supports. * Validate that output indices don't exceed what the node type supports.
*/ */
@@ -1012,19 +1097,13 @@ export class WorkflowValidator {
if (mainOutputCount === 0) return; if (mainOutputCount === 0) return;
// Account for dynamic output counts based on node type and parameters // Override with dynamic output counts for conditional nodes
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, ''); const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (shortType === 'switch') { if (conditionalInfo) {
// Switch node: output count depends on rules configuration mainOutputCount = conditionalInfo.expectedOutputs;
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules; } else if (this.getShortNodeType(sourceNode) === 'switch') {
if (Array.isArray(rules)) { // Switch without determinable rules -- skip bounds check
mainOutputCount = rules.length + 1; // rules + fallback return;
} else {
return; // Cannot determine dynamic output count, skip bounds check
}
}
if (shortType === 'if' || shortType === 'filter') {
mainOutputCount = 2; // true/false
} }
// Account for continueErrorOutput adding an extra output // Account for continueErrorOutput adding an extra output
@@ -1052,6 +1131,60 @@ export class WorkflowValidator {
} }
} }
/**
* Detect when a conditional node (IF, Filter, Switch) has all connections
* crammed into main[0] with higher-index outputs empty. This usually means
* both branches execute together on one condition, while the other branches
* have no effect.
*/
private validateConditionalBranchUsage(
sourceNode: WorkflowNode,
outputs: Array<Array<{ node: string; type: string; index: number }>>,
result: WorkflowValidationResult
): void {
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (!conditionalInfo || conditionalInfo.expectedOutputs < 2) return;
const { shortType, expectedOutputs } = conditionalInfo;
// Check: main[0] has >= 2 connections AND all main[1+] are empty
const main0Count = outputs[0]?.length || 0;
if (main0Count < 2) return;
const hasHigherIndexConnections = outputs.slice(1).some(
conns => conns && conns.length > 0
);
if (hasHigherIndexConnections) return;
// Build a context-appropriate warning message
let message: string;
if (shortType === 'if' || shortType === 'filter') {
const isFilter = shortType === 'filter';
const displayName = isFilter ? 'Filter' : 'IF';
const trueLabel = isFilter ? 'matched' : 'true';
const falseLabel = isFilter ? 'unmatched' : 'false';
message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` +
`but no connections on the "${falseLabel}" branch (main[1]). ` +
`All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` +
`while the "${falseLabel}" branch has no effect. ` +
`Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`;
} else {
message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` +
`but no connections on any other outputs (1-${expectedOutputs - 1}). ` +
`All ${main0Count} target nodes execute together on output 0, ` +
`while other switch branches have no effect. ` +
`Distribute connections across outputs to match switch rules.`;
}
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message,
code: 'CONDITIONAL_BRANCH_FANOUT'
});
}
/** /**
* Validate that input index doesn't exceed what the target node accepts. * Validate that input index doesn't exceed what the target node accepts.
*/ */

View File

@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowValidator } from '@/services/workflow-validator';
import { NodeRepository } from '@/database/node-repository';
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
// Mock dependencies
vi.mock('@/database/node-repository');
vi.mock('@/services/enhanced-config-validator');
vi.mock('@/services/expression-validator');
vi.mock('@/utils/logger');
describe('WorkflowValidator - AI Sub-Node Main Connection Detection', () => {
let validator: WorkflowValidator;
let mockNodeRepository: NodeRepository;
beforeEach(() => {
vi.clearAllMocks();
mockNodeRepository = new NodeRepository({} as any) as any;
if (!mockNodeRepository.getAllNodes) {
mockNodeRepository.getAllNodes = vi.fn();
}
if (!mockNodeRepository.getNode) {
mockNodeRepository.getNode = vi.fn();
}
const nodeTypes: Record<string, any> = {
'nodes-base.manualTrigger': {
type: 'nodes-base.manualTrigger',
displayName: 'Manual Trigger',
package: 'n8n-nodes-base',
isTrigger: true,
outputs: ['main'],
properties: [],
},
'nodes-base.set': {
type: 'nodes-base.set',
displayName: 'Set',
package: 'n8n-nodes-base',
outputs: ['main'],
properties: [],
},
'nodes-langchain.lmChatGoogleGemini': {
type: 'nodes-langchain.lmChatGoogleGemini',
displayName: 'Google Gemini Chat Model',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_languageModel'],
properties: [],
},
'nodes-langchain.memoryBufferWindow': {
type: 'nodes-langchain.memoryBufferWindow',
displayName: 'Window Buffer Memory',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_memory'],
properties: [],
},
'nodes-langchain.embeddingsOpenAi': {
type: 'nodes-langchain.embeddingsOpenAi',
displayName: 'Embeddings OpenAI',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_embedding'],
properties: [],
},
'nodes-langchain.agent': {
type: 'nodes-langchain.agent',
displayName: 'AI Agent',
package: '@n8n/n8n-nodes-langchain',
isAITool: true,
outputs: ['main'],
properties: [],
},
'nodes-langchain.openAi': {
type: 'nodes-langchain.openAi',
displayName: 'OpenAI',
package: '@n8n/n8n-nodes-langchain',
outputs: ['main'],
properties: [],
},
'nodes-langchain.textClassifier': {
type: 'nodes-langchain.textClassifier',
displayName: 'Text Classifier',
package: '@n8n/n8n-nodes-langchain',
outputs: ['={{}}'], // Dynamic expression-based outputs
properties: [],
},
'nodes-langchain.vectorStoreInMemory': {
type: 'nodes-langchain.vectorStoreInMemory',
displayName: 'In-Memory Vector Store',
package: '@n8n/n8n-nodes-langchain',
outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'],
properties: [],
},
};
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
return nodeTypes[nodeType] || null;
});
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
validator = new WorkflowValidator(
mockNodeRepository,
EnhancedConfigValidator as any
);
});
function makeWorkflow(sourceType: string, sourceName: string, connectionKey: string = 'main') {
return {
nodes: [
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} },
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
],
connections: {
'Manual Trigger': {
main: [[{ node: sourceName, type: 'main', index: 0 }]]
},
[sourceName]: {
[connectionKey]: [[{ node: 'Set', type: connectionKey, index: 0 }]]
}
}
};
}
it('should flag LLM node (lmChatGoogleGemini) connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.lmChatGoogleGemini',
'Google Gemini'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_languageModel');
expect(error!.message).toContain('AI sub-node');
expect(error!.nodeName).toBe('Google Gemini');
});
it('should flag memory node (memoryBufferWindow) connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.memoryBufferWindow',
'Window Buffer Memory'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_memory');
});
it('should flag embeddings node connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.embeddingsOpenAi',
'Embeddings OpenAI'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_embedding');
});
it('should NOT flag regular langchain nodes (agent, openAi) connected via main', async () => {
const workflow1 = makeWorkflow('n8n-nodes-langchain.agent', 'AI Agent');
const workflow2 = makeWorkflow('n8n-nodes-langchain.openAi', 'OpenAI');
const result1 = await validator.validateWorkflow(workflow1 as any);
const result2 = await validator.validateWorkflow(workflow2 as any);
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag dynamic-output nodes (expression-based outputs)', async () => {
const workflow1 = makeWorkflow('n8n-nodes-langchain.textClassifier', 'Text Classifier');
const workflow2 = makeWorkflow('n8n-nodes-langchain.vectorStoreInMemory', 'Vector Store');
const result1 = await validator.validateWorkflow(workflow1 as any);
const result2 = await validator.validateWorkflow(workflow2 as any);
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag AI sub-node connected via correct AI type', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'AI Agent', type: 'n8n-nodes-langchain.agent', position: [200, 0], parameters: {} },
{ id: '3', name: 'Google Gemini', type: 'n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} },
],
connections: {
'Manual Trigger': {
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
},
'Google Gemini': {
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag unknown/community nodes not in database', async () => {
const workflow = makeWorkflow('n8n-nodes-community.someNode', 'Community Node');
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
});

View File

@@ -63,6 +63,20 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
outputs: ['main', 'main'], outputs: ['main', 'main'],
properties: [], properties: [],
}, },
'nodes-base.filter': {
type: 'nodes-base.filter',
displayName: 'Filter',
package: 'n8n-nodes-base',
outputs: ['main', 'main'],
properties: [],
},
'nodes-base.switch': {
type: 'nodes-base.switch',
displayName: 'Switch',
package: 'n8n-nodes-base',
outputs: ['main', 'main', 'main', 'main'],
properties: [],
},
'nodes-base.googleSheets': { 'nodes-base.googleSheets': {
type: 'nodes-base.googleSheets', type: 'nodes-base.googleSheets',
displayName: 'Google Sheets', displayName: 'Google Sheets',
@@ -715,4 +729,190 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
expect(orphanWarning!.message).toContain('not connected to any other nodes'); expect(orphanWarning!.message).toContain('not connected to any other nodes');
}); });
}); });
describe('Conditional branch fan-out detection (CONDITIONAL_BRANCH_FANOUT)', () => {
it('should warn when IF node has both branches in main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [[{ node: 'TrueTarget', type: 'main', index: 0 }, { node: 'FalseTarget', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('Route');
expect(warning!.message).toContain('2 connections on the "true" branch');
expect(warning!.message).toContain('"false" branch has no effect');
});
it('should not warn when IF node has correct true/false split', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [
[{ node: 'TrueTarget', type: 'main', index: 0 }],
[{ node: 'FalseTarget', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when IF has fan-out on main[0] AND connections on main[1]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TrueB', type: 'n8n-nodes-base.set', position: [400, 100], parameters: {} },
{ id: '5', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [
[{ node: 'TrueA', type: 'main', index: 0 }, { node: 'TrueB', type: 'main', index: 0 }],
[{ node: 'FalseTarget', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should warn when Switch node has all connections on main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: { rules: { values: [{ value: 'a' }, { value: 'b' }] } } },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
{ id: '5', name: 'TargetC', type: 'n8n-nodes-base.set', position: [400, 400], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
'MySwitch': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }, { node: 'TargetC', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('MySwitch');
expect(warning!.message).toContain('3 connections on output 0');
expect(warning!.message).toContain('other switch branches have no effect');
});
it('should not warn when Switch node has no rules parameter (indeterminate outputs)', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: {} },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
'MySwitch': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when regular node has fan-out on main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySet', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySet', type: 'main', index: 0 }]] },
'MySet': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when IF has only 1 connection on main[0] with empty main[1]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueOnly', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [[{ node: 'TrueOnly', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should warn for Filter node with both branches in main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MyFilter', type: 'n8n-nodes-base.filter', position: [200, 0], parameters: {} },
{ id: '3', name: 'Matched', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'Unmatched', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MyFilter', type: 'main', index: 0 }]] },
'MyFilter': {
main: [[{ node: 'Matched', type: 'main', index: 0 }, { node: 'Unmatched', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('MyFilter');
expect(warning!.message).toContain('"matched" branch');
expect(warning!.message).toContain('"unmatched" branch has no effect');
});
});
}); });