@optave/codegraph 3.9.2 → 3.9.4

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 (76) hide show
  1. package/README.md +93 -10
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +64 -0
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitor.d.ts.map +1 -1
  6. package/dist/ast-analysis/visitor.js +14 -0
  7. package/dist/ast-analysis/visitor.js.map +1 -1
  8. package/dist/domain/analysis/diff-impact.d.ts +12 -0
  9. package/dist/domain/analysis/diff-impact.d.ts.map +1 -1
  10. package/dist/domain/analysis/diff-impact.js +20 -1
  11. package/dist/domain/analysis/diff-impact.js.map +1 -1
  12. package/dist/domain/graph/builder/context.d.ts +15 -0
  13. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  14. package/dist/domain/graph/builder/context.js +7 -0
  15. package/dist/domain/graph/builder/context.js.map +1 -1
  16. package/dist/domain/graph/builder/native-db-proxy.d.ts.map +1 -1
  17. package/dist/domain/graph/builder/native-db-proxy.js +8 -4
  18. package/dist/domain/graph/builder/native-db-proxy.js.map +1 -1
  19. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  20. package/dist/domain/graph/builder/pipeline.js +176 -127
  21. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  22. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  23. package/dist/domain/graph/builder/stages/build-edges.js +67 -6
  24. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  25. package/dist/domain/graph/builder/stages/build-structure.js +2 -2
  26. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  27. package/dist/domain/graph/builder/stages/detect-changes.js +51 -10
  28. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  29. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  30. package/dist/domain/graph/builder/stages/finalize.js +10 -4
  31. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  32. package/dist/domain/graph/builder/stages/run-analyses.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/stages/run-analyses.js +5 -20
  34. package/dist/domain/graph/builder/stages/run-analyses.js.map +1 -1
  35. package/dist/domain/parser.d.ts.map +1 -1
  36. package/dist/domain/parser.js +6 -2
  37. package/dist/domain/parser.js.map +1 -1
  38. package/dist/extractors/javascript.js +120 -0
  39. package/dist/extractors/javascript.js.map +1 -1
  40. package/dist/features/ast.js +2 -2
  41. package/dist/features/ast.js.map +1 -1
  42. package/dist/features/cfg.d.ts +1 -1
  43. package/dist/features/cfg.d.ts.map +1 -1
  44. package/dist/features/cfg.js +52 -6
  45. package/dist/features/cfg.js.map +1 -1
  46. package/dist/features/complexity.d.ts.map +1 -1
  47. package/dist/features/complexity.js +7 -0
  48. package/dist/features/complexity.js.map +1 -1
  49. package/dist/features/structure.d.ts.map +1 -1
  50. package/dist/features/structure.js +14 -1
  51. package/dist/features/structure.js.map +1 -1
  52. package/dist/infrastructure/update-check.d.ts +1 -1
  53. package/dist/infrastructure/update-check.js +3 -3
  54. package/dist/infrastructure/update-check.js.map +1 -1
  55. package/dist/types.d.ts +1 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +7 -7
  58. package/src/ast-analysis/engine.ts +83 -0
  59. package/src/ast-analysis/visitor.ts +15 -0
  60. package/src/domain/analysis/diff-impact.ts +28 -1
  61. package/src/domain/graph/builder/context.ts +17 -0
  62. package/src/domain/graph/builder/native-db-proxy.ts +10 -4
  63. package/src/domain/graph/builder/pipeline.ts +196 -130
  64. package/src/domain/graph/builder/stages/build-edges.ts +80 -6
  65. package/src/domain/graph/builder/stages/build-structure.ts +2 -2
  66. package/src/domain/graph/builder/stages/detect-changes.ts +61 -12
  67. package/src/domain/graph/builder/stages/finalize.ts +11 -4
  68. package/src/domain/graph/builder/stages/run-analyses.ts +5 -26
  69. package/src/domain/parser.ts +6 -2
  70. package/src/extractors/javascript.ts +142 -0
  71. package/src/features/ast.ts +2 -2
  72. package/src/features/cfg.ts +51 -6
  73. package/src/features/complexity.ts +7 -0
  74. package/src/features/structure.ts +17 -1
  75. package/src/infrastructure/update-check.ts +3 -3
  76. package/src/types.ts +1 -0
