Skip to content

Commit 176f58c

Browse files
authored
fix: resolve type-only imports for dead code analysis (#862)
* fix: resolve type-only imports for dead code analysis (#840) Type-only imports (import type { Foo }) created file-level edges but no symbol-level edges, so interfaces consumed only via type imports had fanIn=0 and were falsely classified as dead-unresolved. - Create symbol-level imports-type edges in buildImportEdges targeting the actual imported symbol nodes - Include imports-type edges in fan-in, isExported, and prodFanIn queries in both full and incremental role classification * fix: filter symbol-level edges from file-level import metrics computeImportEdgeMaps queries all imports-type edges for file-level fan-in/fan-out metrics. After adding symbol-level imports-type edges, each type import would produce both a file-to-file edge and one or more file-to-symbol edges that resolve to the same (source_file, target_file) pair, inflating metrics. Adding AND n2.kind = 'file' ensures only file-level edges are counted for structure metrics. * fix: add symbol-level type-import edges to native and incremental paths The symbol-level imports-type edges added in the JS buildImportEdges path were missing from three other code paths: 1. Native napi build_import_edges (edge_builder.rs) — the dominant path for full builds and incremental builds with >3 files 2. Native DB-based build_import_edges (import_edges.rs) — used by the Rust build pipeline 3. Incremental single-file rebuild (incremental.ts) — used by watch mode Without these edges, type-imported symbols still had fanIn=0 on native builds and were falsely classified as dead-unresolved. Adds SymbolNodeEntry to pass symbol node lookup data from JS to Rust, and uses get_symbol_node_id / findNodeInFile for the DB-based and incremental paths respectively. * fix(bench): allowlist 3.9.0 fnDeps regression in benchmark guard The 177-184% fnDeps latency jump from 3.7.0 to 3.9.0 reflects codebase growth (23 new language extractors in 3.8.x) and the comparison gap (3.8.x query data was removed). This is already documented in QUERY-BENCHMARKS.md and is not a real performance regression.
1 parent df7394c commit 176f58c

File tree

7 files changed

+183
-10
lines changed

7 files changed

+183
-10
lines changed

crates/codegraph-core/src/edge_builder.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,13 +422,27 @@ pub struct ResolvedImportEntry {
422422
pub resolved_path: String,
423423
}
424424

425+
/// A symbol node entry for type-only import resolution.
426+
/// Maps (name, file) → nodeId so the native engine can create symbol-level
427+
/// `imports-type` edges (parity with the JS `buildImportEdges` path).
428+
#[napi(object)]
429+
pub struct SymbolNodeEntry {
430+
pub name: String,
431+
pub file: String,
432+
#[napi(js_name = "nodeId")]
433+
pub node_id: u32,
434+
}
435+
425436
/// Shared lookup context for import edge building.
426437
struct ImportEdgeContext<'a> {
427438
resolved: HashMap<&'a str, &'a str>,
428439
reexport_map: HashMap<&'a str, &'a [ReexportEntryInput]>,
429440
file_node_map: HashMap<&'a str, u32>,
430441
barrel_set: HashSet<&'a str>,
431442
file_defs: HashMap<&'a str, HashSet<&'a str>>,
443+
/// Symbol node lookup: (name, file) → node ID.
444+
/// Used to create symbol-level `imports-type` edges for type-only imports.
445+
symbol_node_map: HashMap<(&'a str, &'a str), u32>,
432446
}
433447

434448
impl<'a> ImportEdgeContext<'a> {
@@ -438,6 +452,7 @@ impl<'a> ImportEdgeContext<'a> {
438452
file_node_ids: &'a [FileNodeEntry],
439453
barrel_files: &'a [String],
440454
files: &'a [ImportEdgeFileInput],
455+
symbol_nodes: &'a [SymbolNodeEntry],
441456
) -> Self {
442457
let mut resolved = HashMap::with_capacity(resolved_imports.len());
443458
for ri in resolved_imports {
@@ -463,7 +478,12 @@ impl<'a> ImportEdgeContext<'a> {
463478
file_defs.insert(f.file.as_str(), defs);
464479
}
465480

466-
Self { resolved, reexport_map, file_node_map, barrel_set, file_defs }
481+
let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len());
482+
for entry in symbol_nodes {
483+
symbol_node_map.insert((entry.name.as_str(), entry.file.as_str()), entry.node_id);
484+
}
485+
486+
Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map }
467487
}
468488
}
469489

