@@ -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) ]
15421545pub 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
15651571impl < ' 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