@@ -374,24 +374,73 @@ function purgeAndAddReverseDeps(
374
374
  // Prefer NativeDatabase: purge + reverse-dep edge deletion in one transaction (#670)
375
375
  if (ctx.engineName === 'native' && ctx.nativeDb?.purgeFilesData) {
376
376
  ctx.nativeDb.purgeFilesData(filesToPurge, false, hasReverseDeps ? reverseDepList : undefined);
377
+ // Native path still reparses reverse-deps (works correctly with native edge builder)
378
+ for (const relPath of reverseDeps) {
379
+ const absPath = path.join(rootDir, relPath);
380
+ ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true });
381
+ }
377
382
  } else {
383
+ // WASM/JS path: save edges from reverse-dep files → changed files BEFORE
384
+ // purge, then reconnect them to new node IDs after insertNodes (#932, #933).
385
+ //
386
+ // purgeFilesFromGraph deletes edges in BOTH directions for changed files,
387
+ // which already removes the reverse-dep → changed-file edges. The old
388
+ // approach then over-deleted ALL outgoing edges from reverse-dep files and
389
+ // reparsed them to rebuild everything — expensive (87 extra parses) and
390
+ // lossy (442 missing edges due to imperfect resolution on rebuild).
391
+ //
392
+ // New approach: save the edge topology, let purge handle deletion, then
393
+ // reconnect using new node IDs. No reparse needed.
394
+ if (hasReverseDeps && hasPurge) {
395
+ const changePathSet = new Set(changePaths);
396
+ const saveEdgesStmt = db.prepare(`
397
+ SELECT e.source_id, n_tgt.name AS tgt_name, n_tgt.kind AS tgt_kind,
398
+ n_tgt.file AS tgt_file, n_tgt.line AS tgt_line,
399
+ e.kind AS edge_kind, e.confidence, e.dynamic,
400
+ n_src.file AS src_file
401
+ FROM edges e
402
+ JOIN nodes n_src ON e.source_id = n_src.id
403
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
404
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file
405
+ `);
406
+ for (const changedPath of changePaths) {
407
+ for (const row of saveEdgesStmt.all(changedPath) as Array<{
408
+ source_id: number;
409
+ tgt_name: string;
410
+ tgt_kind: string;
411
+ tgt_file: string;
412
+ tgt_line: number;
413
+ edge_kind: string;
414
+ confidence: number;
415
+ dynamic: number;
416
+ src_file: string;
417
+ }>) {
418
+ // Skip edges whose source is also being purged — buildEdges will
419
+ // re-create them with correct new IDs.
420
+ if (changePathSet.has(row.src_file)) continue;
421
+ ctx.savedReverseDepEdges.push({
422
+ sourceId: row.source_id,
423
+ tgtName: row.tgt_name,
424
+ tgtKind: row.tgt_kind,
425
+ tgtFile: row.tgt_file,
426
+ tgtLine: row.tgt_line,
427
+ edgeKind: row.edge_kind,
428
+ confidence: row.confidence,
429
+ dynamic: row.dynamic,
430
+ });
431
+ }
432
+ }
433
+ debug(`Saved ${ctx.savedReverseDepEdges.length} reverse-dep edges for reconnection`);
434
+ }
435
+
378
436
  if (hasPurge) {
379
437
  purgeFilesFromGraph(db, filesToPurge, { purgeHashes: false });
380
438
  }
381
- if (hasReverseDeps) {
382
- const deleteOutgoingEdgesForFile = db.prepare(
383
- 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
384
- );
385
- for (const relPath of reverseDepList) {
386
- deleteOutgoingEdgesForFile.run(relPath);
387
- }
388
- }
439
+ // No outgoing-edge deletion for reverse-deps — purge already removed
440
+ // edges targeting the changed files, and other outgoing edges are valid.
441
+ // No reverse-deps added to parseChanges no reparse needed.
389
442
  }
390
443
  }
