@timo9378/flow2code 0.1.7 → 0.1.9

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,49 @@ 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.9] — 2026-03-05
9
+
10
+ ### Fixed
11
+ - **`resolveEnvVars` backtick escaping** — Backtick and backslash characters in URLs are now escaped before wrapping in template literals, preventing template literal injection (#24)
12
+ - **`traceLineToNode` binary search** — Replaced linear scan with sorted ranges + binary search for O(log N) source map lookups (#25)
13
+ - **`parseReference` hyphen support** — Regex now matches node IDs containing hyphens (e.g. `node-1`) via `[\w-]+` character class (#27)
14
+ - **Validator duplicate edge ID check** — Added step 4 to detect duplicate `edge.id` values, analogous to the existing duplicate node ID check (#28)
15
+ - **Watch mode SIGINT cleanup** — Added `process.on('SIGINT'/'SIGTERM')` handler to close chokidar watcher and clear pending timers on shutdown (#29)
16
+ - **Split storage colon-in-port-ID** — Edge deserialization now splits on first colon only via `indexOf`, preserving port IDs that contain colons (#30)
17
+
18
+ ### Removed
19
+ - **Dead `generateFlowStateDeclaration`** — Removed unused exported function from `type-inference.ts` (zero callers) (#26)
20
+
21
+ ### Tests
22
+ - Added duplicate edge ID detection test
23
+ - Added colon-in-port-ID round-trip test
24
+ - Test count: 413 tests / 33 test files
25
+
26
+ ## [0.1.8] — 2026-03-05
27
+
28
+ ### Security
29
+ - **HTTP server request timeout** — Added `headersTimeout` (30s) and `requestTimeout` (60s) to prevent Slowloris-style DoS attacks (#16)
30
+ - **Server `headersSent` guard** — Error handler now checks `res.headersSent` before writing 500 response, preventing `ERR_HTTP_HEADERS_SENT` crash (#14)
31
+ - **Validate IR from server** — `handleDecompileTS` and `handleSelectOpenAPIFlow` now call `validateFlowIR()` before `loadIR()`, matching `handleLoadIRFromJSON` behavior (#22)
32
+
33
+ ### Fixed
34
+ - **Validator iterative DFS** — Replaced recursive `dfs()` with explicit stack-based iteration to prevent stack overflow on deep graphs (5000+ nodes) (#11)
35
+ - **`isFetchCall` AST-based detection** — Replaced `text.includes("fetch(")` string matching with proper AST walk using `CallExpression` + `Identifier` node types (#12)
36
+ - **`hasAwaitExpression` AST walk** — Replaced `text.startsWith("await ")` fallback with recursive `forEachChild` AST traversal to detect nested awaits (#23)
37
+ - **`trackVariableUses` strip strings** — Expression string now has string literals (`"..."`, `'...'`, `` `...` ``) stripped before identifier regex scan, preventing false-positive variable references (#13)
38
+ - **`_nodeCounter` max ID parse** — After `loadIR`, counter is set to max numeric ID from existing node IDs (not `nodes.length`), preventing ID collisions (#15)
39
+ - **`resolveTriggerRef` cache** — Trigger node lookup cached per IR reference, eliminating O(N) linear scan on every `$trigger` expression evaluation (#17)
40
+ - **`$input` ambiguity warning** — `resolveInputRef` now emits `console.warn` when a node has multiple non-trigger upstream edges, identifying which source is used (#18)
41
+ - **OpenAPI tags filter** — `handleImportOpenAPI` now implements `filter.tags` (case-insensitive match against `meta.tags`), which was previously accepted but silently ignored (#20)
42
+ - **Clipboard fallback** — `handleExportIR` now catches clipboard write errors gracefully instead of letting promise rejection propagate (#21)
43
+
44
+ ### Added
45
+ - **`createPlatformRegistry` factory** — New function for creating isolated platform registry instances, enabling safe test parallelism and concurrent compilation (#19)
46
+
47
+ ### Tests
48
+ - Added iterative DFS deep graph test (5000 nodes, verifies no stack overflow)
49
+ - Test count: 411 tests / 33 test files
50
+
8
51
  ## [0.1.7] — 2026-03-05
9
52
 
10
53
  ### Performance
package/dist/cli.js CHANGED
@@ -162,6 +162,17 @@ function validateFlowIR(ir) {
162
162
  }
163
163
  idSet.add(node.id);
164
164
  }
165
+ const edgeIdSet = /* @__PURE__ */ new Set();
166
+ for (const edge of workingIR.edges) {
167
+ if (edgeIdSet.has(edge.id)) {
168
+ errors.push({
169
+ code: "DUPLICATE_EDGE_ID",
170
+ message: `Duplicate edge ID: ${edge.id}`,
171
+ edgeId: edge.id
172
+ });
173
+ }
174
+ edgeIdSet.add(edge.id);
175
+ }
165
176
  for (const edge of workingIR.edges) {
166
177
  if (!workingNodeMap.has(edge.sourceNodeId)) {
167
178
  errors.push({
@@ -218,25 +229,32 @@ function detectCycles(nodes, edges) {
218
229
  for (const node of nodes) {
219
230
  color.set(node.id, WHITE);
220
231
  }
221
- function dfs(nodeId) {
222
- color.set(nodeId, GRAY);
223
- for (const neighbor of adjacency.get(nodeId) ?? []) {
232
+ for (const node of nodes) {
233
+ if (color.get(node.id) !== WHITE) continue;
234
+ const stack = [[node.id, 0]];
235
+ color.set(node.id, GRAY);
236
+ while (stack.length > 0) {
237
+ const top = stack[stack.length - 1];
238
+ const [currentId, idx] = top;
239
+ const neighbors = adjacency.get(currentId) ?? [];
240
+ if (idx >= neighbors.length) {
241
+ color.set(currentId, BLACK);
242
+ stack.pop();
243
+ continue;
244
+ }
245
+ top[1] = idx + 1;
246
+ const neighbor = neighbors[idx];
224
247
  if (color.get(neighbor) === GRAY) {
225
248
  errors.push({
226
249
  code: "CYCLE_DETECTED",
227
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
228
- nodeId
250
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
251
+ nodeId: currentId
229
252
  });
230
253
  } else if (color.get(neighbor) === WHITE) {
231
- dfs(neighbor);
254
+ color.set(neighbor, GRAY);
255
+ stack.push([neighbor, 0]);
232
256
  }
233
257
  }
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
258
  }
241
259
  return errors;
242
260
  }
@@ -392,7 +410,7 @@ function tokenize(input) {
392
410
  return tokens;
393
411
  }
394
412
  function parseReference(ref) {
395
- const match = ref.match(/^(\$?\w+)((?:\.[\w]+|\[.+?\])*)$/);
413
+ const match = ref.match(/^(\$?[\w-]+)((?:\.[\w]+|\[.+?\])*)$/);
396
414
  if (!match) {
397
415
  const dotIndex = ref.indexOf(".");
398
416
  const bracketIndex = ref.indexOf("[");
@@ -450,10 +468,16 @@ function resolveInputRef(path, context) {
450
468
  const incoming = context.ir.edges.filter(
451
469
  (e) => e.targetNodeId === context.currentNodeId
452
470
  );
453
- const dataSource = incoming.find((e) => {
471
+ const nonTriggerIncoming = incoming.filter((e) => {
454
472
  const src = context.nodeMap.get(e.sourceNodeId);
455
473
  return src && src.category !== "trigger" /* TRIGGER */;
456
- }) || incoming[0];
474
+ });
475
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
476
+ console.warn(
477
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
478
+ );
479
+ }
480
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
457
481
  if (dataSource) {
458
482
  const srcId = dataSource.sourceNodeId;
459
483
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -469,21 +493,27 @@ function resolveInputRef(path, context) {
469
493
  );
470
494
  }
471
495
  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}`;
496
+ if (_cachedTriggerIR !== context.ir) {
497
+ _cachedTriggerIR = context.ir;
498
+ const trigger = context.ir.nodes.find(
499
+ (n) => n.category === "trigger" /* TRIGGER */
500
+ );
501
+ _cachedTriggerId = trigger?.id ?? null;
502
+ }
503
+ if (_cachedTriggerId) {
504
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
505
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
478
506
  }
479
- return `flowState['${trigger.id}']${path}`;
507
+ return `flowState['${_cachedTriggerId}']${path}`;
480
508
  }
481
509
  return "undefined";
482
510
  }
483
- var ExpressionParseError;
511
+ var _cachedTriggerIR, _cachedTriggerId, ExpressionParseError;
484
512
  var init_expression_parser = __esm({
485
513
  "src/lib/compiler/expression-parser.ts"() {
486
514
  "use strict";
515
+ _cachedTriggerIR = null;
516
+ _cachedTriggerId = null;
487
517
  ExpressionParseError = class extends Error {
488
518
  constructor(message, expression, position) {
489
519
  super(message);
@@ -2222,13 +2252,15 @@ function sanitizeId(id) {
2222
2252
  function resolveEnvVars(url, context) {
2223
2253
  const hasEnvVar = /\$\{(\w+)\}/.test(url);
2224
2254
  if (hasEnvVar) {
2225
- return "`" + url.replace(/\$\{(\w+)\}/g, (_match, varName) => {
2255
+ const escaped = url.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
2256
+ return "`" + escaped.replace(/\$\{(\w+)\}/g, (_match, varName) => {
2226
2257
  context.envVars.add(varName);
2227
2258
  return "${process.env." + varName + "}";
2228
2259
  }) + "`";
2229
2260
  }
2230
2261
  if (url.includes("${")) {
2231
- return "`" + url + "`";
2262
+ const escaped = url.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
2263
+ return "`" + escaped + "`";
2232
2264
  }
2233
2265
  return `"${url}"`;
2234
2266
  }
@@ -2290,9 +2322,18 @@ function buildSourceMap(code, ir, filePath) {
2290
2322
  };
2291
2323
  }
2292
2324
  function traceLineToNode(sourceMap, line) {
2293
- for (const [nodeId, range] of Object.entries(sourceMap.mappings)) {
2294
- if (line >= range.startLine && line <= range.endLine) {
2295
- return { nodeId, ...range };
2325
+ const entries = Object.entries(sourceMap.mappings).map(([nodeId, range]) => ({ nodeId, ...range })).sort((a, b) => a.startLine - b.startLine);
2326
+ let lo = 0;
2327
+ let hi = entries.length - 1;
2328
+ while (lo <= hi) {
2329
+ const mid = lo + hi >>> 1;
2330
+ const e = entries[mid];
2331
+ if (line < e.startLine) {
2332
+ hi = mid - 1;
2333
+ } else if (line > e.endLine) {
2334
+ lo = mid + 1;
2335
+ } else {
2336
+ return e;
2296
2337
  }
2297
2338
  }
2298
2339
  return null;
@@ -2987,12 +3028,18 @@ function computeAuditHints(ctx) {
2987
3028
  return hints;
2988
3029
  }
2989
3030
  function isFetchCall(node) {
2990
- const text = node.getText();
2991
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === SyntaxKind.AwaitExpression);
3031
+ if (node.getKind() === SyntaxKind.CallExpression) {
3032
+ const expr = node.getExpression();
3033
+ if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "fetch") return true;
3034
+ }
3035
+ if (node.getKind() === SyntaxKind.AwaitExpression) {
3036
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
3037
+ }
3038
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
2992
3039
  }
2993
3040
  function hasAwaitExpression(node) {
2994
3041
  if (node.getKind() === SyntaxKind.AwaitExpression) return true;
2995
- return node.getText().startsWith("await ");
3042
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
2996
3043
  }
2997
3044
  function extractAwaitedExpression(node) {
2998
3045
  const text = node.getText();
@@ -3062,7 +3109,8 @@ function inferLabel(code, varName) {
3062
3109
  return truncate(code, 30);
3063
3110
  }
3064
3111
  function trackVariableUses(nodeId, expression, ctx) {
3065
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
3112
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
3113
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
3066
3114
  if (!identifiers) return;
3067
3115
  const uses = [];
3068
3116
  const seen = /* @__PURE__ */ new Set();
@@ -3868,6 +3916,13 @@ function handleImportOpenAPI(body) {
3868
3916
  (flow) => paths.some((p) => flow.meta.name.includes(p))
3869
3917
  );
3870
3918
  }
3919
+ if (body.filter?.tags && Array.isArray(body.filter.tags)) {
3920
+ const tags = body.filter.tags.map((t) => t.toLowerCase());
3921
+ filteredFlows = filteredFlows.filter((flow) => {
3922
+ const flowTags = flow.meta.tags ?? [];
3923
+ return flowTags.some((t) => tags.includes(t.toLowerCase()));
3924
+ });
3925
+ }
3871
3926
  return {
3872
3927
  status: 200,
3873
3928
  body: {
@@ -4128,10 +4183,14 @@ function startServer(options = {}) {
4128
4183
  const server = createServer((req, res) => {
4129
4184
  handleRequest(req, res, staticDir, projectRoot).catch((err) => {
4130
4185
  logger.error("Internal error:", err);
4131
- res.writeHead(500, { "Content-Type": "text/plain" });
4132
- res.end("Internal Server Error");
4186
+ if (!res.headersSent) {
4187
+ res.writeHead(500, { "Content-Type": "text/plain" });
4188
+ res.end("Internal Server Error");
4189
+ }
4133
4190
  });
4134
4191
  });
4192
+ server.headersTimeout = 3e4;
4193
+ server.requestTimeout = 6e4;
4135
4194
  const hasUI = existsSync3(join3(staticDir, "index.html"));
4136
4195
  server.listen(port, host, () => {
4137
4196
  const url = `http://localhost:${port}`;
@@ -4261,8 +4320,12 @@ function mergeIR(files) {
4261
4320
  }
4262
4321
  const edgesRaw = parse(files.edges);
4263
4322
  const edges = (edgesRaw ?? []).map((e) => {
4264
- const [sourceNodeId, sourcePortId] = e.source.split(":");
4265
- const [targetNodeId, targetPortId] = e.target.split(":");
4323
+ const srcIdx = e.source.indexOf(":");
4324
+ const tgtIdx = e.target.indexOf(":");
4325
+ const sourceNodeId = srcIdx > 0 ? e.source.slice(0, srcIdx) : e.source;
4326
+ const sourcePortId = srcIdx > 0 ? e.source.slice(srcIdx + 1) : "output";
4327
+ const targetNodeId = tgtIdx > 0 ? e.target.slice(0, tgtIdx) : e.target;
4328
+ const targetPortId = tgtIdx > 0 ? e.target.slice(tgtIdx + 1) : "input";
4266
4329
  return {
4267
4330
  id: e.id,
4268
4331
  sourceNodeId,
@@ -5056,6 +5119,14 @@ program.command("watch [dir]").description("Watch directory, auto-compile .flow.
5056
5119
  watcher.on("error", (error) => {
5057
5120
  console.error("\u274C Watch error:", error instanceof Error ? error.message : String(error));
5058
5121
  });
5122
+ const cleanup = () => {
5123
+ console.log("\n\u{1F6D1} Stopping watcher...");
5124
+ for (const timer of pendingTimers.values()) clearTimeout(timer);
5125
+ pendingTimers.clear();
5126
+ watcher.close().then(() => process.exit(0));
5127
+ };
5128
+ process.on("SIGINT", cleanup);
5129
+ process.on("SIGTERM", cleanup);
5059
5130
  });
5060
5131
  program.command("init").description("Initialize Flow2Code in current project (Zero Pollution mode)").action(() => {
5061
5132
  const flow2codeDir = resolve3(".flow2code");
package/dist/compiler.cjs CHANGED
@@ -238,6 +238,17 @@ function validateFlowIR(ir) {
238
238
  }
239
239
  idSet.add(node.id);
240
240
  }
241
+ const edgeIdSet = /* @__PURE__ */ new Set();
242
+ for (const edge of workingIR.edges) {
243
+ if (edgeIdSet.has(edge.id)) {
244
+ errors.push({
245
+ code: "DUPLICATE_EDGE_ID",
246
+ message: `Duplicate edge ID: ${edge.id}`,
247
+ edgeId: edge.id
248
+ });
249
+ }
250
+ edgeIdSet.add(edge.id);
251
+ }
241
252
  for (const edge of workingIR.edges) {
242
253
  if (!workingNodeMap.has(edge.sourceNodeId)) {
243
254
  errors.push({
@@ -294,25 +305,32 @@ function detectCycles(nodes, edges) {
294
305
  for (const node of nodes) {
295
306
  color.set(node.id, WHITE);
296
307
  }
297
- function dfs(nodeId) {
298
- color.set(nodeId, GRAY);
299
- for (const neighbor of adjacency.get(nodeId) ?? []) {
308
+ for (const node of nodes) {
309
+ if (color.get(node.id) !== WHITE) continue;
310
+ const stack = [[node.id, 0]];
311
+ color.set(node.id, GRAY);
312
+ while (stack.length > 0) {
313
+ const top = stack[stack.length - 1];
314
+ const [currentId, idx] = top;
315
+ const neighbors = adjacency.get(currentId) ?? [];
316
+ if (idx >= neighbors.length) {
317
+ color.set(currentId, BLACK);
318
+ stack.pop();
319
+ continue;
320
+ }
321
+ top[1] = idx + 1;
322
+ const neighbor = neighbors[idx];
300
323
  if (color.get(neighbor) === GRAY) {
301
324
  errors.push({
302
325
  code: "CYCLE_DETECTED",
303
- message: `Cycle detected: node "${nodeId}" \u2192 "${neighbor}"`,
304
- nodeId
326
+ message: `Cycle detected: node "${currentId}" \u2192 "${neighbor}"`,
327
+ nodeId: currentId
305
328
  });
306
329
  } else if (color.get(neighbor) === WHITE) {
307
- dfs(neighbor);
330
+ color.set(neighbor, GRAY);
331
+ stack.push([neighbor, 0]);
308
332
  }
309
333
  }
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
334
  }
317
335
  return errors;
318
336
  }
@@ -456,7 +474,7 @@ function tokenize(input) {
456
474
  return tokens;
457
475
  }
458
476
  function parseReference(ref) {
459
- const match = ref.match(/^(\$?\w+)((?:\.[\w]+|\[.+?\])*)$/);
477
+ const match = ref.match(/^(\$?[\w-]+)((?:\.[\w]+|\[.+?\])*)$/);
460
478
  if (!match) {
461
479
  const dotIndex = ref.indexOf(".");
462
480
  const bracketIndex = ref.indexOf("[");
@@ -514,10 +532,16 @@ function resolveInputRef(path, context) {
514
532
  const incoming = context.ir.edges.filter(
515
533
  (e) => e.targetNodeId === context.currentNodeId
516
534
  );
517
- const dataSource = incoming.find((e) => {
535
+ const nonTriggerIncoming = incoming.filter((e) => {
518
536
  const src = context.nodeMap.get(e.sourceNodeId);
519
537
  return src && src.category !== "trigger" /* TRIGGER */;
520
- }) || incoming[0];
538
+ });
539
+ if (nonTriggerIncoming.length > 1 && typeof console !== "undefined") {
540
+ console.warn(
541
+ `[flow2code] $input is ambiguous: node "${context.currentNodeId}" has ${nonTriggerIncoming.length} non-trigger upstream edges. Using first match "${nonTriggerIncoming[0].sourceNodeId}".`
542
+ );
543
+ }
544
+ const dataSource = nonTriggerIncoming[0] || incoming[0];
521
545
  if (dataSource) {
522
546
  const srcId = dataSource.sourceNodeId;
523
547
  if (context.blockScopedNodeIds?.has(srcId)) {
@@ -532,15 +556,21 @@ function resolveInputRef(path, context) {
532
556
  `Expression parser error: Node "${context.currentNodeId}" has no input connected`
533
557
  );
534
558
  }
559
+ var _cachedTriggerIR = null;
560
+ var _cachedTriggerId = null;
535
561
  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}`;
562
+ if (_cachedTriggerIR !== context.ir) {
563
+ _cachedTriggerIR = context.ir;
564
+ const trigger = context.ir.nodes.find(
565
+ (n) => n.category === "trigger" /* TRIGGER */
566
+ );
567
+ _cachedTriggerId = trigger?.id ?? null;
568
+ }
569
+ if (_cachedTriggerId) {
570
+ if (context.symbolTable?.hasVar(_cachedTriggerId)) {
571
+ return `${context.symbolTable.getVarName(_cachedTriggerId)}${path}`;
542
572
  }
543
- return `flowState['${trigger.id}']${path}`;
573
+ return `flowState['${_cachedTriggerId}']${path}`;
544
574
  }
545
575
  return "undefined";
546
576
  }
@@ -2236,13 +2266,15 @@ function sanitizeId(id) {
2236
2266
  function resolveEnvVars(url, context) {
2237
2267
  const hasEnvVar = /\$\{(\w+)\}/.test(url);
2238
2268
  if (hasEnvVar) {
2239
- return "`" + url.replace(/\$\{(\w+)\}/g, (_match, varName) => {
2269
+ const escaped = url.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
2270
+ return "`" + escaped.replace(/\$\{(\w+)\}/g, (_match, varName) => {
2240
2271
  context.envVars.add(varName);
2241
2272
  return "${process.env." + varName + "}";
2242
2273
  }) + "`";
2243
2274
  }
2244
2275
  if (url.includes("${")) {
2245
- return "`" + url + "`";
2276
+ const escaped = url.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
2277
+ return "`" + escaped + "`";
2246
2278
  }
2247
2279
  return `"${url}"`;
2248
2280
  }
@@ -2304,9 +2336,18 @@ function buildSourceMap(code, ir, filePath) {
2304
2336
  };
2305
2337
  }
2306
2338
  function traceLineToNode(sourceMap, line) {
2307
- for (const [nodeId, range] of Object.entries(sourceMap.mappings)) {
2308
- if (line >= range.startLine && line <= range.endLine) {
2309
- return { nodeId, ...range };
2339
+ const entries = Object.entries(sourceMap.mappings).map(([nodeId, range]) => ({ nodeId, ...range })).sort((a, b) => a.startLine - b.startLine);
2340
+ let lo = 0;
2341
+ let hi = entries.length - 1;
2342
+ while (lo <= hi) {
2343
+ const mid = lo + hi >>> 1;
2344
+ const e = entries[mid];
2345
+ if (line < e.startLine) {
2346
+ hi = mid - 1;
2347
+ } else if (line > e.endLine) {
2348
+ lo = mid + 1;
2349
+ } else {
2350
+ return e;
2310
2351
  }
2311
2352
  }
2312
2353
  return null;
@@ -3041,12 +3082,18 @@ function computeAuditHints(ctx) {
3041
3082
  return hints;
3042
3083
  }
3043
3084
  function isFetchCall(node) {
3044
- const text = node.getText();
3045
- return text.includes("fetch(") && (text.includes("await") || node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression);
3085
+ if (node.getKind() === import_ts_morph2.SyntaxKind.CallExpression) {
3086
+ const expr = node.getExpression();
3087
+ if (expr.getKind() === import_ts_morph2.SyntaxKind.Identifier && expr.getText() === "fetch") return true;
3088
+ }
3089
+ if (node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression) {
3090
+ return isFetchCall(node.getChildAtIndex(1) ?? node);
3091
+ }
3092
+ return node.forEachChild((child) => isFetchCall(child) || void 0) ?? false;
3046
3093
  }
3047
3094
  function hasAwaitExpression(node) {
3048
3095
  if (node.getKind() === import_ts_morph2.SyntaxKind.AwaitExpression) return true;
3049
- return node.getText().startsWith("await ");
3096
+ return node.forEachChild((child) => hasAwaitExpression(child) || void 0) ?? false;
3050
3097
  }
3051
3098
  function extractAwaitedExpression(node) {
3052
3099
  const text = node.getText();
@@ -3116,7 +3163,8 @@ function inferLabel(code, varName) {
3116
3163
  return truncate2(code, 30);
3117
3164
  }
3118
3165
  function trackVariableUses(nodeId, expression, ctx) {
3119
- const identifiers = expression.match(/\b([a-zA-Z_]\w*)\b/g);
3166
+ const stripped = expression.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, "");
3167
+ const identifiers = stripped.match(/\b([a-zA-Z_]\w*)\b/g);
3120
3168
  if (!identifiers) return;
3121
3169
  const uses = [];
3122
3170
  const seen = /* @__PURE__ */ new Set();
@@ -3737,8 +3785,12 @@ function mergeIR(files) {
3737
3785
  }
3738
3786
  const edgesRaw = (0, import_yaml.parse)(files.edges);
3739
3787
  const edges = (edgesRaw ?? []).map((e) => {
3740
- const [sourceNodeId, sourcePortId] = e.source.split(":");
3741
- const [targetNodeId, targetPortId] = e.target.split(":");
3788
+ const srcIdx = e.source.indexOf(":");
3789
+ const tgtIdx = e.target.indexOf(":");
3790
+ const sourceNodeId = srcIdx > 0 ? e.source.slice(0, srcIdx) : e.source;
3791
+ const sourcePortId = srcIdx > 0 ? e.source.slice(srcIdx + 1) : "output";
3792
+ const targetNodeId = tgtIdx > 0 ? e.target.slice(0, tgtIdx) : e.target;
3793
+ const targetPortId = tgtIdx > 0 ? e.target.slice(tgtIdx + 1) : "input";
3742
3794
  return {
3743
3795
  id: e.id,
3744
3796
  sourceNodeId,