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