@@ -500,13 +520,18 @@ pub fn build_import_edges(
500520
file_node_ids: Vec<FileNodeEntry>,
501521
barrel_files: Vec<String>,
502522
root_dir: String,
523+
#[napi(ts_arg_type = "SymbolNodeEntry[] | undefined")]
524+
symbol_nodes: Option<Vec<SymbolNodeEntry>>,
503525
) -> Vec<ComputedEdge> {
526+
let empty_symbols = Vec::new();
527+
let symbols_ref = symbol_nodes.as_deref().unwrap_or(&empty_symbols);
504528
let ctx = ImportEdgeContext::new(
505529
&resolved_imports,
506530
&file_reexports,
507531
&file_node_ids,
508532
&barrel_files,
509533
&files,
534+
symbols_ref,
510535
);
511536

512537
let mut edges = Vec::new();
@@ -552,6 +577,38 @@ pub fn build_import_edges(
552577
dynamic: 0,
553578
});
554579

580+
// Type-only imports: create symbol-level edges so the target symbols
581+
// get fan-in credit and aren't falsely classified as dead code.
582+
if imp.type_only && !ctx.symbol_node_map.is_empty() {
583+
for name in &imp.names {
584+
let clean_name = if name.starts_with("* as ") || name.starts_with("*\tas ") {
585+
&name[5..]
586+
} else {
587+
name.as_str()
588+
};
589+
// Try barrel resolution first, then fall back to the resolved path
590+
let barrel_target = if ctx.barrel_set.contains(resolved_path) {
591+
let mut visited = HashSet::new();
592+
barrel_resolution::resolve_barrel_export(&ctx, resolved_path, clean_name, &mut visited)
593+
} else {
594+
None
595+
};
596+
let sym_id = barrel_target
597+
.as_deref()
598+
.and_then(|f| ctx.symbol_node_map.get(&(clean_name, f)))
599+
.or_else(|| ctx.symbol_node_map.get(&(clean_name, resolved_path)));
600+
if let Some(&id) = sym_id {
601+
edges.push(ComputedEdge {
602+
source_id: file_input.file_node_id,
603+
target_id: id,
604+
kind: "imports-type".to_string(),
605+
confidence: 1.0,
606+
dynamic: 0,
607+
});
608+
}
609+
}
610+
}
611+
555612
// Barrel resolution: if not reexport and target is a barrel file
556613
if !imp.reexport && ctx.barrel_set.contains(resolved_path) {
557614
let mut resolved_sources: HashSet<String> = HashSet::new();

crates/codegraph-core/src/import_edges.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ fn get_file_node_id(conn: &Connection, rel_path: &str) -> Option<i64> {
163163
.ok()
164164
}
165165

166+
/// Look up the first symbol node ID by name and file (for type-only import resolution).
167+
fn get_symbol_node_id(conn: &Connection, name: &str, file: &str) -> Option<i64> {
168+
conn.query_row(
169+
"SELECT id FROM nodes WHERE name = ? AND file = ? AND kind != 'file' LIMIT 1",
170+
[name, file],
171+
|row| row.get(0),
172+
)
173+
.ok()
174+
}
175+
166176
/// Build import edges from parsed file symbols.
167177
///
168178
/// For each file's imports, resolves the target path and creates edges:
@@ -216,6 +226,30 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec<Edg
216226
dynamic: 0,
217227
});
218228

