@optave/codegraph 3.0.4 → 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', dataflow: opts.dataflow !== false };
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
 
@@ -672,15 +677,38 @@ export async function buildGraph(rootDir, opts = {}) {
672
677
  }
673
678
  }
674
679
 
675
- const insertNode = db.prepare(
676
- 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
677
- );
678
680
  const getNodeId = db.prepare(
679
681
  'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
680
682
  );
681
- const insertEdge = db.prepare(
682
- 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
683
- );
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
+ }
684
712
 
685
713
  // Prepare hash upsert (with size column from migration v4)
686
714
  let upsertHash;
@@ -727,57 +755,76 @@ export async function buildGraph(rootDir, opts = {}) {
727
755
  const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
728
756
 
729
757
  const insertAll = db.transaction(() => {
758
+ // Phase 1: Batch insert all file nodes + definitions + exports
759
+ const phase1Rows = [];
730
760
  for (const [relPath, symbols] of allSymbols) {
731
761
  fileSymbols.set(relPath, symbols);
732
-
733
- // Phase 1: Insert file node + definitions + exports (no children yet)
734
- insertNode.run(relPath, 'file', relPath, 0, null, null);
762
+ phase1Rows.push([relPath, 'file', relPath, 0, null, null]);
735
763
  for (const def of symbols.definitions) {
736
- 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]);
737
765
  }
738
766
  for (const exp of symbols.exports) {
739
- insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
767
+ phase1Rows.push([exp.name, exp.kind, relPath, exp.line, null, null]);
740
768
  }
769
+ }
770
+ batchInsertNodes(phase1Rows);
741
771
 
742
- // Phase 2: Bulk-fetch IDs for file + definitions
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) {
777
+ for (const exp of symbols.exports) {
778
+ markExported.run(exp.name, exp.kind, relPath, exp.line);
779
+ }
780
+ }
781
+
782
+ // Phase 3: Batch insert children (needs parent IDs from Phase 2)
783
+ const childRows = [];
784
+ for (const [relPath, symbols] of allSymbols) {
743
785
  const nodeIdMap = new Map();
744
786
  for (const row of bulkGetNodeIds.all(relPath)) {
745
787
  nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
746
788
  }
747
-
748
- // Phase 3: Insert children with parent_id from the map
749
789
  for (const def of symbols.definitions) {
750
790
  if (!def.children?.length) continue;
751
791
  const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
752
792
  if (!defId) continue;
753
793
  for (const child of def.children) {
754
- 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
+ ]);
755
802
  }
756
803
  }
804
+ }
805
+ batchInsertNodes(childRows);
757
806
 
758
- // Phase 4: Re-fetch to include children IDs
759
- 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();
760
812
  for (const row of bulkGetNodeIds.all(relPath)) {
761
813
  nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
762
814
  }
763
-
764
- // Phase 5: Insert edges using the cached ID map
765
815
  const fileId = nodeIdMap.get(`${relPath}|file|0`);
766
816
  for (const def of symbols.definitions) {
767
817
  const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
768
- // File → top-level definition contains edge
769
818
  if (fileId && defId) {
770
- insertEdge.run(fileId, defId, 'contains', 1.0, 0);
819
+ edgeRows.push([fileId, defId, 'contains', 1.0, 0]);
771
820
  }
772
821
  if (def.children?.length && defId) {
773
822
  for (const child of def.children) {
774
823
  const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
775
824
  if (childId) {
776
- // Parent child contains edge
777
- insertEdge.run(defId, childId, 'contains', 1.0, 0);
778
- // Parameter → parent parameter_of edge (inverse direction)
825
+ edgeRows.push([defId, childId, 'contains', 1.0, 0]);
779
826
  if (child.kind === 'parameter') {
780
- insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
827
+ edgeRows.push([childId, defId, 'parameter_of', 1.0, 0]);
781
828
  }
782
829
  }
783
830
  }
@@ -812,6 +859,7 @@ export async function buildGraph(rootDir, opts = {}) {
812
859
  }
813
860
  }
814
861
  }
862
+ batchInsertEdges(edgeRows);
815
863
 
816
864
  // Also update metadata-only entries (self-heal mtime/size without re-parse)
