@optave/codegraph 3.11.0 → 3.11.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 (223) hide show
  1. package/README.md +38 -31
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +91 -60
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitor-utils.d.ts +3 -0
  6. package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
  7. package/dist/ast-analysis/visitor-utils.js +83 -49
  8. package/dist/ast-analysis/visitor-utils.js.map +1 -1
  9. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  10. package/dist/ast-analysis/visitors/ast-store-visitor.js +78 -62
  11. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  12. package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
  13. package/dist/ast-analysis/visitors/dataflow-visitor.js +61 -42
  14. package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
  15. package/dist/cli/commands/embed.d.ts.map +1 -1
  16. package/dist/cli/commands/embed.js +49 -4
  17. package/dist/cli/commands/embed.js.map +1 -1
  18. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  19. package/dist/domain/analysis/dependencies.js +106 -80
  20. package/dist/domain/analysis/dependencies.js.map +1 -1
  21. package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
  22. package/dist/domain/analysis/fn-impact.js +77 -52
  23. package/dist/domain/analysis/fn-impact.js.map +1 -1
  24. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  25. package/dist/domain/analysis/module-map.js +132 -121
  26. package/dist/domain/analysis/module-map.js.map +1 -1
  27. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  28. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  29. package/dist/domain/graph/builder/helpers.js +47 -33
  30. package/dist/domain/graph/builder/helpers.js.map +1 -1
  31. package/dist/domain/graph/builder/incremental.d.ts +6 -0
  32. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/incremental.js +142 -76
  34. package/dist/domain/graph/builder/incremental.js.map +1 -1
  35. package/dist/domain/graph/builder/pipeline.d.ts +1 -44
  36. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/pipeline.js +10 -766
  38. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  39. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  40. package/dist/domain/graph/builder/stages/build-edges.js +133 -96
  41. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  42. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  43. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  44. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  45. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  46. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  47. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  48. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  49. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  50. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  51. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  52. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  54. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  56. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  57. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  58. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  59. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  60. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  61. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  62. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  63. package/dist/domain/graph/cycles.d.ts +6 -4
  64. package/dist/domain/graph/cycles.d.ts.map +1 -1
  65. package/dist/domain/graph/cycles.js +50 -55
  66. package/dist/domain/graph/cycles.js.map +1 -1
  67. package/dist/domain/graph/journal.d.ts.map +1 -1
  68. package/dist/domain/graph/journal.js +89 -70
  69. package/dist/domain/graph/journal.js.map +1 -1
  70. package/dist/domain/graph/watcher.d.ts.map +1 -1
  71. package/dist/domain/graph/watcher.js +5 -2
  72. package/dist/domain/graph/watcher.js.map +1 -1
  73. package/dist/domain/parser.d.ts +12 -23
  74. package/dist/domain/parser.d.ts.map +1 -1
  75. package/dist/domain/parser.js +126 -79
  76. package/dist/domain/parser.js.map +1 -1
  77. package/dist/domain/search/generator.d.ts +3 -1
  78. package/dist/domain/search/generator.d.ts.map +1 -1
  79. package/dist/domain/search/generator.js +68 -45
  80. package/dist/domain/search/generator.js.map +1 -1
  81. package/dist/domain/search/models.d.ts +2 -0
  82. package/dist/domain/search/models.d.ts.map +1 -1
  83. package/dist/domain/search/models.js +37 -3
  84. package/dist/domain/search/models.js.map +1 -1
  85. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  86. package/dist/domain/search/search/hybrid.js +49 -40
  87. package/dist/domain/search/search/hybrid.js.map +1 -1
  88. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  89. package/dist/domain/search/search/semantic.js +69 -49
  90. package/dist/domain/search/search/semantic.js.map +1 -1
  91. package/dist/domain/wasm-worker-entry.js +201 -136
  92. package/dist/domain/wasm-worker-entry.js.map +1 -1
  93. package/dist/extractors/elixir.js +95 -71
  94. package/dist/extractors/elixir.js.map +1 -1
  95. package/dist/extractors/gleam.d.ts.map +1 -1
  96. package/dist/extractors/gleam.js +23 -31
  97. package/dist/extractors/gleam.js.map +1 -1
  98. package/dist/extractors/helpers.d.ts +79 -1
  99. package/dist/extractors/helpers.d.ts.map +1 -1
  100. package/dist/extractors/helpers.js +137 -0
  101. package/dist/extractors/helpers.js.map +1 -1
  102. package/dist/extractors/java.d.ts.map +1 -1
  103. package/dist/extractors/java.js +37 -49
  104. package/dist/extractors/java.js.map +1 -1
  105. package/dist/extractors/javascript.d.ts.map +1 -1
  106. package/dist/extractors/javascript.js +44 -44
  107. package/dist/extractors/javascript.js.map +1 -1
  108. package/dist/extractors/julia.js +27 -34
  109. package/dist/extractors/julia.js.map +1 -1
  110. package/dist/extractors/r.d.ts.map +1 -1
  111. package/dist/extractors/r.js +33 -58
  112. package/dist/extractors/r.js.map +1 -1
  113. package/dist/extractors/solidity.d.ts.map +1 -1
  114. package/dist/extractors/solidity.js +38 -61
  115. package/dist/extractors/solidity.js.map +1 -1
  116. package/dist/features/boundaries.d.ts.map +1 -1
  117. package/dist/features/boundaries.js +49 -39
  118. package/dist/features/boundaries.js.map +1 -1
  119. package/dist/features/cfg.d.ts.map +1 -1
  120. package/dist/features/cfg.js +90 -63
  121. package/dist/features/cfg.js.map +1 -1
  122. package/dist/features/check.d.ts.map +1 -1
  123. package/dist/features/check.js +43 -34
  124. package/dist/features/check.js.map +1 -1
  125. package/dist/features/cochange.d.ts.map +1 -1
  126. package/dist/features/cochange.js +68 -56
  127. package/dist/features/cochange.js.map +1 -1
  128. package/dist/features/complexity.d.ts.map +1 -1
  129. package/dist/features/complexity.js +105 -75
  130. package/dist/features/complexity.js.map +1 -1
  131. package/dist/features/dataflow.d.ts.map +1 -1
  132. package/dist/features/dataflow.js +37 -29
  133. package/dist/features/dataflow.js.map +1 -1
  134. package/dist/features/flow.d.ts.map +1 -1
  135. package/dist/features/flow.js +31 -22
  136. package/dist/features/flow.js.map +1 -1
  137. package/dist/features/graph-enrichment.d.ts.map +1 -1
  138. package/dist/features/graph-enrichment.js +77 -70
  139. package/dist/features/graph-enrichment.js.map +1 -1
  140. package/dist/features/owners.d.ts +17 -26
  141. package/dist/features/owners.d.ts.map +1 -1
  142. package/dist/features/owners.js +120 -109
  143. package/dist/features/owners.js.map +1 -1
  144. package/dist/features/sequence.d.ts.map +1 -1
  145. package/dist/features/sequence.js +59 -54
  146. package/dist/features/sequence.js.map +1 -1
  147. package/dist/features/structure-query.d.ts.map +1 -1
  148. package/dist/features/structure-query.js +60 -60
  149. package/dist/features/structure-query.js.map +1 -1
  150. package/dist/features/structure.js +28 -36
  151. package/dist/features/structure.js.map +1 -1
  152. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  153. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  154. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  155. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  156. package/dist/graph/classifiers/roles.js +63 -59
  157. package/dist/graph/classifiers/roles.js.map +1 -1
  158. package/dist/infrastructure/config.d.ts +1 -1
  159. package/dist/infrastructure/config.d.ts.map +1 -1
  160. package/dist/infrastructure/config.js +1 -1
  161. package/dist/infrastructure/config.js.map +1 -1
  162. package/dist/presentation/cfg.d.ts.map +1 -1
  163. package/dist/presentation/cfg.js +44 -29
  164. package/dist/presentation/cfg.js.map +1 -1
  165. package/dist/presentation/flow.d.ts.map +1 -1
  166. package/dist/presentation/flow.js +58 -38
  167. package/dist/presentation/flow.js.map +1 -1
  168. package/dist/types.d.ts +1 -1
  169. package/dist/types.d.ts.map +1 -1
  170. package/package.json +7 -7
  171. package/src/ast-analysis/engine.ts +145 -61
  172. package/src/ast-analysis/visitor-utils.ts +86 -46
  173. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  174. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  175. package/src/cli/commands/embed.ts +54 -4
  176. package/src/domain/analysis/dependencies.ts +166 -85
  177. package/src/domain/analysis/fn-impact.ts +120 -50
  178. package/src/domain/analysis/module-map.ts +175 -140
  179. package/src/domain/graph/builder/helpers.ts +85 -76
  180. package/src/domain/graph/builder/incremental.ts +217 -90
  181. package/src/domain/graph/builder/pipeline.ts +19 -957
  182. package/src/domain/graph/builder/stages/build-edges.ts +198 -140
  183. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  184. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  185. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  186. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  187. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  188. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  189. package/src/domain/graph/cycles.ts +51 -49
  190. package/src/domain/graph/journal.ts +84 -69
  191. package/src/domain/graph/watcher.ts +8 -2
  192. package/src/domain/parser.ts +143 -66
  193. package/src/domain/search/generator.ts +132 -74
  194. package/src/domain/search/models.ts +39 -3
  195. package/src/domain/search/search/hybrid.ts +53 -42
  196. package/src/domain/search/search/semantic.ts +105 -65
  197. package/src/domain/wasm-worker-entry.ts +235 -152
  198. package/src/extractors/elixir.ts +91 -64
  199. package/src/extractors/gleam.ts +33 -37
  200. package/src/extractors/helpers.ts +205 -1
  201. package/src/extractors/java.ts +42 -45
  202. package/src/extractors/javascript.ts +44 -43
  203. package/src/extractors/julia.ts +28 -35
  204. package/src/extractors/r.ts +38 -56
  205. package/src/extractors/solidity.ts +43 -71
  206. package/src/features/boundaries.ts +64 -46
  207. package/src/features/cfg.ts +145 -74
  208. package/src/features/check.ts +60 -43
  209. package/src/features/cochange.ts +95 -72
  210. package/src/features/complexity.ts +134 -79
  211. package/src/features/dataflow.ts +57 -34
  212. package/src/features/flow.ts +48 -24
  213. package/src/features/graph-enrichment.ts +105 -70
  214. package/src/features/owners.ts +186 -146
  215. package/src/features/sequence.ts +99 -69
  216. package/src/features/structure-query.ts +94 -79
  217. package/src/features/structure.ts +56 -56
  218. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  219. package/src/graph/classifiers/roles.ts +64 -54
  220. package/src/infrastructure/config.ts +1 -1
  221. package/src/presentation/cfg.ts +48 -32
  222. package/src/presentation/flow.ts +100 -52
  223. package/src/types.ts +1 -1
