Skip to content

Commit 31059f4

Browse files
committed
fix(indexing): multi-survivor receiver match fails closed
- `filter_by_instance_receiver_type`'s multi-survivor case (duplicate class copies in one corpus) returns `None` instead of falling through to the name-keyed priority ladder, whose terminal arms are first-pick and proximity, not identity evidence - `examples/parser_census.rs` tallies `find_variable_types` emission (`vt_total` column) alongside its existing per-language evidence census - `tests/integration/test_resolve_instance_receiver_survivors.rs` locks single-survivor resolution and multi-survivor fail-closed behavior for a typed receiver call
1 parent 88e413a commit 31059f4

4 files changed

Lines changed: 177 additions & 13 deletions

File tree

‎examples/parser_census.rs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
//! Measures what each language parser emits against the evidence fields
44
//! resolution depends on: caller identity, receiver (self-form vs named),
55
//! static flag, caller_range, qualified plain-call targets, ClassMember
6-
//! scope on methods, Defines/Extends emission. Walks a fixture tree,
6+
//! scope on methods, Defines/Extends emission, variable-binding
7+
//! emission (find_variable_types). Walks a fixture tree,
78
//! buckets files by registered extension, aggregates per language.
89
//! Not part of the product surface.
910
//!
@@ -38,6 +39,7 @@ struct LangStats {
3839
defines: usize,
3940
extends: usize,
4041
implements: usize,
42+
vt_total: usize,
4143
}
4244

4345
const SKIP_DIRS: &[&str] = &[
@@ -170,15 +172,18 @@ fn main() {
170172
s.defines += parser.find_defines(&code).len();
171173
s.extends += parser.find_extends(&code).len();
172174
s.implements += parser.find_implementations(&code).len();
175+
// Shape split (ctor vs annotated) omitted: the hook returns
176+
// (name, type, range) with no capture-arm provenance.
177+
s.vt_total += parser.find_variable_types(&code).len();
173178
}
174179
}
175180

176181
println!(
177-
"lang\tfiles\tmc_total\tmc_caller_empty\tmc_recv_self\tmc_recv_named\tmc_recv_none\tmc_static\tmc_caller_range_none\tpc_total\tpc_qualified\tmethods\tm_classmember_named\tm_classmember_anon\tm_scope_other\tm_scope_none\tdefines\textends\timplements"
182+
"lang\tfiles\tmc_total\tmc_caller_empty\tmc_recv_self\tmc_recv_named\tmc_recv_none\tmc_static\tmc_caller_range_none\tpc_total\tpc_qualified\tmethods\tm_classmember_named\tm_classmember_anon\tm_scope_other\tm_scope_none\tdefines\textends\timplements\tvt_total"
178183
);
179184
for (id, s) in &stats {
180185
println!(
181-
"{id}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
186+
"{id}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
182187
s.files,
183188
s.mc_total,
184189
s.mc_caller_empty,
@@ -197,6 +202,7 @@ fn main() {
197202
s.defines,
198203
s.extends,
199204
s.implements,
205+
s.vt_total,
200206
);
201207
}
202208
}

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,20 +1163,21 @@ impl ResolveStage {
11631163
}
11641164
}
11651165
}
1166-
// Instance-call disambiguation via inferred parameter type: when the
1167-
// receiver names a parameter on the caller's signature, filter candidates
1168-
// to those whose containing type matches the inferred type. Zero
1169-
// survivors → NotFound (don't fall through to a wrong-class same-name
1170-
// pick); single survivor wins; multiple fall through.
1166+
// Instance-call disambiguation via inferred receiver type: filter
1167+
// candidates to those whose containing type matches. Single survivor
1168+
// wins; zero fail closed; multiple ALSO fail closed — the survivors
1169+
// are class-correct but copy-ambiguous (duplicate class copies in a
1170+
// corpus), and the ladder below is name-keyed first-pick, not
1171+
// identity evidence. An unresolved edge beats a wrong-copy or
1172+
// wrong-class guess.
11711173
if unresolved.kind == RelationKind::Calls {
11721174
if let Some(survivors) =
11731175
self.filter_by_instance_receiver_type(&filtered, unresolved, language_id, context)
11741176
{
1175-
match survivors.len() {
1176-
1 => return Some(survivors[0]),
1177-
0 => return None,
1178-
_ => {}
1179-
}
1177+
return match survivors.len() {
1178+
1 => Some(survivors[0]),
1179+
_ => None,
1180+
};
11801181
} else if self.has_uninferrable_instance_receiver(unresolved, language_id, context) {
11811182
// Receiver present, type unknown: an unresolved edge beats
11821183
// a first-pick guess from the priority ladder below.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//! Multi-survivor instance-receiver disambiguation fails closed.
2+
//!
3+
//! Regression for the three.js mis-pick class: when the inferred receiver
4+
//! type matches multiple same-name members (duplicate class copies in the
5+
//! corpus), resolution must not fall through to the name-keyed priority
6+
//! ladder, whose terminal arms are first-pick and proximity — not identity
7+
//! evidence. Exactly one survivor resolves; zero or many fail closed.
8+
9+
use codanna::config::Settings;
10+
use codanna::indexing::pipeline::types::{
11+
ResolutionContext, ResolvedBatch, SymbolLookupCache, UnresolvedRelationship, VariableBinding,
12+
};
13+
use codanna::indexing::pipeline::{ResolveStage, ResolveStats};
14+
use codanna::parsing::resolution::GenericResolutionContext;
15+
use codanna::parsing::{LanguageBehavior, LanguageId, ParserFactory};
16+
use codanna::relationship::RelationshipMetadata;
17+
use codanna::symbol::ScopeContext;
18+
use codanna::types::{FileId, Range, SymbolId};
19+
use codanna::{RelationKind, Symbol, SymbolKind, Visibility};
20+
use std::collections::HashMap;
21+
use std::sync::Arc;
22+
23+
fn js() -> LanguageId {
24+
LanguageId::new("javascript")
25+
}
26+
27+
fn build_behaviors() -> HashMap<LanguageId, Arc<dyn LanguageBehavior>> {
28+
let settings = Settings::load().expect("Failed to load settings");
29+
let factory = ParserFactory::new(Arc::new(settings));
30+
let mut map = HashMap::new();
31+
let behavior: Arc<dyn LanguageBehavior> =
32+
Arc::from(factory.create_behavior_from_registry(js()));
33+
map.insert(js(), behavior);
34+
map
35+
}
36+
37+
fn method_of_class(id: u32, name: &str, class: &str, file: u32) -> Symbol {
38+
let mut sym = Symbol::new(
39+
SymbolId::new(id).unwrap(),
40+
name,
41+
SymbolKind::Method,
42+
FileId::new(file).unwrap(),
43+
Range::new(1, 1, 3, 2),
44+
);
45+
sym.language_id = Some(js());
46+
sym.visibility = Visibility::Public;
47+
sym.scope_context = Some(ScopeContext::ClassMember {
48+
class_name: Some(class.into()),
49+
});
50+
sym
51+
}
52+
53+
fn caller_fn(id: u32, file: u32) -> Symbol {
54+
let mut sym = Symbol::new(
55+
SymbolId::new(id).unwrap(),
56+
"run",
57+
SymbolKind::Function,
58+
FileId::new(file).unwrap(),
59+
Range::new(2, 0, 6, 1),
60+
);
61+
sym.language_id = Some(js());
62+
sym.visibility = Visibility::Public;
63+
sym
64+
}
65+
66+
/// `run` holds `v = new Vector3()` (a binding inside its span) and calls
67+
/// `v.set(...)` after the binding site.
68+
fn typed_receiver_call(caller_file: u32) -> (UnresolvedRelationship, Vec<VariableBinding>) {
69+
let call = UnresolvedRelationship {
70+
from_id: Some(SymbolId::new(1).unwrap()),
71+
from_name: "run".into(),
72+
to_name: "set".into(),
73+
file_id: FileId::new(caller_file).unwrap(),
74+
kind: RelationKind::Calls,
75+
metadata: Some(RelationshipMetadata {
76+
line: Some(5),
77+
column: Some(1),
78+
context: None,
79+
receiver: Some("v".into()),
80+
static_call: false,
81+
}),
82+
to_range: Some(Range::new(5, 1, 5, 20)),
83+
};
84+
let bindings = vec![VariableBinding {
85+
name: "v".to_string(),
86+
type_name: "Vector3".to_string(),
87+
range: Range::new(4, 1, 4, 30),
88+
}];
89+
(call, bindings)
90+
}
91+
92+
fn resolve_with_candidates(duplicate_copy: bool) -> (ResolvedBatch, ResolveStats) {
93+
let caller_file = 1;
94+
let cache = Arc::new(SymbolLookupCache::new());
95+
cache.insert(caller_fn(1, caller_file));
96+
// Lowest id: a same-name member of a SIBLING class. The pre-fix
97+
// fall-through picked this via language_matches[0].
98+
cache.insert(method_of_class(2, "set", "Vector2", 2));
99+
cache.insert(method_of_class(3, "set", "Vector3", 3));
100+
if duplicate_copy {
101+
// Byte-copy of the receiver's class in another file (bundle copy).
102+
cache.insert(method_of_class(4, "set", "Vector3", 4));
103+
}
104+
105+
let stage = ResolveStage::new(Arc::clone(&cache), build_behaviors());
106+
let (call, bindings) = typed_receiver_call(caller_file);
107+
108+
let context = ResolutionContext {
109+
file_id: FileId::new(caller_file).unwrap(),
110+
language_id: js(),
111+
imports: vec![],
112+
local_symbols: vec![],
113+
scope: Box::new(GenericResolutionContext::new(
114+
FileId::new(caller_file).unwrap(),
115+
)),
116+
unresolved_rels: vec![call],
117+
variable_bindings: bindings,
118+
};
119+
stage.resolve(&context)
120+
}
121+
122+
#[test]
123+
fn duplicate_class_copies_fail_closed() {
124+
let (batch, stats) = resolve_with_candidates(true);
125+
assert_eq!(
126+
batch.len(),
127+
0,
128+
"two Vector3.set copies tie at distance 0: multiple survivors must \
129+
fail closed, not fall through to the ladder (which would first-pick \
130+
the lowest-id sibling Vector2.set)"
131+
);
132+
assert_eq!(stats.calls_resolved, 0);
133+
assert_eq!(stats.total_processed, 1);
134+
}
135+
136+
#[test]
137+
fn single_class_copy_resolves_correct_member() {
138+
let (batch, stats) = resolve_with_candidates(false);
139+
assert_eq!(
140+
batch.len(),
141+
1,
142+
"exactly one distance-0 survivor must resolve"
143+
);
144+
let rel = batch
145+
.relationships
146+
.first()
147+
.expect("one resolved relationship");
148+
assert_eq!(
149+
rel.to_id,
150+
SymbolId::new(3).unwrap(),
151+
"the survivor is Vector3.set — not the lower-id sibling Vector2.set"
152+
);
153+
assert_eq!(stats.calls_resolved, 1);
154+
}

‎tests/integration_tests.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ mod test_kotlin_semantic_search;
5555
#[path = "integration/test_pipeline_parse_stage.rs"]
5656
mod test_pipeline_parse_stage;
5757

58+
#[path = "integration/test_resolve_instance_receiver_survivors.rs"]
59+
mod test_resolve_instance_receiver_survivors;
60+
5861
#[path = "integration/test_resolve_kind_filter.rs"]
5962
mod test_resolve_kind_filter;
6063

0 commit comments

Comments
 (0)