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