@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
@@ -23,7 +23,13 @@ import { loadNative } from '../../../infrastructure/native.js';
23
23
  import { semverCompare } from '../../../infrastructure/update-check.js';
24
24
  import { toErrorMessage } from '../../../shared/errors.js';
25
25
  import { CODEGRAPH_VERSION } from '../../../shared/version.js';
26
- import type { BuildGraphOpts, BuildResult, Definition, ExtractorOutput } from '../../../types.js';
26
+ import type {
27
+ BetterSqlite3Database,
28
+ BuildGraphOpts,
29
+ BuildResult,
30
+ Definition,
31
+ ExtractorOutput,
32
+ } from '../../../types.js';
27
33
  import { getActiveEngine } from '../../parser.js';
28
34
  import { setWorkspaces } from '../resolve.js';
29
35
  import { PipelineContext } from './context.js';
@@ -82,10 +88,17 @@ function checkEngineSchemaMismatch(ctx: PipelineContext): void {
82
88
  );
83
89
  ctx.forceFullRebuild = true;
84
90
  }
91
+ // When the native engine is active, the Rust addon's version (ctx.engineVersion)
92
+ // is written into codegraph_version by setBuildMeta after a native orchestrator
93
+ // build. The check must compare against the same version, otherwise JS and Rust
94
+ // fight over which version to record — causing every incremental build to be
95
+ // promoted to a full rebuild when npm and crate versions diverge.
96
+ const effectiveVersion =
97
+ ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION;
85
98
  const prevVersion = meta('codegraph_version');
86
- if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
99
+ if (prevVersion && prevVersion !== effectiveVersion) {
87
100
  info(
88
- `Codegraph version changed (${prevVersion} → ${CODEGRAPH_VERSION}), promoting to full rebuild.`,
101
+ `Codegraph version changed (${prevVersion} → ${effectiveVersion}), promoting to full rebuild.`,
89
102
  );
90
103
  ctx.forceFullRebuild = true;
91
104
  }
