@optave/codegraph 3.8.1 → 3.9.1

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 (132) hide show
  1. package/README.md +12 -7
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +121 -48
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  6. package/dist/ast-analysis/visitors/ast-store-visitor.js +15 -18
  7. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  8. package/dist/ast-analysis/visitors/complexity-visitor.d.ts.map +1 -1
  9. package/dist/ast-analysis/visitors/complexity-visitor.js +50 -1
  10. package/dist/ast-analysis/visitors/complexity-visitor.js.map +1 -1
  11. package/dist/cli/commands/branch-compare.d.ts.map +1 -1
  12. package/dist/cli/commands/branch-compare.js +4 -0
  13. package/dist/cli/commands/branch-compare.js.map +1 -1
  14. package/dist/cli/commands/diff-impact.d.ts.map +1 -1
  15. package/dist/cli/commands/diff-impact.js +2 -1
  16. package/dist/cli/commands/diff-impact.js.map +1 -1
  17. package/dist/cli/commands/info.d.ts.map +1 -1
  18. package/dist/cli/commands/info.js +3 -2
  19. package/dist/cli/commands/info.js.map +1 -1
  20. package/dist/db/connection.d.ts +1 -0
  21. package/dist/db/connection.d.ts.map +1 -1
  22. package/dist/db/connection.js +22 -4
  23. package/dist/db/connection.js.map +1 -1
  24. package/dist/db/repository/base.d.ts +41 -0
  25. package/dist/db/repository/base.d.ts.map +1 -1
  26. package/dist/db/repository/base.js +22 -0
  27. package/dist/db/repository/base.js.map +1 -1
  28. package/dist/db/repository/index.d.ts +1 -0
  29. package/dist/db/repository/index.d.ts.map +1 -1
  30. package/dist/db/repository/index.js.map +1 -1
  31. package/dist/db/repository/native-repository.d.ts +8 -1
  32. package/dist/db/repository/native-repository.d.ts.map +1 -1
  33. package/dist/db/repository/native-repository.js +69 -1
  34. package/dist/db/repository/native-repository.js.map +1 -1
  35. package/dist/db/repository/sqlite-repository.d.ts +1 -0
  36. package/dist/db/repository/sqlite-repository.d.ts.map +1 -1
  37. package/dist/db/repository/sqlite-repository.js +25 -0
  38. package/dist/db/repository/sqlite-repository.js.map +1 -1
  39. package/dist/domain/analysis/dependencies.d.ts +1 -28
  40. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  41. package/dist/domain/analysis/dependencies.js +24 -8
  42. package/dist/domain/analysis/dependencies.js.map +1 -1
  43. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/incremental.js +18 -0
  45. package/dist/domain/graph/builder/incremental.js.map +1 -1
  46. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  47. package/dist/domain/graph/builder/pipeline.js +298 -206
  48. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  49. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/stages/build-edges.js +56 -3
  51. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/resolve-imports.js +19 -23
  54. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  55. package/dist/domain/graph/watcher.d.ts.map +1 -1
  56. package/dist/domain/graph/watcher.js +99 -95
  57. package/dist/domain/graph/watcher.js.map +1 -1
  58. package/dist/domain/parser.d.ts +4 -0
  59. package/dist/domain/parser.d.ts.map +1 -1
  60. package/dist/domain/parser.js +130 -61
  61. package/dist/domain/parser.js.map +1 -1
  62. package/dist/domain/search/models.d.ts.map +1 -1
  63. package/dist/domain/search/models.js +7 -5
  64. package/dist/domain/search/models.js.map +1 -1
  65. package/dist/extractors/go.js +53 -35
  66. package/dist/extractors/go.js.map +1 -1
  67. package/dist/extractors/javascript.js +85 -36
  68. package/dist/extractors/javascript.js.map +1 -1
  69. package/dist/features/complexity.d.ts.map +1 -1
  70. package/dist/features/complexity.js +78 -58
  71. package/dist/features/complexity.js.map +1 -1
  72. package/dist/features/dataflow.d.ts.map +1 -1
  73. package/dist/features/dataflow.js +109 -118
  74. package/dist/features/dataflow.js.map +1 -1
  75. package/dist/features/structure.d.ts.map +1 -1
  76. package/dist/features/structure.js +147 -97
  77. package/dist/features/structure.js.map +1 -1
  78. package/dist/graph/algorithms/louvain.d.ts.map +1 -1
  79. package/dist/graph/algorithms/louvain.js +4 -2
  80. package/dist/graph/algorithms/louvain.js.map +1 -1
  81. package/dist/graph/classifiers/roles.d.ts +2 -0
  82. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  83. package/dist/graph/classifiers/roles.js +13 -5
  84. package/dist/graph/classifiers/roles.js.map +1 -1
  85. package/dist/presentation/communities.d.ts.map +1 -1
  86. package/dist/presentation/communities.js +38 -34
  87. package/dist/presentation/communities.js.map +1 -1
  88. package/dist/presentation/manifesto.d.ts.map +1 -1
  89. package/dist/presentation/manifesto.js +31 -33
  90. package/dist/presentation/manifesto.js.map +1 -1
  91. package/dist/presentation/queries-cli/inspect.d.ts.map +1 -1
  92. package/dist/presentation/queries-cli/inspect.js +47 -46
  93. package/dist/presentation/queries-cli/inspect.js.map +1 -1
  94. package/dist/shared/file-utils.d.ts.map +1 -1
  95. package/dist/shared/file-utils.js +94 -72
  96. package/dist/shared/file-utils.js.map +1 -1
  97. package/dist/types.d.ts +83 -2
  98. package/dist/types.d.ts.map +1 -1
  99. package/grammars/tree-sitter-erlang.wasm +0 -0
  100. package/grammars/tree-sitter-gleam.wasm +0 -0
  101. package/package.json +9 -9
  102. package/src/ast-analysis/engine.ts +150 -55
  103. package/src/ast-analysis/visitors/ast-store-visitor.ts +19 -21
  104. package/src/ast-analysis/visitors/complexity-visitor.ts +55 -1
  105. package/src/cli/commands/branch-compare.ts +4 -0
  106. package/src/cli/commands/diff-impact.ts +2 -1
  107. package/src/cli/commands/info.ts +3 -2
  108. package/src/db/connection.ts +24 -5
  109. package/src/db/repository/base.ts +57 -0
  110. package/src/db/repository/index.ts +1 -0
  111. package/src/db/repository/native-repository.ts +92 -1
  112. package/src/db/repository/sqlite-repository.ts +26 -0
  113. package/src/domain/analysis/dependencies.ts +24 -6
  114. package/src/domain/graph/builder/incremental.ts +21 -0
  115. package/src/domain/graph/builder/pipeline.ts +396 -245
  116. package/src/domain/graph/builder/stages/build-edges.ts +53 -2
  117. package/src/domain/graph/builder/stages/resolve-imports.ts +20 -20
  118. package/src/domain/graph/watcher.ts +118 -98
  119. package/src/domain/parser.ts +131 -63
  120. package/src/domain/search/models.ts +11 -5
  121. package/src/extractors/go.ts +57 -32
  122. package/src/extractors/javascript.ts +88 -35
  123. package/src/features/complexity.ts +94 -58
  124. package/src/features/dataflow.ts +153 -132
  125. package/src/features/structure.ts +167 -95
  126. package/src/graph/algorithms/louvain.ts +5 -2
  127. package/src/graph/classifiers/roles.ts +14 -5
  128. package/src/presentation/communities.ts +44 -39
  129. package/src/presentation/manifesto.ts +35 -38
  130. package/src/presentation/queries-cli/inspect.ts +48 -46
  131. package/src/shared/file-utils.ts +116 -77
  132. package/src/types.ts +87 -1