817
865
  if (upsertHash) {
@@ -848,7 +896,7 @@ export async function buildGraph(rootDir, opts = {}) {
848
896
  batchInputs.push({ fromFile: absFile, importSource: imp.source });
849
897
  }
850
898
  }
851
- const batchResolved = resolveImportsBatch(batchInputs, rootDir, aliases);
899
+ const batchResolved = resolveImportsBatch(batchInputs, rootDir, aliases, files);
852
900
  _t.resolveMs = performance.now() - _t.resolve0;
853
901
 
854
902
  function getResolved(absFile, importSource) {
@@ -961,7 +1009,7 @@ export async function buildGraph(rootDir, opts = {}) {
961
1009
  // N+1 optimization: pre-load all nodes into a lookup map for edge building
962
1010
  const allNodes = db
963
1011
  .prepare(
964
- `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')`,
965
1013
  )
966
1014
  .all();
967
1015
  const nodesByName = new Map();
@@ -976,9 +1024,11 @@ export async function buildGraph(rootDir, opts = {}) {
976
1024
  nodesByNameAndFile.get(key).push(node);
977
1025
  }
978
1026
 
979
- // Second pass: build edges
1027
+ // Second pass: build edges (accumulated and batch-inserted)
980
1028
  _t.edges0 = performance.now();
981
1029
  const buildEdges = db.transaction(() => {
1030
+ const allEdgeRows = [];
1031
+
982
1032
  for (const [relPath, symbols] of fileSymbols) {
983
1033
  // Skip barrel-only files — loaded for resolution, edges already in DB
984
1034
  if (barrelOnlyFiles.has(relPath)) continue;
@@ -992,7 +1042,7 @@ export async function buildGraph(rootDir, opts = {}) {
992
1042
  const targetRow = getNodeId.get(resolvedPath, 'file', resolvedPath, 0);
993
1043
  if (targetRow) {
994
1044
  const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
995
- insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
1045
+ allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]);
996
1046
 
997
1047
  if (!imp.reexport && isBarrelFile(resolvedPath)) {
998
1048
  const resolvedSources = new Set();
@@ -1007,171 +1057,218 @@ export async function buildGraph(rootDir, opts = {}) {
1007
1057
  resolvedSources.add(actualSource);
1008
1058
  const actualRow = getNodeId.get(actualSource, 'file', actualSource, 0);
1009
1059
  if (actualRow) {
1010
- insertEdge.run(
1060
+ allEdgeRows.push([
1011
1061
  fileNodeId,
1012
1062
  actualRow.id,
1013
1063
  edgeKind === 'imports-type' ? 'imports-type' : 'imports',
1014
1064
  0.9,
1015
1065
  0,
1016
- );
1066
+ ]);
1017
1067
  }
1018
1068
  }
1019
1069
  }
1020
1070
  }
1021
1071
  }
1022
1072
  }
1073
+ }
1023
1074
 
1024
- // Build import name -> target file mapping
1025
- const importedNames = new Map();
1026
- for (const imp of symbols.imports) {
1027
- const resolvedPath = getResolved(path.join(rootDir, relPath), imp.source);
1028
- for (const name of imp.names) {
1029
- const cleanName = name.replace(/^\*\s+as\s+/, '');
1030
- 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
+ }
1031
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
+ });
1032
1112
  }
1033
1113
 
1034
- // Call edges with confidence scoring — using pre-loaded lookup maps (N+1 fix)
1035
- const seenCallEdges = new Set();
1036
- for (const call of symbols.calls) {
1037
- if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
1038
- let caller = null;
1039
- let callerSpan = Infinity;
1040
- for (const def of symbols.definitions) {
1041
- if (def.line <= call.line) {
1042
- const end = def.endLine || Infinity;
1043
- if (call.line <= end) {
1044
- // Call is inside this definition's range — pick narrowest
1045
- const span = end - def.line;
1046
- if (span < callerSpan) {
1047
- const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1048
- if (row) {
1049
- caller = row;
1050
- 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
+ }
1051
1153
  }
1154
+ } else if (!caller) {
1155
+ const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1156
+ if (row) caller = row;
1052
1157
  }
1053
- } else if (!caller) {
1054
- // Fallback: def starts before call but call is past end
1055
- // Only use if we haven't found an enclosing scope yet
1056
- const row = getNodeId.get(def.name, def.kind, relPath, def.line);
1057
- if (row) caller = row;
1058
1158
  }
1059
1159
  }
1060
- }
1061
- if (!caller) caller = fileNodeRow;
1160
+ if (!caller) caller = fileNodeRow;
1062
1161
 
