@optave/codegraph 3.9.3 → 3.9.5

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.
Files changed (116) hide show
  1. package/README.md +10 -10
  2. package/dist/ast-analysis/visitor.d.ts.map +1 -1
  3. package/dist/ast-analysis/visitor.js +14 -0
  4. package/dist/ast-analysis/visitor.js.map +1 -1
  5. package/dist/cli/commands/watch.d.ts.map +1 -1
  6. package/dist/cli/commands/watch.js +2 -0
  7. package/dist/cli/commands/watch.js.map +1 -1
  8. package/dist/cli.js +24 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/domain/graph/builder/context.d.ts +17 -0
  11. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  12. package/dist/domain/graph/builder/context.js +7 -0
  13. package/dist/domain/graph/builder/context.js.map +1 -1
  14. package/dist/domain/graph/builder/helpers.d.ts +13 -2
  15. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  16. package/dist/domain/graph/builder/helpers.js +30 -4
  17. package/dist/domain/graph/builder/helpers.js.map +1 -1
  18. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  19. package/dist/domain/graph/builder/pipeline.js +221 -51
  20. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  21. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  22. package/dist/domain/graph/builder/stages/build-edges.js +67 -6
  23. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  24. package/dist/domain/graph/builder/stages/build-structure.js +2 -2
  25. package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
  26. package/dist/domain/graph/builder/stages/collect-files.js +58 -26
  27. package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
  28. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  29. package/dist/domain/graph/builder/stages/detect-changes.js +105 -55
  30. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  31. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  32. package/dist/domain/graph/builder/stages/finalize.js +27 -4
  33. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  34. package/dist/domain/graph/builder/stages/run-analyses.d.ts.map +1 -1
  35. package/dist/domain/graph/builder/stages/run-analyses.js +5 -20
  36. package/dist/domain/graph/builder/stages/run-analyses.js.map +1 -1
  37. package/dist/domain/graph/journal.d.ts +15 -0
  38. package/dist/domain/graph/journal.d.ts.map +1 -1
  39. package/dist/domain/graph/journal.js +283 -28
  40. package/dist/domain/graph/journal.js.map +1 -1
  41. package/dist/domain/graph/watcher.d.ts +17 -0
  42. package/dist/domain/graph/watcher.d.ts.map +1 -1
  43. package/dist/domain/graph/watcher.js +23 -7
  44. package/dist/domain/graph/watcher.js.map +1 -1
  45. package/dist/domain/parser.d.ts +13 -4
  46. package/dist/domain/parser.d.ts.map +1 -1
  47. package/dist/domain/parser.js +174 -80
  48. package/dist/domain/parser.js.map +1 -1
  49. package/dist/domain/search/generator.d.ts.map +1 -1
  50. package/dist/domain/search/generator.js +28 -2
  51. package/dist/domain/search/generator.js.map +1 -1
  52. package/dist/domain/wasm-worker-entry.d.ts +24 -0
  53. package/dist/domain/wasm-worker-entry.d.ts.map +1 -0
  54. package/dist/domain/wasm-worker-entry.js +643 -0
  55. package/dist/domain/wasm-worker-entry.js.map +1 -0
  56. package/dist/domain/wasm-worker-pool.d.ts +59 -0
  57. package/dist/domain/wasm-worker-pool.d.ts.map +1 -0
  58. package/dist/domain/wasm-worker-pool.js +312 -0
  59. package/dist/domain/wasm-worker-pool.js.map +1 -0
  60. package/dist/domain/wasm-worker-protocol.d.ts +65 -0
  61. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -0
  62. package/dist/domain/wasm-worker-protocol.js +13 -0
  63. package/dist/domain/wasm-worker-protocol.js.map +1 -0
  64. package/dist/extractors/javascript.js +265 -1
  65. package/dist/extractors/javascript.js.map +1 -1
  66. package/dist/features/boundaries.d.ts +2 -2
  67. package/dist/features/boundaries.d.ts.map +1 -1
  68. package/dist/features/boundaries.js +2 -31
  69. package/dist/features/boundaries.js.map +1 -1
  70. package/dist/features/snapshot.d.ts.map +1 -1
  71. package/dist/features/snapshot.js +99 -13
  72. package/dist/features/snapshot.js.map +1 -1
  73. package/dist/features/structure.d.ts.map +1 -1
  74. package/dist/features/structure.js +14 -1
  75. package/dist/features/structure.js.map +1 -1
  76. package/dist/graph/algorithms/louvain.d.ts.map +1 -1
  77. package/dist/graph/algorithms/louvain.js +2 -4
  78. package/dist/graph/algorithms/louvain.js.map +1 -1
  79. package/dist/infrastructure/config.d.ts.map +1 -1
  80. package/dist/infrastructure/config.js +12 -2
  81. package/dist/infrastructure/config.js.map +1 -1
  82. package/dist/shared/globs.d.ts +40 -0
  83. package/dist/shared/globs.d.ts.map +1 -0
  84. package/dist/shared/globs.js +126 -0
  85. package/dist/shared/globs.js.map +1 -0
  86. package/dist/types.d.ts +26 -1
  87. package/dist/types.d.ts.map +1 -1
  88. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  89. package/package.json +7 -7
  90. package/src/ast-analysis/visitor.ts +15 -0
  91. package/src/cli/commands/watch.ts +2 -0
  92. package/src/cli.ts +31 -8
  93. package/src/domain/graph/builder/context.ts +19 -0
  94. package/src/domain/graph/builder/helpers.ts +53 -3
  95. package/src/domain/graph/builder/pipeline.ts +235 -49
  96. package/src/domain/graph/builder/stages/build-edges.ts +80 -6
  97. package/src/domain/graph/builder/stages/build-structure.ts +2 -2
  98. package/src/domain/graph/builder/stages/collect-files.ts +56 -26
  99. package/src/domain/graph/builder/stages/detect-changes.ts +118 -61
  100. package/src/domain/graph/builder/stages/finalize.ts +27 -4
  101. package/src/domain/graph/builder/stages/run-analyses.ts +5 -26
  102. package/src/domain/graph/journal.ts +284 -27
  103. package/src/domain/graph/watcher.ts +29 -9
  104. package/src/domain/parser.ts +166 -73
  105. package/src/domain/search/generator.ts +34 -2
  106. package/src/domain/wasm-worker-entry.ts +788 -0
  107. package/src/domain/wasm-worker-pool.ts +330 -0
  108. package/src/domain/wasm-worker-protocol.ts +81 -0
  109. package/src/extractors/javascript.ts +290 -1
  110. package/src/features/boundaries.ts +2 -27
  111. package/src/features/snapshot.ts +93 -14
  112. package/src/features/structure.ts +17 -1
  113. package/src/graph/algorithms/louvain.ts +2 -4
  114. package/src/infrastructure/config.ts +12 -2
  115. package/src/shared/globs.ts +121 -0
  116. package/src/types.ts +26 -1