@@ -18,7 +18,13 @@ import {
18
18
  } from '../db/index.js';
19
19
  import { debug, info } from '../infrastructure/logger.js';
20
20
  import { paginateResult } from '../shared/paginate.js';
21
- import type { BetterSqlite3Database, Definition, NodeRow, TreeSitterNode } from '../types.js';
21
+ import type {
22
+ BetterSqlite3Database,
23
+ CfgRulesConfig,
24
+ Definition,
25
+ NodeRow,
26
+ TreeSitterNode,
27
+ } from '../types.js';
22
28
  import { findNodes } from './shared/find-nodes.js';
23
29
 
24
30
  export { _makeCfgRules as makeCfgRules, CFG_RULES };
@@ -122,9 +128,8 @@ async function initCfgParsers(
122
128
  let getParserFn: unknown = null;
123
129
 
124
130
  if (needsFallback) {
125
- const { createParsers } = await import('../domain/parser.js');
126
- parsers = await createParsers();
127
131
  const mod = await import('../domain/parser.js');
132
+ parsers = await mod.createParsers();
128
133
  getParserFn = mod.getParser;
129
134
  }
130
135
 
@@ -187,7 +192,7 @@ interface VisitorCfgResult {
187
192
 
188
193
  function buildVisitorCfgMap(
189
194
  tree: { rootNode: TreeSitterNode } | null | undefined,
190
- cfgRules: unknown,
195
+ cfgRules: CfgRulesConfig,
191
196
  symbols: FileSymbols,
192
197
  langId: string,
193
198
  ): Map<number, VisitorCfgResult[]> | null {
@@ -203,9 +208,8 @@ function buildVisitorCfgMap(
203
208
  if (!needsVisitor) return null;
204
209
 
205
210
  const visitor = createCfgVisitor(cfgRules);
206
- const typedRules = cfgRules as { functionNodes: string[] };
207
211
  const walkerOpts = {
208
- functionNodeTypes: new Set(typedRules.functionNodes),
212
+ functionNodeTypes: new Set(cfgRules.functionNodes),
209
213
  nestingNodeTypes: new Set<string>(),
210
214
  getFunctionName: (node: TreeSitterNode) => {
211
215
  const nameNode = node.childForFieldName?.('name');
@@ -365,79 +369,91 @@ function persistVisitorFileCfg(
365
369
  return count;
366
370
  }
367
371
 
368
- export async function buildCFGData(
372
+ /**
373
+ * Build a single native bulk-insert entry for one definition.
374
+ * Returns null when the def has no CFG blocks or no associated node row.
375
+ */
376
+ function buildNativeCfgEntry(
369
377
  db: BetterSqlite3Database,
370
- fileSymbols: Map<string, FileSymbols>,
371
- rootDir: string,
372
- engineOpts?: {
373
- nativeDb?: { bulkInsertCfg?(entries: Array<Record<string, unknown>>): number };
374
- suspendJsDb?: () => void;
375
- resumeJsDb?: () => void;
376
- },
377
- ): Promise<void> {
378
- // Fast path: when all function/method defs already have native CFG data,
379
- // skip WASM parser init, tree parsing, and JS visitor entirely just persist.
380
- const allNative = allCfgNative(fileSymbols);
378
+ def: Definition,
379
+ relPath: string,
380
+ ): Record<string, unknown> | null {
381
+ if (def.kind !== 'function' && def.kind !== 'method') return null;
382
+ if (!def.line) return null;
383
+
384
+ const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
385
+ if (!nodeId) return null;
386
+
387
+ const cfg = def.cfg as { blocks?: CfgBuildBlock[]; edges?: CfgBuildEdge[] } | undefined;
388
+ if (!cfg?.blocks?.length) return null;
389
+
390
+ return {
391
+ nodeId,
392
+ blocks: cfg.blocks.map((b) => ({
393
+ index: b.index,
394
+ blockType: b.type,
395
+ startLine: b.startLine ?? undefined,
396
+ endLine: b.endLine ?? undefined,
397
+ label: b.label ?? undefined,
398
+ })),
399
+ edges: (cfg.edges || []).map((e) => ({
400
+ sourceIndex: e.sourceIndex,
401
+ targetIndex: e.targetIndex,
402
+ kind: e.kind,
403
+ })),
404
+ };
405
+ }
381
406
 
382
- // ── Native bulk-insert fast path ──────────────────────────────────────
383
- // The Rust bulkInsertCfg handles delete-before-insert atomically on a
384
- // single rusqlite connection, so there is no dual-connection WAL conflict.
407
+ /**
408
+ * Native bulk-insert fast path. The Rust bulkInsertCfg handles
409
+ * delete-before-insert atomically on a single rusqlite connection, so there
410
+ * is no dual-connection WAL conflict. Returns true if this path handled the
411
+ * request (caller should return early); false to fall through to WASM/JS.
412
+ */
413
+ function tryNativeBulkInsertCfg(
414
+ db: BetterSqlite3Database,
415
+ fileSymbols: Map<string, FileSymbols>,
416
+ engineOpts:
417
+ | {
418
+ nativeDb?: { bulkInsertCfg?(entries: Array<Record<string, unknown>>): number };
419
+ suspendJsDb?: () => void;
420
+ resumeJsDb?: () => void;
421
+ }
422
+ | undefined,
423
+ ): boolean {
385
424
  const nativeDb = engineOpts?.nativeDb;
386
- if (allNative && nativeDb?.bulkInsertCfg) {
387
- const entries: Array<Record<string, unknown>> = [];
388
- for (const [relPath, symbols] of fileSymbols) {
389
- const ext = path.extname(relPath).toLowerCase();
390
- if (!CFG_EXTENSIONS.has(ext)) continue;
425
+ if (!nativeDb?.bulkInsertCfg) return false;
391
426
 
392
- for (const def of symbols.definitions) {
393
- if (def.kind !== 'function' && def.kind !== 'method') continue;
394
- if (!def.line) continue;
395
-
396
- const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
397
- if (!nodeId) continue;
398
-
399
- const cfg = def.cfg as { blocks?: CfgBuildBlock[]; edges?: CfgBuildEdge[] } | undefined;
400
- if (!cfg?.blocks?.length) continue;
401
-
402
- entries.push({
403
- nodeId,
404
- blocks: cfg.blocks.map((b) => ({
405
- index: b.index,
406
- blockType: b.type,
407
- startLine: b.startLine ?? undefined,
408
- endLine: b.endLine ?? undefined,
409
- label: b.label ?? undefined,
410
- })),
411
- edges: (cfg.edges || []).map((e) => ({
412
- sourceIndex: e.sourceIndex,
413
- targetIndex: e.targetIndex,
414
- kind: e.kind,
415
- })),
416
- });
417
- }
418
- }
427
+ const entries: Array<Record<string, unknown>> = [];
428
+ for (const [relPath, symbols] of fileSymbols) {
429
+ const ext = path.extname(relPath).toLowerCase();
430
+ if (!CFG_EXTENSIONS.has(ext)) continue;
419
431
 
420
- if (entries.length > 0) {
421
- let inserted = 0;
422
- try {
423
- engineOpts?.suspendJsDb?.();
424
- inserted = nativeDb.bulkInsertCfg(entries);
425
- } finally {
426
- engineOpts?.resumeJsDb?.();
427
- }
428
- info(`CFG (native bulk): ${inserted} functions analyzed`);
432
+ for (const def of symbols.definitions) {
433
+ const entry = buildNativeCfgEntry(db, def, relPath);
434
+ if (entry) entries.push(entry);
429
435
  }
430
- return;
431
436
  }
432
437
 
433
- const extToLang = buildExtToLangMap();
434
- let parsers: unknown = null;
435
- let getParserFn: unknown = null;
436
-
437
- if (!allNative) {
438
- ({ parsers, getParserFn } = await initCfgParsers(fileSymbols));
438
+ if (entries.length > 0) {
439
+ let inserted = 0;
440
+ try {
441
+ engineOpts?.suspendJsDb?.();
442
+ inserted = nativeDb.bulkInsertCfg(entries);
443
+ } finally {
444
+ engineOpts?.resumeJsDb?.();
445
+ }
446
+ info(`CFG (native bulk): ${inserted} functions analyzed`);
439
447
  }
448
+ return true;
449
+ }
440
450
 
451
+ interface CfgInsertStatements {
452
+ insertBlock: ReturnType<BetterSqlite3Database['prepare']>;
453
+ insertEdge: ReturnType<BetterSqlite3Database['prepare']>;
454
+ }
455
+
456
+ function prepareCfgInsertStatements(db: BetterSqlite3Database): CfgInsertStatements {
441
457
  const insertBlock = db.prepare(
442
458
  `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label)
443
459
  VALUES (?, ?, ?, ?, ?, ?)`,
@@ -446,15 +462,31 @@ export async function buildCFGData(
446
462
  `INSERT INTO cfg_edges (function_node_id, source_block_id, target_block_id, kind)
447
463
  VALUES (?, ?, ?, ?)`,
448
464
  );
449
- let analyzed = 0;
465
+ return { insertBlock, insertEdge };
466
+ }
450
467
 
468
+ /**
469
+ * Persist CFG for every CFG-eligible file inside a single transaction.
470
+ * Dispatches to native fast path or visitor path per file.
471
+ */
472
+ function persistAllFileCfgs(
473
+ db: BetterSqlite3Database,
474
+ fileSymbols: Map<string, FileSymbols>,
475
+ rootDir: string,
476
+ allNative: boolean,
477
+ extToLang: Map<string, string>,
478
+ parsers: unknown,
479
+ getParserFn: unknown,
480
+ stmts: CfgInsertStatements,
481
+ ): number {
482
+ let analyzed = 0;
451
483
  const tx = db.transaction(() => {
452
484
  for (const [relPath, symbols] of fileSymbols) {
453
485
  const ext = path.extname(relPath).toLowerCase();
454
486
  if (!CFG_EXTENSIONS.has(ext)) continue;
455
487
 
456
488
  if (allNative && !symbols._tree) {
457
- analyzed += persistNativeFileCfg(db, symbols, relPath, insertBlock, insertEdge);
489
+ analyzed += persistNativeFileCfg(db, symbols, relPath, stmts.insertBlock, stmts.insertEdge);
458
490
  continue;
459
491
  }
460
492
 
@@ -466,13 +498,52 @@ export async function buildCFGData(
466
498
  extToLang,
467
499
  parsers,
468
500
  getParserFn,
469
- insertBlock,
470
- insertEdge,
501
+ stmts.insertBlock,
502
+ stmts.insertEdge,
471
503
  );
472
504
  }
473
505
  });
474
-
475
506
  tx();
507
+ return analyzed;
508
+ }
509
+
510
+ export async function buildCFGData(
511
+ db: BetterSqlite3Database,
512
+ fileSymbols: Map<string, FileSymbols>,
513
+ rootDir: string,
514
+ engineOpts?: {
515
+ nativeDb?: { bulkInsertCfg?(entries: Array<Record<string, unknown>>): number };
516
+ suspendJsDb?: () => void;
517
+ resumeJsDb?: () => void;
518
+ },
519
+ ): Promise<void> {
520
+ // Fast path: when all function/method defs already have native CFG data,
521
+ // skip WASM parser init, tree parsing, and JS visitor entirely — just persist.
522
+ const allNative = allCfgNative(fileSymbols);
523
+
524
+ if (allNative && tryNativeBulkInsertCfg(db, fileSymbols, engineOpts)) {
525
+ return;
526
+ }
527
+
528
+ const extToLang = buildExtToLangMap();
529
+ let parsers: unknown = null;
530
+ let getParserFn: unknown = null;
531
+
532
+ if (!allNative) {
533
+ ({ parsers, getParserFn } = await initCfgParsers(fileSymbols));
534
+ }
535
+
536
+ const stmts = prepareCfgInsertStatements(db);
537
+ const analyzed = persistAllFileCfgs(
538
+ db,
539
+ fileSymbols,
540
+ rootDir,
541
+ allNative,
542
+ extToLang,
543
+ parsers,
544
+ getParserFn,
545
+ stmts,
546
+ );
476
547
 
477
548
  if (analyzed > 0) {
478
549
  info(`CFG: ${analyzed} functions analyzed`);
@@ -22,6 +22,29 @@ interface ParsedDiff {
22
22
  newFiles: Set<string>;
23
23
  }
24
24
 
25
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
26
+ const NEW_FILE_RE = /^\+\+\+ b\/(.+)/;
27
+
28
+ function pushHunkRanges(
29
+ line: string,
30
+ currentFile: string,
31
+ changedRanges: Map<string, DiffRange[]>,
32
+ oldRanges: Map<string, DiffRange[]>,
33
+ ): void {
34
+ const hunkMatch = line.match(HUNK_RE);
35
+ if (!hunkMatch) return;
36
+ const oldStart = parseInt(hunkMatch[1]!, 10);
37
+ const oldCount = parseInt(hunkMatch[2] || '1', 10);
38
+ if (oldCount > 0) {
39
+ oldRanges.get(currentFile)!.push({ start: oldStart, end: oldStart + oldCount - 1 });
40
+ }
41
+ const newStart = parseInt(hunkMatch[3]!, 10);
42
+ const newCount = parseInt(hunkMatch[4] || '1', 10);
43
+ if (newCount > 0) {
44
+ changedRanges.get(currentFile)!.push({ start: newStart, end: newStart + newCount - 1 });
45
+ }
46
+ }
47
+
25
48
  export function parseDiffOutput(diffOutput: string): ParsedDiff {
26
49
  const changedRanges = new Map<string, DiffRange[]>();
27
50
  const oldRanges = new Map<string, DiffRange[]>();
@@ -38,7 +61,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff {
38
61
  prevIsDevNull = false;
39
62
  continue;
40
63
  }
41
- const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
64
+ const fileMatch = line.match(NEW_FILE_RE);
42
65
  if (fileMatch) {
43
66
  currentFile = fileMatch[1]!;
44
67
  if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
@@ -47,19 +70,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff {
47
70
  prevIsDevNull = false;
48
71
  continue;
49
72
  }
50
- const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
51
- if (hunkMatch && currentFile) {
52
- const oldStart = parseInt(hunkMatch[1]!, 10);
53
- const oldCount = parseInt(hunkMatch[2] || '1', 10);
54
- if (oldCount > 0) {
55
- oldRanges.get(currentFile)!.push({ start: oldStart, end: oldStart + oldCount - 1 });
56
- }
57
- const newStart = parseInt(hunkMatch[3]!, 10);
58
- const newCount = parseInt(hunkMatch[4] || '1', 10);
59
- if (newCount > 0) {
60
- changedRanges.get(currentFile)!.push({ start: newStart, end: newStart + newCount - 1 });
61
- }
62
- }
73
+ if (currentFile) pushHunkRanges(line, currentFile, changedRanges, oldRanges);
63
74
  }
64
75
  return { changedRanges, oldRanges, newFiles };
65
76
  }
@@ -96,6 +107,26 @@ interface BlastRadiusResult {
96
107
  violations: BlastRadiusViolation[];
97
108
  }
98
109
 
110
+ type DefRow = {
111
+ id: number;
112
+ name: string;
113
+ kind: string;
114
+ file: string;
115
+ line: number;
116
+ end_line: number | null;
117
+ };
118
+
119
+ function rangesOverlap(defLine: number, endLine: number, ranges: DiffRange[]): boolean {
120
+ for (const range of ranges) {
121
+ if (range.start <= endLine && range.end >= defLine) return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ function defEndLine(def: DefRow, nextDef: DefRow | undefined): number {
127
+ return def.end_line || (nextDef ? nextDef.line - 1 : 999999);
128
+ }
129
+
99
130
  export function checkMaxBlastRadius(
100
131
  db: BetterSqlite3Database,
101
132
  changedRanges: Map<string, DiffRange[]>,
@@ -105,34 +136,18 @@ export function checkMaxBlastRadius(
105
136
  ): BlastRadiusResult {
106
137
  const violations: BlastRadiusViolation[] = [];
107
138
  let maxFound = 0;
139
+ const defsStmt = db.prepare(
140
+ `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
141
+ );
108
142
 
109
143
  for (const [file, ranges] of changedRanges) {
110
144
  if (noTests && isTestFile(file)) continue;
111
- const defs = db
112
- .prepare(
113
- `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
114
- )
115
- .all(file) as Array<{
116
- id: number;
117
- name: string;
118
- kind: string;
119
- file: string;
120
- line: number;
121
- end_line: number | null;
122
- }>;
145
+ const defs = defsStmt.all(file) as DefRow[];
123
146
 
124
147
  for (let i = 0; i < defs.length; i++) {
125
148
  const def = defs[i]!;
126
- const nextDef = defs[i + 1];
127
- const endLine = def.end_line || (nextDef ? nextDef.line - 1 : 999999);
128
- let overlaps = false;
129
- for (const range of ranges) {
130
- if (range.start <= endLine && range.end >= def.line) {
131
- overlaps = true;
132
- break;
133
- }
134
- }
135
- if (!overlaps) continue;
149
+ const endLine = defEndLine(def, defs[i + 1]);
150
+ if (!rangesOverlap(def.line, endLine, ranges)) continue;
136
151
 
137
152
  const { totalDependents: totalCallers } = bfsTransitiveCallers(db, def.id, {
138
153
  noTests,
@@ -364,11 +379,13 @@ function runPredicates(
364
379
  return predicates;
365
380
  }
366
381
 
367
- const EMPTY_CHECK: CheckResult = {
368
- predicates: [],
369
- summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 },
370
- passed: true,
371
- };
382
+ function makeEmptyCheck(): CheckResult {
383
+ return {
384
+ predicates: [],
385
+ summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 },
386
+ passed: true,
387
+ };
388
+ }
372
389
 
373
390
  export function checkData(customDbPath: string | undefined, opts: CheckOpts = {}): CheckResult {
374
391
  const db = openReadonlyOrFail(customDbPath);
@@ -394,10 +411,10 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {}
394
411
  return { error: `Failed to run git diff: ${(e as Error).message}` };
395
412
  }
396
413
 
397
- if (!diffOutput.trim()) return EMPTY_CHECK;
414
+ if (!diffOutput.trim()) return makeEmptyCheck();
398
415
 
399
416
  const diff = parseDiffOutput(diffOutput);
400
- if (diff.changedRanges.size === 0) return EMPTY_CHECK;
417
+ if (diff.changedRanges.size === 0) return makeEmptyCheck();
401
418
 
402
419
  const predicates = runPredicates(db, diff, flags, repoRoot, noTests, maxDepth);
403
420
 
@@ -137,77 +137,50 @@ export function computeCoChanges(
137
137
  return { pairs: results, fileCommitCounts };
138
138
  }
139
139
 
140
- export function analyzeCoChanges(
141
- customDbPath?: string,
142
- opts: {
143
- since?: string;
144
- minSupport?: number;
145
- maxFilesPerCommit?: number;
146
- full?: boolean;
147
- } = {},
148
- ):
149
- | { pairsFound: number; commitsScanned: number; since: string; minSupport: number }
150
- | { error: string } {
151
- const dbPath = findDbPath(customDbPath);
152
- const db = openDb(dbPath);
153
- initSchema(db);
154
-
155
- const repoRoot = path.resolve(path.dirname(dbPath), '..');
156
-
157
- if (!fs.existsSync(path.join(repoRoot, '.git'))) {
158
- closeDb(db);
159
- return { error: `Not a git repository: ${repoRoot}` };
160
- }
161
-
162
- const since = opts.since || '1 year ago';
163
- const minSupport = opts.minSupport ?? 3;
164
- const maxFilesPerCommit = opts.maxFilesPerCommit ?? 50;
165
-
166
- // Check for incremental state
167
- let afterSha: string | null = null;
168
- if (!opts.full) {
169
- try {
170
- const row = db
171
- .prepare<{ value: string }>(
172
- "SELECT value FROM co_change_meta WHERE key = 'last_analyzed_commit'",
173
- )
174
- .get();
175
- if (row) afterSha = row.value;
176
- } catch {
177
- /* table may not exist yet */
178
- }
140
+ /** Read the SHA of the most recently analyzed commit (incremental state). */
141
+ function loadLastAnalyzedSha(db: BetterSqlite3Database): string | null {
142
+ try {
143
+ const row = db
144
+ .prepare<{ value: string }>(
145
+ "SELECT value FROM co_change_meta WHERE key = 'last_analyzed_commit'",
146
+ )
147
+ .get();
148
+ return row ? row.value : null;
149
+ } catch {
150
+ /* table may not exist yet */
151
+ return null;
179
152
  }
153
+ }
180
154
 
181
- // If full re-scan, clear existing data
182
- if (opts.full) {
183
- db.exec('DELETE FROM co_changes');
184
- db.exec('DELETE FROM co_change_meta');
185
- db.exec('DELETE FROM file_commit_counts');
186
- }
155
+ /** Wipe all co-change tables for a full re-scan. */
156
+ function clearCoChangeTables(db: BetterSqlite3Database): void {
157
+ db.exec('DELETE FROM co_changes');
158
+ db.exec('DELETE FROM co_change_meta');
159
+ db.exec('DELETE FROM file_commit_counts');
160
+ }
187
161
 
188
- // Collect known files from the graph for filtering
189
- let knownFiles: Set<string> | null = null;
162
+ /** Collect the set of files currently tracked by the graph for filtering. */
163
+ function loadKnownFiles(db: BetterSqlite3Database): Set<string> | null {
190
164
  try {
191
165
  const rows = db.prepare<{ file: string }>('SELECT DISTINCT file FROM nodes').all();
192
- knownFiles = new Set(rows.map((r) => r.file));
166
+ return new Set(rows.map((r) => r.file));
193
167
  } catch {
194
168
  /* nodes table may not exist */
169
+ return null;
195
170
  }
171
+ }
196
172
 
197
- const { commits } = scanGitHistory(repoRoot, { since, afterSha });
198
- const { pairs: coChanges, fileCommitCounts } = computeCoChanges(commits, {
199
- minSupport,
200
- maxFilesPerCommit,
201
- knownFiles,
202
- });
203
-
204
- // Upsert per-file commit counts so Jaccard can be recomputed from totals
173
+ /** Upsert per-file commit counts and pair counts (Jaccard recomputed later). */
174
+ function persistCoChangeResults(
175
+ db: BetterSqlite3Database,
176
+ fileCommitCounts: Map<string, number>,
177
+ coChanges: Map<string, CoChangePair>,
178
+ ): void {
205
179
  const fileCountUpsert = db.prepare(`
206
180
  INSERT INTO file_commit_counts (file, commit_count) VALUES (?, ?)
207
181
  ON CONFLICT(file) DO UPDATE SET commit_count = commit_count + excluded.commit_count
208
182
  `);
209
183
 
210
- // Upsert pair counts (accumulate commit_count, jaccard placeholder — recomputed below)
211
184
  const pairUpsert = db.prepare(`
212
185
  INSERT INTO co_changes (file_a, file_b, commit_count, jaccard, last_commit_epoch)
213
186
  VALUES (?, ?, ?, 0, ?)
@@ -226,24 +199,31 @@ export function analyzeCoChanges(
226
199
  }
227
200
  });
228
201
  insertMany();
202
+ }
229
203
 
230
- // Recompute Jaccard for all affected pairs from total file commit counts
231
- const affectedFiles = [...fileCommitCounts.keys()];
232
- if (affectedFiles.length > 0) {
233
- const ph = affectedFiles.map(() => '?').join(',');
234
- db.prepare(`
235
- UPDATE co_changes SET jaccard = (
236
- SELECT CAST(co_changes.commit_count AS REAL) / (
237
- COALESCE(fa.commit_count, 0) + COALESCE(fb.commit_count, 0) - co_changes.commit_count
238
- )
239
- FROM file_commit_counts fa, file_commit_counts fb
240
- WHERE fa.file = co_changes.file_a AND fb.file = co_changes.file_b
204
+ /** Recompute Jaccard for every pair touching any file in `affectedFiles`. */
205
+ function recomputeJaccardForAffected(db: BetterSqlite3Database, affectedFiles: string[]): void {
206
+ if (affectedFiles.length === 0) return;
207
+ const ph = affectedFiles.map(() => '?').join(',');
208
+ db.prepare(`
209
+ UPDATE co_changes SET jaccard = (
210
+ SELECT CAST(co_changes.commit_count AS REAL) / (
211
+ COALESCE(fa.commit_count, 0) + COALESCE(fb.commit_count, 0) - co_changes.commit_count
241
212
  )
242
- WHERE file_a IN (${ph}) OR file_b IN (${ph})
243
- `).run(...affectedFiles, ...affectedFiles);
244
- }
213
+ FROM file_commit_counts fa, file_commit_counts fb
214
+ WHERE fa.file = co_changes.file_a AND fb.file = co_changes.file_b
215
+ )
216
+ WHERE file_a IN (${ph}) OR file_b IN (${ph})
217
+ `).run(...affectedFiles, ...affectedFiles);
218
+ }
245
219
 
246
- // Update metadata
220
+ /** Update co_change_meta with the latest analyzer run parameters. */
221
+ function updateCoChangeMeta(
222
+ db: BetterSqlite3Database,
223
+ commits: CommitEntry[],
224
+ since: string,
225
+ minSupport: number,
226
+ ): void {
247
227
  const metaUpsert = db.prepare(`
248
228
  INSERT INTO co_change_meta (key, value) VALUES (?, ?)
249
229
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
@@ -254,6 +234,49 @@ export function analyzeCoChanges(
254
234
  metaUpsert.run('analyzed_at', new Date().toISOString());
255
235
  metaUpsert.run('since', since);
256
236
  metaUpsert.run('min_support', String(minSupport));
237
+ }
238
+
239
+ export function analyzeCoChanges(
240
+ customDbPath?: string,
241
+ opts: {
242
+ since?: string;
243
+ minSupport?: number;
244
+ maxFilesPerCommit?: number;
245
+ full?: boolean;
246
+ } = {},
247
+ ):
248
+ | { pairsFound: number; commitsScanned: number; since: string; minSupport: number }
249
+ | { error: string } {
250
+ const dbPath = findDbPath(customDbPath);
251
+ const db = openDb(dbPath);
252
+ initSchema(db);
253
+
254
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
255
+
256
+ if (!fs.existsSync(path.join(repoRoot, '.git'))) {
257
+ closeDb(db);
258
+ return { error: `Not a git repository: ${repoRoot}` };
259
+ }
260
+
261
+ const since = opts.since || '1 year ago';
262
+ const minSupport = opts.minSupport ?? 3;
263
+ const maxFilesPerCommit = opts.maxFilesPerCommit ?? 50;
264
+
265
+ const afterSha = opts.full ? null : loadLastAnalyzedSha(db);
266
+ if (opts.full) clearCoChangeTables(db);
267
+
268
+ const knownFiles = loadKnownFiles(db);
269
+
270
+ const { commits } = scanGitHistory(repoRoot, { since, afterSha });
271
+ const { pairs: coChanges, fileCommitCounts } = computeCoChanges(commits, {
272
+ minSupport,
273
+ maxFilesPerCommit,
274
+ knownFiles,
275
+ });
276
+
277
+ persistCoChangeResults(db, fileCommitCounts, coChanges);
278
+ recomputeJaccardForAffected(db, [...fileCommitCounts.keys()]);
279
+ updateCoChangeMeta(db, commits, since, minSupport);
257
280
 
258
281
  const totalPairs = db
259
282
  .prepare<{ cnt: number }>('SELECT COUNT(*) as cnt FROM co_changes')