From 03d318fcbbc030dccde2fe59acfff02cf79c6dc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 06:47:00 +0000 Subject: [PATCH] fix(graph): bind builder callers through typed locals (#1355) Carry the closed #1361 lexical LocalTypeScope redesign onto a fresh campaign-tip lane, and address remaining Codex P1s: resolve import aliases when matching Type::method owners, verify local new return types before qualifying bindings, and accept inline module segments. Co-authored-by: Zack Jackson --- .../src/rust_extractor.rs | 459 +++++++++++++++--- .../tests/main/rust.rs | 239 +++++++++ crates/tracedecay-code-index/src/chunks.rs | 13 +- .../src/production/helpers.rs | 170 ++++++- .../production_orchestration.rs | 186 +++++++ .../mcp_handler_test/graph_query_test.rs | 94 ++++ 6 files changed, 1090 insertions(+), 71 deletions(-) diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index 8082eb92c1..a9174333c7 100644 --- a/crates/tracedecay-code-extraction/src/rust_extractor.rs +++ b/crates/tracedecay-code-extraction/src/rust_extractor.rs @@ -1,7 +1,10 @@ /// Tree-sitter based Rust source code extractor. /// /// Parses Rust source files and emits nodes and edges for the code graph. -use std::{collections::BTreeMap, time::Instant}; +use std::{ + collections::{BTreeMap, HashMap}, + time::Instant, +}; use tree_sitter::{Node as TsNode, Tree}; @@ -23,6 +26,47 @@ struct ShadowedCallNames { names: Vec, } +/// Lexical `local → Type` frames for method-call qualification. +/// +/// Bindings are inserted in declaration order; nested blocks push/pop frames +/// so an inner shadow cannot retype outer call sites. +struct LocalTypeScope { + frames: Vec>, +} + +impl LocalTypeScope { + fn new() -> Self { + Self { + frames: vec![HashMap::new()], + } + } + + fn push(&mut self) { + self.frames.push(HashMap::new()); + } + + fn pop(&mut self) { + if self.frames.len() > 1 { + self.frames.pop(); + } + } + + fn insert(&mut self, name: String, type_path: String) { + if let Some(frame) = self.frames.last_mut() { + frame.insert(name, type_path); + } + } + + fn get(&self, name: &str) -> Option<&str> { + for frame in self.frames.iter().rev() { + if let Some(type_path) = frame.get(name) { + return Some(type_path.as_str()); + } + } + None + } +} + /// Internal state used during AST traversal. /// /// Borrows the caller's source for the lifetime of the walk: copying the @@ -1450,74 +1494,123 @@ impl RustExtractor { /// Recursively find `call_expression` nodes inside a given node and create /// unresolved Calls references. + /// + /// Local types are tracked in lexical order while walking: each call site + /// sees only bindings that precede it in the same scope (and outer scopes), + /// so shadowing cannot retype earlier calls. fn extract_call_sites(state: &mut ExtractionState<'_>, node: TsNode<'_>, fn_node_id: &str) { + let mut scope = LocalTypeScope::new(); + Self::seed_parameter_types(state, node, &mut scope); + // Walk the body (not the function_item itself) so nested `function_item` + // nodes remain independently extracted via visit_function. + if let Some(body) = node.child_by_field_name("body") { + Self::extract_call_sites_in_scope(state, body, fn_node_id, &mut scope); + } + } + + fn seed_parameter_types( + state: &ExtractionState<'_>, + function: TsNode<'_>, + scope: &mut LocalTypeScope, + ) { + let Some(params) = function.child_by_field_name("parameters") else { + return; + }; + let mut cursor = params.walk(); + if !cursor.goto_first_child() { + return; + } + loop { + let child = cursor.node(); + if child.kind() == "parameter" + && let (Some(pattern), Some(ty)) = ( + child.child_by_field_name("pattern"), + child.child_by_field_name("type"), + ) + && let Some(name) = Self::binding_identifier(state, pattern) + && let Some(type_path) = Self::simple_type_path(state, ty) + { + scope.insert(name, type_path); + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + /// Walk statements/expressions in order, maintaining [`LocalTypeScope`]. + fn extract_call_sites_in_scope( + state: &mut ExtractionState<'_>, + node: TsNode<'_>, + fn_node_id: &str, + scope: &mut LocalTypeScope, + ) { + match node.kind() { + "call_expression" => { + Self::emit_call_refs(state, node, fn_node_id, scope); + // Nested calls live in arguments / the callee expression. + Self::walk_ordered_children(state, node, fn_node_id, scope); + } + "let_declaration" => { + // Initializer sees the prior scope; the binding applies after. + if let Some(value) = node.child_by_field_name("value") { + Self::extract_call_sites_in_scope(state, value, fn_node_id, scope); + } + Self::record_let_binding(state, node, scope); + } + "block" | "unsafe_block" | "const_block" | "async_block" => { + scope.push(); + Self::walk_ordered_children(state, node, fn_node_id, scope); + scope.pop(); + } + "closure_expression" => { + scope.push(); + if let Some(parameters) = node.child_by_field_name("parameters") { + Self::seed_closure_or_param_list(state, parameters, scope); + } + Self::walk_ordered_children(state, node, fn_node_id, scope); + scope.pop(); + } + "macro_invocation" => { + let macro_name = node.child_by_field_name("macro").map_or_else( + || { + let text = state.node_text(node); + text.split('!').next().unwrap_or("").trim().to_string() + }, + |n| state.node_text(n).to_string(), + ); + state.unresolved_refs.push(UnresolvedRef { + from_node_id: fn_node_id.to_string(), + reference_name: macro_name, + reference_kind: EdgeKind::Calls, + line: node.start_position().row as u32, + column: node.start_position().column as u32, + file_path: state.file_path.clone(), + }); + Self::walk_ordered_children(state, node, fn_node_id, scope); + } + "token_tree" => { + Self::extract_calls_in_token_tree(state, node, fn_node_id); + } + "function_item" => { + // Nested functions are visited independently via visit_function. + } + _ => { + Self::walk_ordered_children(state, node, fn_node_id, scope); + } + } + } + + fn walk_ordered_children( + state: &mut ExtractionState<'_>, + node: TsNode<'_>, + fn_node_id: &str, + scope: &mut LocalTypeScope, + ) { let mut cursor = node.walk(); if cursor.goto_first_child() { loop { - let child = cursor.node(); - match child.kind() { - "call_expression" => { - if let Some(callee) = child.child_by_field_name("function") { - let callee_name = state.node_text(callee); - state.unresolved_refs.push(UnresolvedRef { - from_node_id: fn_node_id.to_string(), - reference_name: callee_name.to_string(), - reference_kind: EdgeKind::Calls, - line: child.start_position().row as u32, - column: child.start_position().column as u32, - file_path: state.file_path.clone(), - }); - // For dot-calls (e.g. `instance.method()`), also emit - // a ref with just the method name so the resolver can - // match it against impl method definitions. - if let Some(method_name) = callee_name.rsplit('.').next() - && method_name != callee_name - { - state.unresolved_refs.push(UnresolvedRef { - from_node_id: fn_node_id.to_string(), - reference_name: method_name.to_string(), - reference_kind: EdgeKind::Calls, - line: child.start_position().row as u32, - column: child.start_position().column as u32, - file_path: state.file_path.clone(), - }); - } - } - Self::extract_call_sites(state, child, fn_node_id); - } - "macro_invocation" => { - let macro_name = child.child_by_field_name("macro").map_or_else( - || { - let text = state.node_text(child); - text.split('!').next().unwrap_or("").trim().to_string() - }, - |n| state.node_text(n).to_string(), - ); - state.unresolved_refs.push(UnresolvedRef { - from_node_id: fn_node_id.to_string(), - reference_name: macro_name, - reference_kind: EdgeKind::Calls, - line: child.start_position().row as u32, - column: child.start_position().column as u32, - file_path: state.file_path.clone(), - }); - Self::extract_call_sites(state, child, fn_node_id); - } - // Inside a macro's token_tree, the grammar does not produce - // call_expression nodes. Instead, a function call appears as - // an identifier immediately followed by a token_tree sibling - // (e.g. `check_count(5)` → identifier "check_count" + token_tree - // "(5)"). Detect that pattern and emit Calls edges, then recurse - // into the token_tree to handle further nesting. - "token_tree" => { - Self::extract_calls_in_token_tree(state, child, fn_node_id); - } - // Skip nested function definitions — they are handled separately. - "function_item" => {} - _ => { - Self::extract_call_sites(state, child, fn_node_id); - } - } + Self::extract_call_sites_in_scope(state, cursor.node(), fn_node_id, scope); if !cursor.goto_next_sibling() { break; } @@ -1525,6 +1618,242 @@ impl RustExtractor { } } + fn seed_closure_or_param_list( + state: &ExtractionState<'_>, + params: TsNode<'_>, + scope: &mut LocalTypeScope, + ) { + let mut cursor = params.walk(); + if !cursor.goto_first_child() { + return; + } + loop { + let child = cursor.node(); + if matches!(child.kind(), "parameter" | "closure_parameters") { + if let Some(pattern) = child.child_by_field_name("pattern") + && let Some(ty) = child.child_by_field_name("type") + && let Some(name) = Self::binding_identifier(state, pattern) + && let Some(type_path) = Self::simple_type_path(state, ty) + { + scope.insert(name, type_path); + } + } else if child.kind() == "identifier" { + // Untyped closure params do not establish a type. + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + fn emit_call_refs( + state: &mut ExtractionState<'_>, + call: TsNode<'_>, + fn_node_id: &str, + scope: &LocalTypeScope, + ) { + let Some(callee) = call.child_by_field_name("function") else { + return; + }; + let callee_name = state.node_text(callee); + state.unresolved_refs.push(UnresolvedRef { + from_node_id: fn_node_id.to_string(), + reference_name: callee_name.to_string(), + reference_kind: EdgeKind::Calls, + line: call.start_position().row as u32, + column: call.start_position().column as u32, + file_path: state.file_path.clone(), + }); + // For dot-calls (e.g. `instance.method()`), also emit a ref with just + // the method name so the resolver can match impl method definitions. + if let Some(method_name) = callee_name.rsplit('.').next() + && method_name != callee_name + { + state.unresolved_refs.push(UnresolvedRef { + from_node_id: fn_node_id.to_string(), + reference_name: method_name.to_string(), + reference_kind: EdgeKind::Calls, + line: call.start_position().row as u32, + column: call.start_position().column as u32, + file_path: state.file_path.clone(), + }); + // When the receiver is a local whose type is known at this + // lexical point, also emit `Type::method`. + if let Some((receiver, _)) = callee_name.rsplit_once('.') + && !receiver.contains('.') + && let Some(type_path) = scope.get(receiver) + { + state.unresolved_refs.push(UnresolvedRef { + from_node_id: fn_node_id.to_string(), + reference_name: format!("{type_path}::{method_name}"), + reference_kind: EdgeKind::Calls, + line: call.start_position().row as u32, + column: call.start_position().column as u32, + file_path: state.file_path.clone(), + }); + } + } + } + + fn record_let_binding( + state: &ExtractionState<'_>, + let_decl: TsNode<'_>, + scope: &mut LocalTypeScope, + ) { + let Some(pattern) = let_decl.child_by_field_name("pattern") else { + return; + }; + let Some(name) = Self::binding_identifier(state, pattern) else { + return; + }; + if let Some(ty) = let_decl.child_by_field_name("type") + && let Some(type_path) = Self::simple_type_path(state, ty) + { + scope.insert(name, type_path); + return; + } + if let Some(value) = let_decl.child_by_field_name("value") + && let Some(type_path) = Self::constructor_type_path(state, value) + { + scope.insert(name, type_path); + } + } + + /// Simple binding name from a pattern, unwrapping `mut` / `ref` wrappers. + /// Destructuring patterns abstain (return `None`). + fn binding_identifier(state: &ExtractionState<'_>, pattern: TsNode<'_>) -> Option { + match pattern.kind() { + "identifier" => Some(state.node_text(pattern).to_owned()), + "mut_pattern" + | "ref_pattern" + | "reference_pattern" + | "captured_pattern" + | "rest_pattern" => pattern + .child_by_field_name("pattern") + .or_else(|| pattern.child_by_field_name("name")) + .or_else(|| { + // tree-sitter-rust often nests the identifier as the sole + // named child under mut/ref wrappers. + (0..pattern.named_child_count()) + .find_map(|index| pattern.named_child(index as u32)) + .filter(|child| { + matches!( + child.kind(), + "identifier" + | "mut_pattern" + | "ref_pattern" + | "reference_pattern" + | "captured_pattern" + ) + }) + }) + .and_then(|inner| Self::binding_identifier(state, inner)), + _ => None, + } + } + + /// `WalkBuilder`, `&mut WalkBuilder`, `ignore::WalkBuilder` — the path a + /// method call should qualify against. Complex types (tuples, impl Trait) + /// return `None`. + fn simple_type_path(state: &ExtractionState<'_>, ty: TsNode<'_>) -> Option { + match ty.kind() { + "type_identifier" => Some(state.node_text(ty).to_owned()), + "scoped_type_identifier" => Some(state.node_text(ty).to_owned()), + "generic_type" => ty + .child_by_field_name("type") + .and_then(|inner| Self::simple_type_path(state, inner)), + "reference_type" => ty + .child_by_field_name("type") + .and_then(|inner| Self::simple_type_path(state, inner)), + "pointer_type" => ty + .child_by_field_name("type") + .and_then(|inner| Self::simple_type_path(state, inner)), + _ => None, + } + } + + /// Only `Type::new` / `path::Type::new` establish the value's type. + /// Arbitrary associated calls (`Factory::make`) do not — their result type + /// is not the qualifier. + /// + /// When a local `new` method is visible in [`ExtractionState::nodes`], its + /// declared return must be `Self` / the constructed type (optionally behind + /// `&` / `&mut`). Other returns abstain so `Factory::new() -> Product` does + /// not invent `Factory` as the binding type. Missing local evidence keeps + /// the cross-file constructor convention (`Type::new` → `Type`). + fn constructor_type_path(state: &ExtractionState<'_>, value: TsNode<'_>) -> Option { + let call = match value.kind() { + "call_expression" => value, + // `let mut x = Type::new();` may wrap through unary / try expressions. + "unary_expression" | "try_expression" | "await_expression" => { + return value + .child(0) + .or_else(|| value.named_child(0)) + .and_then(|inner| Self::constructor_type_path(state, inner)); + } + _ => return None, + }; + let callee = call.child_by_field_name("function")?; + let callee_name = state.node_text(callee); + if callee_name.contains('.') { + return None; + } + let (type_path, ctor) = callee_name.rsplit_once("::")?; + if ctor != "new" || type_path.is_empty() { + return None; + } + let type_simple = type_path.rsplit("::").next().unwrap_or(type_path); + let suffix = format!("{type_simple}::new"); + let mut local_news = state.nodes.iter().filter(|node| { + node.kind == NodeKind::Method + && node.name == "new" + && (node.qualified_name == suffix + || node.qualified_name.ends_with(&format!("::{suffix}"))) + }); + let Some(local_new) = local_news.next() else { + // Cross-file / not-yet-visited constructor: keep convention. + return Some(type_path.to_owned()); + }; + if local_news.next().is_some() { + // Multiple `TypeSimple::new` methods — do not guess. + return None; + } + let signature = local_new.signature.as_deref().unwrap_or(""); + if Self::constructor_return_is_self_or_type(signature, type_path, type_simple) { + Some(type_path.to_owned()) + } else { + None + } + } + + /// `true` when the signature's declared return is `Self` or the constructed + /// type (after stripping a leading `&` / `&mut `). No `->` means unit/`()` + /// and does not establish the qualifier type. + fn constructor_return_is_self_or_type( + signature: &str, + type_path: &str, + type_simple: &str, + ) -> bool { + let Some(after_arrow) = signature.split("->").nth(1) else { + return false; + }; + let raw = after_arrow + .split("where") + .next() + .unwrap_or(after_arrow) + .trim(); + if raw.is_empty() { + return false; + } + let stripped = raw + .strip_prefix("&mut ") + .or_else(|| raw.strip_prefix("&mut")) + .or_else(|| raw.strip_prefix('&')) + .unwrap_or(raw) + .trim(); + stripped == "Self" || stripped == type_path || stripped == type_simple + } + /// Import rows are file-scoped, so a local binding makes the same bare /// call name ambiguous for its whole owning function. Withhold that call /// rather than claiming statement-level resolution the artifact lacks. diff --git a/crates/tracedecay-code-extraction/tests/main/rust.rs b/crates/tracedecay-code-extraction/tests/main/rust.rs index 286d6f66e0..1e7445dbba 100644 --- a/crates/tracedecay-code-extraction/tests/main/rust.rs +++ b/crates/tracedecay-code-extraction/tests/main/rust.rs @@ -913,6 +913,245 @@ fn use_foo() { ref_names.contains(&"bar"), "expected 'bar' method-name ref from f.bar(), got: {ref_names:?}" ); + // And `Foo::bar` once the local's type is known from `Foo::new()`. + assert!( + ref_names.contains(&"Foo::bar"), + "expected Foo::bar from typed local f, got: {ref_names:?}" + ); +} + +#[test] +fn mut_builder_local_emits_qualified_method_ref() { + let source = r#" +struct WalkBuilder; +impl WalkBuilder { + fn new() -> Self { WalkBuilder } + fn build(&self) -> u32 { 1 } +} + +fn make_walk() -> u32 { + let mut builder = WalkBuilder::new(); + builder.build() +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let make_walk = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "make_walk") + .expect("make_walk"); + let refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| r.from_node_id == make_walk.id && r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.as_str()) + .collect(); + assert!( + refs.contains(&"WalkBuilder::build"), + "let mut builder = WalkBuilder::new(); builder.build() must emit WalkBuilder::build, got {refs:?}" + ); +} + +#[test] +fn shadowed_locals_emit_types_in_declaration_order() { + let source = r#" +struct A; +impl A { + fn new() -> Self { A } + fn run(&self) {} +} +struct B; +impl B { + fn new() -> Self { B } + fn run(&self) {} +} + +fn shadow() { + let b = A::new(); + b.run(); + let b = B::new(); + b.run(); +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let shadow = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "shadow") + .expect("shadow"); + let run_refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| { + r.from_node_id == shadow.id + && r.reference_kind == EdgeKind::Calls + && (r.reference_name == "A::run" || r.reference_name == "B::run") + }) + .map(|r| r.reference_name.as_str()) + .collect(); + assert_eq!( + run_refs, + ["A::run", "B::run"], + "each b.run() must use the lexically enclosing binding's type, got {run_refs:?}" + ); +} + +#[test] +fn nested_block_shadow_does_not_retype_outer_calls() { + let source = r#" +struct A; +impl A { + fn new() -> Self { A } + fn run(&self) {} +} +struct B; +impl B { + fn new() -> Self { B } + fn run(&self) {} +} + +fn nested() { + let b = A::new(); + { + let b = B::new(); + b.run(); + } + b.run(); +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let nested = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "nested") + .expect("nested"); + let run_refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| { + r.from_node_id == nested.id + && r.reference_kind == EdgeKind::Calls + && (r.reference_name == "A::run" || r.reference_name == "B::run") + }) + .map(|r| r.reference_name.as_str()) + .collect(); + assert_eq!( + run_refs, + ["B::run", "A::run"], + "inner shadow must not retype the outer b.run(), got {run_refs:?}" + ); +} + +#[test] +fn non_constructor_associated_call_does_not_assume_qualifier_type() { + let source = r#" +struct Factory; +struct Product; +impl Factory { + fn make() -> Product { Product } +} +impl Product { + fn build(&self) -> u32 { 1 } +} +impl Factory { + fn build(&self) -> u32 { 2 } +} + +fn use_factory() -> u32 { + let value = Factory::make(); + value.build() +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let use_factory = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "use_factory") + .expect("use_factory"); + let refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| r.from_node_id == use_factory.id && r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.as_str()) + .collect(); + assert!( + !refs.contains(&"Factory::build"), + "Factory::make() must not invent Factory as value's type: {refs:?}" + ); + assert!( + refs.contains(&"build"), + "bare method hint should still be emitted: {refs:?}" + ); +} + +#[test] +fn new_with_non_self_return_does_not_assume_qualifier_type() { + let source = r#" +struct Factory; +struct Product; +impl Factory { + fn new() -> Product { Product } + fn build(&self) -> u32 { 2 } +} +impl Product { + fn build(&self) -> u32 { 1 } +} +fn use_factory() -> u32 { + let value = Factory::new(); + value.build() +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let use_factory = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "use_factory") + .expect("use_factory"); + let refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| r.from_node_id == use_factory.id && r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.as_str()) + .collect(); + assert!( + !refs.contains(&"Factory::build"), + "Factory::new() -> Product must not invent Factory as value's type: {refs:?}" + ); +} + +#[test] +fn typed_annotation_establishes_local_type_without_new() { + let source = r#" +struct Product; +impl Product { + fn build(&self) -> u32 { 1 } +} +fn from_annotation(value: Product) -> u32 { + value.build() +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let func = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "from_annotation") + .expect("from_annotation"); + let refs: Vec<_> = result + .unresolved_refs + .iter() + .filter(|r| r.from_node_id == func.id && r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.as_str()) + .collect(); + assert!( + refs.contains(&"Product::build"), + "parameter type annotation must emit Product::build, got {refs:?}" + ); } #[test] diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index d3ee9b1b38..7f925e121f 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -2197,9 +2197,13 @@ fn resolve_file_references( /// The retained cross-file form of one reference the file could not bind, or /// `None` when the reference can never bind cross-file: receiver-dotted -/// paths (unknown receiver type), blocklisted ubiquitous names, relation -/// kinds outside the canonical graph contract, and references whose +/// paths (unknown receiver type), blocklisted ubiquitous *unqualified* names, +/// relation kinds outside the canonical graph contract, and references whose /// enclosing symbol is not uniquely identified. +/// +/// Qualified names (`Type::method`, `path::Type::method`) keep their simple +/// segment even when that segment is on the ubiquitous-method blocklist: the +/// path is the authority, and sealing binds through it. fn cross_file_reference_candidate( source: &str, offsets: &[u64], @@ -2210,12 +2214,15 @@ fn cross_file_reference_candidate( if reference.reference_name.contains('.') { return None; } + let qualified = reference.reference_name.contains("::"); let simple_name = reference .reference_name .rsplit("::") .next() .unwrap_or(reference.reference_name.as_str()); - if simple_name.is_empty() || CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name) { + if simple_name.is_empty() + || (!qualified && CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name)) + { return None; } let kind = canonical_relation_kind(&reference.reference_kind)?; diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 327c64af02..8f86a70e62 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -437,8 +437,12 @@ where .or_else(|| reference.reference_name.rsplit("::").next()) .unwrap_or(reference.reference_name.as_str()); // Retention already narrows names, but carried artifacts outlive policy - // revisions; apply the current blocklist to every retained reference. - if simple_name.is_empty() || CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name) { + // revisions; apply the current blocklist to every retained *unqualified* + // reference. Qualified `Type::method` paths keep the method segment even + // when it is ubiquitous — the type path is the binding authority. + if simple_name.is_empty() + || (!qualified && CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name)) + { return None; } let candidates = by_simple_name.get(simple_name)?; @@ -483,10 +487,20 @@ where { return true; } - file_qualified_name_matches( + if file_qualified_name_matches( &reference.reference_name, &files[*candidate_index].as_ref().authority.logical_path, &symbol.qualified_name, + ) { + return true; + } + associated_type_method_matches( + files, + &mut rust, + file, + &reference.reference_name, + *candidate_index, + symbol, ) }, |qualified| { @@ -997,6 +1011,156 @@ fn file_qualified_name_matches( == Some(symbol_path) } +/// Bind `Type::method` / `path::Type::method` to an associated method whose +/// file-relative qualified name is `Type::method`. +/// +/// Ubiquitous method names (`build`, `new`, …) are blocklisted when bare; the +/// type path is what makes them bindable. A simple `Type::method` with an +/// import for `Type` must land in that import's module; a `crate_name::Type::…` +/// path must land under that crate's source root. Uniqueness of the filtered +/// candidate set still applies in the caller. +fn associated_type_method_matches( + files: &[T], + rust: &mut RustResolutionContextV1<'_>, + source_file: &FileGenerationArtifactsV1, + reference_name: &str, + target_index: usize, + target_symbol: &LineageSymbolRecordV1, +) -> bool +where + T: AsRef, +{ + let mut parts = reference_name.split("::").collect::>(); + if parts.len() < 2 { + return false; + } + let method = parts.pop().expect("len >= 2"); + let type_name = *parts.last().expect("len >= 1 after pop"); + if method.is_empty() || type_name.is_empty() { + return false; + } + let target_path = files[target_index].as_ref().authority.logical_path.as_str(); + let Some(relative) = target_symbol + .qualified_name + .strip_prefix(target_path) + .and_then(|path| path.strip_prefix("::")) + else { + return false; + }; + // Bare `Type::method` only: `use path::WalkBuilder as Builder` emits + // `Builder::build`, so resolve the alias's imported_name before matching. + // Path-qualified refs (`dep::WalkBuilder::build`) keep the path segment. + let (owner_name, binding) = if parts.len() == 1 { + let binding = unique_import(source_file, type_name, RelationEdgeKindV1::TypeOf) + .or_else(|| unique_import(source_file, type_name, RelationEdgeKindV1::Calls)); + let owner = binding + .and_then(|row| row.imported_name.as_deref()) + .filter(|name| !name.is_empty() && *name != "*") + .unwrap_or(type_name); + (owner, binding) + } else { + (type_name, None) + }; + let expected = format!("{owner_name}::{method}"); + if relative != expected && !relative.ends_with(&format!("::{expected}")) { + return false; + } + if parts.len() == 1 { + // `Type::method` — when the type is imported, require that import. + let Some(binding) = binding else { + // No import evidence: allow and rely on candidate uniqueness. + return true; + }; + return files[target_index] + .as_ref() + .artifacts + .symbols + .iter() + .filter(|symbol| { + symbol.simple_name == owner_name + && relation_target_kind_is_compatible( + RelationEdgeKindV1::TypeOf, + &symbol.kind, + ) + }) + .any(|type_symbol| match binding.module_kind { + ImportModuleKindV1::ProjectRelative => project_import_matches( + binding, + &binding.logical_path, + target_path, + &type_symbol.qualified_name, + ), + ImportModuleKindV1::BareModule + if source_file.extraction.language.as_str() == "rust" => + { + rust_bare_import_matches( + files, + rust, + binding, + RustSymbolTargetV1 { + index: target_index, + symbol: type_symbol, + }, + ) + } + ImportModuleKindV1::BareModule => false, + }); + } + + // `path::Type::method` — resolve every module segment, not just a crate root. + let module_prefix = &parts[..parts.len() - 1]; + let module_path = module_prefix.join("/"); + + // Crate-qualified: `dep::a::Builder::method` must land under `dep` *and* + // under module `a` (not a sibling `dep::b::Builder`). + if let Some(root_index) = rust.files.crate_root(module_prefix[0]) { + let root_path = files[root_index].as_ref().authority.logical_path.as_str(); + let Some(crate_source_root) = rust_source_root(root_path) else { + return false; + }; + if rust_source_root(target_path) != Some(crate_source_root) { + return false; + } + let Some(relative_file) = target_path + .strip_prefix(crate_source_root) + .and_then(|path| path.strip_prefix('/')) + else { + return false; + }; + let Some(target_module) = rust_file_module(relative_file) else { + return false; + }; + let module_after_crate = module_prefix[1..].join("/"); + return if module_after_crate.is_empty() { + target_module.is_empty() + } else if target_module == module_after_crate + || target_module.starts_with(&format!("{module_after_crate}/")) + { + true + } else if target_module.is_empty() { + // Inline `mod a { struct Builder; … }` in the crate root file: the + // file module is empty but the symbol path carries the segments. + let inline = format!( + "{}::{type_name}::{method}", + module_after_crate.replace('/', "::") + ); + relative == inline || relative.ends_with(&format!("::{inline}")) + } else { + false + }; + } + + // Same-crate module path: `walk::WalkBuilder::method` from `src/lib.rs`. + if let Some(module_index) = rust + .files + .module(&source_file.authority.logical_path, &module_path) + { + return module_index == target_index; + } + + false +} + /// Map an extracted Rust symbol back to the path used by a `crate::...` /// reference. Standard Cargo source roots scope the match, so equal module /// paths in sibling workspace crates cannot cross-bind. diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index a8ba6d6660..5a0fab168a 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs @@ -580,6 +580,192 @@ fn cross_file_edges_require_path_binding_evidence() { })); } +/// #1355: a Rust builder method called through a typed local from another file +/// must produce a `Calls` edge. Bare `build` is blocklisted; binding goes through +/// the extractor's `Type::method` emission and qualified seal resolution. +#[test] +fn cross_file_builder_method_calls_bind_through_typed_locals() { + let sources = [ + ( + "file.builder.caller", + "crates/app/src/lib.rs", + "rust", + "use builder::WalkBuilder;\npub fn make_walk() -> u32 {\n let mut builder = WalkBuilder::new();\n builder.build()\n}\npub fn make_walk_again() -> u32 {\n let mut builder = WalkBuilder::new();\n builder.build()\n}\n", + ), + ( + "file.builder.def", + "crates/builder/src/lib.rs", + "rust", + "pub struct WalkBuilder;\nimpl WalkBuilder {\n pub fn new() -> Self { WalkBuilder }\n pub fn build(&self) -> u32 { 1 }\n}\n", + ), + ]; + let mut request = request("file.builder.seed", 1_100_000); + request.snapshot.files.clear(); + request.snapshot.sanitization_receipts.clear(); + request.captured_files.clear(); + let mut identity = Sha256::new(); + for (ordinal, (occurrence, path, language, source)) in sources.into_iter().enumerate() { + identity.update(path.as_bytes()); + identity.update([0]); + identity.update(source.as_bytes()); + let file_occurrence_id = id::(occurrence); + request.snapshot.files.push(SanitizedCodeFileV1 { + file_occurrence_id: file_occurrence_id.clone(), + logical_path: path.to_owned(), + language: Some(id::(language)), + content_digest: content_digest(source.as_bytes()), + disposition: SnapshotFileDispositionV1::Present, + }); + request + .snapshot + .sanitization_receipts + .push(id::(&format!( + "receipt.builder.{ordinal}" + ))); + request.captured_files.push(CodeIndexCapturedFileV1 { + file_occurrence_id, + sanitized_bytes: Arc::from(source.as_bytes()), + sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, + }); + } + request.snapshot.content_identity = content_digest(&identity.finalize()); + + let generation = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("production owner") + .build_and_publish(request, &ActiveControl) + .expect("generation publishes"); + let occurrence = |qualified_name: &str| { + generation + .symbols() + .symbols + .iter() + .find(|symbol| symbol.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("missing {qualified_name}")) + .occurrence + .clone() + }; + let build = occurrence("crates/builder/src/lib.rs::WalkBuilder::build"); + let make_walk = occurrence("crates/app/src/lib.rs::make_walk"); + let make_walk_again = occurrence("crates/app/src/lib.rs::make_walk_again"); + let incoming_callers = generation + .edges() + .iter() + .filter(|edge| { + edge.to_occurrence == build && edge.kind == RelationEdgeKindV1::Calls + }) + .map(|edge| edge.from_occurrence.clone()) + .collect::>(); + assert!( + incoming_callers.contains(&make_walk), + "make_walk must call WalkBuilder::build: edges={:?} symbols={:?}", + generation.edges(), + generation + .symbols() + .symbols + .iter() + .map(|symbol| symbol.qualified_name.as_str()) + .collect::>() + ); + assert!( + incoming_callers.contains(&make_walk_again), + "make_walk_again must call WalkBuilder::build: {incoming_callers:?}" + ); +} + +/// #1355: import alias (`WalkBuilder as Builder`) must still bind through the +/// imported type name when a typed local calls a blocklisted method. +#[test] +fn cross_file_aliased_builder_method_calls_bind_through_typed_locals() { + let sources = [ + ( + "file.builder.alias.caller", + "crates/app/src/lib.rs", + "rust", + "use builder::WalkBuilder as Builder;\npub fn make_walk() -> u32 {\n let mut builder = Builder::new();\n builder.build()\n}\n", + ), + ( + "file.builder.alias.def", + "crates/builder/src/lib.rs", + "rust", + "pub struct WalkBuilder;\nimpl WalkBuilder {\n pub fn new() -> Self { WalkBuilder }\n pub fn build(&self) -> u32 { 1 }\n}\n", + ), + ]; + let mut request = request("file.builder.alias.seed", 1_100_000); + request.snapshot.files.clear(); + request.snapshot.sanitization_receipts.clear(); + request.captured_files.clear(); + let mut identity = Sha256::new(); + for (ordinal, (occurrence, path, language, source)) in sources.into_iter().enumerate() { + identity.update(path.as_bytes()); + identity.update([0]); + identity.update(source.as_bytes()); + let file_occurrence_id = id::(occurrence); + request.snapshot.files.push(SanitizedCodeFileV1 { + file_occurrence_id: file_occurrence_id.clone(), + logical_path: path.to_owned(), + language: Some(id::(language)), + content_digest: content_digest(source.as_bytes()), + disposition: SnapshotFileDispositionV1::Present, + }); + request + .snapshot + .sanitization_receipts + .push(id::(&format!( + "receipt.builder.alias.{ordinal}" + ))); + request.captured_files.push(CodeIndexCapturedFileV1 { + file_occurrence_id, + sanitized_bytes: Arc::from(source.as_bytes()), + sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, + }); + } + request.snapshot.content_identity = content_digest(&identity.finalize()); + + let generation = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("production owner") + .build_and_publish(request, &ActiveControl) + .expect("generation publishes"); + let occurrence = |qualified_name: &str| { + generation + .symbols() + .symbols + .iter() + .find(|symbol| symbol.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("missing {qualified_name}")) + .occurrence + .clone() + }; + let build = occurrence("crates/builder/src/lib.rs::WalkBuilder::build"); + let make_walk = occurrence("crates/app/src/lib.rs::make_walk"); + let incoming_callers = generation + .edges() + .iter() + .filter(|edge| { + edge.to_occurrence == build && edge.kind == RelationEdgeKindV1::Calls + }) + .map(|edge| edge.from_occurrence.clone()) + .collect::>(); + assert!( + incoming_callers.contains(&make_walk), + "aliased Builder local must call WalkBuilder::build: edges={:?} symbols={:?}", + generation.edges(), + generation + .symbols() + .symbols + .iter() + .map(|symbol| symbol.qualified_name.as_str()) + .collect::>() + ); +} + #[test] fn production_increment_reuses_retained_tree_and_reports_bounded_parse_work() { let store = SharedPublicationStore::default(); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs index 58149f29fc..b1151a582e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs @@ -2308,3 +2308,97 @@ async fn body_prefers_function_over_field_with_same_name() { "body should be the function source, got: {body}" ); } + +/// #1355: public callers of a Rust builder method called from another file +/// through a typed local must not be an empty complete miss. +#[tokio::test] +async fn callers_of_cross_file_builder_method_include_typed_local_sites() { + let (fixture, _root) = graph_query_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write( + project.join("src/lib.rs"), + "mod walk;\n\ + use crate::walk::WalkBuilder;\n\ + pub fn make_walk() -> u32 {\n\ + let mut builder = WalkBuilder::new();\n\ + builder.build()\n\ + }\n\ + pub fn make_walk_again() -> u32 {\n\ + let mut builder = WalkBuilder::new();\n\ + builder.build()\n\ + }\n", + ) + .unwrap(); + fs::write( + project.join("src/walk.rs"), + "pub struct WalkBuilder;\n\ + impl WalkBuilder {\n\ + pub fn new() -> Self { WalkBuilder }\n\ + pub fn build(&self) -> u32 { 1 }\n\ + }\n", + ) + .unwrap(); + }) + .await; + + let server = fixture + .production + .harness + .server(&fixture.production.project_root) + .expect("production graph-query server"); + warm_code_index_search(&server, "WalkBuilder").await; + + let exact = call_production_tool( + &fixture, + "tracedecay_find_exact_symbol", + json!({"name": "build", "limit": 20, "format": "json"}), + None, + None, + ) + .await + .expect("exact-symbol MCP lookup"); + let payload: Value = + serde_json::from_str(extract_text(&exact.value)).expect("exact-symbol response JSON"); + let build_id = payload["matches"] + .as_array() + .into_iter() + .flatten() + .find(|item| { + item["name"] == "build" + && item["file"] + .as_str() + .is_some_and(|file| file.ends_with("walk.rs")) + }) + .and_then(|item| item["id"].as_str()) + .unwrap_or_else(|| panic!("WalkBuilder::build missing from exact-symbol: {payload}")) + .to_owned(); + + let callers = call_production_tool( + &fixture, + "tracedecay_callers", + json!({"node_id": build_id, "format": "json"}), + None, + None, + ) + .await + .expect("callers MCP lookup"); + let callers_payload: Value = + serde_json::from_str(extract_text(&callers.value)).expect("callers response JSON"); + let items = callers_payload + .as_array() + .unwrap_or_else(|| panic!("callers must return a JSON array: {callers_payload}")); + let caller_names = items + .iter() + .filter_map(|item| item["name"].as_str()) + .collect::>(); + assert!( + caller_names.iter().any(|name| *name == "make_walk"), + "expected make_walk among callers of WalkBuilder::build, got {callers_payload}" + ); + assert!( + caller_names.iter().any(|name| *name == "make_walk_again"), + "expected make_walk_again among callers of WalkBuilder::build, got {callers_payload}" + ); + + shutdown_graph_fixture(fixture).await; +}