@optave/codegraph 2.5.1 → 3.0.0

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
@@ -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,18 @@ 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/version mismatch auto-promote to full rebuild
452
+ let forceFullRebuild = false;
356
453
  if (incremental) {
357
454
  const prevEngine = getBuildMeta(db, 'engine');
358
455
  const prevVersion = getBuildMeta(db, 'codegraph_version');
359
456
  if (prevEngine && prevEngine !== engineName) {
360
- warn(
361
- `Engine changed (${prevEngine} → ${engineName}). Consider rebuilding with --no-incremental for consistency.`,
362
- );
457
+ info(`Engine changed (${prevEngine} → ${engineName}), promoting to full rebuild.`);
458
+ forceFullRebuild = true;
363
459
  }
364
460
  if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
365
- warn(
366
- `Codegraph version changed (${prevVersion} → ${CODEGRAPH_VERSION}). Consider rebuilding with --no-incremental for consistency.`,
367
- );
461
+ info(`Version changed (${prevVersion} → ${CODEGRAPH_VERSION}), promoting to full rebuild.`);
462
+ forceFullRebuild = true;
368
463
  }
369
464
  }
370
465
 
@@ -384,21 +479,91 @@ export async function buildGraph(rootDir, opts = {}) {
384
479
  );
385
480
  }
