@pagelines/n8n-mcp 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -6
- package/dist/index.js +2 -71
- package/dist/n8n-client.d.ts +1 -2
- package/dist/n8n-client.js +0 -6
- package/dist/tools.js +0 -29
- package/dist/types.d.ts +1 -28
- package/dist/types.js +1 -1
- package/dist/validators.d.ts +1 -9
- package/dist/validators.js +0 -85
- package/package.json +2 -2
- package/.github/workflows/ci.yml +0 -38
- package/dist/n8n-client.test.d.ts +0 -1
- package/dist/n8n-client.test.js +0 -382
- package/dist/response-format.test.d.ts +0 -1
- package/dist/response-format.test.js +0 -291
- package/dist/validators.test.d.ts +0 -1
- package/dist/validators.test.js +0 -310
- package/docs/best-practices.md +0 -165
- package/docs/node-config.md +0 -205
- package/plans/ai-guidelines.md +0 -233
- package/plans/architecture.md +0 -220
- package/server.json +0 -66
- package/src/autofix.ts +0 -275
- package/src/expressions.ts +0 -254
- package/src/index.ts +0 -550
- package/src/n8n-client.test.ts +0 -467
- package/src/n8n-client.ts +0 -374
- package/src/response-format.test.ts +0 -355
- package/src/response-format.ts +0 -278
- package/src/tools.ts +0 -489
- package/src/types.ts +0 -183
- package/src/validators.test.ts +0 -374
- package/src/validators.ts +0 -394
- package/src/versions.ts +0 -320
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -8
package/src/autofix.ts
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-fix common n8n workflow issues
|
|
3
|
-
* Transforms workflows to follow best practices
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { N8nWorkflow, N8nNode, ValidationWarning } from './types.js';
|
|
7
|
-
|
|
8
|
-
export interface AutofixResult {
|
|
9
|
-
workflow: N8nWorkflow;
|
|
10
|
-
fixes: AutofixAction[];
|
|
11
|
-
unfixable: ValidationWarning[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface AutofixAction {
|
|
15
|
-
type: string;
|
|
16
|
-
target: string;
|
|
17
|
-
description: string;
|
|
18
|
-
before?: string;
|
|
19
|
-
after?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Auto-fix a workflow based on validation warnings
|
|
24
|
-
*/
|
|
25
|
-
export function autofixWorkflow(
|
|
26
|
-
workflow: N8nWorkflow,
|
|
27
|
-
warnings: ValidationWarning[]
|
|
28
|
-
): AutofixResult {
|
|
29
|
-
// Deep clone to avoid mutation
|
|
30
|
-
const fixed: N8nWorkflow = JSON.parse(JSON.stringify(workflow));
|
|
31
|
-
const fixes: AutofixAction[] = [];
|
|
32
|
-
const unfixable: ValidationWarning[] = [];
|
|
33
|
-
|
|
34
|
-
for (const warning of warnings) {
|
|
35
|
-
const result = attemptFix(fixed, warning);
|
|
36
|
-
if (result) {
|
|
37
|
-
fixes.push(result);
|
|
38
|
-
} else {
|
|
39
|
-
unfixable.push(warning);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return { workflow: fixed, fixes, unfixable };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function attemptFix(workflow: N8nWorkflow, warning: ValidationWarning): AutofixAction | null {
|
|
47
|
-
switch (warning.rule) {
|
|
48
|
-
case 'snake_case':
|
|
49
|
-
return fixSnakeCase(workflow, warning);
|
|
50
|
-
|
|
51
|
-
case 'explicit_reference':
|
|
52
|
-
return fixExplicitReference(workflow, warning);
|
|
53
|
-
|
|
54
|
-
case 'ai_structured_output':
|
|
55
|
-
return fixAIStructuredOutput(workflow, warning);
|
|
56
|
-
|
|
57
|
-
default:
|
|
58
|
-
// Rules that can't be auto-fixed:
|
|
59
|
-
// - no_hardcoded_secrets (need manual review)
|
|
60
|
-
// - no_hardcoded_ids (need manual review)
|
|
61
|
-
// - orphan_node (need context to know what to connect)
|
|
62
|
-
// - code_node_usage (info only)
|
|
63
|
-
// - in_memory_storage (architectural decision)
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Fix snake_case naming
|
|
70
|
-
*/
|
|
71
|
-
function fixSnakeCase(workflow: N8nWorkflow, warning: ValidationWarning): AutofixAction | null {
|
|
72
|
-
const target = warning.node || 'workflow';
|
|
73
|
-
|
|
74
|
-
if (!warning.node) {
|
|
75
|
-
// Fix workflow name
|
|
76
|
-
const oldName = workflow.name;
|
|
77
|
-
const newName = toSnakeCase(oldName);
|
|
78
|
-
|
|
79
|
-
if (oldName === newName) return null;
|
|
80
|
-
|
|
81
|
-
workflow.name = newName;
|
|
82
|
-
return {
|
|
83
|
-
type: 'rename',
|
|
84
|
-
target: 'workflow',
|
|
85
|
-
description: `Renamed workflow to snake_case`,
|
|
86
|
-
before: oldName,
|
|
87
|
-
after: newName,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Fix node name
|
|
92
|
-
const node = workflow.nodes.find((n) => n.name === warning.node);
|
|
93
|
-
if (!node) return null;
|
|
94
|
-
|
|
95
|
-
const oldName = node.name;
|
|
96
|
-
const newName = toSnakeCase(oldName);
|
|
97
|
-
|
|
98
|
-
if (oldName === newName) return null;
|
|
99
|
-
|
|
100
|
-
// Update node name
|
|
101
|
-
node.name = newName;
|
|
102
|
-
|
|
103
|
-
// Update connections that reference this node
|
|
104
|
-
if (workflow.connections[oldName]) {
|
|
105
|
-
workflow.connections[newName] = workflow.connections[oldName];
|
|
106
|
-
delete workflow.connections[oldName];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Update connections that target this node
|
|
110
|
-
for (const outputs of Object.values(workflow.connections)) {
|
|
111
|
-
for (const outputType of Object.values(outputs)) {
|
|
112
|
-
for (const connections of outputType) {
|
|
113
|
-
for (const conn of connections) {
|
|
114
|
-
if (conn.node === oldName) {
|
|
115
|
-
conn.node = newName;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
type: 'rename',
|
|
124
|
-
target: `node:${oldName}`,
|
|
125
|
-
description: `Renamed node to snake_case`,
|
|
126
|
-
before: oldName,
|
|
127
|
-
after: newName,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Convert to snake_case
|
|
133
|
-
*/
|
|
134
|
-
function toSnakeCase(name: string): string {
|
|
135
|
-
return name
|
|
136
|
-
.replace(/([A-Z])/g, '_$1')
|
|
137
|
-
.replace(/[-\s]+/g, '_')
|
|
138
|
-
.replace(/^_/, '')
|
|
139
|
-
.replace(/_+/g, '_')
|
|
140
|
-
.toLowerCase();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Fix $json to explicit references
|
|
145
|
-
* This is a best-effort fix - may need manual review
|
|
146
|
-
*/
|
|
147
|
-
function fixExplicitReference(
|
|
148
|
-
workflow: N8nWorkflow,
|
|
149
|
-
warning: ValidationWarning
|
|
150
|
-
): AutofixAction | null {
|
|
151
|
-
if (!warning.node) return null;
|
|
152
|
-
|
|
153
|
-
const node = workflow.nodes.find((n) => n.name === warning.node);
|
|
154
|
-
if (!node) return null;
|
|
155
|
-
|
|
156
|
-
// Find the previous node in the connection chain
|
|
157
|
-
const previousNode = findPreviousNode(workflow, node.name);
|
|
158
|
-
if (!previousNode) {
|
|
159
|
-
// Can't auto-fix without knowing the source
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Replace $json with explicit reference
|
|
164
|
-
const params = JSON.stringify(node.parameters);
|
|
165
|
-
const fixedParams = params
|
|
166
|
-
.replace(/\$json\./g, `$('${previousNode}').item.json.`)
|
|
167
|
-
.replace(/\{\{\s*\$json\./g, `{{ $('${previousNode}').item.json.`);
|
|
168
|
-
|
|
169
|
-
if (params === fixedParams) return null;
|
|
170
|
-
|
|
171
|
-
node.parameters = JSON.parse(fixedParams);
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
type: 'expression_fix',
|
|
175
|
-
target: `node:${node.name}`,
|
|
176
|
-
description: `Changed $json to explicit $('${previousNode}') reference`,
|
|
177
|
-
before: '$json.',
|
|
178
|
-
after: `$('${previousNode}').item.json.`,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Find the previous node in the connection chain
|
|
184
|
-
*/
|
|
185
|
-
function findPreviousNode(workflow: N8nWorkflow, nodeName: string): string | null {
|
|
186
|
-
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
|
187
|
-
for (const outputType of Object.values(outputs)) {
|
|
188
|
-
for (const connections of outputType) {
|
|
189
|
-
for (const conn of connections) {
|
|
190
|
-
if (conn.node === nodeName) {
|
|
191
|
-
return sourceName;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Fix AI structured output settings
|
|
202
|
-
*/
|
|
203
|
-
function fixAIStructuredOutput(
|
|
204
|
-
workflow: N8nWorkflow,
|
|
205
|
-
warning: ValidationWarning
|
|
206
|
-
): AutofixAction | null {
|
|
207
|
-
if (!warning.node) return null;
|
|
208
|
-
|
|
209
|
-
const node = workflow.nodes.find((n) => n.name === warning.node);
|
|
210
|
-
if (!node) return null;
|
|
211
|
-
|
|
212
|
-
const params = node.parameters as Record<string, unknown>;
|
|
213
|
-
const changes: string[] = [];
|
|
214
|
-
|
|
215
|
-
if (params.promptType !== 'define') {
|
|
216
|
-
params.promptType = 'define';
|
|
217
|
-
changes.push('promptType: "define"');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (params.hasOutputParser !== true) {
|
|
221
|
-
params.hasOutputParser = true;
|
|
222
|
-
changes.push('hasOutputParser: true');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (changes.length === 0) return null;
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
type: 'parameter_fix',
|
|
229
|
-
target: `node:${node.name}`,
|
|
230
|
-
description: `Added AI structured output settings: ${changes.join(', ')}`,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Format a workflow for consistency
|
|
236
|
-
* - Sorts nodes by position
|
|
237
|
-
* - Normalizes connection format
|
|
238
|
-
* - Removes empty/null values
|
|
239
|
-
*/
|
|
240
|
-
export function formatWorkflow(workflow: N8nWorkflow): N8nWorkflow {
|
|
241
|
-
const formatted: N8nWorkflow = JSON.parse(JSON.stringify(workflow));
|
|
242
|
-
|
|
243
|
-
// Sort nodes by Y position, then X
|
|
244
|
-
formatted.nodes.sort((a, b) => {
|
|
245
|
-
const [ax, ay] = a.position;
|
|
246
|
-
const [bx, by] = b.position;
|
|
247
|
-
if (ay !== by) return ay - by;
|
|
248
|
-
return ax - bx;
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// Clean up parameters - remove undefined/null
|
|
252
|
-
for (const node of formatted.nodes) {
|
|
253
|
-
node.parameters = cleanObject(node.parameters);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return formatted;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Remove null/undefined values from object
|
|
261
|
-
*/
|
|
262
|
-
function cleanObject(obj: Record<string, unknown>): Record<string, unknown> {
|
|
263
|
-
const cleaned: Record<string, unknown> = {};
|
|
264
|
-
|
|
265
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
266
|
-
if (value === null || value === undefined) continue;
|
|
267
|
-
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
268
|
-
cleaned[key] = cleanObject(value as Record<string, unknown>);
|
|
269
|
-
} else {
|
|
270
|
-
cleaned[key] = value;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return cleaned;
|
|
275
|
-
}
|
package/src/expressions.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* n8n expression validation
|
|
3
|
-
* Parses and validates expressions in workflow parameters
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { N8nWorkflow, N8nNode, ValidationWarning } from './types.js';
|
|
7
|
-
|
|
8
|
-
export interface ExpressionIssue {
|
|
9
|
-
node: string;
|
|
10
|
-
parameter: string;
|
|
11
|
-
expression: string;
|
|
12
|
-
issue: string;
|
|
13
|
-
severity: 'error' | 'warning' | 'info';
|
|
14
|
-
suggestion?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Validate all expressions in a workflow
|
|
19
|
-
*/
|
|
20
|
-
export function validateExpressions(workflow: N8nWorkflow): ExpressionIssue[] {
|
|
21
|
-
const issues: ExpressionIssue[] = [];
|
|
22
|
-
const nodeNames = new Set(workflow.nodes.map((n) => n.name));
|
|
23
|
-
|
|
24
|
-
for (const node of workflow.nodes) {
|
|
25
|
-
const nodeIssues = validateNodeExpressions(node, nodeNames);
|
|
26
|
-
issues.push(...nodeIssues);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return issues;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Validate expressions in a single node
|
|
34
|
-
*/
|
|
35
|
-
function validateNodeExpressions(node: N8nNode, nodeNames: Set<string>): ExpressionIssue[] {
|
|
36
|
-
const issues: ExpressionIssue[] = [];
|
|
37
|
-
const expressions = extractExpressions(node.parameters);
|
|
38
|
-
|
|
39
|
-
for (const { path, expression } of expressions) {
|
|
40
|
-
const exprIssues = validateExpression(expression, nodeNames);
|
|
41
|
-
for (const issue of exprIssues) {
|
|
42
|
-
issues.push({
|
|
43
|
-
node: node.name,
|
|
44
|
-
parameter: path,
|
|
45
|
-
expression,
|
|
46
|
-
...issue,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return issues;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Extract all expressions from parameters
|
|
56
|
-
*/
|
|
57
|
-
function extractExpressions(
|
|
58
|
-
params: Record<string, unknown>,
|
|
59
|
-
basePath: string = ''
|
|
60
|
-
): Array<{ path: string; expression: string }> {
|
|
61
|
-
const results: Array<{ path: string; expression: string }> = [];
|
|
62
|
-
|
|
63
|
-
for (const [key, value] of Object.entries(params)) {
|
|
64
|
-
const path = basePath ? `${basePath}.${key}` : key;
|
|
65
|
-
|
|
66
|
-
if (typeof value === 'string') {
|
|
67
|
-
// Check for expression patterns
|
|
68
|
-
const exprMatches = value.match(/\{\{.*?\}\}/g) || [];
|
|
69
|
-
for (const match of exprMatches) {
|
|
70
|
-
results.push({ path, expression: match });
|
|
71
|
-
}
|
|
72
|
-
// Also check for ={{ prefix (common in n8n)
|
|
73
|
-
if (value.startsWith('={{')) {
|
|
74
|
-
results.push({ path, expression: value });
|
|
75
|
-
}
|
|
76
|
-
} else if (typeof value === 'object' && value !== null) {
|
|
77
|
-
if (Array.isArray(value)) {
|
|
78
|
-
for (let i = 0; i < value.length; i++) {
|
|
79
|
-
if (typeof value[i] === 'object' && value[i] !== null) {
|
|
80
|
-
results.push(...extractExpressions(value[i] as Record<string, unknown>, `${path}[${i}]`));
|
|
81
|
-
} else if (typeof value[i] === 'string') {
|
|
82
|
-
const strVal = value[i] as string;
|
|
83
|
-
const exprMatches = strVal.match(/\{\{.*?\}\}/g) || [];
|
|
84
|
-
for (const match of exprMatches) {
|
|
85
|
-
results.push({ path: `${path}[${i}]`, expression: match });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
results.push(...extractExpressions(value as Record<string, unknown>, path));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return results;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Validate a single expression
|
|
100
|
-
*/
|
|
101
|
-
function validateExpression(
|
|
102
|
-
expression: string,
|
|
103
|
-
nodeNames: Set<string>
|
|
104
|
-
): Array<Omit<ExpressionIssue, 'node' | 'parameter' | 'expression'>> {
|
|
105
|
-
const issues: Array<Omit<ExpressionIssue, 'node' | 'parameter' | 'expression'>> = [];
|
|
106
|
-
|
|
107
|
-
// Strip wrapper
|
|
108
|
-
let inner = expression;
|
|
109
|
-
if (inner.startsWith('={{')) {
|
|
110
|
-
inner = inner.slice(3).trim();
|
|
111
|
-
} else if (inner.startsWith('{{') && inner.endsWith('}}')) {
|
|
112
|
-
inner = inner.slice(2, -2).trim();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Check for $json usage (should use explicit reference)
|
|
116
|
-
if (/\$json\./.test(inner) && !/\$\(['"]/.test(inner)) {
|
|
117
|
-
issues.push({
|
|
118
|
-
issue: 'Uses $json instead of explicit node reference',
|
|
119
|
-
severity: 'warning',
|
|
120
|
-
suggestion: "Use $('node_name').item.json.field instead",
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Check for $input usage (acceptable but check context)
|
|
125
|
-
if (/\$input\./.test(inner)) {
|
|
126
|
-
issues.push({
|
|
127
|
-
issue: '$input reference found - ensure this is intentional',
|
|
128
|
-
severity: 'info',
|
|
129
|
-
suggestion: "Consider explicit $('node_name') for clarity",
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Check for node references that don't exist
|
|
134
|
-
const nodeRefMatches = inner.matchAll(/\$\(['"]([^'"]+)['"]\)/g);
|
|
135
|
-
for (const match of nodeRefMatches) {
|
|
136
|
-
const refNodeName = match[1];
|
|
137
|
-
if (!nodeNames.has(refNodeName)) {
|
|
138
|
-
issues.push({
|
|
139
|
-
issue: `References non-existent node "${refNodeName}"`,
|
|
140
|
-
severity: 'error',
|
|
141
|
-
suggestion: `Check if the node exists or was renamed`,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Check for common syntax errors
|
|
147
|
-
if (expression.includes('{{') && !expression.includes('}}')) {
|
|
148
|
-
issues.push({
|
|
149
|
-
issue: 'Missing closing }}',
|
|
150
|
-
severity: 'error',
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Check for unmatched parentheses
|
|
155
|
-
const openParens = (inner.match(/\(/g) || []).length;
|
|
156
|
-
const closeParens = (inner.match(/\)/g) || []).length;
|
|
157
|
-
if (openParens !== closeParens) {
|
|
158
|
-
issues.push({
|
|
159
|
-
issue: 'Unmatched parentheses',
|
|
160
|
-
severity: 'error',
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Check for unmatched brackets
|
|
165
|
-
const openBrackets = (inner.match(/\[/g) || []).length;
|
|
166
|
-
const closeBrackets = (inner.match(/\]/g) || []).length;
|
|
167
|
-
if (openBrackets !== closeBrackets) {
|
|
168
|
-
issues.push({
|
|
169
|
-
issue: 'Unmatched brackets',
|
|
170
|
-
severity: 'error',
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Check for deprecated patterns
|
|
175
|
-
if (/\$node\./.test(inner)) {
|
|
176
|
-
issues.push({
|
|
177
|
-
issue: '$node is deprecated',
|
|
178
|
-
severity: 'warning',
|
|
179
|
-
suggestion: "Use $('node_name') instead",
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Check for potential undefined access
|
|
184
|
-
if (/\.json\.[a-zA-Z_]+\.[a-zA-Z_]+/.test(inner) && !/\?\./g.test(inner)) {
|
|
185
|
-
issues.push({
|
|
186
|
-
issue: 'Deep property access without optional chaining',
|
|
187
|
-
severity: 'info',
|
|
188
|
-
suggestion: 'Consider using ?. for safer access: obj?.nested?.field',
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return issues;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Get all referenced nodes from expressions
|
|
197
|
-
*/
|
|
198
|
-
export function getReferencedNodes(workflow: N8nWorkflow): Map<string, string[]> {
|
|
199
|
-
const references = new Map<string, string[]>();
|
|
200
|
-
|
|
201
|
-
for (const node of workflow.nodes) {
|
|
202
|
-
const expressions = extractExpressions(node.parameters);
|
|
203
|
-
const refsForNode: string[] = [];
|
|
204
|
-
|
|
205
|
-
for (const { expression } of expressions) {
|
|
206
|
-
const matches = expression.matchAll(/\$\(['"]([^'"]+)['"]\)/g);
|
|
207
|
-
for (const match of matches) {
|
|
208
|
-
const refName = match[1];
|
|
209
|
-
if (!refsForNode.includes(refName)) {
|
|
210
|
-
refsForNode.push(refName);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (refsForNode.length > 0) {
|
|
216
|
-
references.set(node.name, refsForNode);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return references;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Check for circular references in expressions
|
|
225
|
-
*/
|
|
226
|
-
export function checkCircularReferences(workflow: N8nWorkflow): string[][] {
|
|
227
|
-
const references = getReferencedNodes(workflow);
|
|
228
|
-
const cycles: string[][] = [];
|
|
229
|
-
|
|
230
|
-
function findCycle(start: string, path: string[] = []): void {
|
|
231
|
-
if (path.includes(start)) {
|
|
232
|
-
const cycleStart = path.indexOf(start);
|
|
233
|
-
cycles.push([...path.slice(cycleStart), start]);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const refs = references.get(start) || [];
|
|
238
|
-
for (const ref of refs) {
|
|
239
|
-
findCycle(ref, [...path, start]);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
for (const nodeName of references.keys()) {
|
|
244
|
-
findCycle(nodeName);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Deduplicate cycles
|
|
248
|
-
const uniqueCycles = cycles.filter((cycle, index) => {
|
|
249
|
-
const key = [...cycle].sort().join(',');
|
|
250
|
-
return cycles.findIndex((c) => [...c].sort().join(',') === key) === index;
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
return uniqueCycles;
|
|
254
|
-
}
|