@@ -274,14 +274,17 @@ function dispatchQueryMatch(
274
274
  name: c.callfn_name!.text,
275
275
  line: c.callfn_node.startPosition.row + 1,
276
276
  });
277
+ calls.push(...extractCallbackReferenceCalls(c.callfn_node));
277
278
  } else if (c.callmem_node) {
278
279
  const callInfo = extractCallInfo(c.callmem_fn!, c.callmem_node);
279
280
  if (callInfo) calls.push(callInfo);
280
281
  const cbDef = extractCallbackDefinition(c.callmem_node, c.callmem_fn);
281
282
  if (cbDef) definitions.push(cbDef);
283
+ calls.push(...extractCallbackReferenceCalls(c.callmem_node));
282
284
  } else if (c.callsub_node) {
283
285
  const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node);
284
286
  if (callInfo) calls.push(callInfo);
287
+ calls.push(...extractCallbackReferenceCalls(c.callsub_node));
285
288
  } else if (c.newfn_node) {
286
289
  calls.push({
287
290
  name: c.newfn_name!.text,
@@ -321,6 +324,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
321
324
  // Extract typeMap from type annotations and new expressions
322
325
  extractTypeMapWalk(tree.rootNode, typeMap);
323
326
 
327
+ // Extract definitions from destructured bindings (query patterns don't match object_pattern)
328
+ extractDestructuredBindingsWalk(tree.rootNode, definitions);
329
+
324
330
  return { definitions, calls, imports, classes, exports: exps, typeMap };
325
331
  }
326
332
 
@@ -334,6 +340,20 @@ const FUNCTION_SCOPE_TYPES = new Set([
334
340
  'generator_function',
335
341
  ]);
336
342
 
343
+ /**
344
+ * Return true when `node` has an ancestor whose type is in FUNCTION_SCOPE_TYPES.
345
+ * Used by the walk path to skip declarations inside function bodies, matching
346
+ * the query path's top-down FUNCTION_SCOPE_TYPES filter.
347
+ */
348
+ function hasFunctionScopeAncestor(node: TreeSitterNode): boolean {
349
+ let p: TreeSitterNode | null = node.parent ?? null;
350
+ while (p) {
351
+ if (FUNCTION_SCOPE_TYPES.has(p.type)) return true;
352
+ p = p.parent ?? null;
353
+ }
354
+ return false;
355
+ }
356
+
337
357
  /**
338
358
  * Recursively walk the AST to extract `const x = <literal>` as constants.
339
359
  * Skips nodes inside function scopes so only file-level / block-level constants
@@ -363,6 +383,48 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]):
363
383
  }
364
384
  }
365
385
 
386
+ /**
387
+ * Walk the AST to find destructured const bindings (query patterns don't match object_pattern).
388
+ * e.g. `const { handleToken, checkPermissions } = initAuth(config)`
389
+ */
390
+ function extractDestructuredBindingsWalk(node: TreeSitterNode, definitions: Definition[]): void {
391
+ for (let i = 0; i < node.childCount; i++) {
392
+ const child = node.child(i);
393
+ if (!child) continue;
394
+ if (FUNCTION_SCOPE_TYPES.has(child.type)) continue;
395
+
396
+ let declNode = child;
397
+ if (child.type === 'export_statement') {
398
+ const inner = child.childForFieldName('declaration');
399
+ if (inner) declNode = inner;
400
+ }
401
+
402
+ const t = declNode.type;
403
+ if (
404
+ (t === 'lexical_declaration' || t === 'variable_declaration') &&
405
+ declNode.text.startsWith('const ')
406
+ ) {
407
+ for (let j = 0; j < declNode.childCount; j++) {
408
+ const declarator = declNode.child(j);
409
+ if (!declarator || declarator.type !== 'variable_declarator') continue;
410
+ const nameN = declarator.childForFieldName('name');
411
+ if (nameN && nameN.type === 'object_pattern') {
412
+ extractDestructuredBindings(
413
+ nameN,
414
+ declNode.startPosition.row + 1,
415
+ nodeEndLine(declNode),
416
+ definitions,
417
+ );
418
+ }
419
+ }
420
+ }
421
+
422
+ if (child.type !== 'export_statement') {
423
+ extractDestructuredBindingsWalk(child, definitions);
424
+ }
425
+ }
426
+ }
427
+
366
428
  /** Extract constant definitions from a `const` declaration node. */
367
429
  function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definition[]): void {
368
430
  const t = declNode.type;
@@ -637,6 +699,39 @@ function handleTypeAliasDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
637
699
  }
638
700
  }
