1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use fallow_config::{ResolvedConfig, Severity};
8use fallow_types::envelope::AuditIntroduced;
9use fallow_types::identity::{IdentifiedFinding, IdentityPaths, dead_code_occurrence_keys};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AuditFindingRecord {
14 pub collection: &'static str,
16 pub ordinal: usize,
18 pub stable_key: String,
20 pub introduced: bool,
22 pub effective_severity: Severity,
24}
25
26#[derive(Debug, Clone, Default)]
28pub struct DeadCodeAuditLedger {
29 records: Vec<AuditFindingRecord>,
30 keys: FxHashSet<String>,
31 introduced_keys: FxHashSet<String>,
32 inherited_keys: FxHashSet<String>,
33 #[cfg(test)]
34 classifications: usize,
35}
36
37impl DeadCodeAuditLedger {
38 #[must_use]
40 pub fn records(&self) -> &[AuditFindingRecord] {
41 &self.records
42 }
43
44 #[must_use]
46 pub const fn keys(&self) -> &FxHashSet<String> {
47 &self.keys
48 }
49
50 #[cfg(test)]
51 const fn classification_count(&self) -> usize {
52 self.classifications
53 }
54
55 #[must_use]
57 pub fn introduced_count(&self) -> usize {
58 self.introduced_keys.len()
59 }
60
61 #[must_use]
63 pub fn visible_count(&self) -> usize {
64 self.records
65 .iter()
66 .filter(|record| record.effective_severity != Severity::Off)
67 .count()
68 }
69
70 #[must_use]
72 pub fn inherited_count(&self) -> usize {
73 self.inherited_keys.len()
74 }
75
76 #[must_use]
78 pub fn has_introduced_errors(&self) -> bool {
79 self.records
80 .iter()
81 .any(|record| record.introduced && record.effective_severity == Severity::Error)
82 }
83
84 #[must_use]
86 pub fn has_introduced_warnings(&self) -> bool {
87 self.records
88 .iter()
89 .any(|record| record.introduced && record.effective_severity == Severity::Warn)
90 }
91
92 pub fn demote_unattributable_introductions(&mut self, head_syntactic: &FxHashSet<String>) {
103 for record in &mut self.records {
104 if record.introduced && !head_syntactic.contains(&record.stable_key) {
105 record.introduced = false;
106 self.introduced_keys.remove(&record.stable_key);
107 if record.effective_severity != Severity::Off {
108 self.inherited_keys.insert(record.stable_key.clone());
109 }
110 }
111 }
112 }
113
114 #[expect(
119 clippy::too_many_lines,
120 reason = "exhaustive result destructuring and field annotation must stay together so new finding collections require an explicit audit decision"
121 )]
122 pub fn annotate_results(&self, results: &mut fallow_types::results::AnalysisResults) {
123 let fallow_types::results::AnalysisResults {
124 unused_files,
125 unused_exports,
126 unused_types,
127 private_type_leaks,
128 deprecated_exports_in_use,
129 unused_dependencies,
130 unused_dev_dependencies,
131 unused_optional_dependencies,
132 unused_enum_members,
133 unused_class_members,
134 unused_store_members,
135 unresolved_imports,
136 unlisted_dependencies,
137 duplicate_exports,
138 type_only_dependencies,
139 test_only_dependencies,
140 dev_dependencies_in_production,
141 circular_dependencies,
142 re_export_cycles,
143 package_cycles,
144 boundary_violations,
145 boundary_coverage_violations,
146 boundary_call_violations,
147 policy_violations,
148 stale_suppressions: _stale_suppressions,
149 unused_catalog_entries,
150 empty_catalog_groups,
151 unresolved_catalog_references,
152 unused_dependency_overrides,
153 misconfigured_dependency_overrides,
154 invalid_client_exports,
155 mixed_client_server_barrels,
156 misplaced_directives,
157 unprovided_injects,
158 unrendered_components,
159 unused_component_props,
160 unused_component_emits,
161 unused_component_inputs,
162 unused_component_outputs,
163 unused_svelte_events,
164 unused_server_actions,
165 unused_load_data_keys,
166 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
167 route_collisions,
168 dynamic_segment_name_conflicts,
169 suppression_count: _suppression_count,
170 unused_component_props_exempted: _unused_component_props_exempted,
171 active_suppressions: _active_suppressions,
172 feature_flags: _feature_flags,
173 security_findings: _security_findings,
174 security_unresolved_edge_files: _security_unresolved_edge_files,
175 security_unresolved_callee_sites: _security_unresolved_callee_sites,
176 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
177 prop_drilling_chains: _prop_drilling_chains,
178 thin_wrappers: _thin_wrappers,
179 duplicate_prop_shapes: _duplicate_prop_shapes,
180 export_usages: _export_usages,
181 entry_point_summary: _entry_point_summary,
182 render_fan_in: _render_fan_in,
183 react_component_intel: _react_component_intel,
184 semantic_framework_contracts: _semantic_framework_contracts,
185 } = results;
186
187 macro_rules! annotate {
188 ($field:ident, $collection:literal) => {
189 for (item, introduced) in $field.iter_mut().zip(
190 self.records
191 .iter()
192 .filter(|record| record.collection == $collection)
193 .map(|record| record.introduced),
194 ) {
195 item.introduced = Some(AuditIntroduced(introduced));
196 }
197 };
198 }
199
200 annotate!(unused_files, "unused_files");
201 annotate!(unused_exports, "unused_exports");
202 annotate!(unused_types, "unused_types");
203 annotate!(private_type_leaks, "private_type_leaks");
204 annotate!(deprecated_exports_in_use, "deprecated_exports_in_use");
205 annotate!(unused_dependencies, "unused_dependencies");
206 annotate!(unused_dev_dependencies, "unused_dev_dependencies");
207 annotate!(unused_optional_dependencies, "unused_optional_dependencies");
208 annotate!(unused_enum_members, "unused_enum_members");
209 annotate!(unused_class_members, "unused_class_members");
210 annotate!(unused_store_members, "unused_store_members");
211 annotate!(unresolved_imports, "unresolved_imports");
212 annotate!(unlisted_dependencies, "unlisted_dependencies");
213 annotate!(duplicate_exports, "duplicate_exports");
214 annotate!(type_only_dependencies, "type_only_dependencies");
215 annotate!(test_only_dependencies, "test_only_dependencies");
216 annotate!(
217 dev_dependencies_in_production,
218 "dev_dependencies_in_production"
219 );
220 annotate!(circular_dependencies, "circular_dependencies");
221 annotate!(re_export_cycles, "re_export_cycles");
222 annotate!(package_cycles, "package_cycles");
223 annotate!(boundary_violations, "boundary_violations");
224 annotate!(boundary_coverage_violations, "boundary_coverage_violations");
225 annotate!(boundary_call_violations, "boundary_call_violations");
226 annotate!(policy_violations, "policy_violations");
227 annotate!(unused_catalog_entries, "unused_catalog_entries");
228 annotate!(empty_catalog_groups, "empty_catalog_groups");
229 annotate!(
230 unresolved_catalog_references,
231 "unresolved_catalog_references"
232 );
233 annotate!(unused_dependency_overrides, "unused_dependency_overrides");
234 annotate!(
235 misconfigured_dependency_overrides,
236 "misconfigured_dependency_overrides"
237 );
238 annotate!(invalid_client_exports, "invalid_client_exports");
239 annotate!(mixed_client_server_barrels, "mixed_client_server_barrels");
240 annotate!(misplaced_directives, "misplaced_directives");
241 annotate!(unprovided_injects, "unprovided_injects");
242 annotate!(unrendered_components, "unrendered_components");
243 annotate!(route_collisions, "route_collisions");
244 annotate!(
245 dynamic_segment_name_conflicts,
246 "dynamic_segment_name_conflicts"
247 );
248 annotate!(unused_component_props, "unused_component_props");
249 annotate!(unused_component_emits, "unused_component_emits");
250 annotate!(unused_component_inputs, "unused_component_inputs");
251 annotate!(unused_component_outputs, "unused_component_outputs");
252 annotate!(unused_svelte_events, "unused_svelte_events");
253 annotate!(unused_server_actions, "unused_server_actions");
254 annotate!(unused_load_data_keys, "unused_load_data_keys");
255 }
256}
257
258#[derive(Debug, Clone, Default)]
260pub struct AuditDomainLedger {
261 records: Vec<(String, bool)>,
262 keys: FxHashSet<String>,
263 introduced_keys: FxHashSet<String>,
264 inherited_keys: FxHashSet<String>,
265 demoted_keys: FxHashSet<String>,
266}
267
268impl AuditDomainLedger {
269 #[must_use]
271 pub fn compare(
272 keys: impl IntoIterator<Item = String>,
273 base: Option<&FxHashSet<String>>,
274 ) -> Self {
275 let mut records = Vec::new();
276 let mut unique_keys = FxHashSet::default();
277 let mut introduced_keys = FxHashSet::default();
278 let mut inherited_keys = FxHashSet::default();
279 for key in keys {
280 let introduced = base.is_some_and(|base| !base.contains(&key));
281 if base.is_some() {
282 if introduced {
283 introduced_keys.insert(key.clone());
284 } else {
285 inherited_keys.insert(key.clone());
286 }
287 }
288 unique_keys.insert(key.clone());
289 records.push((key, introduced));
290 }
291 Self {
292 records,
293 keys: unique_keys,
294 introduced_keys,
295 inherited_keys,
296 demoted_keys: FxHashSet::default(),
297 }
298 }
299
300 #[must_use]
302 pub const fn keys(&self) -> &FxHashSet<String> {
303 &self.keys
304 }
305
306 #[must_use]
308 pub fn introduced_count(&self) -> usize {
309 self.introduced_keys.len()
310 }
311
312 #[must_use]
314 pub fn inherited_count(&self) -> usize {
315 self.inherited_keys.len()
316 }
317
318 pub fn introduced(&self) -> impl ExactSizeIterator<Item = bool> + '_ {
320 self.records.iter().map(|(_, introduced)| *introduced)
321 }
322
323 #[must_use]
327 pub fn demoted_count(&self) -> usize {
328 self.demoted_keys.len()
329 }
330
331 #[must_use]
334 pub const fn demoted_keys(&self) -> &FxHashSet<String> {
335 &self.demoted_keys
336 }
337
338 pub fn demoted(&self) -> impl ExactSizeIterator<Item = bool> + '_ {
341 self.records
342 .iter()
343 .map(|(key, _)| self.demoted_keys.contains(key))
344 }
345
346 pub fn demote_introductions(&mut self, demote: &FxHashSet<String>) {
351 for (key, introduced) in &mut self.records {
352 if *introduced && demote.contains(key) {
353 *introduced = false;
354 self.introduced_keys.remove(key);
355 self.inherited_keys.insert(key.clone());
356 self.demoted_keys.insert(key.clone());
357 }
358 }
359 }
360}
361
362#[derive(Debug, Clone, Default)]
364pub struct AuditComparison {
365 pub dead_code: DeadCodeAuditLedger,
367 pub health: AuditDomainLedger,
369 pub dupes: AuditDomainLedger,
371 pub styling: AuditDomainLedger,
373}
374
375#[must_use]
387#[expect(
388 clippy::implicit_hasher,
389 reason = "fallow standardizes on FxHashMap/FxHashSet across audit attribution keys"
390)]
391pub fn remap_keys_for_renames(
392 keys: &FxHashSet<String>,
393 renames: &FxHashMap<String, String>,
394) -> FxHashSet<String> {
395 keys.iter()
396 .map(|key| remap_key_for_renames(key, renames))
397 .collect()
398}
399
400fn remap_key_for_renames(key: &str, renames: &FxHashMap<String, String>) -> String {
401 if !key
402 .split([':', '|'])
403 .any(|segment| renames.contains_key(segment))
404 {
405 return key.to_string();
406 }
407 let segments: Vec<String> = key
408 .split(':')
409 .map(|segment| {
410 if segment.contains('|') {
411 let mut pieces: Vec<&str> = segment
412 .split('|')
413 .map(|piece| renames.get(piece).map_or(piece, String::as_str))
414 .collect();
415 pieces.sort_unstable();
416 pieces.dedup();
417 pieces.join("|")
418 } else {
419 renames
420 .get(segment)
421 .cloned()
422 .unwrap_or_else(|| segment.to_string())
423 }
424 })
425 .collect();
426 segments.join(":")
427}
428
429pub fn relative_key_path(path: &Path, root: &Path) -> String {
433 let simple_path = dunce::simplified(path);
434 let simple_root = dunce::simplified(root);
435 simple_path
436 .strip_prefix(simple_root)
437 .unwrap_or(simple_path)
438 .to_string_lossy()
439 .replace('\\', "/")
440}
441
442fn collection_keys<T: IdentifiedFinding>(items: &[T], root: &Path) -> Vec<String> {
449 dead_code_occurrence_keys(items, &IdentityPaths::new(root))
450}
451
452#[derive(Clone, Copy)]
469#[allow(
470 clippy::struct_field_names,
471 reason = "field names mirror the AnalysisResults field names so the destructure stays shorthand"
472)]
473struct DependencyFindingSlices<'a> {
474 unused_dependencies: &'a [fallow_types::output_dead_code::UnusedDependencyFinding],
475 unused_dev_dependencies: &'a [fallow_types::output_dead_code::UnusedDevDependencyFinding],
476 unused_optional_dependencies:
477 &'a [fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
478 unlisted_dependencies: &'a [fallow_types::output_dead_code::UnlistedDependencyFinding],
479 type_only_dependencies: &'a [fallow_types::output_dead_code::TypeOnlyDependencyFinding],
480 test_only_dependencies: &'a [fallow_types::output_dead_code::TestOnlyDependencyFinding],
481 dev_dependencies_in_production:
482 &'a [fallow_types::output_dead_code::DevDependencyInProductionFinding],
483}
484
485#[derive(Clone, Copy)]
488struct FrameworkFindingSlices<'a> {
489 unprovided_injects: &'a [fallow_types::output_dead_code::UnprovidedInjectFinding],
490 unrendered_components: &'a [fallow_types::output_dead_code::UnrenderedComponentFinding],
491 unused_server_actions: &'a [fallow_types::output_dead_code::UnusedServerActionFinding],
492 unused_load_data_keys: &'a [fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
493 route_collisions: &'a [fallow_types::output_dead_code::RouteCollisionFinding],
494 dynamic_segment_name_conflicts:
495 &'a [fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
496}
497
498pub fn dead_code_keys(
508 results: &fallow_types::results::AnalysisResults,
509 root: &Path,
510) -> FxHashSet<String> {
511 let mut collector = DeadCodeKeyCollector::new(root);
512 collector.add_all_findings(results);
513 collector.into_keys()
514}
515
516#[must_use]
518#[expect(
519 clippy::implicit_hasher,
520 reason = "fallow standardizes on FxHashSet across audit attribution keys"
521)]
522pub fn dead_code_audit_ledger(
523 results: &fallow_types::results::AnalysisResults,
524 root: &Path,
525 config: &ResolvedConfig,
526 base: Option<&FxHashSet<String>>,
527) -> DeadCodeAuditLedger {
528 let mut collector = DeadCodeKeyCollector::for_comparison(root, config, base);
529 collector.add_all_findings(results);
530 collector.into_ledger()
531}
532
533impl DeadCodeKeyCollector<'_> {
534 #[expect(
535 clippy::too_many_lines,
536 reason = "flat field-by-field destructure of the large AnalysisResults struct (with per-field provenance comments) plus straight-line dispatch; length tracks the field count, not branching"
537 )]
538 fn add_all_findings(&mut self, results: &fallow_types::results::AnalysisResults) {
539 let fallow_types::results::AnalysisResults {
540 unused_files,
541 unused_exports,
542 unused_types,
543 private_type_leaks,
544 deprecated_exports_in_use,
545 unused_dependencies,
546 unused_dev_dependencies,
547 unused_optional_dependencies,
548 unused_enum_members,
549 unused_class_members,
550 unused_store_members,
551 unresolved_imports,
552 unlisted_dependencies,
553 duplicate_exports,
554 type_only_dependencies,
555 test_only_dependencies,
556 dev_dependencies_in_production,
557 circular_dependencies,
558 re_export_cycles,
559 package_cycles,
560 boundary_violations,
561 boundary_coverage_violations,
562 boundary_call_violations,
563 policy_violations,
564 stale_suppressions,
565 unused_catalog_entries,
566 empty_catalog_groups,
567 unresolved_catalog_references,
568 unused_dependency_overrides,
569 misconfigured_dependency_overrides,
570 invalid_client_exports,
571 mixed_client_server_barrels,
572 misplaced_directives,
573 unprovided_injects,
574 unrendered_components,
575 unused_component_props,
576 unused_component_emits,
577 unused_component_inputs,
578 unused_component_outputs,
579 unused_svelte_events,
580 unused_server_actions,
581 unused_load_data_keys,
582 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
583 route_collisions,
584 dynamic_segment_name_conflicts,
585 suppression_count: _suppression_count,
587 unused_component_props_exempted: _unused_component_props_exempted,
588 active_suppressions: _active_suppressions,
589 feature_flags: _feature_flags,
590 security_findings: _security_findings,
593 security_unresolved_edge_files: _security_unresolved_edge_files,
594 security_unresolved_callee_sites: _security_unresolved_callee_sites,
595 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
596 prop_drilling_chains: _prop_drilling_chains,
599 thin_wrappers: _thin_wrappers,
602 duplicate_prop_shapes: _duplicate_prop_shapes,
606 export_usages: _export_usages,
609 entry_point_summary: _entry_point_summary,
610 render_fan_in: _render_fan_in,
613 react_component_intel: _react_component_intel,
616 semantic_framework_contracts: _semantic_framework_contracts,
617 } = results;
618
619 self.add_core_findings(
620 unused_files,
621 unused_exports,
622 unused_types,
623 private_type_leaks,
624 );
625 self.add_deprecated_exports_in_use(deprecated_exports_in_use);
626 self.add_client_directive_findings(
627 invalid_client_exports,
628 mixed_client_server_barrels,
629 misplaced_directives,
630 );
631 self.add_dependency_findings(&DependencyFindingSlices {
632 unused_dependencies,
633 unused_dev_dependencies,
634 unused_optional_dependencies,
635 unlisted_dependencies,
636 type_only_dependencies,
637 test_only_dependencies,
638 dev_dependencies_in_production,
639 });
640 self.add_dependency_override_findings(
641 unused_dependency_overrides,
642 misconfigured_dependency_overrides,
643 );
644 self.add_member_findings(
645 unused_enum_members,
646 unused_class_members,
647 unused_store_members,
648 );
649 self.add_component_contract_findings(
650 unused_component_props,
651 unused_component_emits,
652 unused_component_inputs,
653 unused_component_outputs,
654 unused_svelte_events,
655 );
656 self.add_graph_findings(
657 unresolved_imports,
658 duplicate_exports,
659 circular_dependencies,
660 re_export_cycles,
661 package_cycles,
662 );
663 self.add_boundary_findings(
664 boundary_violations,
665 boundary_coverage_violations,
666 boundary_call_violations,
667 policy_violations,
668 stale_suppressions,
669 );
670 self.add_catalog_findings(
671 unresolved_catalog_references,
672 unused_catalog_entries,
673 empty_catalog_groups,
674 );
675 self.add_framework_findings(&FrameworkFindingSlices {
676 unprovided_injects,
677 unrendered_components,
678 unused_server_actions,
679 unused_load_data_keys,
680 route_collisions,
681 dynamic_segment_name_conflicts,
682 });
683 }
684}
685
686#[derive(Clone, Copy)]
687enum AuditCollection {
688 UnusedFiles,
689 UnusedExports,
690 UnusedTypes,
691 PrivateTypeLeaks,
692 DeprecatedExportsInUse,
693 UnusedDependencies,
694 UnusedDevDependencies,
695 UnusedOptionalDependencies,
696 UnusedEnumMembers,
697 UnusedClassMembers,
698 UnusedStoreMembers,
699 UnresolvedImports,
700 UnlistedDependencies,
701 DuplicateExports,
702 TypeOnlyDependencies,
703 TestOnlyDependencies,
704 DevDependenciesInProduction,
705 CircularDependencies,
706 ReExportCycles,
707 PackageCycles,
708 BoundaryViolations,
709 BoundaryCoverageViolations,
710 BoundaryCallViolations,
711 PolicyViolations,
712 StaleSuppressions,
713 UnusedCatalogEntries,
714 EmptyCatalogGroups,
715 UnresolvedCatalogReferences,
716 UnusedDependencyOverrides,
717 MisconfiguredDependencyOverrides,
718 InvalidClientExports,
719 MixedClientServerBarrels,
720 MisplacedDirectives,
721 UnprovidedInjects,
722 UnrenderedComponents,
723 RouteCollisions,
724 DynamicSegmentNameConflicts,
725 UnusedComponentProps,
726 UnusedComponentEmits,
727 UnusedComponentInputs,
728 UnusedComponentOutputs,
729 UnusedSvelteEvents,
730 UnusedServerActions,
731 UnusedLoadDataKeys,
732}
733
734impl AuditCollection {
735 #[cfg(test)]
736 const ALL: [Self; 44] = [
737 Self::UnusedFiles,
738 Self::UnusedExports,
739 Self::UnusedTypes,
740 Self::PrivateTypeLeaks,
741 Self::DeprecatedExportsInUse,
742 Self::UnusedDependencies,
743 Self::UnusedDevDependencies,
744 Self::UnusedOptionalDependencies,
745 Self::UnusedEnumMembers,
746 Self::UnusedClassMembers,
747 Self::UnusedStoreMembers,
748 Self::UnresolvedImports,
749 Self::UnlistedDependencies,
750 Self::DuplicateExports,
751 Self::TypeOnlyDependencies,
752 Self::TestOnlyDependencies,
753 Self::DevDependenciesInProduction,
754 Self::CircularDependencies,
755 Self::ReExportCycles,
756 Self::PackageCycles,
757 Self::BoundaryViolations,
758 Self::BoundaryCoverageViolations,
759 Self::BoundaryCallViolations,
760 Self::PolicyViolations,
761 Self::StaleSuppressions,
762 Self::UnusedCatalogEntries,
763 Self::EmptyCatalogGroups,
764 Self::UnresolvedCatalogReferences,
765 Self::UnusedDependencyOverrides,
766 Self::MisconfiguredDependencyOverrides,
767 Self::InvalidClientExports,
768 Self::MixedClientServerBarrels,
769 Self::MisplacedDirectives,
770 Self::UnprovidedInjects,
771 Self::UnrenderedComponents,
772 Self::RouteCollisions,
773 Self::DynamicSegmentNameConflicts,
774 Self::UnusedComponentProps,
775 Self::UnusedComponentEmits,
776 Self::UnusedComponentInputs,
777 Self::UnusedComponentOutputs,
778 Self::UnusedSvelteEvents,
779 Self::UnusedServerActions,
780 Self::UnusedLoadDataKeys,
781 ];
782
783 const fn json_key(self) -> &'static str {
784 match self {
785 Self::UnusedFiles => "unused_files",
786 Self::UnusedExports => "unused_exports",
787 Self::UnusedTypes => "unused_types",
788 Self::PrivateTypeLeaks => "private_type_leaks",
789 Self::DeprecatedExportsInUse => "deprecated_exports_in_use",
790 Self::UnusedDependencies => "unused_dependencies",
791 Self::UnusedDevDependencies => "unused_dev_dependencies",
792 Self::UnusedOptionalDependencies => "unused_optional_dependencies",
793 Self::UnusedEnumMembers => "unused_enum_members",
794 Self::UnusedClassMembers => "unused_class_members",
795 Self::UnusedStoreMembers => "unused_store_members",
796 Self::UnresolvedImports => "unresolved_imports",
797 Self::UnlistedDependencies => "unlisted_dependencies",
798 Self::DuplicateExports => "duplicate_exports",
799 Self::TypeOnlyDependencies => "type_only_dependencies",
800 Self::TestOnlyDependencies => "test_only_dependencies",
801 Self::DevDependenciesInProduction => "dev_dependencies_in_production",
802 Self::CircularDependencies => "circular_dependencies",
803 Self::ReExportCycles => "re_export_cycles",
804 Self::PackageCycles => "package_cycles",
805 Self::BoundaryViolations => "boundary_violations",
806 Self::BoundaryCoverageViolations => "boundary_coverage_violations",
807 Self::BoundaryCallViolations => "boundary_call_violations",
808 Self::PolicyViolations => "policy_violations",
809 Self::StaleSuppressions => "stale_suppressions",
810 Self::UnusedCatalogEntries => "unused_catalog_entries",
811 Self::EmptyCatalogGroups => "empty_catalog_groups",
812 Self::UnresolvedCatalogReferences => "unresolved_catalog_references",
813 Self::UnusedDependencyOverrides => "unused_dependency_overrides",
814 Self::MisconfiguredDependencyOverrides => "misconfigured_dependency_overrides",
815 Self::InvalidClientExports => "invalid_client_exports",
816 Self::MixedClientServerBarrels => "mixed_client_server_barrels",
817 Self::MisplacedDirectives => "misplaced_directives",
818 Self::UnprovidedInjects => "unprovided_injects",
819 Self::UnrenderedComponents => "unrendered_components",
820 Self::RouteCollisions => "route_collisions",
821 Self::DynamicSegmentNameConflicts => "dynamic_segment_name_conflicts",
822 Self::UnusedComponentProps => "unused_component_props",
823 Self::UnusedComponentEmits => "unused_component_emits",
824 Self::UnusedComponentInputs => "unused_component_inputs",
825 Self::UnusedComponentOutputs => "unused_component_outputs",
826 Self::UnusedSvelteEvents => "unused_svelte_events",
827 Self::UnusedServerActions => "unused_server_actions",
828 Self::UnusedLoadDataKeys => "unused_load_data_keys",
829 }
830 }
831}
832
833struct DeadCodeKeyCollector<'a> {
834 root: &'a Path,
835 keys: FxHashSet<String>,
836 introduced_keys: FxHashSet<String>,
837 inherited_keys: FxHashSet<String>,
838 records: Vec<AuditFindingRecord>,
839 collection_counts: FxHashMap<&'static str, usize>,
840 config: Option<&'a ResolvedConfig>,
841 base: Option<&'a FxHashSet<String>>,
842 #[cfg(test)]
843 classifications: usize,
844}
845
846impl<'a> DeadCodeKeyCollector<'a> {
847 fn new(root: &'a Path) -> Self {
848 Self {
849 root,
850 keys: FxHashSet::default(),
851 introduced_keys: FxHashSet::default(),
852 inherited_keys: FxHashSet::default(),
853 records: Vec::new(),
854 collection_counts: FxHashMap::default(),
855 config: None,
856 base: None,
857 #[cfg(test)]
858 classifications: 0,
859 }
860 }
861
862 fn for_comparison(
863 root: &'a Path,
864 config: &'a ResolvedConfig,
865 base: Option<&'a FxHashSet<String>>,
866 ) -> Self {
867 Self {
868 root,
869 keys: FxHashSet::default(),
870 introduced_keys: FxHashSet::default(),
871 inherited_keys: FxHashSet::default(),
872 records: Vec::new(),
873 collection_counts: FxHashMap::default(),
874 config: Some(config),
875 base,
876 #[cfg(test)]
877 classifications: 0,
878 }
879 }
880
881 fn into_keys(self) -> FxHashSet<String> {
882 self.keys
883 }
884
885 fn into_ledger(self) -> DeadCodeAuditLedger {
886 DeadCodeAuditLedger {
887 records: self.records,
888 keys: self.keys,
889 introduced_keys: self.introduced_keys,
890 inherited_keys: self.inherited_keys,
891 #[cfg(test)]
892 classifications: self.classifications,
893 }
894 }
895
896 fn insert(&mut self, collection: AuditCollection, key: String, effective_severity: Severity) {
897 let collection = collection.json_key();
898 let ordinal = self.collection_counts.entry(collection).or_default();
899 if self.config.is_some() {
900 #[cfg(test)]
901 {
902 self.classifications += 1;
903 }
904 let introduced = self.base.is_some_and(|base| !base.contains(&key));
905 if effective_severity != Severity::Off && self.base.is_some() {
906 if introduced {
907 self.introduced_keys.insert(key.clone());
908 } else {
909 self.inherited_keys.insert(key.clone());
910 }
911 }
912 self.records.push(AuditFindingRecord {
913 collection,
914 ordinal: *ordinal,
915 introduced,
916 effective_severity,
917 stable_key: key.clone(),
918 });
919 }
920 *ordinal += 1;
921 self.keys.insert(key);
922 }
923
924 fn add_items<T>(&mut self, collection: AuditCollection, items: &[T])
926 where
927 T: IdentifiedFinding + fallow_engine::dead_code::RuleSeverity,
928 {
929 let keys = collection_keys(items, self.root);
930 for (item, key) in items.iter().zip(keys) {
931 self.insert_rule(collection, key, item);
932 }
933 }
934
935 fn insert_rule(
938 &mut self,
939 collection: AuditCollection,
940 key: String,
941 item: &impl fallow_engine::dead_code::RuleSeverity,
942 ) {
943 let effective = self.config.map_or(Severity::Off, |config| {
944 item.rule_severity(&fallow_engine::dead_code::SeveritySource::from_config(
945 config,
946 ))
947 });
948 self.insert(collection, key, effective);
949 }
950
951 fn add_core_findings(
952 &mut self,
953 unused_files: &[fallow_types::output_dead_code::UnusedFileFinding],
954 unused_exports: &[fallow_types::output_dead_code::UnusedExportFinding],
955 unused_types: &[fallow_types::output_dead_code::UnusedTypeFinding],
956 private_type_leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
957 ) {
958 self.add_unused_files(unused_files);
959 self.add_unused_exports(unused_exports);
960 self.add_unused_types(unused_types);
961 self.add_private_type_leaks(private_type_leaks);
962 }
963
964 fn add_client_directive_findings(
965 &mut self,
966 invalid_client_exports: &[fallow_types::output_dead_code::InvalidClientExportFinding],
967 mixed_client_server_barrels: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
968 misplaced_directives: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
969 ) {
970 self.add_invalid_client_exports(invalid_client_exports);
971 self.add_mixed_client_server_barrels(mixed_client_server_barrels);
972 self.add_misplaced_directives(misplaced_directives);
973 }
974
975 fn add_dependency_findings(&mut self, deps: &DependencyFindingSlices<'_>) {
976 let DependencyFindingSlices {
977 unused_dependencies,
978 unused_dev_dependencies,
979 unused_optional_dependencies,
980 unlisted_dependencies,
981 type_only_dependencies,
982 test_only_dependencies,
983 dev_dependencies_in_production,
984 } = *deps;
985 self.add_unused_dependencies(unused_dependencies);
986 self.add_unused_dev_dependencies(unused_dev_dependencies);
987 self.add_unused_optional_dependencies(unused_optional_dependencies);
988 self.add_unlisted_dependencies(unlisted_dependencies);
989 self.add_type_only_dependencies(type_only_dependencies);
990 self.add_test_only_dependencies(test_only_dependencies);
991 self.add_dev_dependencies_in_production(dev_dependencies_in_production);
992 }
993
994 fn add_dependency_override_findings(
995 &mut self,
996 unused_dependency_overrides: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
997 misconfigured_dependency_overrides: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
998 ) {
999 self.add_unused_dependency_overrides(unused_dependency_overrides);
1000 self.add_misconfigured_dependency_overrides(misconfigured_dependency_overrides);
1001 }
1002
1003 fn add_member_findings(
1004 &mut self,
1005 unused_enum_members: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1006 unused_class_members: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1007 unused_store_members: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1008 ) {
1009 self.add_unused_enum_members(unused_enum_members);
1010 self.add_unused_class_members(unused_class_members);
1011 self.add_unused_store_members(unused_store_members);
1012 }
1013
1014 fn add_component_contract_findings(
1015 &mut self,
1016 unused_component_props: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1017 unused_component_emits: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1018 unused_component_inputs: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1019 unused_component_outputs: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1020 unused_svelte_events: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1021 ) {
1022 self.add_unused_component_props(unused_component_props);
1023 self.add_unused_component_emits(unused_component_emits);
1024 self.add_unused_component_inputs(unused_component_inputs);
1025 self.add_unused_component_outputs(unused_component_outputs);
1026 self.add_unused_svelte_events(unused_svelte_events);
1027 }
1028
1029 fn add_graph_findings(
1030 &mut self,
1031 unresolved_imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1032 duplicate_exports: &[fallow_types::output_dead_code::DuplicateExportFinding],
1033 circular_dependencies: &[fallow_types::output_dead_code::CircularDependencyFinding],
1034 re_export_cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
1035 package_cycles: &[fallow_types::output_dead_code::PackageCycleFinding],
1036 ) {
1037 self.add_unresolved_imports(unresolved_imports);
1038 self.add_duplicate_exports(duplicate_exports);
1039 self.add_circular_dependencies(circular_dependencies);
1040 self.add_re_export_cycles(re_export_cycles);
1041 self.add_package_cycles(package_cycles);
1042 }
1043
1044 fn add_boundary_findings(
1045 &mut self,
1046 boundary_violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1047 boundary_coverage_violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1048 boundary_call_violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1049 policy_violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
1050 stale_suppressions: &[fallow_types::results::StaleSuppression],
1051 ) {
1052 self.add_boundary_violations(boundary_violations);
1053 self.add_boundary_coverage_violations(boundary_coverage_violations);
1054 self.add_boundary_call_violations(boundary_call_violations);
1055 self.add_policy_violations(policy_violations);
1056 self.add_stale_suppressions(stale_suppressions);
1057 }
1058
1059 fn add_catalog_findings(
1060 &mut self,
1061 unresolved_catalog_references: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1062 unused_catalog_entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1063 empty_catalog_groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1064 ) {
1065 self.add_unresolved_catalog_references(unresolved_catalog_references);
1066 self.add_unused_catalog_entries(unused_catalog_entries);
1067 self.add_empty_catalog_groups(empty_catalog_groups);
1068 }
1069
1070 fn add_framework_findings(&mut self, framework: &FrameworkFindingSlices<'_>) {
1071 let FrameworkFindingSlices {
1072 unprovided_injects,
1073 unrendered_components,
1074 unused_server_actions,
1075 unused_load_data_keys,
1076 route_collisions,
1077 dynamic_segment_name_conflicts,
1078 } = *framework;
1079 self.add_unprovided_injects(unprovided_injects);
1080 self.add_unrendered_components(unrendered_components);
1081 self.add_unused_server_actions(unused_server_actions);
1082 self.add_unused_load_data_keys(unused_load_data_keys);
1083 self.add_route_collisions(route_collisions);
1084 self.add_dynamic_segment_name_conflicts(dynamic_segment_name_conflicts);
1085 }
1086
1087 fn add_unused_files(&mut self, items: &[fallow_types::output_dead_code::UnusedFileFinding]) {
1088 self.add_items(AuditCollection::UnusedFiles, items);
1089 }
1090
1091 fn add_unused_exports(
1092 &mut self,
1093 items: &[fallow_types::output_dead_code::UnusedExportFinding],
1094 ) {
1095 self.add_items(AuditCollection::UnusedExports, items);
1096 }
1097
1098 fn add_unused_types(&mut self, items: &[fallow_types::output_dead_code::UnusedTypeFinding]) {
1099 self.add_items(AuditCollection::UnusedTypes, items);
1100 }
1101
1102 fn add_private_type_leaks(
1103 &mut self,
1104 items: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
1105 ) {
1106 self.add_items(AuditCollection::PrivateTypeLeaks, items);
1107 }
1108
1109 fn add_deprecated_exports_in_use(
1110 &mut self,
1111 items: &[fallow_types::output_dead_code::DeprecatedExportInUseFinding],
1112 ) {
1113 self.add_items(AuditCollection::DeprecatedExportsInUse, items);
1114 }
1115
1116 fn add_invalid_client_exports(
1117 &mut self,
1118 items: &[fallow_types::output_dead_code::InvalidClientExportFinding],
1119 ) {
1120 self.add_items(AuditCollection::InvalidClientExports, items);
1121 }
1122
1123 fn add_mixed_client_server_barrels(
1124 &mut self,
1125 items: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
1126 ) {
1127 self.add_items(AuditCollection::MixedClientServerBarrels, items);
1128 }
1129
1130 fn add_misplaced_directives(
1131 &mut self,
1132 items: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1133 ) {
1134 self.add_items(AuditCollection::MisplacedDirectives, items);
1135 }
1136
1137 fn add_unprovided_injects(
1138 &mut self,
1139 items: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
1140 ) {
1141 self.add_items(AuditCollection::UnprovidedInjects, items);
1142 }
1143
1144 fn add_unrendered_components(
1145 &mut self,
1146 items: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1147 ) {
1148 self.add_items(AuditCollection::UnrenderedComponents, items);
1149 }
1150
1151 fn add_unused_component_props(
1152 &mut self,
1153 items: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1154 ) {
1155 self.add_items(AuditCollection::UnusedComponentProps, items);
1156 }
1157
1158 fn add_unused_component_emits(
1159 &mut self,
1160 items: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1161 ) {
1162 self.add_items(AuditCollection::UnusedComponentEmits, items);
1163 }
1164
1165 fn add_unused_component_inputs(
1166 &mut self,
1167 items: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1168 ) {
1169 self.add_items(AuditCollection::UnusedComponentInputs, items);
1170 }
1171
1172 fn add_unused_component_outputs(
1173 &mut self,
1174 items: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1175 ) {
1176 self.add_items(AuditCollection::UnusedComponentOutputs, items);
1177 }
1178
1179 fn add_unused_svelte_events(
1180 &mut self,
1181 items: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1182 ) {
1183 self.add_items(AuditCollection::UnusedSvelteEvents, items);
1184 }
1185
1186 fn add_unused_server_actions(
1187 &mut self,
1188 items: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1189 ) {
1190 self.add_items(AuditCollection::UnusedServerActions, items);
1191 }
1192
1193 fn add_unused_load_data_keys(
1194 &mut self,
1195 items: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1196 ) {
1197 self.add_items(AuditCollection::UnusedLoadDataKeys, items);
1198 }
1199
1200 fn add_route_collisions(
1201 &mut self,
1202 items: &[fallow_types::output_dead_code::RouteCollisionFinding],
1203 ) {
1204 self.add_items(AuditCollection::RouteCollisions, items);
1205 }
1206
1207 fn add_dynamic_segment_name_conflicts(
1208 &mut self,
1209 items: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1210 ) {
1211 self.add_items(AuditCollection::DynamicSegmentNameConflicts, items);
1212 }
1213
1214 fn add_unused_dependencies(
1215 &mut self,
1216 items: &[fallow_types::output_dead_code::UnusedDependencyFinding],
1217 ) {
1218 self.add_items(AuditCollection::UnusedDependencies, items);
1219 }
1220
1221 fn add_unused_dev_dependencies(
1222 &mut self,
1223 items: &[fallow_types::output_dead_code::UnusedDevDependencyFinding],
1224 ) {
1225 self.add_items(AuditCollection::UnusedDevDependencies, items);
1226 }
1227
1228 fn add_unused_optional_dependencies(
1229 &mut self,
1230 items: &[fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
1231 ) {
1232 self.add_items(AuditCollection::UnusedOptionalDependencies, items);
1233 }
1234
1235 fn add_unused_enum_members(
1236 &mut self,
1237 items: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1238 ) {
1239 self.add_items(AuditCollection::UnusedEnumMembers, items);
1240 }
1241
1242 fn add_unused_class_members(
1243 &mut self,
1244 items: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1245 ) {
1246 self.add_items(AuditCollection::UnusedClassMembers, items);
1247 }
1248
1249 fn add_unused_store_members(
1250 &mut self,
1251 items: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1252 ) {
1253 self.add_items(AuditCollection::UnusedStoreMembers, items);
1254 }
1255
1256 fn add_unresolved_imports(
1257 &mut self,
1258 items: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1259 ) {
1260 self.add_items(AuditCollection::UnresolvedImports, items);
1261 }
1262
1263 fn add_unlisted_dependencies(
1264 &mut self,
1265 items: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
1266 ) {
1267 self.add_items(AuditCollection::UnlistedDependencies, items);
1268 }
1269
1270 fn add_duplicate_exports(
1271 &mut self,
1272 items: &[fallow_types::output_dead_code::DuplicateExportFinding],
1273 ) {
1274 self.add_items(AuditCollection::DuplicateExports, items);
1275 }
1276
1277 fn add_type_only_dependencies(
1278 &mut self,
1279 items: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
1280 ) {
1281 self.add_items(AuditCollection::TypeOnlyDependencies, items);
1282 }
1283
1284 fn add_test_only_dependencies(
1285 &mut self,
1286 items: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
1287 ) {
1288 self.add_items(AuditCollection::TestOnlyDependencies, items);
1289 }
1290
1291 fn add_dev_dependencies_in_production(
1292 &mut self,
1293 items: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
1294 ) {
1295 self.add_items(AuditCollection::DevDependenciesInProduction, items);
1296 }
1297
1298 fn add_circular_dependencies(
1299 &mut self,
1300 items: &[fallow_types::output_dead_code::CircularDependencyFinding],
1301 ) {
1302 self.add_items(AuditCollection::CircularDependencies, items);
1303 }
1304
1305 fn add_re_export_cycles(
1306 &mut self,
1307 items: &[fallow_types::output_dead_code::ReExportCycleFinding],
1308 ) {
1309 self.add_items(AuditCollection::ReExportCycles, items);
1310 }
1311
1312 fn add_package_cycles(
1313 &mut self,
1314 items: &[fallow_types::output_dead_code::PackageCycleFinding],
1315 ) {
1316 self.add_items(AuditCollection::PackageCycles, items);
1317 }
1318
1319 fn add_boundary_violations(
1320 &mut self,
1321 items: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1322 ) {
1323 self.add_items(AuditCollection::BoundaryViolations, items);
1324 }
1325
1326 fn add_boundary_coverage_violations(
1327 &mut self,
1328 items: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1329 ) {
1330 self.add_items(AuditCollection::BoundaryCoverageViolations, items);
1331 }
1332
1333 fn add_boundary_call_violations(
1334 &mut self,
1335 items: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1336 ) {
1337 self.add_items(AuditCollection::BoundaryCallViolations, items);
1338 }
1339
1340 fn add_policy_violations(
1341 &mut self,
1342 items: &[fallow_types::output_dead_code::PolicyViolationFinding],
1343 ) {
1344 self.add_items(AuditCollection::PolicyViolations, items);
1345 }
1346
1347 fn add_stale_suppressions(&mut self, items: &[fallow_types::results::StaleSuppression]) {
1348 self.add_items(AuditCollection::StaleSuppressions, items);
1349 }
1350
1351 fn add_unresolved_catalog_references(
1352 &mut self,
1353 items: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1354 ) {
1355 self.add_items(AuditCollection::UnresolvedCatalogReferences, items);
1356 }
1357
1358 fn add_unused_catalog_entries(
1359 &mut self,
1360 items: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1361 ) {
1362 self.add_items(AuditCollection::UnusedCatalogEntries, items);
1363 }
1364
1365 fn add_empty_catalog_groups(
1366 &mut self,
1367 items: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1368 ) {
1369 self.add_items(AuditCollection::EmptyCatalogGroups, items);
1370 }
1371
1372 fn add_unused_dependency_overrides(
1373 &mut self,
1374 items: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1375 ) {
1376 self.add_items(AuditCollection::UnusedDependencyOverrides, items);
1377 }
1378
1379 fn add_misconfigured_dependency_overrides(
1380 &mut self,
1381 items: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1382 ) {
1383 self.add_items(AuditCollection::MisconfiguredDependencyOverrides, items);
1384 }
1385}
1386
1387#[expect(
1409 clippy::implicit_hasher,
1410 reason = "fallow standardizes on FxHashSet across audit attribution keys"
1411)]
1412pub fn retain_introduced_dead_code(
1413 results: &mut fallow_types::results::AnalysisResults,
1414 root: &Path,
1415 base: Option<&FxHashSet<String>>,
1416) {
1417 let Some(base) = base else {
1418 return;
1419 };
1420
1421 classify_introduced_dead_code_fields(results);
1429 macro_rules! retain {
1430 ($($field:ident),* $(,)?) => {
1431 $(retain_introduced(&mut results.$field, root, base);)*
1432 };
1433 }
1434 retain!(
1435 unused_files,
1436 unused_exports,
1437 unused_types,
1438 private_type_leaks,
1439 deprecated_exports_in_use,
1440 unused_enum_members,
1441 unused_class_members,
1442 unused_store_members,
1443 unresolved_imports,
1444 unused_dependencies,
1445 unused_dev_dependencies,
1446 unused_optional_dependencies,
1447 unlisted_dependencies,
1448 duplicate_exports,
1449 type_only_dependencies,
1450 test_only_dependencies,
1451 circular_dependencies,
1452 re_export_cycles,
1453 package_cycles,
1454 boundary_violations,
1455 boundary_coverage_violations,
1456 boundary_call_violations,
1457 policy_violations,
1458 stale_suppressions,
1459 unresolved_catalog_references,
1460 unused_catalog_entries,
1461 empty_catalog_groups,
1462 unused_dependency_overrides,
1463 misconfigured_dependency_overrides,
1464 invalid_client_exports,
1465 mixed_client_server_barrels,
1466 misplaced_directives,
1467 unprovided_injects,
1468 unrendered_components,
1469 unused_component_props,
1470 unused_component_emits,
1471 unused_component_inputs,
1472 unused_component_outputs,
1473 unused_svelte_events,
1474 unused_server_actions,
1475 unused_load_data_keys,
1476 route_collisions,
1477 dynamic_segment_name_conflicts,
1478 );
1479}
1480
1481fn retain_introduced<T: IdentifiedFinding>(
1483 items: &mut Vec<T>,
1484 root: &Path,
1485 base: &FxHashSet<String>,
1486) {
1487 let mut keys = collection_keys(items, root).into_iter();
1488 items.retain(|_| keys.next().is_some_and(|key| !base.contains(&key)));
1489}
1490
1491fn classify_introduced_dead_code_fields(results: &fallow_types::results::AnalysisResults) {
1492 let fallow_types::results::AnalysisResults {
1493 unused_files: _unused_files,
1494 unused_exports: _unused_exports,
1495 unused_types: _unused_types,
1496 private_type_leaks: _private_type_leaks,
1497 deprecated_exports_in_use: _deprecated_exports_in_use,
1498 unused_dependencies: _unused_dependencies,
1499 unused_dev_dependencies: _unused_dev_dependencies,
1500 unused_optional_dependencies: _unused_optional_dependencies,
1501 unused_enum_members: _unused_enum_members,
1502 unused_class_members: _unused_class_members,
1503 unused_store_members: _unused_store_members,
1504 unresolved_imports: _unresolved_imports,
1505 unlisted_dependencies: _unlisted_dependencies,
1506 duplicate_exports: _duplicate_exports,
1507 type_only_dependencies: _type_only_dependencies,
1508 test_only_dependencies: _test_only_dependencies,
1509 dev_dependencies_in_production: _dev_dependencies_in_production,
1510 circular_dependencies: _circular_dependencies,
1511 re_export_cycles: _re_export_cycles,
1512 package_cycles: _package_cycles,
1513 boundary_violations: _boundary_violations,
1514 boundary_coverage_violations: _boundary_coverage_violations,
1515 boundary_call_violations: _boundary_call_violations,
1516 policy_violations: _policy_violations,
1517 stale_suppressions: _stale_suppressions,
1518 unused_catalog_entries: _unused_catalog_entries,
1519 empty_catalog_groups: _empty_catalog_groups,
1520 unresolved_catalog_references: _unresolved_catalog_references,
1521 unused_dependency_overrides: _unused_dependency_overrides,
1522 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
1523 invalid_client_exports: _invalid_client_exports,
1524 mixed_client_server_barrels: _mixed_client_server_barrels,
1525 misplaced_directives: _misplaced_directives,
1526 unprovided_injects: _unprovided_injects,
1527 unrendered_components: _unrendered_components,
1528 unused_component_props: _unused_component_props,
1529 unused_component_emits: _unused_component_emits,
1530 unused_component_inputs: _unused_component_inputs,
1531 unused_component_outputs: _unused_component_outputs,
1532 unused_svelte_events: _unused_svelte_events,
1533 unused_server_actions: _unused_server_actions,
1534 unused_load_data_keys: _unused_load_data_keys,
1535 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
1536 route_collisions: _route_collisions,
1537 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
1538 suppression_count: _suppression_count,
1541 unused_component_props_exempted: _unused_component_props_exempted,
1542 active_suppressions: _active_suppressions,
1543 feature_flags: _feature_flags,
1544 security_findings: _security_findings,
1548 security_unresolved_edge_files: _security_unresolved_edge_files,
1549 security_unresolved_callee_sites: _security_unresolved_callee_sites,
1550 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
1551 prop_drilling_chains: _prop_drilling_chains,
1554 thin_wrappers: _thin_wrappers,
1557 duplicate_prop_shapes: _duplicate_prop_shapes,
1560 export_usages: _export_usages,
1563 entry_point_summary: _entry_point_summary,
1564 render_fan_in: _render_fan_in,
1567 react_component_intel: _react_component_intel,
1570 semantic_framework_contracts: _semantic_framework_contracts,
1571 } = results;
1572}
1573
1574fn introduced_flags<T: IdentifiedFinding>(
1576 items: &[T],
1577 root: &Path,
1578 base: &FxHashSet<String>,
1579) -> Vec<bool> {
1580 collection_keys(items, root)
1581 .iter()
1582 .map(|key| !base.contains(key))
1583 .collect()
1584}
1585
1586fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
1587 !base.contains(key)
1588}
1589
1590fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
1591where
1592 I: IntoIterator<Item = bool>,
1593{
1594 let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
1595 return;
1596 };
1597 for (item, introduced) in items.iter_mut().zip(introduced) {
1598 if let serde_json::Value::Object(map) = item {
1599 map.insert("introduced".to_string(), serde_json::json!(introduced));
1600 }
1601 }
1602}
1603
1604#[expect(
1609 clippy::implicit_hasher,
1610 reason = "fallow standardizes on FxHashSet across audit attribution keys"
1611)]
1612pub fn annotate_dead_code_json(
1613 json: &mut serde_json::Value,
1614 results: &fallow_types::results::AnalysisResults,
1615 root: &Path,
1616 base: &FxHashSet<String>,
1617) {
1618 macro_rules! annotate {
1619 ($($field:ident),* $(,)?) => {
1620 $(annotate_issue_array(
1621 json,
1622 stringify!($field),
1623 introduced_flags(&results.$field, root, base),
1624 );)*
1625 };
1626 }
1627 annotate!(
1628 unused_files,
1629 unused_exports,
1630 unused_types,
1631 private_type_leaks,
1632 deprecated_exports_in_use,
1633 unused_dependencies,
1634 unused_dev_dependencies,
1635 unused_optional_dependencies,
1636 type_only_dependencies,
1637 test_only_dependencies,
1638 unlisted_dependencies,
1639 unused_enum_members,
1640 unused_class_members,
1641 unused_store_members,
1642 unresolved_imports,
1643 duplicate_exports,
1644 circular_dependencies,
1645 re_export_cycles,
1646 package_cycles,
1647 boundary_violations,
1648 boundary_coverage_violations,
1649 boundary_call_violations,
1650 policy_violations,
1651 stale_suppressions,
1652 unresolved_catalog_references,
1653 unused_catalog_entries,
1654 empty_catalog_groups,
1655 unused_dependency_overrides,
1656 misconfigured_dependency_overrides,
1657 invalid_client_exports,
1658 mixed_client_server_barrels,
1659 misplaced_directives,
1660 unprovided_injects,
1661 unrendered_components,
1662 unused_component_props,
1663 unused_component_emits,
1664 unused_component_inputs,
1665 unused_component_outputs,
1666 unused_svelte_events,
1667 unused_server_actions,
1668 route_collisions,
1669 dynamic_segment_name_conflicts,
1670 );
1671}
1672
1673#[expect(
1677 clippy::implicit_hasher,
1678 reason = "fallow standardizes on FxHashSet across audit attribution keys"
1679)]
1680pub fn annotate_stale_suppressions_json(
1681 json: &mut serde_json::Value,
1682 results: &fallow_types::results::AnalysisResults,
1683 root: &Path,
1684 base: &FxHashSet<String>,
1685) {
1686 annotate_issue_array(
1687 json,
1688 "stale_suppressions",
1689 introduced_flags(&results.stale_suppressions, root, base),
1690 );
1691}
1692
1693#[expect(
1697 clippy::implicit_hasher,
1698 reason = "fallow standardizes on FxHashSet across audit attribution keys"
1699)]
1700pub fn annotate_health_json(
1701 json: &mut serde_json::Value,
1702 report: &fallow_output::HealthReport,
1703 root: &Path,
1704 base: &FxHashSet<String>,
1705) {
1706 if let Some(items) = json
1707 .get_mut("findings")
1708 .and_then(serde_json::Value::as_array_mut)
1709 {
1710 for (item, finding) in items.iter_mut().zip(&report.findings) {
1711 if let serde_json::Value::Object(map) = item {
1712 map.insert(
1713 "introduced".to_string(),
1714 serde_json::json!(issue_was_introduced(
1715 &health_finding_key(finding, root),
1716 base
1717 )),
1718 );
1719 }
1720 }
1721 }
1722 if let Some(items) = json
1723 .get_mut("styling_findings")
1724 .and_then(serde_json::Value::as_array_mut)
1725 {
1726 for (item, finding) in items.iter_mut().zip(&report.styling_findings) {
1727 if let serde_json::Value::Object(map) = item {
1728 map.insert(
1729 "introduced".to_string(),
1730 serde_json::json!(issue_was_introduced(
1731 &styling_finding_key(finding, root),
1732 base
1733 )),
1734 );
1735 }
1736 }
1737 }
1738}
1739
1740#[expect(
1743 clippy::implicit_hasher,
1744 reason = "fallow standardizes on FxHashSet across audit attribution keys"
1745)]
1746pub fn annotate_dupes_json(
1747 json: &mut serde_json::Value,
1748 report: &fallow_types::duplicates::DuplicationReport,
1749 root: &Path,
1750 base: &FxHashSet<String>,
1751) {
1752 let Some(items) = json
1753 .get_mut("clone_groups")
1754 .and_then(serde_json::Value::as_array_mut)
1755 else {
1756 return;
1757 };
1758 for (item, group) in items.iter_mut().zip(&report.clone_groups) {
1759 if let serde_json::Value::Object(map) = item {
1760 map.insert(
1761 "introduced".to_string(),
1762 serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
1763 );
1764 }
1765 }
1766}
1767
1768pub fn annotate_domain_json(
1770 json: &mut serde_json::Value,
1771 collection: &str,
1772 introduced: impl IntoIterator<Item = bool>,
1773) {
1774 annotate_issue_array(json, collection, introduced);
1775}
1776
1777pub fn annotate_domain_demotions_json(
1782 json: &mut serde_json::Value,
1783 collection: &str,
1784 demoted: impl IntoIterator<Item = bool>,
1785 reason: crate::CloneDemotionReason,
1786) {
1787 let Some(items) = json
1788 .get_mut(collection)
1789 .and_then(serde_json::Value::as_array_mut)
1790 else {
1791 return;
1792 };
1793 for (item, demoted) in items.iter_mut().zip(demoted) {
1794 if demoted && let serde_json::Value::Object(map) = item {
1795 map.insert("demotion_reason".to_string(), serde_json::json!(reason));
1796 }
1797 }
1798}
1799
1800pub fn health_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
1802 report
1803 .findings
1804 .iter()
1805 .map(|finding| health_finding_key(finding, root))
1806 .collect()
1807}
1808
1809pub fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1813 format!(
1814 "complexity:{}:{}:{:?}",
1815 relative_key_path(Path::new(&finding.path), root),
1816 finding.name,
1817 finding.exceeded
1818 )
1819}
1820
1821pub fn styling_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
1823 report
1824 .styling_findings
1825 .iter()
1826 .map(|finding| styling_finding_key(finding, root))
1827 .collect()
1828}
1829
1830pub fn styling_finding_key(finding: &fallow_output::StylingFinding, root: &Path) -> String {
1833 format!(
1834 "styling:{}:{}:{}:{}:{}",
1835 finding.code,
1836 finding.sub_kind,
1837 relative_key_path(Path::new(&finding.path), root),
1838 finding.line,
1839 finding.value
1840 )
1841}
1842
1843pub fn dupes_keys(
1845 report: &fallow_types::duplicates::DuplicationReport,
1846 root: &Path,
1847) -> FxHashSet<String> {
1848 report
1849 .clone_groups
1850 .iter()
1851 .map(|group| dupe_group_key(group, root))
1852 .collect()
1853}
1854
1855pub fn dupe_group_key(group: &fallow_types::duplicates::CloneGroup, root: &Path) -> String {
1860 let mut files: Vec<String> = group
1861 .instances
1862 .iter()
1863 .map(|instance| relative_key_path(&instance.file, root))
1864 .collect();
1865 files.sort();
1866 files.dedup();
1867 let mut hasher = DefaultHasher::new();
1868 for instance in &group.instances {
1869 instance.fragment.hash(&mut hasher);
1870 }
1871 format!(
1872 "dupe:{}:{}:{}:{:x}",
1873 files.join("|"),
1874 group.token_count,
1875 group.line_count,
1876 hasher.finish()
1877 )
1878}
1879
1880pub fn preexisting_dupe_group_keys<'a>(
1890 groups: impl IntoIterator<Item = &'a fallow_types::duplicates::CloneGroup>,
1891 root: &Path,
1892 diff: &fallow_output::DiffIndex,
1893) -> FxHashSet<String> {
1894 let instance_touched = |instance: &fallow_types::duplicates::CloneInstance| -> bool {
1895 let Some(rel) = diff.key_for(&instance.file, root) else {
1896 return true;
1897 };
1898 let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
1899 let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
1900 diff.range_overlaps_added(&rel, start, end)
1901 };
1902 groups
1903 .into_iter()
1904 .filter(|group| !group.instances.iter().any(instance_touched))
1905 .map(|group| dupe_group_key(group, root))
1906 .collect()
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use std::path::{Path, PathBuf};
1912
1913 use fallow_config::{FallowConfig, Severity};
1914 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationReport};
1915 use fallow_types::envelope::AuditIntroduced;
1916 use fallow_types::extract::MemberKind;
1917 use fallow_types::output_dead_code::*;
1918 use fallow_types::output_format::OutputFormat;
1919 use fallow_types::results::*;
1920 use rustc_hash::FxHashSet;
1921 use serde_json::json;
1922
1923 use fallow_output::{
1924 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
1925 };
1926
1927 use super::{
1928 AuditDomainLedger, annotate_dead_code_json, annotate_dupes_json, annotate_health_json,
1929 annotate_stale_suppressions_json, dead_code_audit_ledger, dead_code_keys, dupe_group_key,
1930 dupes_keys, health_finding_key, health_keys, relative_key_path, remap_keys_for_renames,
1931 retain_introduced_dead_code,
1932 };
1933
1934 fn root() -> PathBuf {
1935 PathBuf::from("/repo")
1936 }
1937
1938 fn export(path: &Path, name: &str) -> UnusedExportFinding {
1939 UnusedExportFinding::with_actions(UnusedExport {
1940 path: path.to_path_buf(),
1941 export_name: name.to_string(),
1942 is_type_only: false,
1943 line: 1,
1944 col: 0,
1945 span_start: 0,
1946 is_re_export: false,
1947 deprecated: false,
1948 deprecated_reason: None,
1949 })
1950 }
1951
1952 fn unused_file(path: &Path) -> UnusedFileFinding {
1953 UnusedFileFinding::with_actions(UnusedFile {
1954 path: path.to_path_buf(),
1955 })
1956 }
1957
1958 fn dependency(path: &Path, package_name: &str) -> UnusedDependencyFinding {
1959 UnusedDependencyFinding::with_actions(UnusedDependency {
1960 package_name: package_name.to_string(),
1961 location: DependencyLocation::Dependencies,
1962 path: path.to_path_buf(),
1963 line: 4,
1964 used_in_workspaces: Vec::new(),
1965 })
1966 }
1967
1968 fn unresolved(path: &Path, specifier: &str) -> UnresolvedImportFinding {
1969 UnresolvedImportFinding::with_actions(UnresolvedImport {
1970 path: path.to_path_buf(),
1971 specifier: specifier.to_string(),
1972 line: 2,
1973 col: 1,
1974 specifier_col: 8,
1975 })
1976 }
1977
1978 fn unlisted(path: &Path, package_name: &str) -> UnlistedDependencyFinding {
1979 UnlistedDependencyFinding::with_actions(UnlistedDependency {
1980 package_name: package_name.to_string(),
1981 imported_from: vec![
1982 ImportSite {
1983 path: path.to_path_buf(),
1984 line: 9,
1985 col: 2,
1986 },
1987 ImportSite {
1988 path: path.to_path_buf(),
1989 line: 9,
1990 col: 2,
1991 },
1992 ],
1993 })
1994 }
1995
1996 fn duplicate_export(root: &Path) -> DuplicateExportFinding {
1997 DuplicateExportFinding::with_actions(DuplicateExport {
1998 export_name: "Button".to_string(),
1999 locations: vec![
2000 DuplicateLocation {
2001 path: root.join("src/b.ts"),
2002 line: 1,
2003 col: 0,
2004 },
2005 DuplicateLocation {
2006 path: root.join("src/a.ts"),
2007 line: 1,
2008 col: 0,
2009 },
2010 DuplicateLocation {
2011 path: root.join("src/a.ts"),
2012 line: 2,
2013 col: 0,
2014 },
2015 ],
2016 })
2017 }
2018
2019 fn deprecated_export(
2020 path: &Path,
2021 name: &str,
2022 ) -> fallow_types::output_dead_code::DeprecatedExportInUseFinding {
2023 fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
2024 fallow_types::results::DeprecatedExportInUse {
2025 path: path.to_path_buf(),
2026 export_name: name.to_string(),
2027 is_type_only: false,
2028 line: 9,
2029 col: 13,
2030 span_start: 90,
2031 deprecated_reason: None,
2032 consumer_count: 1,
2033 consumers: Vec::new(),
2034 public_api: false,
2035 },
2036 )
2037 }
2038
2039 fn sample_results(root: &Path) -> AnalysisResults {
2040 let source = root.join("src/page.ts");
2041 let package_json = root.join("package.json");
2042 let mut results = AnalysisResults::default();
2043 results
2044 .unused_files
2045 .push(unused_file(&root.join("src/dead.ts")));
2046 results.unused_exports.push(export(&source, "loader"));
2047 results
2048 .unused_dependencies
2049 .push(dependency(&package_json, "left-pad"));
2050 results
2051 .unresolved_imports
2052 .push(unresolved(&source, "./missing"));
2053 results.unlisted_dependencies.push(unlisted(&source, "zod"));
2054 results
2055 .deprecated_exports_in_use
2056 .push(deprecated_export(&source, "legacyLoader"));
2057 results.duplicate_exports.push(duplicate_export(root));
2058 results
2059 }
2060
2061 #[test]
2062 fn relative_key_path_strips_root_and_normalizes_separators() {
2063 let path = Path::new("/repo/src\\feature\\index.ts");
2064 assert_eq!(
2065 relative_key_path(path, Path::new("/repo")),
2066 "src/feature/index.ts"
2067 );
2068 }
2069
2070 #[test]
2071 fn dead_code_keys_are_stable_for_unsorted_and_duplicate_locations() {
2072 let root = root();
2073 let keys = dead_code_keys(&sample_results(&root), &root);
2074
2075 assert!(keys.contains("unused-file:src/dead.ts"));
2076 assert!(keys.contains("unused-export:src/page.ts:loader"));
2077 assert!(keys.contains("unused-dependency:package.json:left-pad"));
2078 assert!(keys.contains("unresolved-import:src/page.ts:./missing"));
2079 assert!(keys.contains("unlisted-dependency:zod"));
2080 assert!(keys.contains("duplicate-export:Button:src/a.ts|src/b.ts"));
2081 }
2082
2083 fn type_member_and_dependency_results(root: &Path) -> AnalysisResults {
2084 let source = root.join("src/types.ts");
2085 let package_json = root.join("package.json");
2086 let mut results = AnalysisResults::default();
2087 results
2088 .unused_types
2089 .push(UnusedTypeFinding::with_actions(UnusedExport {
2090 path: source.clone(),
2091 export_name: "UnusedType".to_string(),
2092 is_type_only: true,
2093 line: 3,
2094 col: 0,
2095 span_start: 12,
2096 is_re_export: false,
2097 deprecated: false,
2098 deprecated_reason: None,
2099 }));
2100 results
2101 .private_type_leaks
2102 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
2103 path: source.clone(),
2104 export_name: "makePublic".to_string(),
2105 type_name: "PrivateShape".to_string(),
2106 line: 7,
2107 col: 12,
2108 span_start: 64,
2109 semantic: None,
2110 }));
2111 results
2112 .unused_dev_dependencies
2113 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2114 package_name: "vite".to_string(),
2115 location: DependencyLocation::DevDependencies,
2116 path: package_json.clone(),
2117 line: 10,
2118 used_in_workspaces: Vec::new(),
2119 }));
2120 results
2121 .unused_optional_dependencies
2122 .push(UnusedOptionalDependencyFinding::with_actions(
2123 UnusedDependency {
2124 package_name: "fsevents".to_string(),
2125 location: DependencyLocation::OptionalDependencies,
2126 path: package_json.clone(),
2127 line: 11,
2128 used_in_workspaces: Vec::new(),
2129 },
2130 ));
2131 results
2132 .unused_enum_members
2133 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2134 path: source.clone(),
2135 parent_name: "Status".to_string(),
2136 member_name: "Idle".to_string(),
2137 kind: MemberKind::EnumMember,
2138 line: 15,
2139 col: 2,
2140 }));
2141 results
2142 .unused_class_members
2143 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2144 path: source,
2145 parent_name: "Controller".to_string(),
2146 member_name: "legacy".to_string(),
2147 kind: MemberKind::ClassMethod,
2148 line: 21,
2149 col: 2,
2150 }));
2151 results
2152 .type_only_dependencies
2153 .push(TypeOnlyDependencyFinding::with_actions(
2154 TypeOnlyDependency {
2155 package_name: "zod".to_string(),
2156 path: package_json.clone(),
2157 line: 12,
2158 },
2159 ));
2160 results
2161 .test_only_dependencies
2162 .push(TestOnlyDependencyFinding::with_actions(
2163 TestOnlyDependency {
2164 package_name: "vitest".to_string(),
2165 path: package_json,
2166 line: 13,
2167 },
2168 ));
2169
2170 results
2171 }
2172
2173 #[test]
2174 fn dead_code_keys_cover_type_member_and_dependency_variants() {
2175 let root = root();
2176 let results = type_member_and_dependency_results(&root);
2177
2178 let keys = dead_code_keys(&results, &root);
2179
2180 assert!(keys.contains("unused-type:src/types.ts:UnusedType"));
2181 assert!(keys.contains("private-type-leak:src/types.ts:makePublic:PrivateShape"));
2182 assert!(keys.contains("unused-dev-dependency:package.json:vite"));
2183 assert!(keys.contains("unused-optional-dependency:package.json:fsevents"));
2184 assert!(keys.contains("unused-enum-member:src/types.ts:Status:Idle"));
2185 assert!(keys.contains("unused-class-member:src/types.ts:Controller:legacy"));
2186 assert!(keys.contains("type-only-dependency:package.json:zod"));
2187 assert!(keys.contains("test-only-dependency:package.json:vitest"));
2188 }
2189
2190 #[expect(
2191 clippy::too_many_lines,
2192 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2193 )]
2194 fn graph_boundary_catalog_override_results(root: &std::path::Path) -> AnalysisResults {
2195 let source = root.join("src/app.ts");
2196 let other = root.join("src/other.ts");
2197 let workspace = root.join("pnpm-workspace.yaml");
2198 let mut results = AnalysisResults::default();
2199 results
2200 .circular_dependencies
2201 .push(CircularDependencyFinding::with_actions(
2202 CircularDependency {
2203 files: vec![other.clone(), source.clone()],
2204 length: 2,
2205 line: 4,
2206 col: 0,
2207 edges: Vec::new(),
2208 is_cross_package: false,
2209 },
2210 ));
2211 results
2212 .re_export_cycles
2213 .push(ReExportCycleFinding::with_actions(ReExportCycle {
2214 files: vec![source.clone()],
2215 kind: ReExportCycleKind::SelfLoop,
2216 }));
2217 results.package_cycles.push(
2218 fallow_types::output_dead_code::PackageCycleFinding::with_actions(
2219 fallow_types::results::PackageCycle {
2220 packages: vec!["@x/a".to_string(), "@x/b".to_string()],
2221 package_roots: Vec::new(),
2222 length: 2,
2223 edges: vec![fallow_types::results::PackageCycleEdge {
2224 from_package: "@x/a".to_string(),
2225 to_package: "@x/b".to_string(),
2226 path: source.clone(),
2227 target_path: other.clone(),
2228 line: 4,
2229 col: 0,
2230 type_only: false,
2231 }],
2232 group_truncated: false,
2233 },
2234 ),
2235 );
2236 results
2237 .boundary_violations
2238 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
2239 from_path: source.clone(),
2240 to_path: other,
2241 from_zone: "ui".to_string(),
2242 to_zone: "server".to_string(),
2243 import_specifier: "../other".to_string(),
2244 line: 1,
2245 col: 0,
2246 via_path: None,
2247 }));
2248 results
2249 .boundary_coverage_violations
2250 .push(BoundaryCoverageViolationFinding::with_actions(
2251 BoundaryCoverageViolation {
2252 path: root.join("src/unmatched.ts"),
2253 line: 1,
2254 col: 0,
2255 },
2256 ));
2257 results
2258 .boundary_call_violations
2259 .push(BoundaryCallViolationFinding::with_actions(
2260 BoundaryCallViolation {
2261 path: source.clone(),
2262 line: 12,
2263 col: 4,
2264 zone: "ui".to_string(),
2265 callee: "child_process.exec".to_string(),
2266 pattern: "child_process.*".to_string(),
2267 },
2268 ));
2269 results.stale_suppressions.push(StaleSuppression {
2270 finding_id: None,
2271 path: source,
2272 line: 2,
2273 col: 0,
2274 origin: SuppressionOrigin::Comment {
2275 issue_kind: Some("unused-export".to_string()),
2276 reason: None,
2277 is_file_level: false,
2278 kind_known: true,
2279 },
2280 missing_reason: false,
2281 actions: StaleSuppression::actions_for(false),
2282 effective_severity: None,
2283 });
2284 results.stale_suppressions.push(StaleSuppression {
2285 finding_id: None,
2286 path: root.join("src/app.ts"),
2287 line: 2,
2288 col: 0,
2289 origin: SuppressionOrigin::Comment {
2290 issue_kind: Some("unused-export".to_string()),
2291 reason: None,
2292 is_file_level: false,
2293 kind_known: true,
2294 },
2295 missing_reason: true,
2296 actions: StaleSuppression::actions_for(true),
2297 effective_severity: None,
2298 });
2299 results.unresolved_catalog_references.push(
2300 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
2301 entry_name: "react".to_string(),
2302 catalog_name: "default".to_string(),
2303 path: root.join("packages/app/package.json"),
2304 line: 9,
2305 available_in_catalogs: vec!["react18".to_string()],
2306 }),
2307 );
2308 results
2309 .unused_catalog_entries
2310 .push(UnusedCatalogEntryFinding::with_actions(
2311 UnusedCatalogEntry {
2312 entry_name: "lodash".to_string(),
2313 catalog_name: "default".to_string(),
2314 path: workspace.clone(),
2315 line: 3,
2316 hardcoded_consumers: Vec::new(),
2317 },
2318 ));
2319 results
2320 .empty_catalog_groups
2321 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
2322 catalog_name: "react17".to_string(),
2323 path: workspace.clone(),
2324 line: 7,
2325 }));
2326 results
2327 .unused_dependency_overrides
2328 .push(UnusedDependencyOverrideFinding::with_actions(
2329 UnusedDependencyOverride {
2330 raw_key: "left-pad".to_string(),
2331 target_package: "left-pad".to_string(),
2332 parent_package: None,
2333 version_constraint: None,
2334 version_range: "^1.3.0".to_string(),
2335 source: DependencyOverrideSource::PnpmWorkspaceYaml,
2336 path: workspace.clone(),
2337 line: 11,
2338 hint: None,
2339 },
2340 ));
2341 results.misconfigured_dependency_overrides.push(
2342 MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
2343 raw_key: ">".to_string(),
2344 target_package: None,
2345 raw_value: String::new(),
2346 reason: DependencyOverrideMisconfigReason::UnparsableKey,
2347 source: DependencyOverrideSource::PnpmWorkspaceYaml,
2348 path: workspace,
2349 line: 12,
2350 }),
2351 );
2352 results
2353 }
2354
2355 #[test]
2356 fn dead_code_keys_cover_graph_boundary_catalog_and_override_variants() {
2357 let root = root();
2358 let results = graph_boundary_catalog_override_results(&root);
2359
2360 let keys = dead_code_keys(&results, &root);
2361
2362 assert!(keys.contains("circular-dependency:src/app.ts|src/other.ts"));
2363 assert!(keys.contains("re-export-cycle:self-loop:src/app.ts"));
2364 assert!(keys.contains("boundary-violation:src/app.ts:src/other.ts"));
2365 assert!(keys.contains("boundary-coverage:src/unmatched.ts"));
2366 assert!(keys.contains("boundary-call-violation:src/app.ts:child_process.exec"));
2367 assert!(keys.contains("stale-suppression:src/app.ts:comment:unused-export:line"));
2368 assert!(keys.contains("missing-suppression-reason:src/app.ts:comment:unused-export:line"));
2369 assert!(
2370 keys.contains("unresolved-catalog-reference:packages/app/package.json:default:react")
2371 );
2372 assert!(keys.contains("unused-catalog-entry:pnpm-workspace.yaml:default:lodash"));
2373 assert!(keys.contains("empty-catalog-group:pnpm-workspace.yaml:react17"));
2374 assert!(keys.contains("unused-dependency-override:pnpm-workspace.yaml:left-pad"));
2375 assert!(keys.contains("misconfigured-dependency-override:pnpm-workspace.yaml:>"));
2376 }
2377
2378 #[test]
2379 fn retain_introduced_dead_code_keeps_only_findings_absent_from_base() {
2380 let root = root();
2381 let mut results = sample_results(&root);
2382 let base = FxHashSet::from_iter([
2383 "unused-file:src/dead.ts".to_string(),
2384 "unused-dependency:package.json:left-pad".to_string(),
2385 "unresolved-import:src/page.ts:./missing".to_string(),
2386 ]);
2387
2388 retain_introduced_dead_code(&mut results, &root, Some(&base));
2389
2390 assert!(results.unused_files.is_empty());
2391 assert!(results.unused_dependencies.is_empty());
2392 assert!(results.unresolved_imports.is_empty());
2393 assert_eq!(results.unused_exports.len(), 1);
2394 assert_eq!(results.unlisted_dependencies.len(), 1);
2395 assert_eq!(results.duplicate_exports.len(), 1);
2396 }
2397
2398 #[test]
2399 fn annotate_dead_code_json_marks_introduced_status_by_matching_key_order() {
2400 let root = root();
2401 let results = sample_results(&root);
2402 let base = FxHashSet::from_iter([
2403 "unused-file:src/dead.ts".to_string(),
2404 "unlisted-dependency:zod".to_string(),
2405 ]);
2406 let mut json = json!({
2407 "unused_files": [{}],
2408 "unused_exports": [{}],
2409 "unused_dependencies": [{}],
2410 "unresolved_imports": [{}],
2411 "unlisted_dependencies": [{}],
2412 "duplicate_exports": [{}],
2413 });
2414
2415 annotate_dead_code_json(&mut json, &results, &root, &base);
2416
2417 assert_eq!(json["unused_files"][0]["introduced"], false);
2418 assert_eq!(json["unused_exports"][0]["introduced"], true);
2419 assert_eq!(json["unused_dependencies"][0]["introduced"], true);
2420 assert_eq!(json["unresolved_imports"][0]["introduced"], true);
2421 assert_eq!(json["unlisted_dependencies"][0]["introduced"], false);
2422 assert_eq!(json["duplicate_exports"][0]["introduced"], true);
2423 }
2424
2425 fn framework_inject_and_render_results(root: &Path) -> AnalysisResults {
2428 let src = root.join("src/App.vue");
2429 let mut results = AnalysisResults::default();
2430 results
2431 .unprovided_injects
2432 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
2433 path: src.clone(),
2434 key_name: "userStore".to_string(),
2435 framework: "vue".to_string(),
2436 line: 5,
2437 col: 0,
2438 }));
2439 results
2440 .unrendered_components
2441 .push(UnrenderedComponentFinding::with_actions(
2442 UnrenderedComponent {
2443 path: src.clone(),
2444 component_name: "MyModal".to_string(),
2445 framework: "vue".to_string(),
2446 reachable_via: None,
2447 line: 1,
2448 col: 0,
2449 },
2450 ));
2451 results
2452 .unused_component_props
2453 .push(UnusedComponentPropFinding::with_actions(
2454 UnusedComponentProp {
2455 path: src.clone(),
2456 component_name: "MyModal".to_string(),
2457 prop_name: "title".to_string(),
2458 line: 3,
2459 col: 2,
2460 },
2461 ));
2462 results
2463 .unused_component_emits
2464 .push(UnusedComponentEmitFinding::with_actions(
2465 UnusedComponentEmit {
2466 path: src,
2467 component_name: "MyModal".to_string(),
2468 emit_name: "close".to_string(),
2469 line: 4,
2470 col: 2,
2471 },
2472 ));
2473 results
2474 .unused_svelte_events
2475 .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2476 path: root.join("src/Counter.svelte"),
2477 component_name: "Counter".to_string(),
2478 event_name: "increment".to_string(),
2479 line: 8,
2480 col: 0,
2481 }));
2482
2483 results
2484 }
2485
2486 #[test]
2487 fn dead_code_keys_cover_framework_inject_and_render_variants() {
2488 let root = root();
2489 let results = framework_inject_and_render_results(&root);
2490
2491 let keys = dead_code_keys(&results, &root);
2492
2493 assert!(keys.contains("unprovided-inject:src/App.vue:userStore"));
2494 assert!(keys.contains("unrendered-component:src/App.vue:MyModal"));
2495 assert!(keys.contains("unused-component-prop:src/App.vue:MyModal:title"));
2496 assert!(keys.contains("unused-component-emit:src/App.vue:MyModal:close"));
2497 assert!(keys.contains("unused-svelte-event:src/Counter.svelte:Counter:increment"));
2498 }
2499
2500 fn server_action_load_data_and_route_results(root: &Path) -> AnalysisResults {
2501 let actions_file = root.join("src/actions/submit.ts");
2502 let page_file = root.join("src/routes/blog/+page.server.ts");
2503 let route_file = root.join("app/(auth)/login/page.tsx");
2504 let route_file2 = root.join("app/login/page.tsx");
2505 let mut results = AnalysisResults::default();
2506 results
2507 .unused_server_actions
2508 .push(UnusedServerActionFinding::with_actions(
2509 UnusedServerAction {
2510 path: actions_file,
2511 action_name: "submitForm".to_string(),
2512 line: 2,
2513 col: 0,
2514 },
2515 ));
2516 results
2517 .unused_load_data_keys
2518 .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
2519 path: page_file,
2520 key_name: "posts".to_string(),
2521 line: 10,
2522 col: 4,
2523 route_dir: None,
2524 }));
2525 results
2526 .route_collisions
2527 .push(RouteCollisionFinding::with_actions(RouteCollision {
2528 path: route_file.clone(),
2529 url: "/login".to_string(),
2530 conflicting_paths: vec![route_file2.clone()],
2531 line: 1,
2532 col: 0,
2533 }));
2534 results.dynamic_segment_name_conflicts.push(
2535 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
2536 path: route_file,
2537 position: "/shop".to_string(),
2538 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
2539 conflicting_paths: vec![route_file2],
2540 line: 1,
2541 col: 0,
2542 }),
2543 );
2544
2545 results
2546 }
2547
2548 #[test]
2549 fn dead_code_keys_cover_server_action_load_data_and_route_variants() {
2550 let root = root();
2551 let results = server_action_load_data_and_route_results(&root);
2552
2553 let keys = dead_code_keys(&results, &root);
2554
2555 assert!(keys.contains("unused-server-action:src/actions/submit.ts:submitForm"));
2556 assert!(keys.contains("unused-load-data-key:src/routes/blog/+page.server.ts:posts"));
2557 assert!(keys.contains("route-collision:app/(auth)/login/page.tsx:/login"));
2558 assert!(keys.contains("dynamic-segment-name-conflict:app/(auth)/login/page.tsx:/shop"));
2559 }
2560
2561 fn angular_input_output_and_policy_results(root: &Path) -> AnalysisResults {
2562 let component = root.join("src/app/card.component.ts");
2563 let src = root.join("src/utils.ts");
2564 let mut results = AnalysisResults::default();
2565 results
2566 .unused_component_inputs
2567 .push(UnusedComponentInputFinding::with_actions(
2568 UnusedComponentInput {
2569 path: component.clone(),
2570 component_name: "CardComponent".to_string(),
2571 input_name: "label".to_string(),
2572 line: 12,
2573 col: 4,
2574 },
2575 ));
2576 results
2577 .unused_component_outputs
2578 .push(UnusedComponentOutputFinding::with_actions(
2579 UnusedComponentOutput {
2580 path: component,
2581 component_name: "CardComponent".to_string(),
2582 output_name: "clicked".to_string(),
2583 line: 13,
2584 col: 4,
2585 },
2586 ));
2587 results
2588 .policy_violations
2589 .push(PolicyViolationFinding::with_actions(PolicyViolation {
2590 path: src,
2591 line: 7,
2592 col: 0,
2593 pack: "security".to_string(),
2594 rule_id: "no-eval".to_string(),
2595 kind: PolicyRuleKind::BannedCall,
2596 matched: "eval".to_string(),
2597 severity: PolicyViolationSeverity::Error,
2598 message: None,
2599 }));
2600
2601 results
2602 }
2603
2604 #[test]
2605 fn dead_code_keys_cover_angular_input_output_and_policy_variants() {
2606 let root = root();
2607 let results = angular_input_output_and_policy_results(&root);
2608
2609 let keys = dead_code_keys(&results, &root);
2610
2611 assert!(
2612 keys.contains("unused-component-input:src/app/card.component.ts:CardComponent:label")
2613 );
2614 assert!(
2615 keys.contains(
2616 "unused-component-output:src/app/card.component.ts:CardComponent:clicked"
2617 )
2618 );
2619 assert!(keys.contains("policy-violation:src/utils.ts:security:no-eval:eval"));
2620 }
2621
2622 #[test]
2623 fn dead_code_keys_cover_re_export_cycle_multi_node_variant() {
2624 let root = root();
2625 let a = root.join("src/a.ts");
2626 let b = root.join("src/b.ts");
2627 let mut results = AnalysisResults::default();
2628 results
2629 .re_export_cycles
2630 .push(ReExportCycleFinding::with_actions(ReExportCycle {
2631 files: vec![b, a],
2632 kind: ReExportCycleKind::MultiNode,
2633 }));
2634
2635 let keys = dead_code_keys(&results, &root);
2636
2637 assert!(keys.contains("re-export-cycle:multi-node:src/a.ts|src/b.ts"));
2639 }
2640
2641 #[test]
2642 fn dead_code_keys_cover_package_cycles_by_package_roots() {
2643 let root = root();
2644 let mut results = AnalysisResults::default();
2645 results.package_cycles.push(
2646 fallow_types::output_dead_code::PackageCycleFinding::with_actions(
2647 fallow_types::results::PackageCycle {
2648 packages: vec!["@x/b".to_string(), "@x/a".to_string()],
2649 package_roots: vec![root.join("packages/b"), root.join("packages/a")],
2650 length: 2,
2651 edges: vec![fallow_types::results::PackageCycleEdge {
2652 from_package: "@x/a".to_string(),
2653 to_package: "@x/b".to_string(),
2654 path: root.join("packages/a/src/index.ts"),
2655 target_path: root.join("packages/b/src/index.ts"),
2656 line: 3,
2657 col: 0,
2658 type_only: false,
2659 }],
2660 group_truncated: false,
2661 },
2662 ),
2663 );
2664
2665 let keys = dead_code_keys(&results, &root);
2666
2667 assert!(
2668 keys.contains("package-cycle:packages/a|packages/b"),
2669 "{keys:?}"
2670 );
2671 }
2672
2673 fn unused_store_member_results(root: &Path) -> AnalysisResults {
2674 let src = root.join("src/store.ts");
2675 let mut results = AnalysisResults::default();
2676 results
2677 .unused_store_members
2678 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2679 path: src,
2680 parent_name: "useAuthStore".to_string(),
2681 member_name: "resetPassword".to_string(),
2682 kind: MemberKind::ClassMethod,
2683 line: 42,
2684 col: 2,
2685 }));
2686
2687 results
2688 }
2689
2690 #[test]
2691 fn dead_code_keys_cover_unused_store_member() {
2692 let root = root();
2693 let results = unused_store_member_results(&root);
2694
2695 let keys = dead_code_keys(&results, &root);
2696
2697 assert!(keys.contains("unused-store-member:src/store.ts:useAuthStore:resetPassword"));
2698 }
2699
2700 #[test]
2703 fn annotate_dead_code_json_marks_framework_keys_correctly() {
2704 let root = root();
2705 let src = root.join("src/App.vue");
2706 let mut results = AnalysisResults::default();
2707 results
2708 .unprovided_injects
2709 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
2710 path: src.clone(),
2711 key_name: "theme".to_string(),
2712 framework: "vue".to_string(),
2713 line: 3,
2714 col: 0,
2715 }));
2716 results
2717 .unrendered_components
2718 .push(UnrenderedComponentFinding::with_actions(
2719 UnrenderedComponent {
2720 path: src.clone(),
2721 component_name: "Dialog".to_string(),
2722 framework: "vue".to_string(),
2723 reachable_via: None,
2724 line: 1,
2725 col: 0,
2726 },
2727 ));
2728 results
2729 .unused_component_props
2730 .push(UnusedComponentPropFinding::with_actions(
2731 UnusedComponentProp {
2732 path: src.clone(),
2733 component_name: "Dialog".to_string(),
2734 prop_name: "open".to_string(),
2735 line: 5,
2736 col: 2,
2737 },
2738 ));
2739 results
2740 .unused_component_emits
2741 .push(UnusedComponentEmitFinding::with_actions(
2742 UnusedComponentEmit {
2743 path: src,
2744 component_name: "Dialog".to_string(),
2745 emit_name: "dismiss".to_string(),
2746 line: 6,
2747 col: 2,
2748 },
2749 ));
2750
2751 let base = FxHashSet::from_iter(["unprovided-inject:src/App.vue:theme".to_string()]);
2753 let mut json_val = json!({
2754 "unprovided_injects": [{}],
2755 "unrendered_components": [{}],
2756 "unused_component_props": [{}],
2757 "unused_component_emits": [{}],
2758 });
2759
2760 annotate_dead_code_json(&mut json_val, &results, &root, &base);
2761
2762 assert_eq!(json_val["unprovided_injects"][0]["introduced"], false);
2763 assert_eq!(json_val["unrendered_components"][0]["introduced"], true);
2764 assert_eq!(json_val["unused_component_props"][0]["introduced"], true);
2765 assert_eq!(json_val["unused_component_emits"][0]["introduced"], true);
2766 }
2767
2768 #[test]
2769 fn annotate_dead_code_json_marks_component_io_and_route_keys_correctly() {
2770 let root = root();
2771 let component = root.join("src/card.component.ts");
2772 let svelte_file = root.join("src/Counter.svelte");
2773 let page_file = root.join("src/routes/+page.server.ts");
2774 let route_file = root.join("app/about/page.tsx");
2775 let route_file2 = root.join("app/(info)/about/page.tsx");
2776 let mut results = AnalysisResults::default();
2777 results
2778 .unused_component_inputs
2779 .push(UnusedComponentInputFinding::with_actions(
2780 UnusedComponentInput {
2781 path: component.clone(),
2782 component_name: "CardComponent".to_string(),
2783 input_name: "size".to_string(),
2784 line: 8,
2785 col: 2,
2786 },
2787 ));
2788 results
2789 .unused_component_outputs
2790 .push(UnusedComponentOutputFinding::with_actions(
2791 UnusedComponentOutput {
2792 path: component,
2793 component_name: "CardComponent".to_string(),
2794 output_name: "hovered".to_string(),
2795 line: 9,
2796 col: 2,
2797 },
2798 ));
2799 results
2800 .unused_svelte_events
2801 .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
2802 path: svelte_file,
2803 component_name: "Counter".to_string(),
2804 event_name: "reset".to_string(),
2805 line: 12,
2806 col: 0,
2807 }));
2808 results
2809 .unused_server_actions
2810 .push(UnusedServerActionFinding::with_actions(
2811 UnusedServerAction {
2812 path: page_file,
2813 action_name: "deletePost".to_string(),
2814 line: 3,
2815 col: 0,
2816 },
2817 ));
2818 results
2819 .route_collisions
2820 .push(RouteCollisionFinding::with_actions(RouteCollision {
2821 path: route_file.clone(),
2822 url: "/about".to_string(),
2823 conflicting_paths: vec![route_file2.clone()],
2824 line: 1,
2825 col: 0,
2826 }));
2827 results.dynamic_segment_name_conflicts.push(
2828 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
2829 path: route_file,
2830 position: "/".to_string(),
2831 conflicting_segments: vec!["[id]".to_string()],
2832 conflicting_paths: vec![route_file2],
2833 line: 1,
2834 col: 0,
2835 }),
2836 );
2837
2838 let base = FxHashSet::default();
2840 let mut json_val = json!({
2841 "unused_component_inputs": [{}],
2842 "unused_component_outputs": [{}],
2843 "unused_svelte_events": [{}],
2844 "unused_server_actions": [{}],
2845 "route_collisions": [{}],
2846 "dynamic_segment_name_conflicts": [{}],
2847 });
2848
2849 annotate_dead_code_json(&mut json_val, &results, &root, &base);
2850
2851 assert_eq!(json_val["unused_component_inputs"][0]["introduced"], true);
2852 assert_eq!(json_val["unused_component_outputs"][0]["introduced"], true);
2853 assert_eq!(json_val["unused_svelte_events"][0]["introduced"], true);
2854 assert_eq!(json_val["unused_server_actions"][0]["introduced"], true);
2855 assert_eq!(json_val["route_collisions"][0]["introduced"], true);
2856 assert_eq!(
2857 json_val["dynamic_segment_name_conflicts"][0]["introduced"],
2858 true
2859 );
2860 }
2861
2862 #[test]
2863 fn annotate_dead_code_json_marks_members_and_dependencies_correctly() {
2864 let root = root();
2865 let src = root.join("src/types.ts");
2866 let pkg = root.join("package.json");
2867 let mut results = AnalysisResults::default();
2868 results
2869 .unused_enum_members
2870 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2871 path: src.clone(),
2872 parent_name: "Color".to_string(),
2873 member_name: "Blue".to_string(),
2874 kind: MemberKind::EnumMember,
2875 line: 5,
2876 col: 2,
2877 }));
2878 results
2879 .unused_class_members
2880 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2881 path: src.clone(),
2882 parent_name: "Service".to_string(),
2883 member_name: "reset".to_string(),
2884 kind: MemberKind::ClassMethod,
2885 line: 20,
2886 col: 2,
2887 }));
2888 results
2889 .unused_store_members
2890 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2891 path: src,
2892 parent_name: "useStore".to_string(),
2893 member_name: "logout".to_string(),
2894 kind: MemberKind::ClassMethod,
2895 line: 30,
2896 col: 2,
2897 }));
2898 results
2899 .unused_dev_dependencies
2900 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2901 package_name: "typescript".to_string(),
2902 location: DependencyLocation::DevDependencies,
2903 path: pkg.clone(),
2904 line: 8,
2905 used_in_workspaces: Vec::new(),
2906 }));
2907 results
2908 .type_only_dependencies
2909 .push(TypeOnlyDependencyFinding::with_actions(
2910 TypeOnlyDependency {
2911 package_name: "zod".to_string(),
2912 path: pkg.clone(),
2913 line: 9,
2914 },
2915 ));
2916 results
2917 .test_only_dependencies
2918 .push(TestOnlyDependencyFinding::with_actions(
2919 TestOnlyDependency {
2920 package_name: "vitest".to_string(),
2921 path: pkg,
2922 line: 10,
2923 },
2924 ));
2925
2926 let base = FxHashSet::from_iter([
2929 "unused-enum-member:src/types.ts:Color:Blue".to_string(),
2930 "unused-dev-dependency:package.json:typescript".to_string(),
2931 ]);
2932 let mut json_val = json!({
2933 "unused_enum_members": [{}],
2934 "unused_class_members": [{}],
2935 "unused_store_members": [{}],
2936 "unused_dev_dependencies": [{}],
2937 "type_only_dependencies": [{}],
2938 "test_only_dependencies": [{}],
2939 });
2940
2941 annotate_dead_code_json(&mut json_val, &results, &root, &base);
2942
2943 assert_eq!(json_val["unused_enum_members"][0]["introduced"], false);
2944 assert_eq!(json_val["unused_class_members"][0]["introduced"], true);
2945 assert_eq!(json_val["unused_store_members"][0]["introduced"], true);
2946 assert_eq!(json_val["unused_dev_dependencies"][0]["introduced"], false);
2947 assert_eq!(json_val["type_only_dependencies"][0]["introduced"], true);
2948 assert_eq!(json_val["test_only_dependencies"][0]["introduced"], true);
2949 }
2950
2951 #[test]
2952 fn annotate_dead_code_json_is_noop_when_issue_keys_absent() {
2953 let root = root();
2954 let results = sample_results(&root);
2955 let base = FxHashSet::default();
2956 let mut json_val = json!({"other_key": []});
2957 let original = json_val.clone();
2958
2959 annotate_dead_code_json(&mut json_val, &results, &root, &base);
2960
2961 assert_eq!(json_val, original);
2962 }
2963
2964 fn make_violation(path: &Path, name: &str) -> ComplexityViolation {
2967 ComplexityViolation {
2968 path: path.to_path_buf(),
2969 name: name.to_string(),
2970 line: 1,
2971 col: 0,
2972 cyclomatic: 20,
2973 cognitive: 5,
2974 line_count: 30,
2975 param_count: 2,
2976 react_hook_count: 0,
2977 react_jsx_max_depth: 0,
2978 react_prop_count: 0,
2979 react_hook_profile: None,
2980 exceeded: ExceededThreshold::Cyclomatic,
2981 effective_severity: None,
2982 severity: FindingSeverity::High,
2983 crap: None,
2984 coverage_pct: None,
2985 coverage_tier: None,
2986 coverage_source: None,
2987 inherited_from: None,
2988 component_rollup: None,
2989 contributions: Vec::new(),
2990 effective_thresholds: None,
2991 threshold_source: None,
2992 }
2993 }
2994
2995 fn make_health_report(paths_and_names: &[(&Path, &str)]) -> HealthReport {
2996 let findings = paths_and_names
2997 .iter()
2998 .map(|(path, name)| HealthFinding::from(make_violation(path, name)))
2999 .collect();
3000 HealthReport {
3001 findings,
3002 ..HealthReport::default()
3003 }
3004 }
3005
3006 #[test]
3007 fn health_keys_produces_stable_key_per_finding() {
3008 let root = root();
3009 let path = root.join("src/heavy.ts");
3010 let report = make_health_report(&[(&path, "processAll")]);
3011 let keys = health_keys(&report, &root);
3012 assert!(keys.contains("complexity:src/heavy.ts:processAll:Cyclomatic"));
3013 }
3014
3015 #[test]
3016 fn health_finding_key_uses_path_name_and_exceeded() {
3017 let root = root();
3018 let path = root.join("src/heavy.ts");
3019 let violation = make_violation(&path, "render");
3020 let key = health_finding_key(&violation, &root);
3021 assert_eq!(key, "complexity:src/heavy.ts:render:Cyclomatic");
3022 }
3023
3024 #[test]
3025 fn annotate_health_json_marks_introduced_and_inherited_flags() {
3026 let root = root();
3027 let path_a = root.join("src/heavy.ts");
3028 let path_b = root.join("src/other.ts");
3029 let report = make_health_report(&[(&path_a, "doWork"), (&path_b, "render")]);
3030
3031 let base = FxHashSet::from_iter(["complexity:src/other.ts:render:Cyclomatic".to_string()]);
3033 let mut json_val = json!({
3034 "findings": [{}, {}],
3035 });
3036
3037 annotate_health_json(&mut json_val, &report, &root, &base);
3038
3039 assert_eq!(json_val["findings"][0]["introduced"], true);
3040 assert_eq!(json_val["findings"][1]["introduced"], false);
3041 }
3042
3043 #[test]
3044 fn annotate_health_json_is_noop_when_findings_key_absent() {
3045 let root = root();
3046 let report = make_health_report(&[]);
3047 let base = FxHashSet::default();
3048 let mut json_val = json!({"summary": {}});
3049 let original = json_val.clone();
3050
3051 annotate_health_json(&mut json_val, &report, &root, &base);
3052
3053 assert_eq!(json_val, original);
3054 }
3055
3056 fn make_clone_group(files: &[PathBuf], fragment: &str) -> CloneGroup {
3059 CloneGroup {
3060 instances: files
3061 .iter()
3062 .map(|f| CloneInstance {
3063 file: f.clone(),
3064 start_line: 1,
3065 end_line: 5,
3066 start_col: 0,
3067 end_col: 80,
3068 fragment: fragment.to_string(),
3069 })
3070 .collect(),
3071 token_count: 10,
3072 line_count: 5,
3073 similarity: None,
3074 }
3075 }
3076
3077 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3078 DuplicationReport {
3079 clone_groups: groups,
3080 clone_families: Vec::new(),
3081 mirrored_directories: Vec::new(),
3082 stats: fallow_types::duplicates::DuplicationStats::default(),
3083 }
3084 }
3085
3086 #[test]
3087 fn dupe_group_key_is_stable_for_sorted_deduplicated_files() {
3088 let root = root();
3089 let a = root.join("src/a.ts");
3090 let b = root.join("src/b.ts");
3091 let group_ab = make_clone_group(&[a.clone(), b.clone()], "const x = 1;");
3093 let group_ba = make_clone_group(&[b, a], "const x = 1;");
3094 let key_ab = dupe_group_key(&group_ab, &root);
3095 let key_ba = dupe_group_key(&group_ba, &root);
3096 assert!(key_ab.starts_with("dupe:src/a.ts|src/b.ts:"));
3098 assert!(key_ba.starts_with("dupe:src/a.ts|src/b.ts:"));
3099 assert_eq!(key_ab, key_ba);
3101 }
3102
3103 #[test]
3104 fn preexisting_dupe_group_keys_collects_only_groups_outside_added_lines() {
3105 let root = root();
3106 let a = root.join("src/a.ts");
3107 let b = root.join("src/b.ts");
3108 let c = root.join("src/c.ts");
3109 let untouched = make_clone_group(&[a.clone(), b], "shared scaffolding");
3111 let touched = make_clone_group(&[a, c], "pasted block");
3112 let diff = fallow_output::DiffIndex::from_unified_diff(
3113 "diff --git a/src/c.ts b/src/c.ts\n--- a/src/c.ts\n+++ b/src/c.ts\n@@ -3,0 +3,1 @@\n+const added = 1;\n",
3114 );
3115
3116 let demote = super::preexisting_dupe_group_keys([&untouched, &touched], &root, &diff);
3117
3118 assert!(demote.contains(&dupe_group_key(&untouched, &root)));
3119 assert!(!demote.contains(&dupe_group_key(&touched, &root)));
3120 assert_eq!(demote.len(), 1);
3121 }
3122
3123 #[test]
3124 fn demote_introductions_moves_introduced_keys_to_inherited() {
3125 let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3126 let mut ledger = AuditDomainLedger::compare(
3127 ["dupe:kept".to_string(), "dupe:demoted".to_string()],
3128 Some(&base),
3129 );
3130 assert_eq!(ledger.introduced_count(), 2);
3131
3132 let demote: FxHashSet<String> = std::iter::once("dupe:demoted".to_string()).collect();
3133 ledger.demote_introductions(&demote);
3134
3135 assert_eq!(ledger.introduced_count(), 1);
3136 assert_eq!(ledger.inherited_count(), 1);
3137 let introduced: Vec<bool> = ledger.introduced().collect();
3138 assert_eq!(introduced, vec![true, false]);
3139 assert_eq!(ledger.demoted_count(), 1);
3140 assert!(ledger.demoted_count() <= ledger.inherited_count());
3141 assert!(ledger.demoted_keys().contains("dupe:demoted"));
3142 let demoted: Vec<bool> = ledger.demoted().collect();
3143 assert_eq!(demoted, vec![false, true]);
3144 }
3145
3146 #[test]
3147 fn demote_introductions_ignores_keys_that_were_not_introduced() {
3148 let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3149 let mut ledger = AuditDomainLedger::compare(
3150 ["dupe:old".to_string(), "dupe:new".to_string()],
3151 Some(&base),
3152 );
3153
3154 let demote: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
3155 ledger.demote_introductions(&demote);
3156
3157 assert_eq!(ledger.demoted_count(), 0);
3158 let demoted: Vec<bool> = ledger.demoted().collect();
3159 assert_eq!(demoted, vec![false, false]);
3160 }
3161
3162 #[test]
3163 fn annotate_domain_demotions_json_marks_only_demoted_entries() {
3164 let mut json_val = json!({
3165 "clone_groups": [{}, {}],
3166 });
3167
3168 super::annotate_domain_demotions_json(
3169 &mut json_val,
3170 "clone_groups",
3171 [false, true],
3172 crate::CloneDemotionReason::NoAddedLines,
3173 );
3174
3175 assert!(json_val["clone_groups"][0].get("demotion_reason").is_none());
3176 assert_eq!(
3177 json_val["clone_groups"][1]["demotion_reason"],
3178 "no-added-lines"
3179 );
3180 }
3181
3182 #[test]
3183 fn dupes_keys_produces_one_key_per_clone_group() {
3184 let root = root();
3185 let a = root.join("src/a.ts");
3186 let b = root.join("src/b.ts");
3187 let groups = vec![
3188 make_clone_group(&[a.clone(), b.clone()], "block one"),
3189 make_clone_group(&[a, b], "block two"),
3190 ];
3191 let report = make_duplication_report(groups);
3192 let keys = dupes_keys(&report, &root);
3193 assert_eq!(keys.len(), 2);
3194 }
3195
3196 #[test]
3197 fn annotate_dupes_json_marks_introduced_and_inherited_flags() {
3198 let root = root();
3199 let a = root.join("src/a.ts");
3200 let b = root.join("src/b.ts");
3201 let group_new = make_clone_group(&[a.clone(), b.clone()], "new block");
3202 let group_old = make_clone_group(&[a, b], "old block");
3203 let old_key = dupe_group_key(&group_old, &root);
3204 let base = FxHashSet::from_iter([old_key]);
3205 let report = make_duplication_report(vec![group_new, group_old]);
3206 let mut json_val = json!({
3207 "clone_groups": [{}, {}],
3208 });
3209
3210 annotate_dupes_json(&mut json_val, &report, &root, &base);
3211
3212 assert_eq!(json_val["clone_groups"][0]["introduced"], true);
3213 assert_eq!(json_val["clone_groups"][1]["introduced"], false);
3214 }
3215
3216 #[test]
3217 fn annotate_dupes_json_is_noop_when_clone_groups_key_absent() {
3218 let root = root();
3219 let report = make_duplication_report(Vec::new());
3220 let base = FxHashSet::default();
3221 let mut json_val = json!({"stats": {}});
3222 let original = json_val.clone();
3223
3224 annotate_dupes_json(&mut json_val, &report, &root, &base);
3225
3226 assert_eq!(json_val, original);
3227 }
3228
3229 #[test]
3232 fn retain_introduced_dead_code_is_noop_when_base_is_none() {
3233 let root = root();
3234 let mut results = sample_results(&root);
3235 let original_file_count = results.unused_files.len();
3236 let original_export_count = results.unused_exports.len();
3237
3238 retain_introduced_dead_code(&mut results, &root, None);
3239
3240 assert_eq!(results.unused_files.len(), original_file_count);
3242 assert_eq!(results.unused_exports.len(), original_export_count);
3243 }
3244
3245 #[test]
3248 fn retain_introduced_dead_code_filters_framework_findings() {
3249 let root = root();
3250 let src = root.join("src/App.vue");
3251 let mut results = AnalysisResults::default();
3252 results
3253 .unprovided_injects
3254 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3255 path: src.clone(),
3256 key_name: "existing".to_string(),
3257 framework: "vue".to_string(),
3258 line: 1,
3259 col: 0,
3260 }));
3261 results
3262 .unprovided_injects
3263 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3264 path: src.clone(),
3265 key_name: "new".to_string(),
3266 framework: "vue".to_string(),
3267 line: 2,
3268 col: 0,
3269 }));
3270 results
3271 .unrendered_components
3272 .push(UnrenderedComponentFinding::with_actions(
3273 UnrenderedComponent {
3274 path: src,
3275 component_name: "OldWidget".to_string(),
3276 framework: "vue".to_string(),
3277 reachable_via: None,
3278 line: 1,
3279 col: 0,
3280 },
3281 ));
3282
3283 let base = FxHashSet::from_iter([
3284 "unprovided-inject:src/App.vue:existing".to_string(),
3285 "unrendered-component:src/App.vue:OldWidget".to_string(),
3286 ]);
3287
3288 retain_introduced_dead_code(&mut results, &root, Some(&base));
3289
3290 assert_eq!(results.unprovided_injects.len(), 1);
3292 assert_eq!(results.unprovided_injects[0].inject.key_name, "new");
3293 assert!(results.unrendered_components.is_empty());
3294 }
3295
3296 #[test]
3297 fn retain_introduced_dead_code_filters_graph_findings() {
3298 let root = root();
3299 let a = root.join("src/a.ts");
3300 let b = root.join("src/b.ts");
3301 let mut results = AnalysisResults::default();
3302 results
3303 .circular_dependencies
3304 .push(CircularDependencyFinding::with_actions(
3305 CircularDependency {
3306 files: vec![a.clone(), b],
3307 length: 2,
3308 line: 1,
3309 col: 0,
3310 edges: Vec::new(),
3311 is_cross_package: false,
3312 },
3313 ));
3314 results
3315 .re_export_cycles
3316 .push(ReExportCycleFinding::with_actions(ReExportCycle {
3317 files: vec![a],
3318 kind: ReExportCycleKind::SelfLoop,
3319 }));
3320
3321 let base = FxHashSet::from_iter(["circular-dependency:src/a.ts|src/b.ts".to_string()]);
3323
3324 retain_introduced_dead_code(&mut results, &root, Some(&base));
3325
3326 assert!(results.circular_dependencies.is_empty());
3327 assert_eq!(results.re_export_cycles.len(), 1);
3328 }
3329
3330 #[test]
3331 fn audit_ledger_routes_override_severity_and_persists_introduced_flags() {
3332 let root = root();
3333 let config: FallowConfig = serde_json::from_value(json!({
3334 "rules": { "unused-exports": "warn" },
3335 "overrides": [{
3336 "files": ["src/generated/**"],
3337 "rules": { "unused-exports": "error" }
3338 }]
3339 }))
3340 .expect("config");
3341 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3342 let mut results = AnalysisResults::default();
3343 results
3344 .unused_exports
3345 .push(export(&root.join("src/base.ts"), "baseExport"));
3346 results
3347 .unused_exports
3348 .push(export(&root.join("src/generated/new.ts"), "newExport"));
3349 let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
3350
3351 let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
3352
3353 assert_eq!(ledger.classification_count(), 2);
3354 assert_eq!(ledger.introduced_count(), 1);
3355 assert_eq!(ledger.inherited_count(), 1);
3356 assert!(ledger.has_introduced_errors());
3357 assert!(!ledger.has_introduced_warnings());
3358 assert_eq!(ledger.records()[0].effective_severity, Severity::Warn);
3359 assert_eq!(ledger.records()[1].effective_severity, Severity::Error);
3360
3361 ledger.annotate_results(&mut results);
3362 assert_eq!(ledger.classification_count(), 2);
3363 assert_eq!(
3364 results.unused_exports[0].introduced,
3365 Some(AuditIntroduced(false))
3366 );
3367 assert_eq!(
3368 results.unused_exports[1].introduced,
3369 Some(AuditIntroduced(true))
3370 );
3371 }
3372
3373 #[test]
3374 fn audit_ledger_demotes_semantic_only_introductions_but_keeps_syntactic_new_findings() {
3375 let root = root();
3376 let config: FallowConfig = serde_json::from_value(json!({
3377 "rules": { "unused-exports": "error" }
3378 }))
3379 .expect("config");
3380 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3381 let mut results = AnalysisResults::default();
3382 results
3385 .unused_exports
3386 .push(export(&root.join("src/base.ts"), "baseExport"));
3387 results
3388 .unused_exports
3389 .push(export(&root.join("src/new.ts"), "newExport"));
3390 results
3391 .unused_exports
3392 .push(export(&root.join("src/semantic.ts"), "semanticOnly"));
3393 let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
3394 let head_syntactic = FxHashSet::from_iter([
3395 "unused-export:src/base.ts:baseExport".to_string(),
3396 "unused-export:src/new.ts:newExport".to_string(),
3397 ]);
3398
3399 let mut ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
3400 assert_eq!(ledger.introduced_count(), 2);
3401
3402 ledger.demote_unattributable_introductions(&head_syntactic);
3403
3404 assert_eq!(ledger.introduced_count(), 1);
3406 assert!(ledger.has_introduced_errors());
3407 assert_eq!(ledger.inherited_count(), 2);
3408 ledger.annotate_results(&mut results);
3409 assert_eq!(
3410 results.unused_exports[0].introduced,
3411 Some(AuditIntroduced(false))
3412 );
3413 assert_eq!(
3414 results.unused_exports[1].introduced,
3415 Some(AuditIntroduced(true))
3416 );
3417 assert_eq!(
3418 results.unused_exports[2].introduced,
3419 Some(AuditIntroduced(false))
3420 );
3421 }
3422
3423 #[test]
3424 fn audit_ledger_counts_each_occurrence_of_a_repeated_key() {
3425 let root = root();
3426 let config: FallowConfig = serde_json::from_value(json!({
3427 "rules": { "unused-exports": "error" }
3428 }))
3429 .expect("config");
3430 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3431 let mut results = AnalysisResults::default();
3432 results
3433 .unused_exports
3434 .push(export(&root.join("src/collision.ts"), "sameExport"));
3435 results
3436 .unused_exports
3437 .push(export(&root.join("src/collision.ts"), "sameExport"));
3438
3439 let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&FxHashSet::default()));
3440
3441 assert_eq!(ledger.classification_count(), 2);
3442 assert_eq!(ledger.records().len(), 2);
3443 assert_eq!(ledger.introduced_count(), 2);
3444 assert_eq!(ledger.inherited_count(), 0);
3445
3446 ledger.annotate_results(&mut results);
3447 assert!(
3448 results
3449 .unused_exports
3450 .iter()
3451 .all(|finding| { finding.introduced == Some(AuditIntroduced(true)) })
3452 );
3453 }
3454
3455 #[test]
3456 fn audit_domain_ledger_counts_colliding_keys_once_but_preserves_record_membership() {
3457 let introduced = AuditDomainLedger::compare(
3458 ["same-key".to_string(), "same-key".to_string()],
3459 Some(&FxHashSet::default()),
3460 );
3461 assert_eq!(introduced.introduced_count(), 1);
3462 assert_eq!(introduced.inherited_count(), 0);
3463 assert_eq!(introduced.introduced().collect::<Vec<_>>(), [true, true]);
3464
3465 let base = FxHashSet::from_iter(["same-key".to_string()]);
3466 let inherited = AuditDomainLedger::compare(
3467 ["same-key".to_string(), "same-key".to_string()],
3468 Some(&base),
3469 );
3470 assert_eq!(inherited.introduced_count(), 0);
3471 assert_eq!(inherited.inherited_count(), 1);
3472 assert_eq!(inherited.introduced().collect::<Vec<_>>(), [false, false]);
3473 }
3474
3475 #[test]
3476 fn stale_suppression_fallback_preserves_introduced_annotation() {
3477 let root = root();
3478 let mut results = AnalysisResults::default();
3479 results.stale_suppressions.push(StaleSuppression {
3480 finding_id: None,
3481 path: root.join("src/new.ts"),
3482 line: 2,
3483 col: 0,
3484 origin: SuppressionOrigin::Comment {
3485 issue_kind: Some("unused-export".to_string()),
3486 reason: None,
3487 is_file_level: false,
3488 kind_known: true,
3489 },
3490 missing_reason: false,
3491 actions: StaleSuppression::actions_for(false),
3492 effective_severity: None,
3493 });
3494 let mut json = serde_json::to_value(&results).expect("json");
3495
3496 annotate_stale_suppressions_json(&mut json, &results, &root, &FxHashSet::default());
3497
3498 assert_eq!(json["stale_suppressions"][0]["introduced"], true);
3499 }
3500
3501 #[test]
3502 fn remap_keys_for_renames_relocates_path_segments() {
3503 use rustc_hash::FxHashMap;
3504
3505 let mut renames = FxHashMap::default();
3506 renames.insert(
3507 "src/old/impl.ts".to_string(),
3508 "src/renamed/impl.ts".to_string(),
3509 );
3510
3511 let keys: FxHashSet<String> = [
3512 "complexity:src/old/impl.ts:complexFn:[Cyclomatic]".to_string(),
3513 "unused-export:src/old/impl.ts:unusedHelper".to_string(),
3514 "unused-file:src/old/impl.ts".to_string(),
3515 "unused-export:src/other.ts:src/old/impl.ts-unrelated".to_string(),
3516 ]
3517 .into_iter()
3518 .collect();
3519
3520 let remapped = remap_keys_for_renames(&keys, &renames);
3521
3522 assert!(remapped.contains("complexity:src/renamed/impl.ts:complexFn:[Cyclomatic]"));
3523 assert!(remapped.contains("unused-export:src/renamed/impl.ts:unusedHelper"));
3524 assert!(remapped.contains("unused-file:src/renamed/impl.ts"));
3525 assert!(
3526 remapped.contains("unused-export:src/other.ts:src/old/impl.ts-unrelated"),
3527 "non-exact segment matches must pass through untouched"
3528 );
3529 }
3530
3531 #[test]
3532 fn remap_keys_for_renames_recanonicalizes_sorted_path_lists() {
3533 use rustc_hash::FxHashMap;
3534
3535 let mut renames = FxHashMap::default();
3536 renames.insert("src/a.ts".to_string(), "src/z.ts".to_string());
3537
3538 let keys: FxHashSet<String> = [
3539 "dupe:src/a.ts|src/m.ts:120:12:deadbeef".to_string(),
3542 "circular-dependency:src/a.ts|src/m.ts".to_string(),
3543 ]
3544 .into_iter()
3545 .collect();
3546
3547 let remapped = remap_keys_for_renames(&keys, &renames);
3548
3549 assert!(
3550 remapped.contains("dupe:src/m.ts|src/z.ts:120:12:deadbeef"),
3551 "duplicate-group file lists must be re-sorted: {remapped:#?}"
3552 );
3553 assert!(
3554 remapped.contains("circular-dependency:src/m.ts|src/z.ts"),
3555 "cycle file lists must be re-sorted: {remapped:#?}"
3556 );
3557 }
3558
3559 #[test]
3560 fn remap_keys_for_renames_without_matches_is_identity() {
3561 use rustc_hash::FxHashMap;
3562
3563 let mut renames = FxHashMap::default();
3564 renames.insert("src/elsewhere.ts".to_string(), "src/moved.ts".to_string());
3565
3566 let keys: FxHashSet<String> =
3567 std::iter::once("unused-export:src/utils.ts:helper".to_string()).collect();
3568
3569 assert_eq!(remap_keys_for_renames(&keys, &renames), keys);
3570 }
3571
3572 fn production_and_directive_results(root: &Path) -> AnalysisResults {
3574 let page = root.join("src/app.ts");
3575 let mut results = AnalysisResults::default();
3576 results.dev_dependencies_in_production.push(
3577 DevDependencyInProductionFinding::with_actions(DevDependencyInProduction {
3578 package_name: "vite".to_string(),
3579 path: root.join("package.json"),
3580 line: 14,
3581 }),
3582 );
3583 results
3584 .invalid_client_exports
3585 .push(InvalidClientExportFinding::with_actions(
3586 InvalidClientExport {
3587 path: page.clone(),
3588 export_name: "config".to_string(),
3589 directive: "use client".to_string(),
3590 line: 3,
3591 col: 0,
3592 },
3593 ));
3594 results
3595 .mixed_client_server_barrels
3596 .push(MixedClientServerBarrelFinding::with_actions(
3597 MixedClientServerBarrel {
3598 path: page.clone(),
3599 client_origin: "./client".to_string(),
3600 server_origin: "./server".to_string(),
3601 line: 1,
3602 col: 0,
3603 },
3604 ));
3605 results
3606 .misplaced_directives
3607 .push(MisplacedDirectiveFinding::with_actions(
3608 MisplacedDirective {
3609 path: page,
3610 directive: "use server".to_string(),
3611 line: 5,
3612 col: 2,
3613 },
3614 ));
3615 results
3616 }
3617
3618 fn isolated_findings(fixtures: &[AnalysisResults]) -> Vec<(String, AnalysisResults)> {
3621 let mut isolated = Vec::new();
3622 for fixture in fixtures {
3623 let value = serde_json::to_value(fixture).expect("results serialize");
3624 let object = value.as_object().expect("results serialize as an object");
3625 for (collection, items) in object {
3626 let Some(items) = items.as_array() else {
3627 continue;
3628 };
3629 for item in items {
3630 let mut single = serde_json::to_value(AnalysisResults::default())
3631 .expect("empty results serialize");
3632 single[collection.as_str()] = serde_json::Value::Array(vec![item.clone()]);
3633 let results = serde_json::from_value(single)
3634 .unwrap_or_else(|error| panic!("{collection} round-trips: {error}"));
3635 isolated.push((collection.clone(), results));
3636 }
3637 }
3638 }
3639 isolated
3640 }
3641
3642 fn every_rule_config(
3647 base: [&str; 2],
3648 scoped: Option<[&str; 2]>,
3649 ) -> fallow_config::ResolvedConfig {
3650 let every_rule = |severities: [&str; 2]| {
3651 let rules = serde_json::to_value(fallow_config::RulesConfig::default())
3652 .expect("rules serialize");
3653 let names = rules
3654 .as_object()
3655 .expect("rules serialize as an object")
3656 .keys();
3657 serde_json::Value::Object(
3658 names
3659 .enumerate()
3660 .map(|(index, name)| (name.clone(), json!(severities[index % 2])))
3661 .collect(),
3662 )
3663 };
3664 let mut config = json!({ "rules": every_rule(base) });
3665 if let Some(scoped) = scoped {
3666 config["overrides"] = json!([{
3667 "files": [
3668 "package.json",
3669 "pnpm-workspace.yaml",
3670 "src/app.ts",
3671 "src/page.ts",
3672 "src/types.ts",
3673 "src/App.vue",
3674 "src/actions/**",
3675 "app/**",
3676 "src/app/**",
3677 "src/store.ts"
3678 ],
3679 "rules": every_rule(scoped)
3680 }]);
3681 }
3682 let config: FallowConfig = serde_json::from_value(config).expect("config");
3683 config.resolve(root(), OutputFormat::Json, 1, false, true, None)
3684 }
3685
3686 #[test]
3691 fn audit_ledger_errors_match_the_exit_code_rule_for_every_collection() {
3692 let root = root();
3693 let findings = isolated_findings(&[
3694 sample_results(&root),
3695 graph_boundary_catalog_override_results(&root),
3696 type_member_and_dependency_results(&root),
3697 framework_inject_and_render_results(&root),
3698 server_action_load_data_and_route_results(&root),
3699 angular_input_output_and_policy_results(&root),
3700 unused_store_member_results(&root),
3701 production_and_directive_results(&root),
3702 ]);
3703 let empty_base = FxHashSet::default();
3704 let mut covered = std::collections::BTreeSet::new();
3705 let mut mismatches = Vec::new();
3706 for (base, scoped) in [
3707 (["error", "error"], None),
3708 (["warn", "warn"], None),
3709 (["error", "warn"], None),
3710 (["error", "error"], Some(["warn", "warn"])),
3711 (["warn", "warn"], Some(["error", "error"])),
3712 (["off", "off"], Some(["error", "error"])),
3713 (["error", "warn"], Some(["warn", "error"])),
3714 (["warn", "error"], Some(["error", "warn"])),
3715 ] {
3716 let config = every_rule_config(base, scoped);
3717 for (collection, results) in &findings {
3718 let ledger = dead_code_audit_ledger(results, &root, &config, Some(&empty_base));
3719 covered.extend(ledger.records().iter().map(|record| record.collection));
3720 let exit_rule = fallow_engine::error_severity::has_error_severity_issues(
3721 results,
3722 &config.rules,
3723 Some(&config),
3724 false,
3725 );
3726 if ledger.has_introduced_errors() != exit_rule {
3727 mismatches.push(format!(
3728 "{collection} (rules {base:?}, overrides {scoped:?}): ledger {}, exit rule {exit_rule}",
3729 ledger.has_introduced_errors()
3730 ));
3731 }
3732 }
3733 }
3734 assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
3735 let missing: Vec<&str> = super::AuditCollection::ALL
3736 .iter()
3737 .map(|collection| collection.json_key())
3738 .filter(|key| !covered.contains(key))
3739 .collect();
3740 assert!(
3741 missing.is_empty(),
3742 "the fixtures hold no finding in {missing:?}"
3743 );
3744 }
3745
3746 fn audit_suppression(root: &Path, line: u32, reason: Option<&str>) -> StaleSuppression {
3749 StaleSuppression {
3750 finding_id: None,
3751 path: root.join("src/flags.ts"),
3752 line,
3753 col: 0,
3754 origin: SuppressionOrigin::Comment {
3755 issue_kind: Some("unused-export".to_string()),
3756 reason: reason.map(str::to_owned),
3757 is_file_level: false,
3758 kind_known: true,
3759 },
3760 missing_reason: false,
3761 actions: StaleSuppression::actions_for(false),
3762 effective_severity: None,
3763 }
3764 }
3765
3766 fn shift_lines(results: &mut AnalysisResults, by: u32) {
3768 for item in &mut results.unlisted_dependencies {
3769 for site in &mut item.dep.imported_from {
3770 site.line += by;
3771 }
3772 }
3773 for item in &mut results.stale_suppressions {
3774 item.line += by;
3775 }
3776 for item in &mut results.unused_catalog_entries {
3777 item.entry.line += by;
3778 }
3779 for item in &mut results.empty_catalog_groups {
3780 item.group.line += by;
3781 }
3782 for item in &mut results.unresolved_catalog_references {
3783 item.reference.line += by;
3784 }
3785 for item in &mut results.unused_dependency_overrides {
3786 item.entry.line += by;
3787 }
3788 for item in &mut results.misconfigured_dependency_overrides {
3789 item.entry.line += by;
3790 }
3791 for item in &mut results.misplaced_directives {
3792 item.directive_site.line += by;
3793 }
3794 for item in &mut results.unused_exports {
3795 item.export.line += by;
3796 }
3797 }
3798
3799 fn line_sensitive_results(root: &Path) -> AnalysisResults {
3800 let mut results = graph_boundary_catalog_override_results(root);
3801 let sample = sample_results(root);
3802 results.unlisted_dependencies = sample.unlisted_dependencies;
3803 results.unused_exports = sample.unused_exports;
3804 results
3805 .misplaced_directives
3806 .push(MisplacedDirectiveFinding::with_actions(
3807 MisplacedDirective {
3808 path: root.join("src/action.ts"),
3809 directive: "use server".to_string(),
3810 line: 4,
3811 col: 0,
3812 },
3813 ));
3814 results
3815 }
3816
3817 #[test]
3818 fn dead_code_keys_do_not_change_when_lines_shift() {
3819 let root = root();
3820 let before = line_sensitive_results(&root);
3821 let mut after = before.clone();
3822 shift_lines(&mut after, 20);
3823
3824 let before_keys = dead_code_keys(&before, &root);
3825 let after_keys = dead_code_keys(&after, &root);
3826
3827 let mut changed: Vec<&String> = after_keys.difference(&before_keys).collect();
3828 changed.sort();
3829 assert!(changed.is_empty(), "keys that hold a line: {changed:?}");
3830 }
3831
3832 #[test]
3833 fn stale_suppression_keys_ignore_the_reason_text() {
3834 let root = root();
3835 let with_reason = |reason: &str| AnalysisResults {
3836 stale_suppressions: vec![audit_suppression(&root, 3, Some(reason))],
3837 ..AnalysisResults::default()
3838 };
3839
3840 assert_eq!(
3841 dead_code_keys(&with_reason("kept for the plugin API"), &root),
3842 dead_code_keys(&with_reason("removed in v3"), &root)
3843 );
3844 }
3845
3846 #[test]
3847 fn unlisted_dependency_keys_ignore_the_import_sites() {
3848 let root = root();
3849 let base = sample_results(&root);
3850 let mut head = base.clone();
3851 let item = &mut head.unlisted_dependencies[0].dep;
3852 item.imported_from[0].line = 40;
3853 item.imported_from.push(ImportSite {
3854 path: root.join("src/other.ts"),
3855 line: 1,
3856 col: 0,
3857 });
3858
3859 assert_eq!(dead_code_keys(&base, &root), dead_code_keys(&head, &root));
3860 }
3861
3862 #[test]
3863 fn a_second_occurrence_of_an_inherited_key_is_introduced() {
3864 let root = root();
3865 let config: FallowConfig = serde_json::from_value(json!({
3866 "rules": { "stale-suppressions": "error" }
3867 }))
3868 .expect("config");
3869 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
3870 let base_results = AnalysisResults {
3871 stale_suppressions: vec![audit_suppression(&root, 3, None)],
3872 ..AnalysisResults::default()
3873 };
3874 let base = dead_code_keys(&base_results, &root);
3875 let mut head = AnalysisResults {
3876 stale_suppressions: vec![
3877 audit_suppression(&root, 13, None),
3878 audit_suppression(&root, 17, None),
3879 ],
3880 ..AnalysisResults::default()
3881 };
3882
3883 let ledger = dead_code_audit_ledger(&head, &root, &config, Some(&base));
3884 assert_eq!(ledger.introduced_count(), 1);
3885 assert_eq!(ledger.inherited_count(), 1);
3886 assert!(!ledger.records()[0].introduced);
3887 assert!(ledger.records()[1].introduced);
3888
3889 retain_introduced_dead_code(&mut head, &root, Some(&base));
3890 assert_eq!(head.stale_suppressions.len(), 1);
3891 assert_eq!(head.stale_suppressions[0].line, 17);
3892 }
3893
3894 #[test]
3895 fn a_renamed_file_keeps_its_findings_and_occurrences_inherited() {
3896 let root = root();
3897 let finding = |path: &str, line: u32| {
3898 let mut suppression = audit_suppression(&root, line, None);
3899 suppression.path = root.join(path);
3900 suppression
3901 };
3902 let base_results = AnalysisResults {
3903 stale_suppressions: vec![finding("src/old.ts", 3), finding("src/old.ts", 9)],
3904 ..AnalysisResults::default()
3905 };
3906 let head = AnalysisResults {
3907 stale_suppressions: vec![finding("src/new.ts", 4), finding("src/new.ts", 10)],
3908 ..AnalysisResults::default()
3909 };
3910 let renames = rustc_hash::FxHashMap::from_iter([(
3911 "src/old.ts".to_string(),
3912 "src/new.ts".to_string(),
3913 )]);
3914
3915 let base = remap_keys_for_renames(&dead_code_keys(&base_results, &root), &renames);
3916
3917 assert_eq!(base, dead_code_keys(&head, &root));
3918 }
3919}