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.unused_component_emits {
325 self.entry_for_path(&item.emit.path)
326 .unused_component_emits
327 .push(item.clone());
328 }
329 for item in &results.unused_component_inputs {
330 self.entry_for_path(&item.input.path)
331 .unused_component_inputs
332 .push(item.clone());
333 }
334 for item in &results.unused_component_outputs {
335 self.entry_for_path(&item.output.path)
336 .unused_component_outputs
337 .push(item.clone());
338 }
339 for item in &results.unused_svelte_events {
340 self.entry_for_path(&item.event.path)
341 .unused_svelte_events
342 .push(item.clone());
343 }
344 for item in &results.unused_server_actions {
345 self.entry_for_path(&item.action.path)
346 .unused_server_actions
347 .push(item.clone());
348 }
349 for item in &results.unused_load_data_keys {
350 self.entry_for_path(&item.key.path)
351 .unused_load_data_keys
352 .push(item.clone());
353 }
354 for item in &results.stale_suppressions {
355 self.entry_for_path(&item.path)
356 .stale_suppressions
357 .push(item.clone());
358 }
359 }
360
361 fn group_workspace_config_issues(&mut self, results: &AnalysisResults) {
362 for item in &results.unused_catalog_entries {
363 self.entry_for_path(&item.entry.path)
364 .unused_catalog_entries
365 .push(item.clone());
366 }
367 for item in &results.empty_catalog_groups {
368 self.entry_for_path(&item.group.path)
369 .empty_catalog_groups
370 .push(item.clone());
371 }
372 for item in &results.unresolved_catalog_references {
373 self.entry_for_path(&item.reference.path)
374 .unresolved_catalog_references
375 .push(item.clone());
376 }
377 for item in &results.unused_dependency_overrides {
378 self.entry_for_path(&item.entry.path)
379 .unused_dependency_overrides
380 .push(item.clone());
381 }
382 for item in &results.misconfigured_dependency_overrides {
383 self.entry_for_path(&item.entry.path)
384 .misconfigured_dependency_overrides
385 .push(item.clone());
386 }
387 }
388
389 fn group_component_health_issues(&mut self, results: &AnalysisResults) {
392 for item in &results.prop_drilling_chains {
395 let key = item.chain.hops.first().map_or_else(
396 || UNOWNED_GROUP_LABEL.to_string(),
397 |hop| (self.key_for)(&hop.file),
398 );
399 self.entry_for_key(key)
400 .prop_drilling_chains
401 .push(item.clone());
402 }
403 for item in &results.thin_wrappers {
404 self.entry_for_path(&item.wrapper.file)
405 .thin_wrappers
406 .push(item.clone());
407 }
408 for item in &results.duplicate_prop_shapes {
409 self.entry_for_path(&item.shape.file)
410 .duplicate_prop_shapes
411 .push(item.clone());
412 }
413 }
414}
415
416fn finalize_groups(
417 groups: FxHashMap<String, AnalysisResults>,
418 mut group_owners: FxHashMap<String, Vec<String>>,
419 include_owners: bool,
420) -> Vec<ResultGroup> {
421 let mut sorted: Vec<_> = groups
422 .into_iter()
423 .map(|(key, results)| {
424 let owners = if include_owners {
425 Some(group_owners.remove(&key).unwrap_or_default())
426 } else {
427 None
428 };
429 ResultGroup {
430 key,
431 owners,
432 results,
433 }
434 })
435 .collect();
436 sorted.sort_by(|a, b| {
437 let a_unowned = a.key == UNOWNED_GROUP_LABEL;
438 let b_unowned = b.key == UNOWNED_GROUP_LABEL;
439 match (a_unowned, b_unowned) {
440 (true, false) => std::cmp::Ordering::Greater,
441 (false, true) => std::cmp::Ordering::Less,
442 _ => b
443 .results
444 .total_issues()
445 .cmp(&a.results.total_issues())
446 .then_with(|| a.key.cmp(&b.key)),
447 }
448 });
449 sorted
450}
451
452#[must_use]
454pub fn largest_clone_group_owner_with<F>(group: &CloneGroup, mut key_for_path: F) -> String
455where
456 F: FnMut(&Path) -> String,
457{
458 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
459 for instance in &group.instances {
460 let key = key_for_path(&instance.file);
461 *counts.entry(key).or_insert(0) += 1;
462 }
463 if counts.is_empty() {
464 return UNOWNED_GROUP_LABEL.to_string();
465 }
466 let mut best_key: Option<String> = None;
467 let mut best_count: u32 = 0;
468 for (key, count) in counts {
469 if best_key.is_none() || count > best_count {
470 best_count = count;
471 best_key = Some(key);
472 }
473 }
474 best_key.unwrap_or_else(|| UNOWNED_GROUP_LABEL.to_string())
475}
476
477#[must_use]
479pub fn build_duplication_grouping_with<F>(
480 report: &DuplicationReport,
481 mode: &'static str,
482 mut key_for_path: F,
483) -> DuplicationGrouping
484where
485 F: FnMut(&Path) -> String,
486{
487 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
488 let buckets = build_attributed_clone_buckets(report, &mut key_for_path);
489 let mut groups: Vec<DuplicationGroup> = buckets
490 .into_iter()
491 .map(|(key, groups)| duplication_group(key, groups, report, &fingerprints))
492 .collect();
493 sort_duplication_groups(&mut groups);
494
495 DuplicationGrouping { mode, groups }
496}
497
498fn build_attributed_clone_buckets<F>(
499 report: &DuplicationReport,
500 key_for_path: &mut F,
501) -> BTreeMap<String, Vec<AttributedCloneGroup>>
502where
503 F: FnMut(&Path) -> String,
504{
505 let mut buckets: BTreeMap<String, Vec<AttributedCloneGroup>> = BTreeMap::new();
506 for group in &report.clone_groups {
507 let attributed = attributed_clone_group(group, key_for_path);
508 buckets
509 .entry(attributed.primary_owner.clone())
510 .or_default()
511 .push(attributed);
512 }
513 buckets
514}
515
516fn attributed_clone_group<F>(group: &CloneGroup, key_for_path: &mut F) -> AttributedCloneGroup
517where
518 F: FnMut(&Path) -> String,
519{
520 let primary_owner = largest_clone_group_owner_with(group, &mut *key_for_path);
521 let instances = group
522 .instances
523 .iter()
524 .map(|instance| AttributedInstance {
525 owner: key_for_path(&instance.file),
526 instance: instance.clone(),
527 })
528 .collect();
529 AttributedCloneGroup {
530 primary_owner,
531 token_count: group.token_count,
532 line_count: group.line_count,
533 similarity: group.similarity,
534 instances,
535 }
536}
537
538fn duplication_group(
539 key: String,
540 attributed_groups: Vec<AttributedCloneGroup>,
541 report: &DuplicationReport,
542 fingerprints: &CloneFingerprintSet,
543) -> DuplicationGroup {
544 let mut subset = duplication_subset_report(&attributed_groups, report);
545 subset.stats = fallow_engine::duplicates::recompute_stats(&subset);
546 let clone_families = clone_families_for_bucket(&attributed_groups, report, fingerprints);
547 subset.stats.clone_families = clone_families.len();
548 let clone_groups = attributed_groups
549 .into_iter()
550 .map(|group| {
551 let fingerprint = group.fingerprint(fingerprints);
552 AttributedCloneGroupFinding::with_fingerprint(group, fingerprint)
553 })
554 .collect();
555
556 DuplicationGroup {
557 key,
558 stats: subset.stats,
559 clone_groups,
560 clone_families,
561 }
562}
563
564fn duplication_subset_report(
565 attributed_groups: &[AttributedCloneGroup],
566 report: &DuplicationReport,
567) -> DuplicationReport {
568 DuplicationReport {
569 clone_groups: attributed_groups
570 .iter()
571 .map(|group| CloneGroup {
572 instances: group
573 .instances
574 .iter()
575 .map(|instance| instance.instance.clone())
576 .collect(),
577 token_count: group.token_count,
578 line_count: group.line_count,
579 similarity: group.similarity,
580 })
581 .collect(),
582 clone_families: Vec::new(),
583 mirrored_directories: Vec::new(),
584 stats: DuplicationStats {
585 total_files: report.stats.total_files,
586 files_with_clones: 0,
587 total_lines: report.stats.total_lines,
588 duplicated_lines: 0,
589 total_tokens: report.stats.total_tokens,
590 duplicated_tokens: 0,
591 clone_groups: 0,
592 clone_families: 0,
593 clone_instances: 0,
594 duplication_percentage: 0.0,
595 clone_groups_below_min_occurrences: 0,
598 clone_groups_ignored: 0,
599 near_candidates_skipped: 0,
600 },
601 }
602}
603
604fn clone_families_for_bucket(
605 attributed_groups: &[AttributedCloneGroup],
606 report: &DuplicationReport,
607 fingerprints: &CloneFingerprintSet,
608) -> Vec<CloneFamilyFinding> {
609 let bucket_files: FxHashSet<&Path> = attributed_groups
610 .iter()
611 .flat_map(|group| group.instances.iter().map(|i| i.instance.file.as_path()))
612 .collect();
613
614 report
615 .clone_families
616 .iter()
617 .filter(|family| {
618 family
619 .files
620 .iter()
621 .any(|path| bucket_files.contains(path.as_path()))
622 })
623 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), fingerprints))
624 .collect()
625}
626
627fn sort_duplication_groups(groups: &mut [DuplicationGroup]) {
628 groups.sort_by(|a, b| {
629 let a_unowned = a.key == UNOWNED_GROUP_LABEL;
630 let b_unowned = b.key == UNOWNED_GROUP_LABEL;
631 match (a_unowned, b_unowned) {
632 (true, false) => std::cmp::Ordering::Greater,
633 (false, true) => std::cmp::Ordering::Less,
634 _ => b
635 .clone_groups
636 .len()
637 .cmp(&a.clone_groups.len())
638 .then_with(|| a.key.cmp(&b.key)),
639 }
640 });
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 fn group_by_path(results: &AnalysisResults) -> Vec<ResultGroup> {
649 group_analysis_results_with(
650 results,
651 |path: &Path| path.to_string_lossy().into_owned(),
652 |_: &Path| None,
653 false,
654 )
655 }
656
657 fn list_lengths(results: &AnalysisResults) -> BTreeMap<String, usize> {
659 let value = serde_json::to_value(results).expect("serialize results");
660 value
661 .as_object()
662 .expect("results object")
663 .iter()
664 .filter_map(|(key, value)| value.as_array().map(|items| (key.clone(), items.len())))
665 .collect()
666 }
667
668 #[test]
669 fn every_issue_list_is_grouped() {
670 let source = crate::editor::tests::merge_test_source_with_all_fields();
672
673 let expected = list_lengths(&source);
674 for (key, len) in &expected {
675 assert!(
676 *len > 0,
677 "the fixture must fill `{key}` so this guard checks its grouping"
678 );
679 }
680
681 let groups = group_by_path(&source);
682 let mut grouped: BTreeMap<String, usize> = BTreeMap::new();
683 for group in &groups {
684 for (key, len) in list_lengths(&group.results) {
685 *grouped.entry(key).or_insert(0) += len;
686 }
687 }
688 for (key, len) in &expected {
689 assert_eq!(
690 grouped.get(key).copied().unwrap_or(0),
691 *len,
692 "--group-by drops `{key}` findings: add them to `GroupingBuilder`"
693 );
694 }
695
696 let grouped_total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
697 assert_eq!(grouped_total, source.total_issues());
698 }
699
700 #[test]
701 fn prop_drilling_chain_without_hops_is_unowned() {
702 let mut source = crate::editor::tests::merge_test_source_with_all_fields();
703 let mut chain = source.prop_drilling_chains.remove(0);
704 chain.chain.hops.clear();
705 let mut results = AnalysisResults::default();
706 results.prop_drilling_chains.push(chain);
707
708 let groups = group_by_path(&results);
709 assert_eq!(groups.len(), 1);
710 assert_eq!(groups[0].key, UNOWNED_GROUP_LABEL);
711 assert_eq!(groups[0].results.prop_drilling_chains.len(), 1);
712 }
713
714 #[test]
715 fn health_signal_header_part_names_each_signal_type() {
716 let source = crate::editor::tests::merge_test_source_with_all_fields();
717 let mut results = AnalysisResults::default();
718 assert_eq!(health_signal_header_part(&results), "");
719
720 results
721 .prop_drilling_chains
722 .push(source.prop_drilling_chains[0].clone());
723 results.thin_wrappers.push(source.thin_wrappers[0].clone());
724 results.thin_wrappers.push(source.thin_wrappers[0].clone());
725 assert_eq!(
726 health_signal_header_part(&results),
727 "; 3 health signals: 1 prop drilling chain, 2 thin wrappers"
728 );
729
730 let mut single = AnalysisResults::default();
731 single
732 .duplicate_prop_shapes
733 .push(source.duplicate_prop_shapes[0].clone());
734 assert_eq!(
735 health_signal_header_part(&single),
736 "; 1 health signal: 1 duplicate prop shape"
737 );
738 }
739}