Skip to content

Commit 5d99bf0

Browse files
committed
fix(exports): credit plain imports of TypeScript interfaces/type aliases
codegraph exports/dead-export analysis only credited a symbol-level imports-type edge when the importing statement was syntactically marked type-only (import type { X } or the inline import { type X } modifier). TypeScript also allows importing an interface or type alias through a plain import { X } from 'y' with no type keyword at all -- an extremely common pattern in codebases that don't enforce import/consistent-type-imports. Since interfaces and type aliases are erased before runtime, they can never receive a calls edge either, so a plain import was the only possible consumption signal and it was being ignored -- permanently misclassifying every such interface/type alias as a dead export. Fix the root cause: classify imports-type crediting by the resolved target's node kind (interface/type in a .ts/.tsx file), not by the importing statement's syntax. A plain import of a value symbol (function, class, const) is unaffected -- its consumption credit still requires a real calls edge. Scoped to TypeScript files specifically, since other languages reuse the 'interface'/'type' node kinds for constructs that are runtime-observable (e.g. a Go type alias, a Java interface dispatched through at runtime). Mirrored in both engines: the WASM/JS full-build and incremental pipelines (build-edges.ts, incremental.ts) and both native Rust import-edge paths (the FFI-hybrid build_edges.rs and the native orchestrator's import_edges.rs). Impact: 13 functions changed, 25 affected
1 parent e105c26 commit 5d99bf0

11 files changed

Lines changed: 660 additions & 117 deletions

File tree

crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs

Lines changed: 189 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,14 +1536,18 @@ pub struct ResolvedImportEntry {
15361536
}
15371537

15381538
/// A symbol node entry for type-only import resolution.
1539-
/// Maps (name, file) → nodeId so the native engine can create symbol-level
1540-
/// `imports-type` edges (parity with the JS `buildImportEdges` path).
1539+
/// Maps (name, file) → (nodeId, kind) so the native engine can create
1540+
/// symbol-level `imports-type` edges (parity with the JS `buildImportEdges`
1541+
/// path) — `kind` lets it also credit plain imports of TypeScript
1542+
/// interface/type-alias declarations, not just `import type` statements
1543+
/// (#1833).
15411544
#[napi(object)]
15421545
pub struct SymbolNodeEntry {
15431546
pub name: String,
15441547
pub file: String,
15451548
#[napi(js_name = "nodeId")]
15461549
pub node_id: u32,
1550+
pub kind: String,
15471551
}
15481552

15491553
/// Shared lookup context for import edge building.
@@ -1553,13 +1557,15 @@ struct ImportEdgeContext<'a> {
15531557
file_node_map: HashMap<&'a str, u32>,
15541558
barrel_set: HashSet<&'a str>,
15551559
file_defs: HashMap<&'a str, HashSet<&'a str>>,
1556-
/// Symbol node lookup: (name, file) → node ID.
1557-
/// Used to create symbol-level `imports-type` edges for type-only imports.
1560+
/// Symbol node lookup: (name, file) → (node ID, kind).
1561+
/// Used to create symbol-level `imports-type` edges for type-only imports,
1562+
/// and — via `kind` — for plain imports resolving to a TypeScript
1563+
/// interface/type-alias declaration (#1833).
15581564
///
15591565
/// Owned keys (rather than `&'a str`) because a barrel-rename lookup key
15601566
/// (#1823) is a freshly-resolved name that doesn't borrow from `'a` input
15611567
/// data.
1562-
symbol_node_map: HashMap<(String, String), u32>,
1568+
symbol_node_map: HashMap<(String, String), (u32, String)>,
15631569
}
15641570

15651571
impl<'a> ImportEdgeContext<'a> {
@@ -1597,7 +1603,10 @@ impl<'a> ImportEdgeContext<'a> {
15971603

15981604
let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len());
15991605
for entry in symbol_nodes {
1600-
symbol_node_map.insert((entry.name.clone(), entry.file.clone()), entry.node_id);
1606+
symbol_node_map.insert(
1607+
(entry.name.clone(), entry.file.clone()),
1608+
(entry.node_id, entry.kind.clone()),
1609+
);
16011610
}
16021611

16031612
Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map }
@@ -1698,23 +1707,21 @@ fn is_named_reexport(imp: &ImportInfo) -> bool {
16981707
imp.reexport && !imp.wildcard_reexport
16991708
}
17001709