@@ -120,42 +133,15 @@ function setupPipeline(ctx: PipelineContext): void {
120
133
  const native = enginePref !== 'wasm' ? loadNative() : null;
121
134
  ctx.nativeAvailable = !!native?.NativeDatabase;
122
135
 
123
- // Native-first: use only rusqlite for the entire pipeline (no better-sqlite3).
124
- // This eliminates the dual-connection WAL corruption problem and enables all
125
- // native fast-paths (bulkInsertNodes, classifyRolesFull, etc.).
126
- // Fallback: if native is unavailable or FORCE_JS is set, use better-sqlite3.
127
- if (
128
- ctx.nativeAvailable &&
129
- native?.NativeDatabase &&
130
- process.env.CODEGRAPH_FORCE_JS_PIPELINE !== '1'
131
- ) {
132
- try {
133
- const dir = path.dirname(ctx.dbPath);
134
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
135
- acquireAdvisoryLock(ctx.dbPath);
136
- ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
137
- ctx.nativeDb.initSchema();
138
- const proxy = new NativeDbProxy(ctx.nativeDb);
139
- proxy.__lockPath = `${ctx.dbPath}.lock`;
140
- ctx.db = proxy as unknown as typeof ctx.db;
141
- ctx.nativeFirstProxy = true;
142
- } catch (err) {
143
- warn(`NativeDatabase setup failed, falling back to better-sqlite3: ${toErrorMessage(err)}`);
144
- try {
145
- ctx.nativeDb?.close();
146
- } catch {
147
- /* ignore */
148
- }
149
- ctx.nativeDb = undefined;
150
- ctx.nativeFirstProxy = false;
151
- releaseAdvisoryLock(`${ctx.dbPath}.lock`);
152
- ctx.db = openDb(ctx.dbPath);
153
- initSchema(ctx.db);
154
- }
155
- } else {
156
- ctx.db = openDb(ctx.dbPath);
157
- initSchema(ctx.db);
158
- }
136
+ // Always use better-sqlite3 for setup it's cheap (~4ms) and only needed
137
+ // for metadata reads (schema mismatch check). NativeDatabase.openReadWrite
138
+ // is deferred to tryNativeOrchestrator, saving ~60ms on incremental builds
139
+ // where the Rust orchestrator handles the full pipeline, and avoiding the
140
+ // cost entirely on no-op builds that exit before reaching the orchestrator.
141
+ const dir = path.dirname(ctx.dbPath);
142
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
143
+ ctx.db = openDb(ctx.dbPath);
144
+ initSchema(ctx.db);
159
145
 
160
146
  ctx.config = loadConfig(ctx.rootDir);
161
147
  ctx.incremental =
@@ -260,17 +246,16 @@ interface NativeOrchestratorResult {
260
246
  changedCount?: number;
261
247
  removedCount?: number;
262
248
  isFullBuild?: boolean;
263
- /** Full changed files including reverse-dep files — used by JS structure fallback. */
264
- structureScope?: string[];
265
249
  /** Whether the Rust pipeline handled the structure phase (small-incremental fast path). */
266
250
  structureHandled?: boolean;
251
+ /** Whether the Rust pipeline wrote AST/complexity/CFG/dataflow to DB. */
252
+ analysisComplete?: boolean;
267
253
  }
268
254
 
269
255
  // ── Native orchestrator helpers ───────────────────────────────────────
270
256
 
271
257
  /** Determine whether the native orchestrator should be skipped. Returns a reason string, or null if it should run. */
272
258
  function shouldSkipNativeOrchestrator(ctx: PipelineContext): string | null {
273
- if (process.env.CODEGRAPH_FORCE_JS_PIPELINE === '1') return 'CODEGRAPH_FORCE_JS_PIPELINE=1';
274
259
  if (ctx.forceFullRebuild) return 'forceFullRebuild';
275
260
  // v3.9.0 addon had buggy incremental purge (wrong SQL on analysis tables,
276
261
  // scoped removal over-detection). Fixed in v3.9.1 by PR #865. Gate on
@@ -452,7 +437,11 @@ async function runPostNativeStructure(
452
437
  return performance.now() - structureStart;
453
438
  }
454
439
 
455
- /** Run AST/complexity/CFG/dataflow analysis after native orchestrator. */
440
+ /**
441
+ * JS fallback for AST/complexity/CFG/dataflow analysis after native orchestrator.
442
+ * Used when the Rust addon doesn't include analysis persistence (older addon
443
+ * version) or when analysis failed on the Rust side.
444
+ */
456
445
  async function runPostNativeAnalysis(
457
446
  ctx: PipelineContext,
458
447
  allFileSymbols: Map<string, ExtractorOutput>,
@@ -472,30 +461,43 @@ async function runPostNativeAnalysis(
472
461
  analysisFileSymbols = allFileSymbols;
473
462
  }
474
463
 
475
- // In native-first mode, nativeDb is already open no reopen needed.
476
- if (!ctx.nativeFirstProxy) {
477
- const native = loadNative();
478
- if (native?.NativeDatabase) {
479
- try {
480
- ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
481
- if (ctx.engineOpts) ctx.engineOpts.nativeDb = ctx.nativeDb;
482
- } catch {
483
- ctx.nativeDb = undefined;
484
- if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
485
- }
464
+ // Reopen nativeDb for analysis features (suspend/resume WAL pattern).
465
+ const native = loadNative();
466
+ if (native?.NativeDatabase) {
467
+ try {
468
+ ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
469
+ if (ctx.engineOpts) ctx.engineOpts.nativeDb = ctx.nativeDb;
470
+ } catch {
471
+ ctx.nativeDb = undefined;
472
+ if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
486
473
  }
487
- } else if (ctx.engineOpts) {
488
- ctx.engineOpts.nativeDb = ctx.nativeDb;
474
+ }
475
+
476
+ // Flush JS WAL pages once so Rust can see them, then no-op callbacks.
477
+ // Previously each feature called wal_checkpoint(TRUNCATE) individually
478
+ // (~68ms each × 3-4 features). One FULL checkpoint suffices.
479
+ if (ctx.nativeDb && ctx.engineOpts) {
480
+ ctx.db.pragma('wal_checkpoint(FULL)');
481
+ ctx.engineOpts.suspendJsDb = () => {};
482
+ ctx.engineOpts.resumeJsDb = () => {};
489
483
  }
490
484
 
491
485
  try {
492
- const { runAnalyses: runAnalysesFn } = await import('../../../ast-analysis/engine.js');
486
+ const { runAnalyses: runAnalysesFn } = (await import('../../../ast-analysis/engine.js')) as {
487
+ runAnalyses: (
488
+ db: BetterSqlite3Database,
489
+ fileSymbols: Map<string, ExtractorOutput>,
490
+ rootDir: string,
491
+ opts: Record<string, unknown>,
492
+ engineOpts?: Record<string, unknown>,
493
+ ) => Promise<{ astMs?: number; complexityMs?: number; cfgMs?: number; dataflowMs?: number }>;
494
+ };
493
495
  const result = await runAnalysesFn(
494
496
  ctx.db,
495
497
  analysisFileSymbols,
496
498
  ctx.rootDir,
497
- ctx.opts,
498
- ctx.engineOpts,
499
+ ctx.opts as Record<string, unknown>,
500
+ ctx.engineOpts as unknown as Record<string, unknown> | undefined,
499
501
  );
500
502
  timing.astMs = result.astMs ?? 0;
501
503
  timing.complexityMs = result.complexityMs ?? 0;
@@ -505,8 +507,10 @@ async function runPostNativeAnalysis(
505
507
  warn(`Analysis phases failed after native build: ${toErrorMessage(err)}`);
506
508
  }
507
509
 
508
- // Close nativeDb after analyses (skip in native-first single connection stays open)
509
- if (ctx.nativeDb && !ctx.nativeFirstProxy) {
510
+ // Close nativeDb after analyses TRUNCATE checkpoint flushes all Rust
511
+ // WAL writes so JS and external readers can see them. Runs once after
512
+ // all analysis features complete (not per-feature).
513
+ if (ctx.nativeDb) {
510
514
  try {
511
515
  ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
512
516
  } catch {
@@ -518,7 +522,11 @@ async function runPostNativeAnalysis(
518
522
  /* ignore close errors */
519
523
  }
520
524
  ctx.nativeDb = undefined;
521
- if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
525
+ if (ctx.engineOpts) {
526
+ ctx.engineOpts.nativeDb = undefined;
527
+ ctx.engineOpts.suspendJsDb = undefined;
528
+ ctx.engineOpts.resumeJsDb = undefined;
529
+ }
522
530
  }
523
531
 
524
532
  return timing;
@@ -558,15 +566,26 @@ async function tryNativeOrchestrator(
558
566
  return undefined;
559
567
  }
560
568
 
561
- // In native-first mode, nativeDb is already open from setupPipeline.
562
- // Otherwise, open it on demand (deferred to skip overhead on no-op rebuilds).
569
+ // Open NativeDatabase on demand deferred from setupPipeline to skip the
570
+ // ~60ms cost on no-op/early-exit builds. Close the better-sqlite3 connection
571
+ // first to avoid dual-connection WAL corruption.
563
572
  if (!ctx.nativeDb && ctx.nativeAvailable) {
564
573
  const native = loadNative();
565
574
  if (native?.NativeDatabase) {
566
575
  try {
576
+ // Close better-sqlite3 before opening rusqlite to avoid WAL conflicts.
577
+ // Uses raw close() instead of closeDb() intentionally — the advisory lock
578
+ // is kept and transferred to the NativeDbProxy below, not released here.
579
+ ctx.db.close();
580
+ acquireAdvisoryLock(ctx.dbPath);
567
581
  ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
568
582
  ctx.nativeDb.initSchema();
569
- ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
583
+ // Replace ctx.db with a NativeDbProxy so post-native JS fallback
584
+ // (structure, analysis) can use it without reopening better-sqlite3.
585
+ const proxy = new NativeDbProxy(ctx.nativeDb);
586
+ proxy.__lockPath = `${ctx.dbPath}.lock`;
587
+ ctx.db = proxy as unknown as typeof ctx.db;
588
+ ctx.nativeFirstProxy = true;
570
589
  } catch (err) {
571
590
  warn(`NativeDatabase setup failed, falling back to JS: ${toErrorMessage(err)}`);
572
591
  try {
@@ -575,6 +594,10 @@ async function tryNativeOrchestrator(
575
594
  debug(`tryNativeOrchestrator: close failed during fallback: ${toErrorMessage(e)}`);
576
595
  }
577
596
  ctx.nativeDb = undefined;
597
+ ctx.nativeFirstProxy = false; // defensive: reset in case future refactors move the assignment above throwing lines
598
+ releaseAdvisoryLock(`${ctx.dbPath}.lock`);
599
+ // Reopen better-sqlite3 for JS pipeline fallback
600
+ ctx.db = openDb(ctx.dbPath);
578
601
  }
579
602
  }
580
603
  }
@@ -605,14 +628,18 @@ async function tryNativeOrchestrator(
605
628
  const p = result.phases;
606
629
 
607
630
  // Sync build_meta so JS-side version/engine checks work on next build.
631
+ // Use the Rust addon version as codegraph_version when the native
632
+ // orchestrator performed the build — the Rust side's check_version_mismatch
633
+ // compares this value against CARGO_PKG_VERSION. Writing the JS
634
+ // CODEGRAPH_VERSION here would create a permanent mismatch whenever the
635
+ // npm package version diverges from the Rust crate version, forcing every
636
+ // subsequent native build to be a full rebuild (no incremental).
608
637
  setBuildMeta(ctx.db, {
609
638
  engine: ctx.engineName,
610
639
  engine_version: ctx.engineVersion || '',
611
- codegraph_version: CODEGRAPH_VERSION,
640
+ codegraph_version: ctx.engineVersion || CODEGRAPH_VERSION,
612
641
  schema_version: String(ctx.schemaVersion),
613
642
  built_at: new Date().toISOString(),
614
- node_count: String(result.nodeCount ?? 0),
615
- edge_count: String(result.edgeCount ?? 0),
616
643
  });
617
644
 
618
645
  info(
@@ -620,41 +647,51 @@ async function tryNativeOrchestrator(
620
647
  );
621
648
 
622
649
  // ── Post-native structure + analysis ──────────────────────────────
623
- let analysisTiming = { astMs: 0, complexityMs: 0, cfgMs: 0, dataflowMs: 0 };
650
+ let analysisTiming = {
651
+ astMs: +(p.astMs ?? 0),
652
+ complexityMs: +(p.complexityMs ?? 0),
653
+ cfgMs: +(p.cfgMs ?? 0),
654
+ dataflowMs: +(p.dataflowMs ?? 0),
655
+ };
624
656
  let structurePatchMs = 0;
625
- const needsAnalysis =
626
- ctx.opts.ast !== false ||
627
- ctx.opts.complexity !== false ||
628
- ctx.opts.cfg !== false ||
629
- ctx.opts.dataflow !== false;
630
657
  // Skip JS structure when the Rust pipeline's small-incremental fast path
631
658
  // already handled it. For full builds and large incrementals where Rust
632
659
  // skipped structure, we must run the JS fallback.
633
660
  const needsStructure = !result.structureHandled;
634
-
635
- if (needsAnalysis || needsStructure) {
636
- // In native-first mode the proxy is already wired — no WAL handoff needed.
637
- if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
661
+ // When the Rust addon doesn't include analysis persistence (older addon
662
+ // version or analysis failed), fall back to JS-side analysis.
663
+ const needsAnalysisFallback =
664
+ !result.analysisComplete &&
665
+ (ctx.opts.ast !== false ||
666
+ ctx.opts.complexity !== false ||
667
+ ctx.opts.cfg !== false ||
668
+ ctx.opts.dataflow !== false);
669
+
670
+ if (needsStructure || needsAnalysisFallback) {
671
+ // When analysis fallback is needed, handoff to better-sqlite3 — the
672
+ // analysis engine uses the suspend/resume WAL pattern that requires a
673
+ // real better-sqlite3 connection, not the NativeDbProxy.
674
+ if (needsAnalysisFallback && ctx.nativeFirstProxy) {
675
+ closeNativeDb(ctx, 'pre-analysis-fallback');
676
+ ctx.db = openDb(ctx.dbPath);
677
+ ctx.nativeFirstProxy = false;
678
+ } else if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
638
679
  // DB reopen failed — return partial result
639
680
  return formatNativeTimingResult(p, 0, analysisTiming);
640
681
  }
641
682
 
642
- // When structure was handled by Rust, we only need changed files for
643
- // analysis — no need to load the entire graph from DB. When structure
644
- // was NOT handled, we need all files to build the complete directory tree.
645
- const scopeFiles = needsStructure ? undefined : result.changedFiles;
646
- const fileSymbols = reconstructFileSymbolsFromDb(ctx, scopeFiles);
683
+ const fileSymbols = reconstructFileSymbolsFromDb(ctx);
647
684
 
648
685
  if (needsStructure) {
649
686
  structurePatchMs = await runPostNativeStructure(
650
687
  ctx,
651
688
  fileSymbols,
652
689
  !!result.isFullBuild,
653
- result.structureScope ?? result.changedFiles,
690
+ result.changedFiles,
654
691
  );
655
692
  }
656
693
 
657
- if (needsAnalysis) {
694
+ if (needsAnalysisFallback) {
658
695
  analysisTiming = await runPostNativeAnalysis(ctx, fileSymbols, result.changedFiles);
659
696
  }
660
697
  }
@@ -666,30 +703,7 @@ async function tryNativeOrchestrator(
666
703
  // ── Pipeline stages execution ───────────────────────────────────────────
667
704
 
668
705
  async function runPipelineStages(ctx: PipelineContext): Promise<void> {
669
- // ── Native-first mode ────────────────────────────────────────────────
670
- // When ctx.nativeFirstProxy is true, ctx.db is a NativeDbProxy backed by
671
- // the single rusqlite connection (ctx.nativeDb). No dual-connection WAL
672
- // dance is needed — every stage uses the same connection transparently.
673
- if (ctx.nativeFirstProxy) {
674
- // Ensure engineOpts.nativeDb is set so stages can use dedicated native methods.
675
- if (ctx.engineOpts) {
676
- ctx.engineOpts.nativeDb = ctx.nativeDb;
677
- }
678
-
679
- await collectFiles(ctx);
680
- await detectChanges(ctx);
681
- if (ctx.earlyExit) return;
682
- await parseFiles(ctx);
683
- await insertNodes(ctx);
684
- await resolveImports(ctx);
685
- await buildEdges(ctx);
686
- await buildStructure(ctx);
687
- await runAnalyses(ctx);
688
- await finalize(ctx);
689
- return;
690
- }
691
-
692
- // ── Legacy dual-connection mode (WASM / fallback) ────────────────────
706
+ // ── WASM / fallback dual-connection mode ─────────────────────────────
693
707
  // NativeDatabase is deferred — not opened during setup. collectFiles and
694
708
  // detectChanges only need better-sqlite3. If no files changed, we exit
695
709
  // early without ever opening the native connection, saving ~5ms.
@@ -697,6 +711,13 @@ async function runPipelineStages(ctx: PipelineContext): Promise<void> {
697
711
  // suspend it now to avoid dual-connection WAL corruption during stages.
698
712
  if (ctx.db && ctx.nativeDb) {
699
713
  suspendNativeDb(ctx, 'pre-collect');
714
+ // When nativeFirstProxy is true, ctx.db is a NativeDbProxy wrapping the
715
+ // now-closed NativeDatabase. Replace it with a real better-sqlite3
716
+ // connection so the JS pipeline stages can operate normally.
717
+ if (ctx.nativeFirstProxy) {
718
+ ctx.db = openDb(ctx.dbPath);
719
+ ctx.nativeFirstProxy = false;
720
+ }
700
721
  }
701
722
 
702
723
  await collectFiles(ctx);
@@ -728,25 +749,20 @@ async function runPipelineStages(ctx: PipelineContext): Promise<void> {
728
749
  await buildEdges(ctx);
729
750
  await buildStructure(ctx);
730
751
 
731
- // Reopen nativeDb for feature modules (ast, cfg, complexity, dataflow)
732
- // which use suspendJsDb/resumeJsDb WAL checkpoint before native writes.
752
+ // Reopen nativeDb for feature modules (ast, cfg, complexity, dataflow).
733
753
  // Skip for small incremental builds — same rationale as insertNodes above.
754
+ //
755
+ // Perf: do ONE upfront FULL checkpoint to flush JS WAL pages so Rust
756
+ // can see the latest rows, then make suspendJsDb/resumeJsDb no-ops.
757
+ // Previously each feature called wal_checkpoint(TRUNCATE) individually
758
+ // (~68ms each × 3-4 features = ~200-270ms overhead on incremental builds).
734
759
  if (ctx.nativeAvailable && !smallIncremental) {
735
760
  reopenNativeDb(ctx, 'analyses');
736
761
  if (ctx.nativeDb && ctx.engineOpts) {
762
+ ctx.db.pragma('wal_checkpoint(FULL)');
737
763
  ctx.engineOpts.nativeDb = ctx.nativeDb;
738
- ctx.engineOpts.suspendJsDb = () => {
739
- ctx.db.pragma('wal_checkpoint(TRUNCATE)');
740
- };
741
- ctx.engineOpts.resumeJsDb = () => {
742
- try {
743
- ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
744
- } catch (e) {
745
- debug(
746
- `resumeJsDb: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
747
- );
748
- }
749
- };
764
+ ctx.engineOpts.suspendJsDb = () => {};
765
+ ctx.engineOpts.resumeJsDb = () => {};
750
766
  }
751
767
  if (!ctx.nativeDb && ctx.engineOpts) {
752
768
  ctx.engineOpts.nativeDb = undefined;
@@ -757,11 +773,32 @@ async function runPipelineStages(ctx: PipelineContext): Promise<void> {
757
773
 
758
774
  await runAnalyses(ctx);
759
775
 
760
- // Keep nativeDb open through finalize so persistBuildMetadata, advisory
761
- // checks, and count queries use the native path. closeDbPair inside
762
- // finalize handles both connections. Refresh the JS db so it has a
763
- // valid page cache in case finalize falls back to JS paths (#751).
776
+ // Release WASM trees deterministically on the success path — same cleanup
777
+ // as the error-path catch block. Without this, trees stay allocated until
778
+ // GC collects ctx, holding WASM memory for the rest of the build (#931).
779
+ if (ctx.allSymbols?.size > 0) {
780
+ for (const [, symbols] of ctx.allSymbols) {
781
+ const tree = symbols._tree as { delete?: () => void } | undefined;
782
+ if (tree && typeof tree.delete === 'function') {
783
+ try {
784
+ tree.delete();
785
+ } catch {
786
+ /* ignore cleanup errors */
787
+ }
788
+ }
789
+ symbols._tree = undefined;
790
+ }
791
+ }
792
+
793
+ // Flush Rust WAL writes (AST, complexity, CFG, dataflow) so the JS
794
+ // connection and any post-build readers can see them. One TRUNCATE
795
+ // here replaces the N per-feature resumeJsDb checkpoints (#checkpoint-opt).
764
796
  if (ctx.nativeDb) {
797
+ try {
798
+ ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
799
+ } catch (e) {
800
+ debug(`post-analyses WAL checkpoint failed: ${toErrorMessage(e)}`);
801
+ }
765
802
  refreshJsDb(ctx);
766
803
  }
767
804
 
@@ -797,13 +834,42 @@ export async function buildGraph(
797
834
  if (nativeResult) return nativeResult;
798
835
  } catch (err) {
799
836
  warn(`Native build orchestrator failed, falling back to JS pipeline: ${toErrorMessage(err)}`);
800
- // Fall through to JS pipeline
837
+ // The version gate in checkEngineSchemaMismatch was skipped because
838
+ // nativeAvailable was true. Now that we're falling back to the JS
839
+ // pipeline, perform the codegraph_version check here so a version
840
+ // bump still promotes to a full rebuild (#928).
841
+ if (ctx.incremental && !ctx.forceFullRebuild) {
842
+ const prevVersion = getBuildMeta(ctx.db, 'codegraph_version');
843
+ if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
844
+ info(
845
+ `Codegraph version changed (${prevVersion} → ${CODEGRAPH_VERSION}), promoting to full rebuild.`,
846
+ );
847
+ ctx.forceFullRebuild = true;
848
+ }
849
+ }
801
850
  }
802
851
 
803
852
  await runPipelineStages(ctx);
804
853
  } catch (err) {
805
- if (!ctx.earlyExit && ctx.db) {
806
- closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb });
854
+ if (!ctx.earlyExit) {
855
+ // Release WASM trees before closing DB to prevent V8 crash during
856
+ // GC cleanup of orphaned WASM objects (#931).
857
+ if (ctx.allSymbols?.size > 0) {
858
+ for (const [, symbols] of ctx.allSymbols) {
859
+ const tree = symbols._tree as { delete?: () => void } | undefined;
860
+ if (tree && typeof tree.delete === 'function') {
861
+ try {
862
+ tree.delete();
863
+ } catch {
864
+ /* ignore cleanup errors */
865
+ }
866
+ }
867
+ symbols._tree = undefined;
868
+ }
869
+ }
870
+ if (ctx.db) {
871
+ closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb });
872
+ }
807
873
  }
808
874
  throw err;
809
875
  }
@@ -699,6 +699,69 @@ function buildClassHierarchyEdges(
699
699
  }
700
700
  }
701
701
 
702
+ // ── Reverse-dep edge reconnection (#932, #933) ─────────────────────────
703
+
704
+ /**
705
+ * Reconnect edges that were saved before changed-file purge.
706
+ *
707
+ * Each saved edge records: sourceId (still valid — reverse-dep nodes were not
708
+ * purged) and target attributes (name, kind, file, line). The target node was
709
+ * deleted and re-inserted with a new ID by insertNodes. We look up the new ID
710
+ * by (name, kind, file) and re-create the edge.
711
+ */
712
+ function reconnectReverseDepEdges(ctx: PipelineContext): void {
713
+ const { db } = ctx;
714
+ const findNodeStmt = db.prepare(
715
+ 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? ORDER BY ABS(line - ?) LIMIT 1',
716
+ );
717
+ const reconnectedRows: EdgeRowTuple[] = [];
718
+ let dropped = 0;
719
+
720
+ for (const saved of ctx.savedReverseDepEdges) {
721
+ const newTarget = findNodeStmt.get(
722
+ saved.tgtName,
723
+ saved.tgtKind,
724
+ saved.tgtFile,
725
+ saved.tgtLine,
726
+ ) as { id: number } | undefined;
727
+ if (newTarget) {
728
+ reconnectedRows.push([
729
+ saved.sourceId,
730
+ newTarget.id,
731
+ saved.edgeKind,
732
+ saved.confidence,
733
+ saved.dynamic,
734
+ ]);
735
+ } else {
736
+ // Target was removed or renamed in the changed file — edge is stale
737
+ dropped++;
738
+ }
739
+ }
740
+
741
+ if (reconnectedRows.length > 0) {
742
+ if (ctx.nativeDb?.bulkInsertEdges) {
743
+ const nativeEdges = reconnectedRows.map((r) => ({
744
+ sourceId: r[0],
745
+ targetId: r[1],
746
+ kind: r[2],
747
+ confidence: r[3],
748
+ dynamic: r[4],
749
+ }));
750
+ const ok = ctx.nativeDb.bulkInsertEdges(nativeEdges);
751
+ if (!ok) {
752
+ batchInsertEdges(db, reconnectedRows);
753
+ }
754
+ } else {
755
+ batchInsertEdges(db, reconnectedRows);
756
+ }
757
+ }
758
+
759
+ debug(
760
+ `Reconnected ${reconnectedRows.length} reverse-dep edges` +
761
+ (dropped > 0 ? ` (${dropped} dropped — targets removed/renamed)` : ''),
762
+ );
763
+ }
764
+
702
765
  // ── Main entry point ────────────────────────────────────────────────────
703
766
 
704
767
  /**
@@ -798,10 +861,11 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
798
861
  }
799
862
  }
800
863
 
801
- // Skip native import-edge path for small incremental builds (≤3 files):
802
- // napi-rs marshaling overhead exceeds computation savings.
864
+ // Skip native import-edge path for small incremental builds: napi-rs
865
+ // marshaling overhead (~13ms) exceeds Rust computation savings at this scale.
803
866
  const useNativeImportEdges =
804
- native?.buildImportEdges && (ctx.isFullBuild || ctx.fileSymbols.size > 3);
867
+ native?.buildImportEdges &&
868
+ (ctx.isFullBuild || ctx.fileSymbols.size > ctx.config.build.smallFilesThreshold);
805
869
  if (useNativeImportEdges) {
806
870
  const beforeLen = allEdgeRows.length;
807
871
  buildImportEdgesNative(ctx, getNodeIdStmt, allEdgeRows, native!);
@@ -821,10 +885,11 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
821
885
  buildImportEdges(ctx, getNodeIdStmt, allEdgeRows);
822
886
  }
823
887
 
824
- // Skip native call-edge path for small incremental builds (≤3 files):
825
- // napi-rs marshaling overhead for allNodes exceeds computation savings.
888
+ // Skip native call-edge path for small incremental builds: napi-rs
889
+ // marshaling overhead for allNodes exceeds Rust computation savings.
826
890
  const useNativeCallEdges =
827
- native?.buildCallEdges && (ctx.isFullBuild || ctx.fileSymbols.size > 3);
891
+ native?.buildCallEdges &&
892
+ (ctx.isFullBuild || ctx.fileSymbols.size > ctx.config.build.smallFilesThreshold);
828
893
  if (useNativeCallEdges) {
829
894
  buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodesBefore, native!);
830
895
  } else {
@@ -858,5 +923,14 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
858
923
  }
859
924
  }
860
925
 
926
+ // Phase 3: Reconnect saved reverse-dep edges (#932, #933).
927
+ // When the WASM/JS path purged changed files, edges FROM reverse-dep files TO
928
+ // those files were deleted (target-side). The reverse-dep files were NOT
929
+ // reparsed — instead we saved the edge topology before purge and now reconnect
930
+ // each edge to the new node IDs created by insertNodes.
931
+ if (ctx.savedReverseDepEdges.length > 0) {
932
+ reconnectReverseDepEdges(ctx);
933
+ }
934
+
861
935
  ctx.timing.edgesMs = performance.now() - t0;
862
936
  }
@@ -174,13 +174,13 @@ function updateChangedFileMetrics(ctx: PipelineContext, changedFiles: string[]):
174
174
  SELECT COUNT(DISTINCT n_src.file) AS cnt FROM edges e
175
175
  JOIN nodes n_src ON e.source_id = n_src.id
176
176
  JOIN nodes n_tgt ON e.target_id = n_tgt.id
177
- WHERE e.kind = 'imports' AND n_tgt.file = ? AND n_src.file != n_tgt.file
177
+ WHERE e.kind IN ('imports', 'imports-type') AND n_tgt.file = ? AND n_src.file != n_tgt.file
178
178
  `);
179
179
  const getFanOut = db.prepare(`
180
180
  SELECT COUNT(DISTINCT n_tgt.file) AS cnt FROM edges e
181
181
  JOIN nodes n_src ON e.source_id = n_src.id
182
182
  JOIN nodes n_tgt ON e.target_id = n_tgt.id
183
- WHERE e.kind = 'imports' AND n_src.file = ? AND n_src.file != n_tgt.file
183
+ WHERE e.kind IN ('imports', 'imports-type') AND n_src.file = ? AND n_src.file != n_tgt.file
184
184
  `);
185
185
  const upsertMetric = db.prepare(`
186
186
  INSERT OR REPLACE INTO node_metrics