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