639
701
 
702
+ /**
703
+ * Extract definitions from destructured object bindings.
704
+ * `const { handleToken, checkPermissions } = initAuth(...)` creates definitions
705
+ * for handleToken and checkPermissions so they can be resolved as call targets.
706
+ */
707
+ function extractDestructuredBindings(
708
+ pattern: TreeSitterNode,
709
+ line: number,
710
+ endLine: number,
711
+ definitions: Definition[],
712
+ ): void {
713
+ for (let i = 0; i < pattern.childCount; i++) {
714
+ const child = pattern.child(i);
715
+ if (!child) continue;
716
+ if (
717
+ child.type === 'shorthand_property_identifier_pattern' ||
718
+ child.type === 'shorthand_property_identifier'
719
+ ) {
720
+ // { handleToken } — shorthand binding
721
+ definitions.push({ name: child.text, kind: 'function', line, endLine });
722
+ } else if (child.type === 'pair_pattern' || child.type === 'pair') {
723
+ // { original: renamed } — renamed binding, use the local alias
724
+ const value = child.childForFieldName('value');
725
+ if (
726
+ value &&
727
+ (value.type === 'identifier' || value.type === 'shorthand_property_identifier_pattern')
728
+ ) {
729
+ definitions.push({ name: value.text, kind: 'function', line, endLine });
730
+ }
731
+ }
732
+ }
733
+ }
734
+
640
735
  function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
641
736
  const isConst = node.text.startsWith('const ');
