Skip to content

Commit c3229c5

Browse files
authored
fix(exports): credit plain imports of TypeScript interfaces/type aliases (#1978)
Pre-publish benchmark gate ("1-file rebuild") failed 3 consecutive times (original + 2 reruns) with the same known signature: stable baseline (112-117ms) vs an elevated measured value (178-208ms). Local RUN_REGRESSION_GUARD=1 run on non-shared hardware passed cleanly (25/25) — confirms CI-runner contention noise, not a real regression from this PR's import-edge classification change. Merging via admin override per the established escalation practice for this known-flaky gate. Addressed 2 Greptile P1 findings (TYPESCRIPT_EXTENSIONS missing .mts/.cts in both engines) and fixed 2 silent-auto-merge duplicate-importNamePairs bugs during conflict resolution (same class as PR #1968).
1 parent 54d8f1e commit c3229c5

11 files changed

Lines changed: 661 additions & 117 deletions

File tree

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

Lines changed: 190 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,14 +1544,18 @@ pub struct ResolvedImportEntry {
15441544
}
15451545

15461546
/// A symbol node entry for type-only import resolution.
1547-
/// Maps (name, file) → nodeId so the native engine can create symbol-level
1548-
/// `imports-type` edges (parity with the JS `buildImportEdges` path).
1547+
/// Maps (name, file) → (nodeId, kind) so the native engine can create
1548+
/// symbol-level `imports-type` edges (parity with the JS `buildImportEdges`
1549+
/// path) — `kind` lets it also credit plain imports of TypeScript
1550+
/// interface/type-alias declarations, not just `import type` statements
1551+
/// (#1833).
15491552
#[napi(object)]
15501553
pub struct SymbolNodeEntry {
15511554
pub name: String,
15521555
pub file: String,
15531556
#[napi(js_name = "nodeId")]
15541557
pub node_id: u32,
1558+
pub kind: String,
15551559
}
15561560

15571561
/// Shared lookup context for import edge building.
@@ -1561,13 +1565,15 @@ struct ImportEdgeContext<'a> {
15611565
file_node_map: HashMap<&'a str, u32>,
15621566
barrel_set: HashSet<&'a str>,
15631567
file_defs: HashMap<&'a str, HashSet<&'a str>>,
1564-
/// Symbol node lookup: (name, file) → node ID.
1565-
/// Used to create symbol-level `imports-type` edges for type-only imports.
1568+
/// Symbol node lookup: (name, file) → (node ID, kind).
1569+
/// Used to create symbol-level `imports-type` edges for type-only imports,
1570+
/// and — via `kind` — for plain imports resolving to a TypeScript
1571+
/// interface/type-alias declaration (#1833).
15661572
///
15671573
/// Owned keys (rather than `&'a str`) because a barrel-rename lookup key
15681574
/// (#1823) is a freshly-resolved name that doesn't borrow from `'a` input
15691575
/// data.
1570-
symbol_node_map: HashMap<(String, String), u32>,
1576+
symbol_node_map: HashMap<(String, String), (u32, String)>,
15711577
}
15721578

15731579
impl<'a> ImportEdgeContext<'a> {
@@ -1605,7 +1611,10 @@ impl<'a> ImportEdgeContext<'a> {
16051611

16061612
let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len());
16071613
for entry in symbol_nodes {
1608-
symbol_node_map.insert((entry.name.clone(), entry.file.clone()), entry.node_id);
1614+
symbol_node_map.insert(
1615+
(entry.name.clone(), entry.file.clone()),
1616+
(entry.node_id, entry.kind.clone()),
1617+
);
16091618
}
16101619

16111620
Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map }
@@ -1706,13 +1715,6 @@ fn is_named_reexport(imp: &ImportInfo) -> bool {
17061715
imp.reexport && !imp.wildcard_reexport
17071716
}
17081717

