|
1 | | -//! Multi-survivor instance-receiver disambiguation fails closed. |
| 1 | +//! Multi-survivor instance-receiver disambiguation fails closed; |
| 2 | +//! an identity anchor recovers the pick. |
2 | 3 | //! |
3 | 4 | //! Regression for the three.js mis-pick class: when the inferred receiver |
4 | 5 | //! type matches multiple same-name members (duplicate class copies in the |
5 | 6 | //! corpus), resolution must not fall through to the name-keyed priority |
6 | 7 | //! 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. |
8 | 12 |
|
9 | 13 | use codanna::config::Settings; |
10 | 14 | use codanna::indexing::pipeline::types::{ |
@@ -133,6 +137,188 @@ fn duplicate_class_copies_fail_closed() { |
133 | 137 | assert_eq!(stats.total_processed, 1); |
134 | 138 | } |
135 | 139 |
|
| 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 | + |
136 | 322 | #[test] |
137 | 323 | fn single_class_copy_resolves_correct_member() { |
138 | 324 | let (batch, stats) = resolve_with_candidates(false); |
|
0 commit comments