diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 3a4194ac..ab37f9f7 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -422,6 +422,17 @@ pub struct ResolvedImportEntry { pub resolved_path: String, } +/// A symbol node entry for type-only import resolution. +/// Maps (name, file) → nodeId so the native engine can create symbol-level +/// `imports-type` edges (parity with the JS `buildImportEdges` path). +#[napi(object)] +pub struct SymbolNodeEntry { + pub name: String, + pub file: String, + #[napi(js_name = "nodeId")] + pub node_id: u32, +} + /// Shared lookup context for import edge building. struct ImportEdgeContext<'a> { resolved: HashMap<&'a str, &'a str>, @@ -429,6 +440,9 @@ struct ImportEdgeContext<'a> { file_node_map: HashMap<&'a str, u32>, barrel_set: HashSet<&'a str>, file_defs: HashMap<&'a str, HashSet<&'a str>>, + /// Symbol node lookup: (name, file) → node ID. + /// Used to create symbol-level `imports-type` edges for type-only imports. + symbol_node_map: HashMap<(&'a str, &'a str), u32>, } impl<'a> ImportEdgeContext<'a> { @@ -438,6 +452,7 @@ impl<'a> ImportEdgeContext<'a> { file_node_ids: &'a [FileNodeEntry], barrel_files: &'a [String], files: &'a [ImportEdgeFileInput], + symbol_nodes: &'a [SymbolNodeEntry], ) -> Self { let mut resolved = HashMap::with_capacity(resolved_imports.len()); for ri in resolved_imports { @@ -463,7 +478,12 @@ impl<'a> ImportEdgeContext<'a> { file_defs.insert(f.file.as_str(), defs); } - Self { resolved, reexport_map, file_node_map, barrel_set, file_defs } + let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len()); + for entry in symbol_nodes { + symbol_node_map.insert((entry.name.as_str(), entry.file.as_str()), entry.node_id); + } + + Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map } } } @@ -500,13 +520,18 @@ pub fn build_import_edges( file_node_ids: Vec, barrel_files: Vec, root_dir: String, + #[napi(ts_arg_type = "SymbolNodeEntry[] | undefined")] + symbol_nodes: Option>, ) -> Vec { + let empty_symbols = Vec::new(); + let symbols_ref = symbol_nodes.as_deref().unwrap_or(&empty_symbols); let ctx = ImportEdgeContext::new( &resolved_imports, &file_reexports, &file_node_ids, &barrel_files, &files, + symbols_ref, ); let mut edges = Vec::new(); @@ -552,6 +577,38 @@ pub fn build_import_edges( dynamic: 0, }); + // Type-only imports: create symbol-level edges so the target symbols + // get fan-in credit and aren't falsely classified as dead code. + if imp.type_only && !ctx.symbol_node_map.is_empty() { + for name in &imp.names { + let clean_name = if name.starts_with("* as ") || name.starts_with("*\tas ") { + &name[5..] + } else { + name.as_str() + }; + // Try barrel resolution first, then fall back to the resolved path + let barrel_target = if ctx.barrel_set.contains(resolved_path) { + let mut visited = HashSet::new(); + barrel_resolution::resolve_barrel_export(&ctx, resolved_path, clean_name, &mut visited) + } else { + None + }; + let sym_id = barrel_target + .as_deref() + .and_then(|f| ctx.symbol_node_map.get(&(clean_name, f))) + .or_else(|| ctx.symbol_node_map.get(&(clean_name, resolved_path))); + if let Some(&id) = sym_id { + edges.push(ComputedEdge { + source_id: file_input.file_node_id, + target_id: id, + kind: "imports-type".to_string(), + confidence: 1.0, + dynamic: 0, + }); + } + } + } + // Barrel resolution: if not reexport and target is a barrel file if !imp.reexport && ctx.barrel_set.contains(resolved_path) { let mut resolved_sources: HashSet = HashSet::new(); diff --git a/crates/codegraph-core/src/import_edges.rs b/crates/codegraph-core/src/import_edges.rs index 75af8f7f..f0f517c2 100644 --- a/crates/codegraph-core/src/import_edges.rs +++ b/crates/codegraph-core/src/import_edges.rs @@ -163,6 +163,16 @@ fn get_file_node_id(conn: &Connection, rel_path: &str) -> Option { .ok() } +/// Look up the first symbol node ID by name and file (for type-only import resolution). +fn get_symbol_node_id(conn: &Connection, name: &str, file: &str) -> Option { + conn.query_row( + "SELECT id FROM nodes WHERE name = ? AND file = ? AND kind != 'file' LIMIT 1", + [name, file], + |row| row.get(0), + ) + .ok() +} + /// Build import edges from parsed file symbols. /// /// 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; + if (candidates.length > 0) { + stmts.insertEdge.run(fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0); + edgesAdded++; + } + } + } + // Barrel resolution: create edges through re-export chains if (!imp.reexport && db) { edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp); diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index e85712fd..fabd23b6 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -119,6 +119,23 @@ function buildImportEdges( : 'imports'; allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]); + // Type-only imports: create symbol-level edges so the target symbols + // get fan-in credit and aren't falsely classified as dead code. + if (imp.typeOnly && ctx.nodesByNameAndFile) { + for (const name of imp.names) { + const cleanName = name.replace(/^\*\s+as\s+/, ''); + let targetFile = resolvedPath; + if (isBarrelFile(ctx, resolvedPath)) { + const actual = resolveBarrelExport(ctx, resolvedPath, cleanName); + if (actual) targetFile = actual; + } + const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`); + if (candidates && candidates.length > 0) { + allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0]); + } + } + } + if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) { buildBarrelEdges(ctx, imp, resolvedPath, fileNodeId, edgeKind, getNodeIdStmt, allEdgeRows); } @@ -280,7 +297,18 @@ function buildImportEdgesNative( } } - // 6. Call native + // 6. Build symbol node entries for type-only import resolution + const symbolNodes: Array<{ name: string; file: string; nodeId: number }> = []; + if (ctx.nodesByNameAndFile) { + for (const [key, nodes] of ctx.nodesByNameAndFile) { + if (nodes.length > 0) { + const [name, file] = key.split('|'); + symbolNodes.push({ name: name!, file: file!, nodeId: nodes[0]!.id }); + } + } + } + + // 7. Call native const nativeEdges = native.buildImportEdges!( files, resolvedImports, @@ -288,6 +316,7 @@ function buildImportEdgesNative( fileNodeIds, barrelFiles, rootDir, + symbolNodes, ) as NativeEdge[]; for (const e of nativeEdges) { diff --git a/src/features/structure.ts b/src/features/structure.ts index 3508cfe8..111bcec4 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -145,6 +145,7 @@ function computeImportEdgeMaps(db: BetterSqlite3Database): { JOIN nodes n2 ON e.target_id = n2.id WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file + AND n2.kind = 'file' `) .all() as ImportEdge[]; @@ -534,7 +535,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm COALESCE(fo.cnt, 0) AS fan_out FROM nodes n LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id + SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id ) fi ON n.id = fi.target_id LEFT JOIN ( 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 FROM edges e JOIN nodes caller ON e.source_id = caller.id JOIN nodes target ON e.target_id = target.id - WHERE e.kind = 'calls' AND caller.file != target.file`, + WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file`, ) .all() as { target_id: number }[] ).map((r) => r.target_id), @@ -603,7 +604,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm `SELECT e.target_id, COUNT(*) AS cnt FROM edges e JOIN nodes caller ON e.source_id = caller.id - WHERE e.kind = 'calls' + WHERE e.kind IN ('calls', 'imports-type') ${testFilterSQL('caller.file')} GROUP BY e.target_id`, ) @@ -668,7 +669,7 @@ function classifyNodeRolesIncremental( `SELECT DISTINCT n2.file FROM edges e JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id) JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id) - WHERE e.kind IN ('calls', 'reexports') + WHERE e.kind IN ('calls', 'imports-type', 'reexports') AND n1.file IN (${seedPlaceholders}) AND n2.file NOT IN (${seedPlaceholders}) AND n2.kind NOT IN ('file', 'directory')`, @@ -680,7 +681,9 @@ function classifyNodeRolesIncremental( // 1. Compute global medians from edge distribution (fast: scans edge index, no node join) const fanInDist = ( db - .prepare(`SELECT COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id`) + .prepare( + `SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id`, + ) .all() as { cnt: number }[] ) .map((r) => r.cnt) @@ -708,7 +711,7 @@ function classifyNodeRolesIncremental( const rows = db .prepare( `SELECT n.id, n.name, n.kind, n.file, - (SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND target_id = n.id) AS fan_in, + (SELECT COUNT(*) FROM edges WHERE kind IN ('calls', 'imports-type') AND target_id = n.id) AS fan_in, (SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND source_id = n.id) AS fan_out FROM nodes n WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property') @@ -734,7 +737,7 @@ function classifyNodeRolesIncremental( FROM edges e JOIN nodes caller ON e.source_id = caller.id JOIN nodes target ON e.target_id = target.id - WHERE e.kind = 'calls' AND caller.file != target.file + WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file AND target.file IN (${placeholders})`, ) .all(...allAffectedFiles) as { target_id: number }[] @@ -780,7 +783,7 @@ function classifyNodeRolesIncremental( FROM edges e JOIN nodes caller ON e.source_id = caller.id JOIN nodes target ON e.target_id = target.id - WHERE e.kind = 'calls' + WHERE e.kind IN ('calls', 'imports-type') AND target.file IN (${placeholders}) ${testFilterSQL('caller.file')} GROUP BY e.target_id`, diff --git a/src/types.ts b/src/types.ts index d57ebfc2..c352691b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1913,6 +1913,7 @@ export interface NativeAddon { fileNodeIds: unknown[], barrelFiles: string[], rootDir: string, + symbolNodes?: Array<{ name: string; file: string; nodeId: number }>, ): unknown[]; engineVersion(): string; analyzeComplexity( diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index d729f8e0..9feddb93 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -192,4 +192,32 @@ describe('classifyNodeRoles', () => { const role = db.prepare("SELECT role FROM nodes WHERE name = 'fn1'").get(); expect(role.role).toBe('dead-unresolved'); }); + + it('does not classify type-imported interfaces as dead (#840)', () => { + // Simulate: file b.ts has `import type { MyInterface } from './a'` + // This should create a symbol-level imports-type edge from b.ts file node + // to the MyInterface symbol, giving it fan-in > 0. + const fA = insertNode('a.ts', 'file', 'a.ts', 0); + const fB = insertNode('b.ts', 'file', 'b.ts', 0); + const iface = insertNode('MyInterface', 'interface', 'a.ts', 5); + + // File-level imports-type edge (file → file) + insertEdge(fB, fA, 'imports-type'); + // Symbol-level imports-type edge (file → symbol) — the fix creates these + insertEdge(fB, iface, 'imports-type'); + + classifyNodeRoles(db); + const role = db.prepare("SELECT role FROM nodes WHERE name = 'MyInterface'").get(); + // Should NOT be dead — it has a type-import consumer + expect(role.role).not.toMatch(/^dead/); + }); + + it('classifies interface with no type-import edges as dead', () => { + insertNode('a.ts', 'file', 'a.ts', 0); + insertNode('UnusedInterface', 'interface', 'a.ts', 5); + + classifyNodeRoles(db); + const role = db.prepare("SELECT role FROM nodes WHERE name = 'UnusedInterface'").get(); + expect(role.role).toBe('dead-unresolved'); + }); });