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