mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-21 18:03:07 +00:00
feat: implement comprehensive workflow validation (v2.5.0)
Major Features: - Add ExpressionValidator for n8n expression syntax validation - Add WorkflowValidator for complete workflow structure validation - Add three new MCP tools: validate_workflow, validate_workflow_connections, validate_workflow_expressions Validation Capabilities: - ✅ Detects workflow cycles (infinite loops) - ✅ Validates n8n expressions with syntax checking - ✅ Checks node references in expressions - ✅ Identifies orphaned nodes and missing connections - ✅ Supports multiple node type formats (n8n-nodes-base, @n8n/n8n-nodes-langchain) - ✅ Provides actionable error messages and suggestions Testing & Analysis: - Add test scripts for workflow validation - Add template validation testing - Add validation summary analysis tool - Fixed expression validation false positives - Handle node type normalization correctly Results from testing 50 real n8n templates: - 70.9% of errors are from informal sticky notes - Expression validation catches real syntax issues - Cycle detection prevents runtime infinite loops - Successfully validates both core and LangChain nodes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
299
src/services/expression-validator.ts
Normal file
299
src/services/expression-validator.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Expression Validator for n8n expressions
|
||||
* Validates expression syntax, variable references, and context availability
|
||||
*/
|
||||
|
||||
interface ExpressionValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
usedVariables: Set<string>;
|
||||
usedNodes: Set<string>;
|
||||
}
|
||||
|
||||
interface ExpressionContext {
|
||||
availableNodes: string[];
|
||||
currentNodeName?: string;
|
||||
isInLoop?: boolean;
|
||||
hasInputData?: boolean;
|
||||
}
|
||||
|
||||
export class ExpressionValidator {
|
||||
// Common n8n expression patterns
|
||||
private static readonly EXPRESSION_PATTERN = /\{\{(.+?)\}\}/g;
|
||||
private static readonly VARIABLE_PATTERNS = {
|
||||
json: /\$json(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g,
|
||||
node: /\$node\["([^"]+)"\]\.json/g,
|
||||
input: /\$input\.item(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g,
|
||||
items: /\$items\("([^"]+)"(?:,\s*(\d+))?\)/g,
|
||||
parameter: /\$parameter\["([^"]+)"\]/g,
|
||||
env: /\$env\.([a-zA-Z_][\w]*)/g,
|
||||
workflow: /\$workflow\.(id|name|active)/g,
|
||||
execution: /\$execution\.(id|mode|resumeUrl)/g,
|
||||
prevNode: /\$prevNode\.(name|outputIndex|runIndex)/g,
|
||||
itemIndex: /\$itemIndex/g,
|
||||
runIndex: /\$runIndex/g,
|
||||
now: /\$now/g,
|
||||
today: /\$today/g,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a single expression
|
||||
*/
|
||||
static validateExpression(
|
||||
expression: string,
|
||||
context: ExpressionContext
|
||||
): ExpressionValidationResult {
|
||||
const result: ExpressionValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
usedVariables: new Set(),
|
||||
usedNodes: new Set(),
|
||||
};
|
||||
|
||||
// Check for basic syntax errors
|
||||
const syntaxErrors = this.checkSyntaxErrors(expression);
|
||||
result.errors.push(...syntaxErrors);
|
||||
|
||||
// Extract all expressions
|
||||
const expressions = this.extractExpressions(expression);
|
||||
|
||||
for (const expr of expressions) {
|
||||
// Validate each expression
|
||||
this.validateSingleExpression(expr, context, result);
|
||||
}
|
||||
|
||||
// Check for undefined node references
|
||||
this.checkNodeReferences(result, context);
|
||||
|
||||
result.valid = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for basic syntax errors
|
||||
*/
|
||||
private static checkSyntaxErrors(expression: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for unmatched brackets
|
||||
const openBrackets = (expression.match(/\{\{/g) || []).length;
|
||||
const closeBrackets = (expression.match(/\}\}/g) || []).length;
|
||||
|
||||
if (openBrackets !== closeBrackets) {
|
||||
errors.push('Unmatched expression brackets {{ }}');
|
||||
}
|
||||
|
||||
// Check for nested expressions (not supported in n8n)
|
||||
if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) {
|
||||
const match = expression.match(/\{\{.*\{\{/);
|
||||
if (match) {
|
||||
errors.push('Nested expressions are not supported');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty expressions
|
||||
if (expression.includes('{{}}')) {
|
||||
errors.push('Empty expression found');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all expressions from a string
|
||||
*/
|
||||
private static extractExpressions(text: string): string[] {
|
||||
const expressions: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = this.EXPRESSION_PATTERN.exec(text)) !== null) {
|
||||
expressions.push(match[1].trim());
|
||||
}
|
||||
|
||||
return expressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single expression content
|
||||
*/
|
||||
private static validateSingleExpression(
|
||||
expr: string,
|
||||
context: ExpressionContext,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
// Check for $json usage
|
||||
let match;
|
||||
while ((match = this.VARIABLE_PATTERNS.json.exec(expr)) !== null) {
|
||||
result.usedVariables.add('$json');
|
||||
|
||||
if (!context.hasInputData && !context.isInLoop) {
|
||||
result.warnings.push(
|
||||
'Using $json but node might not have input data'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for $node references
|
||||
while ((match = this.VARIABLE_PATTERNS.node.exec(expr)) !== null) {
|
||||
const nodeName = match[1];
|
||||
result.usedNodes.add(nodeName);
|
||||
result.usedVariables.add('$node');
|
||||
}
|
||||
|
||||
// Check for $input usage
|
||||
while ((match = this.VARIABLE_PATTERNS.input.exec(expr)) !== null) {
|
||||
result.usedVariables.add('$input');
|
||||
|
||||
if (!context.hasInputData) {
|
||||
result.errors.push(
|
||||
'$input is only available when the node has input data'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for $items usage
|
||||
while ((match = this.VARIABLE_PATTERNS.items.exec(expr)) !== null) {
|
||||
const nodeName = match[1];
|
||||
result.usedNodes.add(nodeName);
|
||||
result.usedVariables.add('$items');
|
||||
}
|
||||
|
||||
// Check for other variables
|
||||
for (const [varName, pattern] of Object.entries(this.VARIABLE_PATTERNS)) {
|
||||
if (['json', 'node', 'input', 'items'].includes(varName)) continue;
|
||||
|
||||
if (pattern.test(expr)) {
|
||||
result.usedVariables.add(`$${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common mistakes
|
||||
this.checkCommonMistakes(expr, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common expression mistakes
|
||||
*/
|
||||
private static checkCommonMistakes(
|
||||
expr: string,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
// Check for missing $ prefix - but exclude cases where $ is already present
|
||||
const missingPrefixPattern = /(?<!\$)\b(json|node|input|items|workflow|execution)\b(?!\s*:)/;
|
||||
if (expr.match(missingPrefixPattern)) {
|
||||
result.warnings.push(
|
||||
'Possible missing $ prefix for variable (e.g., use $json instead of json)'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for incorrect array access
|
||||
if (expr.includes('$json[') && !expr.match(/\$json\[\d+\]/)) {
|
||||
result.warnings.push(
|
||||
'Array access should use numeric index: $json[0] or property access: $json.property'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Python-style property access
|
||||
if (expr.match(/\$json\['[^']+'\]/)) {
|
||||
result.warnings.push(
|
||||
"Consider using dot notation: $json.property instead of $json['property']"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for undefined/null access attempts
|
||||
if (expr.match(/\?\./)) {
|
||||
result.warnings.push(
|
||||
'Optional chaining (?.) is not supported in n8n expressions'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for template literals
|
||||
if (expr.includes('${')) {
|
||||
result.errors.push(
|
||||
'Template literals ${} are not supported. Use string concatenation instead'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that all referenced nodes exist
|
||||
*/
|
||||
private static checkNodeReferences(
|
||||
result: ExpressionValidationResult,
|
||||
context: ExpressionContext
|
||||
): void {
|
||||
for (const nodeName of result.usedNodes) {
|
||||
if (!context.availableNodes.includes(nodeName)) {
|
||||
result.errors.push(
|
||||
`Referenced node "${nodeName}" not found in workflow`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all expressions in a node's parameters
|
||||
*/
|
||||
static validateNodeExpressions(
|
||||
parameters: any,
|
||||
context: ExpressionContext
|
||||
): ExpressionValidationResult {
|
||||
const combinedResult: ExpressionValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
usedVariables: new Set(),
|
||||
usedNodes: new Set(),
|
||||
};
|
||||
|
||||
this.validateParametersRecursive(parameters, context, combinedResult);
|
||||
|
||||
combinedResult.valid = combinedResult.errors.length === 0;
|
||||
return combinedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively validate expressions in parameters
|
||||
*/
|
||||
private static validateParametersRecursive(
|
||||
obj: any,
|
||||
context: ExpressionContext,
|
||||
result: ExpressionValidationResult,
|
||||
path: string = ''
|
||||
): void {
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.includes('{{')) {
|
||||
const validation = this.validateExpression(obj, context);
|
||||
|
||||
// Add path context to errors
|
||||
validation.errors.forEach(error => {
|
||||
result.errors.push(`${path}: ${error}`);
|
||||
});
|
||||
|
||||
validation.warnings.forEach(warning => {
|
||||
result.warnings.push(`${path}: ${warning}`);
|
||||
});
|
||||
|
||||
// Merge used variables and nodes
|
||||
validation.usedVariables.forEach(v => result.usedVariables.add(v));
|
||||
validation.usedNodes.forEach(n => result.usedNodes.add(n));
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => {
|
||||
this.validateParametersRecursive(
|
||||
item,
|
||||
context,
|
||||
result,
|
||||
`${path}[${index}]`
|
||||
);
|
||||
});
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
this.validateParametersRecursive(value, context, result, newPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
669
src/services/workflow-validator.ts
Normal file
669
src/services/workflow-validator.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Workflow Validator for n8n workflows
|
||||
* Validates complete workflow structure, connections, and node configurations
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { EnhancedConfigValidator } from './enhanced-config-validator';
|
||||
import { ExpressionValidator } from './expression-validator';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
position: [number, number];
|
||||
parameters: any;
|
||||
credentials?: any;
|
||||
disabled?: boolean;
|
||||
notes?: string;
|
||||
typeVersion?: number;
|
||||
}
|
||||
|
||||
interface WorkflowConnection {
|
||||
[sourceNode: string]: {
|
||||
main?: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
error?: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkflowJson {
|
||||
name?: string;
|
||||
nodes: WorkflowNode[];
|
||||
connections: WorkflowConnection;
|
||||
settings?: any;
|
||||
staticData?: any;
|
||||
pinData?: any;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
interface ValidationIssue {
|
||||
type: 'error' | 'warning';
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
interface WorkflowValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationIssue[];
|
||||
warnings: ValidationIssue[];
|
||||
statistics: {
|
||||
totalNodes: number;
|
||||
enabledNodes: number;
|
||||
triggerNodes: number;
|
||||
validConnections: number;
|
||||
invalidConnections: number;
|
||||
expressionsValidated: number;
|
||||
};
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export class WorkflowValidator {
|
||||
constructor(
|
||||
private nodeRepository: NodeRepository,
|
||||
private nodeValidator: typeof EnhancedConfigValidator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate a complete workflow
|
||||
*/
|
||||
async validateWorkflow(
|
||||
workflow: WorkflowJson,
|
||||
options: {
|
||||
validateNodes?: boolean;
|
||||
validateConnections?: boolean;
|
||||
validateExpressions?: boolean;
|
||||
profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict';
|
||||
} = {}
|
||||
): Promise<WorkflowValidationResult> {
|
||||
const {
|
||||
validateNodes = true,
|
||||
validateConnections = true,
|
||||
validateExpressions = true,
|
||||
profile = 'runtime'
|
||||
} = options;
|
||||
|
||||
const result: WorkflowValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
statistics: {
|
||||
totalNodes: workflow.nodes.length,
|
||||
enabledNodes: workflow.nodes.filter(n => !n.disabled).length,
|
||||
triggerNodes: 0,
|
||||
validConnections: 0,
|
||||
invalidConnections: 0,
|
||||
expressionsValidated: 0,
|
||||
},
|
||||
suggestions: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Basic workflow structure validation
|
||||
this.validateWorkflowStructure(workflow, result);
|
||||
|
||||
// Validate each node if requested
|
||||
if (validateNodes) {
|
||||
await this.validateAllNodes(workflow, result, profile);
|
||||
}
|
||||
|
||||
// Validate connections if requested
|
||||
if (validateConnections) {
|
||||
this.validateConnections(workflow, result);
|
||||
}
|
||||
|
||||
// Validate expressions if requested
|
||||
if (validateExpressions) {
|
||||
this.validateExpressions(workflow, result);
|
||||
}
|
||||
|
||||
// Check workflow patterns and best practices
|
||||
this.checkWorkflowPatterns(workflow, result);
|
||||
|
||||
// Add suggestions based on findings
|
||||
this.generateSuggestions(workflow, result);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error validating workflow:', error);
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: `Workflow validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
|
||||
result.valid = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate basic workflow structure
|
||||
*/
|
||||
private validateWorkflowStructure(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
// Check for required fields
|
||||
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: 'Workflow must have a nodes array'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workflow.connections || typeof workflow.connections !== 'object') {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: 'Workflow must have a connections object'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for empty workflow
|
||||
if (workflow.nodes.length === 0) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: 'Workflow has no nodes'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate node names
|
||||
const nodeNames = new Set<string>();
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (nodeNames.has(node.name)) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Duplicate node name: "${node.name}"`
|
||||
});
|
||||
}
|
||||
nodeNames.add(node.name);
|
||||
|
||||
if (nodeIds.has(node.id)) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
message: `Duplicate node ID: "${node.id}"`
|
||||
});
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
|
||||
// Count trigger nodes - normalize type names first
|
||||
const triggerNodes = workflow.nodes.filter(n => {
|
||||
const normalizedType = n.type.replace('n8n-nodes-base.', 'nodes-base.');
|
||||
return normalizedType.toLowerCase().includes('trigger') ||
|
||||
normalizedType.toLowerCase().includes('webhook') ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
normalizedType === 'nodes-base.manualTrigger' ||
|
||||
normalizedType === 'nodes-base.formTrigger';
|
||||
});
|
||||
result.statistics.triggerNodes = triggerNodes.length;
|
||||
|
||||
// Check for at least one trigger node
|
||||
if (triggerNodes.length === 0 && workflow.nodes.filter(n => !n.disabled).length > 0) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: 'Workflow has no trigger nodes. It can only be executed manually.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all nodes in the workflow
|
||||
*/
|
||||
private async validateAllNodes(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult,
|
||||
profile: string
|
||||
): Promise<void> {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled) continue;
|
||||
|
||||
try {
|
||||
// Get node definition - try multiple formats
|
||||
let nodeInfo = this.nodeRepository.getNode(node.type);
|
||||
|
||||
// If not found, try with normalized type
|
||||
if (!nodeInfo) {
|
||||
let normalizedType = node.type;
|
||||
|
||||
// Handle n8n-nodes-base -> nodes-base
|
||||
if (node.type.startsWith('n8n-nodes-base.')) {
|
||||
normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.');
|
||||
nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
}
|
||||
// Handle @n8n/n8n-nodes-langchain -> nodes-langchain
|
||||
else if (node.type.startsWith('@n8n/n8n-nodes-langchain.')) {
|
||||
normalizedType = node.type.replace('@n8n/n8n-nodes-langchain.', 'nodes-langchain.');
|
||||
nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeInfo) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Unknown node type: ${node.type}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate node configuration
|
||||
const nodeValidation = this.nodeValidator.validateWithMode(
|
||||
node.type,
|
||||
node.parameters,
|
||||
nodeInfo.properties || [],
|
||||
'operation',
|
||||
profile as any
|
||||
);
|
||||
|
||||
// Add node-specific errors and warnings
|
||||
nodeValidation.errors.forEach((error: any) => {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: error
|
||||
});
|
||||
});
|
||||
|
||||
nodeValidation.warnings.forEach((warning: any) => {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: warning
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Failed to validate node: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate workflow connections
|
||||
*/
|
||||
private validateConnections(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
const nodeMap = new Map(workflow.nodes.map(n => [n.name, n]));
|
||||
|
||||
// Check all connections
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
const sourceNode = nodeMap.get(sourceName);
|
||||
|
||||
if (!sourceNode) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: `Connection from non-existent node: "${sourceName}"`
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check main outputs
|
||||
if (outputs.main) {
|
||||
this.validateConnectionOutputs(
|
||||
sourceName,
|
||||
outputs.main,
|
||||
nodeMap,
|
||||
result,
|
||||
'main'
|
||||
);
|
||||
}
|
||||
|
||||
// Check error outputs
|
||||
if (outputs.error) {
|
||||
this.validateConnectionOutputs(
|
||||
sourceName,
|
||||
outputs.error,
|
||||
nodeMap,
|
||||
result,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for orphaned nodes (not connected and not triggers)
|
||||
const connectedNodes = new Set<string>();
|
||||
|
||||
// Add all source nodes
|
||||
Object.keys(workflow.connections).forEach(name => connectedNodes.add(name));
|
||||
|
||||
// Add all target nodes
|
||||
Object.values(workflow.connections).forEach(outputs => {
|
||||
if (outputs.main) {
|
||||
outputs.main.flat().forEach(conn => {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
if (outputs.error) {
|
||||
outputs.error.flat().forEach(conn => {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for orphaned nodes
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled) continue;
|
||||
|
||||
const normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.');
|
||||
const isTrigger = normalizedType.toLowerCase().includes('trigger') ||
|
||||
normalizedType.toLowerCase().includes('webhook') ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
normalizedType === 'nodes-base.manualTrigger' ||
|
||||
normalizedType === 'nodes-base.formTrigger';
|
||||
|
||||
if (!connectedNodes.has(node.name) && !isTrigger) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not connected to any other nodes'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles
|
||||
if (this.hasCycle(workflow)) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: 'Workflow contains a cycle (infinite loop)'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate connection outputs
|
||||
*/
|
||||
private validateConnectionOutputs(
|
||||
sourceName: string,
|
||||
outputs: Array<Array<{ node: string; type: string; index: number }>>,
|
||||
nodeMap: Map<string, WorkflowNode>,
|
||||
result: WorkflowValidationResult,
|
||||
outputType: 'main' | 'error'
|
||||
): void {
|
||||
outputs.forEach((outputConnections, outputIndex) => {
|
||||
if (!outputConnections) return;
|
||||
|
||||
outputConnections.forEach(connection => {
|
||||
const targetNode = nodeMap.get(connection.node);
|
||||
|
||||
if (!targetNode) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
message: `Connection to non-existent node: "${connection.node}" from "${sourceName}"`
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
} else if (targetNode.disabled) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: `Connection to disabled node: "${connection.node}" from "${sourceName}"`
|
||||
});
|
||||
} else {
|
||||
result.statistics.validConnections++;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow has cycles
|
||||
*/
|
||||
private hasCycle(workflow: WorkflowJson): boolean {
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
const hasCycleDFS = (nodeName: string): boolean => {
|
||||
visited.add(nodeName);
|
||||
recursionStack.add(nodeName);
|
||||
|
||||
const connections = workflow.connections[nodeName];
|
||||
if (connections) {
|
||||
const allTargets: string[] = [];
|
||||
|
||||
if (connections.main) {
|
||||
connections.main.flat().forEach(conn => {
|
||||
if (conn) allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of allTargets) {
|
||||
if (!visited.has(target)) {
|
||||
if (hasCycleDFS(target)) return true;
|
||||
} else if (recursionStack.has(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeName);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check from all nodes
|
||||
for (const node of workflow.nodes) {
|
||||
if (!visited.has(node.name)) {
|
||||
if (hasCycleDFS(node.name)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate expressions in the workflow
|
||||
*/
|
||||
private validateExpressions(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
const nodeNames = workflow.nodes.map(n => n.name);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled) continue;
|
||||
|
||||
// Create expression context
|
||||
const context = {
|
||||
availableNodes: nodeNames.filter(n => n !== node.name),
|
||||
currentNodeName: node.name,
|
||||
hasInputData: this.nodeHasInput(node.name, workflow),
|
||||
isInLoop: false // Could be enhanced to detect loop nodes
|
||||
};
|
||||
|
||||
// Validate expressions in parameters
|
||||
const exprValidation = ExpressionValidator.validateNodeExpressions(
|
||||
node.parameters,
|
||||
context
|
||||
);
|
||||
|
||||
result.statistics.expressionsValidated += exprValidation.usedVariables.size;
|
||||
|
||||
// Add expression errors and warnings
|
||||
exprValidation.errors.forEach(error => {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Expression error: ${error}`
|
||||
});
|
||||
});
|
||||
|
||||
exprValidation.warnings.forEach(warning => {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Expression warning: ${warning}`
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node has input connections
|
||||
*/
|
||||
private nodeHasInput(nodeName: string, workflow: WorkflowJson): boolean {
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
if (outputs.main) {
|
||||
for (const outputConnections of outputs.main) {
|
||||
if (outputConnections?.some(conn => conn.node === nodeName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check workflow patterns and best practices
|
||||
*/
|
||||
private checkWorkflowPatterns(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
// Check for error handling
|
||||
const hasErrorHandling = Object.values(workflow.connections).some(
|
||||
outputs => outputs.error && outputs.error.length > 0
|
||||
);
|
||||
|
||||
if (!hasErrorHandling && workflow.nodes.length > 3) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: 'Consider adding error handling to your workflow'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for very long linear workflows
|
||||
const linearChainLength = this.getLongestLinearChain(workflow);
|
||||
if (linearChainLength > 10) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: `Long linear chain detected (${linearChainLength} nodes). Consider breaking into sub-workflows.`
|
||||
});
|
||||
}
|
||||
|
||||
// Check for missing credentials
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.credentials && Object.keys(node.credentials).length > 0) {
|
||||
for (const [credType, credConfig] of Object.entries(node.credentials)) {
|
||||
if (!credConfig || (typeof credConfig === 'object' && !('id' in credConfig))) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Missing credentials configuration for ${credType}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the longest linear chain in the workflow
|
||||
*/
|
||||
private getLongestLinearChain(workflow: WorkflowJson): number {
|
||||
const memo = new Map<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const getChainLength = (nodeName: string): number => {
|
||||
// If we're already visiting this node, we have a cycle
|
||||
if (visiting.has(nodeName)) return 0;
|
||||
|
||||
if (memo.has(nodeName)) return memo.get(nodeName)!;
|
||||
|
||||
visiting.add(nodeName);
|
||||
|
||||
let maxLength = 0;
|
||||
const connections = workflow.connections[nodeName];
|
||||
|
||||
if (connections?.main) {
|
||||
for (const outputConnections of connections.main) {
|
||||
if (outputConnections) {
|
||||
for (const conn of outputConnections) {
|
||||
const length = getChainLength(conn.node);
|
||||
maxLength = Math.max(maxLength, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(nodeName);
|
||||
const result = maxLength + 1;
|
||||
memo.set(nodeName, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
let maxChain = 0;
|
||||
for (const node of workflow.nodes) {
|
||||
if (!this.nodeHasInput(node.name, workflow)) {
|
||||
maxChain = Math.max(maxChain, getChainLength(node.name));
|
||||
}
|
||||
}
|
||||
|
||||
return maxChain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate suggestions based on validation results
|
||||
*/
|
||||
private generateSuggestions(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
// Suggest adding trigger if missing
|
||||
if (result.statistics.triggerNodes === 0) {
|
||||
result.suggestions.push(
|
||||
'Add a trigger node (e.g., Webhook, Schedule Trigger) to automate workflow execution'
|
||||
);
|
||||
}
|
||||
|
||||
// Suggest error handling
|
||||
if (!Object.values(workflow.connections).some(o => o.error)) {
|
||||
result.suggestions.push(
|
||||
'Add error handling using the error output of nodes or an Error Trigger node'
|
||||
);
|
||||
}
|
||||
|
||||
// Suggest optimization for large workflows
|
||||
if (workflow.nodes.length > 20) {
|
||||
result.suggestions.push(
|
||||
'Consider breaking this workflow into smaller sub-workflows for better maintainability'
|
||||
);
|
||||
}
|
||||
|
||||
// Suggest using Code node for complex logic
|
||||
const complexExpressionNodes = workflow.nodes.filter(node => {
|
||||
const jsonString = JSON.stringify(node.parameters);
|
||||
const expressionCount = (jsonString.match(/\{\{/g) || []).length;
|
||||
return expressionCount > 5;
|
||||
});
|
||||
|
||||
if (complexExpressionNodes.length > 0) {
|
||||
result.suggestions.push(
|
||||
'Consider using a Code node for complex data transformations instead of multiple expressions'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user