@optave/codegraph 3.9.1 → 3.9.2

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 (123) hide show
  1. package/README.md +12 -14
  2. package/dist/cli/commands/batch.d.ts.map +1 -1
  3. package/dist/cli/commands/batch.js +5 -17
  4. package/dist/cli/commands/batch.js.map +1 -1
  5. package/dist/cli/commands/structure.d.ts.map +1 -1
  6. package/dist/cli/commands/structure.js +18 -1
  7. package/dist/cli/commands/structure.js.map +1 -1
  8. package/dist/db/connection.d.ts +2 -0
  9. package/dist/db/connection.d.ts.map +1 -1
  10. package/dist/db/connection.js +2 -2
  11. package/dist/db/connection.js.map +1 -1
  12. package/dist/db/index.d.ts +1 -1
  13. package/dist/db/index.d.ts.map +1 -1
  14. package/dist/db/index.js +1 -1
  15. package/dist/db/index.js.map +1 -1
  16. package/dist/domain/analysis/context.d.ts.map +1 -1
  17. package/dist/domain/analysis/context.js +5 -15
  18. package/dist/domain/analysis/context.js.map +1 -1
  19. package/dist/domain/analysis/dependencies.d.ts +5 -5
  20. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  21. package/dist/domain/analysis/dependencies.js +6 -16
  22. package/dist/domain/analysis/dependencies.js.map +1 -1
  23. package/dist/domain/analysis/fn-impact.js +2 -2
  24. package/dist/domain/analysis/fn-impact.js.map +1 -1
  25. package/dist/domain/analysis/implementations.d.ts.map +1 -1
  26. package/dist/domain/analysis/implementations.js +3 -13
  27. package/dist/domain/analysis/implementations.js.map +1 -1
  28. package/dist/domain/graph/builder/context.d.ts +4 -0
  29. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  30. package/dist/domain/graph/builder/context.js +4 -0
  31. package/dist/domain/graph/builder/context.js.map +1 -1
  32. package/dist/domain/graph/builder/native-db-proxy.d.ts +24 -0
  33. package/dist/domain/graph/builder/native-db-proxy.d.ts.map +1 -0
  34. package/dist/domain/graph/builder/native-db-proxy.js +87 -0
  35. package/dist/domain/graph/builder/native-db-proxy.js.map +1 -0
  36. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/pipeline.js +133 -69
  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 +15 -2
  41. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  42. package/dist/domain/graph/builder/stages/build-structure.js +2 -2
  43. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  44. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  45. package/dist/domain/graph/builder/stages/detect-changes.js +6 -28
  46. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  47. package/dist/domain/graph/builder/stages/finalize.js +1 -1
  48. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  49. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/stages/insert-nodes.js +16 -12
  51. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/resolve-imports.js +2 -3
  54. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  55. package/dist/domain/parser.d.ts.map +1 -1
  56. package/dist/domain/parser.js +5 -2
  57. package/dist/domain/parser.js.map +1 -1
  58. package/dist/domain/queries.d.ts +1 -1
  59. package/dist/domain/queries.d.ts.map +1 -1
  60. package/dist/domain/queries.js +1 -1
  61. package/dist/domain/queries.js.map +1 -1
  62. package/dist/features/audit.d.ts.map +1 -1
  63. package/dist/features/audit.js +3 -2
  64. package/dist/features/audit.js.map +1 -1
  65. package/dist/features/boundaries.d.ts.map +1 -1
  66. package/dist/features/boundaries.js +3 -5
  67. package/dist/features/boundaries.js.map +1 -1
  68. package/dist/features/branch-compare.d.ts.map +1 -1
  69. package/dist/features/branch-compare.js +2 -1
  70. package/dist/features/branch-compare.js.map +1 -1
  71. package/dist/features/flow.d.ts.map +1 -1
  72. package/dist/features/flow.js +2 -1
  73. package/dist/features/flow.js.map +1 -1
  74. package/dist/features/manifesto.d.ts.map +1 -1
  75. package/dist/features/manifesto.js +15 -1
  76. package/dist/features/manifesto.js.map +1 -1
  77. package/dist/infrastructure/config.d.ts +1 -0
  78. package/dist/infrastructure/config.d.ts.map +1 -1
  79. package/dist/infrastructure/config.js +1 -0
  80. package/dist/infrastructure/config.js.map +1 -1
  81. package/dist/presentation/batch.d.ts.map +1 -1
  82. package/dist/presentation/batch.js +1 -0
  83. package/dist/presentation/batch.js.map +1 -1
  84. package/dist/presentation/structure.d.ts +1 -1
  85. package/dist/presentation/structure.d.ts.map +1 -1
  86. package/dist/presentation/structure.js +1 -1
  87. package/dist/presentation/structure.js.map +1 -1
  88. package/dist/shared/normalize.d.ts +12 -0
  89. package/dist/shared/normalize.d.ts.map +1 -1
  90. package/dist/shared/normalize.js +4 -0
  91. package/dist/shared/normalize.js.map +1 -1
  92. package/dist/types.d.ts +1 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/package.json +7 -7
  95. package/src/cli/commands/batch.ts +5 -26
  96. package/src/cli/commands/structure.ts +21 -1
  97. package/src/db/connection.ts +2 -2
  98. package/src/db/index.ts +2 -0
  99. package/src/domain/analysis/context.ts +5 -15
  100. package/src/domain/analysis/dependencies.ts +6 -16
  101. package/src/domain/analysis/fn-impact.ts +2 -2
  102. package/src/domain/analysis/implementations.ts +3 -13
  103. package/src/domain/graph/builder/context.ts +4 -0
  104. package/src/domain/graph/builder/native-db-proxy.ts +98 -0
  105. package/src/domain/graph/builder/pipeline.ts +135 -67
  106. package/src/domain/graph/builder/stages/build-edges.ts +15 -2
  107. package/src/domain/graph/builder/stages/build-structure.ts +2 -2
  108. package/src/domain/graph/builder/stages/detect-changes.ts +11 -33
  109. package/src/domain/graph/builder/stages/finalize.ts +1 -1
  110. package/src/domain/graph/builder/stages/insert-nodes.ts +17 -14
  111. package/src/domain/graph/builder/stages/resolve-imports.ts +2 -3
  112. package/src/domain/parser.ts +6 -2
  113. package/src/domain/queries.ts +1 -1
  114. package/src/features/audit.ts +3 -2
  115. package/src/features/boundaries.ts +3 -5
  116. package/src/features/branch-compare.ts +2 -3
  117. package/src/features/flow.ts +2 -1
  118. package/src/features/manifesto.ts +15 -1
  119. package/src/infrastructure/config.ts +1 -0
  120. package/src/presentation/batch.ts +1 -0
  121. package/src/presentation/structure.ts +2 -2
  122. package/src/shared/normalize.ts +10 -0
  123. package/src/types.ts +1 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * NativeDbProxy — wraps a NativeDatabase (rusqlite via napi-rs) to satisfy the