@@ -215,25 +215,37 @@ function runNativeAnalysis(
215
215
  }
216
216
  }
217
217
 
218
+ /** Index native results by line number and match to a definition by name. */
219
+ function indexNativeByLine<T extends { line: number; name: string }>(
220
+ results: T[],
221
+ ): Map<number, T[]> {
222
+ const byLine = new Map<number, T[]>();
223
+ for (const r of results) {
224
+ if (!byLine.has(r.line)) byLine.set(r.line, []);
225
+ byLine.get(r.line)!.push(r);
226
+ }
227
+ return byLine;
228
+ }
229
+
230
+ function matchNativeResult<T extends { name: string }>(
231
+ candidates: T[] | undefined,
232
+ defName: string,
233
+ ): T | undefined {
234
+ if (!candidates) return undefined;
235
+ if (candidates.length === 1) return candidates[0];
236
+ return candidates.find((r) => r.name === defName) ?? candidates[0];
237
+ }
238
+
218
239
  /** Store native complexity results on definitions, matched by line number. */
219
240
  function storeNativeComplexityResults(
220
241
  results: NativeFunctionComplexityResult[],
221
242
  defs: Definition[],
222
243
  ): void {
223
- const byLine = new Map<number, NativeFunctionComplexityResult[]>();
224
- for (const r of results) {
225
- if (!byLine.has(r.line)) byLine.set(r.line, []);
226
- byLine.get(r.line)!.push(r);
227
- }
244
+ const byLine = indexNativeByLine(results);
228
245
 
229
246
  for (const def of defs) {
230
247
  if ((def.kind === 'function' || def.kind === 'method') && def.line && !def.complexity) {
231
- const candidates = byLine.get(def.line);
232
- if (!candidates) continue;
233
- const match =
234
- candidates.length === 1
235
- ? candidates[0]
236
- : (candidates.find((r) => r.name === def.name) ?? candidates[0]);
248
+ const match = matchNativeResult(byLine.get(def.line), def.name);
237
249
  if (!match) continue;
238
250
  const { complexity: c } = match;
239
251
  def.complexity = {
@@ -242,6 +254,12 @@ function storeNativeComplexityResults(
242
254
  maxNesting: c.maxNesting,
243
255
  halstead: c.halstead
244
256
  ? {
257
+ n1: c.halstead.n1,
258
+ n2: c.halstead.n2,
259
+ bigN1: c.halstead.bigN1,
260
+ bigN2: c.halstead.bigN2,
261
+ vocabulary: c.halstead.vocabulary,
262
+ length: c.halstead.length,
245
263
  volume: c.halstead.volume,
246
264
  difficulty: c.halstead.difficulty,
247
265
  effort: c.halstead.effort,
@@ -278,11 +296,7 @@ function overrideCyclomaticFromCfg(def: Definition, cfgCyclomatic: number): void
278
296
 
279
297
  /** Store native CFG results on definitions, matched by line number. */
280
298
  function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definition[]): void {
281
- const byLine = new Map<number, NativeFunctionCfgResult[]>();
282
- for (const r of results) {
283
- if (!byLine.has(r.line)) byLine.set(r.line, []);
284
- byLine.get(r.line)!.push(r);
285
- }
299
+ const byLine = indexNativeByLine(results);
286
300
 
287
301
  for (const def of defs) {
288
302
  if (
@@ -291,12 +305,7 @@ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definit
291
305
  def.cfg !== null &&
292
306
  !def.cfg?.blocks?.length
293
307
  ) {
294
- const candidates = byLine.get(def.line);
295
- if (!candidates) continue;
296
- const match =
297
- candidates.length === 1
298
- ? candidates[0]
299
- : (candidates.find((r) => r.name === def.name) ?? candidates[0]);
308
+ const match = matchNativeResult(byLine.get(def.line), def.name);
300
309
  if (!match) continue;
301
310
  def.cfg = match.cfg;
302
311
 
@@ -309,44 +318,99 @@ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definit
309
318
  }
310
319
  }
311
320
 
321
+ // ─── CFG cyclomatic reconciliation ──────────────────────────────────────
322
+
323
+ /**
324
+ * Apply CFG-derived cyclomatic override for definitions that already have both
325
+ * `complexity` and `cfg` with blocks/edges but whose cyclomatic was never
326
+ * overridden (e.g., native extractors provide both fields inline, so the
327
+ * normal override path in storeNativeCfgResults / storeCfgResults is skipped).
328
+ */
329
+ /** Type guard for cfg objects with blocks and edges arrays. */
330
+ function hasCfgBlocksAndEdges(cfg: unknown): cfg is { blocks: unknown[]; edges: unknown[] } {
331
+ return (
332
+ cfg != null &&
333
+ typeof cfg === 'object' &&
334
+ Array.isArray((cfg as { blocks?: unknown }).blocks) &&
335
+ Array.isArray((cfg as { edges?: unknown }).edges)
336
+ );
337
+ }
338
+
339
+ function reconcileCfgCyclomatic(fileSymbols: Map<string, ExtractorOutput>): void {
340
+ for (const [, symbols] of fileSymbols) {
341
+ const defs = symbols.definitions || [];
342
+ for (const def of defs) {
343
+ if (
344
+ (def.kind === 'function' || def.kind === 'method') &&
345
+ def.complexity &&
346
+ hasCfgBlocksAndEdges(def.cfg)
347
+ ) {
348
+ const cfgCyclomatic = Math.max(def.cfg.edges.length - def.cfg.blocks.length + 2, 1);
349
+ if (cfgCyclomatic !== def.complexity.cyclomatic) {
350
+ overrideCyclomaticFromCfg(def, cfgCyclomatic);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
312
357
  // ─── WASM pre-parse ─────────────────────────────────────────────────────
313
358
 
359
+ /** Check whether a single file needs a WASM tree for any enabled analysis pass. */
360
+ function fileNeedsWasmTree(
361
+ relPath: string,
362
+ symbols: ExtractorOutput,
363
+ flags: { doAst: boolean; doComplexity: boolean; doCfg: boolean; doDataflow: boolean },
364
+ ): boolean {
365
+ if (symbols._tree) return false;
366
+ const ext = path.extname(relPath).toLowerCase();
367
+ const defs = symbols.definitions || [];
368
+ const lid = symbols._langId || '';
369
+
370
+ if (
371
+ flags.doAst &&
372
+ !Array.isArray(symbols.astNodes) &&
373
+ (WALK_EXTENSIONS.has(ext) || AST_TYPE_MAPS.has(lid))
374
+ )
375
+ return true;
376
+ if (
377
+ flags.doComplexity &&
378
+ (COMPLEXITY_EXTENSIONS.has(ext) || COMPLEXITY_RULES.has(lid)) &&
379
+ defs.some((d) => hasFuncBody(d) && !d.complexity)
380
+ )
381
+ return true;
382
+ if (
383
+ flags.doCfg &&
384
+ (CFG_EXTENSIONS.has(ext) || CFG_RULES.has(lid)) &&
385
+ defs.some((d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks))
386
+ )
387
+ return true;
388
+ if (
389
+ flags.doDataflow &&
390
+ !symbols.dataflow &&
391
+ (DATAFLOW_EXTENSIONS.has(ext) || DATAFLOW_RULES.has(lid))
392
+ )
393
+ return true;
394
+ return false;
395
+ }
396
+
314
397
  async function ensureWasmTreesIfNeeded(
315
398
  fileSymbols: Map<string, ExtractorOutput>,
316
399
  opts: AnalysisOpts,
317
400
  rootDir: string,
318
401
  ): Promise<void> {
319
- const doAst = opts.ast !== false;
320
- const doComplexity = opts.complexity !== false;
321
- const doCfg = opts.cfg !== false;
322
- const doDataflow = opts.dataflow !== false;
402
+ const flags = {
403
+ doAst: opts.ast !== false,
404
+ doComplexity: opts.complexity !== false,
405
+ doCfg: opts.cfg !== false,
406
+ doDataflow: opts.dataflow !== false,
407
+ };
323
408
 
324
- if (!doAst && !doComplexity && !doCfg && !doDataflow) return;
409
+ if (!flags.doAst && !flags.doComplexity && !flags.doCfg && !flags.doDataflow) return;
325
410
 
326
411
  let needsWasmTrees = false;
327
412
  for (const [relPath, symbols] of fileSymbols) {
328
- if (symbols._tree) continue;
329
- const ext = path.extname(relPath).toLowerCase();
330
- const defs = symbols.definitions || [];
331
-
332
- // AST: need tree when native didn't provide non-call astNodes
333
- const lid = symbols._langId || '';
334
- const needsAst =
335
- doAst &&
336
- !Array.isArray(symbols.astNodes) &&
337
- (WALK_EXTENSIONS.has(ext) || AST_TYPE_MAPS.has(lid));
338
- const needsComplexity =
339
- doComplexity &&
340
- (COMPLEXITY_EXTENSIONS.has(ext) || COMPLEXITY_RULES.has(lid)) &&
341
- defs.some((d) => hasFuncBody(d) && !d.complexity);
342
- const needsCfg =
343
- doCfg &&
344
- (CFG_EXTENSIONS.has(ext) || CFG_RULES.has(lid)) &&
345
- defs.some((d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks));
346
- const needsDataflow =
347
- doDataflow && !symbols.dataflow && (DATAFLOW_EXTENSIONS.has(ext) || DATAFLOW_RULES.has(lid));
348
-
349
- if (needsAst || needsComplexity || needsCfg || needsDataflow) {
413
+ if (fileNeedsWasmTree(relPath, symbols, flags)) {
350
414
  needsWasmTrees = true;
351
415
  break;
352
416
  }
@@ -565,7 +629,7 @@ async function delegateToBuildFunctions(
565
629
  } catch (err: unknown) {
566
630
  debug(`buildAstNodes failed: ${toErrorMessage(err)}`);
567
631
  }
568
- timing.astMs = performance.now() - t0;
632
+ timing.astMs += performance.now() - t0;
569
633
  }
570
634
 
571
635
  if (opts.complexity !== false) {
@@ -576,7 +640,7 @@ async function delegateToBuildFunctions(
576
640
  } catch (err: unknown) {
577
641
  debug(`buildComplexityMetrics failed: ${toErrorMessage(err)}`);
578
642
  }
579
- timing.complexityMs = performance.now() - t0;
643
+ timing.complexityMs += performance.now() - t0;
580
644
  }
581
645
 
582
646
  if (opts.cfg !== false) {
@@ -587,7 +651,7 @@ async function delegateToBuildFunctions(
587
651
  } catch (err: unknown) {
588
652
  debug(`buildCFGData failed: ${toErrorMessage(err)}`);
589
653
  }
590
- timing.cfgMs = performance.now() - t0;
654
+ timing.cfgMs += performance.now() - t0;
591
655
  }
592
656
 
593
657
  if (opts.dataflow !== false) {
@@ -598,7 +662,7 @@ async function delegateToBuildFunctions(
598
662
  } catch (err: unknown) {
599
663
  debug(`buildDataflowEdges failed: ${toErrorMessage(err)}`);
600
664
  }
601
- timing.dataflowMs = performance.now() - t0;
665
+ timing.dataflowMs += performance.now() - t0;
602
666
  }
603
667
  }
604
668
 
@@ -626,7 +690,7 @@ export async function runAnalyses(
626
690
  // This fills in complexity/CFG/dataflow for files that the native parse pipeline
627
691
  // missed, avoiding the need to parse with WASM + run JS visitors.
628
692
  const native = loadNative();
629
- if (native?.analyzeComplexity ?? native?.buildCfgAnalysis ?? native?.extractDataflowAnalysis) {
693
+ if (native?.analyzeComplexity || native?.buildCfgAnalysis || native?.extractDataflowAnalysis) {
630
694
  const t0native = performance.now();
631
695
  runNativeAnalysis(native, fileSymbols, rootDir, opts, extToLang);
632
696
  debug(`native standalone analysis: ${(performance.now() - t0native).toFixed(1)}ms`);
@@ -635,7 +699,10 @@ export async function runAnalyses(
635
699
  // WASM pre-parse for files that still need it (AST store, or native gaps)
636
700
  await ensureWasmTreesIfNeeded(fileSymbols, opts, rootDir);
637
701
 
638
- // Unified pre-walk: run all applicable visitors in a single DFS per file
702
+ // Unified pre-walk: run all applicable visitors in a single DFS per file.
703
+ // Time each file's walk and distribute equally among active visitors
704
+ // so that phase timers (astMs, complexityMs, etc.) reflect real work — not
705
+ // just the DB-write tail in delegateToBuildFunctions.
639
706
  const t0walk = performance.now();
640
707
 
641
708
  for (const [relPath, symbols] of fileSymbols) {
@@ -650,7 +717,22 @@ export async function runAnalyses(
650
717
 
651
718
  if (visitors.length === 0) continue;
652
719
 
720
+ const walkStart = performance.now();
653
721
  const results = walkWithVisitors(symbols._tree.rootNode, visitors, langId, walkerOpts);
722
+ const walkMs = performance.now() - walkStart;
723
+
724
+ // Distribute walk time equally among active visitors
725
+ const activeCount = [astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor].filter(
726
+ Boolean,
727
+ ).length;
728
+ if (activeCount > 0) {
729
+ const share = walkMs / activeCount;
730
+ if (astVisitor) timing.astMs += share;
731
+ if (complexityVisitor) timing.complexityMs += share;
732
+ if (cfgVisitor) timing.cfgMs += share;
733
+ if (dataflowVisitor) timing.dataflowMs += share;
734
+ }
735
+
654
736
  const defs = symbols.definitions || [];
655
737
 
656
738
  if (astVisitor) {
@@ -663,8 +745,21 @@ export async function runAnalyses(
663
745
  if (dataflowVisitor) symbols.dataflow = results.dataflow as DataflowResult;
664
746
  }
665
747
 
748
+ // Total wall-clock time for the unified walk loop, including per-file
749
+ // setupVisitors overhead. Walk time is already distributed into per-phase
750
+ // timers above, so this field overlaps with (astMs + complexityMs + ...).
751
+ // It is kept as a diagnostic cross-check, not an additive bucket.
666
752
  timing._unifiedWalkMs = performance.now() - t0walk;
667
753
 
754
+ // Reconcile: apply CFG-derived cyclomatic override for any definitions that have
755
+ // both precomputed complexity and CFG data but whose cyclomatic was never overridden.
756
+ // This closes a parity gap where native extractors provide both fields inline but
757
+ // the override step (storeNativeCfgResults / storeCfgResults) is skipped because
758
+ // detectNativeNeeds sees both as already present.
759
+ if (doComplexity && doCfg) {
760
+ reconcileCfgCyclomatic(fileSymbols);
761
+ }
762
+
668
763
  // Delegate to buildXxx functions for DB writes + native fallback
669
764
  await delegateToBuildFunctions(db, fileSymbols, rootDir, opts, engineOpts, timing);
670
765
 
@@ -102,27 +102,25 @@ export function createAstStoreVisitor(
102
102
  return nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
103
103
  }
104
104
 
105
- function resolveNameAndText(
106
- node: TreeSitterNode,
107
- kind: string,
108
- ): { name: string | null | undefined; text: string | null; skip?: boolean } {
109
- switch (kind) {
110
- case 'new':
111
- return { name: extractNewName(node), text: truncate(node.text) };
112
- case 'throw':
113
- return { name: extractThrowName(node), text: extractExpressionText(node) };
114
- case 'await':
115
- return { name: extractAwaitName(node), text: extractExpressionText(node) };
116
- case 'string': {
117
- const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
118
- if (content.length < 2) return { name: null, text: null, skip: true };
119
- return { name: truncate(content, 100), text: truncate(node.text) };
120
- }
121
- case 'regex':
122
- return { name: node.text || '?', text: truncate(node.text) };
123
- default:
124
- return { name: undefined, text: null };
125
- }
105
+ type NameTextResult = { name: string | null | undefined; text: string | null; skip?: boolean };
106
+ type KindHandler = (node: TreeSitterNode) => NameTextResult;
107
+
108
+ const kindHandlers: Record<string, KindHandler> = {
109
+ new: (node) => ({ name: extractNewName(node), text: truncate(node.text) }),
110
+ throw: (node) => ({ name: extractThrowName(node), text: extractExpressionText(node) }),
111
+ await: (node) => ({ name: extractAwaitName(node), text: extractExpressionText(node) }),
112
+ string: (node) => {
113
+ const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
114
+ if (content.length < 2) return { name: null, text: null, skip: true };
115
+ return { name: truncate(content, 100), text: truncate(node.text) };
116
+ },
117
+ regex: (node) => ({ name: node.text || '?', text: truncate(node.text) }),
118
+ };
119
+ const defaultResult: NameTextResult = { name: undefined, text: null };
120
+
121
+ function resolveNameAndText(node: TreeSitterNode, kind: string): NameTextResult {
122
+ const handler = kindHandlers[kind];
123
+ return handler ? handler(node) : defaultResult;
126
124
  }
127
125
 
128
126
  function collectNode(node: TreeSitterNode, kind: string): void {
@@ -40,6 +40,32 @@ function classifyHalstead(node: TreeSitterNode, hRules: AnyRules, acc: Complexit
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Detect whether a branch node is an else-if that the DFS walk would NOT
45
+ * increment nesting for. Returns true for:
46
+ * - Pattern A (JS/C#/Rust): if_statement whose parent is else_clause
47
+ * - Pattern C (Go/Java): if_statement that is the alternative of parent if
48
+ *
49
+ * Pattern B (Python elif_clause) is not an issue because elif_clause is
50
+ * never in nestingNodes.
51
+ */
52
+ function isElseIfNonNesting(node: TreeSitterNode, type: string, cRules: AnyRules): boolean {
53
+ if (type !== cRules.ifNodeType) return false;
54
+
55
+ if (cRules.elseViaAlternative) {
56
+ // Pattern C
57
+ return (
58
+ node.parent?.type === cRules.ifNodeType &&
59
+ node.parent?.childForFieldName('alternative')?.id === node.id
60
+ );
61
+ }
62
+ if (cRules.elseNodeType) {
63
+ // Pattern A
64
+ return node.parent?.type === cRules.elseNodeType;
65
+ }
66
+ return false;
67
+ }
68
+
43
69
  function classifyBranchNode(
44
70
  node: TreeSitterNode,
45
71
  type: string,
@@ -190,6 +216,13 @@ export function createComplexityVisitor(
190
216
  let funcDepth = 0;
191
217
  const results: PerFunctionResult[] = [];
192
218
 
219
+ // The walker increments context.nestingLevel for ALL nodes in nestingNodeTypes
220
+ // (including if_statement). But the DFS engine does NOT increment nesting for
221
+ // else-if if_statement nodes. Track a correction counter so children of else-if
222
+ // nodes see the correct (non-inflated) nesting level.
223
+ let nestingAdjust = 0;
224
+ const adjustNodeIds = new Set<number>();
225
+
193
226
  return {
194
227
  name: 'complexity',
195
228
  functionNodeTypes: cRules.functionNodes,
@@ -204,6 +237,8 @@ export function createComplexityVisitor(
204
237
  activeFuncNode = funcNode;
205
238
  activeFuncName = funcName;
206
239
  funcDepth = 0;
240
+ nestingAdjust = 0;
241
+ adjustNodeIds.clear();
207
242
  } else {
208
243
  funcDepth++;
209
244
  }
@@ -230,11 +265,30 @@ export function createComplexityVisitor(
230
265
  enterNode(node: TreeSitterNode, context: VisitorContext): EnterNodeResult | undefined {
231
266
  if (fileLevelWalk && !activeFuncNode) return;
232
267
 
233
- const nestingLevel = fileLevelWalk ? context.nestingLevel + funcDepth : context.nestingLevel;
268
+ // In file-level mode, funcDepth starts at 0 for the active function.
269
+ // In function-level mode, funcDepth starts at 1 for the root function
270
+ // (since enterFunction always increments it). Nested functions add +1
271
+ // each level — subtract 1 so the root function contributes 0 nesting
272
+ // and each nested level adds +1, matching the Rust engine's behavior.
273
+ const funcNesting = fileLevelWalk ? funcDepth : Math.max(0, funcDepth - 1);
274
+ const nestingLevel = context.nestingLevel + funcNesting - nestingAdjust;
234
275
  classifyNode(node, nestingLevel, cRules, hRules, acc);
276
+
277
+ // If this is an else-if if_statement that the walker will treat as a
278
+ // nesting node (incrementing context.nestingLevel for children), but
279
+ // the DFS walk would NOT increment nesting for, compensate by bumping
280
+ // nestingAdjust so children see the correct level.
281
+ if (cRules.nestingNodes.has(node.type) && isElseIfNonNesting(node, node.type, cRules)) {
282
+ nestingAdjust++;
283
+ adjustNodeIds.add(node.id);
284
+ }
235
285
  },
236
286
 
237
287
  exitNode(node: TreeSitterNode): void {
288
+ if (adjustNodeIds.has(node.id)) {
289
+ nestingAdjust--;
290
+ adjustNodeIds.delete(node.id);
291
+ }
238
292
  if (hRules?.skipTypes.has(node.type)) acc.halsteadSkipDepth--;
239
293
  },
240
294
 
@@ -5,6 +5,7 @@ export const command: CommandDefinition = {
5
5
  description: 'Compare code structure between two branches/refs',
6
6
  options: [
7
7
  ['--depth <n>', 'Max transitive caller depth', '3'],
8
+ ['-d, --db <path>', 'Path to graph.db'],
8
9
  ['-T, --no-tests', 'Exclude test/spec files'],
9
10
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
10
11
  ['-j, --json', 'Output as JSON'],
@@ -12,12 +13,15 @@ export const command: CommandDefinition = {
12
13
  ],
13
14
  async execute([base, target], opts, ctx) {
14
15
  const { branchCompare } = await import('../../presentation/branch-compare.js');
16
+ const path = await import('node:path');
17
+ const repoRoot = opts.db ? path.resolve(path.dirname(opts.db as string), '..') : undefined;
15
18
  await branchCompare(base!, target!, {
16
19
  engine: ctx.program.opts().engine,
17
20
  depth: parseInt(opts.depth as string, 10),
18
21
  noTests: ctx.resolveNoTests(opts),
19
22
  json: opts.json,
20
23
  format: opts.format,
24
+ repoRoot,
21
25
  });
22
26
  },
23
27
  };
@@ -13,6 +13,7 @@ export const command: CommandDefinition = {
13
13
  ['--ndjson', 'Newline-delimited JSON output'],
14
14
  ['--staged', 'Analyze staged changes instead of unstaged'],
15
15
  ['--depth <n>', 'Max transitive caller depth', '3'],
16
+ ['-j, --json', 'Output as JSON (shorthand for -f json)'],
16
17
  ['-f, --format <format>', 'Output format: text, mermaid, json', 'text'],
17
18
  ['--no-implementations', 'Exclude interface/trait implementors from blast radius'],
18
19
  ],
@@ -21,7 +22,7 @@ export const command: CommandDefinition = {
21
22
  ref,
22
23
  staged: opts.staged,
23
24
  depth: parseInt(opts.depth as string, 10),
24
- format: opts.format,
25
+ format: opts.json ? 'json' : opts.format,
25
26
  includeImplementors: opts.implementations !== false,
26
27
  ...ctx.resolveQueryOpts(opts),
27
28
  });
@@ -3,7 +3,8 @@ import type { CommandDefinition } from '../types.js';
3
3
  export const command: CommandDefinition = {
4
4
  name: 'info',
5
5
  description: 'Show codegraph engine info and diagnostics',
6
- async execute(_args, _opts, ctx) {
6
+ options: [['-d, --db <path>', 'Path to graph.db']],
7
+ async execute(_args, opts, ctx) {
7
8
  const { getNativePackageVersion, isNativeAvailable, loadNative } = await import(
8
9
  '../../infrastructure/native.js'
9
10
  );
@@ -40,7 +41,7 @@ export const command: CommandDefinition = {
40
41
  try {
41
42
  const { findDbPath, getBuildMeta } = await import('../../db/index.js');
42
43
  const Database = (await import('better-sqlite3')).default;
43
- const dbPath = findDbPath();
44
+ const dbPath = findDbPath(opts.db as string | undefined);
44
45
  const fs = await import('node:fs');
45
46
  if (fs.existsSync(dbPath)) {
46
47
  const db = new Database(dbPath, { readonly: true });
@@ -311,6 +311,7 @@ export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database {
311
311
  }
312
312
  const Database = getDatabase();
313
313
  const db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database;
314
+ db.pragma('busy_timeout = 5000');
314
315
 
315
316
  warnOnVersionMismatch(() => {
316
317
  const row = db
@@ -359,7 +360,7 @@ function openRepoNative(customDbPath?: string): { repo: Repository; close(): voi
359
360
  */
360
361
  export function openRepo(
361
362
  customDbPath?: string,
362
- opts: { repo?: Repository } = {},
363
+ opts: { repo?: Repository; engine?: 'native' | 'wasm' | 'auto' } = {},
363
364
  ): { repo: Repository; close(): void } {
364
365
  if (opts.repo != null) {
365
366
  if (!(opts.repo instanceof Repository)) {
@@ -370,15 +371,25 @@ export function openRepo(
370
371
  return { repo: opts.repo, close() {} };
371
372
  }
372
373
 
374
+ // Respect explicit engine selection: opts.engine > CODEGRAPH_ENGINE env > auto.
375
+ // This ensures --engine wasm and benchmark workers bypass the native path.
376
+ const engine = opts.engine || process.env.CODEGRAPH_ENGINE || 'auto';
377
+
373
378
  // Try native rusqlite path first (Phase 6.14)
374
- if (isNativeAvailable()) {
379
+ if (engine !== 'wasm' && isNativeAvailable()) {
375
380
  try {
376
381
  return openRepoNative(customDbPath);
377
382
  } catch (e) {
378
383
  // Re-throw user-visible errors (e.g. DB not found) — only silently
379
384
  // fall back for native-engine failures (e.g. incompatible native binary).
380
385
  if (e instanceof DbError) throw e;
381
- debug(`openRepo: native path failed, falling back to better-sqlite3: ${toErrorMessage(e)}`);
386
+ // Re-throw locking/busy errors falling back to better-sqlite3 would
387
+ // hit the same contention (and potentially hang without busy_timeout).
388
+ const msg = toErrorMessage(e);
389
+ if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) {
390
+ throw new DbError(`Database is busy (another process may be writing): ${msg}`, {});
391
+ }
392
+ debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`);
382
393
  }
383
394
  }
384
395
 
@@ -405,14 +416,22 @@ export function openReadonlyWithNative(customPath?: string): {
405
416
  } {
406
417
  const db = openReadonlyOrFail(customPath);
407
418
 
419
+ // Respect explicit engine selection, consistent with openRepo().
420
+ const engine = process.env.CODEGRAPH_ENGINE || 'auto';
421
+
408
422
  let nativeDb: NativeDatabase | undefined;
409
- if (isNativeAvailable()) {
423
+ if (engine !== 'wasm' && isNativeAvailable()) {
410
424
  try {
411
425
  const dbPath = findDbPath(customPath);
412
426
  const native = getNative();
413
427
  nativeDb = native.NativeDatabase.openReadonly(dbPath);
414
428
  } catch (e) {
415
- debug(`openReadonlyWithNative: native path failed: ${toErrorMessage(e)}`);
429
+ const msg = toErrorMessage(e);
430
+ if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) {
431
+ debug(`openReadonlyWithNative: native path busy, skipping native DB: ${msg}`);
432
+ } else {
433
+ debug(`openReadonlyWithNative: native path failed: ${msg}`);
434
+ }
416
435
  }
417
436
  }
418
437
 
@@ -100,6 +100,20 @@ export class Repository implements IRepository {
100
100
  throw new Error('not implemented');
101
101
  }
102
102
 
103
+ /**
104
+ * Batch version of findCallers — returns callers for multiple node IDs in a
105
+ * single query. Default implementation loops; subclasses override with SQL
106
+ * `IN (...)` for efficiency.
107
+ */
108
+ findCallersBatch(nodeIds: number[]): Map<number, RelatedNodeRow[]> {
109
+ const result = new Map<number, RelatedNodeRow[]>();
110
+ for (const id of nodeIds) {
111
+ const callers = this.findCallers(id);
112
+ if (callers.length > 0) result.set(id, callers);
113
+ }
114
+ return result;
115
+ }
116
+
103
117
  findDistinctCallers(_nodeId: number): RelatedNodeRow[] {
104
118
  throw new Error('not implemented');
105
119
  }
@@ -209,4 +223,47 @@ export class Repository implements IRepository {
209
223
  hasCoChangesTable(): boolean {
210
224
  return false;
211
225
  }
226
+
227
+ // ── Composite queries ──────────────────────────────────────────────
228
+ /**
229
+ * Complete fnDeps query in a single call. Returns null when not natively
230
+ * supported — callers should fall back to the JS-orchestrated path.
231
+ */
232
+ fnDeps(
233
+ _name: string,
234
+ _opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string },
235
+ ): FnDepsResult | null {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ // ── Composite query result types ────────────────────────────────────────
241
+
242
+ export interface FnDepsNode {
243
+ name: string;
244
+ kind: string;
245
+ file: string;
246
+ line: number | null;
247
+ }
248
+
249
+ export interface FnDepsCallerNode extends FnDepsNode {
250
+ viaHierarchy?: string;
251
+ }
252
+
253
+ export interface FnDepsEntry {
254
+ name: string;
255
+ kind: string;
256
+ file: string;
257
+ line: number | null;
258
+ endLine: number | null;
259
+ role: string | null;
260
+ fileHash: string | null;
261
+ callees: FnDepsNode[];
262
+ callers: FnDepsCallerNode[];
263
+ transitiveCallers: Record<number, FnDepsNode[]>;
264
+ }
265
+
266
+ export interface FnDepsResult {
267
+ name: string;
268
+ results: FnDepsEntry[];
212
269
  }
@@ -1,5 +1,6 @@
1
1
  // Barrel re-export for repository/ modules.
2
2
 
3
+ export type { FnDepsCallerNode, FnDepsEntry, FnDepsNode, FnDepsResult } from './base.js';
3
4
  export { Repository } from './base.js';
4
5
  export { purgeFileData, purgeFilesData } from './build-stmts.js';
5
6
  export { cachedStmt } from './cached-stmt.js';