642
737
  for (let i = 0; i < node.childCount; i++) {
@@ -667,6 +762,20 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
667
762
  line: node.startPosition.row + 1,
668
763
  endLine: nodeEndLine(node),
669
764
  });
765
+ } else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) {
766
+ // Destructured bindings: const { handleToken, checkPermissions } = initAuth(...)
767
+ // Each destructured property becomes a function definition so it can be
768
+ // resolved when passed as a callback (e.g. router.use(handleToken)).
769
+ // Restricted to const to avoid creating spurious definitions for
770
+ // transient let/var destructuring (e.g. let { userId } = parseRequest(req)).
771
+ // Scope guard mirrors extractDestructuredBindingsWalk (query path) and
772
+ // handle_var_decl (Rust path) — skips bindings inside function bodies.
773
+ extractDestructuredBindings(
774
+ nameN,
775
+ node.startPosition.row + 1,
776
+ nodeEndLine(node),
777
+ ctx.definitions,
778
+ );
670
779
  }
671
780
  }
672
781
  }
@@ -715,6 +824,7 @@ function handleCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void {
715
824
  const cbDef = extractCallbackDefinition(node, fn);
716
825
  if (cbDef) ctx.definitions.push(cbDef);
717
826
  }
827
+ ctx.calls.push(...extractCallbackReferenceCalls(node));
718
828
  }
719
829
  }
720
830
 
@@ -1072,7 +1182,11 @@ function handleVarDeclaratorTypeMap(
1072
1182
  const obj = fn.childForFieldName('object');
1073
1183
  if (obj && obj.type === 'identifier') {
1074
1184
  const objName = obj.text;
1075
- if (objName[0]! !== objName[0]!.toLowerCase() && !BUILTIN_GLOBALS.has(objName)) {
1185
+ if (
1186
+ objName[0] &&
1187
+ objName[0] !== objName[0].toLowerCase() &&
1188
+ !BUILTIN_GLOBALS.has(objName)
1189
+ ) {
1076
1190
  setTypeMapEntry(typeMap, nameN.text, objName, 0.7);
1077
1191
  }
1078
1192
  }
@@ -1167,6 +1281,181 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode):
1167
1281
  return null;
1168
1282
  }
1169
1283
 