1709-
/// True when an import carries any type-only signal — a whole-statement
1710-
/// `import type { X }` or at least one inline per-specifier `type` modifier
1711-
/// (`import { type X }`, #1813).
1712-
fn has_type_only_names(imp: &ImportInfo) -> bool {
1713-
imp.type_only || !imp.type_only_names.is_empty()
1714-
}
1715-
17161718
/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a
17171719
/// distinct file-level marker edge (`reexports-wildcard`) alongside the
17181720
/// generic `reexports` edge so the query layer can tell a target reached
@@ -1730,9 +1732,14 @@ fn is_wildcard_reexport(imp: &ImportInfo) -> bool {
17301732
/// precise re-export surface instead of the target's full export list
17311733
/// (`reexports`, #1742). `kind` selects which edge kind to emit.
17321734
///
1733-
/// For `kind == "imports-type"`, only specifiers actually marked type-only
1734-
/// (whole-statement or inline per-specifier, #1813) get an edge — a mixed
1735-
/// `import { value, type Foo }` must not credit `value`.
1735+
/// For `kind == "imports-type"`, a specifier gets an edge when either it's
1736+
/// actually marked type-only (whole-statement or inline per-specifier,
1737+
/// #1813 — a mixed `import { value, type Foo }` must not credit `value` on
1738+
/// this basis alone), or the resolved target is a TypeScript
1739+
/// interface/type-alias declaration (`is_type_erased_import_target`) — those
1740+
/// kinds are erased before runtime, so a plain `import { Foo } from 'y'` (no
1741+
/// `type` keyword) is the only consumption signal `codegraph exports` can
1742+
/// observe for them (#1833).
17361743
///
17371744
/// `imp.names` holds the *original* declaration name for export specifiers
17381745
/// (see `extractImportNames` in the JS extractor) even when renamed
@@ -1759,35 +1766,41 @@ fn emit_named_symbol_edges(
17591766
}
17601767
for name in &imp.names {
17611768
let clean_name = strip_star_as_prefix(name);
1762-
if kind == "imports-type"
1763-
&& !imp.type_only
1764-
&& !imp.type_only_names.iter().any(|n| n == clean_name)
1765-
{
1766-
continue;
1767-
}
1769+
let type_only = imp.type_only || imp.type_only_names.iter().any(|n| n == clean_name);
17681770
let barrel_target = if ctx.barrel_set.contains(resolved_path) {
17691771
let mut visited = HashSet::new();
17701772
barrel_resolution::resolve_barrel_export(ctx, resolved_path, clean_name, &mut visited)
17711773
} else {
17721774
None
17731775
};
1774-
let sym_id = barrel_target
1775-
.as_ref()
1776-
.and_then(|resolved| ctx.symbol_node_map.get(&(resolved.name.clone(), resolved.file.clone())))
1777-
.or_else(|| {
1778-
ctx.symbol_node_map
1779-
.get(&(clean_name.to_string(), resolved_path.to_string()))
1780-
});
1781-
if let Some(&id) = sym_id {
1782-
edges.push(ComputedEdge {
1783-
source_id: file_input.file_node_id,
1784-
target_id: id,
1785-
kind: kind.to_string(),
1786-
confidence: 1.0,
1787-
dynamic: 0,
1788-
dynamic_kind: None,
1789-
});
1776+
let (target_name, target_file) = match &barrel_target {
1777+
Some(resolved)
1778+
if ctx
1779+
.symbol_node_map
1780+
.contains_key(&(resolved.name.clone(), resolved.file.clone())) =>
1781+
{
1782+
(resolved.name.clone(), resolved.file.clone())
1783+
}
1784+
_ => (clean_name.to_string(), resolved_path.to_string()),
1785+
};
1786+
let Some((id, sym_kind)) = ctx.symbol_node_map.get(&(target_name, target_file.clone()))
1787+
else {
1788+
continue;
1789+
};
1790+
if kind == "imports-type"
1791+
&& !type_only
1792+
&& !crate::shared::constants::is_type_erased_import_target(sym_kind, &target_file)
1793+
{
1794+
continue;
17901795
}
1796+
edges.push(ComputedEdge {
1797+
source_id: file_input.file_node_id,
1798+
target_id: *id,
1799+
kind: kind.to_string(),
1800+
confidence: 1.0,
1801+
dynamic: 0,
1802+
dynamic_kind: None,
1803+
});
17911804
}
17921805
}
17931806

@@ -1872,7 +1885,10 @@ fn process_single_import(
18721885
dynamic: 0,
18731886
dynamic_kind: None,
18741887
});
1875-
if has_type_only_names(imp) {
1888+
// Always attempted (not just for `import type`/inline-`type` specifiers) —
1889+
// emit_named_symbol_edges also credits plain specifiers that resolve to a
1890+
// TypeScript interface/type-alias declaration (#1833).
1891+
if !imp.reexport {
18761892
emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx);
18771893
}
18781894
if is_named_reexport(imp) {
@@ -1989,6 +2005,7 @@ mod import_edge_tests {
19892005
name: "foo".to_string(),
19902006
file: "src/utils.ts".to_string(),
19912007
node_id: 99,
2008+
kind: "function".to_string(),
19922009
}];
19932010