3
+ * BetterSqlite3Database interface. When the native addon is available, the
4
+ * build pipeline uses this proxy as `ctx.db` so that every stage operates on a
5
+ * single rusqlite connection — no dual-connection WAL corruption, no
6
+ * open/close/reopen dance.
7
+ *
8
+ * When native is unavailable, the pipeline falls back to real better-sqlite3.
9
+ */
10
+
11
+ import type { BetterSqlite3Database, NativeDatabase, SqliteStatement } from '../../../types.js';
12
+
13
+ /** Sanitize params for napi-rs: better-sqlite3 treats `undefined` as NULL,
14
+ * but serde_json cannot represent `undefined`. Replace with `null`. */
15
+ function sanitize(params: unknown[]): Array<string | number | null> {
16
+ return params.map((p) => (p === undefined ? null : p)) as Array<string | number | null>;
17
+ }
18
+
19
+ export class NativeDbProxy implements BetterSqlite3Database {
20
+ readonly #ndb: NativeDatabase;
21
+ /** Advisory lock path — set by the pipeline so closeDb() can release it. */
22
+ __lockPath?: string;
23
+
24
+ constructor(nativeDb: NativeDatabase) {
25
+ this.#ndb = nativeDb;
26
+ }
27
+
28
+ prepare<TRow = unknown>(sql: string): SqliteStatement<TRow> {
29
+ const ndb = this.#ndb;
30
+ const stmt: SqliteStatement<TRow> = {
31
+ all(...params: unknown[]): TRow[] {
32
+ return ndb.queryAll(sql, sanitize(params)) as TRow[];
33
+ },
34
+ get(...params: unknown[]): TRow | undefined {
35
+ return (ndb.queryGet(sql, sanitize(params)) ?? undefined) as TRow | undefined;
36
+ },
37
+ run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint } {
38
+ ndb.queryAll(sql, sanitize(params));
39
+ // Retrieve last_insert_rowid via SQLite scalar function so callers
40
+ // that depend on it (e.g. CFG block edge mapping) get correct values.
41
+ const row = ndb.queryGet('SELECT last_insert_rowid() AS rid', []) as { rid: number } | null;
42
+ return { changes: 0, lastInsertRowid: row?.rid ?? 0 };
43
+ },
44
+ iterate(): IterableIterator<TRow> {
45
+ throw new Error('iterate() is not supported via NativeDbProxy');
46
+ },
47
+ raw(): SqliteStatement<TRow> {
48
+ return stmt; // no-op — .raw() is not used in the build pipeline
49
+ },
50
+ };
51
+ return stmt;
52
+ }
53
+
54
+ exec(sql: string): this {
55
+ this.#ndb.exec(sql);
56
+ return this;
57
+ }
58
+
59
+ pragma(sql: string): unknown {
60
+ return this.#ndb.pragma(sql);
61
+ }
62
+
63
+ close(): void {
64
+ // No-op: the pipeline manages the NativeDatabase lifecycle directly.
65
+ // closeDbPair() calls nativeDb.close() separately.
66
+ }
67
+
68
+ get open(): boolean {
69
+ return this.#ndb.isOpen;
70
+ }
71
+
72
+ get name(): string {
73
+ return this.#ndb.dbPath;
74
+ }
75
+
76
+ transaction<F extends (...args: any[]) => any>(
77
+ fn: F,
78
+ ): (...args: F extends (...a: infer A) => unknown ? A : never) => ReturnType<F> {
79
+ const ndb = this.#ndb;
80
+ return ((...args: unknown[]) => {
81
+ // NOTE: nested transactions (savepoints) are not supported — ensure callers
82
+ // do not invoke a transaction() wrapper from within an existing transaction.
83
+ ndb.exec('BEGIN');
84
+ try {
85
+ const result = fn(...args);
86
+ ndb.exec('COMMIT');
87
+ return result;
88
+ } catch (e) {
89
+ try {
90
+ ndb.exec('ROLLBACK');
91
+ } catch {
92
+ // Ignore rollback errors — the original error is more important
93
+ }
94
+ throw e;
95
+ }
96
+ }) as any;
97
+ }
98
+ }
@@ -4,14 +4,17 @@
4
4
  * This is the heart of the builder refactor (ROADMAP 3.9): the monolithic buildGraph()
