Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2b6a00d
fix: scope codegraph batch complexity targets to file paths
carlos-alm Jul 5, 2026
b28051a
fix: exclude parameters and interface/type members from dead-role cla…
carlos-alm Jul 5, 2026
17fbcba
fix: credit import-type usages as exports consumers
carlos-alm Jul 5, 2026
26918bb
fix: prevent fn-impact/query crash when -f/--file is passed
carlos-alm Jul 5, 2026
be6432c
fix: correct exported-symbol detection for literal and object-literal…
carlos-alm Jul 5, 2026
4ead840
fix: exclude primitive type keywords from ast --kind string matching
carlos-alm Jul 5, 2026
f6326bf
fix: resolve call edges through renamed import specifiers
carlos-alm Jul 5, 2026
590f560
fix: couple file_hashes updates with edge regeneration in incremental…
carlos-alm Jul 5, 2026
d3ea4a4
fix: compare signature-change diffs using new-file line ranges
carlos-alm Jul 5, 2026
c306812
feat: add environment doctor check for stale native binary and missin…
carlos-alm Jul 5, 2026
8bb5893
fix: eliminate non-deterministic ordering in community detection
carlos-alm Jul 6, 2026
46037b1
fix: sync update-graph.sh hook extension allowlist with EXTENSIONS
carlos-alm Jul 6, 2026
6a84cf9
fix: recompute directory structure metrics for affected directories o…
carlos-alm Jul 6, 2026
8d936d1
refactor: register hardcoded execFileSync/execSync maxBuffer values i…
carlos-alm Jul 6, 2026
11c84be
fix: gate blast-radius check on newly introduced risk, not pre-existi…
carlos-alm Jul 6, 2026
963301a
fix: gate identifier-argument dynamic call edges on callback-acceptin…
carlos-alm Jul 6, 2026
3e8035d
fix: gate Array.from's callback arg by position, not just callee name
carlos-alm Jul 6, 2026
879635e
fix: scope reexportedSymbols to actually-named re-export specifiers
carlos-alm Jul 6, 2026
af86f3a
fix: preserve wildcard export list when a named reexport shares its t…
carlos-alm Jul 6, 2026
e6764b8
fix: resolve merge conflicts with main (docs check acknowledged)
carlos-alm Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 207 additions & 7 deletions crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComputedEdge>,
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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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![
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<EdgeRow>,
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) {
Expand All @@ -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,
});
Expand Down Expand Up @@ -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,
Expand All @@ -433,7 +485,7 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec<Edg
let mut edges = Vec::new();

let file_node_ids = load_file_node_ids(conn);
let needed_symbol_pairs = collect_type_only_lookup_pairs(ctx);
let needed_symbol_pairs = collect_symbol_lookup_pairs(ctx);
let symbol_node_ids = if needed_symbol_pairs.is_empty() {
HashMap::new()
} else {
Expand Down
Loading
Loading