19942011
let edges = build_import_edges(
@@ -2034,6 +2051,7 @@ mod import_edge_tests {
20342051
name: "foo".to_string(),
20352052
file: "src/utils.ts".to_string(),
20362053
node_id: 99,
2054+
kind: "function".to_string(),
20372055
}];
20382056

20392057
let edges = build_import_edges(
@@ -2079,6 +2097,7 @@ mod import_edge_tests {
20792097
name: "foo".to_string(),
20802098
file: "src/utils.ts".to_string(),
20812099
node_id: 99,
2100+
kind: "function".to_string(),
20822101
}];
20832102

20842103
let edges = build_import_edges(
@@ -2115,9 +2134,19 @@ mod import_edge_tests {
21152134
let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")];
21162135
let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)];
21172136
let symbol_nodes = vec![
2118-
SymbolNodeEntry { name: "foo".to_string(), file: "src/utils.ts".to_string(), node_id: 99 },
2137+
SymbolNodeEntry {
2138+
name: "foo".to_string(),
2139+
file: "src/utils.ts".to_string(),
2140+
node_id: 99,
2141+
kind: "function".to_string(),
2142+
},
21192143
// A decoy under the external alias name must NOT be matched.
2120-
SymbolNodeEntry { name: "bar".to_string(), file: "src/utils.ts".to_string(), node_id: 100 },
2144+
SymbolNodeEntry {
2145+
name: "bar".to_string(),
2146+
file: "src/utils.ts".to_string(),
2147+
node_id: 100,
2148+
kind: "function".to_string(),
2149+
},
21212150
];
21222151