1701-
/// True when an import carries any type-only signal — a whole-statement
1702-
/// `import type { X }` or at least one inline per-specifier `type` modifier
1703-
/// (`import { type X }`, #1813).
1704-
fn has_type_only_names(imp: &ImportInfo) -> bool {
1705-
imp.type_only || !imp.type_only_names.is_empty()
1706-
}
1707-
17081710
/// For a `type` import or a named re-export targeting a barrel or resolved
17091711
/// file, emit one symbol-level edge per named symbol so the target symbols
17101712
/// receive fan-in credit and aren't misclassified as dead code
17111713
/// (`imports-type`, #1724), or so `codegraph exports` can report the
17121714
/// precise re-export surface instead of the target's full export list
17131715
/// (`reexports`, #1742). `kind` selects which edge kind to emit.
17141716
///
1715-
/// For `kind == "imports-type"`, only specifiers actually marked type-only
1716-
/// (whole-statement or inline per-specifier, #1813) get an edge — a mixed
1717-
/// `import { value, type Foo }` must not credit `value`.
1717+
/// For `kind == "imports-type"`, a specifier gets an edge when either it's
1718+
/// actually marked type-only (whole-statement or inline per-specifier,
1719+
/// #1813 — a mixed `import { value, type Foo }` must not credit `value` on
1720+
/// this basis alone), or the resolved target is a TypeScript
1721+
/// interface/type-alias declaration (`is_type_erased_import_target`) — those
1722+
/// kinds are erased before runtime, so a plain `import { Foo } from 'y'` (no
1723+
/// `type` keyword) is the only consumption signal `codegraph exports` can
1724+
/// observe for them (#1833).
17181725
///
17191726
/// `imp.names` holds the *original* declaration name for export specifiers
17201727
/// (see `extractImportNames` in the JS extractor) even when renamed
@@ -1741,35 +1748,41 @@ fn emit_named_symbol_edges(
17411748
}
17421749
for name in &imp.names {
17431750
let clean_name = strip_star_as_prefix(name);
1744-
if kind == "imports-type"
1745-
&& !imp.type_only
1746-
&& !imp.type_only_names.iter().any(|n| n == clean_name)
1747-
{
1748-
continue;
1749-
}
1751+
let type_only = imp.type_only || imp.type_only_names.iter().any(|n| n == clean_name);
17501752
let barrel_target = if ctx.barrel_set.contains(resolved_path) {
17511753
let mut visited = HashSet::new();
17521754
barrel_resolution::resolve_barrel_export(ctx, resolved_path, clean_name, &mut visited)
17531755
} else {
17541756
None
17551757
};
1756-
let sym_id = barrel_target
1757-
.as_ref()
1758-
.and_then(|resolved| ctx.symbol_node_map.get(&(resolved.name.clone(), resolved.file.clone())))
1759-
.or_else(|| {
1760-
ctx.symbol_node_map
1761-
.get(&(clean_name.to_string(), resolved_path.to_string()))
1762-
});
1763-
if let Some(&id) = sym_id {
1764-
edges.push(ComputedEdge {
1765-
source_id: file_input.file_node_id,
1766-
target_id: id,
1767-
kind: kind.to_string(),
1768-
confidence: 1.0,
1769-
dynamic: 0,
1770-
dynamic_kind: None,
1771-
});
1758+
let (target_name, target_file) = match &barrel_target {
1759+
Some(resolved)
1760+
if ctx
1761+
.symbol_node_map
1762+
.contains_key(&(resolved.name.clone(), resolved.file.clone())) =>
1763+
{
1764+
(resolved.name.clone(), resolved.file.clone())
1765+
}
1766+
_ => (clean_name.to_string(), resolved_path.to_string()),
1767+
};
1768+
let Some((id, sym_kind)) = ctx.symbol_node_map.get(&(target_name, target_file.clone()))
1769+
else {
1770+
continue;
1771+
};
1772+
if kind == "imports-type"
1773+
&& !type_only
1774+
&& !crate::shared::constants::is_type_erased_import_target(sym_kind, &target_file)
1775+
{
1776+
continue;
17721777
}
1778+
edges.push(ComputedEdge {
1779+
source_id: file_input.file_node_id,
1780+
target_id: *id,
1781+
kind: kind.to_string(),
1782+
confidence: 1.0,
1783+
dynamic: 0,
1784+
dynamic_kind: None,
1785+
});
17731786
}
17741787
}
17751788