5
5
  * is decomposed into independently testable stages that communicate via PipelineContext.
6
6
  */
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { performance } from 'node:perf_hooks';
9
10
  import {
11
+ acquireAdvisoryLock,
10
12
  closeDbPair,
11
13
  getBuildMeta,
12
14
  initSchema,
13
15
  MIGRATIONS,
14
16
  openDb,
17
+ releaseAdvisoryLock,
15
18
  setBuildMeta,
16
19
  } from '../../../db/index.js';
17
20
  import { detectWorkspaces, loadConfig } from '../../../infrastructure/config.js';
@@ -25,6 +28,7 @@ import { getActiveEngine } from '../../parser.js';
25
28
  import { setWorkspaces } from '../resolve.js';
26
29
  import { PipelineContext } from './context.js';
27
30
  import { loadPathAliases } from './helpers.js';
31
+ import { NativeDbProxy } from './native-db-proxy.js';
28
32
  import { buildEdges } from './stages/build-edges.js';
29
33
  import { buildStructure } from './stages/build-structure.js';
30
34
  // Pipeline stages
@@ -43,29 +47,11 @@ function initializeEngine(ctx: PipelineContext): void {
43
47
  engine: ctx.opts.engine || 'auto',
44
48
  dataflow: ctx.opts.dataflow !== false,
45
49
  ast: ctx.opts.ast !== false,
46
- nativeDb: ctx.nativeDb,
47
- // WAL checkpoint callbacks for dual-connection WAL guard (#696, #715).
48
- // Feature modules (ast, cfg, complexity, dataflow) receive `db` as a
49
- // parameter and cannot tolerate close/reopen (stale reference). Instead,
50
- // checkpoint the WAL so native writes start with a clean slate.
51
- // After native writes, resumeJsDb checkpoints through rusqlite so
52
- // better-sqlite3 never reads WAL frames from a different SQLite library.
53
- suspendJsDb: ctx.nativeDb
54
- ? () => {
55
- ctx.db.pragma('wal_checkpoint(TRUNCATE)');
56
- }
57
- : undefined,
58
- resumeJsDb: ctx.nativeDb
59
- ? () => {
60
- try {
61
- ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
62
- } catch (e) {
63
- debug(
64
- `resumeJsDb: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
65
- );
66
- }
67
- }
68
- : undefined,
50
+ // nativeDb and WAL callbacks are set later when NativeDatabase is opened
51
+ // (deferred to skip overhead on no-op rebuilds).
52
+ nativeDb: undefined,
53
+ suspendJsDb: undefined,
54
+ resumeJsDb: undefined,
69
55
  };
70
56
  const { name: engineName, version: engineVersion } = getActiveEngine(ctx.engineOpts);
71
57
  ctx.engineName = engineName as 'native' | 'wasm';
@@ -79,11 +65,10 @@ function checkEngineSchemaMismatch(ctx: PipelineContext): void {
79
65
  ctx.forceFullRebuild = false;
80
66
  if (!ctx.incremental) return;
81
67
 
82
- // Route metadata reads through NativeDatabase only when using the native engine,
83
- // to avoid dual-SQLite WAL conflicts (rusqlite + better-sqlite3 on same file).
84
- const useNativeDb = ctx.engineName === 'native' && !!ctx.nativeDb;
85
- const meta = (key: string): string | null =>
86
- useNativeDb ? ctx.nativeDb!.getBuildMeta(key) : getBuildMeta(ctx.db, key);
68
+ // NativeDatabase is deferred until after change detection, so always use
69
+ // better-sqlite3 for metadata reads here. Reads are safe WAL conflicts
70
+ // only arise from concurrent writes.
71
+ const meta = (key: string): string | null => getBuildMeta(ctx.db, key);
87
72
 
88
73
  const prevEngine = meta('engine');
89
74
  if (prevEngine && prevEngine !== ctx.engineName) {
@@ -129,34 +114,46 @@ function loadAliases(ctx: PipelineContext): void {
129
114
  function setupPipeline(ctx: PipelineContext): void {
130
115
  ctx.rootDir = path.resolve(ctx.rootDir);
131
116
  ctx.dbPath = path.join(ctx.rootDir, '.codegraph', 'graph.db');
132
- ctx.db = openDb(ctx.dbPath);
133
117
 
134
- // Use NativeDatabase for schema init when native engine is available (Phase 6.13).
135
- // better-sqlite3 (ctx.db) is still always opened — needed for queries and stages
136
- // that haven't been migrated to rusqlite yet.
137
- // Skip native DB entirely when user explicitly requested --engine wasm.
118
+ // Detect whether native engine is available.
138
119
  const enginePref = ctx.opts.engine || 'auto';
139
120
  const native = enginePref !== 'wasm' ? loadNative() : null;
140
- if (native?.NativeDatabase) {
121
+ ctx.nativeAvailable = !!native?.NativeDatabase;
122
+
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
+ ) {
141
132
  try {
133
+ const dir = path.dirname(ctx.dbPath);
134
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
135
+ acquireAdvisoryLock(ctx.dbPath);
142
136
  ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
143
137
  ctx.nativeDb.initSchema();
144
- // Checkpoint WAL through rusqlite so better-sqlite3 sees a clean DB
145
- // with no cross-library WAL frames (#715, #717).
146
- ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
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;
147
142
  } catch (err) {
148
- warn(`NativeDatabase setup failed, falling back to JS: ${toErrorMessage(err)}`);
143
+ warn(`NativeDatabase setup failed, falling back to better-sqlite3: ${toErrorMessage(err)}`);
149
144
  try {
150
145
  ctx.nativeDb?.close();
151
- } catch (e) {
152
- debug(`setupNativeDb: close failed during fallback: ${toErrorMessage(e)}`);
146
+ } catch {
147
+ /* ignore */
153
148
  }
154
149
  ctx.nativeDb = undefined;
150
+ ctx.nativeFirstProxy = false;
151
+ releaseAdvisoryLock(`${ctx.dbPath}.lock`);
152
+ ctx.db = openDb(ctx.dbPath);
153
+ initSchema(ctx.db);
155
154
  }
156
- // Always run JS initSchema so better-sqlite3 sees the schema —
157
- // nativeDb is closed during pipeline stages and reopened for analyses.
158
- initSchema(ctx.db);
159
155
  } else {
156
+ ctx.db = openDb(ctx.dbPath);
160
157
  initSchema(ctx.db);
161
158
  }
162
159
 
@@ -275,7 +272,10 @@ interface NativeOrchestratorResult {
275
272
  function shouldSkipNativeOrchestrator(ctx: PipelineContext): string | null {
276
273
  if (process.env.CODEGRAPH_FORCE_JS_PIPELINE === '1') return 'CODEGRAPH_FORCE_JS_PIPELINE=1';
277
274
  if (ctx.forceFullRebuild) return 'forceFullRebuild';
278
- const orchestratorBuggy = !!ctx.engineVersion && semverCompare(ctx.engineVersion, '3.10.0') < 0;
275
+ // v3.9.0 addon had buggy incremental purge (wrong SQL on analysis tables,
276
+ // scoped removal over-detection). Fixed in v3.9.1 by PR #865. Gate on
277
+ // < 3.9.1 so v3.9.1+ uses the fast Rust orchestrator path.
278
+ const orchestratorBuggy = !!ctx.engineVersion && semverCompare(ctx.engineVersion, '3.9.1') < 0;
279
279
  if (orchestratorBuggy) return `buggy addon ${ctx.engineVersion}`;
280
280
  if (ctx.engineName !== 'native') return `engine=${ctx.engineName}`;
281
281
  return null;
@@ -472,16 +472,20 @@ async function runPostNativeAnalysis(
472
472
  analysisFileSymbols = allFileSymbols;
473
473
  }
474
474
 
475
- // Reopen nativeDb for analysis features (suspend/resume WAL pattern).
476
- const native = loadNative();
477
- if (native?.NativeDatabase) {
478
- try {
479
- ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
480
- if (ctx.engineOpts) ctx.engineOpts.nativeDb = ctx.nativeDb;
481
- } catch {
482
- ctx.nativeDb = undefined;
483
- if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
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
+ }
484
486
  }
487
+ } else if (ctx.engineOpts) {
488
+ ctx.engineOpts.nativeDb = ctx.nativeDb;
485
489
  }
486
490
 
487
491
  try {
@@ -501,8 +505,8 @@ async function runPostNativeAnalysis(
501
505
  warn(`Analysis phases failed after native build: ${toErrorMessage(err)}`);
502
506
  }
503
507
 
504
- // Close nativeDb after analyses
505
- if (ctx.nativeDb) {
508
+ // Close nativeDb after analyses (skip in native-first — single connection stays open)
509
+ if (ctx.nativeDb && !ctx.nativeFirstProxy) {
506
510
  try {
507
511
  ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
508
512
  } catch {
@@ -553,6 +557,28 @@ async function tryNativeOrchestrator(
553
557
  debug(`Skipping native orchestrator: ${skipReason}`);
554
558
  return undefined;
555
559
  }
560
+
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).
563
+ if (!ctx.nativeDb && ctx.nativeAvailable) {
564
+ const native = loadNative();
565
+ if (native?.NativeDatabase) {
566
+ try {
567
+ ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
568
+ ctx.nativeDb.initSchema();
569
+ ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
570
+ } catch (err) {
571
+ warn(`NativeDatabase setup failed, falling back to JS: ${toErrorMessage(err)}`);
572
+ try {
573
+ ctx.nativeDb?.close();
574
+ } catch (e) {
575
+ debug(`tryNativeOrchestrator: close failed during fallback: ${toErrorMessage(e)}`);
576
+ }
577
+ ctx.nativeDb = undefined;
578
+ }
579
+ }
580
+ }
581
+
556
582
  if (!ctx.nativeDb?.buildGraph) return undefined;
557
583
 
558
584
  const resultJson = ctx.nativeDb.buildGraph(
@@ -607,7 +633,8 @@ async function tryNativeOrchestrator(
607
633
  const needsStructure = !result.structureHandled;
608
634
 
609
635
  if (needsAnalysis || needsStructure) {
610
- if (!handoffWalAfterNativeBuild(ctx)) {
636
+ // In native-first mode the proxy is already wired — no WAL handoff needed.
637
+ if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
611
638
  // DB reopen failed — return partial result
612
639
  return formatNativeTimingResult(p, 0, analysisTiming);
613
640
  }
@@ -639,12 +666,35 @@ async function tryNativeOrchestrator(
639
666
  // ── Pipeline stages execution ───────────────────────────────────────────
640
667
 
641
668
  async function runPipelineStages(ctx: PipelineContext): Promise<void> {
642
- // Prevent dual-connection WAL corruption during pipeline stages: when both
643
- // better-sqlite3 (ctx.db) and rusqlite (ctx.nativeDb) are open to the same
644
- // WAL-mode file, native writes corrupt the DB. Close nativeDb so stages
645
- // use JS fallback paths. Reopened before runAnalyses for feature modules
646
- // that use suspendJsDb/resumeJsDb WAL checkpoint pattern (#696).
647
- const hadNativeDb = !!ctx.nativeDb;
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) ────────────────────
693
+ // NativeDatabase is deferred — not opened during setup. collectFiles and
694
+ // detectChanges only need better-sqlite3. If no files changed, we exit
695
+ // early without ever opening the native connection, saving ~5ms.
696
+ // If nativeDb was opened by tryNativeOrchestrator (which fell through),
697
+ // suspend it now to avoid dual-connection WAL corruption during stages.
648
698
  if (ctx.db && ctx.nativeDb) {
649
699
  suspendNativeDb(ctx, 'pre-collect');
650
700
  }
@@ -656,10 +706,13 @@ async function runPipelineStages(ctx: PipelineContext): Promise<void> {
656
706
 
657
707
  await parseFiles(ctx);
658
708
 
659
- // Temporarily reopen nativeDb for insertNodes it uses the WAL checkpoint
660
- // guard internally (same pattern as feature modules). Closed again before
661
- // resolveImports/buildEdges which don't yet have the guard (#709).
662
- if (hadNativeDb && ctx.engineName === 'native') {
709
+ // For small incremental builds (≤smallFilesThreshold files), skip the nativeDb open/close
710
+ // cycle for insertNodes the WAL checkpoint + connection churn (~5-10ms)
711
+ // exceeds the napi bulk-insert savings on a handful of files. The JS
712
+ // fallback path inside insertNodes handles this case efficiently.
713
+ const smallIncremental =
714
+ !ctx.isFullBuild && ctx.allSymbols.size <= ctx.config.build.smallFilesThreshold;
715
+ if (ctx.nativeAvailable && ctx.engineName === 'native' && !smallIncremental) {
663
716
  reopenNativeDb(ctx, 'insertNodes');
664
717
  }
665
718
 
@@ -677,13 +730,28 @@ async function runPipelineStages(ctx: PipelineContext): Promise<void> {
677
730
 
678
731
  // Reopen nativeDb for feature modules (ast, cfg, complexity, dataflow)
679
732
  // which use suspendJsDb/resumeJsDb WAL checkpoint before native writes.
680
- if (hadNativeDb) {
733
+ // Skip for small incremental builds — same rationale as insertNodes above.
734
+ if (ctx.nativeAvailable && !smallIncremental) {
681
735
  reopenNativeDb(ctx, 'analyses');
682
736
  if (ctx.nativeDb && ctx.engineOpts) {
683
737
  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
+ };
684
750
  }
685
751
  if (!ctx.nativeDb && ctx.engineOpts) {
686
752
  ctx.engineOpts.nativeDb = undefined;
753
+ ctx.engineOpts.suspendJsDb = undefined;
754
+ ctx.engineOpts.resumeJsDb = undefined;
687
755
  }
688
756
  }
689
757
 
@@ -342,7 +342,7 @@ function buildCallEdgesNative(
342
342
  if (!fileNodeRow) continue;
343
343
 
344
344
  const importedNames = buildImportedNamesForNative(ctx, relPath, symbols, rootDir);
345
- const typeMap: Array<{ name: string; typeName: string; confidence: number }> =
345
+ const typeMapRaw: Array<{ name: string; typeName: string; confidence: number }> =
346
346
  symbols.typeMap instanceof Map
347
347
  ? [...symbols.typeMap.entries()].map(([name, entry]) => ({
348
348
  name,
@@ -352,6 +352,19 @@ function buildCallEdgesNative(
352
352
  : Array.isArray(symbols.typeMap)
353
353
  ? (symbols.typeMap as Array<{ name: string; typeName: string; confidence: number }>)
354
354
  : [];
355
+ // Deduplicate: keep highest-confidence entry per name (first-wins on tie),
356
+ // matching JS setTypeMapEntry semantics. The Map branch is already
357
+ // deduped by setTypeMapEntry — this loop is only needed for the Array
358
+ // branch (pre-rebuilt native addon) but runs unconditionally as
359
+ // belt-and-suspenders since it's a cheap O(n) pass.
360
+ const typeMapDedup = new Map<string, { name: string; typeName: string; confidence: number }>();
361
+ for (const entry of typeMapRaw) {
362
+ const existing = typeMapDedup.get(entry.name);
363
+ if (!existing || entry.confidence > existing.confidence) {
364
+ typeMapDedup.set(entry.name, entry);
365
+ }
366
+ }
367
+ const typeMap = [...typeMapDedup.values()];
355
368
  nativeFiles.push({
356
369
  file: relPath,
357
370
  fileNodeId: fileNodeRow.id,
@@ -699,7 +712,7 @@ function loadNodes(ctx: PipelineContext): { rows: QueryNodeRow[]; scoped: boolea
699
712
  const nodeKindFilter = `kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant')`;
700
713
 
701
714
  // Gate: only scope for small incremental on large codebases
702
- if (!isFullBuild && fileSymbols.size <= 5) {
715
+ if (!isFullBuild && fileSymbols.size <= ctx.config.build.smallFilesThreshold) {
703
716
  const existingFileCount = (
704
717
  db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get() as { c: number }
705
718
  ).c;
@@ -37,7 +37,7 @@ export async function buildStructure(ctx: PipelineContext): Promise<void> {
37
37
  // For small incremental builds on large codebases, use a fast path that
38
38
  // updates only the changed files' metrics via targeted SQL instead of
39
39
  // loading ALL definitions from DB (~8ms) and recomputing ALL metrics (~15ms).
40
- // Gate: ≤5 changed files AND significantly more existing files (>20) to
40
+ // Gate: ≤smallFilesThreshold changed files AND significantly more existing files (>20) to
41
41
  // avoid triggering on small test fixtures where directory metrics matter.
42
42
  const useNativeReads = ctx.engineName === 'native' && !!ctx.nativeDb;
43
43
  const existingFileCount = !isFullBuild
@@ -52,7 +52,7 @@ export async function buildStructure(ctx: PipelineContext): Promise<void> {
52
52
  const useSmallIncrementalFastPath =
53
53
  !isFullBuild &&
54
54
  changedFileList != null &&
55
- changedFileList.length <= 5 &&
55
+ changedFileList.length <= ctx.config.build.smallFilesThreshold &&
56
56
  existingFileCount > 20;
57
57
 
58
58
  if (!isFullBuild && !useSmallIncrementalFastPath) {
@@ -58,26 +58,9 @@ function getChangedFiles(
58
58
  db: BetterSqlite3Database,
59
59
  allFiles: string[],
60
60
  rootDir: string,
61
- nativeDb?: NativeDatabase,
62
61
  ): ChangeResult {
63
- // Batched native path: single napi call for table check + all rows + max mtime
64
- if (nativeDb?.getFileHashData) {
65
- const data = nativeDb.getFileHashData();
66
- if (!data.exists) {
67
- return {
68
- changed: allFiles.map((f) => ({ file: f })),
69
- removed: [],
70
- isFullBuild: true,
71
- };
72
- }
73
- const existing = new Map<string, FileHashRow>(data.rows.map((r) => [r.file, r]));
74
- const removed = detectRemovedFiles(existing, allFiles, rootDir);
75
- const journalResult = tryJournalTier(db, existing, rootDir, removed, data.maxMtime);
76
- if (journalResult) return journalResult;
77
- return mtimeAndHashTiers(existing, allFiles, rootDir, removed);
78
- }
79
-
80
- // WASM / fallback path
62
+ // NativeDatabase is not open during change detection (deferred to after
63
+ // early-exit check). All queries use better-sqlite3 here.
81
64
  let hasTable = false;
82
65
  try {
83
66
  db.prepare('SELECT 1 FROM file_hashes LIMIT 1').get();
@@ -294,14 +277,14 @@ async function runPendingAnalysis(ctx: PipelineContext): Promise<boolean> {
294
277
  rootDir,
295
278
  analysisOpts,
296
279
  );
297
- if (needsCfg) {
298
- const { buildCFGData } = await import('../../../../features/cfg.js');
299
- await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
300
- }
301
- if (needsDataflow) {
302
- const { buildDataflowEdges } = await import('../../../../features/dataflow.js');
303
- await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
304
- }
280
+ const { runAnalyses } = await import('../../../../ast-analysis/engine.js');
281
+ await runAnalyses(
282
+ db,
283
+ analysisSymbols,
284
+ rootDir,
285
+ { ast: false, complexity: false, cfg: needsCfg, dataflow: needsDataflow },
286
+ engineOpts,
287
+ );
305
288
  return true;
306
289
  }
307
290
 
@@ -487,12 +470,7 @@ export async function detectChanges(ctx: PipelineContext): Promise<void> {
487
470
  }
488
471
  const increResult =
489
472
  incremental && !forceFullRebuild
490
- ? getChangedFiles(
491
- db,
492
- allFiles,
493
- rootDir,
494
- ctx.engineName === 'native' ? ctx.nativeDb : undefined,
495
- )
473
+ ? getChangedFiles(db, allFiles, rootDir)
496
474
  : {
497
475
  changed: allFiles.map((f): ChangedFile => ({ file: f })),
498
476
  removed: [] as string[],
@@ -258,7 +258,7 @@ export async function finalize(ctx: PipelineContext): Promise<void> {
258
258
  // immediately after build.
259
259
  const pair = { db: ctx.db, nativeDb: ctx.nativeDb };
260
260
  const isTempDir = path.resolve(rootDir).startsWith(path.resolve(tmpdir()));
261
- if (!isFullBuild && allSymbols.size <= 5 && !isTempDir) {
261
+ if (!isFullBuild && allSymbols.size <= ctx.config.build.smallFilesThreshold && !isTempDir) {
262
262
  closeDbPairDeferred(pair);
263
263
  } else {
264
264
  closeDbPair(pair);
@@ -159,23 +159,26 @@ function tryNativeInsert(ctx: PipelineContext): boolean {
159
159
  }
160
160
  const fileHashes = buildFileHashes(allSymbols, precomputedData, metadataUpdates, rootDir);
161
161
 
162
- // WAL guard: same suspendJsDb/resumeJsDb pattern used by feature modules
163
- // (ast, cfg, complexity, dataflow). Checkpoint JS side before native write,
164
- // then checkpoint native side after, so neither library reads WAL frames
165
- // written by the other (#696, #709, #715, #717).
162
+ // In native-first mode (single rusqlite connection), no WAL dance is needed.
163
+ // In dual-connection mode, checkpoint JS side before native write, then
164
+ // checkpoint native side after (#696, #709, #715, #717).
166
165
  let result: boolean;
167
- try {
168
- if (ctx.db) {
169
- ctx.db.pragma('wal_checkpoint(TRUNCATE)');
170
- }
166
+ if (ctx.nativeFirstProxy) {
171
167
  result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed);
172
- } finally {
168
+ } else {
173
169
  try {
174
- ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
175
- } catch (e) {
176
- debug(
177
- `tryNativeInsert: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
178
- );
170
+ if (ctx.db) {
171
+ ctx.db.pragma('wal_checkpoint(TRUNCATE)');
172
+ }
173
+ result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed);
174
+ } finally {
175
+ try {
176
+ ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
177
+ } catch (e) {
178
+ debug(
179
+ `tryNativeInsert: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
180
+ );
181
+ }
179
182
  }
180
183
  }
181
184
  return result;
@@ -34,15 +34,14 @@ function buildReexportMap(ctx: PipelineContext): void {
34
34
 
35
35
  /**
36
36
  * Find barrel files related to changed files for scoped re-parsing.
37
- * For small incremental builds (<=5 files), only barrels that re-export from
37
+ * For small incremental builds (<=smallFilesThreshold files), only barrels that re-export from
38
38
  * or are imported by the changed files. For larger changes, all barrels.
39
39
  */
40
40
  function findBarrelCandidates(ctx: PipelineContext): Array<{ file: string }> {
41
41
  const { db, fileSymbols, rootDir, aliases } = ctx;
42
42
  const changedRelPaths = new Set<string>(fileSymbols.keys());
43
43
 
44
- const SMALL_CHANGE_THRESHOLD = 5;
45
- if (changedRelPaths.size <= SMALL_CHANGE_THRESHOLD) {
44
+ if (changedRelPaths.size <= ctx.config.build.smallFilesThreshold) {
46
45
  const allBarrelFiles = new Set(
47
46
  (
48
47
  db
@@ -5,7 +5,7 @@ import type { Tree } from 'web-tree-sitter';
5
5
  import { Language, Parser, Query } from 'web-tree-sitter';
6
6
  import { debug, warn } from '../infrastructure/logger.js';
7
7
  import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js';
8
- import { toErrorMessage } from '../shared/errors.js';
8
+ import { ParseError, toErrorMessage } from '../shared/errors.js';
9
9
  import type {
10
10
  EngineMode,
11
11
  ExtractorOutput,
@@ -188,7 +188,11 @@ async function doLoadLanguage(entry: LanguageRegistryEntry): Promise<void> {
188
188
  _queryCache.set(entry.id, new Query(lang, patterns.join('\n')));
189
189
  }
190
190
  } catch (e: unknown) {
191
- if (entry.required) throw e;
191
+ if (entry.required)
192
+ throw new ParseError(`Required parser ${entry.id} failed to initialize`, {
193
+ file: entry.grammarFile,
194
+ cause: e as Error,
195
+ });
192
196
  warn(
193
197
  `${entry.id} parser failed to initialize: ${(e as Error).message}. ${entry.id} files will be skipped.`,
194
198
  );
@@ -22,7 +22,7 @@ export {
22
22
  VALID_ROLES,
23
23
  } from '../shared/kinds.js';
24
24
  // ── Shared utilities ─────────────────────────────────────────────────────
25
- export { kindIcon, normalizeSymbol } from '../shared/normalize.js';
25
+ export { kindIcon, normalizeSymbol, toSymbolRef } from '../shared/normalize.js';
26
26
  export { briefData } from './analysis/brief.js';
27
27
  export { contextData, explainData } from './analysis/context.js';
28
28
  export { fileDepsData, filePathData, fnDepsData, pathData } from './analysis/dependencies.js';