21232152
let edges = build_import_edges(
@@ -2160,8 +2189,18 @@ mod import_edge_tests {
21602189
let resolved = vec![make_resolved("/root/src/app.ts", "./mixed", "src/mixed.ts")];
21612190
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/mixed.ts", 2)];
21622191
let symbol_nodes = vec![
2163-
SymbolNodeEntry { name: "Foo".to_string(), file: "src/mixed.ts".to_string(), node_id: 50 },
2164-
SymbolNodeEntry { name: "value".to_string(), file: "src/mixed.ts".to_string(), node_id: 51 },
2192+
SymbolNodeEntry {
2193+
name: "Foo".to_string(),
2194+
file: "src/mixed.ts".to_string(),
2195+
node_id: 50,
2196+
kind: "function".to_string(),
2197+
},
2198+
SymbolNodeEntry {
2199+
name: "value".to_string(),
2200+
file: "src/mixed.ts".to_string(),
2201+
node_id: 51,
2202+
kind: "function".to_string(),
2203+
},
21652204
];
21662205

21672206
let edges = build_import_edges(
@@ -2179,6 +2218,114 @@ mod import_edge_tests {
21792218
assert_eq!(edges[1].target_id, 50);
21802219
}
21812220

2221+
#[test]
2222+
fn plain_import_of_ts_interface_credits_imports_type_edge() {
2223+
// `import { Foo } from './types'` — no `type` keyword — where `Foo`
2224+
// is a TypeScript interface. Interfaces are erased before runtime, so
2225+
// this plain import is the only observable consumption signal
2226+
// `codegraph exports` can rely on; it must be credited exactly like
2227+
// `import type { Foo }` would be (#1833).
2228+
let files = vec![make_file(
2229+
"src/app.ts",
2230+
1,
2231+
vec![make_import("./types", vec!["Foo"], false, false, false)],
2232+
vec![],
2233+
)];
2234+
let resolved = vec![make_resolved("/root/src/app.ts", "./types", "src/types.ts")];
2235+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/types.ts", 2)];
2236+
let symbol_nodes = vec![SymbolNodeEntry {
2237+
name: "Foo".to_string(),
2238+
file: "src/types.ts".to_string(),
2239+
node_id: 50,
2240+
kind: "interface".to_string(),
2241+
}];
2242+
2243+
let edges = build_import_edges(
2244+
files,
2245+
resolved,
2246+
vec![],
2247+
node_ids,
2248+
vec![],
2249+
"/root".to_string(),
2250+
Some(symbol_nodes),
2251+
);
2252+
assert_eq!(edges.len(), 2);
2253+
assert_eq!(edges[0].kind, "imports");
2254+
assert_eq!(edges[1].kind, "imports-type");
2255+
assert_eq!(edges[1].target_id, 50);
2256+
}
2257+
2258+
#[test]
2259+
fn plain_import_of_ts_value_symbol_gets_no_symbol_level_edge() {
2260+
// `import { helper } from './utils'` where `helper` is a plain
2261+
// function (not an interface/type alias). Consumption credit for a
2262+
// value symbol must still come exclusively from a real `calls` edge
2263+
// — merely importing it must not fabricate one (#1833 must not
2264+
// regress the existing value-import behaviour).
2265+
let files = vec![make_file(
2266+
"src/app.ts",
2267+
1,
2268+
vec![make_import("./utils", vec!["helper"], false, false, false)],
2269+
vec![],
2270+
)];
2271+
let resolved = vec![make_resolved("/root/src/app.ts", "./utils", "src/utils.ts")];
2272+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/utils.ts", 2)];
2273+
let symbol_nodes = vec![SymbolNodeEntry {
2274+
name: "helper".to_string(),
2275+
file: "src/utils.ts".to_string(),
2276+
node_id: 50,
2277+
kind: "function".to_string(),
2278+
}];
2279+
2280+
let edges = build_import_edges(
2281+
files,
2282+
resolved,
2283+
vec![],
2284+
node_ids,
2285+
vec![],
2286+
"/root".to_string(),
2287+
Some(symbol_nodes),
2288+
);
2289+
assert_eq!(edges.len(), 1);
2290+
assert_eq!(edges[0].kind, "imports");
2291+
}
2292+
2293+
#[test]
2294+
fn plain_import_of_non_typescript_interface_gets_no_symbol_level_edge() {
2295+
// A plain import resolving to an 'interface'-kind node in a
2296+
// non-TypeScript file (e.g. a Go `type ... interface {}`) must not be
2297+
// credited by this heuristic — those kinds are runtime-observable in
2298+
// other languages, so crediting on mere import would mask genuinely
2299+
// dead code instead of fixing a false positive (#1833 is scoped to
2300+
// TypeScript's compile-time-only interfaces/type aliases).
2301+
let files = vec![make_file(
2302+
"src/app.ts",
2303+
1,
2304+
vec![make_import("./iface", vec!["Reader"], false, false, false)],
2305+
vec![],
2306+
)];
2307+
let resolved = vec![make_resolved("/root/src/app.ts", "./iface", "src/iface.go")];
2308+
let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/iface.go", 2)];
2309+
let symbol_nodes = vec![SymbolNodeEntry {
2310+
name: "Reader".to_string(),
2311+
file: "src/iface.go".to_string(),
2312+
node_id: 50,
2313+
kind: "interface".to_string(),
2314+
}];
2315+
2316+
let edges = build_import_edges(
2317+
files,
2318+
resolved,
2319+
vec![],
2320+
node_ids,
2321+
vec![],
2322+
"/root".to_string(),
2323+
Some(symbol_nodes),
2324+
);
2325+
assert_eq!(edges.len(), 1);
2326+
assert_eq!(edges[0].kind, "imports");
2327+
}
2328+
21822329
#[test]
21832330
fn dynamic_import_edge() {
21842331
let files = vec![make_file("src/app.ts", 1, vec![

0 commit comments

Comments
 (0)