@optave/codegraph 2.6.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/builder.js CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { loadConfig } from './config.js';
6
6
  import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
7
- import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
7
+ import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb, setBuildMeta } from './db.js';
8
8
  import { readJournal, writeJournalHeader } from './journal.js';
9
9
  import { debug, info, warn } from './logger.js';
10
10
  import { getActiveEngine, parseFilesAuto } from './parser.js';
@@ -338,7 +338,103 @@ function getChangedFiles(db, allFiles, rootDir) {
338
338
  return { changed, removed, isFullBuild: false };
339
339
  }
340
340
 
341
+ /**
342
+ * Purge all graph data for the specified files.
343
+ * Deletes: embeddings → edges (in+out) → node_metrics → function_complexity → dataflow → nodes.
344
+ * Handles missing tables gracefully (embeddings, complexity, dataflow may not exist in older DBs).
345
+ *
346
+ * @param {import('better-sqlite3').Database} db - Open writable database
347
+ * @param {string[]} files - Relative file paths to purge
348
+ * @param {object} [options]
349
+ * @param {boolean} [options.purgeHashes=true] - Also delete file_hashes entries
350
+ */
351
+ export function purgeFilesFromGraph(db, files, options = {}) {
352
+ const { purgeHashes = true } = options;
353
+ if (!files || files.length === 0) return;
354
+
355
+ // Check if embeddings table exists
356
+ let hasEmbeddings = false;
357
+ try {
358
+ db.prepare('SELECT 1 FROM embeddings LIMIT 1').get();
359
+ hasEmbeddings = true;
360
+ } catch {
361
+ /* table doesn't exist */
362
+ }
363
+
364
+ const deleteEmbeddingsForFile = hasEmbeddings
365
+ ? db.prepare('DELETE FROM embeddings WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)')
366
+ : null;
367
+ const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
368
+ const deleteEdgesForFile = db.prepare(`
369
+ DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f)
370
+ OR target_id IN (SELECT id FROM nodes WHERE file = @f)
371
+ `);
372
+ const deleteMetricsForFile = db.prepare(
373
+ 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
374
+ );
375
+ let deleteComplexityForFile;
376
+ try {
377
+ deleteComplexityForFile = db.prepare(
378
+ 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
379
+ );
380
+ } catch {
381
+ deleteComplexityForFile = null;
382
+ }
383
+ let deleteDataflowForFile;
384
+ try {
385
+ deleteDataflowForFile = db.prepare(
386
+ 'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)',
387
+ );
388
+ } catch {
389
+ deleteDataflowForFile = null;
390
+ }
391
+ let deleteHashForFile;
392
+ if (purgeHashes) {
393
+ try {
394
+ deleteHashForFile = db.prepare('DELETE FROM file_hashes WHERE file = ?');
395
+ } catch {
396
+ deleteHashForFile = null;
397
+ }
398
+ }
399
+ let deleteAstNodesForFile;
400
+ try {
401
+ deleteAstNodesForFile = db.prepare('DELETE FROM ast_nodes WHERE file = ?');
402
+ } catch {
403
+ deleteAstNodesForFile = null;
404
+ }
405
+ let deleteCfgForFile;
406
+ try {
407
+ deleteCfgForFile = db.prepare(
408
+ 'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
409
+ );
410
+ } catch {
411
+ deleteCfgForFile = null;
412
+ }
413
+ let deleteCfgBlocksForFile;
414
+ try {
415
+ deleteCfgBlocksForFile = db.prepare(
416
+ 'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
417
+ );
418
+ } catch {
419
+ deleteCfgBlocksForFile = null;
420
+ }
421
+
422
+ for (const relPath of files) {
423
+ deleteEmbeddingsForFile?.run(relPath);
424
+ deleteEdgesForFile.run({ f: relPath });
425
+ deleteMetricsForFile.run(relPath);
426
+ deleteComplexityForFile?.run(relPath);
427
+ deleteDataflowForFile?.run(relPath, relPath);
428
+ deleteAstNodesForFile?.run(relPath);
429
+ deleteCfgForFile?.run(relPath);
430
+ deleteCfgBlocksForFile?.run(relPath);
431
+ deleteNodesForFile.run(relPath);
432
+ if (purgeHashes) deleteHashForFile?.run(relPath);
433
+ }
434
+ }
435
+
341
436
  export async function buildGraph(rootDir, opts = {}) {
437
+ rootDir = path.resolve(rootDir);
342
438
  const dbPath = path.join(rootDir, '.codegraph', 'graph.db');
343
439
  const db = openDb(dbPath);
344
440
  initSchema(db);
@@ -352,19 +448,22 @@ export async function buildGraph(rootDir, opts = {}) {
352
448
  const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
353
449
  info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
354
450
 
355
- // Check for engine/version mismatch on incremental builds
451
+ // Check for engine/schema mismatch auto-promote to full rebuild
452
+ // Only trigger on engine change or schema version change (not every patch/minor bump)
453
+ const CURRENT_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
454
+ let forceFullRebuild = false;
356
455
  if (incremental) {
357
456
  const prevEngine = getBuildMeta(db, 'engine');
358
- const prevVersion = getBuildMeta(db, 'codegraph_version');
359
457
  if (prevEngine && prevEngine !== engineName) {
360
- warn(
361
- `Engine changed (${prevEngine} → ${engineName}). Consider rebuilding with --no-incremental for consistency.`,
362
- );
458
+ info(`Engine changed (${prevEngine} → ${engineName}), promoting to full rebuild.`);
459
+ forceFullRebuild = true;
363
460
  }
364
- if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
365
- warn(
366
- `Codegraph version changed (${prevVersion} → ${CODEGRAPH_VERSION}). Consider rebuilding with --no-incremental for consistency.`,
461
+ const prevSchema = getBuildMeta(db, 'schema_version');
462
+ if (prevSchema && Number(prevSchema) !== CURRENT_SCHEMA_VERSION) {
463
+ info(
464
+ `Schema version changed (${prevSchema} → ${CURRENT_SCHEMA_VERSION}), promoting to full rebuild.`,
367
465
  );
466
+ forceFullRebuild = true;
368
467
  }
369
468
  }
370
469
 
@@ -384,21 +483,85 @@ export async function buildGraph(rootDir, opts = {}) {
384
483
  );
385
484
  }
386
485
 
387
- const collected = collectFiles(rootDir, [], config, new Set());
388
- const files = collected.files;
389
- const discoveredDirs = collected.directories;
390
- info(`Found ${files.length} files to parse`);
391
-
392
- // Check for incremental build
393
- const { changed, removed, isFullBuild } = incremental
394
- ? getChangedFiles(db, files, rootDir)
395
- : { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
396
-
397
- // Separate metadata-only updates (mtime/size self-heal) from real changes
398
- const parseChanges = changed.filter((c) => !c.metadataOnly);
399
- const metadataUpdates = changed.filter((c) => c.metadataOnly);
486
+ // ── Scoped rebuild: rebuild only specified files ──────────────────
487
+ let files, discoveredDirs, parseChanges, metadataUpdates, removed, isFullBuild;
488
+
489
+ if (opts.scope) {
490
+ const scopedFiles = opts.scope.map((f) => normalizePath(f));
491
+ const existing = [];
492
+ const missing = [];
493
+ for (const rel of scopedFiles) {
494
+ const abs = path.join(rootDir, rel);
495
+ if (fs.existsSync(abs)) {
496
+ existing.push({ file: abs, relPath: rel });
497
+ } else {
498
+ missing.push(rel);
499
+ }
500
+ }
501
+ files = existing.map((e) => e.file);
502
+ // Derive discoveredDirs from scoped files' parent directories
503
+ discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
504
+ parseChanges = existing;
505
+ metadataUpdates = [];
506
+ removed = missing;
507
+ isFullBuild = false;
508
+ info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
509
+ } else {
510
+ const collected = collectFiles(rootDir, [], config, new Set());
511
+ files = collected.files;
512
+ discoveredDirs = collected.directories;
513
+ info(`Found ${files.length} files to parse`);
514
+
515
+ // Check for incremental build
516
+ const increResult =
517
+ incremental && !forceFullRebuild
518
+ ? getChangedFiles(db, files, rootDir)
519
+ : { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
520
+ removed = increResult.removed;
521
+ isFullBuild = increResult.isFullBuild;
522
+
523
+ // Separate metadata-only updates (mtime/size self-heal) from real changes
524
+ parseChanges = increResult.changed.filter((c) => !c.metadataOnly);
525
+ metadataUpdates = increResult.changed.filter((c) => c.metadataOnly);
526
+ }
400
527
 
401
528
  if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
529
+ // Check if default analyses were never computed (e.g. legacy DB)
530
+ const needsCfg =
531
+ opts.cfg !== false &&
532
+ (() => {
533
+ try {
534
+ return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
535
+ } catch {
536
+ return true;
537
+ }
538
+ })();
539
+ const needsDataflow =
540
+ opts.dataflow !== false &&
541
+ (() => {
542
+ try {
543
+ return db.prepare('SELECT COUNT(*) as c FROM dataflow').get().c === 0;
544
+ } catch {
545
+ return true;
546
+ }
547
+ })();
548
+
549
+ if (needsCfg || needsDataflow) {
550
+ info('No file changes. Running pending analysis pass...');
551
+ const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
552
+ if (needsCfg) {
553
+ const { buildCFGData } = await import('./cfg.js');
554
+ await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
555
+ }
556
+ if (needsDataflow) {
557
+ const { buildDataflowEdges } = await import('./dataflow.js');
558
+ await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
559
+ }
560
+ closeDb(db);
561
+ writeJournalHeader(rootDir, Date.now());
562
+ return;
563
+ }
564
+
402
565
  // Still update metadata for self-healing even when no real changes
403
566
  if (metadataUpdates.length > 0) {
404
567
  try {
@@ -435,7 +598,7 @@ export async function buildGraph(rootDir, opts = {}) {
435
598
 
436
599
  if (isFullBuild) {
437
600
  const deletions =
438
- 'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
601
+ 'PRAGMA foreign_keys = OFF; DELETE FROM cfg_edges; DELETE FROM cfg_blocks; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM dataflow; DELETE FROM ast_nodes; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
439
602
  db.exec(
440
603
  hasEmbeddings
441
604
  ? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
@@ -446,29 +609,33 @@ export async function buildGraph(rootDir, opts = {}) {
446
609
  // Find files with edges pointing TO changed/removed files.
447
610
  // Their nodes stay intact (preserving IDs), but outgoing edges are
448
611
  // deleted so they can be rebuilt during the edge-building pass.
449
- const changedRelPaths = new Set();
450
- for (const item of parseChanges) {
451
- changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
452
- }
453
- for (const relPath of removed) {
454
- changedRelPaths.add(relPath);
455
- }
456
-
612
+ // When opts.noReverseDeps is true (e.g. agent rollback to same version),
613
+ // skip this cascade the agent knows exports didn't change.
457
614
  const reverseDeps = new Set();
458
- if (changedRelPaths.size > 0) {
459
- const findReverseDeps = db.prepare(`
460
- SELECT DISTINCT n_src.file FROM edges e
461
- JOIN nodes n_src ON e.source_id = n_src.id
462
- JOIN nodes n_tgt ON e.target_id = n_tgt.id
463
- WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
464
- `);
465
- for (const relPath of changedRelPaths) {
466
- for (const row of findReverseDeps.all(relPath)) {
467
- if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
468
- // Verify the file still exists on disk
469
- const absPath = path.join(rootDir, row.file);
470
- if (fs.existsSync(absPath)) {
471
- reverseDeps.add(row.file);
615
+ if (!opts.noReverseDeps) {
616
+ const changedRelPaths = new Set();
617
+ for (const item of parseChanges) {
618
+ changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
619
+ }
620
+ for (const relPath of removed) {
621
+ changedRelPaths.add(relPath);
622
+ }
623
+
624
+ if (changedRelPaths.size > 0) {
625
+ const findReverseDeps = db.prepare(`
626
+ SELECT DISTINCT n_src.file FROM edges e
627
+ JOIN nodes n_src ON e.source_id = n_src.id
628
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
629
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
630
+ `);
631
+ for (const relPath of changedRelPaths) {
632
+ for (const row of findReverseDeps.all(relPath)) {
633
+ if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
634
+ // Verify the file still exists on disk
635
+ const absPath = path.join(rootDir, row.file);
636
+ if (fs.existsSync(absPath)) {
637
+ reverseDeps.add(row.file);
638
+ }
472
639
  }
473
640
  }
474
641
  }
@@ -482,47 +649,16 @@ export async function buildGraph(rootDir, opts = {}) {
482
649
  debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
483
650
  if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
484
651
  // Remove embeddings/metrics/edges/nodes for changed and removed files
485
- // Embeddings must be deleted BEFORE nodes (we need node IDs to find them)
486
- const deleteEmbeddingsForFile = hasEmbeddings
487
- ? db.prepare('DELETE FROM embeddings WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)')
488
- : null;
489
- const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
490
- const deleteEdgesForFile = db.prepare(`
491
- DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f)
492
- OR target_id IN (SELECT id FROM nodes WHERE file = @f)
493
- `);
494
- const deleteOutgoingEdgesForFile = db.prepare(
495
- 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
652
+ const changePaths = parseChanges.map(
653
+ (item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
496
654
  );
497
- const deleteMetricsForFile = db.prepare(
498
- 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
499
- );
500
- let deleteComplexityForFile;
501
- try {
502
- deleteComplexityForFile = db.prepare(
503
- 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
504
- );
505
- } catch {
506
- deleteComplexityForFile = null;
507
- }
508
- for (const relPath of removed) {
509
- deleteEmbeddingsForFile?.run(relPath);
510
- deleteEdgesForFile.run({ f: relPath });
511
- deleteMetricsForFile.run(relPath);
512
- deleteComplexityForFile?.run(relPath);
513
- deleteNodesForFile.run(relPath);
514
- }
515
- for (const item of parseChanges) {
516
- const relPath = item.relPath || normalizePath(path.relative(rootDir, item.file));
517
- deleteEmbeddingsForFile?.run(relPath);
518
- deleteEdgesForFile.run({ f: relPath });
519
- deleteMetricsForFile.run(relPath);
520
- deleteComplexityForFile?.run(relPath);
521
- deleteNodesForFile.run(relPath);
522
- }
655
+ purgeFilesFromGraph(db, [...removed, ...changePaths], { purgeHashes: false });
523
656
 
524
657
  // Process reverse deps: delete only outgoing edges (nodes/IDs preserved)
525
658
  // then add them to the parse list so they participate in edge building
659
+ const deleteOutgoingEdgesForFile = db.prepare(
660
+ 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
661
+ );
526
662
  for (const relPath of reverseDeps) {
527
663
  deleteOutgoingEdgesForFile.run(relPath);
528
664
  }
@@ -533,7 +669,7 @@ export async function buildGraph(rootDir, opts = {}) {
533
669
  }
534
670
 
535
671
  const insertNode = db.prepare(
536
- 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
672
+ 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
537
673
  );
538
674
  const getNodeId = db.prepare(
539
675
  'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
@@ -583,16 +719,65 @@ export async function buildGraph(rootDir, opts = {}) {
583
719
  }
584
720
  }
585
721
 
722
+ // Bulk-fetch all node IDs for a file in one query (replaces per-node getNodeId calls)
723
+ const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
724
+
586
725
  const insertAll = db.transaction(() => {
587
726
  for (const [relPath, symbols] of allSymbols) {
588
727
  fileSymbols.set(relPath, symbols);
589
728
 
590
- insertNode.run(relPath, 'file', relPath, 0, null);
729
+ // Phase 1: Insert file node + definitions + exports (no children yet)
730
+ insertNode.run(relPath, 'file', relPath, 0, null, null);
591
731
  for (const def of symbols.definitions) {
592
- insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null);
732
+ insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
593
733
  }
594
734
  for (const exp of symbols.exports) {
595
- insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
735
+ insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
736
+ }
737
+
738
+ // Phase 2: Bulk-fetch IDs for file + definitions
739
+ const nodeIdMap = new Map();
740
+ for (const row of bulkGetNodeIds.all(relPath)) {
741
+ nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
742
+ }
743
+
744
+ // Phase 3: Insert children with parent_id from the map
745
+ for (const def of symbols.definitions) {
746
+ if (!def.children?.length) continue;
747
+ const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
748
+ if (!defId) continue;
749
+ for (const child of def.children) {
750
+ insertNode.run(child.name, child.kind, relPath, child.line, child.endLine || null, defId);
751
+ }
752
+ }
753
+
754
+ // Phase 4: Re-fetch to include children IDs
755
+ nodeIdMap.clear();
756
+ for (const row of bulkGetNodeIds.all(relPath)) {
757
+ nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
758
+ }
759
+
760
+ // Phase 5: Insert edges using the cached ID map
761
+ const fileId = nodeIdMap.get(`${relPath}|file|0`);
762
+ for (const def of symbols.definitions) {
763
+ const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
764
+ // File → top-level definition contains edge
765
+ if (fileId && defId) {
766
+ insertEdge.run(fileId, defId, 'contains', 1.0, 0);
767
+ }
768
+ if (def.children?.length && defId) {
769
+ for (const child of def.children) {
770
+ const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
771
+ if (childId) {
772
+ // Parent → child contains edge
773
+ insertEdge.run(defId, childId, 'contains', 1.0, 0);
774
+ // Parameter → parent parameter_of edge (inverse direction)
775
+ if (child.kind === 'parameter') {
776
+ insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
777
+ }
778
+ }
779
+ }
780
+ }
596
781
  }
597
782
 
598
783
  // Update file hash with real mtime+size for incremental builds
@@ -772,7 +957,7 @@ export async function buildGraph(rootDir, opts = {}) {
772
957
  // N+1 optimization: pre-load all nodes into a lookup map for edge building
773
958
  const allNodes = db
774
959
  .prepare(
775
- `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface')`,
960
+ `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
776
961
  )
777
962
  .all();
778
963
  const nodesByName = new Map();
@@ -789,7 +974,6 @@ export async function buildGraph(rootDir, opts = {}) {
789
974
 
790
975
  // Second pass: build edges
791
976
  _t.edges0 = performance.now();
792
- let edgeCount = 0;
793
977
  const buildEdges = db.transaction(() => {
794
978
  for (const [relPath, symbols] of fileSymbols) {
795
979
  // Skip barrel-only files — loaded for resolution, edges already in DB
@@ -805,7 +989,6 @@ export async function buildGraph(rootDir, opts = {}) {
805
989
  if (targetRow) {
806
990
  const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
807
991
  insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
808
- edgeCount++;
809
992
 
810
993
  if (!imp.reexport && isBarrelFile(resolvedPath)) {
811
994
  const resolvedSources = new Set();
@@ -827,7 +1010,6 @@ export async function buildGraph(rootDir, opts = {}) {
827
1010
  0.9,
828
1011
  0,
829
1012
  );
830
- edgeCount++;
831
1013
  }
832
1014
  }
833
1015
  }
@@ -928,7 +1110,29 @@ export async function buildGraph(rootDir, opts = {}) {
928
1110
  seenCallEdges.add(edgeKey);
929
1111
  const confidence = computeConfidence(relPath, t.file, importedFrom);
930
1112
  insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
931
- edgeCount++;
1113
+ }
1114
+ }
1115
+
1116
+ // Receiver edge: caller → receiver type node
1117
+ if (
1118
+ call.receiver &&
1119
+ !BUILTIN_RECEIVERS.has(call.receiver) &&
1120
+ call.receiver !== 'this' &&
1121
+ call.receiver !== 'self' &&
1122
+ call.receiver !== 'super'
1123
+ ) {
1124
+ const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
1125
+ // Same-file first, then global
1126
+ const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
1127
+ const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
1128
+ const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
1129
+ if (receiverNodes.length > 0 && caller) {
1130
+ const recvTarget = receiverNodes[0];
1131
+ const recvKey = `recv|${caller.id}|${recvTarget.id}`;
1132
+ if (!seenCallEdges.has(recvKey)) {
1133
+ seenCallEdges.add(recvKey);
1134
+ insertEdge.run(caller.id, recvTarget.id, 'receiver', 0.7, 0);
1135
+ }
932
1136
  }
933
1137
  }
934
1138
  }
@@ -944,7 +1148,6 @@ export async function buildGraph(rootDir, opts = {}) {
944
1148
  if (sourceRow) {
945
1149
  for (const t of targetRows) {
946
1150
  insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
947
- edgeCount++;
948
1151
  }
949
1152
  }
950
1153
  }
@@ -960,7 +1163,6 @@ export async function buildGraph(rootDir, opts = {}) {
960
1163
  if (sourceRow) {
961
1164
  for (const t of targetRows) {
962
1165
  insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
963
- edgeCount++;
964
1166
  }
965
1167
  }
966
1168
  }
@@ -1047,7 +1249,9 @@ export async function buildGraph(rootDir, opts = {}) {
1047
1249
  }
1048
1250
  try {
1049
1251
  const { buildStructure } = await import('./structure.js');
1050
- buildStructure(db, fileSymbols, rootDir, lineCountMap, relDirs);
1252
+ // Pass changed file paths so incremental builds can scope the rebuild
1253
+ const changedFilePaths = isFullBuild ? null : [...allSymbols.keys()];
1254
+ buildStructure(db, fileSymbols, rootDir, lineCountMap, relDirs, changedFilePaths);
1051
1255
  } catch (err) {
1052
1256
  debug(`Structure analysis failed: ${err.message}`);
1053
1257
  }
@@ -1068,16 +1272,75 @@ export async function buildGraph(rootDir, opts = {}) {
1068
1272
  }
1069
1273
  _t.rolesMs = performance.now() - _t.roles0;
1070
1274
 
1275
+ // For incremental builds, filter out reverse-dep-only files from AST/complexity
1276
+ // — their content didn't change, so existing ast_nodes/function_complexity rows are valid.
1277
+ let astComplexitySymbols = allSymbols;
1278
+ if (!isFullBuild) {
1279
+ const reverseDepFiles = new Set(
1280
+ filesToParse.filter((item) => item._reverseDepOnly).map((item) => item.relPath),
1281
+ );
1282
+ if (reverseDepFiles.size > 0) {
1283
+ astComplexitySymbols = new Map();
1284
+ for (const [relPath, symbols] of allSymbols) {
1285
+ if (!reverseDepFiles.has(relPath)) {
1286
+ astComplexitySymbols.set(relPath, symbols);
1287
+ }
1288
+ }
1289
+ debug(
1290
+ `AST/complexity: processing ${astComplexitySymbols.size} changed files (skipping ${reverseDepFiles.size} reverse-deps)`,
1291
+ );
1292
+ }
1293
+ }
1294
+
1295
+ // AST node extraction (calls, new, string, regex, throw, await)
1296
+ // Must run before complexity which releases _tree references
1297
+ _t.ast0 = performance.now();
1298
+ if (opts.ast !== false) {
1299
+ try {
1300
+ const { buildAstNodes } = await import('./ast.js');
1301
+ await buildAstNodes(db, astComplexitySymbols, rootDir, engineOpts);
1302
+ } catch (err) {
1303
+ debug(`AST node extraction failed: ${err.message}`);
1304
+ }
1305
+ }
1306
+ _t.astMs = performance.now() - _t.ast0;
1307
+
1071
1308
  // Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
1072
1309
  _t.complexity0 = performance.now();
1073
- try {
1074
- const { buildComplexityMetrics } = await import('./complexity.js');
1075
- await buildComplexityMetrics(db, allSymbols, rootDir, engineOpts);
1076
- } catch (err) {
1077
- debug(`Complexity analysis failed: ${err.message}`);
1310
+ if (opts.complexity !== false) {
1311
+ try {
1312
+ const { buildComplexityMetrics } = await import('./complexity.js');
1313
+ await buildComplexityMetrics(db, astComplexitySymbols, rootDir, engineOpts);
1314
+ } catch (err) {
1315
+ debug(`Complexity analysis failed: ${err.message}`);
1316
+ }
1078
1317
  }
1079
1318
  _t.complexityMs = performance.now() - _t.complexity0;
1080
1319
 
1320
+ // CFG analysis (skip with --no-cfg)
1321
+ if (opts.cfg !== false) {
1322
+ _t.cfg0 = performance.now();
1323
+ try {
1324
+ const { buildCFGData } = await import('./cfg.js');
1325
+ await buildCFGData(db, allSymbols, rootDir, engineOpts);
1326
+ } catch (err) {
1327
+ debug(`CFG analysis failed: ${err.message}`);
1328
+ }
1329
+ _t.cfgMs = performance.now() - _t.cfg0;
1330
+ }
1331
+
1332
+ // Dataflow analysis (skip with --no-dataflow)
1333
+ if (opts.dataflow !== false) {
1334
+ _t.dataflow0 = performance.now();
1335
+ try {
1336
+ const { buildDataflowEdges } = await import('./dataflow.js');
1337
+ await buildDataflowEdges(db, allSymbols, rootDir, engineOpts);
1338
+ } catch (err) {
1339
+ debug(`Dataflow analysis failed: ${err.message}`);
1340
+ }
1341
+ _t.dataflowMs = performance.now() - _t.dataflow0;
1342
+ }
1343
+
1081
1344
  // Release any remaining cached WASM trees for GC
1082
1345
  for (const [, symbols] of allSymbols) {
1083
1346
  symbols._tree = null;
@@ -1085,7 +1348,8 @@ export async function buildGraph(rootDir, opts = {}) {
1085
1348
  }
1086
1349
 
1087
1350
  const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
1088
- info(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`);
1351
+ const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
1352
+ info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
1089
1353
  info(`Stored in ${dbPath}`);
1090
1354
 
1091
1355
  // Verify incremental build didn't diverge significantly from previous counts
@@ -1097,11 +1361,11 @@ export async function buildGraph(rootDir, opts = {}) {
1097
1361
  const prevE = Number(prevEdges);
1098
1362
  if (prevN > 0) {
1099
1363
  const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
1100
- const edgeDrift = prevE > 0 ? Math.abs(edgeCount - prevE) / prevE : 0;
1364
+ const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
1101
1365
  const driftThreshold = config.build?.driftThreshold ?? 0.2;
1102
1366
  if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
1103
1367
  warn(
1104
- `Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${edgeCount} [${(edgeDrift * 100).toFixed(1)}%], threshold: ${(driftThreshold * 100).toFixed(0)}%). Consider rebuilding with --no-incremental.`,
1368
+ `Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${actualEdgeCount} [${(edgeDrift * 100).toFixed(1)}%], threshold: ${(driftThreshold * 100).toFixed(0)}%). Consider rebuilding with --no-incremental.`,
1105
1369
  );
1106
1370
  }
1107
1371
  }
@@ -1130,9 +1394,10 @@ export async function buildGraph(rootDir, opts = {}) {
1130
1394
  engine: engineName,
1131
1395
  engine_version: engineVersion || '',
1132
1396
  codegraph_version: CODEGRAPH_VERSION,
1397
+ schema_version: String(CURRENT_SCHEMA_VERSION),
1133
1398
  built_at: new Date().toISOString(),
1134
1399
  node_count: nodeCount,
1135
- edge_count: edgeCount,
1400
+ edge_count: actualEdgeCount,
1136
1401
  });
1137
1402
  } catch (err) {
1138
1403
  warn(`Failed to write build metadata: ${err.message}`);
@@ -1167,7 +1432,10 @@ export async function buildGraph(rootDir, opts = {}) {
1167
1432
  edgesMs: +_t.edgesMs.toFixed(1),
1168
1433
  structureMs: +_t.structureMs.toFixed(1),
1169
1434
  rolesMs: +_t.rolesMs.toFixed(1),
1435
+ astMs: +_t.astMs.toFixed(1),
1170
1436
  complexityMs: +_t.complexityMs.toFixed(1),
1437
+ ...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
1438
+ ...(_t.dataflowMs != null && { dataflowMs: +_t.dataflowMs.toFixed(1) }),
1171
1439
  },
1172
1440
  };
1173
1441
  }