Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 120 additions & 2 deletions crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,18 @@ struct EdgeContext<'a> {
nodes_by_file: HashMap<&'a str, Vec<&'a NodeInfo>>,
builtin_set: HashSet<&'a str>,
receiver_kinds: HashSet<&'a str>,
/// Property/method names ever invoked via member-call syntax
/// (`x.name(...)`) across every file in this build pass — see
/// `collect_invoked_property_names` for the #1895 liveness rationale.
invoked_property_names: HashSet<&'a str>,
}

impl<'a> EdgeContext<'a> {
fn new(all_nodes: &'a [NodeInfo], builtin_receivers: &'a [String]) -> Self {
fn new(
all_nodes: &'a [NodeInfo],
builtin_receivers: &'a [String],
files: &'a [FileEdgeInput],
) -> Self {
let mut nodes_by_name: HashMap<&str, Vec<&NodeInfo>> = HashMap::new();
let mut nodes_by_name_and_file: HashMap<(&str, &str), Vec<&NodeInfo>> = HashMap::new();
let mut nodes_by_file: HashMap<&str, Vec<&NodeInfo>> = HashMap::new();
Expand All @@ -177,8 +185,41 @@ impl<'a> EdgeContext<'a> {
nodes_by_file,
builtin_set,
receiver_kinds,
invoked_property_names: collect_invoked_property_names(files),
}
}
}

/// Collect the set of property/method names ever invoked via member-call
/// syntax (`x.name(...)`) across every file currently being processed —
/// regardless of whether the receiver `x` itself resolves to anything.
///
/// Used as the "one hop further" liveness check for object-literal-property
/// value-refs (#1895): a function referenced as `{ resolve: someFn }` should
/// only be credited with a `calls` edge from that reference when something,
/// somewhere, actually invokes a `.resolve(...)`-shaped call — otherwise the
/// property is wired up but never read, and `someFn` is genuinely dead.
///
/// Scope matches whatever `files` the caller passes to `build_call_edges`:
/// the full codebase for a full build, or just the changed file(s) on an
/// incremental one. The incremental case is a narrower, same-build-pass view
/// (a cross-file consumer added in an untouched file won't be seen until the
/// next full rebuild) — the same scoping trade-off already accepted
/// elsewhere in this codebase's incremental classification
/// (`has_active_file_siblings`, exported-via-reexport, and median fan-in/out
/// all recompute from the affected file set only, not the whole graph, in
/// `graph/classifiers/roles.rs`'s incremental path). Mirrors
/// `collectInvokedPropertyNames` in `src/domain/graph/builder/call-resolver.ts`.
fn collect_invoked_property_names(files: &[FileEdgeInput]) -> HashSet<&str> {
let mut names = HashSet::new();
for file in files {
for call in &file.calls {
if call.receiver.is_some() {
names.insert(call.name.as_str());
}
}
}
names
}

// ── Phase 8.3: points-to analysis ─────────────────────────────────────────
Expand Down Expand Up @@ -467,7 +508,7 @@ pub fn build_call_edges(
builtin_receivers: Vec<String>,
max_iterations: u32,
) -> Vec<ComputedEdge> {
let ctx = EdgeContext::new(&all_nodes, &builtin_receivers);
let ctx = EdgeContext::new(&all_nodes, &builtin_receivers, &files);
let mut edges = Vec::new();

for file_input in &files {
Expand Down Expand Up @@ -799,6 +840,17 @@ fn process_file<'a>(
// (stages/build-edges.ts).
if call.dynamic_kind.as_deref() == Some("value-ref") {
targets.retain(|t| t.kind == "function" || t.kind == "method" || t.kind == "class");
// #1895: object-literal-property value-refs (key_expr set — see
// handle_object_literal_pair_value_ref / shorthand handler)
// additionally require independent evidence that the property is
// actually invoked somewhere (`x.key_expr(...)`) — merely being
// wired into an object literal is not liveness. instanceof/Lua
// value-refs never set key_expr, so they are unaffected.
if let Some(key_expr) = call.key_expr.as_deref() {
if !ctx.invoked_property_names.contains(key_expr) {
targets.clear();
}
}
}
sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from);
emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges);
Expand Down Expand Up @@ -2904,6 +2956,72 @@ mod call_edge_tests {
assert_eq!(re.target_id, 2, "receiver edge target should be Calculator (id=2)");
}

/// Issue #1895: an object-literal-property value-ref call whose property
/// key (`key_expr`) is never independently confirmed to be invoked
/// anywhere (`x.resolve(...)`) must NOT produce a `calls` edge — merely
/// being wired into the object literal is not liveness. A sibling
/// property whose key IS invoked elsewhere (`table.reject(...)`) keeps
/// its edge.
#[test]
fn value_ref_edge_requires_key_invoked_elsewhere() {
let all_nodes = vec![
node(1, "makeTable", "function", "factory.js", 1),
node(2, "neverRead", "function", "factory.js", 2),
node(3, "isRead", "function", "factory.js", 3),
node(4, "run", "function", "consumer.js", 1),
];

let mut resolve_call = call("neverRead", 5, None);
resolve_call.dynamic = Some(true);
resolve_call.dynamic_kind = Some("value-ref".to_string());
resolve_call.key_expr = Some("resolve".to_string());

let mut reject_call = call("isRead", 6, None);
reject_call.dynamic = Some(true);
reject_call.dynamic_kind = Some("value-ref".to_string());
reject_call.key_expr = Some("reject".to_string());

let factory_file = make_file(
"factory.js",
10,
vec![def("makeTable", "function", 1, 8)],
vec![resolve_call, reject_call],
vec![],
vec![],
);

// Evidence that `.reject(...)` is genuinely invoked somewhere, but
// `.resolve(...)` never is.
let consumer_file = make_file(
"consumer.js",
20,
vec![def("run", "function", 1, 3)],
vec![call("reject", 2, Some("table"))],
vec![],
vec![],
);

let edges = build_call_edges(
vec![factory_file, consumer_file],
all_nodes,
vec![],
MAX_SOLVER_ITERATIONS,
);

let calls_never_read = edges.iter().any(|e| e.kind == "calls" && e.target_id == 2);
let calls_is_read = edges.iter().any(|e| e.kind == "calls" && e.target_id == 3);
assert!(
!calls_never_read,
"expected no calls edge to neverRead (key 'resolve' never invoked); got: {:?}",
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
);
assert!(
calls_is_read,
"expected a calls edge to isRead (key 'reject' invoked in consumer.js); got: {:?}",
edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::<Vec<_>>()
);
}

/// Regression: when the same file has a `kind="function"` node for the
/// effective receiver created by a destructured import (e.g.
/// `const { Calculator } = require('./utils')`), that import artifact must
Expand Down
81 changes: 81 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2874,6 +2874,12 @@ fn extract_callback_reference_calls(
/// (`{ name: SOME_CONSTANT }`) naturally fail to resolve into an edge rather
/// than needing a structural allowlist gate here.
///
/// `key_expr` carries the property KEY (e.g. `resolve`), distinct from `name`
/// (the referenced value's own identifier, e.g. `someFunction`) — the
/// downstream "is this property ever invoked" liveness check (#1895) needs
/// the key, since that's the name a dispatch consumer would actually call
/// (`table.resolve(...)`), not the function's own declared name.
///
/// Mirrors `collectObjectLiteralValueRefCall` in `src/extractors/javascript.ts`.
fn handle_object_literal_pair_value_ref(node: &Node, source: &[u8], calls: &mut Vec<Call>) {
let Some(value_n) = node.child_by_field_name("value") else { return };
Expand All @@ -2884,11 +2890,13 @@ fn handle_object_literal_pair_value_ref(node: &Node, source: &[u8], calls: &mut
if JS_BUILTIN_GLOBALS.contains(&text) {
return;
}
let key_expr = node.child_by_field_name("key").and_then(|k| resolve_pair_key_name(&k, source));
calls.push(Call {
name: text.to_string(),
line: start_line(&value_n),
dynamic: Some(true),
dynamic_kind: Some("value-ref".to_string()),
key_expr,
..Default::default()
});
}
Expand All @@ -2900,6 +2908,9 @@ fn handle_object_literal_pair_value_ref(node: &Node, source: &[u8], calls: &mut
/// `shorthand_property_identifier_pattern` kind), so this can't misfire on
/// destructuring targets.
///
/// `key_expr` equals `name` here — the property key and the referenced value
/// are the same identifier in shorthand form (#1895).
///
/// Mirrors the walk path's `shorthand_property_identifier` handling in
/// `src/extractors/javascript.ts`'s `runCollectorWalk` (issue #1771).
fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls: &mut Vec<Call>) {
Expand All @@ -2912,6 +2923,7 @@ fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls:
line: start_line(node),
dynamic: Some(true),
dynamic_kind: Some("value-ref".to_string()),
key_expr: Some(text.to_string()),
..Default::default()
});
}
Expand Down Expand Up @@ -5068,6 +5080,75 @@ mod tests {
assert!(value_refs.iter().any(|c| c.name == "someFunction"));
}

// #1895: key_expr capture — the property key, distinct from the
// referenced value's own name, is what a dispatch consumer would
// actually call (`table.resolve(...)`).
#[test]
fn value_ref_captures_property_key_distinct_from_referenced_name() {
let s = parse_js("const table = { resolve: someFunction };");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction")
.expect("expected a value-ref call");
assert_eq!(call.key_expr.as_deref(), Some("resolve"));
}

#[test]
fn value_ref_captures_string_literal_key_with_quotes_stripped() {
let s = parse_js("const table = { 'resolve': someFunction };");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction")
.expect("expected a value-ref call");
assert_eq!(call.key_expr.as_deref(), Some("resolve"));
}

#[test]
fn value_ref_captures_computed_string_literal_key() {
let s = parse_js("const table = { ['resolve']: someFunction };");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction")
.expect("expected a value-ref call");
assert_eq!(call.key_expr.as_deref(), Some("resolve"));
}

#[test]
fn value_ref_leaves_key_expr_unset_for_non_string_computed_key() {
let s = parse_js("const table = { [Symbol.iterator]: someFunction };");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction")
.expect("expected a value-ref call");
assert_eq!(call.key_expr, None);
}

#[test]
fn value_ref_key_expr_equals_name_for_shorthand_property() {
let s = parse_js("const table = { someFunction };");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction")
.expect("expected a value-ref call");
assert_eq!(call.key_expr.as_deref(), Some("someFunction"));
}

#[test]
fn instanceof_value_ref_leaves_key_expr_unset() {
let s = parse_js("if (err instanceof CodegraphError) {}");
let call = s
.calls
.iter()
.find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "CodegraphError")
.expect("expected a value-ref call");
assert_eq!(call.key_expr, None);
}

