From fc83988f4f91238d1cfc4c71fa9ae95e76ef00a2 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:47:25 -0600 Subject: [PATCH 1/4] 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 --- .../graph/builder/stages/build-edges.ts | 17 +++++++++++ src/features/structure.ts | 18 ++++++------ tests/unit/roles.test.ts | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index e85712fd..4d31db23 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); } diff --git a/src/features/structure.ts b/src/features/structure.ts index b361fb04..42028ca3 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -534,7 +534,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 +560,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), @@ -573,7 +573,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`, ) @@ -628,7 +628,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 = 'calls' + WHERE e.kind IN ('calls', 'imports-type') AND n1.file IN (${seedPlaceholders}) AND n2.file NOT IN (${seedPlaceholders}) AND n2.kind NOT IN ('file', 'directory')`, @@ -640,7 +640,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) @@ -668,7 +670,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') @@ -694,7 +696,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 }[] @@ -709,7 +711,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/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'); + }); }); From a6aedc20dd1542d8588b36ba6a0a36aab3f4563b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:17:44 -0600 Subject: [PATCH 2/4] 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. --- src/features/structure.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/structure.ts b/src/features/structure.ts index 42028ca3..f0a9ae13 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[]; From df2fd4a517d8e286243d52896df3028bfedc071a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:18:03 -0600 Subject: [PATCH 3/4] fix: add symbol-level type-import edges to native and incremental paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/codegraph-core/src/edge_builder.rs | 59 ++++++++++++++++++- crates/codegraph-core/src/import_edges.rs | 34 +++++++++++ src/domain/graph/builder/incremental.ts | 21 +++++++ .../graph/builder/stages/build-edges.ts | 14 ++++- src/types.ts | 1 + 5 files changed, 127 insertions(+), 2 deletions(-) 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 8d3966a7..a1eb92e1 100644 --- a/crates/codegraph-core/src/import_edges.rs +++ b/crates/codegraph-core/src/import_edges.rs @@ -161,6 +161,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: @@ -214,6 +224,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 4d31db23..fabd23b6 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -297,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, @@ -305,6 +316,7 @@ function buildImportEdgesNative( fileNodeIds, barrelFiles, rootDir, + symbolNodes, ) as NativeEdge[]; for (const e of nativeEdges) { diff --git a/src/types.ts b/src/types.ts index e4e63495..142d14f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1878,6 +1878,7 @@ export interface NativeAddon { fileNodeIds: unknown[], barrelFiles: string[], rootDir: string, + symbolNodes?: Array<{ name: string; file: string; nodeId: number }>, ): unknown[]; engineVersion(): string; analyzeComplexity( From 1417f7addce8baedc6bc7eb5648fc8479a096bdb Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:41:07 -0600 Subject: [PATCH 4/4] 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. --- tests/benchmarks/regression-guard.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/benchmarks/regression-guard.test.ts b/tests/benchmarks/regression-guard.test.ts index 918de7fa..d51d01f4 100644 --- a/tests/benchmarks/regression-guard.test.ts +++ b/tests/benchmarks/regression-guard.test.ts @@ -65,8 +65,16 @@ const SKIP_VERSIONS = new Set(['3.8.0']); * - 3.9.0:1-file rebuild — native incremental path re-runs graph-wide phases * (structureMs, AST, CFG, dataflow) on single-file rebuilds. Documented in * BUILD-BENCHMARKS.md Notes section with phase-level breakdown. + * - 3.9.0:fnDeps depth {1,3,5} — 177-184% jump reflects codebase growth + * (23 new language extractors in 3.8.x) and the comparison gap from 3.7.0 + * (3.8.x query data was removed). Documented in QUERY-BENCHMARKS.md Notes. */ -const KNOWN_REGRESSIONS = new Set(['3.9.0:1-file rebuild']); +const KNOWN_REGRESSIONS = new Set([ + '3.9.0:1-file rebuild', + '3.9.0:fnDeps depth 1', + '3.9.0:fnDeps depth 3', + '3.9.0:fnDeps depth 5', +]); /** * Maximum minor-version gap allowed for comparison. When the nearest