From 78e003907fa8c767bb0942f6096bc5b990f53768 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 15:29:06 +0000 Subject: [PATCH 1/4] fix(code-index): disclose ambiguous calls as caller gaps Cross-file resolution returned no binding when more than one inherent candidate remained, and same-file resolution dropped ambiguous references before they reached cross-file resolution. Neither left a gap row, so callers, callees and impact answers looked complete. Ambiguous same-file references are now retained like unbound ones, and cross-file resolution reports ambiguous calls as unresolved-call gaps in both whole-set and sparse resolution. Fixes #3012 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/tracedecay-code-index/src/chunks.rs | 19 ++-- .../src/production/helpers.rs | 94 +++++++++++-------- .../src/production/mod.rs | 5 +- .../src/production/sparse_resolution.rs | 6 +- .../code_index_suite/relation_coverage.rs | 51 ++++++++++ 5 files changed, 124 insertions(+), 51 deletions(-) diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index f3ebf9c5f1..8c8e610904 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -2411,9 +2411,10 @@ fn resolve_file_references( }); } } - // Java overloads the call's arguments cannot tell apart (equal - // arity, a variadic tail) stay a disclosed caller gap. - candidates if candidates.is_empty() || language == "java" => { + // No candidate, or several the file cannot choose between: the + // reference is retained, and cross-file resolution binds it or + // discloses it as a caller gap. + _ => { if let Some(candidate) = cross_file_reference_candidate( source, offsets, @@ -2436,9 +2437,6 @@ fn resolve_file_references( retained.push(candidate); } } - // Same-file ambiguity: adding cross-file candidates can only make - // it more ambiguous, so the reference stays unresolved. - _ => {} } } // The parser may describe one invocation both as a receiver expression @@ -5288,7 +5286,14 @@ pub fn real_symbol() {} &[], ); assert!(resolved.is_empty()); - assert!(retained.is_empty()); + assert_eq!( + retained + .iter() + .map(|reference| reference.reference_name.as_str()) + .collect::>(), + ["Base"], + "an ambiguous bare name stays a retained reference, not a silent drop" + ); } #[test] diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 669e8cabf4..15632d5578 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -313,8 +313,8 @@ type EdgeEvidenceV1 = ( Vec, ); -/// `files`' edge evidence resolved whole, with the interfaces whose -/// implementors the seal cannot decide. +/// `files`' edge evidence resolved whole, with the references the seal +/// cannot decide. pub(crate) fn collect_edge_evidence( files: &[T], ) -> Result @@ -324,12 +324,9 @@ where // Resolution's whole-set indexes are gone before the per-file copies are // made, and the one exact-capacity vector never regrows, so the peak is // the edges this returns plus one sort buffer. - let CrossFileResolutionV1 { - edges, - implementor_gaps, - } = resolve_cross_file_references(files)?; + let CrossFileResolutionV1 { edges, gaps } = resolve_cross_file_references(files)?; let (edges, abstentions) = edge_evidence(files, edges); - Ok((edges, abstentions, implementor_gaps)) + Ok((edges, abstentions, gaps)) } /// `files`' edge evidence: each file's own edges and `cross_file`, the @@ -397,24 +394,26 @@ pub(crate) type ReferenceSelectionV1 = [(usize, Vec)]; /// Resolves only `selection`'s references against the whole file set whose /// symbols `by_simple_name` indexes. Each reference binds exactly as /// [`resolve_cross_file_references`] binds it; the result is the edges those -/// references contribute. +/// references contribute and the ambiguous calls among them. #[tracing::instrument(name = "code_index.seal.resolve_selected", level = "trace", skip_all)] pub(crate) fn resolve_selected_cross_file_references( files: &[T], by_simple_name: &dyn SymbolsByNameV1, selection: &ReferenceSelectionV1, -) -> Result, CodeIndexProductionErrorV1> +) -> Result where T: ResolutionFileV1, { - resolve_references(files, by_simple_name, Some(selection)).map(|resolution| resolution.edges) + resolve_references(files, by_simple_name, Some(selection)) } -/// A whole-set resolution: the cross-file edges, and one row per interface -/// whose implementors the seal cannot decide. +/// A resolution: the cross-file edges, and the references the seal cannot +/// decide, each a disclosed gap: calls with more than one candidate, and, for +/// a whole-set pass, one row per interface whose implementors it cannot +/// decide. pub(crate) struct CrossFileResolutionV1 { pub(crate) edges: Vec, - pub(crate) implementor_gaps: Vec, + pub(crate) gaps: Vec, } /// `selection`'s references, or every retained reference, as `(file index, @@ -516,12 +515,14 @@ where let mut edges = Vec::with_capacity( per_file .iter() - .map(Vec::len) + .map(|(edges, _)| edges.len()) .sum::() .saturating_add(satisfaction.edges.len()), ); - for file_edges in per_file { + let mut gaps = satisfaction.gaps; + for (file_edges, file_gaps) in per_file { edges.extend(file_edges); + gaps.extend(file_gaps); } edges.extend(satisfaction.edges); { @@ -531,10 +532,7 @@ where edges.dedup(); } }; - Ok(CrossFileResolutionV1 { - edges, - implementor_gaps: satisfaction.gaps, - }) + Ok(CrossFileResolutionV1 { edges, gaps }) } /// Retained call sites whose import binding names project code the seal @@ -671,7 +669,7 @@ where } /// Resolve one file's retained unresolved references against the whole staged -/// file set. +/// file set: the edges they bind, and the calls left ambiguous. /// /// The memo is file-local on purpose. `ResolvedReferenceCacheV1` is keyed by /// source-file index, so a shared map could never serve another file's entry. @@ -683,7 +681,10 @@ fn resolve_one_file_cross_file_references( modules: &ResolutionModulesV1<'_, T>, index: usize, picks: Option<&[usize]>, -) -> Vec +) -> ( + Vec, + Vec, +) where T: ResolutionFileV1, { @@ -692,6 +693,7 @@ where let same_file_binds = is_module_import_language(files[index].language()); let mut resolved_references = ResolvedReferenceCacheV1::new(); let mut edges = Vec::new(); + let mut ambiguous = Vec::new(); let references = &files[index].as_ref().artifacts.unresolved_references; let every = picks.is_none().then(|| references.iter()); let picked = picks.into_iter().flatten().map(|&pick| &references[pick]); @@ -713,8 +715,15 @@ where resolved_references.insert(cache_key, resolved.clone()); resolved }; - let Some((target_index, targets)) = resolved else { - continue; + let (target_index, targets) = match resolved { + Some(ReferenceResolutionV1::Bound(target_index, targets)) => (target_index, targets), + Some(ReferenceResolutionV1::Ambiguous) => { + if reference.kind == RelationEdgeKindV1::Calls { + ambiguous.push(reference.clone()); + } + continue; + } + None => continue, }; if target_index == index && !same_file_binds { continue; @@ -727,7 +736,7 @@ where evidence_span: reference.evidence_span, })); } - edges + (edges, ambiguous) } #[cfg(test)] @@ -740,10 +749,18 @@ pub(super) fn take_seal_reference_resolutions() -> usize { SEAL_REFERENCE_RESOLUTIONS.with(|resolutions| resolutions.replace(0)) } -type ResolvedReferenceCacheV1<'a> = HashMap< - (usize, &'a str, RelationEdgeKindV1, Option), - Option<(usize, Vec)>, ->; +type ResolvedReferenceCacheV1<'a> = + HashMap<(usize, &'a str, RelationEdgeKindV1, Option), Option>; + +/// What one retained reference resolves to; `None` beside it is a reference +/// with no cross-file binding. +#[derive(Clone)] +enum ReferenceResolutionV1 { + /// The targets, all in one file. + Bound(usize, Vec), + /// More than one candidate the reference cannot choose between. + Ambiguous, +} fn resolve_cross_file_reference( files: &[T], @@ -751,7 +768,7 @@ fn resolve_cross_file_reference( modules: &ResolutionModulesV1<'_, T>, index: usize, reference: &CodeIndexUnresolvedReferenceV1, -) -> Option<(usize, Vec)> +) -> Option where T: ResolutionFileV1, { @@ -761,9 +778,9 @@ where // and package rules, never by name matching. if is_module_import_language(file.extraction.language.as_str()) { return match modules.modules().call_outcome(index, reference)? { - ImportBindingOutcomeV1::Bound(target_index, symbol) => { - Some((target_index, vec![symbol.occurrence.clone()])) - } + ImportBindingOutcomeV1::Bound(target_index, symbol) => Some( + ReferenceResolutionV1::Bound(target_index, vec![symbol.occurrence.clone()]), + ), ImportBindingOutcomeV1::External | ImportBindingOutcomeV1::Unresolved | ImportBindingOutcomeV1::ValueMember => None, @@ -790,9 +807,9 @@ where ) { return match outcome { - ImportBindingOutcomeV1::Bound(target_index, symbol) if target_index != index => { - Some((target_index, vec![symbol.occurrence.clone()])) - } + ImportBindingOutcomeV1::Bound(target_index, symbol) if target_index != index => Some( + ReferenceResolutionV1::Bound(target_index, vec![symbol.occurrence.clone()]), + ), ImportBindingOutcomeV1::Bound(..) | ImportBindingOutcomeV1::External | ImportBindingOutcomeV1::Unresolved @@ -917,7 +934,8 @@ where .collect::>(); // A type-path call may match both an inherent `Type::method` and one or // more `::method` aliases; Rust prefers the inherent, so - // keep a unique non-UFCS hit when aliases also matched. + // keep a unique non-UFCS hit when aliases also matched. Any other choice + // between candidates is ambiguous. let compatible = match compatible.as_slice() { [] => return None, [_] => compatible, @@ -943,7 +961,7 @@ where .collect::>(); match inherent.as_slice() { [_] => inherent, - _ => return None, + _ => return Some(ReferenceResolutionV1::Ambiguous), } } }; @@ -951,7 +969,7 @@ where if *target_index == index { return None; } - Some(( + Some(ReferenceResolutionV1::Bound( *target_index, compatible .iter() diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index aebb71fc27..f62e20a55b 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -2155,14 +2155,13 @@ where let _span = tracing::trace_span!("code_index.build.assemble.graph_outputs").entered(); { - let (edges, abstentions, implementor_gaps) = - collect_edge_evidence(&staged.files)?; + let (edges, abstentions, gaps) = collect_edge_evidence(&staged.files)?; let mut unresolved = resolution_outputs::unresolved_calls_for_edges( &staged.files, &edges, &|| Ok(()), )?; - unresolved.extend(implementor_gaps); + unresolved.extend(gaps); unresolved.sort(); unresolved.dedup(); Ok::<_, CodeIndexProductionErrorV1>((edges, abstentions, unresolved)) diff --git a/crates/tracedecay-code-index/src/production/sparse_resolution.rs b/crates/tracedecay-code-index/src/production/sparse_resolution.rs index 880dd5f6b2..9197778a7c 100644 --- a/crates/tracedecay-code-index/src/production/sparse_resolution.rs +++ b/crates/tracedecay-code-index/src/production/sparse_resolution.rs @@ -494,7 +494,7 @@ pub(super) fn resolve_edit( .flat_map(|(_, before, _)| before.artifacts.unresolved_references.iter().map(site)), ) .collect::>(); - let resolved = resolve_selected_cross_file_references(files, by_name, &selection)?; + let resolution = resolve_selected_cross_file_references(files, by_name, &selection)?; // A resolved edge's target is a symbol some lookup read: a page row, or a // symbol of a file resolution decoded. @@ -521,7 +521,7 @@ pub(super) fn resolve_edit( }) .collect::>(); let mut result = BTreeMap::::new(); - for edge in resolved { + for edge in resolution.edges { let owner = owner_of .get(&edge.from_occurrence) .ok_or_else(|| contract("a re-resolved edge leaves a file the edit did not select"))?; @@ -617,7 +617,7 @@ pub(super) fn resolve_edit( &|| Ok(()), ) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - for call in rederived { + for call in rederived.into_iter().chain(resolution.gaps) { let owner = owner_of .get(&call.from_occurrence) .ok_or_else(|| contract("a re-derived call limitation leaves the selection"))?; diff --git a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs index ed445ed2b7..67a8c291a6 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs @@ -327,3 +327,54 @@ fn a_crlf_cargo_manifest_names_its_crate_for_cross_crate_calls() { ); assert!(!graph.callers_partial("lib/src/lib.rs::helper")); } + +#[test] +fn a_type_path_call_with_two_inherent_candidates_makes_both_callers_partial() { + let graph = sealed_graph_of(&[ + ( + "rs/src/lib.rs", + "mod unix;\nmod windows;\n\npub struct Clock;\n\npub fn now() -> u64 {\n Clock::tick()\n}\n", + ), + ( + "rs/src/unix.rs", + "use crate::Clock;\n\n#[cfg(unix)]\nimpl Clock {\n pub fn tick() -> u64 {\n 1\n }\n}\n", + ), + ( + "rs/src/windows.rs", + "use crate::Clock;\n\n#[cfg(windows)]\nimpl Clock {\n pub fn tick() -> u64 {\n 2\n }\n}\n", + ), + ]); + + for target in [ + "rs/src/unix.rs::Clock::tick", + "rs/src/windows.rs::Clock::tick", + ] { + assert_eq!(graph.callers(target), Vec::::new(), "{target}"); + assert!(graph.callers_partial(target), "{target} callers partial"); + } + assert!(graph.callees_partial("rs/src/lib.rs::now")); +} + +#[test] +fn a_same_file_call_with_two_candidates_makes_both_callers_partial() { + let graph = sealed_graph_of(&[( + "rb/units.rb", + "def scale(x)\n 3\nend\n\ndef scale(x)\n 1\nend\n\ndef total\n scale(1)\nend\n", + )]); + + let scales = graph + .generation + .symbols() + .symbols + .iter() + .filter(|symbol| symbol.qualified_name == "rb/units.rb::scale") + .map(|symbol| symbol.occurrence.clone()) + .collect::>(); + assert_eq!(scales.len(), 2); + let caller_gaps = graph + .reader + .unresolved_caller_gaps(&scales, None, Arc::new(NeverCancelled)) + .expect("unresolved caller probe"); + assert!(!caller_gaps.is_empty()); + assert!(graph.callees_partial("rb/units.rb::total")); +} From dddb828c8c460f875c705a319c28eafcd8258674 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 15:42:11 +0000 Subject: [PATCH 2/4] fix(code-index): keep same-file ambiguity a gap at seal A retained same-file ambiguous call could bind a shadowed import, or get no gap row in Python and TypeScript. Mark it ambiguous_local so sealing reports it as a gap and never binds it cross-file. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/tracedecay-code-index/src/chunks.rs | 11 +++++--- .../src/chunks/artifacts.rs | 4 +++ .../src/production/go_satisfaction.rs | 1 + .../src/production/helpers.rs | 3 +++ .../code_index_suite/relation_coverage.rs | 25 +++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 8c8e610904..ff96078ed1 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -2411,11 +2411,12 @@ fn resolve_file_references( }); } } - // No candidate, or several the file cannot choose between: the - // reference is retained, and cross-file resolution binds it or - // discloses it as a caller gap. + // No candidate: the reference is retained for cross-file + // resolution. Several the file cannot choose between: it is + // retained as a disclosed caller gap. Java overloads the call's + // arguments cannot tell apart resolve through Java's own rules. _ => { - if let Some(candidate) = cross_file_reference_candidate( + if let Some(mut candidate) = cross_file_reference_candidate( source, offsets, &references_by_site, @@ -2434,6 +2435,7 @@ fn resolve_file_references( candidate.reference_name.clone(), ))) { + candidate.ambiguous_local = !compatible.is_empty() && language != "java"; retained.push(candidate); } } @@ -2536,6 +2538,7 @@ fn cross_file_reference_candidate( .unwrap_or(from.span), unmodeled_import: reference.unmodeled_import, argument_count: reference.argument_count, + ambiguous_local: false, }) } diff --git a/crates/tracedecay-code-index/src/chunks/artifacts.rs b/crates/tracedecay-code-index/src/chunks/artifacts.rs index 71666ab0b5..b8b3614315 100644 --- a/crates/tracedecay-code-index/src/chunks/artifacts.rs +++ b/crates/tracedecay-code-index/src/chunks/artifacts.rs @@ -163,6 +163,10 @@ pub struct CodeIndexUnresolvedReferenceV1 { /// Arguments the call site passes, where the extractor counts them. #[serde(default, skip_serializing_if = "Option::is_none")] pub argument_count: Option, + /// The file defines more than one candidate the call site cannot choose + /// between, so it is a disclosed caller gap and never binds cross-file. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ambiguous_local: bool, } impl CodeIndexUnresolvedReferenceV1 { diff --git a/crates/tracedecay-code-index/src/production/go_satisfaction.rs b/crates/tracedecay-code-index/src/production/go_satisfaction.rs index 8be6f1ce98..4cfa475449 100644 --- a/crates/tracedecay-code-index/src/production/go_satisfaction.rs +++ b/crates/tracedecay-code-index/src/production/go_satisfaction.rs @@ -369,6 +369,7 @@ where evidence_span: interface.span, unmodeled_import: None, argument_count: None, + ambiguous_local: false, }; let methods = match expansion { Ok(methods) => methods, diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 15632d5578..5be4c28ef6 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -772,6 +772,9 @@ fn resolve_cross_file_reference( where T: ResolutionFileV1, { + if reference.ambiguous_local { + return Some(ReferenceResolutionV1::Ambiguous); + } let rust = &modules.rust; let file = files[index].as_ref(); // These languages bind one exact module member through their own import diff --git a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs index 67a8c291a6..7484b127ee 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs @@ -378,3 +378,28 @@ fn a_same_file_call_with_two_candidates_makes_both_callers_partial() { assert!(!caller_gaps.is_empty()); assert!(graph.callees_partial("rb/units.rb::total")); } + +#[test] +fn a_same_file_python_call_with_two_candidates_makes_its_callees_partial() { + let graph = sealed_graph_of(&[( + "py/units.py", + "def scale(x):\n return x\n\ndef scale(x):\n return x + 1\n\ndef total():\n return scale(1)\n", + )]); + + assert!(graph.callees_partial("py/units.py::total")); +} + +#[test] +fn locally_ambiguous_definitions_shadow_an_imported_name() { + let graph = sealed_graph_of(&[ + ("py/pkg/__init__.py", ""), + ("py/pkg/lib.py", "def scale(x):\n return x * 2\n"), + ( + "py/pkg/app.py", + "from pkg.lib import scale\n\ndef scale(x):\n return x\n\ndef scale(x):\n return x + 1\n\ndef total():\n return scale(1)\n", + ), + ]); + + assert_eq!(graph.callers("py/pkg/lib.py::scale"), Vec::::new()); + assert!(graph.callees_partial("py/pkg/app.py::total")); +} From 21e219db999946c4f21a477aa154b1db5818161a Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Mon, 5 Oct 2026 06:28:07 +0000 Subject: [PATCH 3/4] style: unslop prose comments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/tracedecay-code-index/src/chunks.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index d49831a873..aae9069fa9 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -2413,9 +2413,8 @@ fn resolve_file_references( } } // No candidate: the reference is retained for cross-file - // resolution. Several the file cannot choose between: it is - // retained as a disclosed caller gap. Java overloads the call's - // arguments cannot tell apart resolve through Java's own rules. + // resolution. Several it cannot choose between: a disclosed + // caller gap. Java overloads resolve through Java's own rules. _ => { if let Some(mut candidate) = cross_file_reference_candidate( source, From fac4361c28ab114876ee046d8a7d2110b14e2659 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 5 Oct 2026 01:20:39 -0700 Subject: [PATCH 4/4] fix(bench): make query fixture selection standalone --- crates/tracedecay/benches/queries.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay/benches/queries.rs b/crates/tracedecay/benches/queries.rs index 558311a9de..076a5cff77 100644 --- a/crates/tracedecay/benches/queries.rs +++ b/crates/tracedecay/benches/queries.rs @@ -672,7 +672,7 @@ pub fn build_queries(ctx: &QueryContext) -> Vec { ) }), }); - if crate::repos::small_fixture_enabled() { + if std::env::var_os("TRACEDECAY_BENCH_SMALL_FIXTURE").is_some() { groups.push(ToolGroup { tool: "tracedecay_callees", queries: vec![Query::prepared_read(