1284
+ /**
1285
+ * Callee names that idiomatically accept callback references. Used to gate
1286
+ * member_expression args in {@link extractCallbackReferenceCalls}: arguments
1287
+ * like `user.id` are only emitted as dynamic callback calls when the callee
1288
+ * is a known callback-accepting API (router/middleware, promises, array
1289
+ * methods, event emitters, scheduling APIs). This avoids false positives
1290
+ * from plain property reads passed as data, e.g. `store.set(user.id, user)`.
1291
+ *
1292
+ * Identifier args (e.g. `router.use(handleToken)`) are always emitted — the
1293
+ * collateral damage of dropping them is larger than the FP risk, since plain
1294
+ * identifier data args rarely collide with real function names.
1295
+ */
1296
+ const CALLBACK_ACCEPTING_CALLEES: ReadonlySet<string> = new Set([
1297
+ // Express / router / middleware
1298
+ 'use',
1299
+ 'get',
1300
+ 'post',
1301
+ 'put',
1302
+ 'delete',
1303
+ 'patch',
1304
+ 'options',
1305
+ 'head',
1306
+ 'all',
1307
+ // Promises
1308
+ 'then',
1309
+ 'catch',
1310
+ 'finally',
1311
+ // Array iteration / reduction
1312
+ 'map',
1313
+ 'filter',
1314
+ 'forEach',
1315
+ 'find',
1316
+ 'findIndex',
1317
+ 'findLast',
1318
+ 'findLastIndex',
1319
+ 'some',
1320
+ 'every',
1321
+ 'reduce',
1322
+ 'reduceRight',
1323
+ 'flatMap',
1324
+ 'sort',
1325
+ // Event emitters / DOM
1326
+ 'on',
1327
+ 'once',
1328
+ 'off',
1329
+ 'addListener',
1330
+ 'removeListener',
1331
+ 'addEventListener',
1332
+ 'removeEventListener',
1333
+ 'subscribe',
1334
+ 'unsubscribe',
1335
+ // Scheduling / plain function callbacks
1336
+ 'setTimeout',
1337
+ 'setInterval',
1338
+ 'setImmediate',
1339
+ 'queueMicrotask',
1340
+ 'requestAnimationFrame',
1341
+ 'requestIdleCallback',
1342
+ 'nextTick',
1343
+ // Commander / yargs / hooks
1344
+ 'action',
1345
+ 'command',
1346
+ ]);
1347
+
1348
+ /**
1349
+ * HTTP-verb callees that double as Map/cache/repository method names (`get`,
1350
+ * `post`, `put`, `delete`, `patch`, `options`, `head`, `all`). Express/router
1351
+ * invocations always take a string-literal route path as the first argument
1352
+ * (`app.get('/path', handler)`), whereas Map-like APIs pass values/keys
1353
+ * (`cache.get(user.id)`). Requiring a string-literal first arg keeps real
1354
+ * route handlers covered while dropping the Map/cache false-positive surface.
1355
+ *
1356
+ * `use` and `all` without a path are legitimate middleware registrations, so
1357
+ * `use` is intentionally excluded here — it stays in the general allowlist.
1358
+ */
1359
+ const HTTP_VERB_CALLEES: ReadonlySet<string> = new Set([
1360
+ 'get',
1361
+ 'post',
1362
+ 'put',
1363
+ 'delete',
1364
+ 'patch',
1365
+ 'options',
1366
+ 'head',
1367
+ 'all',
1368
+ ]);
1369
+
1370
+ /**
1371
+ * Extract the callee's final name (function identifier or member expression
1372
+ * property) for callback-eligibility filtering. Returns null if the callee
1373
+ * shape is not analyzable (e.g. computed subscripts, IIFEs).
1374
+ *
1375
+ * Optional-chaining (`obj?.method(...)`) is handled transparently: in both
1376
+ * tree-sitter-javascript and tree-sitter-typescript grammars `obj?.method` is
1377
+ * still a `member_expression` (the `?.` appears as an `optional_chain` child),
1378
+ * so the property extraction below returns `method` as expected.
1379
+ */
1380
+ function extractCalleeName(callNode: TreeSitterNode): string | null {
1381
+ const fn = callNode.childForFieldName('function');
1382
+ if (!fn) return null;
1383
+ if (fn.type === 'identifier') return fn.text;
1384
+ if (fn.type === 'member_expression') {
1385
+ const prop = fn.childForFieldName('property');
1386
+ return prop ? prop.text : null;
1387
+ }
1388
+ return null;
1389
+ }
1390
+
1391
+ /**
1392
+ * True iff the first argument of an arguments node is a string literal.
1393
+ * Used to distinguish Express/router route handlers (`app.get('/path', h)`)
1394
+ * from Map/cache APIs that reuse the same verb names (`cache.get(user.id)`).
1395
+ */
1396
+ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean {
1397
+ for (let i = 0; i < argsNode.childCount; i++) {
1398
+ const child = argsNode.child(i);
1399
+ if (!child) continue;
1400
+ // Skip parens and commas; the first non-punctuation child is the first arg.
1401
+ if (child.type === '(' || child.type === ',' || child.type === ')') continue;
1402
+ return child.type === 'string' || child.type === 'template_string';
1403
+ }
1404
+ return false;
1405
+ }
1406
+
1407
+ /**
1408
+ * Extract Call entries for named function references passed as arguments.
1409
+ * e.g. `router.use(handleToken, checkAuth)` yields calls to handleToken and checkAuth.
1410
+ * `app.use(auth.validate)` yields a call to validate with receiver auth.
1411
+ * Skips literals, objects, arrays, anonymous functions, and call expressions (already handled).
1412
+ *
1413
+ * To avoid false positives where plain property reads are passed as data
1414
+ * (e.g. `store.set(user.id, user)` — `user.id` is a value, not a callback),
1415
+ * member_expression args are only emitted when the callee is in
1416
+ * {@link CALLBACK_ACCEPTING_CALLEES}. Identifier args are always emitted.
1417
+ *
1418
+ * HTTP-verb callees (`get`, `post`, `put`, `delete`, `patch`, `options`,
1419
+ * `head`, `all`) double as Map/cache/repository method names, so their
1420
+ * member-expr args are only emitted when the first argument is a string
1421
+ * literal route path — matching Express/router shape and skipping
1422
+ * `cache.get(user.id)`-style calls.
1423
+ */
1424
+ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
1425
+ const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
1426
+ if (!args) return [];
1427
+
1428
+ const calleeName = extractCalleeName(callNode);
1429
+ let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName);
1430
+ if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) {
1431
+ // HTTP verbs require a string-literal route path to be treated as a
1432
+ // callback-accepting API; otherwise `cache.get(user.id)` etc. would
1433
+ // still emit `id` as a dynamic call.
1434
+ memberExprArgsAllowed = firstArgIsStringLiteral(args);
1435
+ }
1436
+
1437
+ const result: Call[] = [];
1438
+ const callLine = callNode.startPosition.row + 1;
1439
+
1440
+ for (let i = 0; i < args.childCount; i++) {
1441
+ const child = args.child(i);
1442
+ if (!child) continue;
1443
+
1444
+ if (child.type === 'identifier') {
1445
+ result.push({ name: child.text, line: callLine, dynamic: true });
1446
+ } else if (child.type === 'member_expression' && memberExprArgsAllowed) {
1447
+ const prop = child.childForFieldName('property');
1448
+ const obj = child.childForFieldName('object');
1449
+ if (prop) {
1450
+ const receiver = extractReceiverName(obj);
1451
+ result.push({ name: prop.text, line: callLine, dynamic: true, receiver });
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ return result;
1457
+ }
1458
+
1170
1459
  function findAnonymousCallback(argsNode: TreeSitterNode): TreeSitterNode | null {
1171
1460
  for (let i = 0; i < argsNode.childCount; i++) {
1172
1461
  const child = argsNode.child(i);
@@ -1,34 +1,9 @@
1
1
  import { isTestFile } from '../infrastructure/test-filter.js';
2
2
  import { BoundaryError } from '../shared/errors.js';
3
+ import { globToRegex } from '../shared/globs.js';
3
4
  import type { BetterSqlite3Database } from '../types.js';
4
5
 
5
- // ─── Glob-to-Regex ───────────────────────────────────────────────────
6
-
7
- export function globToRegex(pattern: string): RegExp {
8
- let re = '';
9
- let i = 0;
10
- while (i < pattern.length) {
11
- const ch = pattern[i] as string;
12
- if (ch === '*' && pattern[i + 1] === '*') {
13
- re += '.*';
14
- i += 2;
15
- if (pattern[i] === '/') i++;
16
- } else if (ch === '*') {
17
- re += '[^/]*';
18
- i++;
19
- } else if (ch === '?') {
20
- re += '[^/]';
21
- i++;
22
- } else if (/[.+^${}()|[\]\\]/.test(ch)) {
23
- re += `\\${ch}`;
24
- i++;
25
- } else {
26
- re += ch;
27
- i++;
28
- }
29
- }
30
- return new RegExp(`^${re}$`);
31
- }
6
+ export { globToRegex };
32
7
 
33
8
  // ─── Presets ─────────────────────────────────────────────────────────
34
9
 
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { getDatabase } from '../db/better-sqlite3.js';
@@ -37,24 +38,77 @@ export function snapshotSave(
37
38
  const dir = snapshotsDir(dbPath);
38
39
  const dest = path.join(dir, `${name}.db`);
39
40
 
40
- if (fs.existsSync(dest)) {
41
- if (!options.force) {
42
- throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
43
- }
44
- fs.unlinkSync(dest);
45
- debug(`Deleted existing snapshot: ${dest}`);
41
+ // Cheap fail-fast for the common non-force case; the authoritative check
42
+ // below uses an atomic linkSync that closes the TOCTOU window.
43
+ if (!options.force && fs.existsSync(dest)) {
44
+ throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
46
45
  }
47
46
 
48
47
  fs.mkdirSync(dir, { recursive: true });
49
48
 
49
+ // VACUUM INTO a unique temp path on the same filesystem, then atomically
50
+ // place it at the destination. This closes the TOCTOU window between
51
+ // existsSync/unlinkSync/VACUUM INTO where two concurrent saves could
52
+ // observe a missing file or interleave their VACUUM writes.
53
+ //
54
+ // Unique temp name: process.pid is shared across worker_threads in the
55
+ // same process, so we add random bytes to keep concurrent callers in any
56
+ // thread from colliding on the temp path.
57
+ const tmp = path.join(
58
+ dir,
59
+ `.${name}.db.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`,
60
+ );
61
+ try {
62
+ fs.unlinkSync(tmp);
63
+ } catch (err) {
64
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
65
+ }
66
+
50
67
  const Database = getDatabase();
51
68
  const db = new Database(dbPath, { readonly: true });
52
69
  try {
53
- db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
70
+ db.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
54
71
  } finally {
55
72
  db.close();
56
73
  }
57
74
 
75
+ try {
76
+ if (options.force) {
77
+ // renameSync overwrites atomically — the correct semantics for --force.
78
+ fs.renameSync(tmp, dest);
79
+ } else {
80
+ // Non-force path: linkSync fails atomically with EEXIST if dest exists,
81
+ // closing the TOCTOU window between existsSync above and the final
82
+ // placement. We then unlink the temp file; on POSIX and NTFS, link
83
+ // creates a second reference so tmp can safely be removed.
84
+ try {
85
+ fs.linkSync(tmp, dest);
86
+ } catch (err) {
87
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
88
+ throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
89
+ }
90
+ throw err;
91
+ }
92
+ try {
93
+ fs.unlinkSync(tmp);
94
+ } catch (cleanupErr) {
95
+ // Best-effort — dest is already in place, so a leftover tmp file is
96
+ // harmless. Log at debug so repeated failures surface during
97
+ // troubleshooting without noising up normal operation.
98
+ debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
99
+ }
100
+ }
101
+ } catch (err) {
102
+ try {
103
+ fs.unlinkSync(tmp);
104
+ } catch (cleanupErr) {
105
+ if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
106
+ debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
107
+ }
108
+ }
109
+ throw err;
110
+ }
111
+
58
112
  const stat = fs.statSync(dest);
59
113
  debug(`Snapshot saved: ${dest} (${stat.size} bytes)`);
60
114
  return { name, path: dest, size: stat.size };
@@ -74,16 +128,38 @@ export function snapshotRestore(name: string, options: SnapshotDbPathOptions = {
74
128
  throw new DbError(`Snapshot "${name}" not found at ${src}`, { file: src });
75
129
  }
76
130
 
77
- // Remove WAL/SHM sidecar files for a clean restore
131
+ // Remove WAL/SHM sidecars first so the old journal can't be replayed over
132
+ // the restored DB. unlink then check ENOENT — avoids the existsSync/unlinkSync
133
+ // race another process could wedge into.
78
134
  for (const suffix of ['-wal', '-shm']) {
79
135
  const sidecar = dbPath + suffix;
80
- if (fs.existsSync(sidecar)) {
136
+ try {
81
137
  fs.unlinkSync(sidecar);
82
138
  debug(`Removed sidecar: ${sidecar}`);
139
+ } catch (err) {
140
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
141
+ }
142
+ }
143
+
144
+ // Copy to a temp path next to the DB, then rename atomically. Readers that
145
+ // open dbPath during restore see either the pre-restore or post-restore
146
+ // file, never a partially-written one.
147
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
148
+ const tmp = `${dbPath}.restore-tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
149
+ try {
150
+ fs.copyFileSync(src, tmp);
151
+ fs.renameSync(tmp, dbPath);
152
+ } catch (err) {
153
+ try {
154
+ fs.unlinkSync(tmp);
155
+ } catch (cleanupErr) {
156
+ if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
157
+ debug(`snapshotRestore: failed to remove temp file ${tmp}: ${cleanupErr}`);
158
+ }
83
159
  }
160
+ throw err;
84
161
  }
85
162
 
86
- fs.copyFileSync(src, dbPath);
87
163
  debug(`Restored snapshot "${name}" → ${dbPath}`);
88
164
  }
89
165
 
@@ -122,10 +198,13 @@ export function snapshotDelete(name: string, options: SnapshotDbPathOptions = {}
122
198
  const dir = snapshotsDir(dbPath);
123
199
  const target = path.join(dir, `${name}.db`);
124
200
 
125
- if (!fs.existsSync(target)) {
126
- throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
201
+ try {
202
+ fs.unlinkSync(target);
203
+ } catch (err) {
204
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
205
+ throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
206
+ }
207
+ throw err;
127
208
  }
128
-
129
- fs.unlinkSync(target);
130
209
  debug(`Deleted snapshot: ${target}`);
131
210
  }
@@ -166,6 +166,22 @@ function computeFileMetrics(
166
166
  fanOutMap: Map<string, number>,
167
167
  ): void {
168
168
  db.transaction(() => {
169
+ // Batch-load import counts per file (distinct imported files,
170
+ // matching the fast-path semantics in updateChangedFileMetrics).
171
+ // Runs inside the transaction for parity with the Rust path.
172
+ const importCountMap = new Map<string, number>();
173
+ for (const row of db
174
+ .prepare(
175
+ `SELECT n1.file AS src, COUNT(DISTINCT n2.file) AS cnt FROM edges e
176
+ JOIN nodes n1 ON e.source_id = n1.id
177
+ JOIN nodes n2 ON e.target_id = n2.id
178
+ WHERE e.kind = 'imports'
179
+ GROUP BY n1.file`,
180
+ )
181
+ .all() as { src: string; cnt: number }[]) {
182
+ importCountMap.set(row.src, row.cnt);
183
+ }
184
+
169
185
  for (const [relPath, symbols] of fileSymbols) {
170
186
  const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
171
187
  if (!fileRow) continue;
@@ -180,7 +196,7 @@ function computeFileMetrics(
180
196
  symbolCount++;
181
197
  }
182
198
  }
183
- const importCount = symbols.imports.length;
199
+ const importCount = importCountMap.get(relPath) || 0;
184
200
  const exportCount = symbols.exports.length;
185
201
  const fanIn = fanInMap.get(relPath) || 0;
186
202
  const fanOut = fanOutMap.get(relPath) || 0;
@@ -6,7 +6,7 @@
6
6
  * JS fallback: Leiden algorithm via `detectClusters` (always undirected, `directed: false`).
7
7
  */
8
8
 
9
- import { warn } from '../../infrastructure/logger.js';
9
+ import { debug } from '../../infrastructure/logger.js';
10
10
  import { loadNative } from '../../infrastructure/native.js';
11
11
  import type { CodeGraph } from '../model.js';
12
12
  import type { DetectClustersResult } from './leiden/index.js';
@@ -36,10 +36,8 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}):
36
36
 
37
37
  const native = loadNative();
38
38
  if (native?.louvainCommunities) {
39
- // maxLevels, maxLocalPasses, and refinementTheta are Leiden-specific tuning knobs
40
- // not supported by the Rust Louvain implementation. Warn callers who set them.
41
39
  if (opts.maxLevels != null || opts.maxLocalPasses != null || opts.refinementTheta != null) {
42
- warn(
40
+ debug(
43
41
  'louvainCommunities: maxLevels/maxLocalPasses/refinementTheta are ignored by the native Rust path',
44
42
  );
45
43
  }
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { toErrorMessage } from '../shared/errors.js';
4
+ import { ConfigError, toErrorMessage } from '../shared/errors.js';
5
5
  import type { CodegraphConfig } from '../types.js';
6
6
  import { debug, warn } from './logger.js';
7
7
 
@@ -179,6 +179,7 @@ export function loadConfig(cwd?: string): CodegraphConfig {
179
179
  _configCache.set(cwd, structuredClone(result));
180
180
  return result;
181
181
  } catch (err: unknown) {
182
+ if (err instanceof ConfigError) throw err;
182
183
  debug(`Failed to parse config ${filePath}: ${toErrorMessage(err)}`);
183
184
  }
184
185
  }
@@ -215,7 +216,16 @@ export function applyEnvOverrides(config: CodegraphConfig): CodegraphConfig {
215
216
 
216
217
  export function resolveSecrets(config: CodegraphConfig): CodegraphConfig {
217
218
  const cmd = config.llm.apiKeyCommand;
218
- if (typeof cmd !== 'string' || cmd.trim() === '') return config;
219
+ if (cmd == null) return config;
220
+ if (typeof cmd !== 'string') {
221
+ const actual = Array.isArray(cmd) ? 'array' : typeof cmd;
222
+ throw new ConfigError(
223
+ `llm.apiKeyCommand must be a string (received ${actual}). ` +
224
+ 'The command is split on whitespace and executed without a shell. ' +
225
+ 'Example: "apiKeyCommand": "op read op://vault/openai/api-key"',
226
+ );
227
+ }
228
+ if (cmd.trim() === '') return config;
219
229
 
220
230
  const parts = cmd.trim().split(/\s+/);
221
231
  const [executable, ...args] = parts;