@@ -1854,7 +1867,10 @@ fn process_single_import(
18541867
dynamic: 0,
18551868
dynamic_kind: None,
18561869
});
1857-
if has_type_only_names(imp) {
1870+
// Always attempted (not just for `import type`/inline-`type` specifiers) —
1871+
// emit_named_symbol_edges also credits plain specifiers that resolve to a
1872+
// TypeScript interface/type-alias declaration (#1833).
1873+
if !imp.reexport {
18581874
emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx);
18591875
}
18601876
if is_named_reexport(imp) {
@@ -1962,6 +1978,7 @@ mod import_edge_tests {
19621978
name: "foo".to_string(),
19631979
file: "src/utils.ts".to_string(),
19641980
node_id: 99,
1981+
kind: "function".to_string(),
19651982
}];
19661983

19671984
let edges = build_import_edges(
@@ -2004,6 +2021,7 @@ mod import_edge_tests {
20042021
name: "foo".to_string(),
20052022
file: "src/utils.ts".to_string(),
20062023
node_id: 99,
2024+
kind: "function".to_string(),
20072025
}];
20082026

20092027
let edges = build_import_edges(
@@ -2032,9 +2050,19 @@ mod import_edge_tests {
20322050
let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")];
20332051
let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)];
20342052
let symbol_nodes = vec![
2035-
SymbolNodeEntry { name: "foo".to_string(), file: "src/utils.ts".to_string(), node_id: 99 },
2053+
SymbolNodeEntry {
2054+
name: "foo".to_string(),
2055+
file: "src/utils.ts".to_string(),
2056+
node_id: 99,
2057+
kind: "function".to_string(),
2058+
},
20362059
// A decoy under the external alias name must NOT be matched.
2037-
SymbolNodeEntry { name: "bar".to_string(), file: "src/utils.ts".to_string(), node_id: 100 },
2060+
SymbolNodeEntry {
2061+
name: "bar".to_string(),
2062+
file: "src/utils.ts".to_string(),
2063+
node_id: 100,
2064+
kind: "function".to_string(),
2065+
},
20382066
];
20392067