#[test]
fn no_value_ref_call_for_call_expression_value() {
let s = parse_js("const table = { resolve: someFunction() };");
Expand Down
35 changes: 35 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,41 @@ export const RECEIVER_KINDS = new Set(['class', 'struct', 'interface', 'type', '
// continue to work without changes (build-edges.ts, etc.).
export { isModuleScopedLanguage };

/**
* Collect the set of property/method names ever invoked via member-call
* syntax (`x.name(...)`) across every file currently being processed —
* regardless of whether the receiver `x` itself resolves to anything.
*
* Used as the "one hop further" liveness check for object-literal-property
* value-refs (#1895): a function referenced as `{ resolve: someFn }` should
* only be credited with a `calls` edge from that reference when something,
* somewhere, actually invokes a `.resolve(...)`-shaped call — otherwise the
* property is wired up but never read, and `someFn` is genuinely dead.
*
* Scope matches whatever set of files the caller passes in: the full
* codebase for a full build (build-edges.ts's `buildCallEdgesJS`, from
* `ctx.fileSymbols`), or just the single file being rebuilt on an incremental
* update (incremental.ts's `buildCallEdges`, from that file's own `calls`).
* The incremental case is a narrower, same-file view — a cross-file consumer
* added in a different, untouched file won't be seen until the next full
* rebuild — the same scoping trade-off already accepted elsewhere in this
* codebase's incremental classification (`hasActiveFileSiblings`,
* exported-via-reexport, and median fan-in/out all recompute from an affected
* subset, not the whole graph, in `graph/classifiers/roles.rs`'s incremental
* path).
*/
export function collectInvokedPropertyNames(
callsList: Iterable<Iterable<{ name: string; receiver?: string }>>,
): Set<string> {
const names = new Set<string>();
for (const calls of callsList) {
for (const call of calls) {
if (call.receiver) names.add(call.name);
}
}
return names;
Comment on lines +71 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Name-only matching can suppress dead-code detection due to unrelated call sites

collectInvokedPropertyNames adds a property name whenever ANY call in the processed files has a non-empty receiver and that name. There is no check that the receiver is plausibly the same dispatch-table object. A completely unrelated logger.resolve() or promise.resolve() call anywhere in the codebase will add resolve to the set and prevent { resolve: neverCalled } from being flagged dead, even if the actual dispatch table is never read. This is the conservative direction of error (false negative for dead-code), so it won't misclassify live code as dead, but it can cause real dead-code entries to escape detection. Given the existing comment explicitly scopes this as "one hop further" heuristic evidence rather than precise analysis, this is understood — just worth noting for future precision work.

Fix in Claude Code

}

/**
* Shared by both the full-build (build-edges.ts) and incremental (incremental.ts)
* same-class fallback strategies: derive the enclosing class name from the
Expand Down
11 changes: 11 additions & 0 deletions src/domain/graph/builder/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from '../resolver/points-to.js';
import {
type CallNodeLookup,
collectInvokedPropertyNames,
findCaller,
isModuleScopedLanguage,
resolveCallTargets,
Expand Down Expand Up @@ -1055,6 +1056,10 @@ function buildCallEdges(
// JS path uses (buildPointsToMapForFile, shared via resolver/points-to.js).
const ptsMap = buildPointsToMapForFile(symbols, importedNames);
const fnRefBindingLhs = new Set(symbols.fnRefBindings?.map((b) => b.lhs) ?? []);
// #1895: scoped to this file's own calls only — see collectInvokedPropertyNames
// doc comment (call-resolver.ts) for why incremental rebuilds use a narrower,
// same-file view rather than a full-codebase one.
const invokedPropertyNames = collectInvokedPropertyNames([symbols.calls]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Incremental rebuild can false-positive dead-code when the invocation is cross-file

collectInvokedPropertyNames([symbols.calls]) only sees calls extracted from the single file currently being rebuilt. If factory.js defines { resolve: neverCalled } and the only table.resolve(1) site lives in consumer.js, an incremental rebuild triggered solely on factory.js will not find resolve in invokedPropertyNames and will drop the edge — classifying neverCalled as dead even though it is genuinely reachable. Before this fix the edge was unconditionally kept, so this is a new false-positive direction introduced on the incremental path.

This is documented as the same scoping trade-off accepted elsewhere, and a full rebuild will correct it. Worth confirming the team is comfortable with incremental builds potentially surfacing "dead" findings that disappear on the next full run, since that could affect CI dead-code checks that run on incremental watches.

Fix in Claude Code

let edgesAdded = 0;

for (const call of symbols.calls) {
Expand Down Expand Up @@ -1089,6 +1094,12 @@ function buildCallEdges(
targets = targets.filter(
(t) => t.kind === 'function' || t.kind === 'method' || t.kind === 'class',
);
// #1895: object-literal-property value-refs additionally require
// independent evidence the property is actually invoked somewhere —
// mirrors the same check in resolveFallbackTargets (stages/build-edges.ts).
if (call.keyExpr && !invokedPropertyNames.has(call.keyExpr)) {
targets = [];
}
}

edgesAdded += emitIncrementalCallEdges(
Expand Down
Loading
Loading