From 1274b537c56d76fb4d12c6b17e170ab2d9bb7299 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 21:55:11 +0000 Subject: [PATCH] fix(code-index): resolve calls through block-scoped use --- .../src/rust_extractor.rs | 143 +++++++++++++++++- .../code_index_scheduler/tests/reconcile.rs | 2 +- crates/tracedecay-code-index/src/extract.rs | 7 +- crates/tracedecay-code-index/src/languages.rs | 8 +- .../src/production/worker_tests.rs | 4 +- .../tests/code_index_suite/import_evidence.rs | 94 ++++++++++++ .../production_orchestration.rs | 12 +- 7 files changed, 254 insertions(+), 16 deletions(-) diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index 5009905176..d7be2b1c3d 100644 --- a/crates/tracedecay-code-extraction/src/rust_extractor.rs +++ b/crates/tracedecay-code-extraction/src/rust_extractor.rs @@ -6,7 +6,7 @@ use std::{ time::Instant, }; -use tree_sitter::{Node as TsNode, Parser, Range, Tree}; +use tree_sitter::{Node as TsNode, Parser, Point, Range, Tree}; use crate::common::local_node_id; use crate::complexity::{RUST_COMPLEXITY, count_complexity}; @@ -28,6 +28,14 @@ struct ShadowedCallNames { names: Vec, } +/// The names one block's `use` declarations bind, with the path each names. +struct BlockUseScope { + start: Point, + end: Point, + /// `None` binds the name to an item this file may itself define. + paths: BTreeMap>, +} + /// Receiver bindings whose type the function body states outright: typed /// parameters, typed `let`s, `let`s initialised by a struct literal /// (`T { .. }`, possibly behind `?`), and `self` in a method. A dotted @@ -381,6 +389,7 @@ impl RustExtractor { Self::collect_receiver_types(state, node, node, &mut receivers); Self::extract_call_sites(state, node, &id, &receivers); Self::suppress_shadowed_calls(state, node, &id); + Self::qualify_block_scoped_uses(state, node, &id); Self::extract_annotations_from_modifiers(state, node, &id); @@ -2333,6 +2342,138 @@ impl RustExtractor { }); } + /// A `use` inside a block binds its names for that whole block, nested + /// blocks included, and shadows a module-scope import of the same name. + /// Import rows are file-scoped, so a call through a block `use` is + /// rewritten to the declared path here; the qualified resolver then binds + /// it exactly like a `crate::`/`super::`/extern path. A path that may + /// stay inside this file keeps its bare name, which binds same-file + /// items; the cross-file resolver never binds into the referencing file. + fn qualify_block_scoped_uses( + state: &mut ExtractionState<'_>, + function: TsNode<'_>, + fn_node_id: &str, + ) { + let mut scopes = Vec::new(); + Self::collect_block_use_scopes(state, function, function, &mut scopes); + if scopes.is_empty() { + return; + } + for reference in &mut state.unresolved_refs { + if reference.from_node_id != fn_node_id || reference.reference_name.contains('.') { + continue; + } + let head = reference + .reference_name + .split("::") + .next() + .unwrap_or_default(); + let site = Point { + row: reference.line as usize, + column: reference.column as usize, + }; + // Pre-order: an inner block follows the block that contains it. + let declared = scopes + .iter() + .rev() + .filter(|scope| scope.start <= site && site < scope.end) + .find_map(|scope| scope.paths.get(head)); + if let Some(Some(path)) = declared { + reference.reference_name = + format!("{path}{}", &reference.reference_name[head.len()..]); + } + } + } + + /// Whether a block `use` path names an item outside this file: `self::` + /// (and `crate::` from a crate root) only when it continues through a + /// root `mod name;` file module, and `super::` only from a file module. + fn block_use_path_leaves_file( + state: &ExtractionState<'_>, + function: TsNode<'_>, + path: &str, + ) -> bool { + let crate_root = state.file_path == "lib.rs" + || state.file_path == "main.rs" + || state.file_path.ends_with("/lib.rs") + || state.file_path.ends_with("/main.rs"); + let mut segments = path.split("::"); + let file_relative = match segments.next() { + Some("self") => true, + Some("crate") => crate_root, + Some("super") => { + return !Self::ancestors(function).any(|node| node.kind() == "mod_item"); + } + _ => false, + }; + if !file_relative { + return true; + } + let Some(module) = segments.next() else { + return false; + }; + let Some(root) = Self::ancestors(function).find(|node| node.kind() == "source_file") else { + return false; + }; + let mut cursor = root.walk(); + root.named_children(&mut cursor).any(|item| { + item.kind() == "mod_item" + && item.child_by_field_name("body").is_none() + && item + .child_by_field_name("name") + .is_some_and(|name| state.node_text(name) == module) + }) + } + + fn ancestors(node: TsNode<'_>) -> impl Iterator> { + std::iter::successors(node.parent(), TsNode::parent) + } + + fn collect_block_use_scopes( + state: &mut ExtractionState<'_>, + node: TsNode<'_>, + function: TsNode<'_>, + scopes: &mut Vec, + ) { + if node != function && node.kind() == "function_item" { + return; + } + if node.kind() == "block" { + let mut paths = BTreeMap::new(); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() != "use_declaration" { + continue; + } + let Some(argument) = child.child_by_field_name("argument") else { + continue; + }; + let first = state.imports.len(); + Self::extract_use_bindings(state, argument, None, false, None); + let bindings = state.imports.drain(first..).collect::>(); + for import in bindings { + if let (Some(local), Some(imported)) = (import.local_name, import.imported_name) + { + let path = format!("{}::{imported}", import.module_specifier); + let leaves_file = Self::block_use_path_leaves_file(state, function, &path); + paths.insert(local, leaves_file.then_some(path)); + } + } + } + if !paths.is_empty() { + scopes.push(BlockUseScope { + start: node.start_position(), + end: node.end_position(), + paths, + }); + } + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + Self::collect_block_use_scopes(state, child, function, scopes); + } + } + fn collect_shadowed_names( state: &ExtractionState<'_>, node: TsNode<'_>, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs index 0f5c115d8c..41cf65df6b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs @@ -855,7 +855,7 @@ fn retained_stale_rust_extractor_generation_is_refused_and_rebuilt() { .iter() .find(|(language, _)| language.as_str() == "rust") .map(|(_, revision)| revision.as_str()), - Some("extractor.rust.v14") + Some("extractor.rust.v15") ); } diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index a8dfb9095c..9dd130886f 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -761,12 +761,13 @@ mod tests { // `self` from the enclosing impl or trait; v12 binds a receiver typed // by a type parameter with one trait bound to `Trait::method`; v13 // retains parser-positioned receiver-call evidence; v14 extracts - // item-list macro bodies and binds `#[cfg]` variants. The revision is + // item-list macro bodies and binds `#[cfg]` variants; v15 qualifies + // calls through block-scoped `use` declarations. The revision is // part of the batch identity, so the pinned digest moves with it. - assert_eq!(descriptor.extractor_revision.as_str(), "extractor.rust.v14"); + assert_eq!(descriptor.extractor_revision.as_str(), "extractor.rust.v15"); assert_eq!( extraction.batch().rows_digest.as_str(), - "sha256:ee93409e76028857423e575422b01262d32c61a001213ca71d4cd06f7bf34162" + "sha256:c7f72d389d8521307a60ca4373df3f9551c15e6527b1b5f68b34e80db87b38af" ); } diff --git a/crates/tracedecay-code-index/src/languages.rs b/crates/tracedecay-code-index/src/languages.rs index 754e11a485..6d3c3f6332 100644 --- a/crates/tracedecay-code-index/src/languages.rs +++ b/crates/tracedecay-code-index/src/languages.rs @@ -223,7 +223,9 @@ impl StaticLanguageRegistry { // parse as item lists (`cfg_rt! { ... }`), records other bodies as // unexpanded `name!` macro nodes, binds calls to every `#[cfg]` // variant of one definition, and retains qualified calls through a - // declared module whose name is also a blocklisted std name. + // declared module whose name is also a blocklisted std name. Rust + // v15 rewrites a call through a `use` declared in a block to the + // path that `use` names, so it binds like a module-scope import. // TypeScript v7 records `export … from` forwarding as public // import evidence and retains explicitly imported ubiquitous names // as cross-file candidates, so barrels and workspace packages bind. @@ -238,7 +240,7 @@ impl StaticLanguageRegistry { // comments and `///` lost its stray `/`; QBasic dialects moved when // CONST names stopped losing their text before an underscore. let extractor_revision = match language.as_str() { - "rust" => 14, + "rust" => 15, "typescript" => 9, "protobuf" => 7, "sql" => 6, @@ -443,7 +445,7 @@ mod tests { assert!(rust.stable_member_spans); assert!(rust.capabilities.extraction); assert_eq!(rust.root_markers, vec!["Cargo.toml".to_owned()]); - assert_eq!(rust.extractor_revision.as_str(), "extractor.rust.v14"); + assert_eq!(rust.extractor_revision.as_str(), "extractor.rust.v15"); assert_eq!( registry diff --git a/crates/tracedecay-code-index/src/production/worker_tests.rs b/crates/tracedecay-code-index/src/production/worker_tests.rs index 9aabb212bb..7b9e7907cc 100644 --- a/crates/tracedecay-code-index/src/production/worker_tests.rs +++ b/crates/tracedecay-code-index/src/production/worker_tests.rs @@ -422,7 +422,7 @@ fn extractor_revision_change_reextracts_before_validating_retained_import_rows() assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), - "extractor.rust.v14" + "extractor.rust.v15" ); assert_ne!( rebuilt.files[0].extraction.parser_import_rows_digest, @@ -490,7 +490,7 @@ fn physical_artifact_reuse_rejects_a_stale_extractor_revision() { assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), - "extractor.rust.v14" + "extractor.rust.v15" ); assert!( rebuilt.files[0] diff --git a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs index c858ccce14..42c0af45d6 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs @@ -1227,3 +1227,97 @@ fn rust_items_inside_item_list_macros_are_symbols_with_resolved_callers() { ] ); } + +fn resolved_callers<'a>( + generation: &'a CodeIndexPublishedGenerationV1, + target: &SymbolOccurrenceId, +) -> Vec<&'a str> { + let mut callers = generation + .edges() + .iter() + .filter(|edge| edge.to_occurrence == *target && edge.kind == RelationEdgeKindV1::Calls) + .filter_map(|edge| { + generation + .symbols() + .symbols + .iter() + .find(|symbol| symbol.occurrence == edge.from_occurrence) + .map(|symbol| symbol.qualified_name.as_str()) + }) + .collect::>(); + callers.sort_unstable(); + callers +} + +#[test] +fn rust_calls_bind_through_a_use_declared_in_the_calling_block() { + let generation = published_rust_workspace(&[ + ( + "file.block-use.lib", + "crates/app/src/lib.rs", + "mod m;\nmod n;\nmod runtime;\nuse crate::n::g;\n\ + pub fn f() { use crate::m::g; g(); }\n\ + pub fn module_scope() { g(); }\n\ + pub fn spawn_inner() {\n use crate::runtime::{context, task};\n context::with_current();\n task::schedule();\n}\n\ + pub fn timer() {\n #[cfg(feature = \"rt\")]\n {\n use crate::runtime::context;\n context::with_current();\n }\n}\n\ + mod inner { pub fn x() {} }\n\ + pub fn same_file() { use self::inner::x; x(); }\n", + ), + ("file.block-use.m", "crates/app/src/m.rs", "pub fn g() {}\n"), + ("file.block-use.n", "crates/app/src/n.rs", "pub fn g() {}\n"), + ( + "file.block-use.runtime", + "crates/app/src/runtime/mod.rs", + "pub mod context;\npub mod task;\n", + ), + ( + "file.block-use.context", + "crates/app/src/runtime/context.rs", + "mod current;\npub(crate) use current::with_current;\n", + ), + ( + "file.block-use.current", + "crates/app/src/runtime/context/current.rs", + "pub(crate) fn with_current() {}\n", + ), + ( + "file.block-use.task", + "crates/app/src/runtime/task.rs", + "pub fn schedule() {}\n", + ), + ]); + let block_g = symbol_occurrence(&generation, "crates/app/src/m.rs::g"); + let module_g = symbol_occurrence(&generation, "crates/app/src/n.rs::g"); + let with_current = symbol_occurrence( + &generation, + "crates/app/src/runtime/context/current.rs::with_current", + ); + let schedule = symbol_occurrence(&generation, "crates/app/src/runtime/task.rs::schedule"); + let inner_x = symbol_occurrence(&generation, "crates/app/src/lib.rs::inner::x"); + + // The block's `use` shadows the module-scope import only inside `f`. + assert_eq!( + resolved_callers(&generation, &block_g), + ["crates/app/src/lib.rs::f"] + ); + assert_eq!( + resolved_callers(&generation, &module_g), + ["crates/app/src/lib.rs::module_scope"] + ); + assert_eq!( + resolved_callers(&generation, &with_current), + [ + "crates/app/src/lib.rs::spawn_inner", + "crates/app/src/lib.rs::timer" + ] + ); + assert_eq!( + resolved_callers(&generation, &schedule), + ["crates/app/src/lib.rs::spawn_inner"] + ); + // A block `use` of this file's own item keeps binding it in-file. + assert_eq!( + resolved_callers(&generation, &inner_x), + ["crates/app/src/lib.rs::same_file"] + ); +} 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 ad45d86e6f..ec8206deed 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 @@ -2984,22 +2984,22 @@ fn partitioned_codec_fixture() -> ( } const PARTITIONED_FORMAT_STATE_DIGEST: &str = - "sha256:1cf2d05997094d9e01c88c7482eef7e405c5c30b6e6bc3ff7d223105fab33c0b"; + "sha256:824e5692181d785e3722fc0a4465b2d21aaac106bcc45f684bd0350693558149"; const PARTITIONED_FORMAT_SEGMENTS: &[(&str, u64)] = &[ ( - "sha256:65f5ee94b70747310ed41f45b6a525ad30f554b8060a388830959adac394347f", + "sha256:810c283c12aeeae815061185f2936982b31666488763adbf3b1212ca68a5b2a9", 1_734, ), ( - "sha256:e2e4ff0252168f278eb5270b4b05b6463fa6ecc961b03638e1fa1e5a9e3f2050", - 1_262, + "sha256:ff6dc0319b2f147a5fb56aa00248462133db05f78252309f0a1dfdfac051335b", + 1_263, ), ( - "sha256:304facbe2ba45ce8ec684842962237cd235b7ee6ee7327d8bd64b35d796fa5d7", + "sha256:cde12a92d48833fd210770a1e3da3fab56015801ca376cf920bbcc8441fc3d31", 1_310, ), ( - "sha256:0735457fdd43120b1cea3f13d983e0ff583e8d0802e96fd506f991aac6dfa1c3", + "sha256:f2fd9edd26bb0724336d5e288fd9d33beed496a8ef543fbffbd76bdcd77c9d5d", 2_837, ), ];