20402068
let edges = build_import_edges(
@@ -2077,8 +2105,18 @@ mod import_edge_tests {
20772105
let resolved = vec![make_resolved("/root/src/app.ts", "./mixed", "src/mixed.ts")];
20782106
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/mixed.ts", 2)];
20792107
let symbol_nodes = vec![
2080-
SymbolNodeEntry { name: "Foo".to_string(), file: "src/mixed.ts".to_string(), node_id: 50 },
2081-
SymbolNodeEntry { name: "value".to_string(), file: "src/mixed.ts".to_string(), node_id: 51 },
2108+
SymbolNodeEntry {
2109+
name: "Foo".to_string(),
2110+
file: "src/mixed.ts".to_string(),
2111+
node_id: 50,
2112+
kind: "function".to_string(),
2113+
},
2114+
SymbolNodeEntry {
2115+
name: "value".to_string(),
2116+
file: "src/mixed.ts".to_string(),
2117+
node_id: 51,
2118+
kind: "function".to_string(),
2119+
},
20822120
];
20832121

20842122
let edges = build_import_edges(
@@ -2096,6 +2134,114 @@ mod import_edge_tests {
20962134
assert_eq!(edges[1].target_id, 50);
20972135
}
20982136

2137+
#[test]
2138+
fn plain_import_of_ts_interface_credits_imports_type_edge() {
2139+
// `import { Foo } from './types'` — no `type` keyword — where `Foo`
2140+
// is a TypeScript interface. Interfaces are erased before runtime, so
2141+
// this plain import is the only observable consumption signal
2142+
// `codegraph exports` can rely on; it must be credited exactly like
2143+
// `import type { Foo }` would be (#1833).
2144+
let files = vec![make_file(
2145+
"src/app.ts",
2146+
1,
2147+
vec![make_import("./types", vec!["Foo"], false, false, false)],
2148+
vec![],
2149+
)];
2150+
let resolved = vec![make_resolved("/root/src/app.ts", "./types", "src/types.ts")];
2151+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/types.ts", 2)];
2152+
let symbol_nodes = vec![SymbolNodeEntry {
2153+
name: "Foo".to_string(),
2154+
file: "src/types.ts".to_string(),
2155+
node_id: 50,
2156+
kind: "interface".to_string(),
2157+
}];
2158+
2159+
let edges = build_import_edges(
2160+
files,
2161+
resolved,
2162+
vec![],
2163+
node_ids,
2164+
vec![],
2165+
"/root".to_string(),
2166+
Some(symbol_nodes),
2167+
);
2168+
assert_eq!(edges.len(), 2);
2169+
assert_eq!(edges[0].kind, "imports");
2170+
assert_eq!(edges[1].kind, "imports-type");
2171+
assert_eq!(edges[1].target_id, 50);
2172+
}
2173+
2174+
#[test]
2175+
fn plain_import_of_ts_value_symbol_gets_no_symbol_level_edge() {
2176+
// `import { helper } from './utils'` where `helper` is a plain
2177+
// function (not an interface/type alias). Consumption credit for a
2178+
// value symbol must still come exclusively from a real `calls` edge
2179+
// — merely importing it must not fabricate one (#1833 must not
2180+
// regress the existing value-import behaviour).
2181+
let files = vec![make_file(
2182+
"src/app.ts",
2183+
1,
2184+
vec![make_import("./utils", vec!["helper"], false, false, false)],
2185+
vec![],
2186+
)];
2187+
let resolved = vec![make_resolved("/root/src/app.ts", "./utils", "src/utils.ts")];
2188+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/utils.ts", 2)];
2189+
let symbol_nodes = vec![SymbolNodeEntry {
2190+
name: "helper".to_string(),
2191+
file: "src/utils.ts".to_string(),
2192+
node_id: 50,
2193+
kind: "function".to_string(),
2194+
}];
2195+
2196+
let edges = build_import_edges(
2197+
files,
2198+
resolved,
2199+
vec![],
2200+
node_ids,
2201+
vec![],
2202+
"/root".to_string(),
2203+
Some(symbol_nodes),
2204+
);
2205+
assert_eq!(edges.len(), 1);
2206+
assert_eq!(edges[0].kind, "imports");
2207+
}
2208+
2209+
#[test]
2210+
fn plain_import_of_non_typescript_interface_gets_no_symbol_level_edge() {
2211+
// A plain import resolving to an 'interface'-kind node in a
2212+
// non-TypeScript file (e.g. a Go `type ... interface {}`) must not be
2213+
// credited by this heuristic — those kinds are runtime-observable in
2214+
// other languages, so crediting on mere import would mask genuinely
2215+
// dead code instead of fixing a false positive (#1833 is scoped to
2216+
// TypeScript's compile-time-only interfaces/type aliases).
2217+
let files = vec![make_file(
2218+
"src/app.ts",
2219+
1,
2220+
vec![make_import("./iface", vec!["Reader"], false, false, false)],
2221+
vec![],
2222+
)];
2223+
let resolved = vec![make_resolved("/root/src/app.ts", "./iface", "src/iface.go")];
2224+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/iface.go", 2)];
2225+
let symbol_nodes = vec![SymbolNodeEntry {
2226+
name: "Reader".to_string(),
2227+
file: "src/iface.go".to_string(),
2228+
node_id: 50,
2229+
kind: "interface".to_string(),
2230+
}];
2231+
2232+
let edges = build_import_edges(
2233+
files,
2234+
resolved,
2235+
vec![],
2236+
node_ids,
2237+
vec![],
2238+
"/root".to_string(),
2239+
Some(symbol_nodes),
2240+
);
2241+
assert_eq!(edges.len(), 1);
2242+
assert_eq!(edges[0].kind, "imports");
2243+
}
2244+
20992245
#[test]
21002246
fn dynamic_import_edge() {
21012247
let files = vec![make_file("src/app.ts", 1, vec![

0 commit comments

Comments
 (0)