@optave/codegraph 2.6.0 → 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 function_complexity; 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 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);
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
@@ -772,7 +937,7 @@ export async function buildGraph(rootDir, opts = {}) {
772
937
  // N+1 optimization: pre-load all nodes into a lookup map for edge building
773
938
  const allNodes = db
774
939
  .prepare(
775
- `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')`,
776
941
  )
777
942
  .all();
778
943
  const nodesByName = new Map();
@@ -789,7 +954,6 @@ export async function buildGraph(rootDir, opts = {}) {
789
954
 
790
955
  // Second pass: build edges
791
956
  _t.edges0 = performance.now();
792
- let edgeCount = 0;
793
957
  const buildEdges = db.transaction(() => {
794
958
  for (const [relPath, symbols] of fileSymbols) {
795
959
  // Skip barrel-only files — loaded for resolution, edges already in DB
@@ -805,7 +969,6 @@ export async function buildGraph(rootDir, opts = {}) {
805
969
  if (targetRow) {
806
970
  const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
807
971
  insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
808
- edgeCount++;
809
972
 
810
973
  if (!imp.reexport && isBarrelFile(resolvedPath)) {
811
974
  const resolvedSources = new Set();
@@ -827,7 +990,6 @@ export async function buildGraph(rootDir, opts = {}) {
827
990
  0.9,
828
991
  0,
829
992
  );
830
- edgeCount++;
831
993
  }
832
994
  }
833
995
  }
@@ -928,7 +1090,29 @@ export async function buildGraph(rootDir, opts = {}) {
928
1090
  seenCallEdges.add(edgeKey);
929
1091
  const confidence = computeConfidence(relPath, t.file, importedFrom);
930
1092
  insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
931
- 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
+ }
932
1116
  }
933
1117
  }
934
1118
  }
@@ -944,7 +1128,6 @@ export async function buildGraph(rootDir, opts = {}) {
944
1128
  if (sourceRow) {
945
1129
  for (const t of targetRows) {
946
1130
  insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
947
- edgeCount++;
948
1131
  }
949
1132
  }
950
1133
  }
@@ -960,7 +1143,6 @@ export async function buildGraph(rootDir, opts = {}) {
960
1143
  if (sourceRow) {
961
1144
  for (const t of targetRows) {
962
1145
  insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
963
- edgeCount++;
964
1146
  }
965
1147
  }
966
1148
  }
@@ -1068,6 +1250,17 @@ export async function buildGraph(rootDir, opts = {}) {
1068
1250
  }
1069
1251
  _t.rolesMs = performance.now() - _t.roles0;
1070
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
+
1071
1264
  // Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
1072
1265
  _t.complexity0 = performance.now();
1073
1266
  try {
@@ -1078,6 +1271,30 @@ export async function buildGraph(rootDir, opts = {}) {
1078
1271
  }
1079
1272
  _t.complexityMs = performance.now() - _t.complexity0;
1080
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
+
1081
1298
  // Release any remaining cached WASM trees for GC
1082
1299
  for (const [, symbols] of allSymbols) {
1083
1300
  symbols._tree = null;
@@ -1085,7 +1302,8 @@ export async function buildGraph(rootDir, opts = {}) {
1085
1302
  }
1086
1303
 
1087
1304
  const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
1088
- 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`);
1089
1307
  info(`Stored in ${dbPath}`);
1090
1308
 
1091
1309
  // Verify incremental build didn't diverge significantly from previous counts
@@ -1097,11 +1315,11 @@ export async function buildGraph(rootDir, opts = {}) {
1097
1315
  const prevE = Number(prevEdges);
1098
1316
  if (prevN > 0) {
1099
1317
  const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
1100
- const edgeDrift = prevE > 0 ? Math.abs(edgeCount - prevE) / prevE : 0;
1318
+ const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
1101
1319
  const driftThreshold = config.build?.driftThreshold ?? 0.2;
1102
1320
  if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
1103
1321
  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.`,
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.`,
1105
1323
  );
1106
1324
  }
1107
1325
  }
@@ -1132,7 +1350,7 @@ export async function buildGraph(rootDir, opts = {}) {
1132
1350
  codegraph_version: CODEGRAPH_VERSION,
1133
1351
  built_at: new Date().toISOString(),
1134
1352
  node_count: nodeCount,
1135
- edge_count: edgeCount,
1353
+ edge_count: actualEdgeCount,
1136
1354
  });
1137
1355
  } catch (err) {
1138
1356
  warn(`Failed to write build metadata: ${err.message}`);
@@ -1168,6 +1386,7 @@ export async function buildGraph(rootDir, opts = {}) {
1168
1386
  structureMs: +_t.structureMs.toFixed(1),
1169
1387
  rolesMs: +_t.rolesMs.toFixed(1),
1170
1388
  complexityMs: +_t.complexityMs.toFixed(1),
1389
+ ...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
1171
1390
  },
1172
1391
  };
1173
1392
  }