diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 8d14f7e4d..1c7caa9fc 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -1573,17 +1573,45 @@ fn classify_import_edge_kind(imp: &ImportInfo) -> &'static str { } } -/// For a `type` import targeting a barrel or resolved file, emit one -/// symbol-level `imports-type` edge per named symbol so the target symbols -/// receive fan-in credit and aren't misclassified as dead code. -fn emit_type_only_symbol_edges( +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &ImportInfo) -> bool { + imp.reexport && !imp.wildcard_reexport +} + +/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a +/// distinct file-level marker edge (`reexports-wildcard`) alongside the +/// generic `reexports` edge so the query layer can tell a target reached +/// only by named specifiers apart from one that's also reached by a +/// wildcard — even when a *different* statement in the same file names +/// specific symbols from that exact target (#1849 review). +fn is_wildcard_reexport(imp: &ImportInfo) -> bool { + imp.reexport && imp.wildcard_reexport +} + +/// For a `type` import or a named re-export targeting a barrel or resolved +/// file, emit one symbol-level edge per named symbol so the target symbols +/// receive fan-in credit and aren't misclassified as dead code +/// (`imports-type`, #1724), or so `codegraph exports` can report the +/// precise re-export surface instead of the target's full export list +/// (`reexports`, #1742). `kind` selects which edge kind to emit. +/// +/// `imp.names` holds the *original* declaration name for export specifiers +/// (see `extractImportNames` in the JS extractor) even when renamed +/// externally, so this naturally resolves `export { X as Z }` against `X`'s +/// own node — the emitted edge (and downstream `reexportedSymbols` entry) +/// is reported under the symbol's own declared name, not the barrel alias. +fn emit_named_symbol_edges( edges: &mut Vec, file_input: &ImportEdgeFileInput, imp: &ImportInfo, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, ) { - if !imp.type_only || ctx.symbol_node_map.is_empty() { + if ctx.symbol_node_map.is_empty() { return; } for name in &imp.names { @@ -1602,7 +1630,7 @@ fn emit_type_only_symbol_edges( edges.push(ComputedEdge { source_id: file_input.file_node_id, target_id: id, - kind: "imports-type".to_string(), + kind: kind.to_string(), confidence: 1.0, dynamic: 0, dynamic_kind: None, @@ -1692,7 +1720,21 @@ fn process_single_import( dynamic: 0, dynamic_kind: None, }); - emit_type_only_symbol_edges(edges, file_input, imp, resolved_path, ctx); + if imp.type_only { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx); + } + if is_named_reexport(imp) { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "reexports", ctx); + } else if is_wildcard_reexport(imp) { + edges.push(ComputedEdge { + source_id: file_input.file_node_id, + target_id: target_node_id, + kind: "reexports-wildcard".to_string(), + confidence: 1.0, + dynamic: 0, + dynamic_kind: None, + }); + } emit_barrel_through_edges(edges, file_input, imp, resolved_path, edge_kind, ctx); } @@ -1761,6 +1803,164 @@ mod import_edge_tests { assert_eq!(edges[0].kind, "reexports"); } + #[test] + fn named_reexport_emits_symbol_level_edge() { + // `export { foo } from './utils'` in src/index.ts, where `foo` is a + // specific symbol defined in src/utils.ts. Alongside the file-level + // `reexports` edge, a symbol-level `reexports` edge should point at + // `foo`'s own node — not at every export of utils.ts (#1742). + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + // File-level edge: index.ts -> utils.ts file node. + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + // Symbol-level edge: index.ts -> foo's own node. + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + + #[test] + fn wildcard_reexport_emits_no_symbol_level_edge() { + // `export * from './utils'` carries no specific names, so no + // symbol-level edge is emitted. It does get the dedicated + // `reexports-wildcard` file-level marker (alongside the generic + // `reexports` edge) so the query layer can always apply full-export + // semantics for genuine wildcards, even when a *different* statement + // to the same target also names specific symbols (#1849 review). + let files = vec![make_file("src/index.ts", 1, vec![ + ImportInfo { + source: "./utils".to_string(), + names: vec![], + reexport: true, + type_only: false, + dynamic_import: false, + wildcard_reexport: true, + }, + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + assert_eq!(edges[1].kind, "reexports-wildcard"); + assert_eq!(edges[1].target_id, 2); + } + + #[test] + fn named_and_wildcard_reexport_of_same_target_both_marked() { + // `export { foo } from './utils'` AND `export * from './utils'` in + // the same file, both targeting utils.ts. The wildcard's full-export + // semantics must stay independently signalled (via the dedicated + // `reexports-wildcard` marker) rather than being suppressed by the + // named specifier's symbol-level edge — otherwise the query layer + // would report only `foo` and silently drop every other export of + // utils.ts that the wildcard was meant to surface (#1849 review). + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ImportInfo { + source: "./utils".to_string(), + names: vec![], + reexport: true, + type_only: false, + dynamic_import: false, + wildcard_reexport: true, + }, + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 4); + // Named statement: file-level `reexports` + symbol-level `reexports` to foo. + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + // Wildcard statement: file-level `reexports` + the `reexports-wildcard` marker. + assert_eq!(edges[2].kind, "reexports"); + assert_eq!(edges[2].target_id, 2); + assert_eq!(edges[3].kind, "reexports-wildcard"); + assert_eq!(edges[3].target_id, 2); + } + + #[test] + fn renamed_reexport_resolves_original_name() { + // `export { foo as bar } from './utils'` — the JS extractor stores + // the *original* declaration name ("foo") in `names`, not the + // external alias ("bar"). The symbol-level edge must resolve + // against foo's own node. + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![ + SymbolNodeEntry { name: "foo".to_string(), file: "src/utils.ts".to_string(), node_id: 99 }, + // A decoy under the external alias name must NOT be matched. + SymbolNodeEntry { name: "bar".to_string(), file: "src/utils.ts".to_string(), node_id: 100 }, + ]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + #[test] fn type_only_edge() { let files = vec![make_file("src/app.ts", 1, vec![ diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index 9f1f97d2b..c80b6ae9d 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -258,17 +258,39 @@ fn load_symbol_node_ids( map } -/// Walk type-only imports in `ctx.file_symbols` and return the distinct -/// `(name, file)` pairs that `build_import_edges` will need to look up. -/// Resolves barrel files the same way the edge-building loop does so the -/// pre-computed set matches the actual lookup keys. -fn collect_type_only_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &crate::types::Import) -> bool { + imp.reexport.unwrap_or(false) && !imp.wildcard_reexport.unwrap_or(false) +} + +/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a +/// distinct file-level marker edge (`reexports-wildcard`) alongside the +/// generic `reexports` edge so the query layer can tell a target reached +/// only by named specifiers apart from one that's also reached by a +/// wildcard — even when a *different* statement in the same file names +/// specific symbols from that exact target (#1849 review). Mirrors +/// `is_wildcard_reexport` in build_edges.rs (FFI fallback path). +fn is_wildcard_reexport(imp: &crate::types::Import) -> bool { + imp.reexport.unwrap_or(false) && imp.wildcard_reexport.unwrap_or(false) +} + +/// Walk type-only imports and named re-exports in `ctx.file_symbols` and +/// return the distinct `(name, file)` pairs that `build_import_edges` will +/// need to look up. Resolves barrel files the same way the edge-building +/// loop does so the pre-computed set matches the actual lookup keys. +/// Shared by symbol-level `imports-type` (#1724) and `reexports` (#1742) +/// edges — both name specific symbols requiring a (name, file) → node-id +/// lookup. +fn collect_symbol_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { let mut pairs = HashSet::new(); for (rel_path, symbols) in &ctx.file_symbols { let abs_file = Path::new(&ctx.root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or(""); for imp in &symbols.imports { - if !imp.type_only.unwrap_or(false) { + if !imp.type_only.unwrap_or(false) && !is_named_reexport(imp) { continue; } let resolved_path = ctx.get_resolved(abs_str, &imp.source); @@ -312,19 +334,20 @@ fn classify_import_kind(imp: &crate::types::Import) -> &'static str { } } -/// For a `type` import, emit one symbol-level `imports-type` edge per name -/// so the target symbols receive fan-in credit and aren't classified dead. -fn emit_type_only_symbol_rows( +/// For a `type` import or a named re-export, emit one symbol-level edge per +/// name so the target symbols receive fan-in credit and aren't classified +/// dead (`imports-type`, #1724), or so `codegraph exports` can report the +/// precise re-export surface instead of the target's full export list +/// (`reexports`, #1742). `kind` selects which edge kind to emit. +fn emit_named_symbol_rows( edges: &mut Vec, file_node_id: i64, imp: &crate::types::Import, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, symbol_node_ids: &HashMap<(String, String), i64>, ) { - if !imp.type_only.unwrap_or(false) { - return; - } for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.to_string(); if ctx.is_barrel_file(resolved_path) { @@ -338,7 +361,7 @@ fn emit_type_only_symbol_rows( edges.push(EdgeRow { source_id: file_node_id, target_id: sym_id, - kind: "imports-type".to_string(), + kind: kind.to_string(), confidence: 1.0, dynamic: 0, }); @@ -417,7 +440,36 @@ fn emit_edges_for_import( confidence: 1.0, dynamic: 0, }); - emit_type_only_symbol_rows(edges, file_node_id, imp, &resolved_path, ctx, symbol_node_ids); + if imp.type_only.unwrap_or(false) { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "imports-type", + ctx, + symbol_node_ids, + ); + } + if is_named_reexport(imp) { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "reexports", + ctx, + symbol_node_ids, + ); + } else if is_wildcard_reexport(imp) { + edges.push(EdgeRow { + source_id: file_node_id, + target_id, + kind: "reexports-wildcard".to_string(), + confidence: 1.0, + dynamic: 0, + }); + } emit_barrel_through_rows( edges, file_node_id, @@ -433,7 +485,7 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec = new WeakMap(); const _reexportsFromStmtCache: StmtCache<{ file: string }> = new WeakMap(); const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap(); +const _reexportSymbolsStmtCache: StmtCache = new WeakMap(); +const _wildcardReexportTargetsStmtCache: StmtCache<{ file: string }> = new WeakMap(); export function exportsData( file: string, @@ -94,19 +96,49 @@ export function exportsData( }); } -/** Collect symbols re-exported through barrel files. */ +/** + * Collect symbols re-exported through barrel files. + * + * `export { X } from 'Y'` records a symbol-level `reexports` edge straight to + * `X`'s own node (emitted by `emitNamedSymbolEdges` in build-edges.ts / + * incremental.ts, and the mirrored Rust extractors) — so for any target file + * reached with at least one such edge, only those specifically-named symbols + * are reported *unless a genuine wildcard also targets that same file*. + * `export * from 'Y'` carries no specific names, so it's marked instead with + * a dedicated `reexports-wildcard` file-level edge — a wildcard's full-export + * semantics must not be suppressed just because a *different* statement in + * the same barrel also names specific symbols from that exact target (#1742, + * #1849 review). Any reexport whose specific name couldn't be resolved to a + * node (and so carries neither a named nor a wildcard marker) also falls + * back to the target's full export list, preserving the pre-fix behaviour + * for that unresolved case. + */ function collectReexportedSymbols( db: BetterSqlite3Database, fileNodeId: number, reexportsToStmt: ReturnType, + reexportSymbolsStmt: ReturnType, + wildcardReexportTargetsStmt: ReturnType, getFileLines: (file: string) => string[] | null, buildSymbolResult: (s: NodeRow, fileLines: string[] | null) => any, ) { const reexportTargets = reexportsToStmt.all(fileNodeId) as Array<{ file: string }>; + const namedSymbols = reexportSymbolsStmt.all(fileNodeId) as NodeRow[]; + const namedByFile = new Map(); + for (const s of namedSymbols) { + if (!namedByFile.has(s.file)) namedByFile.set(s.file, []); + namedByFile.get(s.file)!.push(s); + } + const wildcardFiles = new Set( + (wildcardReexportTargetsStmt.all(fileNodeId) as Array<{ file: string }>).map((r) => r.file), + ); + const reexportedSymbols: Array & { originFile: string }> = []; for (const reexTarget of reexportTargets) { - const targetExported = findExportedNodesByFile(db, reexTarget.file); + const targetExported = wildcardFiles.has(reexTarget.file) + ? findExportedNodesByFile(db, reexTarget.file) + : (namedByFile.get(reexTarget.file) ?? findExportedNodesByFile(db, reexTarget.file)); for (const s of targetExported) { reexportedSymbols.push({ ...buildSymbolResult(s, getFileLines(reexTarget.file)), @@ -159,6 +191,27 @@ function exportsFileImpl( `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id WHERE e.source_id = ? AND e.kind = 'reexports'`, ); + // Symbol-level `reexports` edges — the specific symbols named in + // `export { X } from 'Y'` clauses (target is the symbol node itself, not + // a file node). Distinct from reexportsToStmt above, which only proves a + // reexport *relationship* exists with a target file (#1742). + const reexportSymbolsStmt = cachedStmt( + _reexportSymbolsStmtCache, + db, + `SELECT DISTINCT n.* FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports' AND n.kind != 'file' + ORDER BY n.line`, + ); + // Target files reached by a genuine `export * from 'Y'` wildcard (target + // is the file node itself). A wildcard's full-export semantics must apply + // even when a *different* statement in the same barrel also names + // specific symbols from that exact target file (#1849 review). + const wildcardReexportTargetsStmt = cachedStmt( + _wildcardReexportTargetsStmtCache, + db, + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports-wildcard'`, + ); return fileNodes.map((fn) => { const symbols = findNodesByFile(db, fn.file) as NodeRow[]; @@ -200,6 +253,8 @@ function exportsFileImpl( db, fn.id, reexportsToStmt, + reexportSymbolsStmt, + wildcardReexportTargetsStmt, getFileLines, buildSymbolResult, ); diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index c16f0edfc..9dbff8bef 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -356,13 +356,21 @@ function resolveBarrelImportEdges( return edgesAdded; } -/** Emit symbol-level `imports-type` edges for a single `import type` statement. */ -function emitTypeOnlySymbolEdges( +/** + * Emit one symbol-level edge per named specifier — shared by `import type` + * statements (`imports-type`, #1724) and named re-exports (`reexports`, + * #1742). Wildcard re-exports (`export * from 'Y'`) carry no specific names, + * so the loop is a no-op for them; the query layer falls back to the + * target's full export list for anything reached only by the file-level + * edge. Mirrors `emitNamedSymbolEdges` in build-edges.ts (full-build path). + */ +function emitNamedSymbolEdges( db: BetterSqlite3Database | null, stmts: IncrementalStmts, imp: ExtractorOutput['imports'][number], resolvedPath: string, fileNodeId: number, + edgeKind: 'imports-type' | 'reexports', ): number { let edgesAdded = 0; for (const { original } of importNamePairs(imp)) { @@ -376,7 +384,7 @@ function emitTypeOnlySymbolEdges( file: string; }>; if (candidates.length === 0) continue; - stmts.insertEdge.run(fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0); + stmts.insertEdge.run(fileNodeId, candidates[0]!.id, edgeKind, 1.0, 0); edgesAdded++; } return edgesAdded; @@ -410,7 +418,18 @@ function emitEdgesForImport( let edgesAdded = 1; if (imp.typeOnly) { - edgesAdded += emitTypeOnlySymbolEdges(db, stmts, imp, resolvedPath, fileNodeId); + edgesAdded += emitNamedSymbolEdges(db, stmts, imp, resolvedPath, fileNodeId, 'imports-type'); + } + if (imp.reexport && !imp.wildcardReexport) { + edgesAdded += emitNamedSymbolEdges(db, stmts, imp, resolvedPath, fileNodeId, 'reexports'); + } else if (imp.reexport && imp.wildcardReexport) { + // Mirrors build-edges.ts (full-build path): a genuine wildcard must stay + // distinguishable from a named reexport even when a *different* + // statement in this file names specific symbols from the same target + // (#1849 review). See `collectReexportedSymbols` in + // domain/analysis/exports.ts. + stmts.insertEdge.run(fileNodeId, targetRow.id, 'reexports-wildcard', 1.0, 0); + edgesAdded++; } if (!imp.reexport && db) { edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp); diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 96f03b9ca..e3784465d 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -150,15 +150,36 @@ function importEdgeKind(imp: Import): string { } /** - * For a `import type` statement, emit symbol-level `imports-type` edges so - * the target symbols get fan-in credit and aren't classified as dead code. + * Emit one symbol-level edge per named specifier in `imp`, pointing at the + * specific target symbol (resolved through barrel chains when needed). + * + * Shared by two statement shapes that name specific symbols without a plain + * file-level `imports`/`reexports` edge fully capturing the relationship: + * - `import type { X } from 'Y'` → kind `imports-type`, so the target gets + * fan-in credit and isn't classified as dead code (#1724). + * - `export { X } from 'Y'` / `export { X as Z } from 'Y'` → kind + * `reexports`, so `codegraph exports` can report exactly which symbols + * are re-exported instead of conflating the file-level barrel edge with + * "every export of Y" (#1742). + * + * `imp.names` always carries the *original* declaration name for export + * specifiers, even when renamed externally (see `extractImportNames`), so + * the emitted edge — and the resulting `reexportedSymbols` entry — reports + * the symbol under its own declared name, not the barrel's external alias. + * + * Wildcard re-exports (`export * from 'Y'`) carry no specific names + * (`imp.names` is empty), so the loop below is a no-op for them — a file + * only gets a precise symbol-level edge when a name is actually spelled + * out; the query layer falls back to the target's full export list for + * anything reached only by the file-level edge (genuine wildcard semantics). */ -function emitTypeOnlySymbolEdges( +function emitNamedSymbolEdges( ctx: PipelineContext, imp: Import, resolvedPath: string, fileNodeId: number, allEdgeRows: EdgeRowTuple[], + edgeKind: 'imports-type' | 'reexports', ): void { if (!ctx.nodesByNameAndFile) return; for (const { original } of importNamePairs(imp)) { @@ -169,14 +190,14 @@ function emitTypeOnlySymbolEdges( } const candidates = ctx.nodesByNameAndFile.get(`${original}|${targetFile}`); if (candidates && candidates.length > 0) { - allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0, null, null]); + allEdgeRows.push([fileNodeId, candidates[0]!.id, edgeKind, 1.0, 0, null, null]); } } } /** * Process a single import statement and emit all resulting edges (file→file, - * type-only symbol-level, and barrel re-export targets). + * named-symbol-level, and barrel re-export targets). */ function emitEdgesForImport( ctx: PipelineContext, @@ -194,7 +215,18 @@ function emitEdgesForImport( allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0, null, null]); if (imp.typeOnly) { - emitTypeOnlySymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows); + emitNamedSymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows, 'imports-type'); + } + if (imp.reexport && !imp.wildcardReexport) { + emitNamedSymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows, 'reexports'); + } else if (imp.reexport && imp.wildcardReexport) { + // A genuine wildcard needs to be distinguishable from a named reexport + // even when a *different* statement in the same file names specific + // symbols from this exact target — otherwise the query layer can't tell + // "only these symbols are re-exported" apart from "everything is + // re-exported, and these happen to also be individually named" (#1849 + // review). See `collectReexportedSymbols` in domain/analysis/exports.ts. + allEdgeRows.push([fileNodeId, targetRow.id, 'reexports-wildcard', 1.0, 0, null, null]); } if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) { diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts new file mode 100644 index 000000000..f64e272d6 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts @@ -0,0 +1,2 @@ +// Pure wildcard barrel — genuinely re-exports everything from helpers.ts. +export * from './helpers.js'; diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts new file mode 100644 index 000000000..0cee200ed --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts @@ -0,0 +1,16 @@ +// Combined specifier list: one plain re-export (loadPlotConfig) and one +// renamed re-export (buildLayoutOptions -> buildOptions) in a single +// statement, alongside a type-only import that must NOT be treated as a +// reexport. Two own definitions keep this file from being (mis)classified +// as barrel-only (own-def count > reexport-statement count) — an unrelated, +// separately-tracked engine divergence (#1848) that would otherwise drop +// the type-only import's edges on native full builds. +export { buildLayoutOptions as buildOptions, loadPlotConfig } from './viewer.js'; + +import type { PlotConfig } from './viewer.js'; + +export function useConfig(): PlotConfig { + return { width: 1 }; +} + +export function noop(): void {} diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts new file mode 100644 index 000000000..d0c57f511 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts @@ -0,0 +1,7 @@ +export function formatDate(d: Date): string { + return d.toISOString(); +} + +export function formatNumber(n: number): string { + return n.toFixed(2); +} diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/mixed-barrel.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/mixed-barrel.ts new file mode 100644 index 000000000..b4bbf2a90 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/mixed-barrel.ts @@ -0,0 +1,7 @@ +// Named + wildcard re-export of the SAME target file (helpers.ts). A +// wildcard's full-export semantics must not be suppressed just because a +// *different* statement in this file also names a specific symbol from that +// exact target (#1849 review). + +export * from './helpers.js'; +export { formatDate } from './helpers.js'; diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts new file mode 100644 index 000000000..9a577ccf2 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts @@ -0,0 +1,15 @@ +export function loadPlotConfig(): { width: number } { + return { width: 100 }; +} + +export function buildLayoutOptions(): { margin: number } { + return { margin: 10 }; +} + +export function escapeHtml(input: string): string { + return input.replace(/ { expect(unused.consumers).toEqual([]); }); }); + +// ─── reexportedSymbols scoped to actually-named specifiers (#1742) ─────── +// +// Regression coverage for: a single named re-export (`export { X } from 'Y'`) +// was treated as if the file transitively re-exported EVERY export of `Y`, +// even symbols never mentioned in any reexport clause (and even symbols only +// imported as a type, never re-exported). The builder now emits a +// symbol-level `reexports` edge straight to the specifically-named symbol +// (mirroring the existing `imports-type` symbol-level edge from #1724) — +// see `emitNamedSymbolEdges` in build-edges.ts / incremental.ts and the +// mirrored Rust extractors. `collectReexportedSymbols` only falls back to a +// target's full export list when no symbol-level edge was recorded for it +// (i.e. a genuine `export * from 'Y'` wildcard, which really does re-export +// everything). + +describe('exportsData — reexportedSymbols scoped to named specifiers (#1742)', () => { + let tmpDir3: string, dbPath3: string; + + beforeAll(() => { + tmpDir3 = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-reexport-scope-')); + fs.mkdirSync(path.join(tmpDir3, '.codegraph')); + dbPath3 = path.join(tmpDir3, '.codegraph', 'graph.db'); + + const db = new Database(dbPath3); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + const fViewer = insertNode(db, 'viewer.ts', 'file', 'viewer.ts', 0); + const fHelpers = insertNode(db, 'helpers.ts', 'file', 'helpers.ts', 0); + const fBarrel = insertNode(db, 'enrichment.ts', 'file', 'enrichment.ts', 0); + + // viewer.ts exports four symbols; only two are ever re-exported by + // enrichment.ts, one of them under a renamed external alias. + const loadPlotConfig = insertNode(db, 'loadPlotConfig', 'function', 'viewer.ts', 1); + const buildLayoutOptions = insertNode(db, 'buildLayoutOptions', 'function', 'viewer.ts', 10); + const escapeHtml = insertNode(db, 'escapeHtml', 'function', 'viewer.ts', 20); + const plotConfig = insertNode(db, 'PlotConfig', 'interface', 'viewer.ts', 30); + + // helpers.ts is reached only via a wildcard re-export (`export * from`) — + // no symbol-level edges are ever recorded for it. + const formatDate = insertNode(db, 'formatDate', 'function', 'helpers.ts', 1); + const formatNumber = insertNode(db, 'formatNumber', 'function', 'helpers.ts', 10); + + const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); + for (const id of [ + loadPlotConfig, + buildLayoutOptions, + escapeHtml, + plotConfig, + formatDate, + formatNumber, + ]) { + markExported.run(id); + } + + // enrichment.ts: + // export { loadPlotConfig } from './viewer'; (named, no rename) + // export { buildLayoutOptions as buildOptions } from './viewer'; (named, renamed) + // import type { PlotConfig } from './viewer'; (type-only, NOT a reexport) + // export * from './helpers'; (wildcard) + // + // File-level `reexports` edges (barrel-relationship proof, one per target): + insertEdge(db, fBarrel, fViewer, 'reexports'); + insertEdge(db, fBarrel, fHelpers, 'reexports'); + // Symbol-level `reexports` edges (the precise named specifiers). The + // rename target still points at buildLayoutOptions's own node — `names` + // always carries the pre-rename declaration name (see + // `extractImportNames` / `does not apply rename tracking to + // export_specifier` in tests/parsers/javascript.test.ts). + insertEdge(db, fBarrel, loadPlotConfig, 'reexports'); + insertEdge(db, fBarrel, buildLayoutOptions, 'reexports'); + // Type-only import — must never contribute to reexportedSymbols. + insertEdge(db, fBarrel, plotConfig, 'imports-type'); + + db.close(); + }); + + afterAll(() => { + if (tmpDir3) fs.rmSync(tmpDir3, { recursive: true, force: true }); + }); + + test('only the specifically-named symbols are reported, not every export of the target file', () => { + const data = exportsData('enrichment.ts', dbPath3); + const fromViewer = data.reexportedSymbols.filter((s) => s.originFile === 'viewer.ts'); + const names = fromViewer.map((s) => s.name).sort(); + expect(names).toEqual(['buildLayoutOptions', 'loadPlotConfig']); + // escapeHtml is exported by viewer.ts but never re-exported by + // enrichment.ts — it must not leak in. + expect(names).not.toContain('escapeHtml'); + }); + + test('a symbol imported as a type (not re-exported) is excluded', () => { + const data = exportsData('enrichment.ts', dbPath3); + const names = data.reexportedSymbols.map((s) => s.name); + expect(names).not.toContain('PlotConfig'); + }); + + test('a renamed re-export (`export { X as Y } from ...`) resolves to X, not the external alias', () => { + const data = exportsData('enrichment.ts', dbPath3); + const renamed = data.reexportedSymbols.find( + (s) => s.originFile === 'viewer.ts' && s.name === 'buildLayoutOptions', + ); + expect(renamed).toBeDefined(); + expect(renamed.kind).toBe('function'); + // The external alias name is never used as the reported name. + expect(data.reexportedSymbols.some((s) => s.name === 'buildOptions')).toBe(false); + }); + + test('a wildcard re-export (`export * from ...`) still reports every export of its target, distinctly from the named case', () => { + const data = exportsData('enrichment.ts', dbPath3); + const fromHelpers = data.reexportedSymbols.filter((s) => s.originFile === 'helpers.ts'); + const names = fromHelpers.map((s) => s.name).sort(); + expect(names).toEqual(['formatDate', 'formatNumber']); + }); + + test('total reexported count reflects only the correctly-scoped symbols', () => { + const data = exportsData('enrichment.ts', dbPath3); + // 2 named from viewer.ts (loadPlotConfig, buildLayoutOptions) + 2 wildcard + // from helpers.ts (formatDate, formatNumber) = 4. Not 6 (which would + // include the stray escapeHtml/PlotConfig leak from the pre-fix bug). + expect(data.reexportedSymbols.length).toBe(4); + expect(data.totalReexported).toBe(4); + }); +}); + +// ─── Named + wildcard reexport of the SAME target file (#1849 review) ──── +// +// Greptile flagged that `collectReexportedSymbols`'s `namedByFile.get(file) +// ?? findExportedNodesByFile(...)` selection is winner-takes-all per target +// file: if a named reexport edge exists for a file, the wildcard fallback +// was unconditionally suppressed — even when a *different* statement in the +// same barrel also does `export * from` that exact target. The builder now +// emits a dedicated `reexports-wildcard` file-level marker edge whenever a +// wildcard statement exists, and the query layer always prefers the full +// export list for any target carrying that marker, regardless of whether +// named symbol-level edges also exist for it. + +describe('exportsData — named + wildcard reexport of the same target file (#1849 review)', () => { + let tmpDir4: string, dbPath4: string; + + beforeAll(() => { + tmpDir4 = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-reexport-mixed-')); + fs.mkdirSync(path.join(tmpDir4, '.codegraph')); + dbPath4 = path.join(tmpDir4, '.codegraph', 'graph.db'); + + const db = new Database(dbPath4); + db.pragma('journal_mode = WAL'); + initSchema(db); + + const fUtils = insertNode(db, 'utils.ts', 'file', 'utils.ts', 0); + const fBarrel = insertNode(db, 'mixed-barrel.ts', 'file', 'mixed-barrel.ts', 0); + + const foo = insertNode(db, 'foo', 'function', 'utils.ts', 1); + const bar = insertNode(db, 'bar', 'function', 'utils.ts', 10); + + const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); + markExported.run(foo); + markExported.run(bar); + + // mixed-barrel.ts: + // export { foo } from './utils'; (named) + // export * from './utils'; (wildcard, same target) + insertEdge(db, fBarrel, fUtils, 'reexports'); + insertEdge(db, fBarrel, foo, 'reexports'); + insertEdge(db, fBarrel, fUtils, 'reexports-wildcard'); + + db.close(); + }); + + afterAll(() => { + if (tmpDir4) fs.rmSync(tmpDir4, { recursive: true, force: true }); + }); + + test('reports every export of the target, not just the named specifier', () => { + const data = exportsData('mixed-barrel.ts', dbPath4); + const names = data.reexportedSymbols.map((s) => s.name).sort(); + // Both foo (named) and bar (only reachable via the wildcard) must be + // present — the pre-fix bug would report only ['foo']. + expect(names).toEqual(['bar', 'foo']); + expect(data.totalReexported).toBe(2); + }); +}); diff --git a/tests/integration/issue-1742-reexport-symbol-scope.test.ts b/tests/integration/issue-1742-reexport-symbol-scope.test.ts new file mode 100644 index 000000000..c1c502115 --- /dev/null +++ b/tests/integration/issue-1742-reexport-symbol-scope.test.ts @@ -0,0 +1,176 @@ +/** + * Regression for #1742: `codegraph exports ` treated a single named + * re-export (`export { X } from 'Y'`) as if the file transitively + * re-exported EVERY export of `Y`, even symbols never mentioned in any + * reexport clause (and even symbols only ever imported as a type). + * + * Fixture: + * viewer.ts — defines loadPlotConfig, buildLayoutOptions, escapeHtml + * (function) and PlotConfig (interface) + * helpers.ts — defines formatDate, formatNumber + * enrichment.ts — `export { loadPlotConfig, buildLayoutOptions as + * buildOptions } from './viewer.js'` (named, one plain + + * one renamed specifier in a single statement) plus + * `import type { PlotConfig } from './viewer.js'` + * (type-only — NOT a reexport) + * all-helpers.ts — `export * from './helpers.js'` (pure wildcard barrel) + * mixed-barrel.ts — `export { formatDate } from './helpers.js'` (named) + * PLUS `export * from './helpers.js'` (wildcard, same + * target) — the mixed case flagged in the #1849 review + * + * Before the fix, `reexportedSymbols` for enrichment.ts dumped all four of + * viewer.ts's exports (including escapeHtml and PlotConfig, neither of + * which is re-exported) merely because a file-level `reexports` edge to + * viewer.ts existed. It should report exactly loadPlotConfig and + * buildLayoutOptions from viewer.ts. all-helpers.ts's wildcard re-export + * should keep reporting every export of helpers.ts — genuinely different + * semantics from a named specifier, handled distinctly. mixed-barrel.ts + * additionally covers a target file reached by BOTH a named and a wildcard + * reexport in the same barrel — the wildcard's full-export semantics must + * win, not be silently dropped because a named symbol-level edge also + * exists for that target. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { exportsData } from '../../src/domain/queries.js'; +import type { EngineMode } from '../../src/types.js'; + +const FIXTURE_DIR = path.join( + import.meta.dirname, + '..', + 'fixtures', + 'issue-1742-reexport-symbol-scope', +); + +interface EdgeRow { + source_file: string; + target_file: string; + target_name: string; + target_kind: string; + kind: string; +} + +function readReexportEdges(dbPath: string): EdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.file AS source_file, n2.file AS target_file, + n2.name AS target_name, n2.kind AS target_kind, e.kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('reexports', 'reexports-wildcard') + ORDER BY n1.file, n2.file, n2.name`, + ) + .all() as EdgeRow[]; + } finally { + db.close(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)('#1742 reexportedSymbols scoping (%s)', (engine) => { + let tmpDir: string; + let dbPath: string; + let reexportEdges: EdgeRow[]; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-1742-${engine}-`)); + fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true }); + + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine }); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + reexportEdges = readReexportEdges(dbPath); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits a symbol-level reexports edge straight to loadPlotConfig', () => { + const edge = reexportEdges.find( + (e) => e.source_file === 'enrichment.ts' && e.target_name === 'loadPlotConfig', + ); + expect( + edge, + `Expected a symbol-level reexports edge to loadPlotConfig.\nActual reexports edges:\n${JSON.stringify(reexportEdges, null, 2)}`, + ).toBeDefined(); + expect(edge!.target_file).toBe('viewer.ts'); + expect(edge!.target_kind).not.toBe('file'); + }); + + it('emits a symbol-level reexports edge to buildLayoutOptions (original name, not the external alias)', () => { + const edge = reexportEdges.find( + (e) => e.source_file === 'enrichment.ts' && e.target_name === 'buildLayoutOptions', + ); + expect(edge).toBeDefined(); + expect(edge!.target_file).toBe('viewer.ts'); + expect(reexportEdges.some((e) => e.target_name === 'buildOptions')).toBe(false); + }); + + it('does NOT emit a symbol-level reexports edge to escapeHtml or PlotConfig', () => { + expect(reexportEdges.some((e) => e.target_name === 'escapeHtml')).toBe(false); + expect(reexportEdges.some((e) => e.target_name === 'PlotConfig')).toBe(false); + }); + + it('does NOT emit any symbol-level reexports edge for the wildcard barrel', () => { + const fromAllHelpers = reexportEdges.filter((e) => e.source_file === 'all-helpers.ts'); + // Only the file-level edge (target_kind === 'file') should exist — no + // specific names are ever spelled out in `export * from './helpers.js'`. + expect(fromAllHelpers.every((e) => e.target_kind === 'file')).toBe(true); + expect(fromAllHelpers.length).toBeGreaterThan(0); + }); + + it('exportsData reports only the specifically-named symbols from viewer.ts', () => { + const data = exportsData('enrichment.ts', dbPath); + const fromViewer = data.reexportedSymbols + .filter((s: { originFile: string }) => s.originFile === 'viewer.ts') + .map((s: { name: string }) => s.name) + .sort(); + expect(fromViewer).toEqual(['buildLayoutOptions', 'loadPlotConfig']); + }); + + it('exportsData totalReexported for enrichment.ts is exactly 2 — not the 4 that the pre-fix leak would report', () => { + const data = exportsData('enrichment.ts', dbPath); + expect(data.reexportedSymbols.length).toBe(2); + expect(data.totalReexported).toBe(2); + }); + + it('exportsData still reports the full wildcard re-export list for the pure-barrel file', () => { + const data = exportsData('all-helpers.ts', dbPath); + const names = data.reexportedSymbols.map((s: { name: string }) => s.name).sort(); + expect(names).toEqual(['formatDate', 'formatNumber']); + expect(data.totalReexported).toBe(2); + }); + + it('emits a reexports-wildcard marker edge for mixed-barrel.ts, alongside the named symbol edge', () => { + const wildcardEdge = reexportEdges.find( + (e) => + e.source_file === 'mixed-barrel.ts' && + e.target_file === 'helpers.ts' && + e.kind === 'reexports-wildcard', + ); + expect( + wildcardEdge, + `Expected a reexports-wildcard marker edge from mixed-barrel.ts to helpers.ts.\nActual reexports edges:\n${JSON.stringify(reexportEdges, null, 2)}`, + ).toBeDefined(); + }); + + it('exportsData reports every export of helpers.ts for mixed-barrel.ts, not just the named specifier', () => { + // mixed-barrel.ts does BOTH `export { formatDate } from './helpers.js'` + // (named) AND `export * from './helpers.js'` (wildcard) — the wildcard's + // full-export semantics must win, not be suppressed by the named edge + // (#1849 review). + const data = exportsData('mixed-barrel.ts', dbPath); + const names = data.reexportedSymbols.map((s: { name: string }) => s.name).sort(); + expect(names).toEqual(['formatDate', 'formatNumber']); + expect(data.totalReexported).toBe(2); + }); +});