391
- for (const relPath of reverseDeps) {
392
- const absPath = path.join(rootDir, relPath);
393
- ctx.parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true });
394
- }
395
444
  }
396
445
 
397
446
  function detectHasEmbeddings(db: BetterSqlite3Database, nativeDb?: NativeDatabase): boolean {
@@ -81,13 +81,20 @@ function persistBuildMetadata(
81
81
  ): void {
82
82
  const useNativeDb = ctx.engineName === 'native' && !!ctx.nativeDb;
83
83
  if (!ctx.isFullBuild && ctx.allSymbols.size <= 3) return;
84
+ // When the native engine is active, persist the Rust addon version so that
85
+ // checkEngineSchemaMismatch compares against the same value on the next build.
86
+ // Writing CODEGRAPH_VERSION (the npm package version) here would create a
87
+ // permanent mismatch whenever npm and crate versions diverge, forcing every
88
+ // subsequent build to be a full rebuild.
89
+ const codeVersionToWrite =
90
+ ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION;
84
91
  try {
85
92
  if (useNativeDb) {
86
93
  ctx.nativeDb!.setBuildMeta(
87
94
  Object.entries({
88
95
  engine: ctx.engineName,
89
- engine_version: CODEGRAPH_VERSION,
90
- codegraph_version: CODEGRAPH_VERSION,
96
+ engine_version: codeVersionToWrite,
97
+ codegraph_version: codeVersionToWrite,
91
98
  schema_version: String(ctx.schemaVersion),
92
99
  built_at: buildNow.toISOString(),
93
100
  node_count: String(nodeCount),
@@ -97,8 +104,8 @@ function persistBuildMetadata(
97
104
  } else {
98
105
  setBuildMeta(ctx.db, {
99
106
  engine: ctx.engineName,
100
- engine_version: CODEGRAPH_VERSION,
101
- codegraph_version: CODEGRAPH_VERSION,
107
+ engine_version: codeVersionToWrite,
108
+ codegraph_version: codeVersionToWrite,
102
109
  schema_version: String(ctx.schemaVersion),
103
110
  built_at: buildNow.toISOString(),
104
111
  node_count: nodeCount,
@@ -2,39 +2,18 @@
2
2
  * Stage: runAnalyses
3
3
  *
4
4
  * Dispatches to the unified AST analysis engine (AST nodes, complexity, CFG, dataflow).
5
- * Filters out reverse-dep files for incremental builds.
5
+ * Reverse-dep files are no longer in allSymbols (they are not reparsed since #932/#933),
6
+ * so no filtering is needed here.
6
7
  */
7
- import { debug, warn } from '../../../../infrastructure/logger.js';
8
- import type { ExtractorOutput } from '../../../../types.js';
8
+ import { warn } from '../../../../infrastructure/logger.js';
9
9
  import type { PipelineContext } from '../context.js';
10
10
 
11
11
  export async function runAnalyses(ctx: PipelineContext): Promise<void> {
12
- const { db, allSymbols, rootDir, opts, engineOpts, isFullBuild, filesToParse } = ctx;
13
-
14
- // For incremental builds, exclude reverse-dep-only files
15
- let astComplexitySymbols: Map<string, ExtractorOutput> = allSymbols;
16
- if (!isFullBuild) {
17
- const reverseDepFiles = new Set(
18
- filesToParse
19
- .filter((item) => (item as { _reverseDepOnly?: boolean })._reverseDepOnly)
20
- .map((item) => item.relPath),
21
- );
22
- if (reverseDepFiles.size > 0) {
23
- astComplexitySymbols = new Map();
24
- for (const [relPath, symbols] of allSymbols) {
25
- if (!reverseDepFiles.has(relPath)) {
26
- astComplexitySymbols.set(relPath, symbols);
27
- }
28
- }
29
- debug(
30
- `AST/complexity/CFG/dataflow: processing ${astComplexitySymbols.size} changed files (skipping ${reverseDepFiles.size} reverse-deps)`,
31
- );
32
- }
33
- }
12
+ const { db, allSymbols, rootDir, opts, engineOpts } = ctx;
34
13
 
35
14
  const { runAnalyses: runAnalysesFn } = await import('../../../../ast-analysis/engine.js');
36
15
  try {
37
- const analysisTiming = await runAnalysesFn(db, astComplexitySymbols, rootDir, opts, engineOpts);
16
+ const analysisTiming = await runAnalysesFn(db, allSymbols, rootDir, opts, engineOpts);
38
17
  ctx.timing.astMs = analysisTiming.astMs;
39
18
  ctx.timing.complexityMs = analysisTiming.complexityMs;
40
19
  ctx.timing.cfgMs = analysisTiming.cfgMs;
@@ -780,7 +780,7 @@ export async function parseFileAuto(
780
780
  const { native } = resolveEngine(opts);
781
781
 
782
782
  if (native) {
783
- const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false);
783
+ const result = native.parseFile(filePath, source, true, true);
784
784
  if (!result) return null;
785
785
  const patched = patchNativeResult(result);
786
786
  // Always backfill typeMap for TS/TSX from WASM — native parser's type
@@ -878,7 +878,11 @@ export async function parseFilesAuto(
878
878
  if (!native) return parseFilesWasm(filePaths, rootDir);
879
879
 
880
880
  const result = new Map<string, ExtractorOutput>();
881
- const nativeResults = native.parseFiles(filePaths, rootDir, !!opts.dataflow, opts.ast !== false);
881
+ // Always extract all analysis data (dataflow + AST nodes) during native parse.
882
+ // This eliminates the need for any downstream WASM re-parse or native standalone calls.
883
+ const nativeResults = native.parseFilesFull
884
+ ? native.parseFilesFull(filePaths, rootDir)
885
+ : native.parseFiles(filePaths, rootDir, true, true);
882
886
  const needsTypeMap: { filePath: string; relPath: string }[] = [];
883
887
  for (const r of nativeResults) {
884
888
  if (!r) continue;
@@ -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
 
@@ -1167,6 +1277,38 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode):
1167
1277
  return null;
1168
1278
  }
1169
1279
 
1280
+ /**
1281
+ * Extract Call entries for named function references passed as arguments.
1282
+ * e.g. `router.use(handleToken, checkAuth)` yields calls to handleToken and checkAuth.
1283
+ * `app.use(auth.validate)` yields a call to validate with receiver auth.
1284
+ * Skips literals, objects, arrays, anonymous functions, and call expressions (already handled).
1285
+ */
1286
+ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
1287
+ const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
1288
+ if (!args) return [];
1289
+
1290
+ const result: Call[] = [];
1291
+ const callLine = callNode.startPosition.row + 1;
1292
+
1293
+ for (let i = 0; i < args.childCount; i++) {
1294
+ const child = args.child(i);
1295
+ if (!child) continue;
1296
+
1297
+ if (child.type === 'identifier') {
1298
+ result.push({ name: child.text, line: callLine, dynamic: true });
1299
+ } else if (child.type === 'member_expression') {
1300
+ const prop = child.childForFieldName('property');
1301
+ const obj = child.childForFieldName('object');
1302
+ if (prop) {
1303
+ const receiver = extractReceiverName(obj);
1304
+ result.push({ name: prop.text, line: callLine, dynamic: true, receiver });
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ return result;
1310
+ }
1311
+
1170
1312
  function findAnonymousCallback(argsNode: TreeSitterNode): TreeSitterNode | null {
1171
1313
  for (let i = 0; i < argsNode.childCount; i++) {
1172
1314
  const child = argsNode.child(i);
@@ -115,8 +115,8 @@ function tryNativeBulkInsert(
115
115
  receiver: n.receiver ?? '',
116
116
  })),
117
117
  });
118
- } else if (symbols.calls || symbols._tree) {
119
- return false; // needs JS fallback
118
+ } else if (symbols._tree) {
119
+ return false; // has WASM tree not yet processed — needs JS fallback
120
120
  }
121
121
  }
122
122
 
@@ -369,7 +369,7 @@ export async function buildCFGData(
369
369
  db: BetterSqlite3Database,
370
370
  fileSymbols: Map<string, FileSymbols>,
371
371
  rootDir: string,
372
- _engineOpts?: {
372
+ engineOpts?: {
373
373
  nativeDb?: { bulkInsertCfg?(entries: Array<Record<string, unknown>>): number };
374
374
  suspendJsDb?: () => void;
375
375
  resumeJsDb?: () => void;
@@ -379,11 +379,56 @@ export async function buildCFGData(
379
379
  // skip WASM parser init, tree parsing, and JS visitor entirely — just persist.
380
380
  const allNative = allCfgNative(fileSymbols);
381
381
 
382
- // NOTE: nativeDb.bulkInsertCfg is intentionally NOT used here.
383
- // The CFG path requires delete-before-insert (deleteCfgForNode) which creates
384
- // a dual-connection WAL conflict when deletes go through JS (better-sqlite3)
385
- // and inserts go through native (rusqlite). The JS-only persistNativeFileCfg
386
- // path below handles both on a single connection safely.
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.
385
+ 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;
391
+
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
+ }
419
+
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`);
429
+ }
430
+ return;
431
+ }
387
432
 
388
433
  const extToLang = buildExtToLangMap();
389
434
  let parsers: unknown = null;
@@ -545,6 +545,10 @@ function collectNativeBulkRows(
545
545
  const rows: Array<Record<string, unknown>> = [];
546
546
 
547
547
  for (const [relPath, symbols] of fileSymbols) {
548
+ const ext = path.extname(relPath).toLowerCase();
549
+ const langId = symbols._langId || '';
550
+ const langSupported = COMPLEXITY_EXTENSIONS.has(ext) || COMPLEXITY_RULES.has(langId);
551
+
548
552
  for (const def of symbols.definitions) {
549
553
  if (def.kind !== 'function' && def.kind !== 'method') continue;
550
554
  if (!def.line) continue;
@@ -554,6 +558,9 @@ function collectNativeBulkRows(
554
558
  // of the native bulk-insert path for every TypeScript codebase (#846).
555
559
  if (!def.complexity) {
556
560
  if (def.name.includes('.') || !def.endLine || def.endLine <= def.line) continue;
561
+ // Languages without complexity rules will never have data — skip them
562
+ // rather than bailing out of the entire native bulk path.
563
+ if (!langSupported) continue;
557
564
  return null; // genuine function body missing complexity — needs JS fallback
558
565
  }
559
566
  const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
@@ -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;
@@ -18,11 +18,11 @@ interface UpdateCache {
18
18
 
19
19
  /**
20
20
  * Minimal semver comparison. Returns -1, 0, or 1.
21
- * Only handles numeric x.y.z (no pre-release tags).
21
+ * Strips pre-release suffixes (e.g. "3.9.3-dev.6" → "3.9.3") before comparing.
22
22
  */
23
23
  export function semverCompare(a: string, b: string): -1 | 0 | 1 {
24
- const pa = a.split('.').map(Number);
25
- const pb = b.split('.').map(Number);
24
+ const pa = a.replace(/-.*$/, '').split('.').map(Number);
25
+ const pb = b.replace(/-.*$/, '').split('.').map(Number);
26
26
  for (let i = 0; i < 3; i++) {
27
27
  const na = pa[i] || 0;
28
28
  const nb = pb[i] || 0;
package/src/types.ts CHANGED
@@ -1874,6 +1874,7 @@ export type StmtCache<TRow = unknown> = WeakMap<BetterSqlite3Database, SqliteSta
1874
1874
  export interface NativeAddon {
1875
1875
  parseFile(filePath: string, source: string, dataflow: boolean, ast: boolean): unknown;
1876
1876
  parseFiles(files: string[], rootDir: string, dataflow: boolean, ast: boolean): unknown[];
1877
+ parseFilesFull?(files: string[], rootDir: string): unknown[];
1877
1878
  resolveImport(fromFile: string, importSource: string, rootDir: string, aliases: unknown): string;
1878
1879
  resolveImports(
1879
1880
  items: Array<{ fromFile: string; importSource: string }>,