diff --git a/crates/tracedecay-application/src/primitives/symbol_graph.rs b/crates/tracedecay-application/src/primitives/symbol_graph.rs index a54e728a87..14005ed8b9 100644 --- a/crates/tracedecay-application/src/primitives/symbol_graph.rs +++ b/crates/tracedecay-application/src/primitives/symbol_graph.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tracedecay_code_index::graph_projection::{ CodeGraphInteractiveReader, CodeGraphReadCostMeter, CodeGraphSymbolBindingV1, - CodeGraphSymbolSummaryV1, + CodeGraphSymbolSummaryV1, UnresolvedCallerGapsV1, }; use tracedecay_code_index::lineage::LineageSymbolRecordV1; use tracedecay_contracts::retrieval::{ @@ -562,7 +562,7 @@ where let Ok(graph) = open_graph(&self.code_graph, context).await else { return failed(context, "caller traversal failed"); }; - let (records, unsupported) = match relation_traversal( + let (records, unresolved) = match relation_traversal( &graph.reader, Arc::clone(&graph.cancellation), &request.node_id, @@ -573,7 +573,7 @@ where Ok(records) => records, Err(()) => return failed(context, "caller traversal failed"), }; - let mut gaps = if unsupported { + let mut gaps = if unresolved.exact_target_unavailable { // Unresolved Rust receiver calls and TypeScript imports the // seal could not bind share one disclosure; the gap is the // call site, not a language. @@ -586,6 +586,12 @@ where } else { Vec::new() }; + gaps.extend( + unresolved + .unmodeled_imports + .iter() + .map(|shape| PrimitiveSupportGap::import_unmodeled(shape.as_str())), + ); // A caller inside an unexpanded macro body is syntactic // evidence only; what the expansion calls is not covered. gaps.extend( @@ -972,24 +978,28 @@ fn relation_traversal( maximum_depth: u32, incoming: bool, scope: &SymbolGraphScope, -) -> Result<(Vec, bool), ()> { +) -> Result<(Vec, UnresolvedCallerGapsV1), ()> { let seed = SymbolOccurrenceId::new(seed.to_owned()).map_err(|_| ())?; let mut seen = HashSet::from([seed.clone()]); let mut frontier = vec![seed]; let mut records = Vec::new(); - let mut unsupported_callers = false; + let mut unresolved_callers = UnresolvedCallerGapsV1::default(); for depth in 1..=maximum_depth { if frontier.is_empty() || records.len() >= MAX_COMPATIBILITY_RESULTS { break; } if incoming { - unsupported_callers |= graph - .has_unresolved_callers( + let gaps = graph + .unresolved_caller_gaps( &frontier, scope.path_prefix.as_deref(), Arc::clone(&cancellation), ) .map_err(|_| ())?; + unresolved_callers.exact_target_unavailable |= gaps.exact_target_unavailable; + unresolved_callers + .unmodeled_imports + .extend(gaps.unmodeled_imports); } let batches = if incoming { graph.callers( @@ -1029,7 +1039,7 @@ fn relation_traversal( } frontier = next; } - Ok((records, unsupported_callers)) + Ok((records, unresolved_callers)) } fn trait_dispatch_targets( diff --git a/crates/tracedecay-code-extraction/src/annotations.rs b/crates/tracedecay-code-extraction/src/annotations.rs index 97a9fbf452..38129386d1 100644 --- a/crates/tracedecay-code-extraction/src/annotations.rs +++ b/crates/tracedecay-code-extraction/src/annotations.rs @@ -71,6 +71,7 @@ pub(crate) fn emit_annotation_usage( line: start_line, column: start_column, file_path: state.file_path().to_string(), + unmodeled_import: None, }); state.push_edge(Edge { diff --git a/crates/tracedecay-code-extraction/src/bash_extractor.rs b/crates/tracedecay-code-extraction/src/bash_extractor.rs index 75cc55038c..a8a33dc97a 100644 --- a/crates/tracedecay-code-extraction/src/bash_extractor.rs +++ b/crates/tracedecay-code-extraction/src/bash_extractor.rs @@ -351,6 +351,7 @@ impl BashExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } let mut cursor = node.walk(); diff --git a/crates/tracedecay-code-extraction/src/batch_extractor.rs b/crates/tracedecay-code-extraction/src/batch_extractor.rs index 818524a3be..dca4b5fc90 100644 --- a/crates/tracedecay-code-extraction/src/batch_extractor.rs +++ b/crates/tracedecay-code-extraction/src/batch_extractor.rs @@ -350,6 +350,7 @@ impl BatchExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/clojure_extractor.rs b/crates/tracedecay-code-extraction/src/clojure_extractor.rs index 8e868aa456..0876c2c159 100644 --- a/crates/tracedecay-code-extraction/src/clojure_extractor.rs +++ b/crates/tracedecay-code-extraction/src/clojure_extractor.rs @@ -454,6 +454,7 @@ impl ClojureExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id, 1); diff --git a/crates/tracedecay-code-extraction/src/cobol_extractor.rs b/crates/tracedecay-code-extraction/src/cobol_extractor.rs index 3796fe3f82..4726ca3d60 100644 --- a/crates/tracedecay-code-extraction/src/cobol_extractor.rs +++ b/crates/tracedecay-code-extraction/src/cobol_extractor.rs @@ -503,6 +503,7 @@ impl CobolExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -529,6 +530,7 @@ impl CobolExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/common.rs b/crates/tracedecay-code-extraction/src/common.rs index 08624ab874..dddb5cc99a 100644 --- a/crates/tracedecay-code-extraction/src/common.rs +++ b/crates/tracedecay-code-extraction/src/common.rs @@ -302,6 +302,7 @@ pub(crate) fn extract_call_expression_sites( line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: file_path.to_string(), + unmodeled_import: None, }); } extract_call_expression_sites(source, file_path, unresolved_refs, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/cpp_extractor/metadata.rs b/crates/tracedecay-code-extraction/src/cpp_extractor/metadata.rs index 21c7a02499..06d18d2494 100644 --- a/crates/tracedecay-code-extraction/src/cpp_extractor/metadata.rs +++ b/crates/tracedecay-code-extraction/src/cpp_extractor/metadata.rs @@ -222,6 +222,7 @@ impl CppExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -372,6 +373,7 @@ impl CppExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { source: id, diff --git a/crates/tracedecay-code-extraction/src/csharp_extractor.rs b/crates/tracedecay-code-extraction/src/csharp_extractor.rs index e2fd772a5e..773086a349 100644 --- a/crates/tracedecay-code-extraction/src/csharp_extractor.rs +++ b/crates/tracedecay-code-extraction/src/csharp_extractor.rs @@ -294,6 +294,7 @@ impl CSharpExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1199,6 +1200,7 @@ impl CSharpExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); if let Some(ref tid) = target_id { @@ -1458,6 +1460,7 @@ impl CSharpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -1663,6 +1666,7 @@ impl CSharpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } @@ -1675,6 +1679,7 @@ impl CSharpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } diff --git a/crates/tracedecay-code-extraction/src/dart_extractor.rs b/crates/tracedecay-code-extraction/src/dart_extractor.rs index a1cedffd37..82f215006b 100644 --- a/crates/tracedecay-code-extraction/src/dart_extractor.rs +++ b/crates/tracedecay-code-extraction/src/dart_extractor.rs @@ -316,6 +316,7 @@ impl DartExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -510,6 +511,7 @@ impl DartExtractor { line: superclass.start_position().row as u32, column: superclass.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1624,6 +1626,7 @@ impl DartExtractor { line: ident.start_position().row as u32, column: ident.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1643,6 +1646,7 @@ impl DartExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1664,6 +1668,7 @@ impl DartExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1880,6 +1885,7 @@ impl DartExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/elixir_extractor.rs b/crates/tracedecay-code-extraction/src/elixir_extractor.rs index cfb86fab94..8f88819bdb 100644 --- a/crates/tracedecay-code-extraction/src/elixir_extractor.rs +++ b/crates/tracedecay-code-extraction/src/elixir_extractor.rs @@ -486,6 +486,7 @@ impl ElixirExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/erlang_extractor.rs b/crates/tracedecay-code-extraction/src/erlang_extractor.rs index 2c43e090b5..a2feec5275 100644 --- a/crates/tracedecay-code-extraction/src/erlang_extractor.rs +++ b/crates/tracedecay-code-extraction/src/erlang_extractor.rs @@ -298,6 +298,7 @@ impl ErlangExtractor { line: start_line, column: 0, file_path: state.file_path.clone(), + unmodeled_import: None, }); } let _ = text; @@ -384,6 +385,7 @@ impl ErlangExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/fortran_extractor.rs b/crates/tracedecay-code-extraction/src/fortran_extractor.rs index 7b83b13027..f9c44746b9 100644 --- a/crates/tracedecay-code-extraction/src/fortran_extractor.rs +++ b/crates/tracedecay-code-extraction/src/fortran_extractor.rs @@ -422,6 +422,7 @@ impl FortranExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -844,6 +845,7 @@ impl FortranExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -861,6 +863,7 @@ impl FortranExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/fsharp_extractor.rs b/crates/tracedecay-code-extraction/src/fsharp_extractor.rs index 73ea5105d5..840415fff1 100644 --- a/crates/tracedecay-code-extraction/src/fsharp_extractor.rs +++ b/crates/tracedecay-code-extraction/src/fsharp_extractor.rs @@ -433,6 +433,7 @@ impl FSharpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/glsl_extractor.rs b/crates/tracedecay-code-extraction/src/glsl_extractor.rs index 590f0325f2..017f574042 100644 --- a/crates/tracedecay-code-extraction/src/glsl_extractor.rs +++ b/crates/tracedecay-code-extraction/src/glsl_extractor.rs @@ -584,6 +584,7 @@ impl GlslExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/go_extractor.rs b/crates/tracedecay-code-extraction/src/go_extractor.rs index 67775d3e51..3b6aedb0cc 100644 --- a/crates/tracedecay-code-extraction/src/go_extractor.rs +++ b/crates/tracedecay-code-extraction/src/go_extractor.rs @@ -236,6 +236,7 @@ impl GoExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -713,6 +714,7 @@ impl GoExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1020,6 +1022,7 @@ impl GoExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); // Also try to create a direct Receives edge if we can find // the struct node. We look for it by matching name. @@ -1152,6 +1155,7 @@ impl GoExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } // Also recurse into the call expression for nested calls. diff --git a/crates/tracedecay-code-extraction/src/gwbasic_extractor.rs b/crates/tracedecay-code-extraction/src/gwbasic_extractor.rs index f9d437faa8..6a6a4cc51d 100644 --- a/crates/tracedecay-code-extraction/src/gwbasic_extractor.rs +++ b/crates/tracedecay-code-extraction/src/gwbasic_extractor.rs @@ -534,6 +534,7 @@ impl GwBasicExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/hlsl_extractor.rs b/crates/tracedecay-code-extraction/src/hlsl_extractor.rs index 540a59298a..f01c82c94d 100644 --- a/crates/tracedecay-code-extraction/src/hlsl_extractor.rs +++ b/crates/tracedecay-code-extraction/src/hlsl_extractor.rs @@ -563,6 +563,7 @@ impl HlslExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/java_extractor.rs b/crates/tracedecay-code-extraction/src/java_extractor.rs index 350d2522f1..718fa0d497 100644 --- a/crates/tracedecay-code-extraction/src/java_extractor.rs +++ b/crates/tracedecay-code-extraction/src/java_extractor.rs @@ -335,6 +335,7 @@ impl JavaExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1069,6 +1070,7 @@ impl JavaExtractor { line: inner_child.start_position().row as u32, column: inner_child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); break; } @@ -1123,6 +1125,7 @@ impl JavaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } else if child.kind() == "type_list" { Self::extract_type_list_as_implements(state, child, class_id); @@ -1320,6 +1323,7 @@ impl JavaExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } return; @@ -1352,6 +1356,7 @@ impl JavaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } @@ -1364,6 +1369,7 @@ impl JavaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } diff --git a/crates/tracedecay-code-extraction/src/julia_extractor.rs b/crates/tracedecay-code-extraction/src/julia_extractor.rs index bcda9a1b64..2a444d2276 100644 --- a/crates/tracedecay-code-extraction/src/julia_extractor.rs +++ b/crates/tracedecay-code-extraction/src/julia_extractor.rs @@ -324,6 +324,7 @@ impl JuliaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/kotlin_extractor.rs b/crates/tracedecay-code-extraction/src/kotlin_extractor.rs index f0935f8aa8..67eaf9d04c 100644 --- a/crates/tracedecay-code-extraction/src/kotlin_extractor.rs +++ b/crates/tracedecay-code-extraction/src/kotlin_extractor.rs @@ -348,6 +348,7 @@ impl KotlinExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1425,6 +1426,7 @@ impl KotlinExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1535,6 +1537,7 @@ impl KotlinExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } return; @@ -1566,6 +1569,7 @@ impl KotlinExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } diff --git a/crates/tracedecay-code-extraction/src/lua_extractor.rs b/crates/tracedecay-code-extraction/src/lua_extractor.rs index 871a8cbc00..59aab3e3ee 100644 --- a/crates/tracedecay-code-extraction/src/lua_extractor.rs +++ b/crates/tracedecay-code-extraction/src/lua_extractor.rs @@ -435,6 +435,7 @@ impl LuaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } // Recurse into the call for nested calls. diff --git a/crates/tracedecay-code-extraction/src/msbasic2_extractor.rs b/crates/tracedecay-code-extraction/src/msbasic2_extractor.rs index 30351064bf..c10eafdf5b 100644 --- a/crates/tracedecay-code-extraction/src/msbasic2_extractor.rs +++ b/crates/tracedecay-code-extraction/src/msbasic2_extractor.rs @@ -433,6 +433,7 @@ impl MsBasic2Extractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/nix_extractor.rs b/crates/tracedecay-code-extraction/src/nix_extractor.rs index 3212545afe..213efbac6f 100644 --- a/crates/tracedecay-code-extraction/src/nix_extractor.rs +++ b/crates/tracedecay-code-extraction/src/nix_extractor.rs @@ -463,6 +463,7 @@ impl NixExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !attr_cursor.goto_next_sibling() { @@ -784,6 +785,7 @@ impl NixExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -894,6 +896,7 @@ impl NixExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } diff --git a/crates/tracedecay-code-extraction/src/objc_extractor.rs b/crates/tracedecay-code-extraction/src/objc_extractor.rs index c33849caad..faa525a7fd 100644 --- a/crates/tracedecay-code-extraction/src/objc_extractor.rs +++ b/crates/tracedecay-code-extraction/src/objc_extractor.rs @@ -653,6 +653,7 @@ impl ObjcExtractor { line, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -733,6 +734,7 @@ impl ObjcExtractor { line: start_line, column: superclass.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -769,6 +771,7 @@ impl ObjcExtractor { line, column: type_id.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -1246,6 +1249,7 @@ impl ObjcExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1291,6 +1295,7 @@ impl ObjcExtractor { line: node.start_position().row as u32, column: node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/ocaml_extractor.rs b/crates/tracedecay-code-extraction/src/ocaml_extractor.rs index a6502bf1f6..2c558a1ff1 100644 --- a/crates/tracedecay-code-extraction/src/ocaml_extractor.rs +++ b/crates/tracedecay-code-extraction/src/ocaml_extractor.rs @@ -458,6 +458,7 @@ impl OcamlExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/pascal_extractor.rs b/crates/tracedecay-code-extraction/src/pascal_extractor.rs index a4f592afec..c4e596308c 100644 --- a/crates/tracedecay-code-extraction/src/pascal_extractor.rs +++ b/crates/tracedecay-code-extraction/src/pascal_extractor.rs @@ -364,6 +364,7 @@ impl PascalExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -483,6 +484,7 @@ impl PascalExtractor { line: parent_ref.start_position().row as u32, column: parent_ref.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1241,6 +1243,7 @@ impl PascalExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1335,6 +1338,7 @@ impl PascalExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1364,6 +1368,7 @@ impl PascalExtractor { line: fc.start_position().row as u32, column: fc.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/perl_extractor.rs b/crates/tracedecay-code-extraction/src/perl_extractor.rs index 6b9a471709..b7e676c302 100644 --- a/crates/tracedecay-code-extraction/src/perl_extractor.rs +++ b/crates/tracedecay-code-extraction/src/perl_extractor.rs @@ -524,6 +524,7 @@ impl PerlExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -543,6 +544,7 @@ impl PerlExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -568,6 +570,7 @@ impl PerlExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } else { state.unresolved_refs.push(UnresolvedRef { @@ -577,6 +580,7 @@ impl PerlExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } diff --git a/crates/tracedecay-code-extraction/src/php_extractor.rs b/crates/tracedecay-code-extraction/src/php_extractor.rs index 479071f53b..1a3831d212 100644 --- a/crates/tracedecay-code-extraction/src/php_extractor.rs +++ b/crates/tracedecay-code-extraction/src/php_extractor.rs @@ -825,6 +825,7 @@ impl PhpExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1033,6 +1034,7 @@ impl PhpExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1056,6 +1058,7 @@ impl PhpExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -1161,6 +1164,7 @@ impl PhpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1179,6 +1183,7 @@ impl PhpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1196,6 +1201,7 @@ impl PhpExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1303,6 +1309,7 @@ impl PhpExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/powershell_extractor.rs b/crates/tracedecay-code-extraction/src/powershell_extractor.rs index f0117a895a..85d2958722 100644 --- a/crates/tracedecay-code-extraction/src/powershell_extractor.rs +++ b/crates/tracedecay-code-extraction/src/powershell_extractor.rs @@ -370,6 +370,7 @@ impl PowerShellExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/python_extractor.rs b/crates/tracedecay-code-extraction/src/python_extractor.rs index 5afa90ba8d..5ae06368d1 100644 --- a/crates/tracedecay-code-extraction/src/python_extractor.rs +++ b/crates/tracedecay-code-extraction/src/python_extractor.rs @@ -591,6 +591,7 @@ impl PythonExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -672,6 +673,7 @@ impl PythonExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } "attribute" => { @@ -686,6 +688,7 @@ impl PythonExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } _ => {} @@ -819,6 +822,7 @@ impl PythonExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/qbasic_extractor.rs b/crates/tracedecay-code-extraction/src/qbasic_extractor.rs index ecbb44128d..4f10920bc7 100644 --- a/crates/tracedecay-code-extraction/src/qbasic_extractor.rs +++ b/crates/tracedecay-code-extraction/src/qbasic_extractor.rs @@ -583,6 +583,7 @@ impl QBasicExtractor { line: call_stmt.start_position().row as u32, column: call_stmt.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } diff --git a/crates/tracedecay-code-extraction/src/r_extractor.rs b/crates/tracedecay-code-extraction/src/r_extractor.rs index f2719646cc..6c93aebdcf 100644 --- a/crates/tracedecay-code-extraction/src/r_extractor.rs +++ b/crates/tracedecay-code-extraction/src/r_extractor.rs @@ -192,6 +192,7 @@ impl RExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_calls(state, child, fn_id); diff --git a/crates/tracedecay-code-extraction/src/ruby_extractor.rs b/crates/tracedecay-code-extraction/src/ruby_extractor.rs index 3deceaed4e..b14824cd03 100644 --- a/crates/tracedecay-code-extraction/src/ruby_extractor.rs +++ b/crates/tracedecay-code-extraction/src/ruby_extractor.rs @@ -501,6 +501,7 @@ impl RubyExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } else { @@ -525,6 +526,7 @@ impl RubyExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -640,6 +642,7 @@ impl RubyExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index d7be2b1c3d..608be35c76 100644 --- a/crates/tracedecay-code-extraction/src/rust_extractor.rs +++ b/crates/tracedecay-code-extraction/src/rust_extractor.rs @@ -17,7 +17,7 @@ use crate::extraction_artifact::{ use crate::traversal::find_direct_child_by_kind; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, SourceSpan, - UnresolvedRef, Visibility, generate_node_id, + UnmodeledImportShapeV1, UnresolvedRef, Visibility, generate_node_id, }; /// Extracts code graph nodes and edges from Rust source files using tree-sitter. @@ -28,14 +28,28 @@ struct ShadowedCallNames { names: Vec, } -/// The names one block's `use` declarations bind, with the path each names. -struct BlockUseScope { +/// The names one block's or inline module's `use` declarations bind. +struct UseScope { start: Point, end: Point, - /// `None` binds the name to an item this file may itself define. + /// Named bindings with the path each names; `None` binds the name to an + /// item this file may itself define. paths: BTreeMap>, + /// Project modules outside this file that the scope glob-imports. + globs: Vec, + shape: UnmodeledImportShapeV1, } +/// Call heads a glob import cannot supply in practice: path roots, the +/// prelude's constructors and containers, and primitive types. +// ponytail: a fixed list, not name resolution; a project module that exports +// one of these names through a glob keeps the name unqualified. +const GLOB_EXEMPT_CALL_HEADS: &[&str] = &[ + "crate", "self", "super", "Self", "std", "core", "alloc", "Some", "None", "Ok", "Err", "Box", + "Vec", "String", "Option", "Result", "Default", "drop", "bool", "char", "str", "u8", "u16", + "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64", "i128", "isize", "f32", "f64", +]; + /// 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 @@ -623,6 +637,7 @@ impl RustExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -741,6 +756,7 @@ impl RustExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -833,6 +849,7 @@ impl RustExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if top_level_argument.is_some() { @@ -854,6 +871,7 @@ impl RustExtractor { line: import.start_line, column: import.start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1358,6 +1376,7 @@ impl RustExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } let Some(body) = find_direct_child_by_kind(node, "token_tree") else { @@ -1865,6 +1884,7 @@ impl RustExtractor { line: position.row as u32, column: position.column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); // The simple name of a dotted call is not itself a call. // `items.push()` must not bind a same-file `fn push`. @@ -1881,6 +1901,7 @@ impl RustExtractor { line: position.row as u32, column: position.column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1901,6 +1922,7 @@ impl RustExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id, receivers); } @@ -2343,19 +2365,50 @@ 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 + /// blocks included, and shadows a module-scope import of the same name; + /// a `use` in an inline `mod` body binds for every item of that module. + /// Import rows are file-scoped, so a call through such a `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. + /// + /// A glob names every public item of a module this file cannot see, and + /// Rust resolves it ahead of outer-scope names. A call it could supply + /// is rewritten into the one project module the scope glob-imports and + /// marked with the glob's shape, so the seal keeps it as a disclosed gap + /// if that module does not define the name. fn qualify_block_scoped_uses( state: &mut ExtractionState<'_>, function: TsNode<'_>, fn_node_id: &str, ) { + let depth = Self::ancestors(function) + .filter(|node| node.kind() == "mod_item") + .count(); let mut scopes = Vec::new(); - Self::collect_block_use_scopes(state, function, function, &mut scopes); + if let Some(module_body) = Self::ancestors(function) + .find(|node| node.kind() == "mod_item") + .and_then(|module| module.child_by_field_name("body")) + { + Self::push_use_scope( + state, + function, + module_body, + depth, + UnmodeledImportShapeV1::InlineModuleGlob, + &mut scopes, + ); + } + let mut macro_sites = Vec::new(); + Self::collect_block_use_scopes( + state, + function, + function, + depth, + &mut scopes, + &mut macro_sites, + ); if scopes.is_empty() { return; } @@ -2372,27 +2425,67 @@ impl RustExtractor { row: reference.line as usize, column: reference.column as usize, }; - // Pre-order: an inner block follows the block that contains it. - let declared = scopes + let glob_candidate = reference.reference_kind == EdgeKind::Calls + && !macro_sites.contains(&site) + && !GLOB_EXEMPT_CALL_HEADS.contains(&head) + && !state.root_modules.contains_key(head); + // Pre-order: the module scope comes first and an inner block + // follows the block that contains it. + for scope in 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()..]); + { + if let Some(declared) = scope.paths.get(head) { + if let Some(path) = declared { + reference.reference_name = + format!("{path}{}", &reference.reference_name[head.len()..]); + } + break; + } + if glob_candidate && !scope.globs.is_empty() { + if let [module] = scope.globs.as_slice() { + reference.reference_name = + format!("{module}::{}", reference.reference_name); + } + reference.unmodeled_import = Some(scope.shape); + break; + } } } } - /// 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 { + /// Rewrites a `use` path written `depth` inline modules below the file's + /// own module to the path the file's module would write, or `None` when + /// it names an item inside those inline modules. + fn file_level_use_path(path: &str, depth: usize) -> Option { + if depth == 0 { + return Some(path.to_owned()); + } + let segments = path.split("::").collect::>(); + let supers = segments + .iter() + .take_while(|segment| **segment == "super") + .count(); + match segments.first() { + Some(&"self") => None, + Some(&"super") if supers < depth || supers == segments.len() => None, + Some(&"super") => { + let rest = segments[supers..].join("::"); + Some(if supers == depth { + format!("self::{rest}") + } else { + format!("{}{rest}", "super::".repeat(supers - depth)) + }) + } + _ => Some(path.to_owned()), + } + } + + /// Whether a file-level `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; `super::` always does. + fn 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") @@ -2401,9 +2494,6 @@ impl RustExtractor { 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 { @@ -2429,48 +2519,89 @@ impl RustExtractor { std::iter::successors(node.parent(), TsNode::parent) } + /// Records the `use` declarations directly inside `container` (a block + /// or an inline module body) as one scope. + fn push_use_scope( + state: &mut ExtractionState<'_>, + function: TsNode<'_>, + container: TsNode<'_>, + depth: usize, + shape: UnmodeledImportShapeV1, + scopes: &mut Vec, + ) { + let mut paths = BTreeMap::new(); + let mut globs = Vec::new(); + let mut cursor = container.walk(); + for child in container.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 import.is_glob { + let Some(module) = Self::file_level_use_path(&import.module_specifier, depth) + else { + continue; + }; + let project = + matches!(module.split("::").next(), Some("crate" | "self" | "super")); + if project && Self::use_path_leaves_file(state, function, &module) { + globs.push(module); + } + } else if let (Some(local), Some(imported)) = + (import.local_name, import.imported_name) + { + let path = Self::file_level_use_path( + &format!("{}::{imported}", import.module_specifier), + depth, + ) + .filter(|path| Self::use_path_leaves_file(state, function, path)); + paths.insert(local, path); + } + } + } + if !paths.is_empty() || !globs.is_empty() { + scopes.push(UseScope { + start: container.start_position(), + end: container.end_position(), + paths, + globs, + shape, + }); + } + } + fn collect_block_use_scopes( state: &mut ExtractionState<'_>, node: TsNode<'_>, function: TsNode<'_>, - scopes: &mut Vec, + depth: usize, + scopes: &mut Vec, + macro_sites: &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, - }); - } + match node.kind() { + "block" => Self::push_use_scope( + state, + function, + node, + depth, + UnmodeledImportShapeV1::BlockGlob, + scopes, + ), + "macro_invocation" => macro_sites.push(node.start_position()), + _ => {} } let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - Self::collect_block_use_scopes(state, child, function, scopes); + Self::collect_block_use_scopes(state, child, function, depth, scopes, macro_sites); } } @@ -2564,6 +2695,7 @@ impl RustExtractor { line: cur.start_position().row as u32, column: cur.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_calls_in_token_tree(state, children[i + 1], fn_node_id); i += 2; // skip the token_tree we just handled @@ -2624,6 +2756,7 @@ impl RustExtractor { line, column: attr_node.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -2689,6 +2822,7 @@ impl RustExtractor { line: n.start_position().row as u32, column: n.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); return; } @@ -2700,6 +2834,7 @@ impl RustExtractor { line: n.start_position().row as u32, column: n.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if cursor.goto_first_child() { @@ -2793,6 +2928,7 @@ impl RustExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/scala_extractor.rs b/crates/tracedecay-code-extraction/src/scala_extractor.rs index 21e5852965..d3f678afc7 100644 --- a/crates/tracedecay-code-extraction/src/scala_extractor.rs +++ b/crates/tracedecay-code-extraction/src/scala_extractor.rs @@ -291,6 +291,7 @@ impl ScalaExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1153,6 +1154,7 @@ impl ScalaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1331,6 +1333,7 @@ impl ScalaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } @@ -1343,6 +1346,7 @@ impl ScalaExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites(state, child, fn_node_id); } @@ -1469,6 +1473,7 @@ impl ScalaExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/swift_extractor.rs b/crates/tracedecay-code-extraction/src/swift_extractor.rs index 355c901589..211609617e 100644 --- a/crates/tracedecay-code-extraction/src/swift_extractor.rs +++ b/crates/tracedecay-code-extraction/src/swift_extractor.rs @@ -1060,6 +1060,7 @@ impl SwiftExtractor { line, column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1198,6 +1199,7 @@ impl SwiftExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1352,6 +1354,7 @@ impl SwiftExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/typescript_extractor.rs b/crates/tracedecay-code-extraction/src/typescript_extractor.rs index db20098449..89ddb8906d 100644 --- a/crates/tracedecay-code-extraction/src/typescript_extractor.rs +++ b/crates/tracedecay-code-extraction/src/typescript_extractor.rs @@ -882,6 +882,7 @@ impl TypeScriptExtractor { line: parent.start_position().row as u32, column: parent.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !cursor.goto_next_sibling() { @@ -1326,6 +1327,7 @@ impl TypeScriptExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1343,6 +1345,7 @@ impl TypeScriptExtractor { line: iface.start_position().row as u32, column: iface.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } if !inner.goto_next_sibling() { @@ -1391,6 +1394,7 @@ impl TypeScriptExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); @@ -1560,6 +1564,7 @@ impl TypeScriptExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } else { diff --git a/crates/tracedecay-code-extraction/src/typescript_extractor/imports.rs b/crates/tracedecay-code-extraction/src/typescript_extractor/imports.rs index 3ce527ee86..a8441c7a58 100644 --- a/crates/tracedecay-code-extraction/src/typescript_extractor/imports.rs +++ b/crates/tracedecay-code-extraction/src/typescript_extractor/imports.rs @@ -71,6 +71,7 @@ pub(super) fn visit_import(state: &mut ExtractionState<'_>, node: TsNode<'_>) { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); let Some(module_specifier) = module_specifier else { diff --git a/crates/tracedecay-code-extraction/src/vbnet_extractor.rs b/crates/tracedecay-code-extraction/src/vbnet_extractor.rs index bc3f14a9ad..206a446887 100644 --- a/crates/tracedecay-code-extraction/src/vbnet_extractor.rs +++ b/crates/tracedecay-code-extraction/src/vbnet_extractor.rs @@ -350,6 +350,7 @@ impl VbNetExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); } @@ -1214,6 +1215,7 @@ impl VbNetExtractor { line: base_line + i as u32, column: 0, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } else if let Some(iface_list) = trimmed.strip_prefix("Implements ") { @@ -1228,6 +1230,7 @@ impl VbNetExtractor { line: base_line + i as u32, column: 0, file_path: state.file_path.clone(), + unmodeled_import: None, }); } } @@ -1318,6 +1321,7 @@ impl VbNetExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); Self::extract_call_sites_from_children(state, child, fn_node_id); } @@ -1452,6 +1456,7 @@ impl VbNetExtractor { line: start_line, column: start_column, file_path: state.file_path.clone(), + unmodeled_import: None, }); state.edges.push(Edge { diff --git a/crates/tracedecay-code-extraction/src/wgsl_extractor.rs b/crates/tracedecay-code-extraction/src/wgsl_extractor.rs index f1d11c6733..880019a5a1 100644 --- a/crates/tracedecay-code-extraction/src/wgsl_extractor.rs +++ b/crates/tracedecay-code-extraction/src/wgsl_extractor.rs @@ -473,6 +473,7 @@ impl WgslExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/src/zig_extractor.rs b/crates/tracedecay-code-extraction/src/zig_extractor.rs index 4d72f6c8a1..968213dd4a 100644 --- a/crates/tracedecay-code-extraction/src/zig_extractor.rs +++ b/crates/tracedecay-code-extraction/src/zig_extractor.rs @@ -861,6 +861,7 @@ impl ZigExtractor { line: child.start_position().row as u32, column: child.start_position().column as u32, file_path: state.file_path.clone(), + unmodeled_import: None, }); } Self::extract_call_sites(state, child, fn_node_id); diff --git a/crates/tracedecay-code-extraction/tests/main/rust.rs b/crates/tracedecay-code-extraction/tests/main/rust.rs index bf44bc7019..5ade7ef466 100644 --- a/crates/tracedecay-code-extraction/tests/main/rust.rs +++ b/crates/tracedecay-code-extraction/tests/main/rust.rs @@ -1500,3 +1500,113 @@ cfg_rt! { pub fn plain() {} } .collect::>(); assert_eq!(calls, vec![("handler", 0)]); } + +/// Calls through a `use` the extractor sees only inside one scope, with the +/// name each call carries to the resolver and the unmodeled-import shape it +/// is marked with. +fn scoped_calls(source: &str, function: &str) -> Vec<(String, Option)> { + let result = RustExtractor.extract_artifact("src/lib.rs", source).result; + assert!(result.errors.is_empty(), "{:?}", result.errors); + let caller = result + .nodes + .iter() + .find(|node| node.name == function && node.kind == NodeKind::Function) + .unwrap_or_else(|| panic!("function {function}")); + result + .unresolved_refs + .iter() + .filter(|reference| { + reference.reference_kind == EdgeKind::Calls && reference.from_node_id == caller.id + }) + .map(|reference| (reference.reference_name.clone(), reference.unmodeled_import)) + .collect() +} + +/// A `use` in an inline `mod` body binds for that module's functions, with +/// `self::`/`super::` read against the inline module, not the file. +#[test] +fn inline_module_uses_qualify_calls_against_the_inline_module() { + let source = "\ +mod m; +fn local() {} +mod inner { + use crate::m::h; + use super::m::k; + use super::local; + use self::deeper::d; + mod deeper { pub fn d() {} } + pub fn inline_caller() { h(); k(); local(); d(); } +} +pub fn outer() { h(); } +"; + assert_eq!( + scoped_calls(source, "inline_caller"), + vec![ + ("crate::m::h".to_owned(), None), + ("self::m::k".to_owned(), None), + ("local".to_owned(), None), + ("d".to_owned(), None), + ] + ); + assert_eq!(scoped_calls(source, "outer"), vec![("h".to_owned(), None)]); +} + +/// A glob `use` in a block or inline module qualifies the calls it could +/// supply into its one project module and marks them with its shape; a +/// named `use` still wins, and an external glob or a prelude name is left +/// alone. +#[test] +fn glob_uses_qualify_and_mark_the_calls_they_could_supply() { + let source = "\ +mod m; +mod n; +pub fn glob_caller() { + use crate::m::*; + use crate::n::named; + g(); + named(); + Some(1); + println!(\"{}\", 1); +} +pub fn two_globs() { + use crate::m::*; + use crate::n::*; + g(); +} +pub fn external_glob() { + use std::collections::*; + g(); +} +mod inner { + use crate::m::*; + pub fn inline_caller() { g(); } +} +"; + assert_eq!( + scoped_calls(source, "glob_caller"), + vec![ + ( + "crate::m::g".to_owned(), + Some(UnmodeledImportShapeV1::BlockGlob) + ), + ("crate::n::named".to_owned(), None), + ("Some".to_owned(), None), + ("println".to_owned(), None), + ] + ); + assert_eq!( + scoped_calls(source, "two_globs"), + vec![("g".to_owned(), Some(UnmodeledImportShapeV1::BlockGlob))] + ); + assert_eq!( + scoped_calls(source, "external_glob"), + vec![("g".to_owned(), None)] + ); + assert_eq!( + scoped_calls(source, "inline_caller"), + vec![( + "crate::m::g".to_owned(), + Some(UnmodeledImportShapeV1::InlineModuleGlob) + )] + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs index 9f79ba4690..fc749f72bf 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs @@ -3280,12 +3280,12 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { } } } - let unsupported = match prepared.reader.has_unresolved_callers( + let unresolved = match prepared.reader.unresolved_caller_gaps( &traversed, request.scope.path_prefix.as_deref(), Arc::clone(&cancellation), ) { - Ok(unsupported) => unsupported, + Ok(unresolved) => unresolved, Err(_) => { return unavailable_for_generation( query_finished_at(), @@ -3307,17 +3307,25 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { match outcome { RetrievalPortOutcome::Completed(mut evidence) | RetrievalPortOutcome::Partial(mut evidence) - if unsupported => + if !unresolved.is_empty() => { evidence.coverage.completeness = CoverageCompleteness::Partial; for domain in &mut evidence.coverage.domains { domain.completeness = CoverageCompleteness::Partial; } - evidence.omissions.push(Omission { + let reasons = unresolved + .exact_target_unavailable + .then_some(OmissionReason::Unsupported) + .into_iter() + .chain( + (!unresolved.unmodeled_imports.is_empty()) + .then_some(OmissionReason::ImportUnmodeled), + ); + evidence.omissions.extend(reasons.map(|reason| Omission { domain: EvidenceDomain::Symbol, count: 1, - reason: OmissionReason::Unsupported, - }); + reason, + })); RetrievalPortOutcome::Partial(evidence) } outcome => outcome, 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 41cf65df6b..c1c9105a45 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.v15") + Some("extractor.rust.v16") ); } diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 8f44de821d..114b7b81d9 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -2396,6 +2396,7 @@ fn cross_file_reference_candidate( kind, evidence_span: reference_evidence_span(source, offsets, references_by_site, reference) .unwrap_or(from.span), + unmodeled_import: reference.unmodeled_import, }) } @@ -4881,6 +4882,7 @@ pub fn real_symbol() {} line: 0, column: site, file_path: "src/lib.rs".to_owned(), + unmodeled_import: None, }, UnresolvedRef { from_node_id: caller.node_id.clone(), @@ -4889,6 +4891,7 @@ pub fn real_symbol() {} line: 0, column: site, file_path: "src/lib.rs".to_owned(), + unmodeled_import: None, }, ]; @@ -4960,6 +4963,7 @@ pub fn real_symbol() {} line: 3, column: 5, file_path: "src/lib.rs".to_owned(), + unmodeled_import: None, }; let trait_occurrence = trait_target.occurrence.clone(); @@ -5094,6 +5098,7 @@ pub fn real_symbol() {} line: 2, column: 24, file_path: "src/settings.ts".to_owned(), + unmodeled_import: None, }; let right_occurrence = right.occurrence.clone(); diff --git a/crates/tracedecay-code-index/src/chunks/artifacts.rs b/crates/tracedecay-code-index/src/chunks/artifacts.rs index d6dbe66ce7..77c025ccc8 100644 --- a/crates/tracedecay-code-index/src/chunks/artifacts.rs +++ b/crates/tracedecay-code-index/src/chunks/artifacts.rs @@ -10,7 +10,7 @@ use tracedecay_code_extraction::{ }; use tracedecay_domain::{ CanonicalRelationEdgeV1, CodeGenerationId, EdgeKind, FileOccurrenceId, ManifestDigest, - RelationEdgeKindV1, SourceSpan, SymbolOccurrenceId, + RelationEdgeKindV1, SourceSpan, SymbolOccurrenceId, UnmodeledImportShapeV1, }; use super::{ChunkingFailureV1, CodeFileChunksV1, canonical_edge_key, symbol_occurrence_id}; @@ -155,6 +155,10 @@ pub struct CodeIndexUnresolvedReferenceV1 { /// Extraction-attested reference token span when the parser coordinates /// identify it; otherwise the referencing symbol's enclosing span. pub evidence_span: SourceSpan, + /// The import shape the extractor could not model for this call site. + /// While the reference stays unbound it is a disclosed caller gap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unmodeled_import: Option, } impl CodeIndexUnresolvedReferenceV1 { diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index 9dd130886f..41185cb9a0 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -762,12 +762,13 @@ mod tests { // 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; v15 qualifies - // calls through block-scoped `use` declarations. The revision is + // calls through block-scoped `use` declarations; v16 qualifies calls + // through inline-module and glob `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.v15"); + assert_eq!(descriptor.extractor_revision.as_str(), "extractor.rust.v16"); assert_eq!( extraction.batch().rows_digest.as_str(), - "sha256:c7f72d389d8521307a60ca4373df3f9551c15e6527b1b5f68b34e80db87b38af" + "sha256:c65751ab695fe3515b4f3ab0dd32fc119bfab36ea2cc24f60e6b32dd3491f991" ); } diff --git a/crates/tracedecay-code-index/src/graph_projection.rs b/crates/tracedecay-code-index/src/graph_projection.rs index 10459c3c3c..31e867d3fd 100644 --- a/crates/tracedecay-code-index/src/graph_projection.rs +++ b/crates/tracedecay-code-index/src/graph_projection.rs @@ -45,7 +45,7 @@ pub use self::interactive::{ CodeGraphReadCostMeter, CodeGraphRelationKeyV1, CodeGraphRelationKeysV1, CodeGraphSemanticEdgeV1, CodeGraphSymbolDegreesV1, CodeGraphSymbolPageV1, CodeGraphSymbolPredicate, CodeGraphSymbolRefV1, CodeGraphSymbolSearchPageV1, - CodeGraphSymbolSummaryV1, + CodeGraphSymbolSummaryV1, UnresolvedCallerGapsV1, }; use self::schema::{ SYMBOL_LABEL, SYMBOL_RECORD_PROPERTY, deserialize_property, has_label, record_property, diff --git a/crates/tracedecay-code-index/src/graph_projection/builder.rs b/crates/tracedecay-code-index/src/graph_projection/builder.rs index 1906e87c81..2460bc519c 100644 --- a/crates/tracedecay-code-index/src/graph_projection/builder.rs +++ b/crates/tracedecay-code-index/src/graph_projection/builder.rs @@ -156,8 +156,9 @@ pub fn build_sealed_code_graph_rows( /// symbols, derived from every retained reference and edge of a generation. /// /// A dotted Rust-style call stays a limitation unless the canonical resolver -/// bound its exact receiver site; TypeScript member calls are decided by the -/// module resolver and arrive in `typescript_unresolved`. +/// bound its exact receiver site, and so does a call the extractor marked as +/// sitting under an unmodeled import; TypeScript member calls are decided by +/// the module resolver and arrive in `typescript_unresolved`. pub(crate) fn unresolved_call_limitations<'a>( references: &[(&str, &'a CodeIndexUnresolvedReferenceV1)], edges: impl Iterator, @@ -178,8 +179,12 @@ pub(crate) fn unresolved_call_limitations<'a>( } } let mut resolved_sites = BTreeMap::new(); + let mut bound_call_sites = BTreeSet::new(); for edge in edges { check()?; + if edge.kind == RelationEdgeKindV1::Calls { + bound_call_sites.insert((&edge.from_occurrence, edge.evidence_span)); + } if edge.kind == RelationEdgeKindV1::Calls && edge.authority == EdgeAuthorityV1::NameResolved { let site = (&edge.from_occurrence, edge.evidence_span); @@ -221,6 +226,22 @@ pub(crate) fn unresolved_call_limitations<'a>( && !resolved_method_token { unresolved_calls.push(reference.clone()); + continue; + } + // A call under an import the extractor could not model stays a gap + // unless an edge binds its exact callee token. + if reference.kind == RelationEdgeKindV1::Calls && reference.unmodeled_import.is_some() { + let callee = reference + .reference_name + .rsplit("::") + .next() + .unwrap_or_default(); + let bound = reference.evidence_span.len() == callee.len() as u64 + && bound_call_sites + .contains(&(&reference.from_occurrence, reference.evidence_span)); + if !bound { + unresolved_calls.push(reference.clone()); + } } } // A TypeScript call whose import names project code the seal could not diff --git a/crates/tracedecay-code-index/src/graph_projection/interactive.rs b/crates/tracedecay-code-index/src/graph_projection/interactive.rs index fed28fd46e..a3efc82970 100644 --- a/crates/tracedecay-code-index/src/graph_projection/interactive.rs +++ b/crates/tracedecay-code-index/src/graph_projection/interactive.rs @@ -52,7 +52,7 @@ pub use self::models::{ CodeGraphImpactedSymbolV1, CodeGraphPathSearchV1, CodeGraphRankedNeighborsV1, CodeGraphRankedSymbolV1, CodeGraphRelationKeyV1, CodeGraphRelationKeysV1, CodeGraphSemanticEdgeV1, CodeGraphSymbolDegreesV1, CodeGraphSymbolPageV1, CodeGraphSymbolRefV1, - CodeGraphSymbolSearchPageV1, CodeGraphSymbolSummaryV1, + CodeGraphSymbolSearchPageV1, CodeGraphSymbolSummaryV1, UnresolvedCallerGapsV1, }; pub type CodeGraphSymbolPredicate<'a> = dyn Fn( @@ -332,7 +332,7 @@ impl CodeGraphInteractiveReader { )) } - /// Whether unresolved receiver sites can name one of the queried methods. + /// Whether unresolved call sites can name one of the queried methods. /// Matching a member name establishes uncertainty only, never a target edge. pub fn has_unresolved_callers( &self, @@ -340,9 +340,23 @@ impl CodeGraphInteractiveReader { scope_prefix: Option<&str>, request_cancellation: Arc, ) -> Result { + self.unresolved_caller_gaps(targets, scope_prefix, request_cancellation) + .map(|gaps| !gaps.is_empty()) + } + + /// The kinds of unresolved call site that can name one of the queried + /// methods: receiver or import calls without exact target evidence, and + /// calls under a `use` shape the extractor could not model. + pub fn unresolved_caller_gaps( + &self, + targets: &[SymbolOccurrenceId], + scope_prefix: Option<&str>, + request_cancellation: Arc, + ) -> Result { let cancellation = self.read_cancellation(request_cancellation)?; let catalog = self.catalog(Arc::clone(&cancellation))?; let mut methods = BTreeSet::new(); + let mut gaps = UnresolvedCallerGapsV1::default(); for target in targets { catalog::check_cancelled(cancellation.as_ref())?; let metadata = catalog @@ -364,9 +378,8 @@ impl CodeGraphInteractiveReader { .flatten() { catalog::check_cancelled(cancellation.as_ref())?; - let path = catalog - .symbols - .get(source) + let symbol = catalog.symbols.get(source); + let path = symbol .and_then(|symbol| symbol.binding.as_ref()) .and_then(|binding| binding.logical_path.as_deref()) .ok_or_else(|| { @@ -374,12 +387,27 @@ impl CodeGraphInteractiveReader { "unresolved caller source has no bound logical path".to_owned(), ) })?; - if repository_path_matches_scope(path, scope_prefix) { - return Ok(true); + if !repository_path_matches_scope(path, scope_prefix) { + continue; + } + for call in symbol + .into_iter() + .flat_map(|symbol| &symbol.unresolved_calls) + { + if models::unresolved_callee_name(&call.reference_name) != metadata.simple_name + { + continue; + } + match call.unmodeled_import { + Some(shape) => { + gaps.unmodeled_imports.insert(shape); + } + None => gaps.exact_target_unavailable = true, + } } } } - Ok(false) + Ok(gaps) } /// Lists the symbols bound to one file occurrence. diff --git a/crates/tracedecay-code-index/src/graph_projection/interactive/models.rs b/crates/tracedecay-code-index/src/graph_projection/interactive/models.rs index 096c552f19..aa8d18d524 100644 --- a/crates/tracedecay-code-index/src/graph_projection/interactive/models.rs +++ b/crates/tracedecay-code-index/src/graph_projection/interactive/models.rs @@ -1,12 +1,12 @@ //! Public interactive graph results and the generation-pinned lookup catalog. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use serde::{Serialize, Serializer}; use tracedecay_domain::{ CanonicalRelationEdgeV1, FileOccurrenceId, RelationEdgeKindV1, SanitizedCodeFileV1, - SymbolOccurrenceId, + SymbolOccurrenceId, UnmodeledImportShapeV1, }; use tracedecay_graph_db::GraphEntityId; @@ -189,6 +189,21 @@ pub struct CodeGraphPathSearchV1 { pub complete: bool, } +/// Unresolved call sites that can name a queried callee, by kind of gap. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UnresolvedCallerGapsV1 { + /// A receiver or import call whose exact target the seal could not bind. + pub exact_target_unavailable: bool, + /// Calls under `use` shapes the extractor could not model. + pub unmodeled_imports: BTreeSet, +} + +impl UnresolvedCallerGapsV1 { + pub fn is_empty(&self) -> bool { + !self.exact_target_unavailable && self.unmodeled_imports.is_empty() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct CatalogSymbol { pub(super) binding: Option, @@ -387,15 +402,12 @@ impl InteractiveCatalog { pub(super) fn insert(&mut self, occurrence: SymbolOccurrenceId, record: CatalogSymbol) { for reference in &record.unresolved_calls { - if let Some(member) = reference.reference_name.rsplit('.').next() { - let method = member.split("::").next().unwrap_or(member); - let sources = self - .unresolved_call_sources - .entry(method.to_owned()) - .or_default(); - if sources.last() != Some(&occurrence) { - sources.push(occurrence.clone()); - } + let sources = self + .unresolved_call_sources + .entry(unresolved_callee_name(&reference.reference_name).to_owned()) + .or_default(); + if sources.last() != Some(&occurrence) { + sources.push(occurrence.clone()); } } if let Some(metadata) = &record.metadata { @@ -454,3 +466,12 @@ fn derived_simple_name(qualified_name: &str) -> String { let tail = tail.rsplit('.').next().unwrap_or(tail); tail.to_lowercase() } + +/// The callee name an unresolved call could bind: the member of a dotted +/// receiver call (without turbofish), otherwise the last path segment. +pub(super) fn unresolved_callee_name(reference_name: &str) -> &str { + match reference_name.rsplit_once('.') { + Some((_, member)) => member.split("::").next().unwrap_or(member), + None => reference_name.rsplit("::").next().unwrap_or(reference_name), + } +} diff --git a/crates/tracedecay-code-index/src/languages.rs b/crates/tracedecay-code-index/src/languages.rs index 6d3c3f6332..64c548dc49 100644 --- a/crates/tracedecay-code-index/src/languages.rs +++ b/crates/tracedecay-code-index/src/languages.rs @@ -226,6 +226,9 @@ impl StaticLanguageRegistry { // 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. + // Rust v16 does the same for a `use` in an inline `mod` body and + // rewrites a call under a block or inline-module glob into the + // glob's module, marking it so an unbound one stays a caller gap. // 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. @@ -240,7 +243,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" => 15, + "rust" => 16, "typescript" => 9, "protobuf" => 7, "sql" => 6, @@ -445,7 +448,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.v15"); + assert_eq!(rust.extractor_revision.as_str(), "extractor.rust.v16"); 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 7b9e7907cc..b93a73dabf 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.v15" + "extractor.rust.v16" ); 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.v15" + "extractor.rust.v16" ); 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 42c0af45d6..545b8bb5af 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 @@ -4,6 +4,7 @@ use serde_json::Value; use tracedecay_code_extraction::{ImportModuleKindV1, ImportNamespaceV1}; use tracedecay_code_index::{ chunks::{CodeIndexImportEvidenceV1, content_digest}, + graph_projection::UnresolvedCallerGapsV1, production::{ CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, @@ -13,8 +14,9 @@ use tracedecay_code_index::{ use tracedecay_domain::{ EdgeAuthorityV1, FileOccurrenceId, LanguageId, RelationEdgeKindV1, SanitizationReceiptId, SanitizedCodeFileV1, SensitivityLevelV1, SnapshotFileDispositionV1, SourceSpan, - SymbolOccurrenceId, + SymbolOccurrenceId, UnmodeledImportShapeV1, }; +use tracedecay_graph_db::NeverCancelled; use crate::{ production_orchestration::{ @@ -1321,3 +1323,65 @@ fn rust_calls_bind_through_a_use_declared_in_the_calling_block() { ["crates/app/src/lib.rs::same_file"] ); } + +#[test] +fn rust_calls_bind_through_block_globs_and_inline_module_uses() { + let generation = published_rust_workspace(&[ + ( + "file.glob-use.lib", + "crates/app/src/lib.rs", + "mod m;\nmod n;\nuse crate::n::only_in_n;\n\ + pub fn glob_caller() {\n use crate::m::*;\n g();\n}\n\ + pub fn glob_miss() {\n use crate::m::*;\n only_in_n();\n}\n\ + mod inner {\n use crate::m::h;\n pub fn inline_caller() {\n h();\n }\n}\n\ + mod globbed {\n use crate::m::*;\n pub fn inline_glob_caller() {\n g();\n }\n}\n", + ), + ( + "file.glob-use.m", + "crates/app/src/m.rs", + "pub fn g() {}\npub fn h() {}\n", + ), + ( + "file.glob-use.n", + "crates/app/src/n.rs", + "pub fn only_in_n() {}\n", + ), + ]); + let g = symbol_occurrence(&generation, "crates/app/src/m.rs::g"); + let h = symbol_occurrence(&generation, "crates/app/src/m.rs::h"); + let only_in_n = symbol_occurrence(&generation, "crates/app/src/n.rs::only_in_n"); + + assert_eq!( + resolved_callers(&generation, &g), + [ + "crates/app/src/lib.rs::glob_caller", + "crates/app/src/lib.rs::globbed::inline_glob_caller" + ] + ); + assert_eq!( + resolved_callers(&generation, &h), + ["crates/app/src/lib.rs::inner::inline_caller"] + ); + + // The block glob's module does not define `only_in_n`, and whether Rust + // then reaches the module-scope import is not modeled: the call stays a + // disclosed gap naming the glob, never a silently complete answer. + assert_eq!( + resolved_callers(&generation, &only_in_n), + Vec::<&str>::new() + ); + let reader = crate::typescript_module_resolution::reader(&generation); + let gaps = |target: &SymbolOccurrenceId| { + reader + .unresolved_caller_gaps(std::slice::from_ref(target), None, Arc::new(NeverCancelled)) + .expect("unresolved caller gaps") + }; + assert_eq!( + gaps(&only_in_n), + UnresolvedCallerGapsV1 { + exact_target_unavailable: false, + unmodeled_imports: [UnmodeledImportShapeV1::BlockGlob].into(), + } + ); + assert_eq!(gaps(&g), UnresolvedCallerGapsV1::default()); +} 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 ec8206deed..8cfc6a0a94 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:824e5692181d785e3722fc0a4465b2d21aaac106bcc45f684bd0350693558149"; + "sha256:5231a6b5fe0e651e1dd719adfe78c9aa07726ee8bd39d69489afd25d8514bbc0"; const PARTITIONED_FORMAT_SEGMENTS: &[(&str, u64)] = &[ ( - "sha256:810c283c12aeeae815061185f2936982b31666488763adbf3b1212ca68a5b2a9", - 1_734, + "sha256:913800b16cc183678de1b105f1183d1f15b63a7a83a9a188e3a05594324b5cb9", + 1_732, ), ( - "sha256:ff6dc0319b2f147a5fb56aa00248462133db05f78252309f0a1dfdfac051335b", - 1_263, + "sha256:bfcde81d4ac721f90744bed310753440740b06fc7fbda681a14db3ad39513585", + 1_262, ), ( - "sha256:cde12a92d48833fd210770a1e3da3fab56015801ca376cf920bbcc8441fc3d31", - 1_310, + "sha256:081a1ecfc42c9772f540fe3c4332a6da7725d3016222e64e59a80baf68eb564b", + 1_311, ), ( - "sha256:f2fd9edd26bb0724336d5e288fd9d33beed496a8ef543fbffbd76bdcd77c9d5d", + "sha256:bcb5b905c2cd7853082a47d4925bb2c591b123406c1fda245d5fb3f64df02c48", 2_837, ), ]; diff --git a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs index 215bb7de22..35a8e5a60a 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs @@ -176,7 +176,7 @@ fn callers_with_authority( callers } -fn reader(generation: &CodeIndexPublishedGenerationV1) -> CodeGraphInteractiveReader { +pub(crate) fn reader(generation: &CodeIndexPublishedGenerationV1) -> CodeGraphInteractiveReader { let manifest = PartitionedSealV1::of(generation).graph_manifest( code_graph_projection_identity( GraphNamespace::new("code-graph-ts-monorepo").expect("graph namespace"), diff --git a/crates/tracedecay-contracts/src/result/evidence.rs b/crates/tracedecay-contracts/src/result/evidence.rs index ae7955827c..1dafee119f 100644 --- a/crates/tracedecay-contracts/src/result/evidence.rs +++ b/crates/tracedecay-contracts/src/result/evidence.rs @@ -348,6 +348,10 @@ pub enum OmissionReason { /// Evidence came from a macro body that was not expanded, so what the /// expansion defines or calls is not covered. MacroBodyUnparsed, + /// A caller sits under a `use` whose binding the index could not model + /// (a glob in a block or inline module), so whether it calls the target + /// is not covered. + ImportUnmodeled, /// The continuation cursor can no longer be redeemed (its lifetime ended /// or the snapshot it pages is gone); restart the request without it. CursorExpired, diff --git a/crates/tracedecay-contracts/src/retrieval/symbol_graph.rs b/crates/tracedecay-contracts/src/retrieval/symbol_graph.rs index 5c2f11e6df..8bebc4921f 100644 --- a/crates/tracedecay-contracts/src/retrieval/symbol_graph.rs +++ b/crates/tracedecay-contracts/src/retrieval/symbol_graph.rs @@ -158,6 +158,7 @@ impl PrimitiveFailure { } const MACRO_BODY_UNPARSED: &str = "macro_body_unparsed"; +const IMPORT_UNMODELED: &str = "import_unmodeled"; impl PrimitiveSupportGap { /// A caller that is an item-position macro invocation whose body was not @@ -170,10 +171,21 @@ impl PrimitiveSupportGap { } } + /// Unresolved callers that sit under an import of `shape` (its + /// `snake_case` name) the index could not model. + pub fn import_unmodeled(shape: &str) -> Self { + Self { + provider: Some("code_index".to_owned()), + language: None, + reason: format!("{IMPORT_UNMODELED}: {shape}"), + } + } + /// The typed omission this gap discloses in an evidence packet. pub fn omission_reason(&self) -> OmissionReason { match self.reason.split_once(':') { Some((MACRO_BODY_UNPARSED, _)) => OmissionReason::MacroBodyUnparsed, + Some((IMPORT_UNMODELED, _)) => OmissionReason::ImportUnmodeled, _ => OmissionReason::Unsupported, } } diff --git a/crates/tracedecay-domain/src/code_intelligence/graph.rs b/crates/tracedecay-domain/src/code_intelligence/graph.rs index 62d1ef4218..b5810b28f7 100644 --- a/crates/tracedecay-domain/src/code_intelligence/graph.rs +++ b/crates/tracedecay-domain/src/code_intelligence/graph.rs @@ -278,6 +278,31 @@ pub struct UnresolvedRef { pub line: u32, pub column: u32, pub file_path: String, + /// The import shape this call site sits under when the extractor could + /// not model its binding exactly; the resolver keeps such a site as a + /// disclosed gap while it stays unbound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unmodeled_import: Option, +} + +/// A `use` whose binding the extractor cannot decide from one file: a glob +/// names every public item of a module the file cannot see. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnmodeledImportShapeV1 { + /// A glob `use` declared in a block. + BlockGlob, + /// A glob `use` declared in an inline `mod` body. + InlineModuleGlob, +} + +impl UnmodeledImportShapeV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::BlockGlob => "block_glob", + Self::InlineModuleGlob => "inline_module_glob", + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tracedecay-domain/src/lib.rs b/crates/tracedecay-domain/src/lib.rs index a4ade66381..2dffefe6de 100644 --- a/crates/tracedecay-domain/src/lib.rs +++ b/crates/tracedecay-domain/src/lib.rs @@ -62,15 +62,16 @@ pub use code_intelligence::{ ProjectionOutcomeV1, ProjectionReplayReasonV1, QueryNormalizationRevision, RelationEdgeKindV1, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, SensitivityDecision, SensitivityLevelV1, SnapshotFileDispositionV1, SourceSpan, SymbolIdentityDigest, - SymbolOccurrenceId, TestAttributionEvidenceClassV1, UnresolvedRef, ValidatedCodeFileV1, - Visibility, classify_technical_token, code_reused_partition_arc_share_digest, - code_reused_partition_digest, code_reused_partition_digest_refs, - code_source_parent_delta_digest, exact_search_canonical, generate_node_id, generate_node_id_at, - is_cli_flag_token, is_commit_hash, is_commit_identifier_token, is_compiler_error_code_token, - is_configuration_key_token, is_identifier_token, is_path_shape, is_path_token, - is_qualified_name_token, is_runtime_error_code_token, is_technical_token_char, - is_tool_name_token, path_matches_scope, projection_batch_publication_digest, - repository_path_matches_scope, split_subtokens, technical_tokens, validate_code_logical_path, + SymbolOccurrenceId, TestAttributionEvidenceClassV1, UnmodeledImportShapeV1, UnresolvedRef, + ValidatedCodeFileV1, Visibility, classify_technical_token, + code_reused_partition_arc_share_digest, code_reused_partition_digest, + code_reused_partition_digest_refs, code_source_parent_delta_digest, exact_search_canonical, + generate_node_id, generate_node_id_at, is_cli_flag_token, is_commit_hash, + is_commit_identifier_token, is_compiler_error_code_token, is_configuration_key_token, + is_identifier_token, is_path_shape, is_path_token, is_qualified_name_token, + is_runtime_error_code_token, is_technical_token_char, is_tool_name_token, path_matches_scope, + projection_batch_publication_digest, repository_path_matches_scope, split_subtokens, + technical_tokens, validate_code_logical_path, }; pub use configuration::{ ACCESS_RULES_SETTING_KEY, ANALYZER_SETTINGS_SETTING_KEY, AUTOMATION_SETTINGS_SETTING_KEY, diff --git a/crates/tracedecay/tests/graph_suite/types_test.rs b/crates/tracedecay/tests/graph_suite/types_test.rs index c3327f62c7..7c3f256071 100644 --- a/crates/tracedecay/tests/graph_suite/types_test.rs +++ b/crates/tracedecay/tests/graph_suite/types_test.rs @@ -90,6 +90,7 @@ fn extraction_result_sanitize_no_empty_names() { line: 1, column: 0, file_path: "src/lib.rs".to_string(), + unmodeled_import: None, }; let mut result = ExtractionResult { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/callers_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/callers_behavior_test.rs index bf5eac135e..8b36d4cfad 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/callers_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/callers_behavior_test.rs @@ -415,3 +415,71 @@ async fn tracedecay_callers_parses_item_macro_bodies_and_discloses_unexpanded_on fixture.harness.shutdown().await; } + +const SCOPED_USE_LIB_RS: &str = "\ +mod m; +mod n; +use crate::n::only_in_n; +pub fn glob_caller() { + use crate::m::*; + g(); +} +mod inner { + use crate::m::h; + pub fn inline_caller() { + h(); + } +} +pub fn glob_miss() { + use crate::m::*; + only_in_n(); +} +"; + +/// Issue #2270's shapes: a glob `use` in a block and a `use` in an inline +/// module bind their calls. A call under a glob whose module does not +/// define the name stays a disclosed `import_unmodeled` gap. +#[tokio::test] +async fn tracedecay_callers_binds_scoped_uses_and_discloses_an_unmodeled_glob() { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), SCOPED_USE_LIB_RS).unwrap(); + fs::write(project.join("src/m.rs"), "pub fn g() {}\npub fn h() {}\n").unwrap(); + fs::write(project.join("src/n.rs"), "pub fn only_in_n() {}\n").unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + warm_code_index_search(&server, "only_in_n").await; + + for (target, caller, line) in [("g", "glob_caller", 4), ("h", "inline_caller", 10)] { + let id = function_id(&server, target).await; + let callers = + evidence(&call_callers(&server, json!({"node_id": id, "maximum_depth": 1})).await); + assert_eq!( + caller_rows(&callers), + vec![row(caller, "src/lib.rs", line, 1)], + "{target}: {callers}" + ); + assert_eq!(callers["coverage"]["completeness"], "complete", "{callers}"); + assert_eq!(callers["omissions"], json!([]), "{callers}"); + } + + let id = function_id(&server, "only_in_n").await; + let callers = + evidence(&call_callers(&server, json!({"node_id": id, "maximum_depth": 1})).await); + assert_eq!(caller_rows(&callers), Vec::new(), "{callers}"); + assert_eq!(callers["coverage"]["completeness"], "partial", "{callers}"); + assert_eq!( + callers["omissions"], + json!([{"domain": "graph", "count": 1, "reason": "import_unmodeled"}]) + ); + assert_eq!( + callers["payload"]["support_gaps"], + json!([{"provider": "code_index", "language": null, "reason": "import_unmodeled: block_glob"}]) + ); + + fixture.harness.shutdown().await; +} diff --git a/sdks/typescript/src/operations.ts b/sdks/typescript/src/operations.ts index 94069b8f68..8d89eefe7a 100644 --- a/sdks/typescript/src/operations.ts +++ b/sdks/typescript/src/operations.ts @@ -641,7 +641,7 @@ export type ObservationSourceIdentityV1 = { readonly provider?: ProviderId; read export type ObservatoryReadModelV1 = { readonly analytics_mode: AnalyticsModeReadModelV1; readonly authorized_scope_ref: string; readonly comparison: PerformanceComparisonReadModelV1; readonly current: boolean; readonly horizon: ObservabilityHorizonV1; readonly metrics: readonly MetricValueV1[]; readonly observed_at_micros: number; readonly rejected_arguments: RejectedArgumentAnalyticsV1; readonly watermark: string; readonly [key: string]: unknown }; export type OccurrenceProvenance = { readonly evidence_role: EvidenceRole; readonly file_occurrence_id?: FileOccurrenceId | null; readonly freshness: SourceFreshness; readonly logical_copy_cluster_id?: LogicalCopyClusterId | null; readonly logical_copy_evidence_anchor?: RetrievalAnchorId | null; readonly repository_id?: RepositoryId | null; readonly retriever_evidence_anchor: RetrievalAnchorId; readonly session_or_thread_id?: SessionOrThreadId | null; readonly source_namespace: SourceNamespace; readonly source_occurrence_id: SourceOccurrenceId }; export type Omission = { readonly count: number; readonly domain: EvidenceDomain; readonly reason: OmissionReason }; -export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict" | "macro_body_unparsed" | "cursor_expired" | "cursor_foreign"; +export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict" | "macro_body_unparsed" | "import_unmodeled" | "cursor_expired" | "cursor_foreign"; export type OperationBudgetUsage = { readonly bytes_consumed: number; readonly elapsed_micros: number; readonly units_consumed: number }; export type OperationReceipt_2 = { readonly budget: OperationBudgetUsage; readonly cancellation?: CancellationObservation | null; readonly effective_deadline: Deadline; readonly ended_at: UtcMicros; readonly started_at: UtcMicros; readonly termination: OperationTermination_2 }; export type OperationTermination_2 = "completed" | "cancelled" | "timed_out" | "failed" | "unavailable" | "partial" | "effect_unknown"; @@ -2219,7 +2219,7 @@ const DEFINITIONS = { ObservatoryReadModelV1: {"properties":{"analytics_mode":{"$ref":"#/$defs/AnalyticsModeReadModelV1"},"authorized_scope_ref":{"type":"string"},"comparison":{"$ref":"#/$defs/PerformanceComparisonReadModelV1"},"current":{"type":"boolean"},"horizon":{"$ref":"#/$defs/ObservabilityHorizonV1"},"metrics":{"items":{"$ref":"#/$defs/MetricValueV1"},"type":"array"},"observed_at_micros":{"format":"int64","type":"integer"},"rejected_arguments":{"$ref":"#/$defs/RejectedArgumentAnalyticsV1"},"watermark":{"type":"string"}},"required":["authorized_scope_ref","horizon","watermark","observed_at_micros","current","metrics","analytics_mode","comparison","rejected_arguments"],"type":"object"}, OccurrenceProvenance: {"additionalProperties":false,"description":"Structured occurrence provenance retained through fusion. Fusion\npreserves each exact `(source_occurrence_id, retriever_evidence_anchor)`\npair; parallel unassociated provenance vectors are forbidden.","properties":{"evidence_role":{"$ref":"#/$defs/EvidenceRole"},"file_occurrence_id":{"anyOf":[{"$ref":"#/$defs/FileOccurrenceId"},{"type":"null"}]},"freshness":{"$ref":"#/$defs/SourceFreshness"},"logical_copy_cluster_id":{"anyOf":[{"$ref":"#/$defs/LogicalCopyClusterId"},{"type":"null"}]},"logical_copy_evidence_anchor":{"anyOf":[{"$ref":"#/$defs/RetrievalAnchorId"},{"type":"null"}]},"repository_id":{"anyOf":[{"$ref":"#/$defs/RepositoryId"},{"type":"null"}]},"retriever_evidence_anchor":{"$ref":"#/$defs/RetrievalAnchorId"},"session_or_thread_id":{"anyOf":[{"$ref":"#/$defs/SessionOrThreadId"},{"type":"null"}]},"source_namespace":{"$ref":"#/$defs/SourceNamespace"},"source_occurrence_id":{"$ref":"#/$defs/SourceOccurrenceId"}},"required":["source_occurrence_id","retriever_evidence_anchor","source_namespace","evidence_role","freshness"],"type":"object"}, Omission: {"additionalProperties":false,"properties":{"count":{"format":"uint64","minimum":0,"type":"integer"},"domain":{"$ref":"#/$defs/EvidenceDomain"},"reason":{"$ref":"#/$defs/OmissionReason"}},"required":["domain","count","reason"],"type":"object"}, - OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","oneOf":[{"enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"},{"const":"macro_body_unparsed","description":"Evidence came from a macro body that was not expanded, so what the\nexpansion defines or calls is not covered.","type":"string"},{"const":"cursor_expired","description":"The continuation cursor can no longer be redeemed (its lifetime ended\nor the snapshot it pages is gone); restart the request without it.","type":"string"},{"const":"cursor_foreign","description":"The continuation cursor was issued for another project, worktree, or\nref; redeem it where it was issued.","type":"string"}]}, + OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","oneOf":[{"enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"},{"const":"macro_body_unparsed","description":"Evidence came from a macro body that was not expanded, so what the\nexpansion defines or calls is not covered.","type":"string"},{"const":"import_unmodeled","description":"A caller sits under a `use` whose binding the index could not model\n(a glob in a block or inline module), so whether it calls the target\nis not covered.","type":"string"},{"const":"cursor_expired","description":"The continuation cursor can no longer be redeemed (its lifetime ended\nor the snapshot it pages is gone); restart the request without it.","type":"string"},{"const":"cursor_foreign","description":"The continuation cursor was issued for another project, worktree, or\nref; redeem it where it was issued.","type":"string"}]}, OperationBudgetUsage: {"additionalProperties":false,"description":"Bounded work accounting supplied by an owning port or transaction.","properties":{"bytes_consumed":{"format":"uint64","minimum":0,"type":"integer"},"elapsed_micros":{"format":"uint64","minimum":0,"type":"integer"},"units_consumed":{"format":"uint64","minimum":0,"type":"integer"}},"required":["units_consumed","bytes_consumed","elapsed_micros"],"type":"object"}, OperationReceipt_2: {"additionalProperties":false,"description":"Canonical operation evidence. An admitted failure remains represented here\nrather than being replaced by a transport exception.","properties":{"budget":{"$ref":"#/$defs/OperationBudgetUsage"},"cancellation":{"anyOf":[{"$ref":"#/$defs/CancellationObservation"},{"type":"null"}]},"effective_deadline":{"$ref":"#/$defs/Deadline"},"ended_at":{"$ref":"#/$defs/UtcMicros"},"started_at":{"$ref":"#/$defs/UtcMicros"},"termination":{"$ref":"#/$defs/OperationTermination"}},"required":["started_at","ended_at","effective_deadline","budget","termination"],"type":"object"}, OperationTermination_2: {"description":"Terminal state after an operation has been admitted.","enum":["completed","cancelled","timed_out","failed","unavailable","partial","effect_unknown"],"type":"string"},