@optave/codegraph 3.0.3 → 3.1.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
@@ -7,6 +7,7 @@ import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
7
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
+ import { loadNative } from './native.js';
10
11
  import { getActiveEngine, parseFilesAuto } from './parser.js';
11
12
  import { computeConfidence, resolveImportPath, resolveImportsBatch } from './resolve.js';
12
13
 
@@ -444,7 +445,11 @@ export async function buildGraph(rootDir, opts = {}) {
444
445
  opts.incremental !== false && config.build && config.build.incremental !== false;
445
446
 
446
447
  // Engine selection: 'native', 'wasm', or 'auto' (default)
447
- const engineOpts = { engine: opts.engine || 'auto' };
448
+ const engineOpts = {
449
+ engine: opts.engine || 'auto',
450
+ dataflow: opts.dataflow !== false,
451
+ ast: opts.ast !== false,
452
+ };
448
453
  const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
449
454
  info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
450
455
 
@@ -548,7 +553,11 @@ export async function buildGraph(rootDir, opts = {}) {
548
553
 
549
554
  if (needsCfg || needsDataflow) {
550
555
  info('No file changes. Running pending analysis pass...');
551
- const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
556
+ const analysisOpts = {
557
+ ...engineOpts,
558
+ dataflow: needsDataflow && opts.dataflow !== false,
559
+ };
560
+ const analysisSymbols = await parseFilesAuto(files, rootDir, analysisOpts);
552
561
  if (needsCfg) {
553
562
  const { buildCFGData } = await import('./cfg.js');
554
563
  await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
@@ -668,15 +677,38 @@ export async function buildGraph(rootDir, opts = {}) {
668
677
  }
669
678
  }
670
679
 
671
- const insertNode = db.prepare(
672
- 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
673
- );
674
680
  const getNodeId = db.prepare(
675
681
  'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
676
682
  );
677
- const insertEdge = db.prepare(
678
- 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
679
- );
683
+
684
+ // Batch INSERT helpers multi-value INSERTs reduce SQLite round-trips
685
+ const BATCH_CHUNK = 200;
686
+ function batchInsertNodes(rows) {
687
+ if (!rows.length) return;
688
+ const ph = '(?,?,?,?,?,?)';
689
+ for (let i = 0; i < rows.length; i += BATCH_CHUNK) {
690
+ const chunk = rows.slice(i, i + BATCH_CHUNK);
691
+ const vals = [];
692
+ for (const r of chunk) vals.push(r[0], r[1], r[2], r[3], r[4], r[5]);
693
+ db.prepare(
694
+ 'INSERT OR IGNORE INTO nodes (name,kind,file,line,end_line,parent_id) VALUES ' +
695
+ chunk.map(() => ph).join(','),
696
+ ).run(...vals);
697
+ }
698
+ }
699
+ function batchInsertEdges(rows) {
700
+ if (!rows.length) return;
701
+ const ph = '(?,?,?,?,?)';
702
+ for (let i = 0; i < rows.length; i += BATCH_CHUNK) {
703
+ const chunk = rows.slice(i, i + BATCH_CHUNK);
704
+ const vals = [];
705
+ for (const r of chunk) vals.push(r[0], r[1], r[2], r[3], r[4]);
706
+ db.prepare(
707
+ 'INSERT INTO edges (source_id,target_id,kind,confidence,dynamic) VALUES ' +
708
+ chunk.map(() => ph).join(','),
709
+ ).run(...vals);
710
+ }
711
+ }
680
712
 
681
713
  // Prepare hash upsert (with size column from migration v4)
682
714
  let upsertHash;
@@ -723,57 +755,76 @@ export async function buildGraph(rootDir, opts = {}) {
723
755
  const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
724
756
 
725
757
  const insertAll = db.transaction(() => {
758
+ // Phase 1: Batch insert all file nodes + definitions + exports
759
+ const phase1Rows = [];
726
760
  for (const [relPath, symbols] of allSymbols) {
727
761
  fileSymbols.set(relPath, symbols);
728
-
729
- // Phase 1: Insert file node + definitions + exports (no children yet)
730
- insertNode.run(relPath, 'file', relPath, 0, null, null);
762
+ phase1Rows.push([relPath, 'file', relPath, 0, null, null]);
731
763
  for (const def of symbols.definitions) {
732
- insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
764
+ phase1Rows.push([def.name, def.kind, relPath, def.line, def.endLine || null, null]);
765
+ }
766
+ for (const exp of symbols.exports) {
767
+ phase1Rows.push([exp.name, exp.kind, relPath, exp.line, null, null]);
733
768
  }
769
+ }
770
+ batchInsertNodes(phase1Rows);
771
+
772
+ // Phase 1b: Mark exported symbols
773
+ const markExported = db.prepare(
774
+ 'UPDATE nodes SET exported = 1 WHERE name = ? AND kind = ? AND file = ? AND line = ?',
775
+ );
776
+ for (const [relPath, symbols] of allSymbols) {
734
777
  for (const exp of symbols.exports) {
735
- insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
778
+ markExported.run(exp.name, exp.kind, relPath, exp.line);
736
779
  }
780
+ }
737
781
 
738
- // Phase 2: Bulk-fetch IDs for file + definitions
782
+ // Phase 3: Batch insert children (needs parent IDs from Phase 2)
783
+ const childRows = [];
784
+ for (const [relPath, symbols] of allSymbols) {
739
785
  const nodeIdMap = new Map();
740
786
  for (const row of bulkGetNodeIds.all(relPath)) {
741
787
  nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
742
788
  }
743
-
744
- // Phase 3: Insert children with parent_id from the map
745
789
  for (const def of symbols.definitions) {
746
790
  if (!def.children?.length) continue;
747
791
  const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
748
792
  if (!defId) continue;
749
793
  for (const child of def.children) {
750
- insertNode.run(child.name, child.kind, relPath, child.line, child.endLine || null, defId);
794
+ childRows.push([
795
+ child.name,
796
+ child.kind,
797
+ relPath,
798
+ child.line,
799
+ child.endLine || null,
800
+ defId,
801
+ ]);
751
802
  }
752
803
  }
804
+ }
805
+ batchInsertNodes(childRows);
753
806
 
754
- // Phase 4: Re-fetch to include children IDs
755
- nodeIdMap.clear();
807
+ // Phase 5: Batch insert contains/parameter_of edges
808
+ const edgeRows = [];
809
+ for (const [relPath, symbols] of allSymbols) {
810
+ // Re-fetch to include children IDs
811
+ const nodeIdMap = new Map();
756
812
  for (const row of bulkGetNodeIds.all(relPath)) {
757
813
  nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
758
814
  }
759
-
760
- // Phase 5: Insert edges using the cached ID map
761
815
  const fileId = nodeIdMap.get(`${relPath}|file|0`);
762
816
  for (const def of symbols.definitions) {
763
817
  const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
764
- // File → top-level definition contains edge
765
818
  if (fileId && defId) {
766
- insertEdge.run(fileId, defId, 'contains', 1.0, 0);
819
+ edgeRows.push([fileId, defId, 'contains', 1.0, 0]);
767
820
  }
768
821
  if (def.children?.length && defId) {
769
822
  for (const child of def.children) {
770
823
  const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
771
824
  if (childId) {
772
- // Parent child contains edge
773
- insertEdge.run(defId, childId, 'contains', 1.0, 0);
774
- // Parameter → parent parameter_of edge (inverse direction)
825
+ edgeRows.push([defId, childId, 'contains', 1.0, 0]);
775
826
  if (child.kind === 'parameter') {
776
- insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
827
+ edgeRows.push([childId, defId, 'parameter_of', 1.0, 0]);
777
828
  }
778
829
  }
779
830
  }
@@ -808,6 +859,7 @@ export async function buildGraph(rootDir, opts = {}) {
808
859
  }
809
860
  }
810
861
  }
862
+ batchInsertEdges(edgeRows);
811
863
 
812
864
  // Also update metadata-only entries (self-heal mtime/size without re-parse)
813
865
  if (upsertHash) {
@@ -844,7 +896,7 @@ export async function buildGraph(rootDir, opts = {}) {
844
896
  batchInputs.push({ fromFile: absFile, importSource: imp.source });
845
897
  }
846
898
  }
847
- const batchResolved = resolveImportsBatch(batchInputs, rootDir, aliases);
899
+ const batchResolved = resolveImportsBatch(batchInputs, rootDir, aliases, files);
848
900
  _t.resolveMs = performance.now() - _t.resolve0;
849
901
 
850
902
  function getResolved(absFile, importSource) {
@@ -957,7 +1009,7 @@ export async function buildGraph(rootDir, opts = {}) {
957
1009
  // N+1 optimization: pre-load all nodes into a lookup map for edge building
958
1010
  const allNodes = db
959
1011
  .prepare(
960
- `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
1012
+ `SELECT id, name, kind, file, line FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
961
1013
  )
962
1014
  .all();
963
1015
  const nodesByName = new Map();
@@ -972,9 +1024,11 @@ export async function buildGraph(rootDir, opts = {}) {
972
1024
  nodesByNameAndFile.get(key).push(node);
973
1025
  }
974
1026
 
975
- // Second pass: build edges
1027
+ // Second pass: build edges (accumulated and batch-inserted)
976
1028
  _t.edges0 = performance.now();
977
1029
  const buildEdges = db.transaction(() => {
1030
+ const allEdgeRows = [];
1031
+
978
1032
  for (const [relPath, symbols] of fileSymbols) {
979
1033
  // Skip barrel-only files — loaded for resolution, edges already in DB
980
1034
  if (barrelOnlyFiles.has(relPath)) continue;
@@ -988,7 +1042,7 @@ export async function buildGraph(rootDir, opts = {}) {
988
1042
  const targetRow = getNodeId.get(resolvedPath, 'file', resolvedPath, 0);
989
1043
  if (targetRow) {
990
1044
  const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
991
- insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
1045
+ allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]);
992
1046
 
993
1047
  if (!imp.reexport && isBarrelFile(resolvedPath)) {
994
1048
  const resolvedSources = new Set();
@@ -1003,171 +1057,218 @@ export async function buildGraph(rootDir, opts = {}) {
1003
1057
  resolvedSources.add(actualSource);
1004
1058
  const actualRow = getNodeId.get(actualSource, 'file', actualSource, 0);
1005
1059
  if (actualRow) {
1006
- insertEdge.run(
1060
+ allEdgeRows.push([
1007
1061
  fileNodeId,
1008
1062
  actualRow.id,
1009
1063
  edgeKind === 'imports-type' ? 'imports-type' : 'imports',
1010
1064
  0.9,
1011
1065
  0,
1012
- );
1066
+ ]);
1013
1067
  }
1014
1068
  }
1015
1069
  }
1016
1070
  }
1017
1071
  }
1018
1072
  }
1073
+ }
1019
1074
 
1020
- // Build import name -> target file mapping
1021
- const importedNames = new Map();
1022
- for (const imp of symbols.imports) {
1023
- const resolvedPath = getResolved(path.join(rootDir, relPath), imp.source);
1024
- for (const name of imp.names) {
1025
- const cleanName = name.replace(/^\*\s+as\s+/, '');
1026
- importedNames.set(cleanName, resolvedPath);
1075
+ // Call/receiver/extends/implements edges native when available
1076
+ const native = engineName === 'native' ? loadNative() : null;
1077
+ if (native?.buildCallEdges) {
1078
+ const nativeFiles = [];
1079
+ for (const [relPath, symbols] of fileSymbols) {
1080
+ if (barrelOnlyFiles.has(relPath)) continue;
1081
+ const fileNodeRow = getNodeId.get(relPath, 'file', relPath, 0);
1082
+ if (!fileNodeRow) continue;
1083
+
1084
+ // Pre-resolve imported names (including barrel resolution)
1085
+ const importedNames = [];
1086
+ for (const imp of symbols.imports) {
1087
+ const resolvedPath = getResolved(path.join(rootDir, relPath), imp.source);
1088
+ for (const name of imp.names) {
1089
+ const cleanName = name.replace(/^\*\s+as\s+/, '');
1090
+ let targetFile = resolvedPath;
1091
+ if (isBarrelFile(resolvedPath)) {
1092
+ const actual = resolveBarrelExport(resolvedPath, cleanName);
1093
+ if (actual) targetFile = actual;
1094
+ }
1095
+ importedNames.push({ name: cleanName, file: targetFile });
1096
+ }
1027
1097
  }
1098
+
1099
+ nativeFiles.push({
1100
+ file: relPath,
1101
+ fileNodeId: fileNodeRow.id,
1102
+ definitions: symbols.definitions.map((d) => ({
1103
+ name: d.name,
1104
+ kind: d.kind,
1105
+ line: d.line,
1106
+ endLine: d.endLine ?? null,
1107
+ })),
1108
+ calls: symbols.calls,
1109
+ importedNames,
1110
+ classes: symbols.classes,
1111
+ });
1028
1112
  }
1029
1113
 
1030
- // Call edges with confidence scoring — using pre-loaded lookup maps (N+1 fix)
1031
- const seenCallEdges = new Set();
1032
- for (const call of symbols.calls) {
1033
- if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
1034
- let caller = null;
1035
- let callerSpan = Infinity;
1036
- for (const def of symbols.definitions) {
1037
- if (def.line <= call.line) {
1038
- const end = def.endLine || Infinity;
1039
- if (call.line <= end) {
1040
- // Call is inside this definition's range — pick narrowest
1041
- const span = end - def.line;
1042
- if (span < callerSpan) {
1043
- const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1044
- if (row) {
1045
- caller = row;
1046
- callerSpan = span;
1114
+ const nativeEdges = native.buildCallEdges(nativeFiles, allNodes, [...BUILTIN_RECEIVERS]);
1115
+
1116
+ for (const e of nativeEdges) {
1117
+ allEdgeRows.push([e.sourceId, e.targetId, e.kind, e.confidence, e.dynamic]);
1118
+ }
1119
+ } else {
1120
+ // JS fallback call/receiver/extends/implements edges
1121
+ for (const [relPath, symbols] of fileSymbols) {
1122
+ if (barrelOnlyFiles.has(relPath)) continue;
1123
+ const fileNodeRow = getNodeId.get(relPath, 'file', relPath, 0);
1124
+ if (!fileNodeRow) continue;
1125
+
1126
+ // Build import name -> target file mapping
1127
+ const importedNames = new Map();
1128
+ for (const imp of symbols.imports) {
1129
+ const resolvedPath = getResolved(path.join(rootDir, relPath), imp.source);
1130
+ for (const name of imp.names) {
1131
+ const cleanName = name.replace(/^\*\s+as\s+/, '');
1132
+ importedNames.set(cleanName, resolvedPath);
1133
+ }
1134
+ }
1135
+
1136
+ // Call edges with confidence scoring — using pre-loaded lookup maps (N+1 fix)
1137
+ const seenCallEdges = new Set();
1138
+ for (const call of symbols.calls) {
1139
+ if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
1140
+ let caller = null;
1141
+ let callerSpan = Infinity;
1142
+ for (const def of symbols.definitions) {
1143
+ if (def.line <= call.line) {
1144
+ const end = def.endLine || Infinity;
1145
+ if (call.line <= end) {
1146
+ const span = end - def.line;
1147
+ if (span < callerSpan) {
1148
+ const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1149
+ if (row) {
1150
+ caller = row;
1151
+ callerSpan = span;
1152
+ }
1047
1153
  }
1154
+ } else if (!caller) {
1155
+ const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1156
+ if (row) caller = row;
1048
1157
  }
1049
- } else if (!caller) {
1050
- // Fallback: def starts before call but call is past end
1051
- // Only use if we haven't found an enclosing scope yet
1052
- const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1053
- if (row) caller = row;
1054
1158
  }
1055
1159
  }
1056
- }
1057
- if (!caller) caller = fileNodeRow;
1160
+ if (!caller) caller = fileNodeRow;
1058
1161
 
1059
- const isDynamic = call.dynamic ? 1 : 0;
1060
- let targets;
1061
- const importedFrom = importedNames.get(call.name);
1162
+ const isDynamic = call.dynamic ? 1 : 0;
1163
+ let targets;
1164
+ const importedFrom = importedNames.get(call.name);
1062
1165
 
1063
- if (importedFrom) {
1064
- // Use pre-loaded map instead of DB query
1065
- targets = nodesByNameAndFile.get(`${call.name}|${importedFrom}`) || [];
1166
+ if (importedFrom) {
1167
+ targets = nodesByNameAndFile.get(`${call.name}|${importedFrom}`) || [];
1066
1168
 
1067
- if (targets.length === 0 && isBarrelFile(importedFrom)) {
1068
- const actualSource = resolveBarrelExport(importedFrom, call.name);
1069
- if (actualSource) {
1070
- targets = nodesByNameAndFile.get(`${call.name}|${actualSource}`) || [];
1169
+ if (targets.length === 0 && isBarrelFile(importedFrom)) {
1170
+ const actualSource = resolveBarrelExport(importedFrom, call.name);
1171
+ if (actualSource) {
1172
+ targets = nodesByNameAndFile.get(`${call.name}|${actualSource}`) || [];
1173
+ }
1071
1174
  }
1072
1175
  }
1073
- }
1074
- if (!targets || targets.length === 0) {
1075
- // Same file
1076
- targets = nodesByNameAndFile.get(`${call.name}|${relPath}`) || [];
1077
- if (targets.length === 0) {
1078
- // Method name match (e.g. ClassName.methodName)
1079
- const methodCandidates = (nodesByName.get(call.name) || []).filter(
1080
- (n) => n.name.endsWith(`.${call.name}`) && n.kind === 'method',
1081
- );
1082
- if (methodCandidates.length > 0) {
1083
- targets = methodCandidates;
1084
- } else if (
1085
- !call.receiver ||
1086
- call.receiver === 'this' ||
1087
- call.receiver === 'self' ||
1088
- call.receiver === 'super'
1089
- ) {
1090
- // Scoped fallback — same-dir or parent-dir only, not global
1091
- targets = (nodesByName.get(call.name) || []).filter(
1092
- (n) => computeConfidence(relPath, n.file, null) >= 0.5,
1176
+ if (!targets || targets.length === 0) {
1177
+ targets = nodesByNameAndFile.get(`${call.name}|${relPath}`) || [];
1178
+ if (targets.length === 0) {
1179
+ const methodCandidates = (nodesByName.get(call.name) || []).filter(
1180
+ (n) => n.name.endsWith(`.${call.name}`) && n.kind === 'method',
1093
1181
  );
1182
+ if (methodCandidates.length > 0) {
1183
+ targets = methodCandidates;
1184
+ } else if (
1185
+ !call.receiver ||
1186
+ call.receiver === 'this' ||
1187
+ call.receiver === 'self' ||
1188
+ call.receiver === 'super'
1189
+ ) {
1190
+ targets = (nodesByName.get(call.name) || []).filter(
1191
+ (n) => computeConfidence(relPath, n.file, null) >= 0.5,
1192
+ );
1193
+ }
1094
1194
  }
1095
- // else: method call on a receiver — skip global fallback entirely
1096
1195
  }
1097
- }
1098
1196
 
1099
- if (targets.length > 1) {
1100
- targets.sort((a, b) => {
1101
- const confA = computeConfidence(relPath, a.file, importedFrom);
1102
- const confB = computeConfidence(relPath, b.file, importedFrom);
1103
- return confB - confA;
1104
- });
1105
- }
1197
+ if (targets.length > 1) {
1198
+ targets.sort((a, b) => {
1199
+ const confA = computeConfidence(relPath, a.file, importedFrom);
1200
+ const confB = computeConfidence(relPath, b.file, importedFrom);
1201
+ return confB - confA;
1202
+ });
1203
+ }
1106
1204
 
1107
- for (const t of targets) {
1108
- const edgeKey = `${caller.id}|${t.id}`;
1109
- if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
1110
- seenCallEdges.add(edgeKey);
1111
- const confidence = computeConfidence(relPath, t.file, importedFrom);
1112
- insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
1205
+ for (const t of targets) {
1206
+ const edgeKey = `${caller.id}|${t.id}`;
1207
+ if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
1208
+ seenCallEdges.add(edgeKey);
1209
+ const confidence = computeConfidence(relPath, t.file, importedFrom);
1210
+ allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic]);
1211
+ }
1113
1212
  }
1114
- }
1115
1213
 
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);
1214
+ // Receiver edge: caller → receiver type node
1215
+ if (
1216
+ call.receiver &&
1217
+ !BUILTIN_RECEIVERS.has(call.receiver) &&
1218
+ call.receiver !== 'this' &&
1219
+ call.receiver !== 'self' &&
1220
+ call.receiver !== 'super'
1221
+ ) {
1222
+ const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
1223
+ const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
1224
+ const candidates =
1225
+ samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
1226
+ const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
1227
+ if (receiverNodes.length > 0 && caller) {
1228
+ const recvTarget = receiverNodes[0];
1229
+ const recvKey = `recv|${caller.id}|${recvTarget.id}`;
1230
+ if (!seenCallEdges.has(recvKey)) {
1231
+ seenCallEdges.add(recvKey);
1232
+ allEdgeRows.push([caller.id, recvTarget.id, 'receiver', 0.7, 0]);
1233
+ }
1135
1234
  }
1136
1235
  }
1137
1236
  }
1138
- }
1139
1237
 
1140
- // Class extends edges (use pre-loaded maps instead of inline DB queries)
1141
- for (const cls of symbols.classes) {
1142
- if (cls.extends) {
1143
- const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1144
- (n) => n.kind === 'class',
1145
- );
1146
- const targetCandidates = nodesByName.get(cls.extends) || [];
1147
- const targetRows = targetCandidates.filter((n) => n.kind === 'class');
1148
- if (sourceRow) {
1149
- for (const t of targetRows) {
1150
- insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
1238
+ // Class extends edges
1239
+ for (const cls of symbols.classes) {
1240
+ if (cls.extends) {
1241
+ const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1242
+ (n) => n.kind === 'class',
1243
+ );
1244
+ const targetCandidates = nodesByName.get(cls.extends) || [];
1245
+ const targetRows = targetCandidates.filter((n) => n.kind === 'class');
1246
+ if (sourceRow) {
1247
+ for (const t of targetRows) {
1248
+ allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0]);
1249
+ }
1151
1250
  }
1152
1251
  }
1153
- }
1154
1252
 
1155
- if (cls.implements) {
1156
- const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1157
- (n) => n.kind === 'class',
1158
- );
1159
- const targetCandidates = nodesByName.get(cls.implements) || [];
1160
- const targetRows = targetCandidates.filter(
1161
- (n) => n.kind === 'interface' || n.kind === 'class',
1162
- );
1163
- if (sourceRow) {
1164
- for (const t of targetRows) {
1165
- insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
1253
+ if (cls.implements) {
1254
+ const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1255
+ (n) => n.kind === 'class',
1256
+ );
1257
+ const targetCandidates = nodesByName.get(cls.implements) || [];
1258
+ const targetRows = targetCandidates.filter(
1259
+ (n) => n.kind === 'interface' || n.kind === 'class',
1260
+ );
1261
+ if (sourceRow) {
1262
+ for (const t of targetRows) {
1263
+ allEdgeRows.push([sourceRow.id, t.id, 'implements', 1.0, 0]);
1264
+ }
1166
1265
  }
1167
1266
  }
1168
1267
  }
1169
1268
  }
1170
1269
  }
1270
+
1271
+ batchInsertEdges(allEdgeRows);
1171
1272
  });
1172
1273
  buildEdges();
1173
1274
  _t.edgesMs = performance.now() - _t.edges0;
@@ -1175,8 +1276,8 @@ export async function buildGraph(rootDir, opts = {}) {
1175
1276
  // Build line count map for structure metrics (prefer cached _lineCount from parser)
1176
1277
  const lineCountMap = new Map();
1177
1278
  for (const [relPath, symbols] of fileSymbols) {
1178
- if (symbols._lineCount) {
1179
- lineCountMap.set(relPath, symbols._lineCount);
1279
+ if (symbols.lineCount ?? symbols._lineCount) {
1280
+ lineCountMap.set(relPath, symbols.lineCount ?? symbols._lineCount);
1180
1281
  } else {
1181
1282
  const absPath = path.join(rootDir, relPath);
1182
1283
  try {
@@ -1317,16 +1418,47 @@ export async function buildGraph(rootDir, opts = {}) {
1317
1418
  _t.complexityMs = performance.now() - _t.complexity0;
1318
1419
 
1319
1420
  // Pre-parse files missing WASM trees (native builds) so CFG + dataflow
1320
- // share a single parse pass instead of each creating parsers independently
1421
+ // share a single parse pass instead of each creating parsers independently.
1422
+ // Skip entirely when native engine already provides CFG + dataflow data.
1321
1423
  if (opts.cfg !== false || opts.dataflow !== false) {
1322
- _t.wasmPre0 = performance.now();
1323
- try {
1324
- const { ensureWasmTrees } = await import('./parser.js');
1325
- await ensureWasmTrees(astComplexitySymbols, rootDir);
1326
- } catch (err) {
1327
- debug(`WASM pre-parse failed: ${err.message}`);
1424
+ const needsCfg = opts.cfg !== false;
1425
+ const needsDataflow = opts.dataflow !== false;
1426
+
1427
+ let needsWasmTrees = false;
1428
+ for (const [, symbols] of astComplexitySymbols) {
1429
+ if (symbols._tree) continue; // already has a tree
1430
+ // CFG: need tree if any function/method def lacks native CFG
1431
+ if (needsCfg) {
1432
+ const fnDefs = (symbols.definitions || []).filter(
1433
+ (d) => (d.kind === 'function' || d.kind === 'method') && d.line,
1434
+ );
1435
+ if (
1436
+ fnDefs.length > 0 &&
1437
+ !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))
1438
+ ) {
1439
+ needsWasmTrees = true;
1440
+ break;
1441
+ }
1442
+ }
1443
+ // Dataflow: need tree if file lacks native dataflow
1444
+ if (needsDataflow && !symbols.dataflow) {
1445
+ needsWasmTrees = true;
1446
+ break;
1447
+ }
1448
+ }
1449
+
1450
+ if (needsWasmTrees) {
1451
+ _t.wasmPre0 = performance.now();
1452
+ try {
1453
+ const { ensureWasmTrees } = await import('./parser.js');
1454
+ await ensureWasmTrees(astComplexitySymbols, rootDir);
1455
+ } catch (err) {
1456
+ debug(`WASM pre-parse failed: ${err.message}`);
1457
+ }
1458
+ _t.wasmPreMs = performance.now() - _t.wasmPre0;
1459
+ } else {
1460
+ _t.wasmPreMs = 0;
1328
1461
  }
1329
- _t.wasmPreMs = performance.now() - _t.wasmPre0;
1330
1462
  }
1331
1463
 
1332
1464
  // CFG analysis (skip with --no-cfg)
@@ -1400,6 +1532,29 @@ export async function buildGraph(rootDir, opts = {}) {
1400
1532
  }
1401
1533
  }
1402
1534
 
1535
+ // Warn about unused exports (exported but zero cross-file consumers)
1536
+ try {
1537
+ const unusedCount = db
1538
+ .prepare(
1539
+ `SELECT COUNT(*) as c FROM nodes
1540
+ WHERE exported = 1 AND kind != 'file'
1541
+ AND id NOT IN (
1542
+ SELECT DISTINCT e.target_id FROM edges e
1543
+ JOIN nodes caller ON e.source_id = caller.id
1544
+ JOIN nodes target ON e.target_id = target.id
1545
+ WHERE e.kind = 'calls' AND caller.file != target.file
1546
+ )`,
1547
+ )
1548
+ .get().c;
1549
+ if (unusedCount > 0) {
1550
+ warn(
1551
+ `${unusedCount} exported symbol${unusedCount > 1 ? 's have' : ' has'} zero cross-file consumers. Run "codegraph exports <file> --unused" to inspect.`,
1552
+ );
1553
+ }
1554
+ } catch {
1555
+ /* exported column may not exist on older DBs */
1556
+ }
1557
+
1403
1558
  // Persist build metadata for mismatch detection
1404
1559
  try {
1405
1560
  setBuildMeta(db, {