1063
- const isDynamic = call.dynamic ? 1 : 0;
1064
- let targets;
1065
- const importedFrom = importedNames.get(call.name);
1162
+ const isDynamic = call.dynamic ? 1 : 0;
1163
+ let targets;
1164
+ const importedFrom = importedNames.get(call.name);
1066
1165
 
1067
- if (importedFrom) {
1068
- // Use pre-loaded map instead of DB query
1069
- targets = nodesByNameAndFile.get(`${call.name}|${importedFrom}`) || [];
1166
+ if (importedFrom) {
1167
+ targets = nodesByNameAndFile.get(`${call.name}|${importedFrom}`) || [];
1070
1168
 
1071
- if (targets.length === 0 && isBarrelFile(importedFrom)) {
1072
- const actualSource = resolveBarrelExport(importedFrom, call.name);
1073
- if (actualSource) {
1074
- 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
+ }
1075
1174
  }
1076
1175
  }
1077
- }
1078
- if (!targets || targets.length === 0) {
1079
- // Same file
1080
- targets = nodesByNameAndFile.get(`${call.name}|${relPath}`) || [];
1081
- if (targets.length === 0) {
1082
- // Method name match (e.g. ClassName.methodName)
1083
- const methodCandidates = (nodesByName.get(call.name) || []).filter(
1084
- (n) => n.name.endsWith(`.${call.name}`) && n.kind === 'method',
1085
- );
1086
- if (methodCandidates.length > 0) {
1087
- targets = methodCandidates;
1088
- } else if (
1089
- !call.receiver ||
1090
- call.receiver === 'this' ||
1091
- call.receiver === 'self' ||
1092
- call.receiver === 'super'
1093
- ) {
1094
- // Scoped fallback — same-dir or parent-dir only, not global
1095
- targets = (nodesByName.get(call.name) || []).filter(
1096
- (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',
1097
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
+ }
1098
1194
  }
1099
- // else: method call on a receiver — skip global fallback entirely
1100
1195
  }
1101
- }
1102
1196
 
1103
- if (targets.length > 1) {
1104
- targets.sort((a, b) => {
1105
- const confA = computeConfidence(relPath, a.file, importedFrom);
1106
- const confB = computeConfidence(relPath, b.file, importedFrom);
1107
- return confB - confA;
1108
- });
1109
- }
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
+ }
1110
1204
 
1111
- for (const t of targets) {
1112
- const edgeKey = `${caller.id}|${t.id}`;
1113
- if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
1114
- seenCallEdges.add(edgeKey);
1115
- const confidence = computeConfidence(relPath, t.file, importedFrom);
1116
- 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
+ }
1117
1212
  }
1118
- }
1119
1213
 
1120
- // Receiver edge: caller → receiver type node
1121
- if (
1122
- call.receiver &&
1123
- !BUILTIN_RECEIVERS.has(call.receiver) &&
1124
- call.receiver !== 'this' &&
1125
- call.receiver !== 'self' &&
1126
- call.receiver !== 'super'
1127
- ) {
1128
- const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
1129
- // Same-file first, then global
1130
- const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
1131
- const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
1132
- const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
1133
- if (receiverNodes.length > 0 && caller) {
1134
- const recvTarget = receiverNodes[0];
1135
- const recvKey = `recv|${caller.id}|${recvTarget.id}`;
1136
- if (!seenCallEdges.has(recvKey)) {
1137
- seenCallEdges.add(recvKey);
1138
- 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
+ }
1139
1234
  }
1140
1235
  }
1141
1236
  }
1142
- }
1143
1237
 
1144
- // Class extends edges (use pre-loaded maps instead of inline DB queries)
1145
- for (const cls of symbols.classes) {
1146
- if (cls.extends) {
1147
- const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1148
- (n) => n.kind === 'class',
1149
- );
1150
- const targetCandidates = nodesByName.get(cls.extends) || [];
1151
- const targetRows = targetCandidates.filter((n) => n.kind === 'class');
1152
- if (sourceRow) {
1153
- for (const t of targetRows) {
1154
- 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
+ }
1155
1250
  }
1156
1251
  }
