1use std::collections::BTreeMap;
4use std::path::Path;
5
6use fallow_engine::duplicates::CloneFingerprintSet;
7use fallow_types::duplicates::{CloneGroup, DuplicationReport, DuplicationStats};
8use fallow_types::results::AnalysisResults;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use crate::{
12 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
13 DuplicationGroup, DuplicationGrouping,
14};
15
16pub const UNOWNED_GROUP_LABEL: &str = "(unowned)";
18
19#[must_use]
25pub fn health_signal_header_part(results: &AnalysisResults) -> String {
26 let kinds = [
27 (results.prop_drilling_chains.len(), "prop drilling chain"),
28 (results.thin_wrappers.len(), "thin wrapper"),
29 (results.duplicate_prop_shapes.len(), "duplicate prop shape"),
30 ];
31 let count: usize = kinds.iter().map(|(n, _)| n).sum();
32 if count == 0 {
33 return String::new();
34 }
35 let parts: Vec<String> = kinds
36 .iter()
37 .filter(|(n, _)| *n > 0)
38 .map(|(n, label)| format!("{n} {label}{}", plural_suffix(*n)))
39 .collect();
40 format!(
41 "; {count} health signal{}: {}",
42 plural_suffix(count),
43 parts.join(", ")
44 )
45}
46
47const fn plural_suffix(count: usize) -> &'static str {
48 if count == 1 { "" } else { "s" }
49}
50
51pub struct ResultGroup {
53 pub key: String,
55 pub owners: Option<Vec<String>>,
60 pub results: AnalysisResults,
62}
63
64#[must_use]
69pub fn group_analysis_results_with<F, O>(
70 results: &AnalysisResults,
71 mut key_for_path: F,
72 mut owners_for_path: O,
73 include_owners: bool,
74) -> Vec<ResultGroup>
75where
76 F: FnMut(&Path) -> String,
77 O: FnMut(&Path) -> Option<Vec<String>>,
78{
79 let mut group_owners: FxHashMap<String, Vec<String>> = FxHashMap::default();
80 let mut builder = GroupingBuilder::new(|path: &Path| {
81 let key = key_for_path(path);
82 if include_owners && !group_owners.contains_key(&key) {
83 let owners = owners_for_path(path).unwrap_or_default();
84 group_owners.insert(key.clone(), owners);
85 }
86 key
87 });
88 builder.group_symbol_issues(results);
89 builder.group_dependency_issues(results);
90 builder.group_relationship_issues(results);
91 builder.group_workspace_config_issues(results);
92 builder.group_component_health_issues(results);
93
94 finalize_groups(builder.into_groups(), group_owners, include_owners)
95}
96
97struct GroupingBuilder<F> {
98 groups: FxHashMap<String, AnalysisResults>,
99 key_for: F,
100}
101
102impl<F> GroupingBuilder<F>
103where
104 F: FnMut(&Path) -> String,
105{
106 fn new(key_for: F) -> Self {
107 Self {
108 groups: FxHashMap::default(),
109 key_for,
110 }
111 }
112
113 fn entry_for_path(&mut self, path: &Path) -> &mut AnalysisResults {
114 let key = (self.key_for)(path);
115 self.groups.entry(key).or_default()
116 }
117
118 fn entry_for_key(&mut self, key: String) -> &mut AnalysisResults {
119 self.groups.entry(key).or_default()
120 }
121
122 fn into_groups(self) -> FxHashMap<String, AnalysisResults> {
123 self.groups
124 }
125
126 fn group_symbol_issues(&mut self, results: &AnalysisResults) {
127 for item in &results.unused_files {
128 self.entry_for_path(&item.file.path)
129 .unused_files
130 .push(item.clone());
131 }
132 for item in &results.unused_exports {
133 self.entry_for_path(&item.export.path)
134 .unused_exports
135 .push(item.clone());
136 }
137 for item in &results.unused_types {
138 self.entry_for_path(&item.export.path)
139 .unused_types
140 .push(item.clone());
141 }
142 for item in &results.private_type_leaks {
143 self.entry_for_path(&item.leak.path)
144 .private_type_leaks
145 .push(item.clone());
146 }
147 for item in &results.deprecated_exports_in_use {
148 self.entry_for_path(&item.export.path)
149 .deprecated_exports_in_use
150 .push(item.clone());
151 }
152 for item in &results.unused_enum_members {
153 self.entry_for_path(&item.member.path)
154 .unused_enum_members
155 .push(item.clone());
156 }
157 for item in &results.unused_class_members {
158 self.entry_for_path(&item.member.path)
159 .unused_class_members
160 .push(item.clone());
161 }
162 for item in &results.unused_store_members {
163 self.entry_for_path(&item.member.path)
164 .unused_store_members
165 .push(item.clone());
166 }
167 for item in &results.unresolved_imports {
168 self.entry_for_path(&item.import.path)
169 .unresolved_imports
170 .push(item.clone());
171 }
172 }
173
174 fn group_dependency_issues(&mut self, results: &AnalysisResults) {
175 for item in &results.unused_dependencies {
176 self.entry_for_path(&item.dep.path)
177 .unused_dependencies
178 .push(item.clone());
179 }
180 for item in &results.unused_dev_dependencies {
181 self.entry_for_path(&item.dep.path)
182 .unused_dev_dependencies
183 .push(item.clone());
184 }
185 for item in &results.unused_optional_dependencies {
186 self.entry_for_path(&item.dep.path)
187 .unused_optional_dependencies
188 .push(item.clone());
189 }
190 for item in &results.type_only_dependencies {
191 self.entry_for_path(&item.dep.path)
192 .type_only_dependencies
193 .push(item.clone());
194 }
195 for item in &results.test_only_dependencies {
196 self.entry_for_path(&item.dep.path)
197 .test_only_dependencies
198 .push(item.clone());
199 }
200 for item in &results.dev_dependencies_in_production {
201 self.entry_for_path(&item.dep.path)
202 .dev_dependencies_in_production
203 .push(item.clone());
204 }
205
206 for item in &results.unlisted_dependencies {
207 let key = item.dep.imported_from.first().map_or_else(
208 || UNOWNED_GROUP_LABEL.to_string(),
209 |site| (self.key_for)(&site.path),
210 );
211 self.entry_for_key(key)
212 .unlisted_dependencies
213 .push(item.clone());
214 }
215 for item in &results.duplicate_exports {
216 let key = item.export.locations.first().map_or_else(
217 || UNOWNED_GROUP_LABEL.to_string(),
218 |loc| (self.key_for)(&loc.path),
219 );
220 self.entry_for_key(key).duplicate_exports.push(item.clone());
221 }
222 }
223
224 fn group_relationship_issues(&mut self, results: &AnalysisResults) {
225 self.group_structure_issues(results);
226 self.group_framework_boundary_issues(results);
227 self.group_component_contract_issues(results);
228 }
229
230 fn group_structure_issues(&mut self, results: &AnalysisResults) {
231 for item in &results.circular_dependencies {
232 let key = item
233 .cycle
234 .files
235 .first()
236 .map_or_else(|| UNOWNED_GROUP_LABEL.to_string(), |f| (self.key_for)(f));
237 self.entry_for_key(key)
238 .circular_dependencies
239 .push(item.clone());
240 }
241 for item in &results.re_export_cycles {
242 let key = item
243 .cycle
244 .files
245 .first()
246 .map_or_else(|| UNOWNED_GROUP_LABEL.to_string(), |f| (self.key_for)(f));
247 self.entry_for_key(key).re_export_cycles.push(item.clone());
248 }
249 for item in &results.package_cycles {
252 let key = item.cycle.edges.first().map_or_else(
253 || UNOWNED_GROUP_LABEL.to_string(),
254 |edge| (self.key_for)(&edge.path),
255 );
256 self.entry_for_key(key).package_cycles.push(item.clone());
257 }
258 for item in &results.boundary_violations {
259 self.entry_for_path(&item.violation.from_path)
260 .boundary_violations
261 .push(item.clone());
262 }
263 for item in &results.boundary_coverage_violations {
264 self.entry_for_path(&item.violation.path)
265 .boundary_coverage_violations
266 .push(item.clone());
267 }
268 for item in &results.boundary_call_violations {
269 self.entry_for_path(&item.violation.path)
270 .boundary_call_violations
271 .push(item.clone());
272 }
273 for item in &results.policy_violations {
274 self.entry_for_path(&item.violation.path)
275 .policy_violations
276 .push(item.clone());
277 }
278 }
279
280 fn group_framework_boundary_issues(&mut self, results: &AnalysisResults) {
281 for item in &results.invalid_client_exports {
282 self.entry_for_path(&item.export.path)
283 .invalid_client_exports
284 .push(item.clone());
285 }
286 for item in &results.mixed_client_server_barrels {
287 self.entry_for_path(&item.barrel.path)
288 .mixed_client_server_barrels
289 .push(item.clone());
290 }
291 for item in &results.misplaced_directives {
292 self.entry_for_path(&item.directive_site.path)
293 .misplaced_directives
294 .push(item.clone());
295 }
296 for item in &results.unprovided_injects {
297 self.entry_for_path(&item.inject.path)
298 .unprovided_injects
299 .push(item.clone());
300 }
301 for item in &results.unrendered_components {
302 self.entry_for_path(&item.component.path)
303 .unrendered_components
304 .push(item.clone());
305 }
306 for item in &results.route_collisions {
307 self.entry_for_path(&item.collision.path)
308 .route_collisions
309 .push(item.clone());
310 }
311 for item in &results.dynamic_segment_name_conflicts {
312 self.entry_for_path(&item.conflict.path)
313 .dynamic_segment_name_conflicts
314 .push(item.clone());
315 }
316 }
317
318 fn group_component_contract_issues(&mut self, results: &AnalysisResults) {
319 for item in &results.unused_component_props {
320 self.entry_for_path(&item.prop.path)
321 .unused_component_props
322 .push(item.clone());
323 }
324 for item in &results.absent_component_props {
325 self.entry_for_path(&item.prop.path)
326 .absent_component_props
327 .push(item.clone());
328 }
329 for item in &results.unused_component_emits {
330 self.entry_for_path(&item.emit.path)
331 .unused_component_emits
332 .push(item.clone());
333 }
334 for item in &results.unused_component_inputs {
335 self.entry_for_path(&item.input.path)
336 .unused_component_inputs
337 .push(item.clone());
338 }
339 for item in &results.unused_component_outputs {
340 self.entry_for_path(&item.output.path)
341 .unused_component_outputs
342 .push(item.clone());
343 }
344 for item in &results.unused_svelte_events {
345 self.entry_for_path(&item.event.path)
346 .unused_svelte_events
347 .push(item.clone());
348 }
349 for item in &results.unused_server_actions {
350 self.entry_for_path(&item.action.path)
351 .unused_server_actions
352 .push(item.clone());
353 }
354 for item in &results.unused_load_data_keys {
355 self.entry_for_path(&item.key.path)
356 .unused_load_data_keys
357 .push(item.clone());
358 }
359 for item in &results.stale_suppressions {
360 self.entry_for_path(&item.path)
361 .stale_suppressions
362 .push(item.clone());
363 }
364 }
365
366 fn group_workspace_config_issues(&mut self, results: &AnalysisResults) {
367 for item in &results.unused_catalog_entries {
368 self.entry_for_path(&item.entry.path)
369 .unused_catalog_entries
370 .push(item.clone());
371 }
372 for item in &results.empty_catalog_groups {
373 self.entry_for_path(&item.group.path)
374 .empty_catalog_groups
375 .push(item.clone());
376 }
377 for item in &results.unresolved_catalog_references {
378 self.entry_for_path(&item.reference.path)
379 .unresolved_catalog_references
380 .push(item.clone());
381 }
382 for item in &results.unused_dependency_overrides {
383 self.entry_for_path(&item.entry.path)
384 .unused_dependency_overrides
385 .push(item.clone());
386 }
387 for item in &results.misconfigured_dependency_overrides {
388 self.entry_for_path(&item.entry.path)
389 .misconfigured_dependency_overrides
390 .push(item.clone());
391 }
392 }
393
394 fn group_component_health_issues(&mut self, results: &AnalysisResults) {
397 for item in &results.prop_drilling_chains {
400 let key = item.chain.hops.first().map_or_else(
401 || UNOWNED_GROUP_LABEL.to_string(),
402 |hop| (self.key_for)(&hop.file),
403 );
404 self.entry_for_key(key)
405 .prop_drilling_chains
406 .push(item.clone());
407 }
408 for item in &results.thin_wrappers {
409 self.entry_for_path(&item.wrapper.file)
410 .thin_wrappers
411 .push(item.clone());
412 }
413 for item in &results.duplicate_prop_shapes {
414 self.entry_for_path(&item.shape.file)
415 .duplicate_prop_shapes
416 .push(item.clone());
417 }
418 }
419}
420
421fn finalize_groups(
422 groups: FxHashMap<String, AnalysisResults>,
423 mut group_owners: FxHashMap<String, Vec<String>>,
424 include_owners: bool,
425) -> Vec<ResultGroup> {
426 let mut sorted: Vec<_> = groups
427 .into_iter()
428 .map(|(key, results)| {
429 let owners = if include_owners {
430 Some(group_owners.remove(&key).unwrap_or_default())
431 } else {
432 None
433 };
434 ResultGroup {
435 key,
436 owners,
437 results,
438 }
439 })
440 .collect();
441 sorted.sort_by(|a, b| {
442 let a_unowned = a.key == UNOWNED_GROUP_LABEL;
443 let b_unowned = b.key == UNOWNED_GROUP_LABEL;
444 match (a_unowned, b_unowned) {
445 (true, false) => std::cmp::Ordering::Greater,
446 (false, true) => std::cmp::Ordering::Less,
447 _ => b
448 .results
449 .total_issues()
450 .cmp(&a.results.total_issues())
451 .then_with(|| a.key.cmp(&b.key)),
452 }
453 });
454 sorted
455}
456
457#[must_use]
459pub fn largest_clone_group_owner_with<F>(group: &CloneGroup, mut key_for_path: F) -> String
460where
461 F: FnMut(&Path) -> String,
462{
463 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
464 for instance in &group.instances {
465 let key = key_for_path(&instance.file);
466 *counts.entry(key).or_insert(0) += 1;
467 }
468 if counts.is_empty() {
469 return UNOWNED_GROUP_LABEL.to_string();
470 }
471 let mut best_key: Option<String> = None;
472 let mut best_count: u32 = 0;
473 for (key, count) in counts {
474 if best_key.is_none() || count > best_count {
475 best_count = count;
476 best_key = Some(key);
477 }
478 }
479 best_key.unwrap_or_else(|| UNOWNED_GROUP_LABEL.to_string())
480}
481
482#[must_use]
484pub fn build_duplication_grouping_with<F>(
485 report: &DuplicationReport,
486 mode: &'static str,
487 mut key_for_path: F,
488) -> DuplicationGrouping
489where
490 F: FnMut(&Path) -> String,
491{
492 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
493 let buckets = build_attributed_clone_buckets(report, &mut key_for_path);
494 let mut groups: Vec<DuplicationGroup> = buckets
495 .into_iter()
496 .map(|(key, groups)| duplication_group(key, groups, report, &fingerprints))
497 .collect();
498 sort_duplication_groups(&mut groups);
499
500 DuplicationGrouping { mode, groups }
501}
502
503fn build_attributed_clone_buckets<F>(
504 report: &DuplicationReport,
505 key_for_path: &mut F,
506) -> BTreeMap<String, Vec<AttributedCloneGroup>>
507where
508 F: FnMut(&Path) -> String,
509{
510 let mut buckets: BTreeMap<String, Vec<AttributedCloneGroup>> = BTreeMap::new();
511 for group in &report.clone_groups {
512 let attributed = attributed_clone_group(group, key_for_path);
513 buckets
514 .entry(attributed.primary_owner.clone())
515 .or_default()
516 .push(attributed);
517 }
518 buckets
519}
520
521fn attributed_clone_group<F>(group: &CloneGroup, key_for_path: &mut F) -> AttributedCloneGroup
522where
523 F: FnMut(&Path) -> String,
524{
525 let primary_owner = largest_clone_group_owner_with(group, &mut *key_for_path);
526 let instances = group
527 .instances
528 .iter()
529 .map(|instance| AttributedInstance {
530 owner: key_for_path(&instance.file),
531 instance: instance.clone(),
532 })
533 .collect();
534 AttributedCloneGroup {
535 primary_owner,
536 token_count: group.token_count,
537 line_count: group.line_count,
538 similarity: group.similarity,
539 instances,
540 }
541}
542
543fn duplication_group(
544 key: String,
545 attributed_groups: Vec<AttributedCloneGroup>,
546 report: &DuplicationReport,
547 fingerprints: &CloneFingerprintSet,
548) -> DuplicationGroup {
549 let mut subset = duplication_subset_report(&attributed_groups, report);
550 subset.stats = fallow_engine::duplicates::recompute_stats(&subset);
551 let clone_families = clone_families_for_bucket(&attributed_groups, report, fingerprints);
552 subset.stats.clone_families = clone_families.len();
553 let clone_groups = attributed_groups
554 .into_iter()
555 .map(|group| {
556 let fingerprint = group.fingerprint(fingerprints);
557 AttributedCloneGroupFinding::with_fingerprint(group, fingerprint)
558 })
559 .collect();
560
561 DuplicationGroup {
562 key,
563 stats: subset.stats,
564 clone_groups,
565 clone_families,
566 }
567}
568
569fn duplication_subset_report(
570 attributed_groups: &[AttributedCloneGroup],
571 report: &DuplicationReport,
572) -> DuplicationReport {
573 DuplicationReport {
574 clone_groups: attributed_groups
575 .iter()
576 .map(|group| CloneGroup {
577 instances: group
578 .instances
579 .iter()
580 .map(|instance| instance.instance.clone())
581 .collect(),
582 token_count: group.token_count,
583 line_count: group.line_count,
584 similarity: group.similarity,
585 })
586 .collect(),
587 clone_families: Vec::new(),
588 mirrored_directories: Vec::new(),
589 stats: DuplicationStats {
590 total_files: report.stats.total_files,
591 files_with_clones: 0,
592 total_lines: report.stats.total_lines,
593 duplicated_lines: 0,
594 total_tokens: report.stats.total_tokens,
595 duplicated_tokens: 0,
596 clone_groups: 0,
597 clone_families: 0,
598 clone_instances: 0,
599 duplication_percentage: 0.0,
600 clone_groups_below_min_occurrences: 0,
603 clone_groups_ignored: 0,
604 near_candidates_skipped: 0,
605 },
606 }
607}
608
609fn clone_families_for_bucket(
610 attributed_groups: &[AttributedCloneGroup],
611 report: &DuplicationReport,
612 fingerprints: &CloneFingerprintSet,
613) -> Vec<CloneFamilyFinding> {
614 let bucket_files: FxHashSet<&Path> = attributed_groups
615 .iter()
616 .flat_map(|group| group.instances.iter().map(|i| i.instance.file.as_path()))
617 .collect();
618
619 report
620 .clone_families
621 .iter()
622 .filter(|family| {
623 family
624 .files
625 .iter()
626 .any(|path| bucket_files.contains(path.as_path()))
627 })
628 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), fingerprints))
629 .collect()
630}
631
632fn sort_duplication_groups(groups: &mut [DuplicationGroup]) {
633 groups.sort_by(|a, b| {
634 let a_unowned = a.key == UNOWNED_GROUP_LABEL;
635 let b_unowned = b.key == UNOWNED_GROUP_LABEL;
636 match (a_unowned, b_unowned) {
637 (true, false) => std::cmp::Ordering::Greater,
638 (false, true) => std::cmp::Ordering::Less,
639 _ => b
640 .clone_groups
641 .len()
642 .cmp(&a.clone_groups.len())
643 .then_with(|| a.key.cmp(&b.key)),
644 }
645 });
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 fn group_by_path(results: &AnalysisResults) -> Vec<ResultGroup> {
654 group_analysis_results_with(
655 results,
656 |path: &Path| path.to_string_lossy().into_owned(),
657 |_: &Path| None,
658 false,
659 )
660 }
661
662 fn list_lengths(results: &AnalysisResults) -> BTreeMap<String, usize> {
664 let value = serde_json::to_value(results).expect("serialize results");
665 value
666 .as_object()
667 .expect("results object")
668 .iter()
669 .filter_map(|(key, value)| value.as_array().map(|items| (key.clone(), items.len())))
670 .collect()
671 }
672
673 #[test]
674 fn every_issue_list_is_grouped() {
675 let source = crate::editor::tests::merge_test_source_with_all_fields();
677
678 let expected = list_lengths(&source);
679 for (key, len) in &expected {
680 assert!(
681 *len > 0,
682 "the fixture must fill `{key}` so this guard checks its grouping"
683 );
684 }
685
686 let groups = group_by_path(&source);
687 let mut grouped: BTreeMap<String, usize> = BTreeMap::new();
688 for group in &groups {
689 for (key, len) in list_lengths(&group.results) {
690 *grouped.entry(key).or_insert(0) += len;
691 }
692 }
693 for (key, len) in &expected {
694 assert_eq!(
695 grouped.get(key).copied().unwrap_or(0),
696 *len,
697 "--group-by drops `{key}` findings: add them to `GroupingBuilder`"
698 );
699 }
700
701 let grouped_total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
702 assert_eq!(grouped_total, source.total_issues());
703 }
704
705 #[test]
706 fn prop_drilling_chain_without_hops_is_unowned() {
707 let mut source = crate::editor::tests::merge_test_source_with_all_fields();
708 let mut chain = source.prop_drilling_chains.remove(0);
709 chain.chain.hops.clear();
710 let mut results = AnalysisResults::default();
711 results.prop_drilling_chains.push(chain);
712
713 let groups = group_by_path(&results);
714 assert_eq!(groups.len(), 1);
715 assert_eq!(groups[0].key, UNOWNED_GROUP_LABEL);
716 assert_eq!(groups[0].results.prop_drilling_chains.len(), 1);
717 }
718
719 #[test]
720 fn health_signal_header_part_names_each_signal_type() {
721 let source = crate::editor::tests::merge_test_source_with_all_fields();
722 let mut results = AnalysisResults::default();
723 assert_eq!(health_signal_header_part(&results), "");
724
725 results
726 .prop_drilling_chains
727 .push(source.prop_drilling_chains[0].clone());
728 results.thin_wrappers.push(source.thin_wrappers[0].clone());
729 results.thin_wrappers.push(source.thin_wrappers[0].clone());
730 assert_eq!(
731 health_signal_header_part(&results),
732 "; 3 health signals: 1 prop drilling chain, 2 thin wrappers"
733 );
734
735 let mut single = AnalysisResults::default();
736 single
737 .duplicate_prop_shapes
738 .push(source.duplicate_prop_shapes[0].clone());
739 assert_eq!(
740 health_signal_header_part(&single),
741 "; 1 health signal: 1 duplicate prop shape"
742 );
743 }
744}