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