Skip to content
59 changes: 58 additions & 1 deletion crates/codegraph-core/src/edge_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,13 +422,27 @@ 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>,
reexport_map: HashMap<&'a str, &'a [ReexportEntryInput]>,
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> {
Expand All @@ -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 {
Expand All @@ -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 }
}
}

Expand Down Expand Up @@ -500,13 +520,18 @@ pub fn build_import_edges(
file_node_ids: Vec<FileNodeEntry>,
barrel_files: Vec<String>,
root_dir: String,
#[napi(ts_arg_type = "SymbolNodeEntry[] | undefined")]
symbol_nodes: Option<Vec<SymbolNodeEntry>>,
) -> Vec<ComputedEdge> {
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();
Expand Down Expand Up @@ -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<String> = HashSet::new();
Expand Down
34 changes: 34 additions & 0 deletions crates/codegraph-core/src/import_edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ fn get_file_node_id(conn: &Connection, rel_path: &str) -> Option<i64> {
.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<i64> {
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:
Expand Down Expand Up @@ -216,6 +226,30 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec<Edg
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.unwrap_or(false) {
for name in &imp.names {
let clean_name = name.strip_prefix("* as ").unwrap_or(name);
let mut target_file = resolved_path.clone();
if ctx.is_barrel_file(&resolved_path) {
let mut visited = HashSet::new();
if let Some(actual) = ctx.resolve_barrel_export(&resolved_path, clean_name, &mut visited) {
target_file = actual;
}
}
if let Some(sym_id) = get_symbol_node_id(conn, clean_name, &target_file) {
edges.push(EdgeRow {
source_id: file_node_id,
target_id: sym_id,
kind: "imports-type".to_string(),
confidence: 1.0,
dynamic: 0,
});
}
}
}

// Build barrel-through edges if the target is a barrel file
if !is_reexport && ctx.is_barrel_file(&resolved_path) {
let mut resolved_sources = HashSet::new();
Expand Down
21 changes: 21 additions & 0 deletions src/domain/graph/builder/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,27 @@ function buildImportEdges(
stmts.insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
edgesAdded++;

// 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) {
for (const name of imp.names) {
const cleanName = name.replace(/^\*\s+as\s+/, '');
let targetFile = resolvedPath;
if (db && isBarrelFile(db, resolvedPath)) {
const actual = resolveBarrelTarget(db, resolvedPath, cleanName);
if (actual) targetFile = actual;
}
const candidates = stmts.findNodeInFile.all(cleanName, targetFile) as Array<{
id: number;
file: string;
}>;
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);
Expand Down
31 changes: 30 additions & 1 deletion src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
}
Comment on lines +124 to +137
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Native engine parity gap — fix doesn't apply to native builds

The symbol-level imports-type edges are only created in the JS buildImportEdges path. The native Rust counterpart (crates/codegraph-core/src/import_edges.rs, build_import_edges) was not updated and only creates file-level edges:

// Rust only looks up file nodes — never symbol nodes
let target_id = match get_file_node_id(conn, &resolved_path) {
    Some(id) => id,
    None => continue,
};

The fallback at line 790–793 only retriggers JS if native produced zero edges total. Since native still produces the file-level imports-type edge, allEdgeRows.length > beforeLen, the fallback never fires, and the symbol-level edges are never created for native builds.

Result: on any full build or incremental build with fileSymbols.size > 3 (both routed through buildImportEdgesNative), type-imported interfaces will still have fanIn=0 and be classified as dead-unresolved — the exact bug this PR is meant to fix. CLAUDE.md explicitly states that engine divergence is a bug that must be fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in df2fd4a. Added symbol-level imports-type edge emission to both native paths:

  1. edge_builder.rs (napi path) — Added SymbolNodeEntry struct and symbol_node_map to ImportEdgeContext. The JS side now passes all symbol nodes via a new symbolNodes parameter. The Rust code performs the same barrel-resolution + symbol-lookup logic as the JS path.

  2. import_edges.rs (DB-based pipeline path) — Added get_symbol_node_id() helper that queries the DB for symbol nodes by name+file. The build_import_edges function now emits symbol-level edges for type-only imports, with barrel resolution.

  3. incremental.ts (watch mode path) — Also lacked symbol-level edges. Now uses findNodeInFile to resolve symbol nodes for type-only imports.

All 569 tests pass.


if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) {
buildBarrelEdges(ctx, imp, resolvedPath, fileNodeId, edgeKind, getNodeIdStmt, allEdgeRows);
}
Expand Down Expand Up @@ -280,14 +297,26 @@ 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,
fileReexports,
fileNodeIds,
barrelFiles,
rootDir,
symbolNodes,
) as NativeEdge[];

for (const e of nativeEdges) {
Expand Down
19 changes: 11 additions & 8 deletions src/features/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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`,
)
Expand Down Expand Up @@ -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')`,
Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand All @@ -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 }[]
Expand Down Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading