Skip to content

Commit 445178f

Browse files
committed
fix(indexing): receiver-type anchor recovers duplicate-copy ties
- `receiver_type_anchor` resolves the receiver's inferred type name to a specific Class symbol via the caller's file scope (import binding) or the exactly-one function-local Class inside the caller's span; None when the name doesn't resolve to a Class here - with an anchor, distance-0 membership requires the candidate to be a same-file direct member inside the anchor's own span, splitting duplicate class copies (vendored bundles, twin test files) that previously tied at distance 0 and failed closed - without an anchor, distance-0 stays name-keyed compatibility via `LanguageBehavior::is_receiver_compatible` - the ancestor-chain walk starts from the same anchor instead of re-resolving the type name independently
1 parent 31059f4 commit 445178f

2 files changed

Lines changed: 250 additions & 20 deletions

File tree

‎src/indexing/pipeline/stages/resolve.rs‎

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -508,27 +508,20 @@ impl ResolveStage {
508508
/// (Extends evidence, MRO order for python), None = not on the chain.
509509
/// Inherited members resolve through the chain; the concrete type's own
510510
/// override always ranks closer.
511-
fn receiver_chain_distance(
511+
/// Identity anchor for an inferred receiver type: the specific Class
512+
/// symbol the type name denotes in THIS file — file-scope resolution
513+
/// (imports included) first, then the exactly-one function-local Class
514+
/// inside the caller's span. None when the name does not resolve to a
515+
/// Class here (unresolvable import specifier, non-Class kinds such as
516+
/// rust Structs): copy selection is then impossible and distance 0
517+
/// stays name-keyed.
518+
fn receiver_type_anchor(
512519
&self,
513-
candidate: &crate::Symbol,
514520
type_name: &str,
515521
caller: Option<&crate::Symbol>,
516-
language_id: &LanguageId,
517522
context: &ResolutionContext,
518-
) -> Option<usize> {
519-
let behavior = self.get_behavior(language_id)?;
520-
if behavior.is_receiver_compatible(candidate, type_name, caller) {
521-
return Some(0);
522-
}
523-
// Ancestor hops are identity-grade: the receiver type must resolve
524-
// to a Class through THIS file's scope, and each hop follows that
525-
// class's own Extends edges with scope-resolved parents. Bare-name
526-
// chains merge every same-named class in the corpus (the pydantic
527-
// test `Model` population) and mis-attribute inherited members.
528-
// Chains dead-end after leaving the file (a parent's own Extends
529-
// edges live in its file's context): deep cross-file chains
530-
// under-report.
531-
let start = context
523+
) -> Option<SymbolId> {
524+
context
532525
.resolve(type_name)
533526
.filter(|&id| {
534527
self.symbol_cache
@@ -555,7 +548,58 @@ impl ResolveStage {
555548
}
556549
}
557550
only
558-
})?;
551+
})
552+
}
553+
554+
fn receiver_chain_distance(
555+
&self,
556+
candidate: &crate::Symbol,
557+
type_name: &str,
558+
caller: Option<&crate::Symbol>,
559+
language_id: &LanguageId,
560+
context: &ResolutionContext,
561+
) -> Option<usize> {
562+
let behavior = self.get_behavior(language_id)?;
563+
// Distance 0: with an identity anchor, membership is in the ANCHORED
564+
// class copy — same file, inside the anchor's span, direct member.
565+
// Name-keyed compatibility alone cannot split duplicate class copies
566+
// (vendored bundles, twin test files) or same-file same-name locals.
567+
// Without an anchor, name-keyed is the only evidence there is.
568+
let anchor = self.receiver_type_anchor(type_name, caller, context);
569+
match anchor {
570+
Some(anchor_id) => {
571+
let (anchor_name, anchor_file, anchor_range) = {
572+
let sym = self.symbol_cache.get_ref(anchor_id)?;
573+
(sym.name.clone(), sym.file_id, sym.range)
574+
};
575+
if candidate.file_id == anchor_file
576+
&& range_contains(&anchor_range, &candidate.range)
577+
&& self.is_direct_member(
578+
candidate.scope_context.as_ref(),
579+
candidate.range,
580+
anchor_id,
581+
&anchor_name,
582+
anchor_file,
583+
)
584+
{
585+
return Some(0);
586+
}
587+
}
588+
None => {
589+
if behavior.is_receiver_compatible(candidate, type_name, caller) {
590+
return Some(0);
591+
}
592+
}
593+
}
594+
// Ancestor hops are identity-grade: the receiver type must resolve
595+
// to a Class through THIS file's scope, and each hop follows that
596+
// class's own Extends edges with scope-resolved parents. Bare-name
597+
// chains merge every same-named class in the corpus (the pydantic
598+
// test `Model` population) and mis-attribute inherited members.
599+
// Chains dead-end after leaving the file (a parent's own Extends
600+
// edges live in its file's context): deep cross-file chains
601+
// under-report.
602+
let start = anchor?;
559603
let mut frontier = vec![start];
560604
for distance in 1..=8usize {
561605
let mut next = Vec::new();

‎tests/integration/test_resolve_instance_receiver_survivors.rs‎

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
//! Multi-survivor instance-receiver disambiguation fails closed.
1+
//! Multi-survivor instance-receiver disambiguation fails closed;
2+
//! an identity anchor recovers the pick.
23
//!
34
//! Regression for the three.js mis-pick class: when the inferred receiver
45
//! type matches multiple same-name members (duplicate class copies in the
56
//! corpus), resolution must not fall through to the name-keyed priority
67
//! ladder, whose terminal arms are first-pick and proximity — not identity
7-
//! evidence. Exactly one survivor resolves; zero or many fail closed.
8+
//! evidence. Exactly one survivor resolves; zero or many fail closed —
9+
//! unless the receiver type resolves to a specific Class in the caller's
10+
//! file scope (import binding) or span (function-local class): then the
11+
//! anchored copy's own member wins and the other copies are rejected.
812
913
use codanna::config::Settings;
1014
use codanna::indexing::pipeline::types::{
@@ -133,6 +137,188 @@ fn duplicate_class_copies_fail_closed() {
133137
assert_eq!(stats.total_processed, 1);
134138
}
135139

140+
fn class_sym(id: u32, name: &str, file: u32, range: Range) -> Symbol {
141+
let mut sym = Symbol::new(
142+
SymbolId::new(id).unwrap(),
143+
name,
144+
SymbolKind::Class,
145+
FileId::new(file).unwrap(),
146+
range,
147+
);
148+
sym.language_id = Some(js());
149+
sym.visibility = Visibility::Public;
150+
sym
151+
}
152+
153+
fn method_in_class(id: u32, name: &str, class: &str, file: u32, range: Range) -> Symbol {
154+
let mut sym = Symbol::new(
155+
SymbolId::new(id).unwrap(),
156+
name,
157+
SymbolKind::Method,
158+
FileId::new(file).unwrap(),
159+
range,
160+
);
161+
sym.language_id = Some(js());
162+
sym.visibility = Visibility::Public;
163+
sym.scope_context = Some(ScopeContext::ClassMember {
164+
class_name: Some(class.into()),
165+
});
166+
sym
167+
}
168+
169+
/// Duplicate copies exist, but the caller's file scope binds the type name
170+
/// to one specific Class (an import binding): that copy's member wins.
171+
#[test]
172+
fn import_anchored_duplicate_copies_recover() {
173+
use codanna::parsing::ResolutionScope;
174+
use codanna::parsing::resolution::ScopeLevel;
175+
176+
let caller_file = 1;
177+
let cache = Arc::new(SymbolLookupCache::new());
178+
cache.insert(caller_fn(1, caller_file));
179+
cache.insert(method_of_class(2, "set", "Vector2", 2));
180+
cache.insert(method_in_class(
181+
3,
182+
"set",
183+
"Vector3",
184+
3,
185+
Range::new(1, 1, 3, 2),
186+
));
187+
cache.insert(method_in_class(
188+
4,
189+
"set",
190+
"Vector3",
191+
4,
192+
Range::new(1, 1, 3, 2),
193+
));
194+
cache.insert(class_sym(5, "Vector3", 3, Range::new(0, 0, 4, 1)));
195+
cache.insert(class_sym(6, "Vector3", 4, Range::new(0, 0, 4, 1)));
196+
197+
let mut scope = GenericResolutionContext::new(FileId::new(caller_file).unwrap());
198+
scope.add_symbol(
199+
"Vector3".to_string(),
200+
SymbolId::new(5).unwrap(),
201+
ScopeLevel::Module,
202+
);
203+
204+
let stage = ResolveStage::new(Arc::clone(&cache), build_behaviors());
205+
let (call, bindings) = typed_receiver_call(caller_file);
206+
207+
let context = ResolutionContext {
208+
file_id: FileId::new(caller_file).unwrap(),
209+
language_id: js(),
210+
imports: vec![],
211+
local_symbols: vec![],
212+
scope: Box::new(scope),
213+
unresolved_rels: vec![call],
214+
variable_bindings: bindings,
215+
};
216+
let (batch, stats) = stage.resolve(&context);
217+
218+
assert_eq!(
219+
batch.len(),
220+
1,
221+
"the anchored copy's member must win over the duplicate in file 4"
222+
);
223+
let rel = batch
224+
.relationships
225+
.first()
226+
.expect("one resolved relationship");
227+
assert_eq!(
228+
rel.to_id,
229+
SymbolId::new(3).unwrap(),
230+
"survivor is the member of the Class the scope binding names"
231+
);
232+
assert_eq!(stats.calls_resolved, 1);
233+
}
234+
235+
/// Two same-name function-local classes in ONE file (the pydantic
236+
/// validate_call twin-test shape): the class inside the caller's own span
237+
/// anchors, and only its member resolves.
238+
#[test]
239+
fn span_local_class_anchors_same_file_duplicates() {
240+
let caller_file = 1;
241+
let cache = Arc::new(SymbolLookupCache::new());
242+
// Caller spans lines 2..20; its local class X spans 3..8 with foo at
243+
// 4..6. A sibling test function's X spans 30..38 with foo at 31..33.
244+
let mut caller = caller_fn(1, caller_file);
245+
caller.range = Range::new(2, 0, 20, 1);
246+
cache.insert(caller);
247+
cache.insert(method_in_class(
248+
2,
249+
"foo",
250+
"X",
251+
caller_file,
252+
Range::new(4, 1, 6, 2),
253+
));
254+
cache.insert(method_in_class(
255+
3,
256+
"foo",
257+
"X",
258+
caller_file,
259+
Range::new(31, 1, 33, 2),
260+
));
261+
cache.insert(class_sym(5, "X", caller_file, Range::new(3, 0, 8, 1)));
262+
cache.insert(class_sym(6, "X", caller_file, Range::new(30, 0, 38, 1)));
263+
264+
let stage = ResolveStage::new(Arc::clone(&cache), build_behaviors());
265+
266+
let call = UnresolvedRelationship {
267+
from_id: Some(SymbolId::new(1).unwrap()),
268+
from_name: "run".into(),
269+
to_name: "foo".into(),
270+
file_id: FileId::new(caller_file).unwrap(),
271+
kind: RelationKind::Calls,
272+
metadata: Some(RelationshipMetadata {
273+
line: Some(10),
274+
column: Some(1),
275+
context: None,
276+
receiver: Some("x".into()),
277+
static_call: false,
278+
}),
279+
to_range: Some(Range::new(10, 1, 10, 20)),
280+
};
281+
let bindings = vec![VariableBinding {
282+
name: "x".to_string(),
283+
type_name: "X".to_string(),
284+
range: Range::new(9, 1, 9, 20),
285+
}];
286+
287+
let context = ResolutionContext {
288+
file_id: FileId::new(caller_file).unwrap(),
289+
language_id: js(),
290+
imports: vec![],
291+
local_symbols: vec![
292+
SymbolId::new(5).unwrap(),
293+
SymbolId::new(6).unwrap(),
294+
SymbolId::new(2).unwrap(),
295+
SymbolId::new(3).unwrap(),
296+
],
297+
scope: Box::new(GenericResolutionContext::new(
298+
FileId::new(caller_file).unwrap(),
299+
)),
300+
unresolved_rels: vec![call],
301+
variable_bindings: bindings,
302+
};
303+
let (batch, stats) = stage.resolve(&context);
304+
305+
assert_eq!(
306+
batch.len(),
307+
1,
308+
"the X inside the caller's span anchors; its foo must resolve"
309+
);
310+
let rel = batch
311+
.relationships
312+
.first()
313+
.expect("one resolved relationship");
314+
assert_eq!(
315+
rel.to_id,
316+
SymbolId::new(2).unwrap(),
317+
"survivor is the caller-span X's foo, not the sibling function's"
318+
);
319+
assert_eq!(stats.calls_resolved, 1);
320+
}
321+
136322
#[test]
137323
fn single_class_copy_resolves_correct_member() {
138324
let (batch, stats) = resolve_with_candidates(false);

0 commit comments

Comments
 (0)