1157
- }
1158
1252
 
1159
- if (cls.implements) {
1160
- const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
1161
- (n) => n.kind === 'class',
1162
- );
1163
- const targetCandidates = nodesByName.get(cls.implements) || [];
1164
- const targetRows = targetCandidates.filter(
1165
- (n) => n.kind === 'interface' || n.kind === 'class',
1166
- );
1167
- if (sourceRow) {
1168
- for (const t of targetRows) {
1169
- 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
+ }
1170
1265
  }
1171
1266
  }
1172
1267
  }
1173
1268
  }
1174
1269
  }
1270
+
1271
+ batchInsertEdges(allEdgeRows);
1175
1272
  });
1176
1273
  buildEdges();
1177
1274
  _t.edgesMs = performance.now() - _t.edges0;
@@ -1179,8 +1276,8 @@ export async function buildGraph(rootDir, opts = {}) {
1179
1276
  // Build line count map for structure metrics (prefer cached _lineCount from parser)
1180
1277
  const lineCountMap = new Map();
1181
1278
  for (const [relPath, symbols] of fileSymbols) {
1182
- if (symbols._lineCount) {
1183
- lineCountMap.set(relPath, symbols._lineCount);
1279
+ if (symbols.lineCount ?? symbols._lineCount) {
1280
+ lineCountMap.set(relPath, symbols.lineCount ?? symbols._lineCount);
1184
1281
  } else {
1185
1282
  const absPath = path.join(rootDir, relPath);
1186
1283
  try {
@@ -1435,6 +1532,29 @@ export async function buildGraph(rootDir, opts = {}) {
1435
1532
  }
1436
1533
  }
1437
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
+
1438
1558
  // Persist build metadata for mismatch detection
1439
1559
  try {
1440
1560
  setBuildMeta(db, {
package/src/cfg.js CHANGED
@@ -1046,9 +1046,17 @@ export function buildFunctionCFG(functionNode, langId) {
1046
1046
  export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1047
1047
  // Lazily init WASM parsers if needed
1048
1048
  let parsers = null;
1049
- let extToLang = null;
1050
1049
  let needsFallback = false;
1051
1050
 
1051
+ // Always build ext→langId map so native-only builds (where _langId is unset)
1052
+ // can still derive the language from the file extension.
1053
+ const extToLang = new Map();
1054
+ for (const entry of LANGUAGE_REGISTRY) {
1055
+ for (const ext of entry.extensions) {
1056
+ extToLang.set(ext, entry.id);
1057
+ }
1058
+ }
1059
+
1052
1060
  for (const [relPath, symbols] of fileSymbols) {
1053
1061
  if (!symbols._tree) {
1054
1062
  const ext = path.extname(relPath).toLowerCase();
@@ -1068,12 +1076,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1068
1076
  if (needsFallback) {
1069
1077
  const { createParsers } = await import('./parser.js');
1070
1078
  parsers = await createParsers();
1071
- extToLang = new Map();
1072
- for (const entry of LANGUAGE_REGISTRY) {
1073
- for (const ext of entry.extensions) {
1074
- extToLang.set(ext, entry.id);
1075
- }
1076
- }
1077
1079
  }
1078
1080
 
1079
1081
  let getParserFn = null;
@@ -1115,7 +1117,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1115
1117
 
1116
1118
  // WASM fallback if no cached tree and not all native
1117
1119
  if (!tree && !allNative) {
1118
- if (!extToLang || !getParserFn) continue;
1120
+ if (!getParserFn) continue;
1119
1121
  langId = extToLang.get(ext);
1120
1122
  if (!langId || !CFG_LANG_IDS.has(langId)) continue;
1121
1123
 
@@ -1138,7 +1140,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1138
1140
  }
1139
1141
 
1140
1142
  if (!langId) {
1141
- langId = extToLang ? extToLang.get(ext) : null;
1143
+ langId = extToLang.get(ext);
1142
1144
  if (!langId) continue;
1143
1145
  }
1144
1146