1use rustc_hash::{FxHashMap, FxHashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::snapshot::{ReferenceRecord, WorkspaceSnapshot};
6
7#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11pub struct ResolutionAudit {
12 pub totals: AuditTotals,
13 pub clusters: Vec<AuditCluster>,
14 pub zones: Vec<AuditZone>,
15}
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
18pub struct AuditTotals {
19 pub references: usize,
20 pub resolved: usize,
22 pub unique: usize,
23 pub candidate: usize,
24 pub external: usize,
25 pub sdk: usize,
26 pub dependency: usize,
27 pub injected_external: usize,
28 pub unknown_external: usize,
29 pub dynamic: usize,
30 pub blocked: usize,
31 pub unresolved: usize,
32 pub explained: usize,
33 pub weak_or_unexplained: usize,
34 pub name_match_resolved: usize,
35 pub name_match_candidate: usize,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
39pub struct AuditCluster {
40 pub id: String,
41 pub pattern: AuditPattern,
42 pub count: usize,
43 pub samples: Vec<AuditSample>,
44}
45
46#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
47pub struct AuditPattern {
48 pub status: String,
49 pub reason: String,
50 pub evidence: String,
51 pub confidence: String,
52 pub kind: String,
53 pub receiver: String,
54 pub target_shape: String,
55 pub target_head: String,
56 pub srcset: String,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct AuditSample {
61 pub file: String,
62 pub line_range: Option<(u32, u32)>,
63 pub snippet: String,
64 pub source: String,
65 pub call_name: String,
66 pub receiver: String,
67 pub target: String,
68 pub evidence: String,
69 pub constraints: Vec<String>,
70 pub candidates: Vec<String>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
74pub struct AuditZone {
75 pub zone: String,
76 pub unresolved: usize,
77 pub dominant_pattern: String,
78}
79
80#[derive(Clone, Debug)]
81pub struct AuditOptions {
82 pub cluster_limit: usize,
83 pub sample_limit: usize,
84 pub sample_offset: usize,
85 pub zone_limit: usize,
86 pub cluster: Option<String>,
87}
88
89impl Default for AuditOptions {
90 fn default() -> Self {
91 Self {
92 cluster_limit: 20,
93 sample_limit: 3,
94 sample_offset: 0,
95 zone_limit: 10,
96 cluster: None,
97 }
98 }
99}
100
101struct AuditLookups<'a> {
102 symbols: &'a crate::snapshot::RecordTable<crate::snapshot::SymbolRecord>,
103 sources: &'a [crate::snapshot::SourceFileRecord],
104 read_index: Option<&'a crate::snapshot::LinkageReadIndex>,
105 resolved_fallback: Option<
106 FxHashMap<
107 crate::snapshot::ReferenceId,
108 (
109 crate::snapshot::ResolutionEvidence,
110 crate::snapshot::SymbolId,
111 ),
112 >,
113 >,
114 name_match_resolved: FxHashSet<crate::snapshot::ReferenceId>,
115 unresolved: FxHashMap<crate::snapshot::ReferenceId, &'static str>,
116 blocked: FxHashMap<crate::snapshot::ReferenceId, &'static str>,
117 external: FxHashMap<crate::snapshot::ReferenceId, crate::snapshot::ExternalReferenceOrigin>,
118 candidates: FxHashMap<crate::snapshot::ReferenceId, &'a crate::snapshot::CandidateReference>,
119 dynamic: FxHashMap<crate::snapshot::ReferenceId, &'a crate::snapshot::DynamicReference>,
120}
121
122impl<'a> AuditLookups<'a> {
123 fn new(snapshot: &'a WorkspaceSnapshot) -> Self {
124 let read_index = snapshot.linkage.read_index.get();
125 Self {
126 symbols: &snapshot.index.symbols,
127 sources: &snapshot.index.sources,
128 read_index,
129 resolved_fallback: read_index.is_none().then(|| {
130 snapshot
131 .linkage
132 .resolved
133 .iter()
134 .map(|edge| (edge.reference, (edge.evidence, edge.target)))
135 .collect()
136 }),
137 name_match_resolved: if read_index.is_some() {
138 snapshot
139 .linkage
140 .resolved
141 .iter()
142 .filter(|edge| edge.evidence == crate::snapshot::ResolutionEvidence::NameMatch)
143 .map(|edge| edge.reference)
144 .collect()
145 } else {
146 FxHashSet::default()
147 },
148 unresolved: unresolved_reasons(&snapshot.linkage.unresolved),
149 blocked: blocked_reasons(snapshot),
150 external: external_references(&snapshot.linkage.external),
151 candidates: candidate_references(&snapshot.linkage.candidates),
152 dynamic: dynamic_references(&snapshot.linkage.dynamic),
153 }
154 }
155
156 fn symbol_identity(&self, id: crate::snapshot::SymbolId) -> Option<&'a str> {
157 self.symbols
158 .file_records(id.file())
159 .get(id.def())
160 .map(|symbol| symbol.identity.as_ref())
161 }
162
163 fn source(
164 &self,
165 id: crate::snapshot::SourceId,
166 ) -> Option<&'a crate::snapshot::SourceFileRecord> {
167 self.sources.get(id.file()).filter(|source| source.id == id)
168 }
169
170 fn resolved_target(
171 &self,
172 reference: crate::snapshot::ReferenceId,
173 ) -> Option<crate::snapshot::SymbolId> {
174 self.read_index
175 .and_then(|index| index.resolved_target(&reference))
176 .copied()
177 .or_else(|| {
178 self.resolved_fallback
179 .as_ref()
180 .and_then(|resolved| resolved.get(&reference))
181 .map(|(_, target)| *target)
182 })
183 }
184
185 fn is_name_match_resolved(&self, reference: crate::snapshot::ReferenceId) -> bool {
186 if self.read_index.is_some() {
187 return self.name_match_resolved.contains(&reference);
188 }
189 self.resolved_fallback
190 .as_ref()
191 .and_then(|resolved| resolved.get(&reference))
192 .is_some_and(|(evidence, _)| {
193 *evidence == crate::snapshot::ResolutionEvidence::NameMatch
194 })
195 }
196}
197
198fn unresolved_reasons(
199 references: &[crate::snapshot::UnresolvedReference],
200) -> FxHashMap<crate::snapshot::ReferenceId, &'static str> {
201 references
202 .iter()
203 .map(|item| (item.reference, item.reason.as_str()))
204 .collect()
205}
206
207fn blocked_reasons(
208 snapshot: &WorkspaceSnapshot,
209) -> FxHashMap<crate::snapshot::ReferenceId, &'static str> {
210 snapshot
211 .linkage
212 .blocked
213 .iter()
214 .chain(snapshot.linkage.manifest_blocked.iter())
215 .map(|item| (item.reference, item.reason.as_str()))
216 .collect()
217}
218
219fn external_references(
220 references: &[crate::snapshot::ExternalReference],
221) -> FxHashMap<crate::snapshot::ReferenceId, crate::snapshot::ExternalReferenceOrigin> {
222 references
223 .iter()
224 .map(|item| (item.reference, item.origin))
225 .collect()
226}
227
228fn candidate_references(
229 references: &[crate::snapshot::CandidateReference],
230) -> FxHashMap<crate::snapshot::ReferenceId, &crate::snapshot::CandidateReference> {
231 references
232 .iter()
233 .map(|candidate| (candidate.reference, candidate))
234 .collect()
235}
236
237fn dynamic_references(
238 references: &[crate::snapshot::DynamicReference],
239) -> FxHashMap<crate::snapshot::ReferenceId, &crate::snapshot::DynamicReference> {
240 references
241 .iter()
242 .map(|dynamic| (dynamic.reference, dynamic))
243 .collect()
244}
245
246struct AuditClassification {
247 status: &'static str,
248 reason: &'static str,
249 evidence: &'static str,
250 scope: &'static str,
251 candidate_targets: Vec<String>,
252}
253
254pub fn resolution_audit(
255 snapshot: &WorkspaceSnapshot,
256 prefix: &str,
257 options: AuditOptions,
258) -> ResolutionAudit {
259 let lookups = AuditLookups::new(snapshot);
260 let mut totals = AuditTotals::default();
261 let mut clusters: FxHashMap<AuditPattern, (usize, Vec<AuditSample>)> = FxHashMap::default();
262 let mut zones: FxHashMap<String, (usize, FxHashMap<String, usize>)> = FxHashMap::default();
263
264 for reference in snapshot.index.references.iter() {
265 let source = lookups
266 .symbol_identity(reference.source_symbol)
267 .unwrap_or_default();
268 if !prefix.is_empty() && !source.contains(prefix) {
269 continue;
270 }
271 totals.references += 1;
272 let Some(classification) = classify_reference(&lookups, reference, &mut totals) else {
273 continue;
274 };
275
276 let pattern = pattern_for(
277 classification.status,
278 classification.reason,
279 classification.evidence,
280 reference,
281 source,
282 );
283 let unresolved_cluster = classification.status == "unresolved";
284 let cluster_id = pattern_id(&pattern);
285 if options
286 .cluster
287 .as_deref()
288 .is_some_and(|expected| expected != cluster_id.as_str())
289 {
290 continue;
291 }
292 let entry = clusters.entry(pattern.clone()).or_default();
293 let sample_index = entry.0;
294 entry.0 += 1;
295 if sample_index >= options.sample_offset && entry.1.len() < options.sample_limit {
296 entry.1.push(sample_for(
297 reference,
298 source,
299 lookups
300 .source(reference.source)
301 .map_or("", |source| source.rel_path.as_str()),
302 lookups
303 .source(reference.source)
304 .map_or("", |source| source.text.as_str()),
305 classification,
306 ));
307 }
308 if unresolved_cluster {
309 let zone = zone_of(source);
310 let slot = zones.entry(zone).or_default();
311 slot.0 += 1;
312 *slot.1.entry(pattern_label(&pattern)).or_default() += 1;
313 }
314 }
315 totals.resolved = totals.unique;
316 debug_assert_eq!(
317 totals.external,
318 totals.sdk + totals.dependency + totals.injected_external + totals.unknown_external,
319 "external compatibility total must equal its provenance buckets"
320 );
321 totals.explained = totals.unique
322 + totals.candidate
323 + totals.sdk
324 + totals.dependency
325 + totals.injected_external
326 + totals.dynamic
327 + totals.blocked;
328 totals.weak_or_unexplained = totals.candidate + totals.unknown_external + totals.unresolved;
329 debug_assert_eq!(
330 totals.references,
331 totals.explained + totals.unknown_external + totals.unresolved,
332 "resolution audit categories must partition every reference"
333 );
334
335 let mut clusters: Vec<AuditCluster> = clusters
336 .into_iter()
337 .map(|(pattern, (count, samples))| AuditCluster {
338 id: pattern_id(&pattern),
339 pattern,
340 count,
341 samples,
342 })
343 .collect();
344 clusters.sort_by_key(|cluster| std::cmp::Reverse(cluster.count));
345 clusters.truncate(options.cluster_limit);
346
347 let mut zones: Vec<AuditZone> = zones
348 .into_iter()
349 .map(|(zone, (unresolved, patterns))| AuditZone {
350 zone,
351 unresolved,
352 dominant_pattern: patterns
353 .into_iter()
354 .max_by_key(|(_, count)| *count)
355 .map(|(label, _)| label)
356 .unwrap_or_default(),
357 })
358 .collect();
359 zones.sort_by_key(|zone| std::cmp::Reverse(zone.unresolved));
360 zones.truncate(options.zone_limit);
361
362 ResolutionAudit {
363 totals,
364 clusters,
365 zones,
366 }
367}
368
369fn classify_reference(
370 lookups: &AuditLookups<'_>,
371 reference: &ReferenceRecord,
372 totals: &mut AuditTotals,
373) -> Option<AuditClassification> {
374 if let Some(target) = lookups.resolved_target(reference.id) {
375 if lookups.symbol_identity(target).is_none() {
376 totals.unresolved += 1;
377 return Some(AuditClassification {
378 status: "unresolved",
379 reason: "dangling_binding",
380 evidence: "linkage",
381 scope: "unknown",
382 candidate_targets: Vec::new(),
383 });
384 }
385 if !lookups.is_name_match_resolved(reference.id) {
386 totals.unique += 1;
387 return None;
388 }
389 totals.candidate += 1;
390 totals.name_match_candidate += 1;
391 return Some(AuditClassification {
392 status: "candidate",
393 reason: "weak_name_match",
394 evidence: crate::snapshot::ResolutionEvidence::NameMatch.as_str(),
395 scope: "unknown",
396 candidate_targets: Vec::new(),
397 });
398 }
399 if let Some(candidate) = lookups.candidates.get(&reference.id) {
400 totals.candidate += 1;
401 if candidate.reason == crate::snapshot::CandidateReason::WeakNameMatch {
402 totals.name_match_candidate += 1;
403 }
404 return Some(AuditClassification {
405 status: "candidate",
406 reason: candidate.reason.as_str(),
407 evidence: candidate.evidence.as_str(),
408 scope: candidate.scope.as_str(),
409 candidate_targets: candidate_identities(&candidate.targets, lookups),
410 });
411 }
412 if let Some(origin) = lookups.external.get(&reference.id) {
413 totals.external += 1;
414 match origin {
415 crate::snapshot::ExternalReferenceOrigin::Sdk => {
416 totals.sdk += 1;
417 return None;
418 }
419 crate::snapshot::ExternalReferenceOrigin::Dependency => {
420 totals.dependency += 1;
421 return None;
422 }
423 crate::snapshot::ExternalReferenceOrigin::Injected => {
424 totals.injected_external += 1;
425 return None;
426 }
427 crate::snapshot::ExternalReferenceOrigin::UnknownExternal => {
428 totals.unknown_external += 1;
429 return Some(AuditClassification {
430 status: "unknown_external",
431 reason: origin.label(),
432 evidence: "extractor",
433 scope: "external",
434 candidate_targets: Vec::new(),
435 });
436 }
437 }
438 }
439 if let Some(dynamic) = lookups.dynamic.get(&reference.id) {
440 totals.dynamic += 1;
441 return Some(AuditClassification {
442 status: "dynamic",
443 reason: dynamic.reason.as_str(),
444 evidence: "runtime",
445 scope: "runtime",
446 candidate_targets: candidate_identities(&dynamic.candidates, lookups),
447 });
448 }
449 if let Some(reason) = lookups.blocked.get(&reference.id) {
450 totals.blocked += 1;
451 return Some(AuditClassification {
452 status: "blocked",
453 reason,
454 evidence: "policy",
455 scope: "policy",
456 candidate_targets: Vec::new(),
457 });
458 }
459 totals.unresolved += 1;
460 Some(AuditClassification {
461 status: "unresolved",
462 reason: lookups
463 .unresolved
464 .get(&reference.id)
465 .copied()
466 .unwrap_or("missing_decision"),
467 evidence: "",
468 scope: "",
469 candidate_targets: Vec::new(),
470 })
471}
472
473pub fn pattern_id(pattern: &AuditPattern) -> String {
474 let mut hash = 0xcbf29ce484222325u64;
475 for byte in pattern_label(pattern).bytes() {
476 hash ^= u64::from(byte);
477 hash = hash.wrapping_mul(0x100000001b3);
478 }
479 format!("resolution-{hash:016x}")
480}
481
482pub fn pattern_label(pattern: &AuditPattern) -> String {
483 let mut label = format!("{} {}/{}", pattern.status, pattern.confidence, pattern.kind);
484 if !pattern.reason.is_empty() {
485 label.push_str(&format!(" reason:{}", pattern.reason));
486 }
487 if !pattern.evidence.is_empty() {
488 label.push_str(&format!(" evidence:{}", pattern.evidence));
489 }
490 if !pattern.receiver.is_empty() {
491 label.push_str(&format!(" recv:{}", pattern.receiver));
492 }
493 if !pattern.target_shape.is_empty() {
494 label.push_str(&format!(" shape:{}", pattern.target_shape));
495 }
496 if !pattern.target_head.is_empty() {
497 label.push_str(&format!(" head:{}", pattern.target_head));
498 }
499 if !pattern.srcset.is_empty() {
500 label.push_str(&format!(" srcset:{}", pattern.srcset));
501 }
502 label
503}
504
505fn pattern_for(
506 status: &str,
507 reason: &str,
508 evidence: &str,
509 reference: &ReferenceRecord,
510 source: &str,
511) -> AuditPattern {
512 let target = reference.target_identity.as_ref();
513 AuditPattern {
514 status: status.to_string(),
515 reason: reason.to_string(),
516 evidence: evidence.to_string(),
517 confidence: reference.confidence.clone().unwrap_or_default(),
518 kind: reference.kind.clone(),
519 receiver: receiver_class(reference).to_string(),
520 target_shape: target_shape(target),
521 target_head: target_head(target, source),
522 srcset: segment_value(target, "srcset:"),
523 }
524}
525
526fn sample_for(
527 reference: &ReferenceRecord,
528 source: &str,
529 file: &str,
530 source_text: &str,
531 classification: AuditClassification,
532) -> AuditSample {
533 AuditSample {
534 file: file.to_string(),
535 line_range: reference.line_range,
536 snippet: source_excerpt(source_text, reference.line_range),
537 source: identity_tail(source, 4),
538 call_name: reference.call_name.clone().unwrap_or_default(),
539 receiver: reference.receiver.clone().unwrap_or_default(),
540 target: identity_tail(reference.target_identity.as_ref(), 5),
541 evidence: classification.evidence.to_string(),
542 constraints: sample_constraints(
543 reference,
544 classification.reason,
545 classification.evidence,
546 classification.scope,
547 ),
548 candidates: classification.candidate_targets,
549 }
550}
551
552fn source_excerpt(source: &str, line_range: Option<(u32, u32)>) -> String {
553 let Some((start, end)) = line_range else {
554 return String::new();
555 };
556 let line_count = end.saturating_sub(start).saturating_add(1).min(3) as usize;
557 let excerpt = source
558 .lines()
559 .skip(start.saturating_sub(1) as usize)
560 .take(line_count)
561 .map(str::trim)
562 .filter(|line| !line.is_empty())
563 .collect::<Vec<_>>()
564 .join(" ");
565 excerpt.chars().take(240).collect()
566}
567
568fn sample_constraints(
569 reference: &ReferenceRecord,
570 reason: &str,
571 evidence: &str,
572 scope: &str,
573) -> Vec<String> {
574 let mut constraints = vec![format!("kind:{}", reference.kind)];
575 for (label, value) in [
576 ("reason", Some(reason)),
577 ("evidence", Some(evidence)),
578 ("scope", Some(scope)),
579 ("confidence", reference.confidence.as_deref()),
580 ] {
581 if let Some(value) = value.filter(|value| !value.is_empty()) {
582 constraints.push(format!("{label}:{value}"));
583 }
584 }
585 if let Some(arity) = reference.call_arity {
586 constraints.push(format!("arity:{arity}"));
587 }
588 constraints
589}
590
591fn candidate_identities(
592 candidates: &[crate::snapshot::SymbolId],
593 lookups: &AuditLookups<'_>,
594) -> Vec<String> {
595 candidates
596 .iter()
597 .filter_map(|candidate| lookups.symbol_identity(*candidate))
598 .take(8)
599 .map(|identity| identity_tail(identity, 5))
600 .collect()
601}
602
603fn receiver_class(reference: &ReferenceRecord) -> &'static str {
604 match reference.receiver.as_deref() {
605 None | Some("") => "",
606 Some("call") => "call",
607 Some("self" | "cls" | "this") => "self",
608 Some(_) => "named",
609 }
610}
611
612fn target_shape(target: &str) -> String {
615 let mut kinds: Vec<&str> = Vec::new();
616 for segment in target.split('/') {
617 let Some((kind, _)) = segment.split_once(':') else {
618 continue;
619 };
620 if kind.contains('+') || kind.is_empty() {
621 continue;
622 }
623 kinds.push(kind);
624 }
625 let mut collapsed: Vec<String> = Vec::new();
626 for kind in kinds {
627 match collapsed.last_mut() {
628 Some(last) if last.trim_end_matches('+') == kind => {
629 if !last.ends_with('+') {
630 last.push('+');
631 }
632 }
633 _ => collapsed.push(kind.to_string()),
634 }
635 }
636 collapsed.join("/")
637}
638
639fn target_head(target: &str, source: &str) -> String {
640 if let Some(root) = target
641 .split('/')
642 .find_map(|segment| segment.strip_prefix("external_pkg:"))
643 {
644 return format!("external_pkg:{root}");
645 }
646 if let (Some(source_module), Some(target_module)) =
647 (module_prefix(source), module_prefix(target))
648 && source_module == target_module
649 {
650 return "own_module".to_string();
651 }
652 String::new()
653}
654
655fn module_prefix(identity: &str) -> Option<&str> {
656 let idx = identity.find("/module:")?;
657 let rest = &identity[idx + 1..];
658 let end = rest
659 .find('/')
660 .map(|i| idx + 1 + i)
661 .unwrap_or(identity.len());
662 Some(&identity[..end])
663}
664
665fn segment_value(identity: &str, prefix: &str) -> String {
666 identity
667 .split('/')
668 .find_map(|segment| segment.strip_prefix(prefix))
669 .unwrap_or_default()
670 .to_string()
671}
672
673fn zone_of(source: &str) -> String {
674 match module_prefix(source) {
675 Some(module) => identity_tail(module, 4),
676 None => identity_tail(source, 3),
677 }
678}
679
680fn identity_tail(identity: &str, segments: usize) -> String {
681 let parts: Vec<&str> = identity
682 .split('/')
683 .filter(|part| !part.is_empty())
684 .collect();
685 let start = parts.len().saturating_sub(segments);
686 parts[start..].join("/")
687}
688
689#[cfg(test)]
690mod tests {
691 use std::sync::Arc;
692
693 use super::*;
694 use crate::snapshot::{
695 CandidateReason, CandidateReference, CandidateScope, ChangeOverlay, CodeIndex,
696 DynamicReason, DynamicReference, ExternalReference, ExternalReferenceOrigin, LinkageEdge,
697 LinkageReadIndexHandle, LinkageSnapshot, ReferenceId, ResourceGeneration, SourceCatalog,
698 SourceFileRecord, SourceId, SymbolId, SymbolRecord, UnresolvedReason, UnresolvedReference,
699 WorkspaceSnapshot, WorkspaceTimings,
700 };
701
702 #[test]
703 fn dangling_resolved_binding_is_counted_unresolved_not_unique() {
704 let generation = ResourceGeneration::new(1);
705 let source = SourceId::at(0);
706 let source_symbol = SymbolId::at(0, 0);
707 let missing_target = SymbolId::at(9, 9);
708 let mut source_record = SymbolRecord::new(source_symbol, source, "run", "function");
709 source_record.identity =
710 Arc::from("code+moniker://./lang:python/module:sample/function:run");
711 let reference = ReferenceRecord::new(
712 ReferenceId::at(0, 0),
713 source,
714 source_symbol,
715 "code+moniker://./lang:python/module:sample/method:work",
716 "method_call",
717 Some((10, 10)),
718 );
719 let resolved = vec![LinkageEdge::new(ReferenceId::at(0, 0), missing_target)];
720 let linkage = LinkageSnapshot {
721 generation,
722 index_generation: generation,
723 resolved_refs: 1,
724 candidate_refs: 0,
725 external_refs: 0,
726 dynamic_refs: 0,
727 blocked_refs: 0,
728 manifest_blocked_refs: 0,
729 unresolved_refs: 0,
730 ambiguous_refs: 0,
731 read_index: LinkageReadIndexHandle::from_edges(&resolved),
732 resolved,
733 candidates: Vec::new(),
734 external: Vec::new(),
735 dynamic: Vec::new(),
736 blocked: Vec::new(),
737 manifest_blocked: Vec::new(),
738 unresolved: Vec::new(),
739 };
740 let mut index = CodeIndex::with_references(
741 generation,
742 generation,
743 vec![source_record],
744 vec![reference],
745 );
746 index.sources.push(SourceFileRecord {
747 id: source,
748 uri: "file://sample.py".to_string(),
749 source_root: 0,
750 path: "sample.py".to_string(),
751 rel_path: "src/sample.py".to_string(),
752 anchor: "sample.py".to_string(),
753 language: "python".to_string(),
754 text: (0..20).map(|_| "value.work()\n").collect(),
755 });
756 let snapshot = WorkspaceSnapshot {
757 generation,
758 catalog: SourceCatalog::new(generation, Vec::new()),
759 index,
760 linkage,
761 changes: ChangeOverlay::new(generation, generation, generation, Vec::new()),
762 timings: WorkspaceTimings::default(),
763 };
764
765 let audit = resolution_audit(&snapshot, "lang:python", AuditOptions::default());
766
767 assert_eq!(
768 audit.totals.unique, 0,
769 "a binding to a symbol absent from the index must not count unique"
770 );
771 assert_eq!(audit.totals.unresolved, 1, "{:?}", audit.totals);
772 assert!(
773 audit
774 .clusters
775 .iter()
776 .any(|cluster| cluster.pattern.reason == "dangling_binding"),
777 "the dangling binding must cluster under its own reason: {:?}",
778 audit.clusters
779 );
780 }
781
782 #[test]
783 fn totals_partition_unique_candidate_external_dynamic_blocked_and_unresolved() {
784 let generation = ResourceGeneration::new(1);
785 let source = SourceId::at(0);
786 let source_symbol = SymbolId::at(0, 0);
787 let candidate_target = SymbolId::at(0, 1);
788 let mut source_record = SymbolRecord::new(source_symbol, source, "run", "function");
789 source_record.identity =
790 Arc::from("code+moniker://./lang:python/module:sample/function:run");
791 let mut target_record = SymbolRecord::new(candidate_target, source, "Target", "class");
792 target_record.identity =
793 Arc::from("code+moniker://./lang:python/module:sample/class:Target");
794 let references = (0..9)
795 .map(|idx| {
796 ReferenceRecord::new(
797 ReferenceId::at(0, idx),
798 source,
799 source_symbol,
800 "code+moniker://./lang:python/module:sample/method:work",
801 "method_call",
802 Some((10 + idx as u32, 10 + idx as u32)),
803 )
804 .with_metadata(
805 Some("resolved".to_string()),
806 Some("value".to_string()),
807 None,
808 )
809 })
810 .collect::<Vec<_>>();
811 let linkage = fixture_linkage(generation, candidate_target);
812 let mut index = CodeIndex::with_references(
813 generation,
814 generation,
815 vec![source_record, target_record],
816 references,
817 );
818 index.sources.push(SourceFileRecord {
819 id: source,
820 uri: "file://sample.py".to_string(),
821 source_root: 0,
822 path: "sample.py".to_string(),
823 rel_path: "src/sample.py".to_string(),
824 anchor: "sample.py".to_string(),
825 language: "python".to_string(),
826 text: (0..20).map(|_| "value.work()\n").collect(),
827 });
828 let snapshot = WorkspaceSnapshot {
829 generation,
830 catalog: SourceCatalog::new(generation, Vec::new()),
831 index,
832 linkage,
833 changes: ChangeOverlay::new(generation, generation, generation, Vec::new()),
834 timings: WorkspaceTimings::default(),
835 };
836
837 let audit = resolution_audit(&snapshot, "lang:python", AuditOptions::default());
838
839 assert_eq!(audit.totals.references, 9);
840 assert_eq!(audit.totals.unique, 1);
841 assert_eq!(audit.totals.candidate, 1);
842 assert_eq!(audit.totals.external, 4);
843 assert_eq!(audit.totals.sdk, 1);
844 assert_eq!(audit.totals.dependency, 1);
845 assert_eq!(audit.totals.injected_external, 1);
846 assert_eq!(audit.totals.unknown_external, 1);
847 assert_eq!(audit.totals.dynamic, 1);
848 assert_eq!(audit.totals.blocked, 1);
849 assert_eq!(audit.totals.unresolved, 1);
850 assert_eq!(audit.totals.explained, 7);
851 assert_eq!(audit.totals.weak_or_unexplained, 3);
852 assert_audit_clusters(&audit);
853 let mut without_read_index = snapshot.clone();
854 without_read_index.linkage.read_index = LinkageReadIndexHandle::default();
855 let fallback =
856 resolution_audit(&without_read_index, "lang:python", AuditOptions::default());
857 assert_eq!(fallback.totals.unique, audit.totals.unique);
858 assert_eq!(fallback.totals.candidate, audit.totals.candidate);
859 assert_eq!(fallback.totals.unresolved, audit.totals.unresolved);
860 let candidate_cluster = audit
861 .clusters
862 .iter()
863 .find(|cluster| cluster.pattern.status == "candidate")
864 .expect("candidate cluster");
865 let drill_down = resolution_audit(
866 &snapshot,
867 "lang:python",
868 AuditOptions {
869 cluster: Some(candidate_cluster.id.clone()),
870 sample_offset: 0,
871 sample_limit: 1,
872 ..AuditOptions::default()
873 },
874 );
875 assert_eq!(drill_down.clusters.len(), 1);
876 assert_eq!(drill_down.clusters[0].id, candidate_cluster.id);
877 assert_eq!(drill_down.clusters[0].samples.len(), 1);
878 }
879
880 fn fixture_linkage(
881 generation: ResourceGeneration,
882 candidate_target: SymbolId,
883 ) -> LinkageSnapshot {
884 let resolved = vec![LinkageEdge::new(ReferenceId::at(0, 0), candidate_target)];
885 let manifest_blocked = UnresolvedReference::new(
886 ReferenceId::at(0, 4),
887 "code+moniker://./lang:python/module:sample/method:work",
888 UnresolvedReason::ManifestBlocked,
889 );
890 LinkageSnapshot {
891 generation,
892 index_generation: generation,
893 resolved_refs: 1,
894 candidate_refs: 1,
895 external_refs: 4,
896 dynamic_refs: 1,
897 blocked_refs: 1,
898 manifest_blocked_refs: 1,
899 unresolved_refs: 1,
900 ambiguous_refs: 1,
901 read_index: LinkageReadIndexHandle::from_edges(&resolved),
902 resolved,
903 candidates: vec![CandidateReference::new(
904 ReferenceId::at(0, 1),
905 vec![candidate_target],
906 CandidateReason::WeakNameMatch,
907 CandidateScope::Global,
908 crate::snapshot::ResolutionEvidence::NameMatch,
909 )],
910 external: vec![
911 ExternalReference::new(
912 ReferenceId::at(0, 2),
913 "code+moniker://./external_pkg:sample/path:work",
914 ExternalReferenceOrigin::Dependency,
915 ),
916 ExternalReference::new(
917 ReferenceId::at(0, 6),
918 "code+moniker://./sdk:python/path:builtins/path:print",
919 ExternalReferenceOrigin::Sdk,
920 ),
921 ExternalReference::new(
922 ReferenceId::at(0, 7),
923 "code+moniker://./external_pkg:generated/path:work",
924 ExternalReferenceOrigin::Injected,
925 ),
926 ExternalReference::new(
927 ReferenceId::at(0, 8),
928 "code+moniker://./external_pkg:unknown/path:work",
929 ExternalReferenceOrigin::UnknownExternal,
930 ),
931 ],
932 dynamic: vec![DynamicReference::new(
933 ReferenceId::at(0, 3),
934 "code+moniker://./lang:python/module:sample/method:work",
935 DynamicReason::DynamicAttribute,
936 Vec::new(),
937 )],
938 blocked: vec![manifest_blocked.clone()],
939 manifest_blocked: vec![manifest_blocked],
940 unresolved: vec![UnresolvedReference::new(
941 ReferenceId::at(0, 5),
942 "code+moniker://./lang:python/module:sample/method:work",
943 UnresolvedReason::NoCandidate,
944 )],
945 }
946 }
947
948 fn assert_audit_clusters(audit: &ResolutionAudit) {
949 assert!(audit.clusters.iter().any(|cluster| {
950 cluster.pattern.status == "candidate"
951 && cluster.pattern.reason == "weak_name_match"
952 && cluster.pattern.evidence == "name_match"
953 && cluster.samples.iter().any(|sample| {
954 sample.file == "src/sample.py"
955 && sample.line_range == Some((11, 11))
956 && sample.snippet == "value.work()"
957 && sample.constraints.contains(&"scope:global".to_string())
958 && sample
959 .candidates
960 .iter()
961 .any(|candidate| candidate.ends_with("class:Target"))
962 })
963 }));
964 assert!(audit.clusters.iter().any(|cluster| {
965 cluster.pattern.status == "dynamic" && cluster.pattern.reason == "dynamic_attribute"
966 }));
967 assert!(audit.clusters.iter().any(|cluster| {
968 cluster.pattern.status == "blocked"
969 && cluster.pattern.reason == "manifest_blocked"
970 && cluster.pattern.evidence == "policy"
971 }));
972 assert!(audit.clusters.iter().any(|cluster| {
973 cluster.pattern.status == "unknown_external"
974 && cluster.pattern.reason == "unknown_external"
975 && cluster.pattern.evidence == "extractor"
976 }));
977 }
978}