@timo9378/flow2code 0.1.6 → 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,47 @@ 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
+
33
+ ## [0.1.7] — 2026-03-05
34
+
35
+ ### Performance
36
+ - **Precompute `edgeSuccessors` map** — Eliminated O(N×E) per-call rebuild in `generateBlockContinuation`; successor lookup is now O(1) via pre-built map in `CompilerContext`
37
+ - **Reuse `nodeMap` in control-flow analysis** — `computeControlFlowDescendants` now receives the existing `nodeMap` instead of rebuilding a redundant `new Map()` each call
38
+
39
+ ### Fixed
40
+ - **Decompiler `processForStatement` AST correctness** — Replaced fragile `.split("{")` string hack with proper AST methods (`getInitializer()`, `getCondition()`, `getIncrementor()`); fixes incorrect parsing when loop body contains object literals
41
+ - **OpenAPI YAML import** — `handleImportOpenAPI` now supports `.yaml`/`.yml` files via dynamic `import("yaml")` instead of silently failing with `JSON.parse`
42
+ - **`revokeObjectURL` download race** — Deferred `URL.revokeObjectURL` by 10 seconds after `click()` to prevent Safari/slow-browser download failures
43
+ - **CLI watch mode async I/O** — New `loadFlowProjectAsync` reads flow projects with `fs/promises` (`readFile`/`readdir`); `compileFlowDirAsync` no longer blocks the event loop with sync file I/O
44
+
45
+ ### Tests
46
+ - Added `loadFlowProjectAsync` parity tests (split + JSON + error case)
47
+ - Test count: 410 tests / 33 test files
48
+
8
49
  ## [0.1.6] — 2026-03-05
9
50
 
10
51
  ### Fixed (Critical)
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);
@@ -1852,10 +1871,18 @@ function compile(ir, options) {
1852
1871
  dagMode: false,
1853
1872
  symbolTableExclusions: /* @__PURE__ */ new Set(),
1854
1873
  generatedBlockNodeIds: /* @__PURE__ */ new Set(),
1855
- pluginRegistry
1874
+ pluginRegistry,
1875
+ edgeSuccessors: (() => {
1876
+ const map = /* @__PURE__ */ new Map();
1877
+ for (const edge of workingIR.edges) {
1878
+ if (!map.has(edge.sourceNodeId)) map.set(edge.sourceNodeId, []);
1879
+ map.get(edge.sourceNodeId).push(edge.targetNodeId);
1880
+ }
1881
+ return map;
1882
+ })()
1856
1883
  };
1857
1884
  const trigger = workingIR.nodes.find((n) => n.category === "trigger" /* TRIGGER */);