386
481
 
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);
482
+ // ── Scoped rebuild: rebuild only specified files ──────────────────
483
+ let files, discoveredDirs, parseChanges, metadataUpdates, removed, isFullBuild;
484
+
485
+ if (opts.scope) {
486
+ const scopedFiles = opts.scope.map((f) => normalizePath(f));
487
+ const existing = [];
488
+ const missing = [];
489
+ for (const rel of scopedFiles) {
490
+ const abs = path.join(rootDir, rel);
491
+ if (fs.existsSync(abs)) {
492
+ existing.push({ file: abs, relPath: rel });
493
+ } else {
494
+ missing.push(rel);
495
+ }
496
+ }
497
+ files = existing.map((e) => e.file);
498
+ // Derive discoveredDirs from scoped files' parent directories
499
+ discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
500
+ parseChanges = existing;
501
+ metadataUpdates = [];
502
+ removed = missing;
503
+ isFullBuild = false;
504
+ info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
505
+ } else {
506
+ const collected = collectFiles(rootDir, [], config, new Set());
507
+ files = collected.files;
508
+ discoveredDirs = collected.directories;
509
+ info(`Found ${files.length} files to parse`);
510
+
511
+ // Check for incremental build
512
+ const increResult =
513
+ incremental && !forceFullRebuild
514
+ ? getChangedFiles(db, files, rootDir)
515
+ : { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
516
+ removed = increResult.removed;
517
+ isFullBuild = increResult.isFullBuild;
518
+
519
+ // Separate metadata-only updates (mtime/size self-heal) from real changes
520
+ parseChanges = increResult.changed.filter((c) => !c.metadataOnly);
521
+ metadataUpdates = increResult.changed.filter((c) => c.metadataOnly);
522
+ }
400
523
 
401
524
  if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
525
+ // Check if optional analysis was requested but never computed
526
+ const needsCfg =
527
+ opts.cfg &&
528
+ (() => {
529
+ try {
530
+ return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
531
+ } catch {
532
+ return true;
533
+ }
534
+ })();
535
+ const needsDataflow =
536
+ opts.dataflow &&
537
+ (() => {
538
+ try {
539
+ return (
540
+ db
541
+ .prepare(
542
+ "SELECT COUNT(*) as c FROM edges WHERE kind IN ('flows_to','returns','mutates')",
543
+ )
544
+ .get().c === 0
545
+ );
546
+ } catch {
547
+ return true;
548
+ }
549
+ })();
550
+
551
+ if (needsCfg || needsDataflow) {
552
+ info('No file changes. Running pending analysis pass...');
553
+ const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
554
+ if (needsCfg) {
555
+ const { buildCFGData } = await import('./cfg.js');
556
+ await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
557
+ }
558
+ if (needsDataflow) {
559
+ const { buildDataflowEdges } = await import('./dataflow.js');
560
+ await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
561
+ }
562
+ closeDb(db);
563
+ writeJournalHeader(rootDir, Date.now());
564
+ return;
565
+ }
566
+
402
567
  // Still update metadata for self-healing even when no real changes
403
568
  if (metadataUpdates.length > 0) {
404
569
  try {
@@ -435,7 +600,7 @@ export async function buildGraph(rootDir, opts = {}) {
435
600
 
436
601
  if (isFullBuild) {
437
602
  const deletions =
438
- 'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
603
+ '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
604
  db.exec(
440
605
  hasEmbeddings
441
606
  ? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
@@ -446,29 +611,33 @@ export async function buildGraph(rootDir, opts = {}) {
446
611
  // Find files with edges pointing TO changed/removed files.
447
612
  // Their nodes stay intact (preserving IDs), but outgoing edges are
448
613
  // 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
-
614
+ // When opts.noReverseDeps is true (e.g. agent rollback to same version),
615
+ // skip this cascade the agent knows exports didn't change.
457
616
  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
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);
617
+ if (!opts.noReverseDeps) {
618
+ const changedRelPaths = new Set();
619
+ for (const item of parseChanges) {
620
+ changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
621
+ }
622
+ for (const relPath of removed) {
623
+ changedRelPaths.add(relPath);
624
+ }
625
+
626
+ if (changedRelPaths.size > 0) {
627
+ const findReverseDeps = db.prepare(`
628
+ SELECT DISTINCT n_src.file FROM edges e
629
+ JOIN nodes n_src ON e.source_id = n_src.id
630
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
631
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
632
+ `);
633
+ for (const relPath of changedRelPaths) {
634
+ for (const row of findReverseDeps.all(relPath)) {
635
+ if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
636
+ // Verify the file still exists on disk
637
+ const absPath = path.join(rootDir, row.file);
638
+ if (fs.existsSync(absPath)) {
639
+ reverseDeps.add(row.file);
640
+ }
472
641
  }
473
642
  }
474
643
  }
@@ -482,47 +651,16 @@ export async function buildGraph(rootDir, opts = {}) {
482
651
  debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
483
652
  if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
484
653
  // 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 = ?)',
496
- );
497
- const deleteMetricsForFile = db.prepare(
498
- 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
654
+ const changePaths = parseChanges.map(
655
+ (item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
499
656
  );
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
- }
657
+ purgeFilesFromGraph(db, [...removed, ...changePaths], { purgeHashes: false });
523
658
 
524
659
  // Process reverse deps: delete only outgoing edges (nodes/IDs preserved)
525
660
  // then add them to the parse list so they participate in edge building
661
+ const deleteOutgoingEdgesForFile = db.prepare(
662
+ 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
663
+ );
526
664
  for (const relPath of reverseDeps) {
527
665
  deleteOutgoingEdgesForFile.run(relPath);
528
666
  }
@@ -533,7 +671,7 @@ export async function buildGraph(rootDir, opts = {}) {
533
671
  }
534
672
 
535
673
  const insertNode = db.prepare(
536
- 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
674
+ 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
537
675
  );
538
676
  const getNodeId = db.prepare(
539
677
  'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
@@ -587,12 +725,39 @@ export async function buildGraph(rootDir, opts = {}) {
587
725
  for (const [relPath, symbols] of allSymbols) {
588
726
  fileSymbols.set(relPath, symbols);
589
727
 
590
- insertNode.run(relPath, 'file', relPath, 0, null);
728
+ insertNode.run(relPath, 'file', relPath, 0, null, null);
729
+ const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
591
730
  for (const def of symbols.definitions) {
592
- insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null);
731
+ insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
732
+ const defRow = getNodeId.get(def.name, def.kind, relPath, def.line);
733
+ // File → top-level definition contains edge
734
+ if (fileRow && defRow) {
735
+ insertEdge.run(fileRow.id, defRow.id, 'contains', 1.0, 0);
736
+ }
737
+ if (def.children?.length && defRow) {
738
+ for (const child of def.children) {
739
+ insertNode.run(
740
+ child.name,
741
+ child.kind,
742
+ relPath,
743
+ child.line,
744
+ child.endLine || null,
745
+ defRow.id,
746
+ );
747
+ // Parent → child contains edge
748
+ const childRow = getNodeId.get(child.name, child.kind, relPath, child.line);
749
+ if (childRow) {
750
+ insertEdge.run(defRow.id, childRow.id, 'contains', 1.0, 0);
751
+ // Parameter → parent parameter_of edge (inverse direction)
752
+ if (child.kind === 'parameter') {
753
+ insertEdge.run(childRow.id, defRow.id, 'parameter_of', 1.0, 0);
754
+ }
755
+ }
756
+ }
757
+ }
593
758
  }
594
759
  for (const exp of symbols.exports) {
595
- insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
760
+ insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
596
761
  }
597
762
 
598
763
  // Update file hash with real mtime+size for incremental builds
@@ -687,6 +852,46 @@ export async function buildGraph(rootDir, opts = {}) {
687
852
  }
688
853
  }
689
854
 
855
+ // For incremental builds, load unchanged barrel files into reexportMap
856
+ // so barrel-resolved import/call edges aren't dropped for reverse-dep files.
857
+ // These files are loaded only for resolution — they must NOT be iterated
858
+ // in the edge-building loop (their existing edges are still in the DB).
859
+ const barrelOnlyFiles = new Set();
860
+ if (!isFullBuild) {
861
+ const barrelCandidates = db
862
+ .prepare(
863
+ `SELECT DISTINCT n1.file FROM edges e
864
+ JOIN nodes n1 ON e.source_id = n1.id
865
+ WHERE e.kind = 'reexports' AND n1.kind = 'file'`,
866
+ )
867
+ .all();
868
+ for (const { file: relPath } of barrelCandidates) {
869
+ if (fileSymbols.has(relPath)) continue;
870
+ const absPath = path.join(rootDir, relPath);
871
+ try {
872
+ const symbols = await parseFilesAuto([absPath], rootDir, engineOpts);
873
+ const fileSym = symbols.get(relPath);
874
+ if (fileSym) {
875
+ fileSymbols.set(relPath, fileSym);
876
+ barrelOnlyFiles.add(relPath);
877
+ const reexports = fileSym.imports.filter((imp) => imp.reexport);
878
+ if (reexports.length > 0) {
879
+ reexportMap.set(
880
+ relPath,
881
+ reexports.map((imp) => ({
882
+ source: getResolved(absPath, imp.source),
883
+ names: imp.names,
884
+ wildcardReexport: imp.wildcardReexport || false,
885
+ })),
886
+ );
887
+ }
888
+ }
889
+ } catch {
890
+ /* skip if unreadable */
891
+ }
892
+ }
893
+ }
894
+
690
895
  function isBarrelFile(relPath) {
691
896
  const symbols = fileSymbols.get(relPath);
692
897
  if (!symbols) return false;
@@ -732,7 +937,7 @@ export async function buildGraph(rootDir, opts = {}) {
732
937
  // N+1 optimization: pre-load all nodes into a lookup map for edge building
733
938
  const allNodes = db
734
939
  .prepare(
735
- `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface')`,
940
+ `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
736
941
  )
737
942
  .all();
738
943
  const nodesByName = new Map();
@@ -749,9 +954,10 @@ export async function buildGraph(rootDir, opts = {}) {
749
954
 
750
955
  // Second pass: build edges
751
956
  _t.edges0 = performance.now();
752
- let edgeCount = 0;
753
957
  const buildEdges = db.transaction(() => {
754
958
  for (const [relPath, symbols] of fileSymbols) {
959
+ // Skip barrel-only files — loaded for resolution, edges already in DB
960
+ if (barrelOnlyFiles.has(relPath)) continue;
755
961
  const fileNodeRow = getNodeId.get(relPath, 'file', relPath, 0);
756
962
  if (!fileNodeRow) continue;
757
963
  const fileNodeId = fileNodeRow.id;
@@ -763,7 +969,6 @@ export async function buildGraph(rootDir, opts = {}) {
763
969
  if (targetRow) {
764
970
  const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
765
971
  insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
766
- edgeCount++;
767
972
 
768
973
  if (!imp.reexport && isBarrelFile(resolvedPath)) {
769
974
  const resolvedSources = new Set();
@@ -785,7 +990,6 @@ export async function buildGraph(rootDir, opts = {}) {
785
990
  0.9,
786
991
  0,
787
992
  );
788
- edgeCount++;
789
993
  }
790
994
  }
791
995
  }
@@ -886,7 +1090,29 @@ export async function buildGraph(rootDir, opts = {}) {
886
1090
  seenCallEdges.add(edgeKey);
887
1091
  const confidence = computeConfidence(relPath, t.file, importedFrom);
888
1092
  insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
889
- edgeCount++;
1093
+ }
1094
+ }
1095
+
1096
+ // Receiver edge: caller → receiver type node
1097
+ if (
1098
+ call.receiver &&
1099
+ !BUILTIN_RECEIVERS.has(call.receiver) &&
1100
+ call.receiver !== 'this' &&
1101
+ call.receiver !== 'self' &&
1102
+ call.receiver !== 'super'
1103
+ ) {
1104
+ const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
1105
+ // Same-file first, then global
1106
+ const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
1107
+ const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
1108
+ const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
1109
+ if (receiverNodes.length > 0 && caller) {
1110
+ const recvTarget = receiverNodes[0];
1111
+ const recvKey = `recv|${caller.id}|${recvTarget.id}`;
1112
+ if (!seenCallEdges.has(recvKey)) {
1113
+ seenCallEdges.add(recvKey);
1114
+ insertEdge.run(caller.id, recvTarget.id, 'receiver', 0.7, 0);
1115
+ }
890
1116
  }
891
1117
  }
892
1118
  }
@@ -902,7 +1128,6 @@ export async function buildGraph(rootDir, opts = {}) {
902
1128
  if (sourceRow) {
903
1129
  for (const t of targetRows) {
904
1130
  insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
905
- edgeCount++;
906
1131
  }
907
1132
  }
908
1133
  }
@@ -918,7 +1143,6 @@ export async function buildGraph(rootDir, opts = {}) {
918
1143
  if (sourceRow) {
919
1144
  for (const t of targetRows) {
920
1145
  insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
921
- edgeCount++;
922
1146
  }
923
1147
  }
924
1148
  }
@@ -1026,6 +1250,17 @@ export async function buildGraph(rootDir, opts = {}) {
1026
1250
  }
1027
1251
  _t.rolesMs = performance.now() - _t.roles0;
1028
1252
 
1253
+ // Always-on AST node extraction (calls, new, string, regex, throw, await)
1254
+ // Must run before complexity which releases _tree references
1255
+ _t.ast0 = performance.now();
1256
+ try {
1257
+ const { buildAstNodes } = await import('./ast.js');
1258
+ await buildAstNodes(db, allSymbols, rootDir, engineOpts);
1259
+ } catch (err) {
1260
+ debug(`AST node extraction failed: ${err.message}`);
1261
+ }
1262
+ _t.astMs = performance.now() - _t.ast0;
1263
+
1029
1264
  // Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
1030
1265
  _t.complexity0 = performance.now();
1031
1266
  try {
@@ -1036,6 +1271,30 @@ export async function buildGraph(rootDir, opts = {}) {
1036
1271
  }
1037
1272
  _t.complexityMs = performance.now() - _t.complexity0;
1038
1273
 
1274
+ // Opt-in CFG analysis (--cfg)
1275
+ if (opts.cfg) {
1276
+ _t.cfg0 = performance.now();
1277
+ try {
1278
+ const { buildCFGData } = await import('./cfg.js');
1279
+ await buildCFGData(db, allSymbols, rootDir, engineOpts);
1280
+ } catch (err) {
1281
+ debug(`CFG analysis failed: ${err.message}`);
1282
+ }
1283
+ _t.cfgMs = performance.now() - _t.cfg0;
1284
+ }
1285
+
1286
+ // Opt-in dataflow analysis (--dataflow)
1287
+ if (opts.dataflow) {
1288
+ _t.dataflow0 = performance.now();
1289
+ try {
1290
+ const { buildDataflowEdges } = await import('./dataflow.js');
1291
+ await buildDataflowEdges(db, allSymbols, rootDir, engineOpts);
1292
+ } catch (err) {
1293
+ debug(`Dataflow analysis failed: ${err.message}`);
1294
+ }
1295
+ _t.dataflowMs = performance.now() - _t.dataflow0;
1296
+ }
1297
+
1039
1298
  // Release any remaining cached WASM trees for GC
1040
1299
  for (const [, symbols] of allSymbols) {
1041
1300
  symbols._tree = null;
@@ -1043,9 +1302,30 @@ export async function buildGraph(rootDir, opts = {}) {
1043
1302
  }
1044
1303
 
1045
1304
  const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
1046
- info(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`);
1305
+ const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
1306
+ info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
1047
1307
  info(`Stored in ${dbPath}`);
1048
1308
 
1309
+ // Verify incremental build didn't diverge significantly from previous counts
1310
+ if (!isFullBuild) {
1311
+ const prevNodes = getBuildMeta(db, 'node_count');
1312
+ const prevEdges = getBuildMeta(db, 'edge_count');
1313
+ if (prevNodes && prevEdges) {
1314
+ const prevN = Number(prevNodes);
1315
+ const prevE = Number(prevEdges);
1316
+ if (prevN > 0) {
1317
+ const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
1318
+ const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
1319
+ const driftThreshold = config.build?.driftThreshold ?? 0.2;
1320
+ if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
1321
+ warn(
1322
+ `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.`,
1323
+ );
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+
1049
1329
  // Warn about orphaned embeddings that no longer match any node
1050
1330
  if (hasEmbeddings) {
1051
1331
  try {
@@ -1069,6 +1349,8 @@ export async function buildGraph(rootDir, opts = {}) {
1069
1349
  engine_version: engineVersion || '',
1070
1350
  codegraph_version: CODEGRAPH_VERSION,
1071
1351
  built_at: new Date().toISOString(),
1352
+ node_count: nodeCount,
1353
+ edge_count: actualEdgeCount,
1072
1354
  });
1073
1355
  } catch (err) {
1074
1356
  warn(`Failed to write build metadata: ${err.message}`);
@@ -1104,6 +1386,7 @@ export async function buildGraph(rootDir, opts = {}) {
1104
1386
  structureMs: +_t.structureMs.toFixed(1),
1105
1387
  rolesMs: +_t.rolesMs.toFixed(1),
1106
1388
  complexityMs: +_t.complexityMs.toFixed(1),
1389
+ ...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
1107
1390
  },
1108
1391
  };
1109
1392
  }