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        (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
54/// A single grouped dead-code analysis bucket.
55pub struct ResultGroup {
56    /// Group label such as owner, directory, package, or section.
57    pub key: String,
58    /// Section default owners for section grouping.
59    ///
60    /// `None` for grouping modes without owner metadata. `Some(vec![])` for
61    /// groups that have no section owners.
62    pub owners: Option<Vec<String>>,
63    /// Issues belonging to this group.
64    pub results: AnalysisResults,
65}
66
67/// Partition analysis results into groups using caller-provided path resolvers.
68///
69/// The caller owns all environment-specific context, such as CODEOWNERS,
70/// package discovery, root-relative path normalization, or section metadata.
71#[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        // The first example import file owns a package cycle, the same file
253        // that workspace scope and per-file severity use.
254        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    /// Group the opt-in component health signals. Advisory signals do not count toward
413    /// `total_issues`, but the flat report lists them, so the groups must too.
414    fn group_component_health_issues(&mut self, results: &AnalysisResults) {
415        // The first hop owns the drilled prop and anchors the finding, the
416        // same file that suppression and CI annotations use.
417        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/// Return the majority owner for a clone group using caller-provided path attribution.
476#[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/// Build grouped duplication output using caller-provided path attribution.
501#[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            // Filtering and bounded-work counters are report-scoped and cannot
619            // be attributed reliably to one grouped bucket.
620            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    /// Group by the full file path, so every finding anchor gets its own group.
671    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    /// The length of each serialized issue list of `results`, by key.
681    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        // The fixture names every field, so a new field must get a value there.
694        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}