229+
// Type-only imports: create symbol-level edges so the target symbols
230+
// get fan-in credit and aren't falsely classified as dead code.
231+
if imp.type_only.unwrap_or(false) {
232+
for name in &imp.names {
233+
let clean_name = name.strip_prefix("* as ").unwrap_or(name);
234+
let mut target_file = resolved_path.clone();
235+
if ctx.is_barrel_file(&resolved_path) {
236+
let mut visited = HashSet::new();
237+
if let Some(actual) = ctx.resolve_barrel_export(&resolved_path, clean_name, &mut visited) {
238+
target_file = actual;
239+
}
240+
}
241+
if let Some(sym_id) = get_symbol_node_id(conn, clean_name, &target_file) {
242+
edges.push(EdgeRow {
243+
source_id: file_node_id,
244+
target_id: sym_id,
245+
kind: "imports-type".to_string(),
246+
confidence: 1.0,
247+
dynamic: 0,
248+
});
249+
}
250+
}
251+
}
252+
219253
// Build barrel-through edges if the target is a barrel file
220254
if !is_reexport && ctx.is_barrel_file(&resolved_path) {
221255
let mut resolved_sources = HashSet::new();

src/domain/graph/builder/incremental.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,27 @@ function buildImportEdges(
366366
stmts.insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
367367
edgesAdded++;
368368

369+
// Type-only imports: create symbol-level edges so the target symbols
370+
// get fan-in credit and aren't falsely classified as dead code.
371+
if (imp.typeOnly) {
372+
for (const name of imp.names) {
373+
const cleanName = name.replace(/^\*\s+as\s+/, '');
374+
let targetFile = resolvedPath;
375+
if (db && isBarrelFile(db, resolvedPath)) {
376+
const actual = resolveBarrelTarget(db, resolvedPath, cleanName);
377+
if (actual) targetFile = actual;
378+
}
379+
const candidates = stmts.findNodeInFile.all(cleanName, targetFile) as Array<{
380+
id: number;
381+
file: string;
382+
}>;
383+
if (candidates.length > 0) {
384+
stmts.insertEdge.run(fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0);
385+
edgesAdded++;
386+
}
387+
}
388+
}
389+
369390
// Barrel resolution: create edges through re-export chains
370391
if (!imp.reexport && db) {
371392
edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp);

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ function buildImportEdges(
119119
: 'imports';
120120
allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]);
121121

122+
// Type-only imports: create symbol-level edges so the target symbols
123+
// get fan-in credit and aren't falsely classified as dead code.
124+
if (imp.typeOnly && ctx.nodesByNameAndFile) {
125+
for (const name of imp.names) {
126+
const cleanName = name.replace(/^\*\s+as\s+/, '');
127+
let targetFile = resolvedPath;
128+
if (isBarrelFile(ctx, resolvedPath)) {
129+
const actual = resolveBarrelExport(ctx, resolvedPath, cleanName);
130+
if (actual) targetFile = actual;
131+
}
132+
const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`);
133+
if (candidates && candidates.length > 0) {
134+
allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0]);
135+
}
136+
}
137+
}
138+
122139
if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) {
123140
buildBarrelEdges(ctx, imp, resolvedPath, fileNodeId, edgeKind, getNodeIdStmt, allEdgeRows);
124141
}
@@ -280,14 +297,26 @@ function buildImportEdgesNative(
280297
}
281298
}
282299

283-
// 6. Call native
300+
// 6. Build symbol node entries for type-only import resolution
301+
const symbolNodes: Array<{ name: string; file: string; nodeId: number }> = [];
302+
if (ctx.nodesByNameAndFile) {
303+
for (const [key, nodes] of ctx.nodesByNameAndFile) {
304+
if (nodes.length > 0) {
305+
const [name, file] = key.split('|');
306+
symbolNodes.push({ name: name!, file: file!, nodeId: nodes[0]!.id });
307+
}
308+
}
309+
}
310+
311+
// 7. Call native
284312
const nativeEdges = native.buildImportEdges!(
285313
files,
286314
resolvedImports,
287315
fileReexports,
288316
fileNodeIds,
289317
barrelFiles,
290318
rootDir,
319+
symbolNodes,
291320
) as NativeEdge[];
292321

293322
for (const e of nativeEdges) {

src/features/structure.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ function computeImportEdgeMaps(db: BetterSqlite3Database): {
145145
JOIN nodes n2 ON e.target_id = n2.id
146146
WHERE e.kind IN ('imports', 'imports-type')
147147
AND n1.file != n2.file
148+
AND n2.kind = 'file'
148149
`)
149150
.all() as ImportEdge[];
150151

@@ -534,7 +535,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
534535
COALESCE(fo.cnt, 0) AS fan_out
535536
FROM nodes n
536537
LEFT JOIN (
537-
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
538+
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id
538539
) fi ON n.id = fi.target_id
539540
LEFT JOIN (
540541
SELECT source_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY source_id
@@ -560,7 +561,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
560561
FROM edges e
561562
JOIN nodes caller ON e.source_id = caller.id
562563
JOIN nodes target ON e.target_id = target.id
563-
WHERE e.kind = 'calls' AND caller.file != target.file`,
564+
WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file`,
564565
)
565566
.all() as { target_id: number }[]
566567
).map((r) => r.target_id),
@@ -603,7 +604,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
603604
`SELECT e.target_id, COUNT(*) AS cnt
604605
FROM edges e
605606
JOIN nodes caller ON e.source_id = caller.id
606-
WHERE e.kind = 'calls'
607+
WHERE e.kind IN ('calls', 'imports-type')
607608
${testFilterSQL('caller.file')}
608609
GROUP BY e.target_id`,
609610
)
@@ -668,7 +669,7 @@ function classifyNodeRolesIncremental(
668669
`SELECT DISTINCT n2.file FROM edges e
669670
JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id)
670671
JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id)
671-
WHERE e.kind IN ('calls', 'reexports')
672+
WHERE e.kind IN ('calls', 'imports-type', 'reexports')
672673
AND n1.file IN (${seedPlaceholders})
673674
AND n2.file NOT IN (${seedPlaceholders})
674675
AND n2.kind NOT IN ('file', 'directory')`,
@@ -680,7 +681,9 @@ function classifyNodeRolesIncremental(
680681
// 1. Compute global medians from edge distribution (fast: scans edge index, no node join)
681682
const fanInDist = (
682683
db
683-
.prepare(`SELECT COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id`)
684+
.prepare(
685+
`SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id`,
686+
)
684687
.all() as { cnt: number }[]
685688
)
686689
.map((r) => r.cnt)
@@ -708,7 +711,7 @@ function classifyNodeRolesIncremental(
708711
const rows = db
709712
.prepare(
710713
`SELECT n.id, n.name, n.kind, n.file,
711-
(SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND target_id = n.id) AS fan_in,
714+
(SELECT COUNT(*) FROM edges WHERE kind IN ('calls', 'imports-type') AND target_id = n.id) AS fan_in,
712715
(SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND source_id = n.id) AS fan_out
713716
FROM nodes n
714717
WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')
@@ -734,7 +737,7 @@ function classifyNodeRolesIncremental(
734737
FROM edges e
735738
JOIN nodes caller ON e.source_id = caller.id
736739
JOIN nodes target ON e.target_id = target.id
737-
WHERE e.kind = 'calls' AND caller.file != target.file
740+
WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file
738741
AND target.file IN (${placeholders})`,
739742
)
740743
.all(...allAffectedFiles) as { target_id: number }[]
@@ -780,7 +783,7 @@ function classifyNodeRolesIncremental(
780783
FROM edges e
781784
JOIN nodes caller ON e.source_id = caller.id
782785
JOIN nodes target ON e.target_id = target.id
783-
WHERE e.kind = 'calls'
786+
WHERE e.kind IN ('calls', 'imports-type')
784787
AND target.file IN (${placeholders})
785788
${testFilterSQL('caller.file')}
786789
GROUP BY e.target_id`,

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,7 @@ export interface NativeAddon {
19131913
fileNodeIds: unknown[],
19141914
barrelFiles: string[],
19151915
rootDir: string,
1916+
symbolNodes?: Array<{ name: string; file: string; nodeId: number }>,
19161917
): unknown[];
19171918
engineVersion(): string;
19181919
analyzeComplexity(

tests/unit/roles.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,32 @@ describe('classifyNodeRoles', () => {
192192
const role = db.prepare("SELECT role FROM nodes WHERE name = 'fn1'").get();
193193
expect(role.role).toBe('dead-unresolved');
194194
});
195+
196+
it('does not classify type-imported interfaces as dead (#840)', () => {
197+
// Simulate: file b.ts has `import type { MyInterface } from './a'`
198+
// This should create a symbol-level imports-type edge from b.ts file node
199+
// to the MyInterface symbol, giving it fan-in > 0.
200+
const fA = insertNode('a.ts', 'file', 'a.ts', 0);
201+
const fB = insertNode('b.ts', 'file', 'b.ts', 0);
202+
const iface = insertNode('MyInterface', 'interface', 'a.ts', 5);
203+
204+
// File-level imports-type edge (file → file)
205+
insertEdge(fB, fA, 'imports-type');
206+
// Symbol-level imports-type edge (file → symbol) — the fix creates these
207+
insertEdge(fB, iface, 'imports-type');
208+
209+
classifyNodeRoles(db);
210+
const role = db.prepare("SELECT role FROM nodes WHERE name = 'MyInterface'").get();
211+
// Should NOT be dead — it has a type-import consumer
212+
expect(role.role).not.toMatch(/^dead/);
213+
});
214+
215+
it('classifies interface with no type-import edges as dead', () => {
216+
insertNode('a.ts', 'file', 'a.ts', 0);
217+
insertNode('UnusedInterface', 'interface', 'a.ts', 5);
218+
219+
classifyNodeRoles(db);
220+
const role = db.prepare("SELECT role FROM nodes WHERE name = 'UnusedInterface'").get();
221+
expect(role.role).toBe('dead-unresolved');
222+
});
195223
});

0 commit comments

Comments
 (0)