1858
- const preComputedBlockNodes = computeControlFlowDescendants(workingIR, trigger.id);
1885
+ const preComputedBlockNodes = computeControlFlowDescendants(workingIR, trigger.id, nodeMap);
1859
1886
  for (const nodeId of preComputedBlockNodes) {
1860
1887
  context.childBlockNodeIds.add(nodeId);
1861
1888
  context.symbolTableExclusions.add(nodeId);
@@ -1935,8 +1962,7 @@ function isControlFlowEdge(edge, nodeMap) {
1935
1962
  const controlPorts = CONTROL_FLOW_PORT_MAP[sourceNode.nodeType];
1936
1963
  return controlPorts !== void 0 && controlPorts.has(edge.sourcePortId);
1937
1964
  }
1938
- function computeControlFlowDescendants(ir, triggerId) {
1939
- const nodeMap = new Map(ir.nodes.map((n) => [n.id, n]));
1965
+ function computeControlFlowDescendants(ir, triggerId, nodeMap) {
1940
1966
  const strippedSuccessors = /* @__PURE__ */ new Map();
1941
1967
  for (const node of ir.nodes) {
1942
1968
  strippedSuccessors.set(node.id, /* @__PURE__ */ new Set());
@@ -1969,13 +1995,7 @@ function computeControlFlowDescendants(ir, triggerId) {
1969
1995
  function generateBlockContinuation(writer, fromNodeId, context) {
1970
1996
  const reachable = /* @__PURE__ */ new Set();
1971
1997
  const bfsQueue = [fromNodeId];
1972
- const edgeSuccessors = /* @__PURE__ */ new Map();
1973
- for (const edge of context.ir.edges) {
1974
- if (!edgeSuccessors.has(edge.sourceNodeId)) {
1975
- edgeSuccessors.set(edge.sourceNodeId, []);
1976
- }
1977
- edgeSuccessors.get(edge.sourceNodeId).push(edge.targetNodeId);
1978
- }
1998
+ const edgeSuccessors = context.edgeSuccessors;
1979
1999
  while (bfsQueue.length > 0) {
1980
2000
  const id = bfsQueue.shift();
1981
2001
  if (reachable.has(id)) continue;
@@ -2764,7 +2784,10 @@ function processForInStatement(stmt, _prevNodeId, ctx, line) {
2764
2784
  }
2765
2785
  function processForStatement(stmt, _prevNodeId, ctx, line) {
2766
2786
  const nodeId = ctx.nextId("loop");
2767
- const fullText = stmt.getText().split("{")[0].trim();
2787
+ const initText = stmt.getInitializer()?.getText() ?? "";
2788
+ const condText = stmt.getCondition()?.getText() ?? "";
2789
+ const incrText = stmt.getIncrementor()?.getText() ?? "";
2790
+ const fullText = `for (${initText}; ${condText}; ${incrText})`;
2768
2791
  ctx.addNode({
2769
2792
  id: nodeId,
2770
2793
  nodeType: "for_loop" /* FOR_LOOP */,
@@ -2983,12 +3006,18 @@ function computeAuditHints(ctx) {
2983
3006
  return hints;
2984
3007
  }
2985
3008
  function isFetchCall(node) {
2986
- const text = node.getText();
2987
- 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;
2988
3017
  }
2989
3018
  function hasAwaitExpression(node) {
2990
3019
  if (node.getKind() === SyntaxKind.AwaitExpression) return true;
2991
- return node.getText().startsWith("await ");
3020
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
2992
3021
  }
2993
3022
  function extractAwaitedExpression(node) {
2994
3023
  const text = node.getText();
@@ -3058,7 +3087,8 @@ function inferLabel(code, varName) {
3058
3087
  return truncate(code, 30);
3059
3088
  }
3060
3089
  function trackVariableUses(nodeId, expression, ctx) {
3061
- 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);
3062
3092
  if (!identifiers) return;
3063
3093
  const uses = [];
3064
3094
  const seen = /* @__PURE__ */ new Set();
@@ -3864,6 +3894,13 @@ function handleImportOpenAPI(body) {
3864
3894
  (flow) => paths.some((p) => flow.meta.name.includes(p))
3865
3895
  );
3866
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
+ }
3867
3904
  return {
3868
3905
  status: 200,
3869
3906
  body: {
@@ -3946,7 +3983,7 @@ __export(server_exports, {
3946
3983
  startServer: () => startServer
3947
3984
  });
3948
3985
  import { createServer } from "http";
3949
- import { readFile, stat } from "fs/promises";
3986
+ import { readFile as readFile2, stat } from "fs/promises";
3950
3987
  import { join as join3, extname as extname2, dirname as dirname3, resolve as resolve2 } from "path";
3951
3988
  import { fileURLToPath } from "url";
3952
3989
  import { existsSync as existsSync3 } from "fs";
@@ -4044,7 +4081,7 @@ async function serveStatic(staticDir, pathname, res) {
4044
4081
  if (!s.isFile()) return false;
4045
4082
  const ext = extname2(filePath).toLowerCase();
4046
4083
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
4047
- const content = await readFile(filePath);
4084
+ const content = await readFile2(filePath);
4048
4085
  res.writeHead(200, { "Content-Type": contentType });
4049
4086
  res.end(content);
4050
4087
  return true;
@@ -4108,7 +4145,7 @@ async function handleRequest(req, res, staticDir, projectRoot) {
4108
4145
  if (served) return;
4109
4146
  const indexPath = join3(staticDir, "index.html");
4110
4147
  try {
4111
- const content = await readFile(indexPath, "utf-8");
4148
+ const content = await readFile2(indexPath, "utf-8");
4112
4149
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
4113
4150
  res.end(content);
4114
4151
  } catch {
@@ -4124,10 +4161,14 @@ function startServer(options = {}) {
4124
4161
  const server = createServer((req, res) => {
4125
4162
  handleRequest(req, res, staticDir, projectRoot).catch((err) => {
4126
4163
  logger.error("Internal error:", err);
4127
- res.writeHead(500, { "Content-Type": "text/plain" });
4128
- res.end("Internal Server Error");
4164
+ if (!res.headersSent) {
4165
+ res.writeHead(500, { "Content-Type": "text/plain" });
4166
+ res.end("Internal Server Error");
4167
+ }
4129
4168
  });
4130
4169
  });
4170
+ server.headersTimeout = 3e4;
4171
+ server.requestTimeout = 6e4;
4131
4172
  const hasUI = existsSync3(join3(staticDir, "index.html"));
4132
4173
  server.listen(port, host, () => {
4133
4174
  const url = `http://localhost:${port}`;
@@ -4190,7 +4231,7 @@ init_logger();
4190
4231
  init_validator();
4191
4232
  import { Command } from "commander";
4192
4233
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync4, readdirSync as readdirSync2, rmSync as rmSync2 } from "fs";
4193
- import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
4234
+ import { readFile as readFile3, writeFile, mkdir } from "fs/promises";
4194
4235
  import { join as join4, dirname as dirname4, resolve as resolve3, basename as basename2 } from "path";
4195
4236
  import { watch } from "chokidar";
4196
4237
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -4329,6 +4370,7 @@ function sanitizeFilename(id) {
4329
4370
 
4330
4371
  // src/lib/storage/flow-project.ts
4331
4372
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, statSync } from "fs";
4373
+ import { readFile, readdir } from "fs/promises";
4332
4374
  import { join, dirname } from "path";
4333
4375
  function detectFormat(inputPath) {
4334
4376
  if (inputPath.endsWith(".flow.json") && existsSync(inputPath)) {
@@ -4379,6 +4421,37 @@ function loadFlowProject(inputPath) {
4379
4421
  const ir = mergeIR({ meta, edges, nodes });
4380
4422
  return { path: resolvedPath, format, ir };
4381
4423
  }
4424
+ async function loadFlowProjectAsync(inputPath) {
4425
+ const { resolvedPath, format } = detectFormat(inputPath);
4426
+ if (format === "json") {
4427
+ if (!existsSync(resolvedPath)) {
4428
+ throw new Error(`Flow file not found: ${resolvedPath}`);
4429
+ }
4430
+ const raw = await readFile(resolvedPath, "utf-8");
4431
+ const ir2 = JSON.parse(raw);
4432
+ return { path: resolvedPath, format, ir: ir2 };
4433
+ }
4434
+ if (!existsSync(resolvedPath)) {
4435
+ throw new Error(`Flow directory not found: ${resolvedPath}`);
4436
+ }
4437
+ const metaPath = join(resolvedPath, "meta.yaml");
4438
+ if (!existsSync(metaPath)) {
4439
+ throw new Error(`meta.yaml not found in ${resolvedPath} \u2014 not a valid Flow directory`);
4440
+ }
4441
+ const meta = await readFile(metaPath, "utf-8");
4442
+ const edgesPath = join(resolvedPath, "edges.yaml");
4443
+ const edges = existsSync(edgesPath) ? await readFile(edgesPath, "utf-8") : "";
4444
+ const nodesDir = join(resolvedPath, "nodes");
4445
+ const nodes = /* @__PURE__ */ new Map();
4446
+ if (existsSync(nodesDir)) {
4447
+ const nodeFiles = (await readdir(nodesDir)).filter((f) => f.endsWith(".yaml"));
4448
+ for (const file of nodeFiles) {
4449
+ nodes.set(file, await readFile(join(nodesDir, file), "utf-8"));
4450
+ }
4451
+ }
4452
+ const ir = mergeIR({ meta, edges, nodes });
4453
+ return { path: resolvedPath, format, ir };
4454
+ }
4382
4455
  function saveFlowProject(ir, outputPath, options = {}) {
4383
4456
  const { format = "split", cleanOrphanNodes = true } = options;
4384
4457
  if (format === "json") {
@@ -5310,7 +5383,7 @@ function generateEnvExample() {
5310
5383
  async function compileFileAsync(filePath, projectRoot) {
5311
5384
  const startTime = Date.now();
5312
5385
  try {
5313
- const raw = await readFile2(filePath, "utf-8");
5386
+ const raw = await readFile3(filePath, "utf-8");
5314
5387
  const ir = JSON.parse(raw);
5315
5388
  const validation = validateFlowIR(ir);
5316
5389
  if (!validation.valid) {
@@ -5345,7 +5418,7 @@ async function compileFileAsync(filePath, projectRoot) {
5345
5418
  async function compileFlowDirAsync(dirPath, projectRoot) {
5346
5419
  const startTime = Date.now();
5347
5420
  try {
5348
- const project = loadFlowProject(dirPath);
5421
+ const project = await loadFlowProjectAsync(dirPath);
5349
5422
  const ir = project.ir;
5350
5423
  const validation = validateFlowIR(ir);
5351
5424
  if (!validation.valid) {
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
  }
@@ -1861,10 +1880,18 @@ function compile(ir, options) {
1861
1880
  dagMode: false,
1862
1881
  symbolTableExclusions: /* @__PURE__ */ new Set(),
1863
1882
  generatedBlockNodeIds: /* @__PURE__ */ new Set(),
1864
- pluginRegistry
1883
+ pluginRegistry,
1884
+ edgeSuccessors: (() => {
1885
+ const map = /* @__PURE__ */ new Map();
1886
+ for (const edge of workingIR.edges) {
1887
+ if (!map.has(edge.sourceNodeId)) map.set(edge.sourceNodeId, []);
1888
+ map.get(edge.sourceNodeId).push(edge.targetNodeId);
1889
+ }
1890
+ return map;
1891
+ })()
1865
1892
  };
1866
1893
  const trigger = workingIR.nodes.find((n) => n.category === "trigger" /* TRIGGER */);
1867
- const preComputedBlockNodes = computeControlFlowDescendants(workingIR, trigger.id);
1894
+ const preComputedBlockNodes = computeControlFlowDescendants(workingIR, trigger.id, nodeMap);
1868
1895
  for (const nodeId of preComputedBlockNodes) {
1869
1896
  context.childBlockNodeIds.add(nodeId);
1870
1897
  context.symbolTableExclusions.add(nodeId);
@@ -1949,8 +1976,7 @@ function isControlFlowEdge(edge, nodeMap) {
1949
1976
  const controlPorts = CONTROL_FLOW_PORT_MAP[sourceNode.nodeType];
1950
1977
  return controlPorts !== void 0 && controlPorts.has(edge.sourcePortId);
1951
1978
  }
1952
- function computeControlFlowDescendants(ir, triggerId) {
1953
- const nodeMap = new Map(ir.nodes.map((n) => [n.id, n]));
1979
+ function computeControlFlowDescendants(ir, triggerId, nodeMap) {
1954
1980
  const strippedSuccessors = /* @__PURE__ */ new Map();
1955
1981
  for (const node of ir.nodes) {
1956
1982
  strippedSuccessors.set(node.id, /* @__PURE__ */ new Set());
@@ -1983,13 +2009,7 @@ function computeControlFlowDescendants(ir, triggerId) {
1983
2009
  function generateBlockContinuation(writer, fromNodeId, context) {
1984
2010
  const reachable = /* @__PURE__ */ new Set();
1985
2011
  const bfsQueue = [fromNodeId];
1986
- const edgeSuccessors = /* @__PURE__ */ new Map();
1987
- for (const edge of context.ir.edges) {
1988
- if (!edgeSuccessors.has(edge.sourceNodeId)) {
1989
- edgeSuccessors.set(edge.sourceNodeId, []);
1990
- }
1991
- edgeSuccessors.get(edge.sourceNodeId).push(edge.targetNodeId);
1992
- }
2012
+ const edgeSuccessors = context.edgeSuccessors;
1993
2013
  while (bfsQueue.length > 0) {
1994
2014
  const id = bfsQueue.shift();
1995
2015
  if (reachable.has(id)) continue;
@@ -2818,7 +2838,10 @@ function processForInStatement(stmt, _prevNodeId, ctx, line) {
2818
2838
  }
2819
2839
  function processForStatement(stmt, _prevNodeId, ctx, line) {
2820
2840
  const nodeId = ctx.nextId("loop");
2821
- const fullText = stmt.getText().split("{")[0].trim();
2841
+ const initText = stmt.getInitializer()?.getText() ?? "";
2842
+ const condText = stmt.getCondition()?.getText() ?? "";
2843
+ const incrText = stmt.getIncrementor()?.getText() ?? "";
2844
+ const fullText = `for (${initText}; ${condText}; ${incrText})`;
2822
2845
  ctx.addNode({
2823
2846
  id: nodeId,
2824
2847
  nodeType: "for_loop" /* FOR_LOOP */,
@@ -3037,12 +3060,18 @@ function computeAuditHints(ctx) {
3037
3060
  return hints;
3038
3061
  }
3039
3062
  function isFetchCall(node) {
3040
- const text = node.getText();
3041
- 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;
3042
3071
  }
3043
3072
  function hasAwaitExpression(node) {
3044
3073
  if (node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression) return true;
3045
- return node.getText().startsWith("await ");
3074
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
3046
3075
  }
3047
3076
  function extractAwaitedExpression(node) {
3048
3077
  const text = node.getText();
@@ -3112,7 +3141,8 @@ function inferLabel(code, varName) {
3112
3141
  return truncate2(code, 30);
3113
3142
  }
3114
3143
  function trackVariableUses(nodeId, expression, ctx) {
3115
- 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);
3116
3146
  if (!identifiers) return;
3117
3147
  const uses = [];
3118
3148
  const seen = /* @__PURE__ */ new Set();
@@ -3767,6 +3797,7 @@ function sanitizeFilename(id) {
3767
3797
 
3768
3798
  // src/lib/storage/flow-project.ts
3769
3799
  var import_node_fs = require("fs");
3800
+ var import_promises = require("fs/promises");
3770
3801
  var import_node_path = require("path");
3771
3802
  function detectFormat(inputPath) {
3772
3803
  if (inputPath.endsWith(".flow.json") && (0, import_node_fs.existsSync)(inputPath)) {