1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::snapshot::{ReferenceRecord, WorkspaceSnapshot};
6
7#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
12pub struct ResolutionAudit {
13 pub totals: AuditTotals,
14 pub clusters: Vec<AuditCluster>,
15 pub zones: Vec<AuditZone>,
16}
17
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
19pub struct AuditTotals {
20 pub references: usize,
21 pub resolved: usize,
22 pub external: usize,
23 pub blocked: usize,
24 pub unresolved: usize,
25 pub name_match_resolved: usize,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29pub struct AuditCluster {
30 pub pattern: AuditPattern,
31 pub count: usize,
32 pub samples: Vec<AuditSample>,
33}
34
35#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
36pub struct AuditPattern {
37 pub status: String,
38 pub confidence: String,
39 pub kind: String,
40 pub receiver: String,
41 pub target_shape: String,
42 pub target_head: String,
43 pub srcset: String,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct AuditSample {
48 pub source: String,
49 pub call_name: String,
50 pub receiver: String,
51 pub target: String,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
55pub struct AuditZone {
56 pub zone: String,
57 pub unresolved: usize,
58 pub dominant_pattern: String,
59}
60
61#[derive(Clone, Copy, Debug)]
62pub struct AuditOptions {
63 pub cluster_limit: usize,
64 pub sample_limit: usize,
65 pub zone_limit: usize,
66}
67
68impl Default for AuditOptions {
69 fn default() -> Self {
70 Self {
71 cluster_limit: 20,
72 sample_limit: 3,
73 zone_limit: 10,
74 }
75 }
76}
77
78pub fn resolution_audit(
79 snapshot: &WorkspaceSnapshot,
80 prefix: &str,
81 options: AuditOptions,
82) -> ResolutionAudit {
83 let sources: HashMap<_, _> = snapshot
84 .index
85 .symbols
86 .iter()
87 .map(|symbol| (symbol.id, symbol.identity.as_ref()))
88 .collect();
89 let unresolved: HashMap<_, _> = snapshot
90 .linkage
91 .unresolved
92 .iter()
93 .map(|item| (item.reference, ()))
94 .collect();
95 let blocked: HashMap<_, _> = snapshot
96 .linkage
97 .manifest_blocked
98 .iter()
99 .map(|item| (item.reference, ()))
100 .collect();
101 let external: HashMap<_, _> = snapshot
102 .linkage
103 .external
104 .iter()
105 .map(|item| (item.reference, ()))
106 .collect();
107 let resolved: HashMap<_, _> = snapshot
108 .linkage
109 .resolved
110 .iter()
111 .map(|edge| (edge.reference, ()))
112 .collect();
113
114 let mut totals = AuditTotals::default();
115 let mut clusters: HashMap<AuditPattern, (usize, Vec<AuditSample>)> = HashMap::new();
116 let mut zones: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
117
118 for reference in snapshot.index.references.iter() {
119 let source = sources
120 .get(&reference.source_symbol)
121 .copied()
122 .unwrap_or_default();
123 if !prefix.is_empty() && !source.contains(prefix) {
124 continue;
125 }
126 totals.references += 1;
127 let status = if resolved.contains_key(&reference.id) {
128 totals.resolved += 1;
129 if reference.confidence.as_deref() != Some("name_match") {
130 continue;
131 }
132 totals.name_match_resolved += 1;
133 "resolved_name_match"
134 } else if external.contains_key(&reference.id) {
135 totals.external += 1;
136 continue;
137 } else if blocked.contains_key(&reference.id) {
138 totals.blocked += 1;
139 continue;
140 } else if unresolved.contains_key(&reference.id) {
141 totals.unresolved += 1;
142 "unresolved"
143 } else {
144 continue;
145 };
146
147 let pattern = pattern_for(status, reference, source);
148 let entry = clusters.entry(pattern.clone()).or_default();
149 entry.0 += 1;
150 if entry.1.len() < options.sample_limit {
151 entry.1.push(sample_for(reference, source));
152 }
153 if status == "unresolved" {
154 let zone = zone_of(source);
155 let slot = zones.entry(zone).or_default();
156 slot.0 += 1;
157 *slot.1.entry(pattern_label(&pattern)).or_default() += 1;
158 }
159 }
160
161 let mut clusters: Vec<AuditCluster> = clusters
162 .into_iter()
163 .map(|(pattern, (count, samples))| AuditCluster {
164 pattern,
165 count,
166 samples,
167 })
168 .collect();
169 clusters.sort_by_key(|cluster| std::cmp::Reverse(cluster.count));
170 clusters.truncate(options.cluster_limit);
171
172 let mut zones: Vec<AuditZone> = zones
173 .into_iter()
174 .map(|(zone, (unresolved, patterns))| AuditZone {
175 zone,
176 unresolved,
177 dominant_pattern: patterns
178 .into_iter()
179 .max_by_key(|(_, count)| *count)
180 .map(|(label, _)| label)
181 .unwrap_or_default(),
182 })
183 .collect();
184 zones.sort_by_key(|zone| std::cmp::Reverse(zone.unresolved));
185 zones.truncate(options.zone_limit);
186
187 ResolutionAudit {
188 totals,
189 clusters,
190 zones,
191 }
192}
193
194pub fn pattern_label(pattern: &AuditPattern) -> String {
195 let mut label = format!("{} {}/{}", pattern.status, pattern.confidence, pattern.kind);
196 if !pattern.receiver.is_empty() {
197 label.push_str(&format!(" recv:{}", pattern.receiver));
198 }
199 if !pattern.target_shape.is_empty() {
200 label.push_str(&format!(" shape:{}", pattern.target_shape));
201 }
202 if !pattern.target_head.is_empty() {
203 label.push_str(&format!(" head:{}", pattern.target_head));
204 }
205 if !pattern.srcset.is_empty() {
206 label.push_str(&format!(" srcset:{}", pattern.srcset));
207 }
208 label
209}
210
211fn pattern_for(status: &str, reference: &ReferenceRecord, source: &str) -> AuditPattern {
212 let target = reference.target_identity.as_ref();
213 AuditPattern {
214 status: status.to_string(),
215 confidence: reference.confidence.clone().unwrap_or_default(),
216 kind: reference.kind.clone(),
217 receiver: receiver_class(reference).to_string(),
218 target_shape: target_shape(target),
219 target_head: target_head(target, source),
220 srcset: segment_value(target, "srcset:"),
221 }
222}
223
224fn sample_for(reference: &ReferenceRecord, source: &str) -> AuditSample {
225 AuditSample {
226 source: identity_tail(source, 4),
227 call_name: reference.call_name.clone().unwrap_or_default(),
228 receiver: reference.receiver.clone().unwrap_or_default(),
229 target: identity_tail(reference.target_identity.as_ref(), 5),
230 }
231}
232
233fn receiver_class(reference: &ReferenceRecord) -> &'static str {
234 match reference.receiver.as_deref() {
235 None | Some("") => "",
236 Some("call") => "call",
237 Some("self" | "cls" | "this") => "self",
238 Some(_) => "named",
239 }
240}
241
242fn target_shape(target: &str) -> String {
245 let mut kinds: Vec<&str> = Vec::new();
246 for segment in target.split('/') {
247 let Some((kind, _)) = segment.split_once(':') else {
248 continue;
249 };
250 if kind.contains('+') || kind.is_empty() {
251 continue;
252 }
253 kinds.push(kind);
254 }
255 let mut collapsed: Vec<String> = Vec::new();
256 for kind in kinds {
257 match collapsed.last_mut() {
258 Some(last) if last.trim_end_matches('+') == kind => {
259 if !last.ends_with('+') {
260 last.push('+');
261 }
262 }
263 _ => collapsed.push(kind.to_string()),
264 }
265 }
266 collapsed.join("/")
267}
268
269fn target_head(target: &str, source: &str) -> String {
270 if let Some(root) = target
271 .split('/')
272 .find_map(|segment| segment.strip_prefix("external_pkg:"))
273 {
274 return format!("external_pkg:{root}");
275 }
276 if let (Some(source_module), Some(target_module)) =
277 (module_prefix(source), module_prefix(target))
278 && source_module == target_module
279 {
280 return "own_module".to_string();
281 }
282 String::new()
283}
284
285fn module_prefix(identity: &str) -> Option<&str> {
286 let idx = identity.find("/module:")?;
287 let rest = &identity[idx + 1..];
288 let end = rest
289 .find('/')
290 .map(|i| idx + 1 + i)
291 .unwrap_or(identity.len());
292 Some(&identity[..end])
293}
294
295fn segment_value(identity: &str, prefix: &str) -> String {
296 identity
297 .split('/')
298 .find_map(|segment| segment.strip_prefix(prefix))
299 .unwrap_or_default()
300 .to_string()
301}
302
303fn zone_of(source: &str) -> String {
304 match module_prefix(source) {
305 Some(module) => identity_tail(module, 4),
306 None => identity_tail(source, 3),
307 }
308}
309
310fn identity_tail(identity: &str, segments: usize) -> String {
311 let parts: Vec<&str> = identity
312 .split('/')
313 .filter(|part| !part.is_empty())
314 .collect();
315 let start = parts.len().saturating_sub(segments);
316 parts[start..].join("/")
317}