@replikanti/flowlint-core 0.6.0
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/README.md +74 -0
- package/dist/index.d.mts +230 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +1209 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1183 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +58 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/parser/parser-n8n.ts
|
|
9
|
+
import YAML from "yaml";
|
|
10
|
+
|
|
11
|
+
// src/schemas/index.ts
|
|
12
|
+
import Ajv from "ajv";
|
|
13
|
+
import addFormats from "ajv-formats";
|
|
14
|
+
|
|
15
|
+
// src/schemas/n8n-workflow.schema.json
|
|
16
|
+
var n8n_workflow_schema_default = {
|
|
17
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
18
|
+
$id: "https://flowlint.dev/schemas/n8n-workflow.json",
|
|
19
|
+
title: "n8n Workflow Schema",
|
|
20
|
+
description: "JSON Schema for n8n workflow files (v1.x)",
|
|
21
|
+
type: "object",
|
|
22
|
+
required: ["nodes", "connections"],
|
|
23
|
+
properties: {
|
|
24
|
+
name: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Workflow name"
|
|
27
|
+
},
|
|
28
|
+
nodes: {
|
|
29
|
+
type: "array",
|
|
30
|
+
description: "Array of workflow nodes",
|
|
31
|
+
items: {
|
|
32
|
+
type: "object",
|
|
33
|
+
required: ["type", "name"],
|
|
34
|
+
properties: {
|
|
35
|
+
id: {
|
|
36
|
+
type: "string",
|
|
37
|
+
minLength: 1,
|
|
38
|
+
description: "Unique node identifier"
|
|
39
|
+
},
|
|
40
|
+
type: {
|
|
41
|
+
type: "string",
|
|
42
|
+
minLength: 1,
|
|
43
|
+
description: "Node type (e.g., n8n-nodes-base.httpRequest)"
|
|
44
|
+
},
|
|
45
|
+
name: {
|
|
46
|
+
type: "string",
|
|
47
|
+
minLength: 1,
|
|
48
|
+
description: "Human-readable node name"
|
|
49
|
+
},
|
|
50
|
+
parameters: {
|
|
51
|
+
type: "object",
|
|
52
|
+
description: "Node-specific configuration parameters"
|
|
53
|
+
},
|
|
54
|
+
credentials: {
|
|
55
|
+
type: "object",
|
|
56
|
+
description: "Credential references for this node"
|
|
57
|
+
},
|
|
58
|
+
position: {
|
|
59
|
+
type: "array",
|
|
60
|
+
description: "X,Y coordinates for UI placement",
|
|
61
|
+
items: {
|
|
62
|
+
type: "number"
|
|
63
|
+
},
|
|
64
|
+
minItems: 2,
|
|
65
|
+
maxItems: 2
|
|
66
|
+
},
|
|
67
|
+
continueOnFail: {
|
|
68
|
+
type: "boolean",
|
|
69
|
+
description: "Whether to continue execution on node failure"
|
|
70
|
+
},
|
|
71
|
+
disabled: {
|
|
72
|
+
type: "boolean",
|
|
73
|
+
description: "Whether the node is disabled"
|
|
74
|
+
},
|
|
75
|
+
notesInFlow: {
|
|
76
|
+
type: "boolean"
|
|
77
|
+
},
|
|
78
|
+
notes: {
|
|
79
|
+
type: "string"
|
|
80
|
+
},
|
|
81
|
+
typeVersion: {
|
|
82
|
+
type: "number",
|
|
83
|
+
description: "Version of the node type"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
additionalProperties: true
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
connections: {
|
|
90
|
+
type: "object",
|
|
91
|
+
description: "Map of node connections (source node ID -> connection details)",
|
|
92
|
+
patternProperties: {
|
|
93
|
+
"^.*$": {
|
|
94
|
+
type: "object",
|
|
95
|
+
description: "Connection channels for a source node",
|
|
96
|
+
patternProperties: {
|
|
97
|
+
"^(main|error|timeout|.*?)$": {
|
|
98
|
+
description: "Connection array for this channel",
|
|
99
|
+
oneOf: [
|
|
100
|
+
{
|
|
101
|
+
type: "array",
|
|
102
|
+
items: {
|
|
103
|
+
type: "object",
|
|
104
|
+
required: ["node"],
|
|
105
|
+
properties: {
|
|
106
|
+
node: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Target node ID or name"
|
|
109
|
+
},
|
|
110
|
+
type: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "Connection type"
|
|
113
|
+
},
|
|
114
|
+
index: {
|
|
115
|
+
type: "number",
|
|
116
|
+
description: "Output index"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: "array",
|
|
123
|
+
items: {
|
|
124
|
+
type: "array",
|
|
125
|
+
items: {
|
|
126
|
+
type: "object",
|
|
127
|
+
required: ["node"],
|
|
128
|
+
properties: {
|
|
129
|
+
node: {
|
|
130
|
+
type: "string"
|
|
131
|
+
},
|
|
132
|
+
type: {
|
|
133
|
+
type: "string"
|
|
134
|
+
},
|
|
135
|
+
index: {
|
|
136
|
+
type: "number"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
active: {
|
|
149
|
+
type: "boolean",
|
|
150
|
+
description: "Whether the workflow is active"
|
|
151
|
+
},
|
|
152
|
+
settings: {
|
|
153
|
+
type: "object",
|
|
154
|
+
description: "Workflow settings"
|
|
155
|
+
},
|
|
156
|
+
tags: {
|
|
157
|
+
type: "array",
|
|
158
|
+
description: "Workflow tags",
|
|
159
|
+
items: {
|
|
160
|
+
oneOf: [
|
|
161
|
+
{ type: "string" },
|
|
162
|
+
{
|
|
163
|
+
type: "object",
|
|
164
|
+
required: ["name"],
|
|
165
|
+
properties: {
|
|
166
|
+
id: { type: "string" },
|
|
167
|
+
name: { type: "string" }
|
|
168
|
+
},
|
|
169
|
+
additionalProperties: true
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
pinData: {
|
|
175
|
+
type: "object",
|
|
176
|
+
description: "Pinned execution data for testing"
|
|
177
|
+
},
|
|
178
|
+
versionId: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Workflow version identifier"
|
|
181
|
+
},
|
|
182
|
+
id: {
|
|
183
|
+
type: ["string", "number"],
|
|
184
|
+
description: "Workflow ID"
|
|
185
|
+
},
|
|
186
|
+
meta: {
|
|
187
|
+
type: "object",
|
|
188
|
+
description: "Metadata"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
additionalProperties: true
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/utils/utils.ts
|
|
195
|
+
function flattenConnections(value) {
|
|
196
|
+
if (!value) return [];
|
|
197
|
+
if (Array.isArray(value)) {
|
|
198
|
+
return value.flatMap((entry) => flattenConnections(entry));
|
|
199
|
+
}
|
|
200
|
+
if (typeof value === "object" && "node" in value) {
|
|
201
|
+
return [value];
|
|
202
|
+
}
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
function buildValidationErrors(items, errorConfig) {
|
|
206
|
+
const itemArray = Array.isArray(items) ? items : Array.from(items);
|
|
207
|
+
return itemArray.map((item) => ({
|
|
208
|
+
path: errorConfig.path,
|
|
209
|
+
message: errorConfig.messageTemplate(item),
|
|
210
|
+
suggestion: errorConfig.suggestionTemplate(item)
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
function collectStrings(value, out = []) {
|
|
214
|
+
if (typeof value === "string") out.push(value);
|
|
215
|
+
else if (Array.isArray(value)) value.forEach((entry) => collectStrings(entry, out));
|
|
216
|
+
else if (value && typeof value === "object")
|
|
217
|
+
Object.values(value).forEach((entry) => collectStrings(entry, out));
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function toRegex(pattern) {
|
|
221
|
+
let source = pattern;
|
|
222
|
+
let flags = "";
|
|
223
|
+
if (source.startsWith("(?i)")) {
|
|
224
|
+
source = source.slice(4);
|
|
225
|
+
flags += "i";
|
|
226
|
+
}
|
|
227
|
+
return new RegExp(source, flags);
|
|
228
|
+
}
|
|
229
|
+
function isApiNode(type) {
|
|
230
|
+
return /http|request|google|facebook|ads/i.test(type);
|
|
231
|
+
}
|
|
232
|
+
function isMutationNode(type) {
|
|
233
|
+
return /write|insert|update|delete|post|put|patch|database|mongo|supabase|sheet/i.test(type);
|
|
234
|
+
}
|
|
235
|
+
function isErrorProneNode(type) {
|
|
236
|
+
return isApiNode(type) || isMutationNode(type) || /execute|workflow|function/i.test(type);
|
|
237
|
+
}
|
|
238
|
+
function isNotificationNode(type) {
|
|
239
|
+
return /slack|discord|email|gotify|mattermost|microsoftTeams|pushbullet|pushover|rocketchat|zulip|telegram/i.test(
|
|
240
|
+
type
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
function isErrorHandlerNode(type, name) {
|
|
244
|
+
const normalizedType = type.toLowerCase();
|
|
245
|
+
if (normalizedType.includes("stopanderror")) return true;
|
|
246
|
+
if (normalizedType.includes("errorhandler")) return true;
|
|
247
|
+
if (normalizedType.includes("raiseerror")) return true;
|
|
248
|
+
const normalizedName = name?.toLowerCase() ?? "";
|
|
249
|
+
if (normalizedName.includes("stop and error")) return true;
|
|
250
|
+
if (normalizedName.includes("error handler")) return true;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
function isRejoinNode(graph, nodeId) {
|
|
254
|
+
const incoming = graph.edges.filter((e) => e.to === nodeId);
|
|
255
|
+
if (incoming.length <= 1) return false;
|
|
256
|
+
const hasErrorEdge = incoming.some((e) => e.on === "error");
|
|
257
|
+
const hasSuccessEdge = incoming.some((e) => e.on !== "error");
|
|
258
|
+
return hasErrorEdge && hasSuccessEdge;
|
|
259
|
+
}
|
|
260
|
+
function isMeaningfulConsumer(node) {
|
|
261
|
+
return isMutationNode(node.type) || // Writes to a DB, sheet, etc.
|
|
262
|
+
isNotificationNode(node.type) || // Sends a message to Slack, email, etc.
|
|
263
|
+
isApiNode(node.type) || // Calls an external API
|
|
264
|
+
/respondToWebhook/i.test(node.type);
|
|
265
|
+
}
|
|
266
|
+
function containsCandidate(value, candidates) {
|
|
267
|
+
if (!value || !candidates.length) return false;
|
|
268
|
+
const queue = [value];
|
|
269
|
+
const candidateRegex = new RegExp(`(${candidates.join("|")})`, "i");
|
|
270
|
+
while (queue.length > 0) {
|
|
271
|
+
const current = queue.shift();
|
|
272
|
+
if (typeof current === "string") {
|
|
273
|
+
if (candidateRegex.test(current)) return true;
|
|
274
|
+
} else if (Array.isArray(current)) {
|
|
275
|
+
queue.push(...current);
|
|
276
|
+
} else if (current && typeof current === "object") {
|
|
277
|
+
for (const [key, val] of Object.entries(current)) {
|
|
278
|
+
if (candidateRegex.test(key)) return true;
|
|
279
|
+
queue.push(val);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
var TERMINAL_NODE_PATTERNS = [
|
|
286
|
+
"respond",
|
|
287
|
+
"reply",
|
|
288
|
+
"end",
|
|
289
|
+
"stop",
|
|
290
|
+
"terminate",
|
|
291
|
+
"return",
|
|
292
|
+
"sticky",
|
|
293
|
+
"note",
|
|
294
|
+
"noop",
|
|
295
|
+
"no operation",
|
|
296
|
+
"slack",
|
|
297
|
+
"email",
|
|
298
|
+
"discord",
|
|
299
|
+
"teams",
|
|
300
|
+
"webhook",
|
|
301
|
+
"telegram",
|
|
302
|
+
"pushbullet",
|
|
303
|
+
"mattermost",
|
|
304
|
+
"notifier",
|
|
305
|
+
"notification",
|
|
306
|
+
"alert",
|
|
307
|
+
"sms",
|
|
308
|
+
"call"
|
|
309
|
+
];
|
|
310
|
+
function isTerminalNode(type, name) {
|
|
311
|
+
const label = `${type} ${name ?? ""}`.toLowerCase();
|
|
312
|
+
return TERMINAL_NODE_PATTERNS.some((pattern) => label.includes(pattern));
|
|
313
|
+
}
|
|
314
|
+
function readNumber(source, paths) {
|
|
315
|
+
for (const path of paths) {
|
|
316
|
+
const value = path.split(".").reduce((acc, key) => acc ? acc[key] : void 0, source);
|
|
317
|
+
if (typeof value === "number") return value;
|
|
318
|
+
if (typeof value === "string" && !Number.isNaN(Number(value))) return Number(value);
|
|
319
|
+
}
|
|
320
|
+
return void 0;
|
|
321
|
+
}
|
|
322
|
+
function findAllDownstreamNodes(graph, startNodeId) {
|
|
323
|
+
const visited = /* @__PURE__ */ new Set();
|
|
324
|
+
const queue = [startNodeId];
|
|
325
|
+
visited.add(startNodeId);
|
|
326
|
+
let head = 0;
|
|
327
|
+
while (head < queue.length) {
|
|
328
|
+
const currentId = queue[head++];
|
|
329
|
+
const outgoing = graph.edges.filter((e) => e.from === currentId);
|
|
330
|
+
for (const edge of outgoing) {
|
|
331
|
+
if (!visited.has(edge.to)) {
|
|
332
|
+
visited.add(edge.to);
|
|
333
|
+
queue.push(edge.to);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return visited;
|
|
338
|
+
}
|
|
339
|
+
function findAllUpstreamNodes(graph, startNodeId) {
|
|
340
|
+
const visited = /* @__PURE__ */ new Set();
|
|
341
|
+
const queue = [startNodeId];
|
|
342
|
+
visited.add(startNodeId);
|
|
343
|
+
let head = 0;
|
|
344
|
+
while (head < queue.length) {
|
|
345
|
+
const currentId = queue[head++];
|
|
346
|
+
const incoming = graph.edges.filter((e) => e.to === currentId);
|
|
347
|
+
for (const edge of incoming) {
|
|
348
|
+
if (!visited.has(edge.from)) {
|
|
349
|
+
visited.add(edge.from);
|
|
350
|
+
queue.push(edge.from);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return visited;
|
|
355
|
+
}
|
|
356
|
+
var EXAMPLES_BASE_URL = "https://github.com/Replikanti/flowlint-examples/tree/main";
|
|
357
|
+
function getExampleLink(ruleId) {
|
|
358
|
+
return `${EXAMPLES_BASE_URL}/${ruleId}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/schemas/index.ts
|
|
362
|
+
var ValidationError = class extends Error {
|
|
363
|
+
constructor(errors) {
|
|
364
|
+
super(`Workflow validation failed: ${errors.length} error(s)`);
|
|
365
|
+
this.errors = errors;
|
|
366
|
+
this.name = "ValidationError";
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
var createDummyValidator = () => {
|
|
370
|
+
const v = () => true;
|
|
371
|
+
v.errors = [];
|
|
372
|
+
return v;
|
|
373
|
+
};
|
|
374
|
+
var validatorInstance = null;
|
|
375
|
+
function getValidator() {
|
|
376
|
+
if (validatorInstance) return validatorInstance;
|
|
377
|
+
const isNode = typeof process !== "undefined" && process?.versions?.node != null;
|
|
378
|
+
if (isNode) {
|
|
379
|
+
try {
|
|
380
|
+
const ajv = new Ajv({
|
|
381
|
+
allErrors: true,
|
|
382
|
+
strict: false,
|
|
383
|
+
verbose: true,
|
|
384
|
+
code: { source: true, es5: true }
|
|
385
|
+
});
|
|
386
|
+
addFormats(ajv);
|
|
387
|
+
validatorInstance = ajv.compile(n8n_workflow_schema_default);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
console.warn("Failed to compile JSON schema validator, falling back to dummy validator:", error);
|
|
390
|
+
validatorInstance = createDummyValidator();
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
validatorInstance = createDummyValidator();
|
|
394
|
+
}
|
|
395
|
+
return validatorInstance;
|
|
396
|
+
}
|
|
397
|
+
function throwIfInvalid(items, config) {
|
|
398
|
+
if (items.size > 0) {
|
|
399
|
+
const errors = buildValidationErrors(items, config);
|
|
400
|
+
throw new ValidationError(errors);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function checkDuplicateNodeIds(data) {
|
|
404
|
+
if (!Array.isArray(data.nodes)) return;
|
|
405
|
+
const seen = /* @__PURE__ */ new Set();
|
|
406
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
407
|
+
for (const node of data.nodes) {
|
|
408
|
+
if (node.id && seen.has(node.id)) {
|
|
409
|
+
duplicates.add(node.id);
|
|
410
|
+
}
|
|
411
|
+
if (node.id) {
|
|
412
|
+
seen.add(node.id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
throwIfInvalid(duplicates, {
|
|
416
|
+
path: "nodes[].id",
|
|
417
|
+
messageTemplate: (id) => `Duplicate node ID: "${id}"`,
|
|
418
|
+
suggestionTemplate: (id) => `Each node must have a unique ID. Remove or rename the duplicate node with ID "${id}".`
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function checkOrphanedConnections(data) {
|
|
422
|
+
if (!data.connections || !Array.isArray(data.nodes)) return;
|
|
423
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
424
|
+
const nodeNames = /* @__PURE__ */ new Set();
|
|
425
|
+
for (const node of data.nodes) {
|
|
426
|
+
if (node.id) nodeIds.add(node.id);
|
|
427
|
+
if (node.name) nodeNames.add(node.name);
|
|
428
|
+
}
|
|
429
|
+
const orphanedRefs = /* @__PURE__ */ new Set();
|
|
430
|
+
Object.entries(data.connections).forEach(([sourceId, channels]) => {
|
|
431
|
+
if (!nodeIds.has(sourceId) && !nodeNames.has(sourceId)) {
|
|
432
|
+
orphanedRefs.add(sourceId);
|
|
433
|
+
}
|
|
434
|
+
if (typeof channels === "object" && channels !== null) {
|
|
435
|
+
Object.values(channels).forEach((connArray) => {
|
|
436
|
+
const flatConnections = flattenConnections(connArray);
|
|
437
|
+
flatConnections.forEach((conn) => {
|
|
438
|
+
if (conn?.node) {
|
|
439
|
+
if (!nodeIds.has(conn.node) && !nodeNames.has(conn.node)) {
|
|
440
|
+
orphanedRefs.add(conn.node);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
throwIfInvalid(orphanedRefs, {
|
|
448
|
+
path: "connections",
|
|
449
|
+
messageTemplate: (ref) => `Orphaned connection reference: "${ref}"`,
|
|
450
|
+
suggestionTemplate: (ref) => `Connection references node "${ref}" which does not exist. Add the missing node or remove the invalid connection.`
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
function validateN8nWorkflow(data) {
|
|
454
|
+
const validate = getValidator();
|
|
455
|
+
if (!validate(data)) {
|
|
456
|
+
const errors = (validate.errors || []).map((err) => {
|
|
457
|
+
const path = err.instancePath || err.schemaPath;
|
|
458
|
+
const message = err.message || "Validation error";
|
|
459
|
+
let suggestion = "";
|
|
460
|
+
if (err.keyword === "required") {
|
|
461
|
+
const missing = err.params?.missingProperty;
|
|
462
|
+
suggestion = `Add the required field "${missing}" to the workflow.`;
|
|
463
|
+
} else if (err.keyword === "type") {
|
|
464
|
+
const expected = err.params?.type;
|
|
465
|
+
suggestion = `The field should be of type "${expected}".`;
|
|
466
|
+
} else if (err.keyword === "minLength") {
|
|
467
|
+
suggestion = "This field cannot be empty.";
|
|
468
|
+
}
|
|
469
|
+
return { path, message, suggestion };
|
|
470
|
+
});
|
|
471
|
+
throw new ValidationError(errors);
|
|
472
|
+
}
|
|
473
|
+
checkDuplicateNodeIds(data);
|
|
474
|
+
checkOrphanedConnections(data);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/parser/parser-n8n.ts
|
|
478
|
+
function parseN8n(doc) {
|
|
479
|
+
let parsed;
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(doc);
|
|
482
|
+
} catch {
|
|
483
|
+
parsed = YAML.parse(doc);
|
|
484
|
+
}
|
|
485
|
+
validateN8nWorkflow(parsed);
|
|
486
|
+
const nodes = parsed.nodes.map((node, idx) => {
|
|
487
|
+
const nodeId = node.id || node.name || `node-${idx}`;
|
|
488
|
+
const flags = {
|
|
489
|
+
continueOnFail: node.continueOnFail,
|
|
490
|
+
retryOnFail: node.retryOnFail ?? node.settings?.retryOnFail,
|
|
491
|
+
waitBetweenTries: node.waitBetweenTries ?? node.settings?.waitBetweenTries,
|
|
492
|
+
maxTries: node.maxTries ?? node.settings?.maxTries
|
|
493
|
+
};
|
|
494
|
+
const hasFlags = flags.continueOnFail !== void 0 || flags.retryOnFail !== void 0 || flags.waitBetweenTries !== void 0;
|
|
495
|
+
return {
|
|
496
|
+
id: nodeId,
|
|
497
|
+
type: node.type,
|
|
498
|
+
name: node.name,
|
|
499
|
+
params: node.parameters,
|
|
500
|
+
cred: node.credentials,
|
|
501
|
+
flags: hasFlags ? flags : void 0
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
const nameToId = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const node of nodes) {
|
|
506
|
+
if (node.id) nameToId.set(node.id, node.id);
|
|
507
|
+
if (node.name) nameToId.set(node.name, node.id);
|
|
508
|
+
}
|
|
509
|
+
const lines = doc.split(/\r?\n/);
|
|
510
|
+
const idLine = /* @__PURE__ */ new Map();
|
|
511
|
+
const nameLine = /* @__PURE__ */ new Map();
|
|
512
|
+
lines.forEach((line, idx) => {
|
|
513
|
+
const idMatch = line.match(/"id":\s*"([^"]+)"/);
|
|
514
|
+
if (idMatch) idLine.set(idMatch[1], idx + 1);
|
|
515
|
+
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
|
|
516
|
+
if (nameMatch) nameLine.set(nameMatch[1], idx + 1);
|
|
517
|
+
});
|
|
518
|
+
const nodeLines = /* @__PURE__ */ new Map();
|
|
519
|
+
for (const node of nodes) {
|
|
520
|
+
const lineNumber = node.name && nameLine.get(node.name) || idLine.get(node.id);
|
|
521
|
+
if (lineNumber) {
|
|
522
|
+
nodeLines.set(node.id, lineNumber);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const nodeById = /* @__PURE__ */ new Map();
|
|
526
|
+
for (const node of nodes) {
|
|
527
|
+
nodeById.set(node.id, node);
|
|
528
|
+
}
|
|
529
|
+
const resolveEdgeType = (connectionType, outputIndex, sourceType) => {
|
|
530
|
+
if (connectionType === "error") return "error";
|
|
531
|
+
if (connectionType === "timeout") return "timeout";
|
|
532
|
+
if (connectionType === "main") {
|
|
533
|
+
if (typeof outputIndex === "number" && outputIndex > 0 && sourceType && isErrorProneNode(sourceType)) {
|
|
534
|
+
return "error";
|
|
535
|
+
}
|
|
536
|
+
return "success";
|
|
537
|
+
}
|
|
538
|
+
return "success";
|
|
539
|
+
};
|
|
540
|
+
const edges = [];
|
|
541
|
+
Object.entries(parsed.connections || {}).forEach(([from, exits]) => {
|
|
542
|
+
if (!exits) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const exitChannels = exits;
|
|
546
|
+
Object.entries(exitChannels).forEach(([exitType, conn]) => {
|
|
547
|
+
const sourceId = nameToId.get(from) ?? from;
|
|
548
|
+
const sourceNode = nodeById.get(sourceId);
|
|
549
|
+
const enqueueEdges = (value, outputIndex) => {
|
|
550
|
+
flattenConnections(value).forEach((link) => {
|
|
551
|
+
if (!link || typeof link !== "object") return;
|
|
552
|
+
const targetId = nameToId.get(link.node) ?? link.node;
|
|
553
|
+
if (!targetId) return;
|
|
554
|
+
const edgeType = resolveEdgeType(exitType, outputIndex, sourceNode?.type);
|
|
555
|
+
edges.push({ from: sourceId, to: targetId, on: edgeType });
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
if (Array.isArray(conn)) {
|
|
559
|
+
conn.forEach((entry, index) => enqueueEdges(entry, index));
|
|
560
|
+
} else {
|
|
561
|
+
enqueueEdges(conn);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
return {
|
|
566
|
+
nodes,
|
|
567
|
+
edges,
|
|
568
|
+
meta: {
|
|
569
|
+
credentials: !!parsed.credentials,
|
|
570
|
+
nodeLines: Object.fromEntries(nodeLines)
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/rules/rule-utils.ts
|
|
576
|
+
function createNodeRule(ruleId, configKey, logic) {
|
|
577
|
+
return (graph, ctx) => {
|
|
578
|
+
const ruleConfig = ctx.cfg.rules[configKey];
|
|
579
|
+
if (!ruleConfig?.enabled) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const findings = [];
|
|
583
|
+
for (const node of graph.nodes) {
|
|
584
|
+
const result = logic(node, graph, ctx);
|
|
585
|
+
if (result) {
|
|
586
|
+
if (Array.isArray(result)) {
|
|
587
|
+
findings.push(...result);
|
|
588
|
+
} else {
|
|
589
|
+
findings.push(result);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return findings;
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function createHardcodedStringRule({
|
|
597
|
+
ruleId,
|
|
598
|
+
severity,
|
|
599
|
+
configKey,
|
|
600
|
+
messageFn,
|
|
601
|
+
details
|
|
602
|
+
}) {
|
|
603
|
+
const logic = (node, graph, ctx) => {
|
|
604
|
+
const cfg = ctx.cfg.rules[configKey];
|
|
605
|
+
if (!cfg.denylist_regex?.length) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
const regexes = cfg.denylist_regex.map((pattern) => toRegex(pattern));
|
|
609
|
+
const findings = [];
|
|
610
|
+
const strings = collectStrings(node.params);
|
|
611
|
+
for (const value of strings) {
|
|
612
|
+
if (!value || value.includes("{{")) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (regexes.some((regex) => regex.test(value))) {
|
|
616
|
+
findings.push({
|
|
617
|
+
rule: ruleId,
|
|
618
|
+
severity,
|
|
619
|
+
path: ctx.path,
|
|
620
|
+
message: messageFn(node, value),
|
|
621
|
+
nodeId: node.id,
|
|
622
|
+
line: ctx.nodeLines?.[node.id],
|
|
623
|
+
raw_details: details
|
|
624
|
+
});
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return findings;
|
|
629
|
+
};
|
|
630
|
+
return createNodeRule(ruleId, configKey, logic);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/rules/index.ts
|
|
634
|
+
var r1Retry = createNodeRule("R1", "rate_limit_retry", (node, graph, ctx) => {
|
|
635
|
+
if (!isApiNode(node.type)) return null;
|
|
636
|
+
const params = node.params ?? {};
|
|
637
|
+
const options = params.options ?? {};
|
|
638
|
+
const retryCandidates = [
|
|
639
|
+
options.retryOnFail,
|
|
640
|
+
params.retryOnFail,
|
|
641
|
+
node.flags?.retryOnFail
|
|
642
|
+
];
|
|
643
|
+
const retryOnFail = retryCandidates.find((value) => value !== void 0 && value !== null);
|
|
644
|
+
if (retryOnFail === true) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (typeof retryOnFail === "string") {
|
|
648
|
+
const normalized = retryOnFail.trim().toLowerCase();
|
|
649
|
+
if (retryOnFail.includes("{{") || normalized === "true") {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
rule: "R1",
|
|
655
|
+
severity: "must",
|
|
656
|
+
path: ctx.path,
|
|
657
|
+
message: `Node ${node.name || node.id} is missing retry/backoff configuration`,
|
|
658
|
+
raw_details: `In the node properties, enable "Retry on Fail" under Options.`,
|
|
659
|
+
nodeId: node.id,
|
|
660
|
+
line: ctx.nodeLines?.[node.id]
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
var r2ErrorHandling = createNodeRule("R2", "error_handling", (node, graph, ctx) => {
|
|
664
|
+
if (ctx.cfg.rules.error_handling.forbid_continue_on_fail && node.flags?.continueOnFail) {
|
|
665
|
+
return {
|
|
666
|
+
rule: "R2",
|
|
667
|
+
severity: "must",
|
|
668
|
+
path: ctx.path,
|
|
669
|
+
message: `Node ${node.name || node.id} has continueOnFail enabled (disable it and route errors explicitly)`,
|
|
670
|
+
nodeId: node.id,
|
|
671
|
+
line: ctx.nodeLines?.[node.id],
|
|
672
|
+
raw_details: 'Open the node in n8n and disable "Continue On Fail" (Options > Continue On Fail). Route failures down an explicit error branch instead.'
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
});
|
|
677
|
+
var r4Secrets = createHardcodedStringRule({
|
|
678
|
+
ruleId: "R4",
|
|
679
|
+
severity: "must",
|
|
680
|
+
configKey: "secrets",
|
|
681
|
+
messageFn: (node) => `Node ${node.name || node.id} contains a hardcoded secret (move it to credentials/env vars)`,
|
|
682
|
+
details: "Move API keys/tokens into Credentials or environment variables; the workflow should only reference {{$credentials.*}} expressions."
|
|
683
|
+
});
|
|
684
|
+
var r9ConfigLiterals = createHardcodedStringRule({
|
|
685
|
+
ruleId: "R9",
|
|
686
|
+
severity: "should",
|
|
687
|
+
configKey: "config_literals",
|
|
688
|
+
messageFn: (node, value) => `Node ${node.name || node.id} contains env-specific literal "${value.substring(0, 40)}" (move to expression/credential)`,
|
|
689
|
+
details: "Move environment-specific URLs/IDs into expressions or credentials (e.g., {{$env.API_BASE_URL}}) so the workflow is portable."
|
|
690
|
+
});
|
|
691
|
+
var r10NamingConvention = createNodeRule("R10", "naming_convention", (node, graph, ctx) => {
|
|
692
|
+
const genericNames = new Set(ctx.cfg.rules.naming_convention.generic_names ?? []);
|
|
693
|
+
if (!node.name || genericNames.has(node.name.toLowerCase())) {
|
|
694
|
+
return {
|
|
695
|
+
rule: "R10",
|
|
696
|
+
severity: "nit",
|
|
697
|
+
path: ctx.path,
|
|
698
|
+
message: `Node ${node.id} uses a generic name "${node.name ?? ""}" (rename it to describe the action)`,
|
|
699
|
+
nodeId: node.id,
|
|
700
|
+
line: ctx.nodeLines?.[node.id],
|
|
701
|
+
raw_details: 'Rename the node to describe its purpose (e.g., "Check subscription status" instead of "IF") for easier reviews and debugging.'
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return null;
|
|
705
|
+
});
|
|
706
|
+
var DEPRECATED_NODES = {
|
|
707
|
+
"n8n-nodes-base.splitInBatches": "Use Loop over items instead",
|
|
708
|
+
"n8n-nodes-base.executeWorkflow": "Use Execute Workflow (Sub-Workflow) instead"
|
|
709
|
+
};
|
|
710
|
+
var r11DeprecatedNodes = createNodeRule("R11", "deprecated_nodes", (node, graph, ctx) => {
|
|
711
|
+
if (DEPRECATED_NODES[node.type]) {
|
|
712
|
+
return {
|
|
713
|
+
rule: "R11",
|
|
714
|
+
severity: "should",
|
|
715
|
+
path: ctx.path,
|
|
716
|
+
message: `Node ${node.name || node.id} uses deprecated type ${node.type} (replace with ${DEPRECATED_NODES[node.type]})`,
|
|
717
|
+
nodeId: node.id,
|
|
718
|
+
line: ctx.nodeLines?.[node.id],
|
|
719
|
+
raw_details: `Replace this node with ${DEPRECATED_NODES[node.type]} so future n8n upgrades don\xE2\u20AC\u2122t break the workflow.`
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
});
|
|
724
|
+
var r12UnhandledErrorPath = createNodeRule("R12", "unhandled_error_path", (node, graph, ctx) => {
|
|
725
|
+
if (!isErrorProneNode(node.type)) return null;
|
|
726
|
+
const hasErrorPath = graph.edges.some((edge) => {
|
|
727
|
+
if (edge.from !== node.id) return false;
|
|
728
|
+
if (edge.on === "error") return true;
|
|
729
|
+
const targetNode = graph.nodes.find((candidate) => candidate.id === edge.to);
|
|
730
|
+
return targetNode ? isErrorHandlerNode(targetNode.type, targetNode.name) : false;
|
|
731
|
+
});
|
|
732
|
+
if (!hasErrorPath) {
|
|
733
|
+
return {
|
|
734
|
+
rule: "R12",
|
|
735
|
+
severity: "must",
|
|
736
|
+
path: ctx.path,
|
|
737
|
+
message: `Node ${node.name || node.id} has no error branch (add a red connector to handler)`,
|
|
738
|
+
nodeId: node.id,
|
|
739
|
+
line: ctx.nodeLines?.[node.id],
|
|
740
|
+
raw_details: "Add an error (red) branch to a Stop and Error or logging/alert node so failures do not disappear silently."
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
});
|
|
745
|
+
function r13WebhookAcknowledgment(graph, ctx) {
|
|
746
|
+
const cfg = ctx.cfg.rules.webhook_acknowledgment;
|
|
747
|
+
if (!cfg?.enabled) return [];
|
|
748
|
+
const findings = [];
|
|
749
|
+
const webhookNodes = graph.nodes.filter(
|
|
750
|
+
(node) => node.type === "n8n-nodes-base.webhook" || node.type.includes("webhook") && !node.type.includes("respondToWebhook")
|
|
751
|
+
);
|
|
752
|
+
for (const webhookNode of webhookNodes) {
|
|
753
|
+
const directDownstream = graph.edges.filter((edge) => edge.from === webhookNode.id).map((edge) => graph.nodes.find((n) => n.id === edge.to)).filter((n) => !!n);
|
|
754
|
+
if (directDownstream.length === 0) continue;
|
|
755
|
+
const hasImmediateResponse = directDownstream.some(
|
|
756
|
+
(node) => node.type === "n8n-nodes-base.respondToWebhook" || /respond.*webhook/i.test(node.type) || /respond.*webhook/i.test(node.name || "")
|
|
757
|
+
);
|
|
758
|
+
if (hasImmediateResponse) continue;
|
|
759
|
+
const heavyNodeTypes = cfg.heavy_node_types || [
|
|
760
|
+
"n8n-nodes-base.httpRequest",
|
|
761
|
+
"n8n-nodes-base.postgres",
|
|
762
|
+
"n8n-nodes-base.mysql",
|
|
763
|
+
"n8n-nodes-base.mongodb",
|
|
764
|
+
"n8n-nodes-base.openAi",
|
|
765
|
+
"n8n-nodes-base.anthropic"
|
|
766
|
+
];
|
|
767
|
+
const hasHeavyProcessing = directDownstream.some(
|
|
768
|
+
(node) => heavyNodeTypes.includes(node.type) || /loop|batch/i.test(node.type)
|
|
769
|
+
);
|
|
770
|
+
if (hasHeavyProcessing) {
|
|
771
|
+
findings.push({
|
|
772
|
+
rule: "R13",
|
|
773
|
+
severity: "must",
|
|
774
|
+
path: ctx.path,
|
|
775
|
+
message: `Webhook "${webhookNode.name || webhookNode.id}" performs heavy processing before acknowledgment (risk of timeout/duplicates)`,
|
|
776
|
+
nodeId: webhookNode.id,
|
|
777
|
+
line: ctx.nodeLines?.[webhookNode.id],
|
|
778
|
+
raw_details: `Add a "Respond to Webhook" node immediately after the webhook trigger (return 200/204), then perform heavy processing. This prevents webhook timeouts and duplicate events.`
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return findings;
|
|
783
|
+
}
|
|
784
|
+
var r14RetryAfterCompliance = createNodeRule("R14", "retry_after_compliance", (node, graph, ctx) => {
|
|
785
|
+
if (!isApiNode(node.type)) return null;
|
|
786
|
+
const params = node.params ?? {};
|
|
787
|
+
const options = params.options ?? {};
|
|
788
|
+
const retryCandidates = [
|
|
789
|
+
options.retryOnFail,
|
|
790
|
+
params.retryOnFail,
|
|
791
|
+
node.flags?.retryOnFail
|
|
792
|
+
];
|
|
793
|
+
const retryOnFail = retryCandidates.find((value) => value !== void 0 && value !== null);
|
|
794
|
+
if (!retryOnFail || retryOnFail === false) return null;
|
|
795
|
+
if (typeof retryOnFail === "string") {
|
|
796
|
+
const normalized = retryOnFail.trim().toLowerCase();
|
|
797
|
+
if (retryOnFail.includes("{{") && normalized !== "true") {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const waitBetweenTries = node.flags?.waitBetweenTries;
|
|
802
|
+
if (waitBetweenTries !== void 0 && waitBetweenTries !== null) {
|
|
803
|
+
if (typeof waitBetweenTries === "number") return null;
|
|
804
|
+
if (typeof waitBetweenTries === "string" && !isNaN(Number(waitBetweenTries)) && !waitBetweenTries.includes("{{")) {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const nodeStr = JSON.stringify(node);
|
|
809
|
+
const hasRetryAfterLogic = /retry[-_]?after|retryafter/i.test(nodeStr);
|
|
810
|
+
if (hasRetryAfterLogic) {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
rule: "R14",
|
|
815
|
+
severity: "should",
|
|
816
|
+
path: ctx.path,
|
|
817
|
+
message: `Node ${node.name || node.id} has retry logic but ignores Retry-After headers (429/503 responses)`,
|
|
818
|
+
raw_details: `Add expression to parse Retry-After header: const retryAfter = $json.headers['retry-after']; const delay = retryAfter ? (parseInt(retryAfter) || new Date(retryAfter) - Date.now()) : Math.min(1000 * Math.pow(2, $execution.retryCount), 60000); This prevents API bans and respects server rate limits.`,
|
|
819
|
+
nodeId: node.id,
|
|
820
|
+
line: ctx.nodeLines?.[node.id]
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
function r3Idempotency(graph, ctx) {
|
|
824
|
+
const cfg = ctx.cfg.rules.idempotency;
|
|
825
|
+
if (!cfg?.enabled) return [];
|
|
826
|
+
const hasIngress = graph.nodes.some((node) => /webhook|trigger|start/i.test(node.type));
|
|
827
|
+
if (!hasIngress) return [];
|
|
828
|
+
const mutationNodes = graph.nodes.filter((node) => isMutationNode(node.type));
|
|
829
|
+
if (!mutationNodes.length) return [];
|
|
830
|
+
const findings = [];
|
|
831
|
+
for (const mutationNode of mutationNodes) {
|
|
832
|
+
const upstreamNodeIds = findAllUpstreamNodes(graph, mutationNode.id);
|
|
833
|
+
const upstreamNodes = graph.nodes.filter((n) => upstreamNodeIds.has(n.id));
|
|
834
|
+
const hasGuard = upstreamNodes.some(
|
|
835
|
+
(p) => containsCandidate(p.params, cfg.key_field_candidates ?? [])
|
|
836
|
+
);
|
|
837
|
+
if (!hasGuard) {
|
|
838
|
+
findings.push({
|
|
839
|
+
rule: "R3",
|
|
840
|
+
severity: "must",
|
|
841
|
+
path: ctx.path,
|
|
842
|
+
message: `The mutation path ending at "${mutationNode.name || mutationNode.id}" appears to be missing an idempotency guard.`,
|
|
843
|
+
raw_details: `Ensure one of the upstream nodes or the mutation node itself uses an idempotency key, such as one of: ${(cfg.key_field_candidates ?? []).join(
|
|
844
|
+
", "
|
|
845
|
+
)}`,
|
|
846
|
+
nodeId: mutationNode.id,
|
|
847
|
+
line: ctx.nodeLines?.[mutationNode.id]
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return findings;
|
|
852
|
+
}
|
|
853
|
+
function r5DeadEnds(graph, ctx) {
|
|
854
|
+
const cfg = ctx.cfg.rules.dead_ends;
|
|
855
|
+
if (!cfg?.enabled) return [];
|
|
856
|
+
if (graph.nodes.length <= 1) return [];
|
|
857
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
858
|
+
for (const node of graph.nodes) outgoing.set(node.id, 0);
|
|
859
|
+
for (const edge of graph.edges) outgoing.set(edge.from, (outgoing.get(edge.from) || 0) + 1);
|
|
860
|
+
const findings = [];
|
|
861
|
+
for (const node of graph.nodes) {
|
|
862
|
+
if ((outgoing.get(node.id) || 0) === 0 && !isTerminalNode(node.type, node.name)) {
|
|
863
|
+
findings.push({
|
|
864
|
+
rule: "R5",
|
|
865
|
+
severity: "nit",
|
|
866
|
+
path: ctx.path,
|
|
867
|
+
message: `Node ${node.name || node.id} has no outgoing connections (either wire it up or remove it)`,
|
|
868
|
+
nodeId: node.id,
|
|
869
|
+
line: ctx.nodeLines?.[node.id],
|
|
870
|
+
raw_details: "Either remove this node as dead code or connect it to the next/safe step so the workflow can continue."
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return findings;
|
|
875
|
+
}
|
|
876
|
+
function r6LongRunning(graph, ctx) {
|
|
877
|
+
const cfg = ctx.cfg.rules.long_running;
|
|
878
|
+
if (!cfg?.enabled) return [];
|
|
879
|
+
const findings = [];
|
|
880
|
+
const loopNodes = graph.nodes.filter((node) => /loop|batch|while|repeat/i.test(node.type));
|
|
881
|
+
for (const node of loopNodes) {
|
|
882
|
+
const iterations = readNumber(node.params, [
|
|
883
|
+
"maxIterations",
|
|
884
|
+
"maxIteration",
|
|
885
|
+
"limit",
|
|
886
|
+
"options.maxIterations"
|
|
887
|
+
]);
|
|
888
|
+
if (!iterations || cfg.max_iterations && iterations > cfg.max_iterations) {
|
|
889
|
+
findings.push({
|
|
890
|
+
rule: "R6",
|
|
891
|
+
severity: "should",
|
|
892
|
+
path: ctx.path,
|
|
893
|
+
message: `Node ${node.name || node.id} allows ${iterations ?? "unbounded"} iterations (limit ${cfg.max_iterations}; set a lower cap)`,
|
|
894
|
+
nodeId: node.id,
|
|
895
|
+
line: ctx.nodeLines?.[node.id],
|
|
896
|
+
raw_details: `Set Options > Max iterations to \xE2\u2030\xA4 ${cfg.max_iterations} or split the processing into smaller batches.`
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (cfg.timeout_ms) {
|
|
900
|
+
const timeout = readNumber(node.params, ["timeout", "timeoutMs", "options.timeout"]);
|
|
901
|
+
if (timeout && timeout > cfg.timeout_ms) {
|
|
902
|
+
findings.push({
|
|
903
|
+
rule: "R6",
|
|
904
|
+
severity: "should",
|
|
905
|
+
path: ctx.path,
|
|
906
|
+
message: `Node ${node.name || node.id} uses timeout ${timeout}ms (limit ${cfg.timeout_ms}ms; shorten the timeout or break work apart)`,
|
|
907
|
+
nodeId: node.id,
|
|
908
|
+
line: ctx.nodeLines?.[node.id],
|
|
909
|
+
raw_details: `Lower the timeout to \xE2\u2030\xA4 ${cfg.timeout_ms}ms or split the workflow so no single step blocks for too long.`
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return findings;
|
|
915
|
+
}
|
|
916
|
+
function r7AlertLogEnforcement(graph, ctx) {
|
|
917
|
+
const cfg = ctx.cfg.rules.alert_log_enforcement;
|
|
918
|
+
if (!cfg?.enabled) return [];
|
|
919
|
+
const findings = [];
|
|
920
|
+
const errorEdges = graph.edges.filter((edge) => edge.on === "error");
|
|
921
|
+
for (const edge of errorEdges) {
|
|
922
|
+
const fromNode = graph.nodes.find((n) => n.id === edge.from);
|
|
923
|
+
let isHandled = false;
|
|
924
|
+
const queue = [edge.to];
|
|
925
|
+
const visited = /* @__PURE__ */ new Set([edge.to]);
|
|
926
|
+
let head = 0;
|
|
927
|
+
while (head < queue.length) {
|
|
928
|
+
const currentId = queue[head++];
|
|
929
|
+
const currentNode = graph.nodes.find((n) => n.id === currentId);
|
|
930
|
+
if (isNotificationNode(currentNode.type) || isErrorHandlerNode(currentNode.type, currentNode.name)) {
|
|
931
|
+
isHandled = true;
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
if (isRejoinNode(graph, currentId)) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const outgoing = graph.edges.filter((e) => e.from === currentId);
|
|
938
|
+
for (const outEdge of outgoing) {
|
|
939
|
+
if (!visited.has(outEdge.to)) {
|
|
940
|
+
visited.add(outEdge.to);
|
|
941
|
+
queue.push(outEdge.to);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (!isHandled) {
|
|
946
|
+
findings.push({
|
|
947
|
+
rule: "R7",
|
|
948
|
+
severity: "should",
|
|
949
|
+
path: ctx.path,
|
|
950
|
+
message: `Error path from node ${fromNode.name || fromNode.id} has no log/alert before rejoining (add notification node)`,
|
|
951
|
+
nodeId: fromNode.id,
|
|
952
|
+
line: ctx.nodeLines?.[fromNode.id],
|
|
953
|
+
raw_details: "Add a Slack/Email/Log node on the error branch before it rejoins the main flow so failures leave an audit trail."
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return findings;
|
|
958
|
+
}
|
|
959
|
+
function r8UnusedData(graph, ctx) {
|
|
960
|
+
const cfg = ctx.cfg.rules.unused_data;
|
|
961
|
+
if (!cfg?.enabled) return [];
|
|
962
|
+
const findings = [];
|
|
963
|
+
for (const node of graph.nodes) {
|
|
964
|
+
if (isTerminalNode(node.type, node.name) || !graph.edges.some((e) => e.from === node.id)) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const downstreamNodes = findAllDownstreamNodes(graph, node.id);
|
|
968
|
+
downstreamNodes.delete(node.id);
|
|
969
|
+
const leadsToConsumer = [...downstreamNodes].some((id) => {
|
|
970
|
+
const downstreamNode = graph.nodes.find((n) => n.id === id);
|
|
971
|
+
return isMeaningfulConsumer(downstreamNode);
|
|
972
|
+
});
|
|
973
|
+
if (!leadsToConsumer) {
|
|
974
|
+
findings.push({
|
|
975
|
+
rule: "R8",
|
|
976
|
+
severity: "nit",
|
|
977
|
+
path: ctx.path,
|
|
978
|
+
message: `Node "${node.name || node.id}" produces data that never reaches any consumer`,
|
|
979
|
+
nodeId: node.id,
|
|
980
|
+
line: ctx.nodeLines?.[node.id],
|
|
981
|
+
raw_details: "Wire this branch into a consumer (DB/API/response) or remove it\xE2\u20AC\u201Dotherwise the data produced here is never used."
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return findings;
|
|
986
|
+
}
|
|
987
|
+
var rules = [
|
|
988
|
+
r1Retry,
|
|
989
|
+
r2ErrorHandling,
|
|
990
|
+
r3Idempotency,
|
|
991
|
+
r4Secrets,
|
|
992
|
+
r5DeadEnds,
|
|
993
|
+
r6LongRunning,
|
|
994
|
+
r7AlertLogEnforcement,
|
|
995
|
+
r8UnusedData,
|
|
996
|
+
r9ConfigLiterals,
|
|
997
|
+
r10NamingConvention,
|
|
998
|
+
r11DeprecatedNodes,
|
|
999
|
+
r12UnhandledErrorPath,
|
|
1000
|
+
r13WebhookAcknowledgment,
|
|
1001
|
+
r14RetryAfterCompliance
|
|
1002
|
+
];
|
|
1003
|
+
function runAllRules(graph, ctx) {
|
|
1004
|
+
return rules.flatMap((rule) => rule(graph, ctx));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/config/default-config.ts
|
|
1008
|
+
var defaultConfig = {
|
|
1009
|
+
files: {
|
|
1010
|
+
include: ["**/*.n8n.json", "**/workflows/*.json", "**/workflows/**/*.json", "**/*.n8n.yaml", "**/*.json"],
|
|
1011
|
+
ignore: [
|
|
1012
|
+
"samples/**",
|
|
1013
|
+
"**/*.spec.json",
|
|
1014
|
+
"node_modules/**",
|
|
1015
|
+
"package*.json",
|
|
1016
|
+
"tsconfig*.json",
|
|
1017
|
+
".flowlint.yml",
|
|
1018
|
+
".github/**",
|
|
1019
|
+
".husky/**",
|
|
1020
|
+
".vscode/**",
|
|
1021
|
+
"infra/**",
|
|
1022
|
+
"*.config.js",
|
|
1023
|
+
"*.config.ts",
|
|
1024
|
+
"**/*.lock"
|
|
1025
|
+
]
|
|
1026
|
+
},
|
|
1027
|
+
report: { annotations: true, summary_limit: 25 },
|
|
1028
|
+
rules: {
|
|
1029
|
+
rate_limit_retry: {
|
|
1030
|
+
enabled: true,
|
|
1031
|
+
max_concurrency: 5,
|
|
1032
|
+
default_retry: { count: 3, strategy: "exponential", base_ms: 500 }
|
|
1033
|
+
},
|
|
1034
|
+
error_handling: { enabled: true, forbid_continue_on_fail: true },
|
|
1035
|
+
idempotency: { enabled: true, key_field_candidates: ["eventId", "messageId"] },
|
|
1036
|
+
secrets: { enabled: true, denylist_regex: ["(?i)api[_-]?key", "Bearer "] },
|
|
1037
|
+
dead_ends: { enabled: true },
|
|
1038
|
+
long_running: { enabled: true, max_iterations: 1e3, timeout_ms: 3e5 },
|
|
1039
|
+
unused_data: { enabled: true },
|
|
1040
|
+
unhandled_error_path: { enabled: true },
|
|
1041
|
+
alert_log_enforcement: { enabled: true },
|
|
1042
|
+
deprecated_nodes: { enabled: true },
|
|
1043
|
+
naming_convention: {
|
|
1044
|
+
enabled: true,
|
|
1045
|
+
generic_names: ["http request", "set", "if", "merge", "switch", "no-op", "start"]
|
|
1046
|
+
},
|
|
1047
|
+
config_literals: {
|
|
1048
|
+
enabled: true,
|
|
1049
|
+
denylist_regex: [
|
|
1050
|
+
"(?i)\\b(dev|development)\\b",
|
|
1051
|
+
"(?i)\\b(stag|staging)\\b",
|
|
1052
|
+
"(?i)\\b(prod|production)\\b",
|
|
1053
|
+
"(?i)\\b(test|testing)\\b"
|
|
1054
|
+
]
|
|
1055
|
+
},
|
|
1056
|
+
webhook_acknowledgment: {
|
|
1057
|
+
enabled: true,
|
|
1058
|
+
heavy_node_types: [
|
|
1059
|
+
"n8n-nodes-base.httpRequest",
|
|
1060
|
+
"n8n-nodes-base.postgres",
|
|
1061
|
+
"n8n-nodes-base.mysql",
|
|
1062
|
+
"n8n-nodes-base.mongodb",
|
|
1063
|
+
"n8n-nodes-base.openAi",
|
|
1064
|
+
"n8n-nodes-base.anthropic",
|
|
1065
|
+
"n8n-nodes-base.huggingFace"
|
|
1066
|
+
]
|
|
1067
|
+
},
|
|
1068
|
+
retry_after_compliance: {
|
|
1069
|
+
enabled: true,
|
|
1070
|
+
suggest_exponential_backoff: true,
|
|
1071
|
+
suggest_jitter: true
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
// src/config/loader.ts
|
|
1077
|
+
import YAML2 from "yaml";
|
|
1078
|
+
function deepMerge(base, override) {
|
|
1079
|
+
const baseCopy = JSON.parse(JSON.stringify(base));
|
|
1080
|
+
if (!override) return baseCopy;
|
|
1081
|
+
return mergeInto(baseCopy, override);
|
|
1082
|
+
}
|
|
1083
|
+
function mergeInto(target, source) {
|
|
1084
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1085
|
+
if (value === void 0 || value === null) continue;
|
|
1086
|
+
if (Array.isArray(value)) {
|
|
1087
|
+
target[key] = value;
|
|
1088
|
+
} else if (typeof value === "object") {
|
|
1089
|
+
if (typeof target[key] !== "object" || target[key] === null) {
|
|
1090
|
+
target[key] = {};
|
|
1091
|
+
}
|
|
1092
|
+
mergeInto(target[key], value);
|
|
1093
|
+
} else {
|
|
1094
|
+
target[key] = value;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return target;
|
|
1098
|
+
}
|
|
1099
|
+
function parseConfig(content) {
|
|
1100
|
+
const parsed = YAML2.parse(content) || {};
|
|
1101
|
+
return deepMerge(defaultConfig, parsed);
|
|
1102
|
+
}
|
|
1103
|
+
function loadConfig(configPath) {
|
|
1104
|
+
if (typeof globalThis !== "undefined" && "window" in globalThis) {
|
|
1105
|
+
return defaultConfig;
|
|
1106
|
+
}
|
|
1107
|
+
if (configPath) {
|
|
1108
|
+
return loadConfigFromFile(configPath);
|
|
1109
|
+
}
|
|
1110
|
+
return loadConfigFromCwd();
|
|
1111
|
+
}
|
|
1112
|
+
function loadConfigFromFile(configPath) {
|
|
1113
|
+
try {
|
|
1114
|
+
const fs = __require("fs");
|
|
1115
|
+
if (!fs.existsSync(configPath)) {
|
|
1116
|
+
return defaultConfig;
|
|
1117
|
+
}
|
|
1118
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
1119
|
+
return parseConfig(content);
|
|
1120
|
+
} catch {
|
|
1121
|
+
return defaultConfig;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function loadConfigFromCwd() {
|
|
1125
|
+
try {
|
|
1126
|
+
const fs = __require("fs");
|
|
1127
|
+
const path = __require("path");
|
|
1128
|
+
const candidates = [".flowlint.yml", ".flowlint.yaml", "flowlint.config.yml"];
|
|
1129
|
+
const cwd = process.cwd();
|
|
1130
|
+
for (const candidate of candidates) {
|
|
1131
|
+
const configPath = path.join(cwd, candidate);
|
|
1132
|
+
if (fs.existsSync(configPath)) {
|
|
1133
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
1134
|
+
return parseConfig(content);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return defaultConfig;
|
|
1138
|
+
} catch {
|
|
1139
|
+
return defaultConfig;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function validateConfig(config) {
|
|
1143
|
+
if (!config || typeof config !== "object") return false;
|
|
1144
|
+
const c = config;
|
|
1145
|
+
return "files" in c && "report" in c && "rules" in c && typeof c.files === "object" && typeof c.report === "object" && typeof c.rules === "object";
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/utils/findings.ts
|
|
1149
|
+
function countFindingsBySeverity(findings) {
|
|
1150
|
+
return {
|
|
1151
|
+
must: findings.filter((f) => f.severity === "must").length,
|
|
1152
|
+
should: findings.filter((f) => f.severity === "should").length,
|
|
1153
|
+
nit: findings.filter((f) => f.severity === "nit").length,
|
|
1154
|
+
total: findings.length
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
function getSeverityOrder() {
|
|
1158
|
+
return { must: 0, should: 1, nit: 2 };
|
|
1159
|
+
}
|
|
1160
|
+
function sortFindingsBySeverity(findings) {
|
|
1161
|
+
const order = getSeverityOrder();
|
|
1162
|
+
return [...findings].sort((a, b) => order[a.severity] - order[b.severity]);
|
|
1163
|
+
}
|
|
1164
|
+
export {
|
|
1165
|
+
ValidationError,
|
|
1166
|
+
countFindingsBySeverity,
|
|
1167
|
+
defaultConfig,
|
|
1168
|
+
flattenConnections,
|
|
1169
|
+
getExampleLink,
|
|
1170
|
+
isApiNode,
|
|
1171
|
+
isErrorProneNode,
|
|
1172
|
+
isMutationNode,
|
|
1173
|
+
isNotificationNode,
|
|
1174
|
+
isTerminalNode,
|
|
1175
|
+
loadConfig,
|
|
1176
|
+
parseConfig,
|
|
1177
|
+
parseN8n,
|
|
1178
|
+
runAllRules,
|
|
1179
|
+
sortFindingsBySeverity,
|
|
1180
|
+
validateConfig,
|
|
1181
|
+
validateN8nWorkflow
|
|
1182
|
+
};
|
|
1183
|
+
//# sourceMappingURL=index.mjs.map
|