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.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    /// Group the opt-in component health signals. They do not count toward
390    /// `total_issues`, but the flat report lists them, so the groups must too.
391    fn group_component_health_issues(&mut self, results: &AnalysisResults) {
392        // The first hop owns the drilled prop and anchors the finding, the
393        // same file that suppression and CI annotations use.
394        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/// Return the majority owner for a clone group using caller-provided path attribution.
453#[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/// Build grouped duplication output using caller-provided path attribution.
478#[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            // Filtering and bounded-work counters are report-scoped and cannot
596            // be attributed reliably to one grouped bucket.
597            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    /// Group by the full file path, so every finding anchor gets its own group.
648    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    /// The length of each serialized issue list of `results`, by key.
658    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        // The fixture names every field, so a new field must get a value there.
671        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}