@@ -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) ]
15501553pub 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
15731579impl < ' 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