@timo9378/flow2code 0.1.7 → 0.1.8

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 CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.1.8] — 2026-03-05
9
+
10
+ ### Security
11
+ - **HTTP server request timeout** — Added `headersTimeout` (30s) and `requestTimeout` (60s) to prevent Slowloris-style DoS attacks (#16)
12
+ - **Server `headersSent` guard** — Error handler now checks `res.headersSent` before writing 500 response, preventing `ERR_HTTP_HEADERS_SENT` crash (#14)
13
+ - **Validate IR from server** — `handleDecompileTS` and `handleSelectOpenAPIFlow` now call `validateFlowIR()` before `loadIR()`, matching `handleLoadIRFromJSON` behavior (#22)
14
+
15
+ ### Fixed
16
+ - **Validator iterative DFS** — Replaced recursive `dfs()` with explicit stack-based iteration to prevent stack overflow on deep graphs (5000+ nodes) (#11)
17
+ - **`isFetchCall` AST-based detection** — Replaced `text.includes("fetch(")` string matching with proper AST walk using `CallExpression` + `Identifier` node types (#12)
18
+ - **`hasAwaitExpression` AST walk** — Replaced `text.startsWith("await ")` fallback with recursive `forEachChild` AST traversal to detect nested awaits (#23)
19
+ - **`trackVariableUses` strip strings** — Expression string now has string literals (`"..."`, `'...'`, `` `...` ``) stripped before identifier regex scan, preventing false-positive variable references (#13)
20
+ - **`_nodeCounter` max ID parse** — After `loadIR`, counter is set to max numeric ID from existing node IDs (not `nodes.length`), preventing ID collisions (#15)
21
+ - **`resolveTriggerRef` cache** — Trigger node lookup cached per IR reference, eliminating O(N) linear scan on every `$trigger` expression evaluation (#17)
22
+ - **`$input` ambiguity warning** — `resolveInputRef` now emits `console.warn` when a node has multiple non-trigger upstream edges, identifying which source is used (#18)
23
+ - **OpenAPI tags filter** — `handleImportOpenAPI` now implements `filter.tags` (case-insensitive match against `meta.tags`), which was previously accepted but silently ignored (#20)
24
+ - **Clipboard fallback** — `handleExportIR` now catches clipboard write errors gracefully instead of letting promise rejection propagate (#21)
25
+
26
+ ### Added
27
+ - **`createPlatformRegistry` factory** — New function for creating isolated platform registry instances, enabling safe test parallelism and concurrent compilation (#19)
28
+
29
+ ### Tests
30
+ - Added iterative DFS deep graph test (5000 nodes, verifies no stack overflow)
31
+ - Test count: 411 tests / 33 test files
32
+
8
33
  ## [0.1.7] — 2026-03-05
9
34
 
10
35
  ### Performance
package/dist/cli.js CHANGED
@@ -218,25 +218,32 @@ function detectCycles(nodes, edges) {
218
218
  for (const node of nodes) {
219
219
  color.set(node.id, WHITE);
220
220
  }
221
- function dfs(nodeId) {
222
- color.set(nodeId, GRAY);
223
- for (const neighbor of adjacency.get(nodeId) ?? []) {
221
+ for (const node of nodes) {
222
+ if (color.get(node.id) !== WHITE) continue;
223
+ const stack = [[node.id, 0]];
224
+ color.set(node.id, GRAY);
225
+ while (stack.length > 0) {
226
+ const top = stack[stack.length - 1];
227
+ const [currentId, idx] = top;
228
+ const neighbors = adjacency.get(currentId) ?? [];
229
+ if (idx >= neighbors.length) {
230
+ color.set(currentId, BLACK);
231
+ stack.pop();
232
+ continue;
233
+ }
234
+ top[1] = idx + 1;
235
+ const neighbor = neighbors[idx];
224
236
  if (color.get(neighbor) === GRAY) {
225
237
  errors.push({
226
238
  code: "CYCLE_DETECTED",
227
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
228
- nodeId
239
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
240
+ nodeId: currentId
229
241
  });
230
242
  } else if (color.get(neighbor) === WHITE) {
231
- dfs(neighbor);
243
+ color.set(neighbor, GRAY);
244
+ stack.push([neighbor, 0]);
232
245
  }
233
246
  }
234
- color.set(nodeId, BLACK);
235
- }
236
- for (const node of nodes) {
237
- if (color.get(node.id) === WHITE) {
238
- dfs(node.id);
239
- }
240
247
  }
241
248
  return errors;
242
249
  }
@@ -450,10 +457,16 @@ function resolveInputRef(path, context) {
450
457
  const incoming = context.ir.edges.filter(
451
458
  (e) => e.targetNodeId === context.currentNodeId
452
459
  );
453
- const dataSource = incoming.find((e) => {
460
+ const nonTriggerIncoming = incoming.filter((e) => {
454
461
  const src = context.nodeMap.get(e.sourceNodeId);
455
462
  return src && src.category !== "trigger" /* TRIGGER */;
456
- }) || incoming[0];
463
+ });
464
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
465
+ console.warn(
466
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
467
+ );
468
+ }
469
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
457
470
  if (dataSource) {
458
471
  const srcId = dataSource.sourceNodeId;
459
472
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -469,21 +482,27 @@ function resolveInputRef(path, context) {
469
482
  );
470
483
  }
471
484
  function resolveTriggerRef(path, context) {
472
- const trigger = context.ir.nodes.find(
473
- (n) => n.category === "trigger" /* TRIGGER */
474
- );
475
- if (trigger) {
476
- if (context.symbolTable?.hasVar(trigger.id)) {
477
- return `${context.symbolTable.getVarName(trigger.id)}${path}`;
485
+ if (_cachedTriggerIR !== context.ir) {
486
+ _cachedTriggerIR = context.ir;
487
+ const trigger = context.ir.nodes.find(
488
+ (n) => n.category === "trigger" /* TRIGGER */
489
+ );
490
+ _cachedTriggerId = trigger?.id ?? null;
491
+ }
492
+ if (_cachedTriggerId) {
493
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
494
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
478
495
  }
479
- return `flowState['${trigger.id}']${path}`;
496
+ return `flowState['${_cachedTriggerId}']${path}`;
480
497
  }
481
498
  return "undefined";
482
499
  }
483
- var ExpressionParseError;
500
+ var _cachedTriggerIR, _cachedTriggerId, ExpressionParseError;
484
501
  var init_expression_parser = __esm({
485
502
  "src/lib/compiler/expression-parser.ts"() {
486
503
  "use strict";
504
+ _cachedTriggerIR = null;
505
+ _cachedTriggerId = null;
487
506
  ExpressionParseError = class extends Error {
488
507
  constructor(message, expression, position) {
489
508
  super(message);
@@ -2987,12 +3006,18 @@ function computeAuditHints(ctx) {
2987
3006
  return hints;
2988
3007
  }
2989
3008
  function isFetchCall(node) {
2990
- const text = node.getText();
2991
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === SyntaxKind.AwaitExpression);
3009
+ if (node.getKind() === SyntaxKind.CallExpression) {
3010
+ const expr = node.getExpression();
3011
+ if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "fetch") return true;
3012
+ }
3013
+ if (node.getKind() === SyntaxKind.AwaitExpression) {
3014
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
3015
+ }
3016
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
2992
3017
  }
2993
3018
  function hasAwaitExpression(node) {
2994
3019
  if (node.getKind() === SyntaxKind.AwaitExpression) return true;
2995
- return node.getText().startsWith("await ");
3020
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
2996
3021
  }
2997
3022
  function extractAwaitedExpression(node) {
2998
3023
  const text = node.getText();
@@ -3062,7 +3087,8 @@ function inferLabel(code, varName) {
3062
3087
  return truncate(code, 30);
3063
3088
  }
3064
3089
  function trackVariableUses(nodeId, expression, ctx) {
3065
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
3090
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
3091
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
3066
3092
  if (!identifiers) return;
3067
3093
  const uses = [];
3068
3094
  const seen = /* @__PURE__ */ new Set();
@@ -3868,6 +3894,13 @@ function handleImportOpenAPI(body) {
3868
3894
  (flow) => paths.some((p) => flow.meta.name.includes(p))
3869
3895
  );
3870
3896
  }
3897
+ if (body.filter?.tags && Array.isArray(body.filter.tags)) {
3898
+ const tags = body.filter.tags.map((t) => t.toLowerCase());
3899
+ filteredFlows = filteredFlows.filter((flow) => {
3900
+ const flowTags = flow.meta.tags ?? [];
3901
+ return flowTags.some((t) => tags.includes(t.toLowerCase()));
3902
+ });
3903
+ }
3871
3904
  return {
3872
3905
  status: 200,
3873
3906
  body: {
@@ -4128,10 +4161,14 @@ function startServer(options = {}) {
4128
4161
  const server = createServer((req, res) => {
4129
4162
  handleRequest(req, res, staticDir, projectRoot).catch((err) => {
4130
4163
  logger.error("Internal error:", err);
4131
- res.writeHead(500, { "Content-Type": "text/plain" });
4132
- res.end("Internal Server Error");
4164
+ if (!res.headersSent) {
4165
+ res.writeHead(500, { "Content-Type": "text/plain" });
4166
+ res.end("Internal Server Error");
4167
+ }
4133
4168
  });
4134
4169
  });
4170
+ server.headersTimeout = 3e4;
4171
+ server.requestTimeout = 6e4;
4135
4172
  const hasUI = existsSync3(join3(staticDir, "index.html"));
4136
4173
  server.listen(port, host, () => {
4137
4174
  const url = `http://localhost:${port}`;
package/dist/compiler.cjs CHANGED
@@ -294,25 +294,32 @@ function detectCycles(nodes, edges) {
294
294
  for (const node of nodes) {
295
295
  color.set(node.id, WHITE);
296
296
  }
297
- function dfs(nodeId) {
298
- color.set(nodeId, GRAY);
299
- for (const neighbor of adjacency.get(nodeId) ?? []) {
297
+ for (const node of nodes) {
298
+ if (color.get(node.id) !== WHITE) continue;
299
+ const stack = [[node.id, 0]];
300
+ color.set(node.id, GRAY);
301
+ while (stack.length > 0) {
302
+ const top = stack[stack.length - 1];
303
+ const [currentId, idx] = top;
304
+ const neighbors = adjacency.get(currentId) ?? [];
305
+ if (idx >= neighbors.length) {
306
+ color.set(currentId, BLACK);
307
+ stack.pop();
308
+ continue;
309
+ }
310
+ top[1] = idx + 1;
311
+ const neighbor = neighbors[idx];
300
312
  if (color.get(neighbor) === GRAY) {
301
313
  errors.push({
302
314
  code: "CYCLE_DETECTED",
303
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
304
- nodeId
315
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
316
+ nodeId: currentId
305
317
  });
306
318
  } else if (color.get(neighbor) === WHITE) {
307
- dfs(neighbor);
319
+ color.set(neighbor, GRAY);
320
+ stack.push([neighbor, 0]);
308
321
  }
309
322
  }
310
- color.set(nodeId, BLACK);
311
- }
312
- for (const node of nodes) {
313
- if (color.get(node.id) === WHITE) {
314
- dfs(node.id);
315
- }
316
323
  }
317
324
  return errors;
318
325
  }
@@ -514,10 +521,16 @@ function resolveInputRef(path, context) {
514
521
  const incoming = context.ir.edges.filter(
515
522
  (e) => e.targetNodeId === context.currentNodeId
516
523
  );
517
- const dataSource = incoming.find((e) => {
524
+ const nonTriggerIncoming = incoming.filter((e) => {
518
525
  const src = context.nodeMap.get(e.sourceNodeId);
519
526
  return src && src.category !== "trigger" /* TRIGGER */;
520
- }) || incoming[0];
527
+ });
528
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
529
+ console.warn(
530
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
531
+ );
532
+ }
533
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
521
534
  if (dataSource) {
522
535
  const srcId = dataSource.sourceNodeId;
523
536
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -532,15 +545,21 @@ function resolveInputRef(path, context) {
532
545
  `Expression parser error: Node "${context.currentNodeId}" has no input connected`
533
546
  );
534
547
  }
548
+ var _cachedTriggerIR = null;
549
+ var _cachedTriggerId = null;
535
550
  function resolveTriggerRef(path, context) {
536
- const trigger = context.ir.nodes.find(
537
- (n) => n.category === "trigger" /* TRIGGER */
538
- );
539
- if (trigger) {
540
- if (context.symbolTable?.hasVar(trigger.id)) {
541
- return `${context.symbolTable.getVarName(trigger.id)}${path}`;
551
+ if (_cachedTriggerIR !== context.ir) {
552
+ _cachedTriggerIR = context.ir;
553
+ const trigger = context.ir.nodes.find(
554
+ (n) => n.category === "trigger" /* TRIGGER */
555
+ );
556
+ _cachedTriggerId = trigger?.id ?? null;
557
+ }
558
+ if (_cachedTriggerId) {
559
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
560
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
542
561
  }
543
- return `flowState['${trigger.id}']${path}`;
562
+ return `flowState['${_cachedTriggerId}']${path}`;
544
563
  }
545
564
  return "undefined";
546
565
  }
@@ -3041,12 +3060,18 @@ function computeAuditHints(ctx) {
3041
3060
  return hints;
3042
3061
  }
3043
3062
  function isFetchCall(node) {
3044
- const text = node.getText();
3045
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression);
3063
+ if (node.getKind() === import_ts_morph2.SyntaxKind.CallExpression) {
3064
+ const expr = node.getExpression();
3065
+ if (expr.getKind() === import_ts_morph2.SyntaxKind.Identifier && expr.getText() === "fetch") return true;
3066
+ }
3067
+ if (node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression) {
3068
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
3069
+ }
3070
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
3046
3071
  }
3047
3072
  function hasAwaitExpression(node) {
3048
3073
  if (node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression) return true;
3049
- return node.getText().startsWith("await ");
3074
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
3050
3075
  }
3051
3076
  function extractAwaitedExpression(node) {
3052
3077
  const text = node.getText();
@@ -3116,7 +3141,8 @@ function inferLabel(code, varName) {
3116
3141
  return truncate2(code, 30);
3117
3142
  }
3118
3143
  function trackVariableUses(nodeId, expression, ctx) {
3119
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
3144
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
3145
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
3120
3146
  if (!identifiers) return;
3121
3147
  const uses = [];
3122
3148
  const seen = /* @__PURE__ */ new Set();
package/dist/compiler.js CHANGED
@@ -227,25 +227,32 @@ function detectCycles(nodes, edges) {
227
227
  for (const node of nodes) {
228
228
  color.set(node.id, WHITE);
229
229
  }
230
- function dfs(nodeId) {
231
- color.set(nodeId, GRAY);
232
- for (const neighbor of adjacency.get(nodeId) ?? []) {
230
+ for (const node of nodes) {
231
+ if (color.get(node.id) !== WHITE) continue;
232
+ const stack = [[node.id, 0]];
233
+ color.set(node.id, GRAY);
234
+ while (stack.length > 0) {
235
+ const top = stack[stack.length - 1];
236
+ const [currentId, idx] = top;
237
+ const neighbors = adjacency.get(currentId) ?? [];
238
+ if (idx >= neighbors.length) {
239
+ color.set(currentId, BLACK);
240
+ stack.pop();
241
+ continue;
242
+ }
243
+ top[1] = idx + 1;
244
+ const neighbor = neighbors[idx];
233
245
  if (color.get(neighbor) === GRAY) {
234
246
  errors.push({
235
247
  code: "CYCLE_DETECTED",
236
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
237
- nodeId
248
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
249
+ nodeId: currentId
238
250
  });
239
251
  } else if (color.get(neighbor) === WHITE) {
240
- dfs(neighbor);
252
+ color.set(neighbor, GRAY);
253
+ stack.push([neighbor, 0]);
241
254
  }
242
255
  }
243
- color.set(nodeId, BLACK);
244
- }
245
- for (const node of nodes) {
246
- if (color.get(node.id) === WHITE) {
247
- dfs(node.id);
248
- }
249
256
  }
250
257
  return errors;
251
258
  }
@@ -447,10 +454,16 @@ function resolveInputRef(path, context) {
447
454
  const incoming = context.ir.edges.filter(
448
455
  (e) => e.targetNodeId === context.currentNodeId
449
456
  );
450
- const dataSource = incoming.find((e) => {
457
+ const nonTriggerIncoming = incoming.filter((e) => {
451
458
  const src = context.nodeMap.get(e.sourceNodeId);
452
459
  return src && src.category !== "trigger" /* TRIGGER */;
453
- }) || incoming[0];
460
+ });
461
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
462
+ console.warn(
463
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
464
+ );
465
+ }
466
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
454
467
  if (dataSource) {
455
468
  const srcId = dataSource.sourceNodeId;
456
469
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -465,15 +478,21 @@ function resolveInputRef(path, context) {
465
478
  `Expression parser error: Node "${context.currentNodeId}" has no input connected`
466
479
  );
467
480
  }
481
+ var _cachedTriggerIR = null;
482
+ var _cachedTriggerId = null;
468
483
  function resolveTriggerRef(path, context) {
469
- const trigger = context.ir.nodes.find(
470
- (n) => n.category === "trigger" /* TRIGGER */
471
- );
472
- if (trigger) {
473
- if (context.symbolTable?.hasVar(trigger.id)) {
474
- return `${context.symbolTable.getVarName(trigger.id)}${path}`;
484
+ if (_cachedTriggerIR !== context.ir) {
485
+ _cachedTriggerIR = context.ir;
486
+ const trigger = context.ir.nodes.find(
487
+ (n) => n.category === "trigger" /* TRIGGER */
488
+ );
489
+ _cachedTriggerId = trigger?.id ?? null;
490
+ }
491
+ if (_cachedTriggerId) {
492
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
493
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
475
494
  }
476
- return `flowState['${trigger.id}']${path}`;
495
+ return `flowState['${_cachedTriggerId}']${path}`;
477
496
  }
478
497
  return "undefined";
479
498
  }
@@ -2977,12 +2996,18 @@ function computeAuditHints(ctx) {
2977
2996
  return hints;
2978
2997
  }
2979
2998
  function isFetchCall(node) {
2980
- const text = node.getText();
2981
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === SyntaxKind.AwaitExpression);
2999
+ if (node.getKind() === SyntaxKind.CallExpression) {
3000
+ const expr = node.getExpression();
3001
+ if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "fetch") return true;
3002
+ }
3003
+ if (node.getKind() === SyntaxKind.AwaitExpression) {
3004
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
3005
+ }
3006
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
2982
3007
  }
2983
3008
  function hasAwaitExpression(node) {
2984
3009
  if (node.getKind() === SyntaxKind.AwaitExpression) return true;
2985
- return node.getText().startsWith("await ");
3010
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
2986
3011
  }
2987
3012
  function extractAwaitedExpression(node) {
2988
3013
  const text = node.getText();
@@ -3052,7 +3077,8 @@ function inferLabel(code, varName) {
3052
3077
  return truncate2(code, 30);
3053
3078
  }
3054
3079
  function trackVariableUses(nodeId, expression, ctx) {
3055
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
3080
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
3081
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
3056
3082
  if (!identifiers) return;
3057
3083
  const uses = [];
3058
3084
  const seen = /* @__PURE__ */ new Set();
package/dist/server.js CHANGED
@@ -198,25 +198,32 @@ function detectCycles(nodes, edges) {
198
198
  for (const node of nodes) {
199
199
  color.set(node.id, WHITE);
200
200
  }
201
- function dfs(nodeId) {
202
- color.set(nodeId, GRAY);
203
- for (const neighbor of adjacency.get(nodeId) ?? []) {
201
+ for (const node of nodes) {
202
+ if (color.get(node.id) !== WHITE) continue;
203
+ const stack = [[node.id, 0]];
204
+ color.set(node.id, GRAY);
205
+ while (stack.length > 0) {
206
+ const top = stack[stack.length - 1];
207
+ const [currentId, idx] = top;
208
+ const neighbors = adjacency.get(currentId) ?? [];
209
+ if (idx >= neighbors.length) {
210
+ color.set(currentId, BLACK);
211
+ stack.pop();
212
+ continue;
213
+ }
214
+ top[1] = idx + 1;
215
+ const neighbor = neighbors[idx];
204
216
  if (color.get(neighbor) === GRAY) {
205
217
  errors.push({
206
218
  code: "CYCLE_DETECTED",
207
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
208
- nodeId
219
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
220
+ nodeId: currentId
209
221
  });
210
222
  } else if (color.get(neighbor) === WHITE) {
211
- dfs(neighbor);
223
+ color.set(neighbor, GRAY);
224
+ stack.push([neighbor, 0]);
212
225
  }
213
226
  }
214
- color.set(nodeId, BLACK);
215
- }
216
- for (const node of nodes) {
217
- if (color.get(node.id) === WHITE) {
218
- dfs(node.id);
219
- }
220
227
  }
221
228
  return errors;
222
229
  }
@@ -418,10 +425,16 @@ function resolveInputRef(path, context) {
418
425
  const incoming = context.ir.edges.filter(
419
426
  (e) => e.targetNodeId === context.currentNodeId
420
427
  );
421
- const dataSource = incoming.find((e) => {
428
+ const nonTriggerIncoming = incoming.filter((e) => {
422
429
  const src = context.nodeMap.get(e.sourceNodeId);
423
430
  return src && src.category !== "trigger" /* TRIGGER */;
424
- }) || incoming[0];
431
+ });
432
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
433
+ console.warn(
434
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
435
+ );
436
+ }
437
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
425
438
  if (dataSource) {
426
439
  const srcId = dataSource.sourceNodeId;
427
440
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -436,15 +449,21 @@ function resolveInputRef(path, context) {
436
449
  `Expression parser error: Node "${context.currentNodeId}" has no input connected`
437
450
  );
438
451
  }
452
+ var _cachedTriggerIR = null;
453
+ var _cachedTriggerId = null;
439
454
  function resolveTriggerRef(path, context) {
440
- const trigger = context.ir.nodes.find(
441
- (n) => n.category === "trigger" /* TRIGGER */
442
- );
443
- if (trigger) {
444
- if (context.symbolTable?.hasVar(trigger.id)) {
445
- return `${context.symbolTable.getVarName(trigger.id)}${path}`;
455
+ if (_cachedTriggerIR !== context.ir) {
456
+ _cachedTriggerIR = context.ir;
457
+ const trigger = context.ir.nodes.find(
458
+ (n) => n.category === "trigger" /* TRIGGER */
459
+ );
460
+ _cachedTriggerId = trigger?.id ?? null;
461
+ }
462
+ if (_cachedTriggerId) {
463
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
464
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
446
465
  }
447
- return `flowState['${trigger.id}']${path}`;
466
+ return `flowState['${_cachedTriggerId}']${path}`;
448
467
  }
449
468
  return "undefined";
450
469
  }
@@ -2790,12 +2809,18 @@ function computeAuditHints(ctx) {
2790
2809
  return hints;
2791
2810
  }
2792
2811
  function isFetchCall(node) {
2793
- const text = node.getText();
2794
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === SyntaxKind.AwaitExpression);
2812
+ if (node.getKind() === SyntaxKind.CallExpression) {
2813
+ const expr = node.getExpression();
2814
+ if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "fetch") return true;
2815
+ }
2816
+ if (node.getKind() === SyntaxKind.AwaitExpression) {
2817
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
2818
+ }
2819
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
2795
2820
  }
2796
2821
  function hasAwaitExpression(node) {
2797
2822
  if (node.getKind() === SyntaxKind.AwaitExpression) return true;
2798
- return node.getText().startsWith("await ");
2823
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
2799
2824
  }
2800
2825
  function extractAwaitedExpression(node) {
2801
2826
  const text = node.getText();
@@ -2865,7 +2890,8 @@ function inferLabel(code, varName) {
2865
2890
  return truncate(code, 30);
2866
2891
  }
2867
2892
  function trackVariableUses(nodeId, expression, ctx) {
2868
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
2893
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
2894
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
2869
2895
  if (!identifiers) return;
2870
2896
  const uses = [];
2871
2897
  const seen = /* @__PURE__ */ new Set();
@@ -3646,6 +3672,13 @@ function handleImportOpenAPI(body) {
3646
3672
  (flow) => paths.some((p) => flow.meta.name.includes(p))
3647
3673
  );
3648
3674
  }
3675
+ if (body.filter?.tags && Array.isArray(body.filter.tags)) {
3676
+ const tags = body.filter.tags.map((t) => t.toLowerCase());
3677
+ filteredFlows = filteredFlows.filter((flow) => {
3678
+ const flowTags = flow.meta.tags ?? [];
3679
+ return flowTags.some((t) => tags.includes(t.toLowerCase()));
3680
+ });
3681
+ }
3649
3682
  return {
3650
3683
  status: 200,
3651
3684
  body: {
@@ -3964,10 +3997,14 @@ function startServer(options = {}) {
3964
3997
  const server = createServer((req, res) => {
3965
3998
  handleRequest(req, res, staticDir, projectRoot).catch((err) => {
3966
3999
  logger.error("Internal error:", err);
3967
- res.writeHead(500, { "Content-Type": "text/plain" });
3968
- res.end("Internal Server Error");
4000
+ if (!res.headersSent) {
4001
+ res.writeHead(500, { "Content-Type": "text/plain" });
4002
+ res.end("Internal Server Error");
4003
+ }
3969
4004
  });
3970
4005
  });
4006
+ server.headersTimeout = 3e4;
4007
+ server.requestTimeout = 6e4;
3971
4008
  const hasUI = existsSync2(join2(staticDir, "index.html"));
3972
4009
  server.listen(port, host, () => {
3973
4010
  const url = `http://localhost:${port}`;