Skip to main content

fallow_api/
grouped_output.rs

1//! Shared grouped-output builders for programmatic and CLI consumers.
2
3use 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
16/// Canonical label for issues that cannot be attributed to a group.
17pub const UNOWNED_GROUP_LABEL: &str = "(unowned)";
18
19/// The group header part that names the opt-in component health signals of
20/// `results`, such as `; 3 health signals: 3 duplicate prop shapes`. Empty when
21/// there are none. Advisory signals do not count toward `total_issues`, so a group
22/// header names them next to the issue count. The human and markdown grouped
23/// reports share this text.
24#[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
60/// A single grouped dead-code analysis bucket.
61pub struct ResultGroup {
62    /// Group label such as owner, directory, package, or section.
63    pub key: String,
64    /// Section default owners for section grouping.
65    ///
66    /// `None` for grouping modes without owner metadata. `Some(vec![])` for
67    /// groups that have no section owners.
68    pub owners: Option<Vec<String>>,
69    /// Issues belonging to this group.
70    pub results: AnalysisResults,
71}
72
73/// Partition analysis results into groups using caller-provided path resolvers.
74///
75/// The caller owns all environment-specific context, such as CODEOWNERS,
76/// package discovery, root-relative path normalization, or section metadata.
77#[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        // The first example import file owns a package cycle, the same file
259        // that workspace scope and per-file severity use.
260        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    /// Group the opt-in component health signals. Advisory signals do not count toward
419    /// `total_issues`, but the flat report lists them, so the groups must too.
420    fn group_component_health_issues(&mut self, results: &AnalysisResults) {
421        // The first hop owns the drilled prop and anchors the finding, the
422        // same file that suppression and CI annotations use.
423        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/// Return the majority owner for a clone group using caller-provided path attribution.
482#[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/// Build grouped duplication output using caller-provided path attribution.
507#[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            // Filtering and bounded-work counters are report-scoped and cannot
625            // be attributed reliably to one grouped bucket.
626            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    /// Group by the full file path, so every finding anchor gets its own group.
677    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    /// The length of each serialized issue list of `results`, by key.
687    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        // The fixture names every field, so a new field must get a value there.
700        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}