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