1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use fallow_config::{ResolvedConfig, Severity};
8use fallow_types::envelope::AuditIntroduced;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AuditFindingRecord {
13 pub collection: &'static str,
15 pub ordinal: usize,
17 pub stable_key: String,
19 pub introduced: bool,
21 pub effective_severity: Severity,
23}
24
25#[derive(Debug, Clone, Default)]
27pub struct DeadCodeAuditLedger {
28 records: Vec<AuditFindingRecord>,
29 keys: FxHashSet<String>,
30 introduced_keys: FxHashSet<String>,
31 inherited_keys: FxHashSet<String>,
32 #[cfg(test)]
33 classifications: usize,
34}
35
36impl DeadCodeAuditLedger {
37 #[must_use]
39 pub fn records(&self) -> &[AuditFindingRecord] {
40 &self.records
41 }
42
43 #[must_use]
45 pub const fn keys(&self) -> &FxHashSet<String> {
46 &self.keys
47 }
48
49 #[cfg(test)]
50 const fn classification_count(&self) -> usize {
51 self.classifications
52 }
53
54 #[must_use]
56 pub fn introduced_count(&self) -> usize {
57 self.introduced_keys.len()
58 }
59
60 #[must_use]
62 pub fn visible_count(&self) -> usize {
63 self.records
64 .iter()
65 .filter(|record| record.effective_severity != Severity::Off)
66 .count()
67 }
68
69 #[must_use]
71 pub fn inherited_count(&self) -> usize {
72 self.inherited_keys.len()
73 }
74
75 #[must_use]
77 pub fn has_introduced_errors(&self) -> bool {
78 self.records
79 .iter()
80 .any(|record| record.introduced && record.effective_severity == Severity::Error)
81 }
82
83 #[must_use]
85 pub fn has_introduced_warnings(&self) -> bool {
86 self.records
87 .iter()
88 .any(|record| record.introduced && record.effective_severity == Severity::Warn)
89 }
90
91 pub fn demote_unattributable_introductions(&mut self, head_syntactic: &FxHashSet<String>) {
102 for record in &mut self.records {
103 if record.introduced && !head_syntactic.contains(&record.stable_key) {
104 record.introduced = false;
105 self.introduced_keys.remove(&record.stable_key);
106 if record.effective_severity != Severity::Off {
107 self.inherited_keys.insert(record.stable_key.clone());
108 }
109 }
110 }
111 }
112
113 #[expect(
118 clippy::too_many_lines,
119 reason = "exhaustive result destructuring and field annotation must stay together so new finding collections require an explicit audit decision"
120 )]
121 pub fn annotate_results(&self, results: &mut fallow_types::results::AnalysisResults) {
122 let fallow_types::results::AnalysisResults {
123 unused_files,
124 unused_exports,
125 unused_types,
126 private_type_leaks,
127 deprecated_exports_in_use,
128 unused_dependencies,
129 unused_dev_dependencies,
130 unused_optional_dependencies,
131 unused_enum_members,
132 unused_class_members,
133 unused_store_members,
134 unresolved_imports,
135 unlisted_dependencies,
136 duplicate_exports,
137 type_only_dependencies,
138 test_only_dependencies,
139 dev_dependencies_in_production,
140 circular_dependencies,
141 re_export_cycles,
142 boundary_violations,
143 boundary_coverage_violations,
144 boundary_call_violations,
145 policy_violations,
146 stale_suppressions: _stale_suppressions,
147 unused_catalog_entries,
148 empty_catalog_groups,
149 unresolved_catalog_references,
150 unused_dependency_overrides,
151 misconfigured_dependency_overrides,
152 invalid_client_exports,
153 mixed_client_server_barrels,
154 misplaced_directives,
155 unprovided_injects,
156 unrendered_components,
157 unused_component_props,
158 unused_component_emits,
159 unused_component_inputs,
160 unused_component_outputs,
161 unused_svelte_events,
162 unused_server_actions,
163 unused_load_data_keys,
164 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
165 route_collisions,
166 dynamic_segment_name_conflicts,
167 suppression_count: _suppression_count,
168 unused_component_props_exempted: _unused_component_props_exempted,
169 active_suppressions: _active_suppressions,
170 feature_flags: _feature_flags,
171 security_findings: _security_findings,
172 security_unresolved_edge_files: _security_unresolved_edge_files,
173 security_unresolved_callee_sites: _security_unresolved_callee_sites,
174 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
175 prop_drilling_chains: _prop_drilling_chains,
176 thin_wrappers: _thin_wrappers,
177 duplicate_prop_shapes: _duplicate_prop_shapes,
178 export_usages: _export_usages,
179 entry_point_summary: _entry_point_summary,
180 render_fan_in: _render_fan_in,
181 react_component_intel: _react_component_intel,
182 semantic_framework_contracts: _semantic_framework_contracts,
183 } = results;
184
185 macro_rules! annotate {
186 ($field:ident, $collection:literal) => {
187 for (item, introduced) in $field.iter_mut().zip(
188 self.records
189 .iter()
190 .filter(|record| record.collection == $collection)
191 .map(|record| record.introduced),
192 ) {
193 item.introduced = Some(AuditIntroduced(introduced));
194 }
195 };
196 }
197
198 annotate!(unused_files, "unused_files");
199 annotate!(unused_exports, "unused_exports");
200 annotate!(unused_types, "unused_types");
201 annotate!(private_type_leaks, "private_type_leaks");
202 annotate!(deprecated_exports_in_use, "deprecated_exports_in_use");
203 annotate!(unused_dependencies, "unused_dependencies");
204 annotate!(unused_dev_dependencies, "unused_dev_dependencies");
205 annotate!(unused_optional_dependencies, "unused_optional_dependencies");
206 annotate!(unused_enum_members, "unused_enum_members");
207 annotate!(unused_class_members, "unused_class_members");
208 annotate!(unused_store_members, "unused_store_members");
209 annotate!(unresolved_imports, "unresolved_imports");
210 annotate!(unlisted_dependencies, "unlisted_dependencies");
211 annotate!(duplicate_exports, "duplicate_exports");
212 annotate!(type_only_dependencies, "type_only_dependencies");
213 annotate!(test_only_dependencies, "test_only_dependencies");
214 annotate!(
215 dev_dependencies_in_production,
216 "dev_dependencies_in_production"
217 );
218 annotate!(circular_dependencies, "circular_dependencies");
219 annotate!(re_export_cycles, "re_export_cycles");
220 annotate!(boundary_violations, "boundary_violations");
221 annotate!(boundary_coverage_violations, "boundary_coverage_violations");
222 annotate!(boundary_call_violations, "boundary_call_violations");
223 annotate!(policy_violations, "policy_violations");
224 annotate!(unused_catalog_entries, "unused_catalog_entries");
225 annotate!(empty_catalog_groups, "empty_catalog_groups");
226 annotate!(
227 unresolved_catalog_references,
228 "unresolved_catalog_references"
229 );
230 annotate!(unused_dependency_overrides, "unused_dependency_overrides");
231 annotate!(
232 misconfigured_dependency_overrides,
233 "misconfigured_dependency_overrides"
234 );
235 annotate!(invalid_client_exports, "invalid_client_exports");
236 annotate!(mixed_client_server_barrels, "mixed_client_server_barrels");
237 annotate!(misplaced_directives, "misplaced_directives");
238 annotate!(unprovided_injects, "unprovided_injects");
239 annotate!(unrendered_components, "unrendered_components");
240 annotate!(route_collisions, "route_collisions");
241 annotate!(
242 dynamic_segment_name_conflicts,
243 "dynamic_segment_name_conflicts"
244 );
245 annotate!(unused_component_props, "unused_component_props");
246 annotate!(unused_component_emits, "unused_component_emits");
247 annotate!(unused_component_inputs, "unused_component_inputs");
248 annotate!(unused_component_outputs, "unused_component_outputs");
249 annotate!(unused_svelte_events, "unused_svelte_events");
250 annotate!(unused_server_actions, "unused_server_actions");
251 annotate!(unused_load_data_keys, "unused_load_data_keys");
252 }
253}
254
255#[derive(Debug, Clone, Default)]
257pub struct AuditDomainLedger {
258 records: Vec<(String, bool)>,
259 keys: FxHashSet<String>,
260 introduced_keys: FxHashSet<String>,
261 inherited_keys: FxHashSet<String>,
262 demoted_keys: FxHashSet<String>,
263}
264
265impl AuditDomainLedger {
266 #[must_use]
268 pub fn compare(
269 keys: impl IntoIterator<Item = String>,
270 base: Option<&FxHashSet<String>>,
271 ) -> Self {
272 let mut records = Vec::new();
273 let mut unique_keys = FxHashSet::default();
274 let mut introduced_keys = FxHashSet::default();
275 let mut inherited_keys = FxHashSet::default();
276 for key in keys {
277 let introduced = base.is_some_and(|base| !base.contains(&key));
278 if base.is_some() {
279 if introduced {
280 introduced_keys.insert(key.clone());
281 } else {
282 inherited_keys.insert(key.clone());
283 }
284 }
285 unique_keys.insert(key.clone());
286 records.push((key, introduced));
287 }
288 Self {
289 records,
290 keys: unique_keys,
291 introduced_keys,
292 inherited_keys,
293 demoted_keys: FxHashSet::default(),
294 }
295 }
296
297 #[must_use]
299 pub const fn keys(&self) -> &FxHashSet<String> {
300 &self.keys
301 }
302
303 #[must_use]
305 pub fn introduced_count(&self) -> usize {
306 self.introduced_keys.len()
307 }
308
309 #[must_use]
311 pub fn inherited_count(&self) -> usize {
312 self.inherited_keys.len()
313 }
314
315 pub fn introduced(&self) -> impl ExactSizeIterator<Item = bool> + '_ {
317 self.records.iter().map(|(_, introduced)| *introduced)
318 }
319
320 #[must_use]
324 pub fn demoted_count(&self) -> usize {
325 self.demoted_keys.len()
326 }
327
328 #[must_use]
331 pub const fn demoted_keys(&self) -> &FxHashSet<String> {
332 &self.demoted_keys
333 }
334
335 pub fn demoted(&self) -> impl ExactSizeIterator<Item = bool> + '_ {
338 self.records
339 .iter()
340 .map(|(key, _)| self.demoted_keys.contains(key))
341 }
342
343 pub fn demote_introductions(&mut self, demote: &FxHashSet<String>) {
348 for (key, introduced) in &mut self.records {
349 if *introduced && demote.contains(key) {
350 *introduced = false;
351 self.introduced_keys.remove(key);
352 self.inherited_keys.insert(key.clone());
353 self.demoted_keys.insert(key.clone());
354 }
355 }
356 }
357}
358
359#[derive(Debug, Clone, Default)]
361pub struct AuditComparison {
362 pub dead_code: DeadCodeAuditLedger,
364 pub health: AuditDomainLedger,
366 pub dupes: AuditDomainLedger,
368 pub styling: AuditDomainLedger,
370}
371
372#[must_use]
384#[expect(
385 clippy::implicit_hasher,
386 reason = "fallow standardizes on FxHashMap/FxHashSet across audit attribution keys"
387)]
388pub fn remap_keys_for_renames(
389 keys: &FxHashSet<String>,
390 renames: &FxHashMap<String, String>,
391) -> FxHashSet<String> {
392 keys.iter()
393 .map(|key| remap_key_for_renames(key, renames))
394 .collect()
395}
396
397fn remap_key_for_renames(key: &str, renames: &FxHashMap<String, String>) -> String {
398 if !key
399 .split([':', '|'])
400 .any(|segment| renames.contains_key(segment))
401 {
402 return key.to_string();
403 }
404 let segments: Vec<String> = key
405 .split(':')
406 .map(|segment| {
407 if segment.contains('|') {
408 let mut pieces: Vec<&str> = segment
409 .split('|')
410 .map(|piece| renames.get(piece).map_or(piece, String::as_str))
411 .collect();
412 pieces.sort_unstable();
413 pieces.dedup();
414 pieces.join("|")
415 } else {
416 renames
417 .get(segment)
418 .cloned()
419 .unwrap_or_else(|| segment.to_string())
420 }
421 })
422 .collect();
423 segments.join(":")
424}
425
426pub fn relative_key_path(path: &Path, root: &Path) -> String {
430 let simple_path = dunce::simplified(path);
431 let simple_root = dunce::simplified(root);
432 simple_path
433 .strip_prefix(simple_root)
434 .unwrap_or(simple_path)
435 .to_string_lossy()
436 .replace('\\', "/")
437}
438
439fn dependency_location_key(location: &fallow_types::results::DependencyLocation) -> &'static str {
440 match location {
441 fallow_types::results::DependencyLocation::Dependencies => "unused-dependency",
442 fallow_types::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
443 fallow_types::results::DependencyLocation::OptionalDependencies => {
444 "unused-optional-dependency"
445 }
446 }
447}
448
449fn unused_dependency_key(item: &fallow_types::results::UnusedDependency, root: &Path) -> String {
450 format!(
451 "{}:{}:{}",
452 dependency_location_key(&item.location),
453 relative_key_path(&item.path, root),
454 item.package_name
455 )
456}
457
458fn invalid_client_export_key(
459 item: &fallow_types::results::InvalidClientExport,
460 root: &Path,
461) -> String {
462 format!(
463 "invalid-client-export:{}:{}",
464 relative_key_path(&item.path, root),
465 item.export_name
466 )
467}
468
469fn mixed_client_server_barrel_key(
470 item: &fallow_types::results::MixedClientServerBarrel,
471 root: &Path,
472) -> String {
473 format!(
474 "mixed-client-server-barrel:{}:{}:{}",
475 relative_key_path(&item.path, root),
476 item.client_origin,
477 item.server_origin
478 )
479}
480
481fn misplaced_directive_key(
482 item: &fallow_types::results::MisplacedDirective,
483 root: &Path,
484) -> String {
485 format!(
486 "misplaced-directive:{}:{}:{}",
487 relative_key_path(&item.path, root),
488 item.line,
489 item.directive
490 )
491}
492
493fn unprovided_inject_key(item: &fallow_types::results::UnprovidedInject, root: &Path) -> String {
494 format!(
495 "unprovided-inject:{}:{}",
496 relative_key_path(&item.path, root),
497 item.key_name
498 )
499}
500
501fn unrendered_component_key(
502 item: &fallow_types::results::UnrenderedComponent,
503 root: &Path,
504) -> String {
505 format!(
506 "unrendered-component:{}:{}",
507 relative_key_path(&item.path, root),
508 item.component_name
509 )
510}
511
512fn unused_component_prop_key(
513 item: &fallow_types::results::UnusedComponentProp,
514 root: &Path,
515) -> String {
516 format!(
517 "unused-component-prop:{}:{}",
518 relative_key_path(&item.path, root),
519 item.prop_name
520 )
521}
522
523fn unused_component_emit_key(
524 item: &fallow_types::results::UnusedComponentEmit,
525 root: &Path,
526) -> String {
527 format!(
528 "unused-component-emit:{}:{}",
529 relative_key_path(&item.path, root),
530 item.emit_name
531 )
532}
533
534fn unused_component_input_key(
535 item: &fallow_types::results::UnusedComponentInput,
536 root: &Path,
537) -> String {
538 format!(
539 "unused-component-input:{}:{}",
540 relative_key_path(&item.path, root),
541 item.input_name
542 )
543}
544
545fn unused_component_output_key(
546 item: &fallow_types::results::UnusedComponentOutput,
547 root: &Path,
548) -> String {
549 format!(
550 "unused-component-output:{}:{}",
551 relative_key_path(&item.path, root),
552 item.output_name
553 )
554}
555
556fn unused_svelte_event_key(item: &fallow_types::results::UnusedSvelteEvent, root: &Path) -> String {
557 format!(
558 "unused-svelte-event:{}:{}",
559 relative_key_path(&item.path, root),
560 item.event_name
561 )
562}
563
564fn unused_server_action_key(
565 item: &fallow_types::results::UnusedServerAction,
566 root: &Path,
567) -> String {
568 format!(
569 "unused-server-action:{}:{}",
570 relative_key_path(&item.path, root),
571 item.action_name
572 )
573}
574
575fn unused_load_data_key_key(
576 item: &fallow_types::results::UnusedLoadDataKey,
577 root: &Path,
578) -> String {
579 format!(
580 "unused-load-data-key:{}:{}",
581 relative_key_path(&item.path, root),
582 item.key_name
583 )
584}
585
586fn route_collision_key(item: &fallow_types::results::RouteCollision, root: &Path) -> String {
587 format!(
588 "route-collision:{}:{}",
589 relative_key_path(&item.path, root),
590 item.url
591 )
592}
593
594fn dynamic_segment_name_conflict_key(
595 item: &fallow_types::results::DynamicSegmentNameConflict,
596 root: &Path,
597) -> String {
598 format!(
599 "dynamic-segment-name-conflict:{}:{}",
600 relative_key_path(&item.path, root),
601 item.position
602 )
603}
604
605fn unlisted_dependency_key(
606 item: &fallow_types::results::UnlistedDependency,
607 root: &Path,
608) -> String {
609 let mut sites = item
610 .imported_from
611 .iter()
612 .map(|site| {
613 format!(
614 "{}:{}:{}",
615 relative_key_path(&site.path, root),
616 site.line,
617 site.col
618 )
619 })
620 .collect::<Vec<_>>();
621 sites.sort();
622 sites.dedup();
623 format!(
624 "unlisted-dependency:{}:{}",
625 item.package_name,
626 sites.join("|")
627 )
628}
629
630fn unused_member_key(
631 rule_id: &str,
632 item: &fallow_types::results::UnusedMember,
633 root: &Path,
634) -> String {
635 format!(
636 "{}:{}:{}:{}",
637 rule_id,
638 relative_key_path(&item.path, root),
639 item.parent_name,
640 item.member_name
641 )
642}
643
644fn unused_catalog_entry_key(
645 item: &fallow_types::results::UnusedCatalogEntry,
646 root: &Path,
647) -> String {
648 format!(
649 "unused-catalog-entry:{}:{}:{}:{}",
650 relative_key_path(&item.path, root),
651 item.line,
652 item.catalog_name,
653 item.entry_name
654 )
655}
656
657fn empty_catalog_group_key(item: &fallow_types::results::EmptyCatalogGroup, root: &Path) -> String {
658 format!(
659 "empty-catalog-group:{}:{}:{}",
660 relative_key_path(&item.path, root),
661 item.line,
662 item.catalog_name
663 )
664}
665
666fn sorted_relative_path_keys<'a>(
667 paths: impl Iterator<Item = &'a Path>,
668 root: &Path,
669) -> Vec<String> {
670 let mut keys = paths
671 .map(|path| relative_key_path(path, root))
672 .collect::<Vec<_>>();
673 keys.sort();
674 keys
675}
676
677fn duplicate_export_key(
678 item: &fallow_types::output_dead_code::DuplicateExportFinding,
679 root: &Path,
680) -> String {
681 let mut locations = sorted_relative_path_keys(
682 item.export.locations.iter().map(|loc| loc.path.as_path()),
683 root,
684 );
685 locations.dedup();
686 format!(
687 "duplicate-export:{}:{}",
688 item.export.export_name,
689 locations.join("|")
690 )
691}
692
693fn circular_dependency_key(
694 item: &fallow_types::output_dead_code::CircularDependencyFinding,
695 root: &Path,
696) -> String {
697 let files = sorted_relative_path_keys(
698 item.cycle.files.iter().map(std::path::PathBuf::as_path),
699 root,
700 );
701 format!("circular-dependency:{}", files.join("|"))
702}
703
704fn re_export_cycle_key(
705 item: &fallow_types::output_dead_code::ReExportCycleFinding,
706 root: &Path,
707) -> String {
708 let kind = match item.cycle.kind {
709 fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
710 fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
711 };
712 let files = sorted_relative_path_keys(
713 item.cycle.files.iter().map(std::path::PathBuf::as_path),
714 root,
715 );
716 format!("re-export-cycle:{kind}:{}", files.join("|"))
717}
718
719fn boundary_violation_key(
720 item: &fallow_types::output_dead_code::BoundaryViolationFinding,
721 root: &Path,
722) -> String {
723 format!(
724 "boundary-violation:{}:{}:{}",
725 relative_key_path(&item.violation.from_path, root),
726 relative_key_path(&item.violation.to_path, root),
727 item.violation.import_specifier
728 )
729}
730
731fn boundary_coverage_key(
732 item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
733 root: &Path,
734) -> String {
735 format!(
736 "boundary-coverage:{}",
737 relative_key_path(&item.violation.path, root)
738 )
739}
740
741fn boundary_call_key(
742 item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
743 root: &Path,
744) -> String {
745 format!(
746 "boundary-call:{}:{}",
747 relative_key_path(&item.violation.path, root),
748 item.violation.callee
749 )
750}
751
752fn policy_violation_key(
753 item: &fallow_types::output_dead_code::PolicyViolationFinding,
754 root: &Path,
755) -> String {
756 format!(
757 "policy-violation:{}:{}/{}:{}",
758 relative_key_path(&item.violation.path, root),
759 item.violation.pack,
760 item.violation.rule_id,
761 item.violation.matched
762 )
763}
764
765fn stale_suppression_key(item: &fallow_types::results::StaleSuppression, root: &Path) -> String {
766 let rule_id = if item.missing_reason {
767 "missing-suppression-reason"
768 } else {
769 "stale-suppression"
770 };
771 format!(
772 "{rule_id}:{}:{}",
773 relative_key_path(&item.path, root),
774 item.description()
775 )
776}
777
778fn unresolved_catalog_reference_key(
779 item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
780 root: &Path,
781) -> String {
782 format!(
783 "unresolved-catalog-reference:{}:{}:{}:{}",
784 relative_key_path(&item.reference.path, root),
785 item.reference.line,
786 item.reference.catalog_name,
787 item.reference.entry_name
788 )
789}
790
791fn unused_dependency_override_key(
792 item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
793 root: &Path,
794) -> String {
795 format!(
796 "unused-dependency-override:{}:{}:{}",
797 relative_key_path(&item.entry.path, root),
798 item.entry.line,
799 item.entry.raw_key
800 )
801}
802
803fn misconfigured_dependency_override_key(
804 item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
805 root: &Path,
806) -> String {
807 format!(
808 "misconfigured-dependency-override:{}:{}:{}",
809 relative_key_path(&item.entry.path, root),
810 item.entry.line,
811 item.entry.raw_key
812 )
813}
814
815#[derive(Clone, Copy)]
832#[allow(
833 clippy::struct_field_names,
834 reason = "field names mirror the AnalysisResults field names so the destructure stays shorthand"
835)]
836struct DependencyFindingSlices<'a> {
837 unused_dependencies: &'a [fallow_types::output_dead_code::UnusedDependencyFinding],
838 unused_dev_dependencies: &'a [fallow_types::output_dead_code::UnusedDevDependencyFinding],
839 unused_optional_dependencies:
840 &'a [fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
841 unlisted_dependencies: &'a [fallow_types::output_dead_code::UnlistedDependencyFinding],
842 type_only_dependencies: &'a [fallow_types::output_dead_code::TypeOnlyDependencyFinding],
843 test_only_dependencies: &'a [fallow_types::output_dead_code::TestOnlyDependencyFinding],
844 dev_dependencies_in_production:
845 &'a [fallow_types::output_dead_code::DevDependencyInProductionFinding],
846}
847
848#[derive(Clone, Copy)]
851struct FrameworkFindingSlices<'a> {
852 unprovided_injects: &'a [fallow_types::output_dead_code::UnprovidedInjectFinding],
853 unrendered_components: &'a [fallow_types::output_dead_code::UnrenderedComponentFinding],
854 unused_server_actions: &'a [fallow_types::output_dead_code::UnusedServerActionFinding],
855 unused_load_data_keys: &'a [fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
856 route_collisions: &'a [fallow_types::output_dead_code::RouteCollisionFinding],
857 dynamic_segment_name_conflicts:
858 &'a [fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
859}
860
861pub fn dead_code_keys(
871 results: &fallow_types::results::AnalysisResults,
872 root: &Path,
873) -> FxHashSet<String> {
874 let mut collector = DeadCodeKeyCollector::new(root);
875 collector.add_all_findings(results);
876 collector.into_keys()
877}
878
879#[must_use]
881#[expect(
882 clippy::implicit_hasher,
883 reason = "fallow standardizes on FxHashSet across audit attribution keys"
884)]
885pub fn dead_code_audit_ledger(
886 results: &fallow_types::results::AnalysisResults,
887 root: &Path,
888 config: &ResolvedConfig,
889 base: Option<&FxHashSet<String>>,
890) -> DeadCodeAuditLedger {
891 let mut collector = DeadCodeKeyCollector::for_comparison(root, config, base);
892 collector.add_all_findings(results);
893 collector.into_ledger()
894}
895
896impl DeadCodeKeyCollector<'_> {
897 #[expect(
898 clippy::too_many_lines,
899 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"
900 )]
901 fn add_all_findings(&mut self, results: &fallow_types::results::AnalysisResults) {
902 let fallow_types::results::AnalysisResults {
903 unused_files,
904 unused_exports,
905 unused_types,
906 private_type_leaks,
907 deprecated_exports_in_use,
908 unused_dependencies,
909 unused_dev_dependencies,
910 unused_optional_dependencies,
911 unused_enum_members,
912 unused_class_members,
913 unused_store_members,
914 unresolved_imports,
915 unlisted_dependencies,
916 duplicate_exports,
917 type_only_dependencies,
918 test_only_dependencies,
919 dev_dependencies_in_production,
920 circular_dependencies,
921 re_export_cycles,
922 boundary_violations,
923 boundary_coverage_violations,
924 boundary_call_violations,
925 policy_violations,
926 stale_suppressions,
927 unused_catalog_entries,
928 empty_catalog_groups,
929 unresolved_catalog_references,
930 unused_dependency_overrides,
931 misconfigured_dependency_overrides,
932 invalid_client_exports,
933 mixed_client_server_barrels,
934 misplaced_directives,
935 unprovided_injects,
936 unrendered_components,
937 unused_component_props,
938 unused_component_emits,
939 unused_component_inputs,
940 unused_component_outputs,
941 unused_svelte_events,
942 unused_server_actions,
943 unused_load_data_keys,
944 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
945 route_collisions,
946 dynamic_segment_name_conflicts,
947 suppression_count: _suppression_count,
949 unused_component_props_exempted: _unused_component_props_exempted,
950 active_suppressions: _active_suppressions,
951 feature_flags: _feature_flags,
952 security_findings: _security_findings,
955 security_unresolved_edge_files: _security_unresolved_edge_files,
956 security_unresolved_callee_sites: _security_unresolved_callee_sites,
957 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
958 prop_drilling_chains: _prop_drilling_chains,
961 thin_wrappers: _thin_wrappers,
964 duplicate_prop_shapes: _duplicate_prop_shapes,
968 export_usages: _export_usages,
971 entry_point_summary: _entry_point_summary,
972 render_fan_in: _render_fan_in,
975 react_component_intel: _react_component_intel,
978 semantic_framework_contracts: _semantic_framework_contracts,
979 } = results;
980
981 self.add_core_findings(
982 unused_files,
983 unused_exports,
984 unused_types,
985 private_type_leaks,
986 );
987 self.add_deprecated_exports_in_use(deprecated_exports_in_use);
988 self.add_client_directive_findings(
989 invalid_client_exports,
990 mixed_client_server_barrels,
991 misplaced_directives,
992 );
993 self.add_dependency_findings(&DependencyFindingSlices {
994 unused_dependencies,
995 unused_dev_dependencies,
996 unused_optional_dependencies,
997 unlisted_dependencies,
998 type_only_dependencies,
999 test_only_dependencies,
1000 dev_dependencies_in_production,
1001 });
1002 self.add_dependency_override_findings(
1003 unused_dependency_overrides,
1004 misconfigured_dependency_overrides,
1005 );
1006 self.add_member_findings(
1007 unused_enum_members,
1008 unused_class_members,
1009 unused_store_members,
1010 );
1011 self.add_component_contract_findings(
1012 unused_component_props,
1013 unused_component_emits,
1014 unused_component_inputs,
1015 unused_component_outputs,
1016 unused_svelte_events,
1017 );
1018 self.add_graph_findings(
1019 unresolved_imports,
1020 duplicate_exports,
1021 circular_dependencies,
1022 re_export_cycles,
1023 );
1024 self.add_boundary_findings(
1025 boundary_violations,
1026 boundary_coverage_violations,
1027 boundary_call_violations,
1028 policy_violations,
1029 stale_suppressions,
1030 );
1031 self.add_catalog_findings(
1032 unresolved_catalog_references,
1033 unused_catalog_entries,
1034 empty_catalog_groups,
1035 );
1036 self.add_framework_findings(&FrameworkFindingSlices {
1037 unprovided_injects,
1038 unrendered_components,
1039 unused_server_actions,
1040 unused_load_data_keys,
1041 route_collisions,
1042 dynamic_segment_name_conflicts,
1043 });
1044 }
1045}
1046
1047#[derive(Clone, Copy)]
1048enum AuditCollection {
1049 UnusedFiles,
1050 UnusedExports,
1051 UnusedTypes,
1052 PrivateTypeLeaks,
1053 DeprecatedExportsInUse,
1054 UnusedDependencies,
1055 UnusedDevDependencies,
1056 UnusedOptionalDependencies,
1057 UnusedEnumMembers,
1058 UnusedClassMembers,
1059 UnusedStoreMembers,
1060 UnresolvedImports,
1061 UnlistedDependencies,
1062 DuplicateExports,
1063 TypeOnlyDependencies,
1064 TestOnlyDependencies,
1065 DevDependenciesInProduction,
1066 CircularDependencies,
1067 ReExportCycles,
1068 BoundaryViolations,
1069 BoundaryCoverageViolations,
1070 BoundaryCallViolations,
1071 PolicyViolations,
1072 StaleSuppressions,
1073 UnusedCatalogEntries,
1074 EmptyCatalogGroups,
1075 UnresolvedCatalogReferences,
1076 UnusedDependencyOverrides,
1077 MisconfiguredDependencyOverrides,
1078 InvalidClientExports,
1079 MixedClientServerBarrels,
1080 MisplacedDirectives,
1081 UnprovidedInjects,
1082 UnrenderedComponents,
1083 RouteCollisions,
1084 DynamicSegmentNameConflicts,
1085 UnusedComponentProps,
1086 UnusedComponentEmits,
1087 UnusedComponentInputs,
1088 UnusedComponentOutputs,
1089 UnusedSvelteEvents,
1090 UnusedServerActions,
1091 UnusedLoadDataKeys,
1092}
1093
1094impl AuditCollection {
1095 #[cfg(test)]
1096 const ALL: [Self; 43] = [
1097 Self::UnusedFiles,
1098 Self::UnusedExports,
1099 Self::UnusedTypes,
1100 Self::PrivateTypeLeaks,
1101 Self::DeprecatedExportsInUse,
1102 Self::UnusedDependencies,
1103 Self::UnusedDevDependencies,
1104 Self::UnusedOptionalDependencies,
1105 Self::UnusedEnumMembers,
1106 Self::UnusedClassMembers,
1107 Self::UnusedStoreMembers,
1108 Self::UnresolvedImports,
1109 Self::UnlistedDependencies,
1110 Self::DuplicateExports,
1111 Self::TypeOnlyDependencies,
1112 Self::TestOnlyDependencies,
1113 Self::DevDependenciesInProduction,
1114 Self::CircularDependencies,
1115 Self::ReExportCycles,
1116 Self::BoundaryViolations,
1117 Self::BoundaryCoverageViolations,
1118 Self::BoundaryCallViolations,
1119 Self::PolicyViolations,
1120 Self::StaleSuppressions,
1121 Self::UnusedCatalogEntries,
1122 Self::EmptyCatalogGroups,
1123 Self::UnresolvedCatalogReferences,
1124 Self::UnusedDependencyOverrides,
1125 Self::MisconfiguredDependencyOverrides,
1126 Self::InvalidClientExports,
1127 Self::MixedClientServerBarrels,
1128 Self::MisplacedDirectives,
1129 Self::UnprovidedInjects,
1130 Self::UnrenderedComponents,
1131 Self::RouteCollisions,
1132 Self::DynamicSegmentNameConflicts,
1133 Self::UnusedComponentProps,
1134 Self::UnusedComponentEmits,
1135 Self::UnusedComponentInputs,
1136 Self::UnusedComponentOutputs,
1137 Self::UnusedSvelteEvents,
1138 Self::UnusedServerActions,
1139 Self::UnusedLoadDataKeys,
1140 ];
1141
1142 const fn json_key(self) -> &'static str {
1143 match self {
1144 Self::UnusedFiles => "unused_files",
1145 Self::UnusedExports => "unused_exports",
1146 Self::UnusedTypes => "unused_types",
1147 Self::PrivateTypeLeaks => "private_type_leaks",
1148 Self::DeprecatedExportsInUse => "deprecated_exports_in_use",
1149 Self::UnusedDependencies => "unused_dependencies",
1150 Self::UnusedDevDependencies => "unused_dev_dependencies",
1151 Self::UnusedOptionalDependencies => "unused_optional_dependencies",
1152 Self::UnusedEnumMembers => "unused_enum_members",
1153 Self::UnusedClassMembers => "unused_class_members",
1154 Self::UnusedStoreMembers => "unused_store_members",
1155 Self::UnresolvedImports => "unresolved_imports",
1156 Self::UnlistedDependencies => "unlisted_dependencies",
1157 Self::DuplicateExports => "duplicate_exports",
1158 Self::TypeOnlyDependencies => "type_only_dependencies",
1159 Self::TestOnlyDependencies => "test_only_dependencies",
1160 Self::DevDependenciesInProduction => "dev_dependencies_in_production",
1161 Self::CircularDependencies => "circular_dependencies",
1162 Self::ReExportCycles => "re_export_cycles",
1163 Self::BoundaryViolations => "boundary_violations",
1164 Self::BoundaryCoverageViolations => "boundary_coverage_violations",
1165 Self::BoundaryCallViolations => "boundary_call_violations",
1166 Self::PolicyViolations => "policy_violations",
1167 Self::StaleSuppressions => "stale_suppressions",
1168 Self::UnusedCatalogEntries => "unused_catalog_entries",
1169 Self::EmptyCatalogGroups => "empty_catalog_groups",
1170 Self::UnresolvedCatalogReferences => "unresolved_catalog_references",
1171 Self::UnusedDependencyOverrides => "unused_dependency_overrides",
1172 Self::MisconfiguredDependencyOverrides => "misconfigured_dependency_overrides",
1173 Self::InvalidClientExports => "invalid_client_exports",
1174 Self::MixedClientServerBarrels => "mixed_client_server_barrels",
1175 Self::MisplacedDirectives => "misplaced_directives",
1176 Self::UnprovidedInjects => "unprovided_injects",
1177 Self::UnrenderedComponents => "unrendered_components",
1178 Self::RouteCollisions => "route_collisions",
1179 Self::DynamicSegmentNameConflicts => "dynamic_segment_name_conflicts",
1180 Self::UnusedComponentProps => "unused_component_props",
1181 Self::UnusedComponentEmits => "unused_component_emits",
1182 Self::UnusedComponentInputs => "unused_component_inputs",
1183 Self::UnusedComponentOutputs => "unused_component_outputs",
1184 Self::UnusedSvelteEvents => "unused_svelte_events",
1185 Self::UnusedServerActions => "unused_server_actions",
1186 Self::UnusedLoadDataKeys => "unused_load_data_keys",
1187 }
1188 }
1189}
1190
1191struct DeadCodeKeyCollector<'a> {
1192 root: &'a Path,
1193 keys: FxHashSet<String>,
1194 introduced_keys: FxHashSet<String>,
1195 inherited_keys: FxHashSet<String>,
1196 records: Vec<AuditFindingRecord>,
1197 collection_counts: FxHashMap<&'static str, usize>,
1198 config: Option<&'a ResolvedConfig>,
1199 base: Option<&'a FxHashSet<String>>,
1200 #[cfg(test)]
1201 classifications: usize,
1202}
1203
1204impl<'a> DeadCodeKeyCollector<'a> {
1205 fn new(root: &'a Path) -> Self {
1206 Self {
1207 root,
1208 keys: FxHashSet::default(),
1209 introduced_keys: FxHashSet::default(),
1210 inherited_keys: FxHashSet::default(),
1211 records: Vec::new(),
1212 collection_counts: FxHashMap::default(),
1213 config: None,
1214 base: None,
1215 #[cfg(test)]
1216 classifications: 0,
1217 }
1218 }
1219
1220 fn for_comparison(
1221 root: &'a Path,
1222 config: &'a ResolvedConfig,
1223 base: Option<&'a FxHashSet<String>>,
1224 ) -> Self {
1225 Self {
1226 root,
1227 keys: FxHashSet::default(),
1228 introduced_keys: FxHashSet::default(),
1229 inherited_keys: FxHashSet::default(),
1230 records: Vec::new(),
1231 collection_counts: FxHashMap::default(),
1232 config: Some(config),
1233 base,
1234 #[cfg(test)]
1235 classifications: 0,
1236 }
1237 }
1238
1239 fn into_keys(self) -> FxHashSet<String> {
1240 self.keys
1241 }
1242
1243 fn into_ledger(self) -> DeadCodeAuditLedger {
1244 DeadCodeAuditLedger {
1245 records: self.records,
1246 keys: self.keys,
1247 introduced_keys: self.introduced_keys,
1248 inherited_keys: self.inherited_keys,
1249 #[cfg(test)]
1250 classifications: self.classifications,
1251 }
1252 }
1253
1254 fn insert(&mut self, collection: AuditCollection, key: String, effective_severity: Severity) {
1255 let collection = collection.json_key();
1256 let ordinal = self.collection_counts.entry(collection).or_default();
1257 if self.config.is_some() {
1258 #[cfg(test)]
1259 {
1260 self.classifications += 1;
1261 }
1262 let introduced = self.base.is_some_and(|base| !base.contains(&key));
1263 if effective_severity != Severity::Off && self.base.is_some() {
1264 if introduced {
1265 self.introduced_keys.insert(key.clone());
1266 } else {
1267 self.inherited_keys.insert(key.clone());
1268 }
1269 }
1270 self.records.push(AuditFindingRecord {
1271 collection,
1272 ordinal: *ordinal,
1273 introduced,
1274 effective_severity,
1275 stable_key: key.clone(),
1276 });
1277 }
1278 *ordinal += 1;
1279 self.keys.insert(key);
1280 }
1281
1282 fn insert_rule(
1285 &mut self,
1286 collection: AuditCollection,
1287 key: String,
1288 item: &impl fallow_engine::dead_code::RuleSeverity,
1289 ) {
1290 let effective = self.config.map_or(Severity::Off, |config| {
1291 item.rule_severity(&fallow_engine::dead_code::SeveritySource::from_config(
1292 config,
1293 ))
1294 });
1295 self.insert(collection, key, effective);
1296 }
1297
1298 fn add_core_findings(
1299 &mut self,
1300 unused_files: &[fallow_types::output_dead_code::UnusedFileFinding],
1301 unused_exports: &[fallow_types::output_dead_code::UnusedExportFinding],
1302 unused_types: &[fallow_types::output_dead_code::UnusedTypeFinding],
1303 private_type_leaks: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
1304 ) {
1305 self.add_unused_files(unused_files);
1306 self.add_unused_exports(unused_exports);
1307 self.add_unused_types(unused_types);
1308 self.add_private_type_leaks(private_type_leaks);
1309 }
1310
1311 fn add_client_directive_findings(
1312 &mut self,
1313 invalid_client_exports: &[fallow_types::output_dead_code::InvalidClientExportFinding],
1314 mixed_client_server_barrels: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
1315 misplaced_directives: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1316 ) {
1317 self.add_invalid_client_exports(invalid_client_exports);
1318 self.add_mixed_client_server_barrels(mixed_client_server_barrels);
1319 self.add_misplaced_directives(misplaced_directives);
1320 }
1321
1322 fn add_dependency_findings(&mut self, deps: &DependencyFindingSlices<'_>) {
1323 let DependencyFindingSlices {
1324 unused_dependencies,
1325 unused_dev_dependencies,
1326 unused_optional_dependencies,
1327 unlisted_dependencies,
1328 type_only_dependencies,
1329 test_only_dependencies,
1330 dev_dependencies_in_production,
1331 } = *deps;
1332 self.add_unused_dependencies(unused_dependencies);
1333 self.add_unused_dev_dependencies(unused_dev_dependencies);
1334 self.add_unused_optional_dependencies(unused_optional_dependencies);
1335 self.add_unlisted_dependencies(unlisted_dependencies);
1336 self.add_type_only_dependencies(type_only_dependencies);
1337 self.add_test_only_dependencies(test_only_dependencies);
1338 self.add_dev_dependencies_in_production(dev_dependencies_in_production);
1339 }
1340
1341 fn add_dependency_override_findings(
1342 &mut self,
1343 unused_dependency_overrides: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1344 misconfigured_dependency_overrides: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
1345 ) {
1346 self.add_unused_dependency_overrides(unused_dependency_overrides);
1347 self.add_misconfigured_dependency_overrides(misconfigured_dependency_overrides);
1348 }
1349
1350 fn add_member_findings(
1351 &mut self,
1352 unused_enum_members: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1353 unused_class_members: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1354 unused_store_members: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1355 ) {
1356 self.add_unused_enum_members(unused_enum_members);
1357 self.add_unused_class_members(unused_class_members);
1358 self.add_unused_store_members(unused_store_members);
1359 }
1360
1361 fn add_component_contract_findings(
1362 &mut self,
1363 unused_component_props: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1364 unused_component_emits: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1365 unused_component_inputs: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1366 unused_component_outputs: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1367 unused_svelte_events: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1368 ) {
1369 self.add_unused_component_props(unused_component_props);
1370 self.add_unused_component_emits(unused_component_emits);
1371 self.add_unused_component_inputs(unused_component_inputs);
1372 self.add_unused_component_outputs(unused_component_outputs);
1373 self.add_unused_svelte_events(unused_svelte_events);
1374 }
1375
1376 fn add_graph_findings(
1377 &mut self,
1378 unresolved_imports: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1379 duplicate_exports: &[fallow_types::output_dead_code::DuplicateExportFinding],
1380 circular_dependencies: &[fallow_types::output_dead_code::CircularDependencyFinding],
1381 re_export_cycles: &[fallow_types::output_dead_code::ReExportCycleFinding],
1382 ) {
1383 self.add_unresolved_imports(unresolved_imports);
1384 self.add_duplicate_exports(duplicate_exports);
1385 self.add_circular_dependencies(circular_dependencies);
1386 self.add_re_export_cycles(re_export_cycles);
1387 }
1388
1389 fn add_boundary_findings(
1390 &mut self,
1391 boundary_violations: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1392 boundary_coverage_violations: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1393 boundary_call_violations: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1394 policy_violations: &[fallow_types::output_dead_code::PolicyViolationFinding],
1395 stale_suppressions: &[fallow_types::results::StaleSuppression],
1396 ) {
1397 self.add_boundary_violations(boundary_violations);
1398 self.add_boundary_coverage_violations(boundary_coverage_violations);
1399 self.add_boundary_call_violations(boundary_call_violations);
1400 self.add_policy_violations(policy_violations);
1401 self.add_stale_suppressions(stale_suppressions);
1402 }
1403
1404 fn add_catalog_findings(
1405 &mut self,
1406 unresolved_catalog_references: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1407 unused_catalog_entries: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1408 empty_catalog_groups: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1409 ) {
1410 self.add_unresolved_catalog_references(unresolved_catalog_references);
1411 self.add_unused_catalog_entries(unused_catalog_entries);
1412 self.add_empty_catalog_groups(empty_catalog_groups);
1413 }
1414
1415 fn add_framework_findings(&mut self, framework: &FrameworkFindingSlices<'_>) {
1416 let FrameworkFindingSlices {
1417 unprovided_injects,
1418 unrendered_components,
1419 unused_server_actions,
1420 unused_load_data_keys,
1421 route_collisions,
1422 dynamic_segment_name_conflicts,
1423 } = *framework;
1424 self.add_unprovided_injects(unprovided_injects);
1425 self.add_unrendered_components(unrendered_components);
1426 self.add_unused_server_actions(unused_server_actions);
1427 self.add_unused_load_data_keys(unused_load_data_keys);
1428 self.add_route_collisions(route_collisions);
1429 self.add_dynamic_segment_name_conflicts(dynamic_segment_name_conflicts);
1430 }
1431
1432 fn add_unused_files(&mut self, items: &[fallow_types::output_dead_code::UnusedFileFinding]) {
1433 for item in items {
1434 self.insert_rule(
1435 AuditCollection::UnusedFiles,
1436 format!(
1437 "unused-file:{}",
1438 relative_key_path(&item.file.path, self.root)
1439 ),
1440 item,
1441 );
1442 }
1443 }
1444
1445 fn add_unused_exports(
1446 &mut self,
1447 items: &[fallow_types::output_dead_code::UnusedExportFinding],
1448 ) {
1449 for item in items {
1450 self.insert_rule(
1451 AuditCollection::UnusedExports,
1452 format!(
1453 "unused-export:{}:{}",
1454 relative_key_path(&item.export.path, self.root),
1455 item.export.export_name
1456 ),
1457 item,
1458 );
1459 }
1460 }
1461
1462 fn add_unused_types(&mut self, items: &[fallow_types::output_dead_code::UnusedTypeFinding]) {
1463 for item in items {
1464 self.insert_rule(
1465 AuditCollection::UnusedTypes,
1466 format!(
1467 "unused-type:{}:{}",
1468 relative_key_path(&item.export.path, self.root),
1469 item.export.export_name
1470 ),
1471 item,
1472 );
1473 }
1474 }
1475
1476 fn add_private_type_leaks(
1477 &mut self,
1478 items: &[fallow_types::output_dead_code::PrivateTypeLeakFinding],
1479 ) {
1480 for item in items {
1481 self.insert_rule(
1482 AuditCollection::PrivateTypeLeaks,
1483 format!(
1484 "private-type-leak:{}:{}:{}",
1485 relative_key_path(&item.leak.path, self.root),
1486 item.leak.export_name,
1487 item.leak.type_name
1488 ),
1489 item,
1490 );
1491 }
1492 }
1493
1494 fn add_deprecated_exports_in_use(
1495 &mut self,
1496 items: &[fallow_types::output_dead_code::DeprecatedExportInUseFinding],
1497 ) {
1498 for item in items {
1499 self.insert_rule(
1500 AuditCollection::DeprecatedExportsInUse,
1501 deprecated_export_key(&item.export, self.root),
1502 item,
1503 );
1504 }
1505 }
1506
1507 fn add_invalid_client_exports(
1508 &mut self,
1509 items: &[fallow_types::output_dead_code::InvalidClientExportFinding],
1510 ) {
1511 for item in items {
1512 self.insert_rule(
1513 AuditCollection::InvalidClientExports,
1514 invalid_client_export_key(&item.export, self.root),
1515 item,
1516 );
1517 }
1518 }
1519
1520 fn add_mixed_client_server_barrels(
1521 &mut self,
1522 items: &[fallow_types::output_dead_code::MixedClientServerBarrelFinding],
1523 ) {
1524 for item in items {
1525 self.insert_rule(
1526 AuditCollection::MixedClientServerBarrels,
1527 mixed_client_server_barrel_key(&item.barrel, self.root),
1528 item,
1529 );
1530 }
1531 }
1532
1533 fn add_misplaced_directives(
1534 &mut self,
1535 items: &[fallow_types::output_dead_code::MisplacedDirectiveFinding],
1536 ) {
1537 for item in items {
1538 self.insert_rule(
1539 AuditCollection::MisplacedDirectives,
1540 misplaced_directive_key(&item.directive_site, self.root),
1541 item,
1542 );
1543 }
1544 }
1545
1546 fn add_unprovided_injects(
1547 &mut self,
1548 items: &[fallow_types::output_dead_code::UnprovidedInjectFinding],
1549 ) {
1550 for item in items {
1551 self.insert_rule(
1552 AuditCollection::UnprovidedInjects,
1553 unprovided_inject_key(&item.inject, self.root),
1554 item,
1555 );
1556 }
1557 }
1558
1559 fn add_unrendered_components(
1560 &mut self,
1561 items: &[fallow_types::output_dead_code::UnrenderedComponentFinding],
1562 ) {
1563 for item in items {
1564 self.insert_rule(
1565 AuditCollection::UnrenderedComponents,
1566 unrendered_component_key(&item.component, self.root),
1567 item,
1568 );
1569 }
1570 }
1571
1572 fn add_unused_component_props(
1573 &mut self,
1574 items: &[fallow_types::output_dead_code::UnusedComponentPropFinding],
1575 ) {
1576 for item in items {
1577 self.insert_rule(
1578 AuditCollection::UnusedComponentProps,
1579 unused_component_prop_key(&item.prop, self.root),
1580 item,
1581 );
1582 }
1583 }
1584
1585 fn add_unused_component_emits(
1586 &mut self,
1587 items: &[fallow_types::output_dead_code::UnusedComponentEmitFinding],
1588 ) {
1589 for item in items {
1590 self.insert_rule(
1591 AuditCollection::UnusedComponentEmits,
1592 unused_component_emit_key(&item.emit, self.root),
1593 item,
1594 );
1595 }
1596 }
1597
1598 fn add_unused_component_inputs(
1599 &mut self,
1600 items: &[fallow_types::output_dead_code::UnusedComponentInputFinding],
1601 ) {
1602 for item in items {
1603 self.insert_rule(
1604 AuditCollection::UnusedComponentInputs,
1605 unused_component_input_key(&item.input, self.root),
1606 item,
1607 );
1608 }
1609 }
1610
1611 fn add_unused_component_outputs(
1612 &mut self,
1613 items: &[fallow_types::output_dead_code::UnusedComponentOutputFinding],
1614 ) {
1615 for item in items {
1616 self.insert_rule(
1617 AuditCollection::UnusedComponentOutputs,
1618 unused_component_output_key(&item.output, self.root),
1619 item,
1620 );
1621 }
1622 }
1623
1624 fn add_unused_svelte_events(
1625 &mut self,
1626 items: &[fallow_types::output_dead_code::UnusedSvelteEventFinding],
1627 ) {
1628 for item in items {
1629 self.insert_rule(
1630 AuditCollection::UnusedSvelteEvents,
1631 unused_svelte_event_key(&item.event, self.root),
1632 item,
1633 );
1634 }
1635 }
1636
1637 fn add_unused_server_actions(
1638 &mut self,
1639 items: &[fallow_types::output_dead_code::UnusedServerActionFinding],
1640 ) {
1641 for item in items {
1642 self.insert_rule(
1643 AuditCollection::UnusedServerActions,
1644 unused_server_action_key(&item.action, self.root),
1645 item,
1646 );
1647 }
1648 }
1649
1650 fn add_unused_load_data_keys(
1651 &mut self,
1652 items: &[fallow_types::output_dead_code::UnusedLoadDataKeyFinding],
1653 ) {
1654 for item in items {
1655 self.insert_rule(
1656 AuditCollection::UnusedLoadDataKeys,
1657 unused_load_data_key_key(&item.key, self.root),
1658 item,
1659 );
1660 }
1661 }
1662
1663 fn add_route_collisions(
1664 &mut self,
1665 items: &[fallow_types::output_dead_code::RouteCollisionFinding],
1666 ) {
1667 for item in items {
1668 self.insert_rule(
1669 AuditCollection::RouteCollisions,
1670 route_collision_key(&item.collision, self.root),
1671 item,
1672 );
1673 }
1674 }
1675
1676 fn add_dynamic_segment_name_conflicts(
1677 &mut self,
1678 items: &[fallow_types::output_dead_code::DynamicSegmentNameConflictFinding],
1679 ) {
1680 for item in items {
1681 self.insert_rule(
1682 AuditCollection::DynamicSegmentNameConflicts,
1683 dynamic_segment_name_conflict_key(&item.conflict, self.root),
1684 item,
1685 );
1686 }
1687 }
1688
1689 fn add_unused_dependencies(
1690 &mut self,
1691 items: &[fallow_types::output_dead_code::UnusedDependencyFinding],
1692 ) {
1693 for item in items {
1694 self.insert_rule(
1695 AuditCollection::UnusedDependencies,
1696 unused_dependency_key(&item.dep, self.root),
1697 item,
1698 );
1699 }
1700 }
1701
1702 fn add_unused_dev_dependencies(
1703 &mut self,
1704 items: &[fallow_types::output_dead_code::UnusedDevDependencyFinding],
1705 ) {
1706 for item in items {
1707 self.insert_rule(
1708 AuditCollection::UnusedDevDependencies,
1709 unused_dependency_key(&item.dep, self.root),
1710 item,
1711 );
1712 }
1713 }
1714
1715 fn add_unused_optional_dependencies(
1716 &mut self,
1717 items: &[fallow_types::output_dead_code::UnusedOptionalDependencyFinding],
1718 ) {
1719 for item in items {
1720 self.insert_rule(
1721 AuditCollection::UnusedOptionalDependencies,
1722 unused_dependency_key(&item.dep, self.root),
1723 item,
1724 );
1725 }
1726 }
1727
1728 fn add_unused_enum_members(
1729 &mut self,
1730 items: &[fallow_types::output_dead_code::UnusedEnumMemberFinding],
1731 ) {
1732 for item in items {
1733 self.insert_rule(
1734 AuditCollection::UnusedEnumMembers,
1735 unused_member_key("unused-enum-member", &item.member, self.root),
1736 item,
1737 );
1738 }
1739 }
1740
1741 fn add_unused_class_members(
1742 &mut self,
1743 items: &[fallow_types::output_dead_code::UnusedClassMemberFinding],
1744 ) {
1745 for item in items {
1746 self.insert_rule(
1747 AuditCollection::UnusedClassMembers,
1748 unused_member_key("unused-class-member", &item.member, self.root),
1749 item,
1750 );
1751 }
1752 }
1753
1754 fn add_unused_store_members(
1755 &mut self,
1756 items: &[fallow_types::output_dead_code::UnusedStoreMemberFinding],
1757 ) {
1758 for item in items {
1759 self.insert_rule(
1760 AuditCollection::UnusedStoreMembers,
1761 unused_member_key("unused-store-member", &item.member, self.root),
1762 item,
1763 );
1764 }
1765 }
1766
1767 fn add_unresolved_imports(
1768 &mut self,
1769 items: &[fallow_types::output_dead_code::UnresolvedImportFinding],
1770 ) {
1771 for item in items {
1772 self.insert_rule(
1773 AuditCollection::UnresolvedImports,
1774 format!(
1775 "unresolved-import:{}:{}",
1776 relative_key_path(&item.import.path, self.root),
1777 item.import.specifier
1778 ),
1779 item,
1780 );
1781 }
1782 }
1783
1784 fn add_unlisted_dependencies(
1785 &mut self,
1786 items: &[fallow_types::output_dead_code::UnlistedDependencyFinding],
1787 ) {
1788 for item in items {
1789 self.insert_rule(
1790 AuditCollection::UnlistedDependencies,
1791 unlisted_dependency_key(&item.dep, self.root),
1792 item,
1793 );
1794 }
1795 }
1796
1797 fn add_duplicate_exports(
1798 &mut self,
1799 items: &[fallow_types::output_dead_code::DuplicateExportFinding],
1800 ) {
1801 for item in items {
1802 self.insert_rule(
1803 AuditCollection::DuplicateExports,
1804 duplicate_export_key(item, self.root),
1805 item,
1806 );
1807 }
1808 }
1809
1810 fn add_type_only_dependencies(
1811 &mut self,
1812 items: &[fallow_types::output_dead_code::TypeOnlyDependencyFinding],
1813 ) {
1814 for item in items {
1815 self.insert_rule(
1816 AuditCollection::TypeOnlyDependencies,
1817 format!(
1818 "type-only-dependency:{}:{}",
1819 relative_key_path(&item.dep.path, self.root),
1820 item.dep.package_name
1821 ),
1822 item,
1823 );
1824 }
1825 }
1826
1827 fn add_test_only_dependencies(
1828 &mut self,
1829 items: &[fallow_types::output_dead_code::TestOnlyDependencyFinding],
1830 ) {
1831 for item in items {
1832 self.insert_rule(
1833 AuditCollection::TestOnlyDependencies,
1834 format!(
1835 "test-only-dependency:{}:{}",
1836 relative_key_path(&item.dep.path, self.root),
1837 item.dep.package_name
1838 ),
1839 item,
1840 );
1841 }
1842 }
1843
1844 fn add_dev_dependencies_in_production(
1845 &mut self,
1846 items: &[fallow_types::output_dead_code::DevDependencyInProductionFinding],
1847 ) {
1848 for item in items {
1849 self.insert_rule(
1850 AuditCollection::DevDependenciesInProduction,
1851 format!(
1852 "dev-dependency-in-production:{}:{}",
1853 relative_key_path(&item.dep.path, self.root),
1854 item.dep.package_name
1855 ),
1856 item,
1857 );
1858 }
1859 }
1860
1861 fn add_circular_dependencies(
1862 &mut self,
1863 items: &[fallow_types::output_dead_code::CircularDependencyFinding],
1864 ) {
1865 for item in items {
1866 self.insert_rule(
1867 AuditCollection::CircularDependencies,
1868 circular_dependency_key(item, self.root),
1869 item,
1870 );
1871 }
1872 }
1873
1874 fn add_re_export_cycles(
1875 &mut self,
1876 items: &[fallow_types::output_dead_code::ReExportCycleFinding],
1877 ) {
1878 for item in items {
1879 self.insert_rule(
1880 AuditCollection::ReExportCycles,
1881 re_export_cycle_key(item, self.root),
1882 item,
1883 );
1884 }
1885 }
1886
1887 fn add_boundary_violations(
1888 &mut self,
1889 items: &[fallow_types::output_dead_code::BoundaryViolationFinding],
1890 ) {
1891 for item in items {
1892 self.insert_rule(
1893 AuditCollection::BoundaryViolations,
1894 boundary_violation_key(item, self.root),
1895 item,
1896 );
1897 }
1898 }
1899
1900 fn add_boundary_coverage_violations(
1901 &mut self,
1902 items: &[fallow_types::output_dead_code::BoundaryCoverageViolationFinding],
1903 ) {
1904 for item in items {
1905 self.insert_rule(
1906 AuditCollection::BoundaryCoverageViolations,
1907 boundary_coverage_key(item, self.root),
1908 item,
1909 );
1910 }
1911 }
1912
1913 fn add_boundary_call_violations(
1914 &mut self,
1915 items: &[fallow_types::output_dead_code::BoundaryCallViolationFinding],
1916 ) {
1917 for item in items {
1918 self.insert_rule(
1919 AuditCollection::BoundaryCallViolations,
1920 boundary_call_key(item, self.root),
1921 item,
1922 );
1923 }
1924 }
1925
1926 fn add_policy_violations(
1927 &mut self,
1928 items: &[fallow_types::output_dead_code::PolicyViolationFinding],
1929 ) {
1930 for item in items {
1931 self.insert_rule(
1932 AuditCollection::PolicyViolations,
1933 policy_violation_key(item, self.root),
1934 item,
1935 );
1936 }
1937 }
1938
1939 fn add_stale_suppressions(&mut self, items: &[fallow_types::results::StaleSuppression]) {
1940 for item in items {
1941 self.insert_rule(
1942 AuditCollection::StaleSuppressions,
1943 stale_suppression_key(item, self.root),
1944 item,
1945 );
1946 }
1947 }
1948
1949 fn add_unresolved_catalog_references(
1950 &mut self,
1951 items: &[fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding],
1952 ) {
1953 for item in items {
1954 self.insert_rule(
1955 AuditCollection::UnresolvedCatalogReferences,
1956 unresolved_catalog_reference_key(item, self.root),
1957 item,
1958 );
1959 }
1960 }
1961
1962 fn add_unused_catalog_entries(
1963 &mut self,
1964 items: &[fallow_types::output_dead_code::UnusedCatalogEntryFinding],
1965 ) {
1966 for item in items {
1967 self.insert_rule(
1968 AuditCollection::UnusedCatalogEntries,
1969 unused_catalog_entry_key(&item.entry, self.root),
1970 item,
1971 );
1972 }
1973 }
1974
1975 fn add_empty_catalog_groups(
1976 &mut self,
1977 items: &[fallow_types::output_dead_code::EmptyCatalogGroupFinding],
1978 ) {
1979 for item in items {
1980 self.insert_rule(
1981 AuditCollection::EmptyCatalogGroups,
1982 empty_catalog_group_key(&item.group, self.root),
1983 item,
1984 );
1985 }
1986 }
1987
1988 fn add_unused_dependency_overrides(
1989 &mut self,
1990 items: &[fallow_types::output_dead_code::UnusedDependencyOverrideFinding],
1991 ) {
1992 for item in items {
1993 self.insert_rule(
1994 AuditCollection::UnusedDependencyOverrides,
1995 unused_dependency_override_key(item, self.root),
1996 item,
1997 );
1998 }
1999 }
2000
2001 fn add_misconfigured_dependency_overrides(
2002 &mut self,
2003 items: &[fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding],
2004 ) {
2005 for item in items {
2006 self.insert_rule(
2007 AuditCollection::MisconfiguredDependencyOverrides,
2008 misconfigured_dependency_override_key(item, self.root),
2009 item,
2010 );
2011 }
2012 }
2013}
2014
2015#[expect(
2037 clippy::implicit_hasher,
2038 reason = "fallow standardizes on FxHashSet across audit attribution keys"
2039)]
2040pub fn retain_introduced_dead_code(
2041 results: &mut fallow_types::results::AnalysisResults,
2042 root: &Path,
2043 base: Option<&FxHashSet<String>>,
2044) {
2045 let Some(base) = base else {
2046 return;
2047 };
2048
2049 let introduced = introduced_dead_code_keys(results, root, base);
2057 classify_introduced_dead_code_fields(results);
2058
2059 retain_introduced_fast_paths(
2064 &mut results.unused_files,
2065 &mut results.unused_exports,
2066 &mut results.unused_types,
2067 root,
2068 base,
2069 );
2070 retain_introduced_core_findings(results, root, &introduced);
2071 retain_introduced_dependency_and_graph_findings(results, root, &introduced);
2072 retain_introduced_workspace_findings(results, root, &introduced);
2073 retain_introduced_framework_findings(results, root, &introduced);
2074}
2075
2076fn introduced_dead_code_keys(
2077 results: &fallow_types::results::AnalysisResults,
2078 root: &Path,
2079 base: &FxHashSet<String>,
2080) -> FxHashSet<String> {
2081 dead_code_keys(results, root)
2082 .into_iter()
2083 .filter(|key| !base.contains(key))
2084 .collect()
2085}
2086
2087fn classify_introduced_dead_code_fields(results: &fallow_types::results::AnalysisResults) {
2088 let fallow_types::results::AnalysisResults {
2089 unused_files: _unused_files,
2090 unused_exports: _unused_exports,
2091 unused_types: _unused_types,
2092 private_type_leaks: _private_type_leaks,
2093 deprecated_exports_in_use: _deprecated_exports_in_use,
2094 unused_dependencies: _unused_dependencies,
2095 unused_dev_dependencies: _unused_dev_dependencies,
2096 unused_optional_dependencies: _unused_optional_dependencies,
2097 unused_enum_members: _unused_enum_members,
2098 unused_class_members: _unused_class_members,
2099 unused_store_members: _unused_store_members,
2100 unresolved_imports: _unresolved_imports,
2101 unlisted_dependencies: _unlisted_dependencies,
2102 duplicate_exports: _duplicate_exports,
2103 type_only_dependencies: _type_only_dependencies,
2104 test_only_dependencies: _test_only_dependencies,
2105 dev_dependencies_in_production: _dev_dependencies_in_production,
2106 circular_dependencies: _circular_dependencies,
2107 re_export_cycles: _re_export_cycles,
2108 boundary_violations: _boundary_violations,
2109 boundary_coverage_violations: _boundary_coverage_violations,
2110 boundary_call_violations: _boundary_call_violations,
2111 policy_violations: _policy_violations,
2112 stale_suppressions: _stale_suppressions,
2113 unused_catalog_entries: _unused_catalog_entries,
2114 empty_catalog_groups: _empty_catalog_groups,
2115 unresolved_catalog_references: _unresolved_catalog_references,
2116 unused_dependency_overrides: _unused_dependency_overrides,
2117 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
2118 invalid_client_exports: _invalid_client_exports,
2119 mixed_client_server_barrels: _mixed_client_server_barrels,
2120 misplaced_directives: _misplaced_directives,
2121 unprovided_injects: _unprovided_injects,
2122 unrendered_components: _unrendered_components,
2123 unused_component_props: _unused_component_props,
2124 unused_component_emits: _unused_component_emits,
2125 unused_component_inputs: _unused_component_inputs,
2126 unused_component_outputs: _unused_component_outputs,
2127 unused_svelte_events: _unused_svelte_events,
2128 unused_server_actions: _unused_server_actions,
2129 unused_load_data_keys: _unused_load_data_keys,
2130 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
2131 route_collisions: _route_collisions,
2132 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
2133 suppression_count: _suppression_count,
2136 unused_component_props_exempted: _unused_component_props_exempted,
2137 active_suppressions: _active_suppressions,
2138 feature_flags: _feature_flags,
2139 security_findings: _security_findings,
2143 security_unresolved_edge_files: _security_unresolved_edge_files,
2144 security_unresolved_callee_sites: _security_unresolved_callee_sites,
2145 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
2146 prop_drilling_chains: _prop_drilling_chains,
2149 thin_wrappers: _thin_wrappers,
2152 duplicate_prop_shapes: _duplicate_prop_shapes,
2155 export_usages: _export_usages,
2158 entry_point_summary: _entry_point_summary,
2159 render_fan_in: _render_fan_in,
2162 react_component_intel: _react_component_intel,
2165 semantic_framework_contracts: _semantic_framework_contracts,
2166 } = results;
2167}
2168
2169fn retain_introduced_fast_paths(
2170 unused_files: &mut Vec<fallow_types::output_dead_code::UnusedFileFinding>,
2171 unused_exports: &mut Vec<fallow_types::output_dead_code::UnusedExportFinding>,
2172 unused_types: &mut Vec<fallow_types::output_dead_code::UnusedTypeFinding>,
2173 root: &Path,
2174 base: &FxHashSet<String>,
2175) {
2176 unused_files.retain(|item| {
2177 !base.contains(&format!(
2178 "unused-file:{}",
2179 relative_key_path(&item.file.path, root)
2180 ))
2181 });
2182 unused_exports.retain(|item| {
2183 !base.contains(&format!(
2184 "unused-export:{}:{}",
2185 relative_key_path(&item.export.path, root),
2186 item.export.export_name
2187 ))
2188 });
2189 unused_types.retain(|item| {
2190 !base.contains(&format!(
2191 "unused-type:{}:{}",
2192 relative_key_path(&item.export.path, root),
2193 item.export.export_name
2194 ))
2195 });
2196}
2197
2198fn keep_introduced(introduced: &FxHashSet<String>, key: impl AsRef<str>) -> bool {
2199 introduced.contains(key.as_ref())
2200}
2201
2202fn deprecated_export_key(
2206 item: &fallow_types::results::DeprecatedExportInUse,
2207 root: &Path,
2208) -> String {
2209 format!(
2210 "deprecated-export-in-use:{}:{}",
2211 relative_key_path(&item.path, root),
2212 item.export_name
2213 )
2214}
2215
2216fn retain_introduced_core_findings(
2217 results: &mut fallow_types::results::AnalysisResults,
2218 root: &Path,
2219 introduced: &FxHashSet<String>,
2220) {
2221 results.private_type_leaks.retain(|item| {
2222 keep_introduced(
2223 introduced,
2224 format!(
2225 "private-type-leak:{}:{}:{}",
2226 relative_key_path(&item.leak.path, root),
2227 item.leak.export_name,
2228 item.leak.type_name
2229 ),
2230 )
2231 });
2232 results
2233 .deprecated_exports_in_use
2234 .retain(|item| keep_introduced(introduced, deprecated_export_key(&item.export, root)));
2235 results.unused_enum_members.retain(|item| {
2236 keep_introduced(
2237 introduced,
2238 unused_member_key("unused-enum-member", &item.member, root),
2239 )
2240 });
2241 results.unused_class_members.retain(|item| {
2242 keep_introduced(
2243 introduced,
2244 unused_member_key("unused-class-member", &item.member, root),
2245 )
2246 });
2247 results.unused_store_members.retain(|item| {
2248 keep_introduced(
2249 introduced,
2250 unused_member_key("unused-store-member", &item.member, root),
2251 )
2252 });
2253 results.unresolved_imports.retain(|item| {
2254 keep_introduced(
2255 introduced,
2256 format!(
2257 "unresolved-import:{}:{}",
2258 relative_key_path(&item.import.path, root),
2259 item.import.specifier
2260 ),
2261 )
2262 });
2263}
2264
2265fn retain_introduced_dependency_and_graph_findings(
2266 results: &mut fallow_types::results::AnalysisResults,
2267 root: &Path,
2268 introduced: &FxHashSet<String>,
2269) {
2270 results
2271 .unused_dependencies
2272 .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2273 results
2274 .unused_dev_dependencies
2275 .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2276 results
2277 .unused_optional_dependencies
2278 .retain(|item| keep_introduced(introduced, unused_dependency_key(&item.dep, root)));
2279 results
2280 .unlisted_dependencies
2281 .retain(|item| keep_introduced(introduced, unlisted_dependency_key(&item.dep, root)));
2282 results
2283 .duplicate_exports
2284 .retain(|item| keep_introduced(introduced, duplicate_export_key(item, root)));
2285 results.type_only_dependencies.retain(|item| {
2286 keep_introduced(
2287 introduced,
2288 format!(
2289 "type-only-dependency:{}:{}",
2290 relative_key_path(&item.dep.path, root),
2291 item.dep.package_name
2292 ),
2293 )
2294 });
2295 results.test_only_dependencies.retain(|item| {
2296 keep_introduced(
2297 introduced,
2298 format!(
2299 "test-only-dependency:{}:{}",
2300 relative_key_path(&item.dep.path, root),
2301 item.dep.package_name
2302 ),
2303 )
2304 });
2305 results
2306 .circular_dependencies
2307 .retain(|item| keep_introduced(introduced, circular_dependency_key(item, root)));
2308 results
2309 .re_export_cycles
2310 .retain(|item| keep_introduced(introduced, re_export_cycle_key(item, root)));
2311 results
2312 .boundary_violations
2313 .retain(|item| keep_introduced(introduced, boundary_violation_key(item, root)));
2314 results
2315 .boundary_coverage_violations
2316 .retain(|item| keep_introduced(introduced, boundary_coverage_key(item, root)));
2317 results
2318 .boundary_call_violations
2319 .retain(|item| keep_introduced(introduced, boundary_call_key(item, root)));
2320 results
2321 .policy_violations
2322 .retain(|item| keep_introduced(introduced, policy_violation_key(item, root)));
2323 results
2324 .stale_suppressions
2325 .retain(|item| keep_introduced(introduced, stale_suppression_key(item, root)));
2326}
2327
2328fn retain_introduced_workspace_findings(
2329 results: &mut fallow_types::results::AnalysisResults,
2330 root: &Path,
2331 introduced: &FxHashSet<String>,
2332) {
2333 results
2334 .unresolved_catalog_references
2335 .retain(|item| keep_introduced(introduced, unresolved_catalog_reference_key(item, root)));
2336 results
2337 .unused_catalog_entries
2338 .retain(|item| keep_introduced(introduced, unused_catalog_entry_key(&item.entry, root)));
2339 results
2340 .empty_catalog_groups
2341 .retain(|item| keep_introduced(introduced, empty_catalog_group_key(&item.group, root)));
2342 results
2343 .unused_dependency_overrides
2344 .retain(|item| keep_introduced(introduced, unused_dependency_override_key(item, root)));
2345 results.misconfigured_dependency_overrides.retain(|item| {
2346 keep_introduced(
2347 introduced,
2348 misconfigured_dependency_override_key(item, root),
2349 )
2350 });
2351}
2352
2353fn retain_introduced_framework_findings(
2354 results: &mut fallow_types::results::AnalysisResults,
2355 root: &Path,
2356 introduced: &FxHashSet<String>,
2357) {
2358 results
2359 .invalid_client_exports
2360 .retain(|item| keep_introduced(introduced, invalid_client_export_key(&item.export, root)));
2361 results.mixed_client_server_barrels.retain(|item| {
2362 keep_introduced(
2363 introduced,
2364 mixed_client_server_barrel_key(&item.barrel, root),
2365 )
2366 });
2367 results.misplaced_directives.retain(|item| {
2368 keep_introduced(
2369 introduced,
2370 misplaced_directive_key(&item.directive_site, root),
2371 )
2372 });
2373 results
2374 .unprovided_injects
2375 .retain(|item| keep_introduced(introduced, unprovided_inject_key(&item.inject, root)));
2376 results.unrendered_components.retain(|item| {
2377 keep_introduced(introduced, unrendered_component_key(&item.component, root))
2378 });
2379 results
2380 .unused_component_props
2381 .retain(|item| keep_introduced(introduced, unused_component_prop_key(&item.prop, root)));
2382 results
2383 .unused_component_emits
2384 .retain(|item| keep_introduced(introduced, unused_component_emit_key(&item.emit, root)));
2385 results
2386 .unused_component_inputs
2387 .retain(|item| keep_introduced(introduced, unused_component_input_key(&item.input, root)));
2388 results.unused_component_outputs.retain(|item| {
2389 keep_introduced(introduced, unused_component_output_key(&item.output, root))
2390 });
2391 results
2392 .unused_svelte_events
2393 .retain(|item| keep_introduced(introduced, unused_svelte_event_key(&item.event, root)));
2394 results
2395 .unused_server_actions
2396 .retain(|item| keep_introduced(introduced, unused_server_action_key(&item.action, root)));
2397 results
2398 .unused_load_data_keys
2399 .retain(|item| keep_introduced(introduced, unused_load_data_key_key(&item.key, root)));
2400 results
2401 .route_collisions
2402 .retain(|item| keep_introduced(introduced, route_collision_key(&item.collision, root)));
2403 results.dynamic_segment_name_conflicts.retain(|item| {
2404 keep_introduced(
2405 introduced,
2406 dynamic_segment_name_conflict_key(&item.conflict, root),
2407 )
2408 });
2409}
2410
2411fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
2412 !base.contains(key)
2413}
2414
2415fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
2416where
2417 I: IntoIterator<Item = bool>,
2418{
2419 let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
2420 return;
2421 };
2422 for (item, introduced) in items.iter_mut().zip(introduced) {
2423 if let serde_json::Value::Object(map) = item {
2424 map.insert("introduced".to_string(), serde_json::json!(introduced));
2425 }
2426 }
2427}
2428
2429#[expect(
2434 clippy::implicit_hasher,
2435 reason = "fallow standardizes on FxHashSet across audit attribution keys"
2436)]
2437pub fn annotate_dead_code_json(
2438 json: &mut serde_json::Value,
2439 results: &fallow_types::results::AnalysisResults,
2440 root: &Path,
2441 base: &FxHashSet<String>,
2442) {
2443 let mut annotator = DeadCodeJsonAnnotator {
2444 json,
2445 results,
2446 root,
2447 base,
2448 };
2449 annotator.annotate_file_symbols();
2450 annotator.annotate_dependencies();
2451 annotator.annotate_members();
2452 annotator.annotate_imports_and_exports();
2453 annotator.annotate_graph();
2454 annotator.annotate_catalog();
2455}
2456
2457#[expect(
2461 clippy::implicit_hasher,
2462 reason = "fallow standardizes on FxHashSet across audit attribution keys"
2463)]
2464pub fn annotate_stale_suppressions_json(
2465 json: &mut serde_json::Value,
2466 results: &fallow_types::results::AnalysisResults,
2467 root: &Path,
2468 base: &FxHashSet<String>,
2469) {
2470 annotate_issue_array(
2471 json,
2472 "stale_suppressions",
2473 results
2474 .stale_suppressions
2475 .iter()
2476 .map(|item| issue_was_introduced(&stale_suppression_key(item, root), base)),
2477 );
2478}
2479
2480struct DeadCodeJsonAnnotator<'a> {
2481 json: &'a mut serde_json::Value,
2482 results: &'a fallow_types::results::AnalysisResults,
2483 root: &'a Path,
2484 base: &'a FxHashSet<String>,
2485}
2486
2487impl DeadCodeJsonAnnotator<'_> {
2488 fn annotate_file_symbols(&mut self) {
2489 annotate_issue_array(
2490 self.json,
2491 "unused_files",
2492 self.results.unused_files.iter().map(|item| {
2493 issue_was_introduced(
2494 &format!(
2495 "unused-file:{}",
2496 relative_key_path(&item.file.path, self.root)
2497 ),
2498 self.base,
2499 )
2500 }),
2501 );
2502 annotate_issue_array(
2503 self.json,
2504 "unused_exports",
2505 self.results.unused_exports.iter().map(|item| {
2506 issue_was_introduced(
2507 &format!(
2508 "unused-export:{}:{}",
2509 relative_key_path(&item.export.path, self.root),
2510 item.export.export_name
2511 ),
2512 self.base,
2513 )
2514 }),
2515 );
2516 annotate_issue_array(
2517 self.json,
2518 "unused_types",
2519 self.results.unused_types.iter().map(|item| {
2520 issue_was_introduced(
2521 &format!(
2522 "unused-type:{}:{}",
2523 relative_key_path(&item.export.path, self.root),
2524 item.export.export_name
2525 ),
2526 self.base,
2527 )
2528 }),
2529 );
2530 annotate_issue_array(
2531 self.json,
2532 "private_type_leaks",
2533 self.results.private_type_leaks.iter().map(|item| {
2534 issue_was_introduced(
2535 &format!(
2536 "private-type-leak:{}:{}:{}",
2537 relative_key_path(&item.leak.path, self.root),
2538 item.leak.export_name,
2539 item.leak.type_name
2540 ),
2541 self.base,
2542 )
2543 }),
2544 );
2545 annotate_issue_array(
2546 self.json,
2547 "deprecated_exports_in_use",
2548 self.results.deprecated_exports_in_use.iter().map(|item| {
2549 issue_was_introduced(&deprecated_export_key(&item.export, self.root), self.base)
2550 }),
2551 );
2552 }
2553
2554 fn annotate_dependencies(&mut self) {
2555 annotate_dependency_json(self.json, self.results, self.root, self.base);
2556 annotate_issue_array(
2557 self.json,
2558 "type_only_dependencies",
2559 self.results.type_only_dependencies.iter().map(|item| {
2560 issue_was_introduced(
2561 &format!(
2562 "type-only-dependency:{}:{}",
2563 relative_key_path(&item.dep.path, self.root),
2564 item.dep.package_name
2565 ),
2566 self.base,
2567 )
2568 }),
2569 );
2570 annotate_issue_array(
2571 self.json,
2572 "test_only_dependencies",
2573 self.results.test_only_dependencies.iter().map(|item| {
2574 issue_was_introduced(
2575 &format!(
2576 "test-only-dependency:{}:{}",
2577 relative_key_path(&item.dep.path, self.root),
2578 item.dep.package_name
2579 ),
2580 self.base,
2581 )
2582 }),
2583 );
2584 }
2585
2586 fn annotate_members(&mut self) {
2587 annotate_member_json(self.json, self.results, self.root, self.base);
2588 }
2589
2590 fn annotate_imports_and_exports(&mut self) {
2591 self.annotate_import_dependency_keys();
2592 self.annotate_framework_keys();
2593 self.annotate_component_keys();
2594 self.annotate_route_keys();
2595 }
2596
2597 fn annotate_import_dependency_keys(&mut self) {
2598 annotate_issue_array(
2599 self.json,
2600 "unresolved_imports",
2601 self.results.unresolved_imports.iter().map(|item| {
2602 issue_was_introduced(
2603 &format!(
2604 "unresolved-import:{}:{}",
2605 relative_key_path(&item.import.path, self.root),
2606 item.import.specifier
2607 ),
2608 self.base,
2609 )
2610 }),
2611 );
2612 annotate_issue_array(
2613 self.json,
2614 "unlisted_dependencies",
2615 self.results.unlisted_dependencies.iter().map(|item| {
2616 issue_was_introduced(&unlisted_dependency_key(&item.dep, self.root), self.base)
2617 }),
2618 );
2619 annotate_issue_array(
2620 self.json,
2621 "duplicate_exports",
2622 self.results.duplicate_exports.iter().map(|item| {
2623 let mut locations: Vec<String> = item
2624 .export
2625 .locations
2626 .iter()
2627 .map(|loc| relative_key_path(&loc.path, self.root))
2628 .collect();
2629 locations.sort();
2630 locations.dedup();
2631 issue_was_introduced(
2632 &format!(
2633 "duplicate-export:{}:{}",
2634 item.export.export_name,
2635 locations.join("|")
2636 ),
2637 self.base,
2638 )
2639 }),
2640 );
2641 }
2642
2643 fn annotate_framework_keys(&mut self) {
2644 annotate_issue_array(
2645 self.json,
2646 "invalid_client_exports",
2647 self.results.invalid_client_exports.iter().map(|item| {
2648 issue_was_introduced(
2649 &invalid_client_export_key(&item.export, self.root),
2650 self.base,
2651 )
2652 }),
2653 );
2654 annotate_issue_array(
2655 self.json,
2656 "mixed_client_server_barrels",
2657 self.results.mixed_client_server_barrels.iter().map(|item| {
2658 issue_was_introduced(
2659 &mixed_client_server_barrel_key(&item.barrel, self.root),
2660 self.base,
2661 )
2662 }),
2663 );
2664 annotate_issue_array(
2665 self.json,
2666 "misplaced_directives",
2667 self.results.misplaced_directives.iter().map(|item| {
2668 issue_was_introduced(
2669 &misplaced_directive_key(&item.directive_site, self.root),
2670 self.base,
2671 )
2672 }),
2673 );
2674 annotate_issue_array(
2675 self.json,
2676 "unprovided_injects",
2677 self.results.unprovided_injects.iter().map(|item| {
2678 issue_was_introduced(&unprovided_inject_key(&item.inject, self.root), self.base)
2679 }),
2680 );
2681 }
2682
2683 fn annotate_component_keys(&mut self) {
2684 self.annotate_component_render_keys();
2685 self.annotate_component_io_keys();
2686 }
2687
2688 fn annotate_component_render_keys(&mut self) {
2690 annotate_issue_array(
2691 self.json,
2692 "unrendered_components",
2693 self.results.unrendered_components.iter().map(|item| {
2694 issue_was_introduced(
2695 &unrendered_component_key(&item.component, self.root),
2696 self.base,
2697 )
2698 }),
2699 );
2700 annotate_issue_array(
2701 self.json,
2702 "unused_component_props",
2703 self.results.unused_component_props.iter().map(|item| {
2704 issue_was_introduced(&unused_component_prop_key(&item.prop, self.root), self.base)
2705 }),
2706 );
2707 annotate_issue_array(
2708 self.json,
2709 "unused_component_emits",
2710 self.results.unused_component_emits.iter().map(|item| {
2711 issue_was_introduced(&unused_component_emit_key(&item.emit, self.root), self.base)
2712 }),
2713 );
2714 }
2715
2716 fn annotate_component_io_keys(&mut self) {
2718 annotate_issue_array(
2719 self.json,
2720 "unused_component_inputs",
2721 self.results.unused_component_inputs.iter().map(|item| {
2722 issue_was_introduced(
2723 &unused_component_input_key(&item.input, self.root),
2724 self.base,
2725 )
2726 }),
2727 );
2728 annotate_issue_array(
2729 self.json,
2730 "unused_component_outputs",
2731 self.results.unused_component_outputs.iter().map(|item| {
2732 issue_was_introduced(
2733 &unused_component_output_key(&item.output, self.root),
2734 self.base,
2735 )
2736 }),
2737 );
2738 annotate_issue_array(
2739 self.json,
2740 "unused_svelte_events",
2741 self.results.unused_svelte_events.iter().map(|item| {
2742 issue_was_introduced(&unused_svelte_event_key(&item.event, self.root), self.base)
2743 }),
2744 );
2745 annotate_issue_array(
2746 self.json,
2747 "unused_server_actions",
2748 self.results.unused_server_actions.iter().map(|item| {
2749 issue_was_introduced(
2750 &unused_server_action_key(&item.action, self.root),
2751 self.base,
2752 )
2753 }),
2754 );
2755 }
2756
2757 fn annotate_route_keys(&mut self) {
2758 annotate_issue_array(
2759 self.json,
2760 "route_collisions",
2761 self.results.route_collisions.iter().map(|item| {
2762 issue_was_introduced(&route_collision_key(&item.collision, self.root), self.base)
2763 }),
2764 );
2765 annotate_issue_array(
2766 self.json,
2767 "dynamic_segment_name_conflicts",
2768 self.results
2769 .dynamic_segment_name_conflicts
2770 .iter()
2771 .map(|item| {
2772 issue_was_introduced(
2773 &dynamic_segment_name_conflict_key(&item.conflict, self.root),
2774 self.base,
2775 )
2776 }),
2777 );
2778 }
2779
2780 fn annotate_graph(&mut self) {
2781 annotate_graph_json(self.json, self.results, self.root, self.base);
2782 }
2783
2784 fn annotate_catalog(&mut self) {
2785 annotate_catalog_json(self.json, self.results, self.root, self.base);
2786 }
2787}
2788
2789fn annotate_dependency_json(
2790 json: &mut serde_json::Value,
2791 results: &fallow_types::results::AnalysisResults,
2792 root: &Path,
2793 base: &FxHashSet<String>,
2794) {
2795 annotate_issue_array(
2796 json,
2797 "unused_dependencies",
2798 results
2799 .unused_dependencies
2800 .iter()
2801 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2802 );
2803 annotate_issue_array(
2804 json,
2805 "unused_dev_dependencies",
2806 results
2807 .unused_dev_dependencies
2808 .iter()
2809 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2810 );
2811 annotate_issue_array(
2812 json,
2813 "unused_optional_dependencies",
2814 results
2815 .unused_optional_dependencies
2816 .iter()
2817 .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2818 );
2819}
2820
2821fn annotate_member_json(
2822 json: &mut serde_json::Value,
2823 results: &fallow_types::results::AnalysisResults,
2824 root: &Path,
2825 base: &FxHashSet<String>,
2826) {
2827 annotate_issue_array(
2828 json,
2829 "unused_enum_members",
2830 results.unused_enum_members.iter().map(|item| {
2831 issue_was_introduced(
2832 &unused_member_key("unused-enum-member", &item.member, root),
2833 base,
2834 )
2835 }),
2836 );
2837 annotate_issue_array(
2838 json,
2839 "unused_class_members",
2840 results.unused_class_members.iter().map(|item| {
2841 issue_was_introduced(
2842 &unused_member_key("unused-class-member", &item.member, root),
2843 base,
2844 )
2845 }),
2846 );
2847 annotate_issue_array(
2848 json,
2849 "unused_store_members",
2850 results.unused_store_members.iter().map(|item| {
2851 issue_was_introduced(
2852 &unused_member_key("unused-store-member", &item.member, root),
2853 base,
2854 )
2855 }),
2856 );
2857}
2858
2859fn annotate_graph_json(
2860 json: &mut serde_json::Value,
2861 results: &fallow_types::results::AnalysisResults,
2862 root: &Path,
2863 base: &FxHashSet<String>,
2864) {
2865 annotate_cycle_json(json, results, root, base);
2866 annotate_boundary_json(json, results, root, base);
2867 annotate_policy_json(json, results, root, base);
2868}
2869
2870fn annotate_cycle_json(
2871 json: &mut serde_json::Value,
2872 results: &fallow_types::results::AnalysisResults,
2873 root: &Path,
2874 base: &FxHashSet<String>,
2875) {
2876 annotate_issue_array(
2877 json,
2878 "circular_dependencies",
2879 results.circular_dependencies.iter().map(|item| {
2880 let mut files: Vec<String> = item
2881 .cycle
2882 .files
2883 .iter()
2884 .map(|path| relative_key_path(path, root))
2885 .collect();
2886 files.sort();
2887 issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
2888 }),
2889 );
2890 annotate_issue_array(
2891 json,
2892 "re_export_cycles",
2893 results.re_export_cycles.iter().map(|item| {
2894 let kind = match item.cycle.kind {
2895 fallow_types::results::ReExportCycleKind::MultiNode => "multi-node",
2896 fallow_types::results::ReExportCycleKind::SelfLoop => "self-loop",
2897 };
2898 let mut files: Vec<String> = item
2899 .cycle
2900 .files
2901 .iter()
2902 .map(|path| relative_key_path(path, root))
2903 .collect();
2904 files.sort();
2905 issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
2906 }),
2907 );
2908}
2909
2910fn annotate_boundary_json(
2911 json: &mut serde_json::Value,
2912 results: &fallow_types::results::AnalysisResults,
2913 root: &Path,
2914 base: &FxHashSet<String>,
2915) {
2916 annotate_issue_array(
2917 json,
2918 "boundary_violations",
2919 results.boundary_violations.iter().map(|item| {
2920 issue_was_introduced(
2921 &format!(
2922 "boundary-violation:{}:{}:{}",
2923 relative_key_path(&item.violation.from_path, root),
2924 relative_key_path(&item.violation.to_path, root),
2925 item.violation.import_specifier
2926 ),
2927 base,
2928 )
2929 }),
2930 );
2931 annotate_issue_array(
2932 json,
2933 "boundary_coverage_violations",
2934 results.boundary_coverage_violations.iter().map(|item| {
2935 issue_was_introduced(
2936 &format!(
2937 "boundary-coverage:{}",
2938 relative_key_path(&item.violation.path, root)
2939 ),
2940 base,
2941 )
2942 }),
2943 );
2944 annotate_issue_array(
2945 json,
2946 "boundary_call_violations",
2947 results.boundary_call_violations.iter().map(|item| {
2948 issue_was_introduced(
2949 &format!(
2950 "boundary-call:{}:{}",
2951 relative_key_path(&item.violation.path, root),
2952 item.violation.callee
2953 ),
2954 base,
2955 )
2956 }),
2957 );
2958}
2959
2960fn annotate_policy_json(
2961 json: &mut serde_json::Value,
2962 results: &fallow_types::results::AnalysisResults,
2963 root: &Path,
2964 base: &FxHashSet<String>,
2965) {
2966 annotate_issue_array(
2967 json,
2968 "policy_violations",
2969 results.policy_violations.iter().map(|item| {
2970 issue_was_introduced(
2971 &format!(
2972 "policy-violation:{}:{}/{}:{}",
2973 relative_key_path(&item.violation.path, root),
2974 item.violation.pack,
2975 item.violation.rule_id,
2976 item.violation.matched
2977 ),
2978 base,
2979 )
2980 }),
2981 );
2982 annotate_issue_array(
2983 json,
2984 "stale_suppressions",
2985 results
2986 .stale_suppressions
2987 .iter()
2988 .map(|item| issue_was_introduced(&stale_suppression_key(item, root), base)),
2989 );
2990}
2991
2992fn annotate_catalog_json(
2993 json: &mut serde_json::Value,
2994 results: &fallow_types::results::AnalysisResults,
2995 root: &Path,
2996 base: &FxHashSet<String>,
2997) {
2998 annotate_catalog_entry_json(json, results, root, base);
2999 annotate_dependency_override_json(json, results, root, base);
3000}
3001
3002fn annotate_catalog_entry_json(
3004 json: &mut serde_json::Value,
3005 results: &fallow_types::results::AnalysisResults,
3006 root: &Path,
3007 base: &FxHashSet<String>,
3008) {
3009 annotate_issue_array(
3010 json,
3011 "unresolved_catalog_references",
3012 results.unresolved_catalog_references.iter().map(|item| {
3013 issue_was_introduced(
3014 &format!(
3015 "unresolved-catalog-reference:{}:{}:{}:{}",
3016 relative_key_path(&item.reference.path, root),
3017 item.reference.line,
3018 item.reference.catalog_name,
3019 item.reference.entry_name
3020 ),
3021 base,
3022 )
3023 }),
3024 );
3025 annotate_issue_array(
3026 json,
3027 "unused_catalog_entries",
3028 results
3029 .unused_catalog_entries
3030 .iter()
3031 .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
3032 );
3033 annotate_issue_array(
3034 json,
3035 "empty_catalog_groups",
3036 results
3037 .empty_catalog_groups
3038 .iter()
3039 .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
3040 );
3041}
3042
3043fn annotate_dependency_override_json(
3045 json: &mut serde_json::Value,
3046 results: &fallow_types::results::AnalysisResults,
3047 root: &Path,
3048 base: &FxHashSet<String>,
3049) {
3050 annotate_issue_array(
3051 json,
3052 "unused_dependency_overrides",
3053 results.unused_dependency_overrides.iter().map(|item| {
3054 issue_was_introduced(
3055 &format!(
3056 "unused-dependency-override:{}:{}:{}",
3057 relative_key_path(&item.entry.path, root),
3058 item.entry.line,
3059 item.entry.raw_key
3060 ),
3061 base,
3062 )
3063 }),
3064 );
3065 annotate_issue_array(
3066 json,
3067 "misconfigured_dependency_overrides",
3068 results
3069 .misconfigured_dependency_overrides
3070 .iter()
3071 .map(|item| {
3072 issue_was_introduced(
3073 &format!(
3074 "misconfigured-dependency-override:{}:{}:{}",
3075 relative_key_path(&item.entry.path, root),
3076 item.entry.line,
3077 item.entry.raw_key
3078 ),
3079 base,
3080 )
3081 }),
3082 );
3083}
3084
3085#[expect(
3089 clippy::implicit_hasher,
3090 reason = "fallow standardizes on FxHashSet across audit attribution keys"
3091)]
3092pub fn annotate_health_json(
3093 json: &mut serde_json::Value,
3094 report: &fallow_output::HealthReport,
3095 root: &Path,
3096 base: &FxHashSet<String>,
3097) {
3098 if let Some(items) = json
3099 .get_mut("findings")
3100 .and_then(serde_json::Value::as_array_mut)
3101 {
3102 for (item, finding) in items.iter_mut().zip(&report.findings) {
3103 if let serde_json::Value::Object(map) = item {
3104 map.insert(
3105 "introduced".to_string(),
3106 serde_json::json!(issue_was_introduced(
3107 &health_finding_key(finding, root),
3108 base
3109 )),
3110 );
3111 }
3112 }
3113 }
3114 if let Some(items) = json
3115 .get_mut("styling_findings")
3116 .and_then(serde_json::Value::as_array_mut)
3117 {
3118 for (item, finding) in items.iter_mut().zip(&report.styling_findings) {
3119 if let serde_json::Value::Object(map) = item {
3120 map.insert(
3121 "introduced".to_string(),
3122 serde_json::json!(issue_was_introduced(
3123 &styling_finding_key(finding, root),
3124 base
3125 )),
3126 );
3127 }
3128 }
3129 }
3130}
3131
3132#[expect(
3135 clippy::implicit_hasher,
3136 reason = "fallow standardizes on FxHashSet across audit attribution keys"
3137)]
3138pub fn annotate_dupes_json(
3139 json: &mut serde_json::Value,
3140 report: &fallow_types::duplicates::DuplicationReport,
3141 root: &Path,
3142 base: &FxHashSet<String>,
3143) {
3144 let Some(items) = json
3145 .get_mut("clone_groups")
3146 .and_then(serde_json::Value::as_array_mut)
3147 else {
3148 return;
3149 };
3150 for (item, group) in items.iter_mut().zip(&report.clone_groups) {
3151 if let serde_json::Value::Object(map) = item {
3152 map.insert(
3153 "introduced".to_string(),
3154 serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
3155 );
3156 }
3157 }
3158}
3159
3160pub fn annotate_domain_json(
3162 json: &mut serde_json::Value,
3163 collection: &str,
3164 introduced: impl IntoIterator<Item = bool>,
3165) {
3166 annotate_issue_array(json, collection, introduced);
3167}
3168
3169pub fn annotate_domain_demotions_json(
3174 json: &mut serde_json::Value,
3175 collection: &str,
3176 demoted: impl IntoIterator<Item = bool>,
3177 reason: crate::CloneDemotionReason,
3178) {
3179 let Some(items) = json
3180 .get_mut(collection)
3181 .and_then(serde_json::Value::as_array_mut)
3182 else {
3183 return;
3184 };
3185 for (item, demoted) in items.iter_mut().zip(demoted) {
3186 if demoted && let serde_json::Value::Object(map) = item {
3187 map.insert("demotion_reason".to_string(), serde_json::json!(reason));
3188 }
3189 }
3190}
3191
3192pub fn health_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
3194 report
3195 .findings
3196 .iter()
3197 .map(|finding| health_finding_key(finding, root))
3198 .collect()
3199}
3200
3201pub fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
3205 format!(
3206 "complexity:{}:{}:{:?}",
3207 relative_key_path(Path::new(&finding.path), root),
3208 finding.name,
3209 finding.exceeded
3210 )
3211}
3212
3213pub fn styling_keys(report: &fallow_output::HealthReport, root: &Path) -> FxHashSet<String> {
3215 report
3216 .styling_findings
3217 .iter()
3218 .map(|finding| styling_finding_key(finding, root))
3219 .collect()
3220}
3221
3222pub fn styling_finding_key(finding: &fallow_output::StylingFinding, root: &Path) -> String {
3225 format!(
3226 "styling:{}:{}:{}:{}:{}",
3227 finding.code,
3228 finding.sub_kind,
3229 relative_key_path(Path::new(&finding.path), root),
3230 finding.line,
3231 finding.value
3232 )
3233}
3234
3235pub fn dupes_keys(
3237 report: &fallow_types::duplicates::DuplicationReport,
3238 root: &Path,
3239) -> FxHashSet<String> {
3240 report
3241 .clone_groups
3242 .iter()
3243 .map(|group| dupe_group_key(group, root))
3244 .collect()
3245}
3246
3247pub fn dupe_group_key(group: &fallow_types::duplicates::CloneGroup, root: &Path) -> String {
3252 let mut files: Vec<String> = group
3253 .instances
3254 .iter()
3255 .map(|instance| relative_key_path(&instance.file, root))
3256 .collect();
3257 files.sort();
3258 files.dedup();
3259 let mut hasher = DefaultHasher::new();
3260 for instance in &group.instances {
3261 instance.fragment.hash(&mut hasher);
3262 }
3263 format!(
3264 "dupe:{}:{}:{}:{:x}",
3265 files.join("|"),
3266 group.token_count,
3267 group.line_count,
3268 hasher.finish()
3269 )
3270}
3271
3272pub fn preexisting_dupe_group_keys<'a>(
3282 groups: impl IntoIterator<Item = &'a fallow_types::duplicates::CloneGroup>,
3283 root: &Path,
3284 diff: &fallow_output::DiffIndex,
3285) -> FxHashSet<String> {
3286 let instance_touched = |instance: &fallow_types::duplicates::CloneInstance| -> bool {
3287 let Some(rel) = diff.key_for(&instance.file, root) else {
3288 return true;
3289 };
3290 let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
3291 let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
3292 diff.range_overlaps_added(&rel, start, end)
3293 };
3294 groups
3295 .into_iter()
3296 .filter(|group| !group.instances.iter().any(instance_touched))
3297 .map(|group| dupe_group_key(group, root))
3298 .collect()
3299}
3300
3301#[cfg(test)]
3302mod tests {
3303 use std::path::{Path, PathBuf};
3304
3305 use fallow_config::{FallowConfig, Severity};
3306 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationReport};
3307 use fallow_types::envelope::AuditIntroduced;
3308 use fallow_types::extract::MemberKind;
3309 use fallow_types::output_dead_code::*;
3310 use fallow_types::output_format::OutputFormat;
3311 use fallow_types::results::*;
3312 use rustc_hash::FxHashSet;
3313 use serde_json::json;
3314
3315 use fallow_output::{
3316 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
3317 };
3318
3319 use super::{
3320 AuditDomainLedger, annotate_dead_code_json, annotate_dupes_json, annotate_health_json,
3321 annotate_stale_suppressions_json, dead_code_audit_ledger, dead_code_keys, dupe_group_key,
3322 dupes_keys, health_finding_key, health_keys, relative_key_path, remap_keys_for_renames,
3323 retain_introduced_dead_code,
3324 };
3325
3326 fn root() -> PathBuf {
3327 PathBuf::from("/repo")
3328 }
3329
3330 fn export(path: &Path, name: &str) -> UnusedExportFinding {
3331 UnusedExportFinding::with_actions(UnusedExport {
3332 path: path.to_path_buf(),
3333 export_name: name.to_string(),
3334 is_type_only: false,
3335 line: 1,
3336 col: 0,
3337 span_start: 0,
3338 is_re_export: false,
3339 deprecated: false,
3340 deprecated_reason: None,
3341 })
3342 }
3343
3344 fn unused_file(path: &Path) -> UnusedFileFinding {
3345 UnusedFileFinding::with_actions(UnusedFile {
3346 path: path.to_path_buf(),
3347 })
3348 }
3349
3350 fn dependency(path: &Path, package_name: &str) -> UnusedDependencyFinding {
3351 UnusedDependencyFinding::with_actions(UnusedDependency {
3352 package_name: package_name.to_string(),
3353 location: DependencyLocation::Dependencies,
3354 path: path.to_path_buf(),
3355 line: 4,
3356 used_in_workspaces: Vec::new(),
3357 })
3358 }
3359
3360 fn unresolved(path: &Path, specifier: &str) -> UnresolvedImportFinding {
3361 UnresolvedImportFinding::with_actions(UnresolvedImport {
3362 path: path.to_path_buf(),
3363 specifier: specifier.to_string(),
3364 line: 2,
3365 col: 1,
3366 specifier_col: 8,
3367 })
3368 }
3369
3370 fn unlisted(path: &Path, package_name: &str) -> UnlistedDependencyFinding {
3371 UnlistedDependencyFinding::with_actions(UnlistedDependency {
3372 package_name: package_name.to_string(),
3373 imported_from: vec![
3374 ImportSite {
3375 path: path.to_path_buf(),
3376 line: 9,
3377 col: 2,
3378 },
3379 ImportSite {
3380 path: path.to_path_buf(),
3381 line: 9,
3382 col: 2,
3383 },
3384 ],
3385 })
3386 }
3387
3388 fn duplicate_export(root: &Path) -> DuplicateExportFinding {
3389 DuplicateExportFinding::with_actions(DuplicateExport {
3390 export_name: "Button".to_string(),
3391 locations: vec![
3392 DuplicateLocation {
3393 path: root.join("src/b.ts"),
3394 line: 1,
3395 col: 0,
3396 },
3397 DuplicateLocation {
3398 path: root.join("src/a.ts"),
3399 line: 1,
3400 col: 0,
3401 },
3402 DuplicateLocation {
3403 path: root.join("src/a.ts"),
3404 line: 2,
3405 col: 0,
3406 },
3407 ],
3408 })
3409 }
3410
3411 fn deprecated_export(
3412 path: &Path,
3413 name: &str,
3414 ) -> fallow_types::output_dead_code::DeprecatedExportInUseFinding {
3415 fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
3416 fallow_types::results::DeprecatedExportInUse {
3417 path: path.to_path_buf(),
3418 export_name: name.to_string(),
3419 is_type_only: false,
3420 line: 9,
3421 col: 13,
3422 span_start: 90,
3423 deprecated_reason: None,
3424 consumer_count: 1,
3425 consumers: Vec::new(),
3426 public_api: false,
3427 },
3428 )
3429 }
3430
3431 fn sample_results(root: &Path) -> AnalysisResults {
3432 let source = root.join("src/page.ts");
3433 let package_json = root.join("package.json");
3434 let mut results = AnalysisResults::default();
3435 results
3436 .unused_files
3437 .push(unused_file(&root.join("src/dead.ts")));
3438 results.unused_exports.push(export(&source, "loader"));
3439 results
3440 .unused_dependencies
3441 .push(dependency(&package_json, "left-pad"));
3442 results
3443 .unresolved_imports
3444 .push(unresolved(&source, "./missing"));
3445 results.unlisted_dependencies.push(unlisted(&source, "zod"));
3446 results
3447 .deprecated_exports_in_use
3448 .push(deprecated_export(&source, "legacyLoader"));
3449 results.duplicate_exports.push(duplicate_export(root));
3450 results
3451 }
3452
3453 #[test]
3454 fn relative_key_path_strips_root_and_normalizes_separators() {
3455 let path = Path::new("/repo/src\\feature\\index.ts");
3456 assert_eq!(
3457 relative_key_path(path, Path::new("/repo")),
3458 "src/feature/index.ts"
3459 );
3460 }
3461
3462 #[test]
3463 fn dead_code_keys_are_stable_for_unsorted_and_duplicate_locations() {
3464 let root = root();
3465 let keys = dead_code_keys(&sample_results(&root), &root);
3466
3467 assert!(keys.contains("unused-file:src/dead.ts"));
3468 assert!(keys.contains("unused-export:src/page.ts:loader"));
3469 assert!(keys.contains("unused-dependency:package.json:left-pad"));
3470 assert!(keys.contains("unresolved-import:src/page.ts:./missing"));
3471 assert!(keys.contains("unlisted-dependency:zod:src/page.ts:9:2"));
3472 assert!(keys.contains("duplicate-export:Button:src/a.ts|src/b.ts"));
3473 }
3474
3475 fn type_member_and_dependency_results(root: &Path) -> AnalysisResults {
3476 let source = root.join("src/types.ts");
3477 let package_json = root.join("package.json");
3478 let mut results = AnalysisResults::default();
3479 results
3480 .unused_types
3481 .push(UnusedTypeFinding::with_actions(UnusedExport {
3482 path: source.clone(),
3483 export_name: "UnusedType".to_string(),
3484 is_type_only: true,
3485 line: 3,
3486 col: 0,
3487 span_start: 12,
3488 is_re_export: false,
3489 deprecated: false,
3490 deprecated_reason: None,
3491 }));
3492 results
3493 .private_type_leaks
3494 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3495 path: source.clone(),
3496 export_name: "makePublic".to_string(),
3497 type_name: "PrivateShape".to_string(),
3498 line: 7,
3499 col: 12,
3500 span_start: 64,
3501 semantic: None,
3502 }));
3503 results
3504 .unused_dev_dependencies
3505 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3506 package_name: "vite".to_string(),
3507 location: DependencyLocation::DevDependencies,
3508 path: package_json.clone(),
3509 line: 10,
3510 used_in_workspaces: Vec::new(),
3511 }));
3512 results
3513 .unused_optional_dependencies
3514 .push(UnusedOptionalDependencyFinding::with_actions(
3515 UnusedDependency {
3516 package_name: "fsevents".to_string(),
3517 location: DependencyLocation::OptionalDependencies,
3518 path: package_json.clone(),
3519 line: 11,
3520 used_in_workspaces: Vec::new(),
3521 },
3522 ));
3523 results
3524 .unused_enum_members
3525 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3526 path: source.clone(),
3527 parent_name: "Status".to_string(),
3528 member_name: "Idle".to_string(),
3529 kind: MemberKind::EnumMember,
3530 line: 15,
3531 col: 2,
3532 }));
3533 results
3534 .unused_class_members
3535 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3536 path: source,
3537 parent_name: "Controller".to_string(),
3538 member_name: "legacy".to_string(),
3539 kind: MemberKind::ClassMethod,
3540 line: 21,
3541 col: 2,
3542 }));
3543 results
3544 .type_only_dependencies
3545 .push(TypeOnlyDependencyFinding::with_actions(
3546 TypeOnlyDependency {
3547 package_name: "zod".to_string(),
3548 path: package_json.clone(),
3549 line: 12,
3550 },
3551 ));
3552 results
3553 .test_only_dependencies
3554 .push(TestOnlyDependencyFinding::with_actions(
3555 TestOnlyDependency {
3556 package_name: "vitest".to_string(),
3557 path: package_json,
3558 line: 13,
3559 },
3560 ));
3561
3562 results
3563 }
3564
3565 #[test]
3566 fn dead_code_keys_cover_type_member_and_dependency_variants() {
3567 let root = root();
3568 let results = type_member_and_dependency_results(&root);
3569
3570 let keys = dead_code_keys(&results, &root);
3571
3572 assert!(keys.contains("unused-type:src/types.ts:UnusedType"));
3573 assert!(keys.contains("private-type-leak:src/types.ts:makePublic:PrivateShape"));
3574 assert!(keys.contains("unused-dev-dependency:package.json:vite"));
3575 assert!(keys.contains("unused-optional-dependency:package.json:fsevents"));
3576 assert!(keys.contains("unused-enum-member:src/types.ts:Status:Idle"));
3577 assert!(keys.contains("unused-class-member:src/types.ts:Controller:legacy"));
3578 assert!(keys.contains("type-only-dependency:package.json:zod"));
3579 assert!(keys.contains("test-only-dependency:package.json:vitest"));
3580 }
3581
3582 #[expect(
3583 clippy::too_many_lines,
3584 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3585 )]
3586 fn graph_boundary_catalog_override_results(root: &std::path::Path) -> AnalysisResults {
3587 let source = root.join("src/app.ts");
3588 let other = root.join("src/other.ts");
3589 let workspace = root.join("pnpm-workspace.yaml");
3590 let mut results = AnalysisResults::default();
3591 results
3592 .circular_dependencies
3593 .push(CircularDependencyFinding::with_actions(
3594 CircularDependency {
3595 files: vec![other.clone(), source.clone()],
3596 length: 2,
3597 line: 4,
3598 col: 0,
3599 edges: Vec::new(),
3600 is_cross_package: false,
3601 },
3602 ));
3603 results
3604 .re_export_cycles
3605 .push(ReExportCycleFinding::with_actions(ReExportCycle {
3606 files: vec![source.clone()],
3607 kind: ReExportCycleKind::SelfLoop,
3608 }));
3609 results
3610 .boundary_violations
3611 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3612 from_path: source.clone(),
3613 to_path: other,
3614 from_zone: "ui".to_string(),
3615 to_zone: "server".to_string(),
3616 import_specifier: "../other".to_string(),
3617 line: 1,
3618 col: 0,
3619 }));
3620 results
3621 .boundary_coverage_violations
3622 .push(BoundaryCoverageViolationFinding::with_actions(
3623 BoundaryCoverageViolation {
3624 path: root.join("src/unmatched.ts"),
3625 line: 1,
3626 col: 0,
3627 },
3628 ));
3629 results
3630 .boundary_call_violations
3631 .push(BoundaryCallViolationFinding::with_actions(
3632 BoundaryCallViolation {
3633 path: source.clone(),
3634 line: 12,
3635 col: 4,
3636 zone: "ui".to_string(),
3637 callee: "child_process.exec".to_string(),
3638 pattern: "child_process.*".to_string(),
3639 },
3640 ));
3641 results.stale_suppressions.push(StaleSuppression {
3642 path: source,
3643 line: 2,
3644 col: 0,
3645 origin: SuppressionOrigin::Comment {
3646 issue_kind: Some("unused-export".to_string()),
3647 reason: None,
3648 is_file_level: false,
3649 kind_known: true,
3650 },
3651 missing_reason: false,
3652 actions: StaleSuppression::actions_for(false),
3653 effective_severity: None,
3654 });
3655 results.stale_suppressions.push(StaleSuppression {
3656 path: root.join("src/app.ts"),
3657 line: 2,
3658 col: 0,
3659 origin: SuppressionOrigin::Comment {
3660 issue_kind: Some("unused-export".to_string()),
3661 reason: None,
3662 is_file_level: false,
3663 kind_known: true,
3664 },
3665 missing_reason: true,
3666 actions: StaleSuppression::actions_for(true),
3667 effective_severity: None,
3668 });
3669 results.unresolved_catalog_references.push(
3670 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
3671 entry_name: "react".to_string(),
3672 catalog_name: "default".to_string(),
3673 path: root.join("packages/app/package.json"),
3674 line: 9,
3675 available_in_catalogs: vec!["react18".to_string()],
3676 }),
3677 );
3678 results
3679 .unused_catalog_entries
3680 .push(UnusedCatalogEntryFinding::with_actions(
3681 UnusedCatalogEntry {
3682 entry_name: "lodash".to_string(),
3683 catalog_name: "default".to_string(),
3684 path: workspace.clone(),
3685 line: 3,
3686 hardcoded_consumers: Vec::new(),
3687 },
3688 ));
3689 results
3690 .empty_catalog_groups
3691 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
3692 catalog_name: "react17".to_string(),
3693 path: workspace.clone(),
3694 line: 7,
3695 }));
3696 results
3697 .unused_dependency_overrides
3698 .push(UnusedDependencyOverrideFinding::with_actions(
3699 UnusedDependencyOverride {
3700 raw_key: "left-pad".to_string(),
3701 target_package: "left-pad".to_string(),
3702 parent_package: None,
3703 version_constraint: None,
3704 version_range: "^1.3.0".to_string(),
3705 source: DependencyOverrideSource::PnpmWorkspaceYaml,
3706 path: workspace.clone(),
3707 line: 11,
3708 hint: None,
3709 },
3710 ));
3711 results.misconfigured_dependency_overrides.push(
3712 MisconfiguredDependencyOverrideFinding::with_actions(MisconfiguredDependencyOverride {
3713 raw_key: ">".to_string(),
3714 target_package: None,
3715 raw_value: String::new(),
3716 reason: DependencyOverrideMisconfigReason::UnparsableKey,
3717 source: DependencyOverrideSource::PnpmWorkspaceYaml,
3718 path: workspace,
3719 line: 12,
3720 }),
3721 );
3722 results
3723 }
3724
3725 #[test]
3726 fn dead_code_keys_cover_graph_boundary_catalog_and_override_variants() {
3727 let root = root();
3728 let results = graph_boundary_catalog_override_results(&root);
3729
3730 let keys = dead_code_keys(&results, &root);
3731
3732 assert!(keys.contains("circular-dependency:src/app.ts|src/other.ts"));
3733 assert!(keys.contains("re-export-cycle:self-loop:src/app.ts"));
3734 assert!(keys.contains("boundary-violation:src/app.ts:src/other.ts:../other"));
3735 assert!(keys.contains("boundary-coverage:src/unmatched.ts"));
3736 assert!(keys.contains("boundary-call:src/app.ts:child_process.exec"));
3737 assert!(
3738 keys.contains("stale-suppression:src/app.ts:// fallow-ignore-next-line unused-export")
3739 );
3740 assert!(keys.contains(
3741 "missing-suppression-reason:src/app.ts:// fallow-ignore-next-line unused-export"
3742 ));
3743 assert!(
3744 keys.contains("unresolved-catalog-reference:packages/app/package.json:9:default:react")
3745 );
3746 assert!(keys.contains("unused-catalog-entry:pnpm-workspace.yaml:3:default:lodash"));
3747 assert!(keys.contains("empty-catalog-group:pnpm-workspace.yaml:7:react17"));
3748 assert!(keys.contains("unused-dependency-override:pnpm-workspace.yaml:11:left-pad"));
3749 assert!(keys.contains("misconfigured-dependency-override:pnpm-workspace.yaml:12:>"));
3750 }
3751
3752 #[test]
3753 fn retain_introduced_dead_code_keeps_only_findings_absent_from_base() {
3754 let root = root();
3755 let mut results = sample_results(&root);
3756 let base = FxHashSet::from_iter([
3757 "unused-file:src/dead.ts".to_string(),
3758 "unused-dependency:package.json:left-pad".to_string(),
3759 "unresolved-import:src/page.ts:./missing".to_string(),
3760 ]);
3761
3762 retain_introduced_dead_code(&mut results, &root, Some(&base));
3763
3764 assert!(results.unused_files.is_empty());
3765 assert!(results.unused_dependencies.is_empty());
3766 assert!(results.unresolved_imports.is_empty());
3767 assert_eq!(results.unused_exports.len(), 1);
3768 assert_eq!(results.unlisted_dependencies.len(), 1);
3769 assert_eq!(results.duplicate_exports.len(), 1);
3770 }
3771
3772 #[test]
3773 fn annotate_dead_code_json_marks_introduced_status_by_matching_key_order() {
3774 let root = root();
3775 let results = sample_results(&root);
3776 let base = FxHashSet::from_iter([
3777 "unused-file:src/dead.ts".to_string(),
3778 "unlisted-dependency:zod:src/page.ts:9:2".to_string(),
3779 ]);
3780 let mut json = json!({
3781 "unused_files": [{}],
3782 "unused_exports": [{}],
3783 "unused_dependencies": [{}],
3784 "unresolved_imports": [{}],
3785 "unlisted_dependencies": [{}],
3786 "duplicate_exports": [{}],
3787 });
3788
3789 annotate_dead_code_json(&mut json, &results, &root, &base);
3790
3791 assert_eq!(json["unused_files"][0]["introduced"], false);
3792 assert_eq!(json["unused_exports"][0]["introduced"], true);
3793 assert_eq!(json["unused_dependencies"][0]["introduced"], true);
3794 assert_eq!(json["unresolved_imports"][0]["introduced"], true);
3795 assert_eq!(json["unlisted_dependencies"][0]["introduced"], false);
3796 assert_eq!(json["duplicate_exports"][0]["introduced"], true);
3797 }
3798
3799 fn framework_inject_and_render_results(root: &Path) -> AnalysisResults {
3802 let src = root.join("src/App.vue");
3803 let mut results = AnalysisResults::default();
3804 results
3805 .unprovided_injects
3806 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
3807 path: src.clone(),
3808 key_name: "userStore".to_string(),
3809 framework: "vue".to_string(),
3810 line: 5,
3811 col: 0,
3812 }));
3813 results
3814 .unrendered_components
3815 .push(UnrenderedComponentFinding::with_actions(
3816 UnrenderedComponent {
3817 path: src.clone(),
3818 component_name: "MyModal".to_string(),
3819 framework: "vue".to_string(),
3820 reachable_via: None,
3821 line: 1,
3822 col: 0,
3823 },
3824 ));
3825 results
3826 .unused_component_props
3827 .push(UnusedComponentPropFinding::with_actions(
3828 UnusedComponentProp {
3829 path: src.clone(),
3830 component_name: "MyModal".to_string(),
3831 prop_name: "title".to_string(),
3832 line: 3,
3833 col: 2,
3834 },
3835 ));
3836 results
3837 .unused_component_emits
3838 .push(UnusedComponentEmitFinding::with_actions(
3839 UnusedComponentEmit {
3840 path: src,
3841 component_name: "MyModal".to_string(),
3842 emit_name: "close".to_string(),
3843 line: 4,
3844 col: 2,
3845 },
3846 ));
3847 results
3848 .unused_svelte_events
3849 .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
3850 path: root.join("src/Counter.svelte"),
3851 component_name: "Counter".to_string(),
3852 event_name: "increment".to_string(),
3853 line: 8,
3854 col: 0,
3855 }));
3856
3857 results
3858 }
3859
3860 #[test]
3861 fn dead_code_keys_cover_framework_inject_and_render_variants() {
3862 let root = root();
3863 let results = framework_inject_and_render_results(&root);
3864
3865 let keys = dead_code_keys(&results, &root);
3866
3867 assert!(keys.contains("unprovided-inject:src/App.vue:userStore"));
3868 assert!(keys.contains("unrendered-component:src/App.vue:MyModal"));
3869 assert!(keys.contains("unused-component-prop:src/App.vue:title"));
3870 assert!(keys.contains("unused-component-emit:src/App.vue:close"));
3871 assert!(keys.contains("unused-svelte-event:src/Counter.svelte:increment"));
3872 }
3873
3874 fn server_action_load_data_and_route_results(root: &Path) -> AnalysisResults {
3875 let actions_file = root.join("src/actions/submit.ts");
3876 let page_file = root.join("src/routes/blog/+page.server.ts");
3877 let route_file = root.join("app/(auth)/login/page.tsx");
3878 let route_file2 = root.join("app/login/page.tsx");
3879 let mut results = AnalysisResults::default();
3880 results
3881 .unused_server_actions
3882 .push(UnusedServerActionFinding::with_actions(
3883 UnusedServerAction {
3884 path: actions_file,
3885 action_name: "submitForm".to_string(),
3886 line: 2,
3887 col: 0,
3888 },
3889 ));
3890 results
3891 .unused_load_data_keys
3892 .push(UnusedLoadDataKeyFinding::with_actions(UnusedLoadDataKey {
3893 path: page_file,
3894 key_name: "posts".to_string(),
3895 line: 10,
3896 col: 4,
3897 route_dir: None,
3898 }));
3899 results
3900 .route_collisions
3901 .push(RouteCollisionFinding::with_actions(RouteCollision {
3902 path: route_file.clone(),
3903 url: "/login".to_string(),
3904 conflicting_paths: vec![route_file2.clone()],
3905 line: 1,
3906 col: 0,
3907 }));
3908 results.dynamic_segment_name_conflicts.push(
3909 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
3910 path: route_file,
3911 position: "/shop".to_string(),
3912 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
3913 conflicting_paths: vec![route_file2],
3914 line: 1,
3915 col: 0,
3916 }),
3917 );
3918
3919 results
3920 }
3921
3922 #[test]
3923 fn dead_code_keys_cover_server_action_load_data_and_route_variants() {
3924 let root = root();
3925 let results = server_action_load_data_and_route_results(&root);
3926
3927 let keys = dead_code_keys(&results, &root);
3928
3929 assert!(keys.contains("unused-server-action:src/actions/submit.ts:submitForm"));
3930 assert!(keys.contains("unused-load-data-key:src/routes/blog/+page.server.ts:posts"));
3931 assert!(keys.contains("route-collision:app/(auth)/login/page.tsx:/login"));
3932 assert!(keys.contains("dynamic-segment-name-conflict:app/(auth)/login/page.tsx:/shop"));
3933 }
3934
3935 fn angular_input_output_and_policy_results(root: &Path) -> AnalysisResults {
3936 let component = root.join("src/app/card.component.ts");
3937 let src = root.join("src/utils.ts");
3938 let mut results = AnalysisResults::default();
3939 results
3940 .unused_component_inputs
3941 .push(UnusedComponentInputFinding::with_actions(
3942 UnusedComponentInput {
3943 path: component.clone(),
3944 component_name: "CardComponent".to_string(),
3945 input_name: "label".to_string(),
3946 line: 12,
3947 col: 4,
3948 },
3949 ));
3950 results
3951 .unused_component_outputs
3952 .push(UnusedComponentOutputFinding::with_actions(
3953 UnusedComponentOutput {
3954 path: component,
3955 component_name: "CardComponent".to_string(),
3956 output_name: "clicked".to_string(),
3957 line: 13,
3958 col: 4,
3959 },
3960 ));
3961 results
3962 .policy_violations
3963 .push(PolicyViolationFinding::with_actions(PolicyViolation {
3964 path: src,
3965 line: 7,
3966 col: 0,
3967 pack: "security".to_string(),
3968 rule_id: "no-eval".to_string(),
3969 kind: PolicyRuleKind::BannedCall,
3970 matched: "eval".to_string(),
3971 severity: PolicyViolationSeverity::Error,
3972 message: None,
3973 }));
3974
3975 results
3976 }
3977
3978 #[test]
3979 fn dead_code_keys_cover_angular_input_output_and_policy_variants() {
3980 let root = root();
3981 let results = angular_input_output_and_policy_results(&root);
3982
3983 let keys = dead_code_keys(&results, &root);
3984
3985 assert!(keys.contains("unused-component-input:src/app/card.component.ts:label"));
3986 assert!(keys.contains("unused-component-output:src/app/card.component.ts:clicked"));
3987 assert!(keys.contains("policy-violation:src/utils.ts:security/no-eval:eval"));
3988 }
3989
3990 #[test]
3991 fn dead_code_keys_cover_re_export_cycle_multi_node_variant() {
3992 let root = root();
3993 let a = root.join("src/a.ts");
3994 let b = root.join("src/b.ts");
3995 let mut results = AnalysisResults::default();
3996 results
3997 .re_export_cycles
3998 .push(ReExportCycleFinding::with_actions(ReExportCycle {
3999 files: vec![b, a],
4000 kind: ReExportCycleKind::MultiNode,
4001 }));
4002
4003 let keys = dead_code_keys(&results, &root);
4004
4005 assert!(keys.contains("re-export-cycle:multi-node:src/a.ts|src/b.ts"));
4007 }
4008
4009 fn unused_store_member_results(root: &Path) -> AnalysisResults {
4010 let src = root.join("src/store.ts");
4011 let mut results = AnalysisResults::default();
4012 results
4013 .unused_store_members
4014 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4015 path: src,
4016 parent_name: "useAuthStore".to_string(),
4017 member_name: "resetPassword".to_string(),
4018 kind: MemberKind::ClassMethod,
4019 line: 42,
4020 col: 2,
4021 }));
4022
4023 results
4024 }
4025
4026 #[test]
4027 fn dead_code_keys_cover_unused_store_member() {
4028 let root = root();
4029 let results = unused_store_member_results(&root);
4030
4031 let keys = dead_code_keys(&results, &root);
4032
4033 assert!(keys.contains("unused-store-member:src/store.ts:useAuthStore:resetPassword"));
4034 }
4035
4036 #[test]
4039 fn annotate_dead_code_json_marks_framework_keys_correctly() {
4040 let root = root();
4041 let src = root.join("src/App.vue");
4042 let mut results = AnalysisResults::default();
4043 results
4044 .unprovided_injects
4045 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
4046 path: src.clone(),
4047 key_name: "theme".to_string(),
4048 framework: "vue".to_string(),
4049 line: 3,
4050 col: 0,
4051 }));
4052 results
4053 .unrendered_components
4054 .push(UnrenderedComponentFinding::with_actions(
4055 UnrenderedComponent {
4056 path: src.clone(),
4057 component_name: "Dialog".to_string(),
4058 framework: "vue".to_string(),
4059 reachable_via: None,
4060 line: 1,
4061 col: 0,
4062 },
4063 ));
4064 results
4065 .unused_component_props
4066 .push(UnusedComponentPropFinding::with_actions(
4067 UnusedComponentProp {
4068 path: src.clone(),
4069 component_name: "Dialog".to_string(),
4070 prop_name: "open".to_string(),
4071 line: 5,
4072 col: 2,
4073 },
4074 ));
4075 results
4076 .unused_component_emits
4077 .push(UnusedComponentEmitFinding::with_actions(
4078 UnusedComponentEmit {
4079 path: src,
4080 component_name: "Dialog".to_string(),
4081 emit_name: "dismiss".to_string(),
4082 line: 6,
4083 col: 2,
4084 },
4085 ));
4086
4087 let base = FxHashSet::from_iter(["unprovided-inject:src/App.vue:theme".to_string()]);
4089 let mut json_val = json!({
4090 "unprovided_injects": [{}],
4091 "unrendered_components": [{}],
4092 "unused_component_props": [{}],
4093 "unused_component_emits": [{}],
4094 });
4095
4096 annotate_dead_code_json(&mut json_val, &results, &root, &base);
4097
4098 assert_eq!(json_val["unprovided_injects"][0]["introduced"], false);
4099 assert_eq!(json_val["unrendered_components"][0]["introduced"], true);
4100 assert_eq!(json_val["unused_component_props"][0]["introduced"], true);
4101 assert_eq!(json_val["unused_component_emits"][0]["introduced"], true);
4102 }
4103
4104 #[test]
4105 fn annotate_dead_code_json_marks_component_io_and_route_keys_correctly() {
4106 let root = root();
4107 let component = root.join("src/card.component.ts");
4108 let svelte_file = root.join("src/Counter.svelte");
4109 let page_file = root.join("src/routes/+page.server.ts");
4110 let route_file = root.join("app/about/page.tsx");
4111 let route_file2 = root.join("app/(info)/about/page.tsx");
4112 let mut results = AnalysisResults::default();
4113 results
4114 .unused_component_inputs
4115 .push(UnusedComponentInputFinding::with_actions(
4116 UnusedComponentInput {
4117 path: component.clone(),
4118 component_name: "CardComponent".to_string(),
4119 input_name: "size".to_string(),
4120 line: 8,
4121 col: 2,
4122 },
4123 ));
4124 results
4125 .unused_component_outputs
4126 .push(UnusedComponentOutputFinding::with_actions(
4127 UnusedComponentOutput {
4128 path: component,
4129 component_name: "CardComponent".to_string(),
4130 output_name: "hovered".to_string(),
4131 line: 9,
4132 col: 2,
4133 },
4134 ));
4135 results
4136 .unused_svelte_events
4137 .push(UnusedSvelteEventFinding::with_actions(UnusedSvelteEvent {
4138 path: svelte_file,
4139 component_name: "Counter".to_string(),
4140 event_name: "reset".to_string(),
4141 line: 12,
4142 col: 0,
4143 }));
4144 results
4145 .unused_server_actions
4146 .push(UnusedServerActionFinding::with_actions(
4147 UnusedServerAction {
4148 path: page_file,
4149 action_name: "deletePost".to_string(),
4150 line: 3,
4151 col: 0,
4152 },
4153 ));
4154 results
4155 .route_collisions
4156 .push(RouteCollisionFinding::with_actions(RouteCollision {
4157 path: route_file.clone(),
4158 url: "/about".to_string(),
4159 conflicting_paths: vec![route_file2.clone()],
4160 line: 1,
4161 col: 0,
4162 }));
4163 results.dynamic_segment_name_conflicts.push(
4164 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
4165 path: route_file,
4166 position: "/".to_string(),
4167 conflicting_segments: vec!["[id]".to_string()],
4168 conflicting_paths: vec![route_file2],
4169 line: 1,
4170 col: 0,
4171 }),
4172 );
4173
4174 let base = FxHashSet::default();
4176 let mut json_val = json!({
4177 "unused_component_inputs": [{}],
4178 "unused_component_outputs": [{}],
4179 "unused_svelte_events": [{}],
4180 "unused_server_actions": [{}],
4181 "route_collisions": [{}],
4182 "dynamic_segment_name_conflicts": [{}],
4183 });
4184
4185 annotate_dead_code_json(&mut json_val, &results, &root, &base);
4186
4187 assert_eq!(json_val["unused_component_inputs"][0]["introduced"], true);
4188 assert_eq!(json_val["unused_component_outputs"][0]["introduced"], true);
4189 assert_eq!(json_val["unused_svelte_events"][0]["introduced"], true);
4190 assert_eq!(json_val["unused_server_actions"][0]["introduced"], true);
4191 assert_eq!(json_val["route_collisions"][0]["introduced"], true);
4192 assert_eq!(
4193 json_val["dynamic_segment_name_conflicts"][0]["introduced"],
4194 true
4195 );
4196 }
4197
4198 #[test]
4199 fn annotate_dead_code_json_marks_members_and_dependencies_correctly() {
4200 let root = root();
4201 let src = root.join("src/types.ts");
4202 let pkg = root.join("package.json");
4203 let mut results = AnalysisResults::default();
4204 results
4205 .unused_enum_members
4206 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
4207 path: src.clone(),
4208 parent_name: "Color".to_string(),
4209 member_name: "Blue".to_string(),
4210 kind: MemberKind::EnumMember,
4211 line: 5,
4212 col: 2,
4213 }));
4214 results
4215 .unused_class_members
4216 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4217 path: src.clone(),
4218 parent_name: "Service".to_string(),
4219 member_name: "reset".to_string(),
4220 kind: MemberKind::ClassMethod,
4221 line: 20,
4222 col: 2,
4223 }));
4224 results
4225 .unused_store_members
4226 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4227 path: src,
4228 parent_name: "useStore".to_string(),
4229 member_name: "logout".to_string(),
4230 kind: MemberKind::ClassMethod,
4231 line: 30,
4232 col: 2,
4233 }));
4234 results
4235 .unused_dev_dependencies
4236 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
4237 package_name: "typescript".to_string(),
4238 location: DependencyLocation::DevDependencies,
4239 path: pkg.clone(),
4240 line: 8,
4241 used_in_workspaces: Vec::new(),
4242 }));
4243 results
4244 .type_only_dependencies
4245 .push(TypeOnlyDependencyFinding::with_actions(
4246 TypeOnlyDependency {
4247 package_name: "zod".to_string(),
4248 path: pkg.clone(),
4249 line: 9,
4250 },
4251 ));
4252 results
4253 .test_only_dependencies
4254 .push(TestOnlyDependencyFinding::with_actions(
4255 TestOnlyDependency {
4256 package_name: "vitest".to_string(),
4257 path: pkg,
4258 line: 10,
4259 },
4260 ));
4261
4262 let base = FxHashSet::from_iter([
4265 "unused-enum-member:src/types.ts:Color:Blue".to_string(),
4266 "unused-dev-dependency:package.json:typescript".to_string(),
4267 ]);
4268 let mut json_val = json!({
4269 "unused_enum_members": [{}],
4270 "unused_class_members": [{}],
4271 "unused_store_members": [{}],
4272 "unused_dev_dependencies": [{}],
4273 "type_only_dependencies": [{}],
4274 "test_only_dependencies": [{}],
4275 });
4276
4277 annotate_dead_code_json(&mut json_val, &results, &root, &base);
4278
4279 assert_eq!(json_val["unused_enum_members"][0]["introduced"], false);
4280 assert_eq!(json_val["unused_class_members"][0]["introduced"], true);
4281 assert_eq!(json_val["unused_store_members"][0]["introduced"], true);
4282 assert_eq!(json_val["unused_dev_dependencies"][0]["introduced"], false);
4283 assert_eq!(json_val["type_only_dependencies"][0]["introduced"], true);
4284 assert_eq!(json_val["test_only_dependencies"][0]["introduced"], true);
4285 }
4286
4287 #[test]
4288 fn annotate_dead_code_json_is_noop_when_issue_keys_absent() {
4289 let root = root();
4290 let results = sample_results(&root);
4291 let base = FxHashSet::default();
4292 let mut json_val = json!({"other_key": []});
4293 let original = json_val.clone();
4294
4295 annotate_dead_code_json(&mut json_val, &results, &root, &base);
4296
4297 assert_eq!(json_val, original);
4298 }
4299
4300 fn make_violation(path: &Path, name: &str) -> ComplexityViolation {
4303 ComplexityViolation {
4304 path: path.to_path_buf(),
4305 name: name.to_string(),
4306 line: 1,
4307 col: 0,
4308 cyclomatic: 20,
4309 cognitive: 5,
4310 line_count: 30,
4311 param_count: 2,
4312 react_hook_count: 0,
4313 react_jsx_max_depth: 0,
4314 react_prop_count: 0,
4315 react_hook_profile: None,
4316 exceeded: ExceededThreshold::Cyclomatic,
4317 effective_severity: None,
4318 severity: FindingSeverity::High,
4319 crap: None,
4320 coverage_pct: None,
4321 coverage_tier: None,
4322 coverage_source: None,
4323 inherited_from: None,
4324 component_rollup: None,
4325 contributions: Vec::new(),
4326 effective_thresholds: None,
4327 threshold_source: None,
4328 }
4329 }
4330
4331 fn make_health_report(paths_and_names: &[(&Path, &str)]) -> HealthReport {
4332 let findings = paths_and_names
4333 .iter()
4334 .map(|(path, name)| HealthFinding::from(make_violation(path, name)))
4335 .collect();
4336 HealthReport {
4337 findings,
4338 ..HealthReport::default()
4339 }
4340 }
4341
4342 #[test]
4343 fn health_keys_produces_stable_key_per_finding() {
4344 let root = root();
4345 let path = root.join("src/heavy.ts");
4346 let report = make_health_report(&[(&path, "processAll")]);
4347 let keys = health_keys(&report, &root);
4348 assert!(keys.contains("complexity:src/heavy.ts:processAll:Cyclomatic"));
4349 }
4350
4351 #[test]
4352 fn health_finding_key_uses_path_name_and_exceeded() {
4353 let root = root();
4354 let path = root.join("src/heavy.ts");
4355 let violation = make_violation(&path, "render");
4356 let key = health_finding_key(&violation, &root);
4357 assert_eq!(key, "complexity:src/heavy.ts:render:Cyclomatic");
4358 }
4359
4360 #[test]
4361 fn annotate_health_json_marks_introduced_and_inherited_flags() {
4362 let root = root();
4363 let path_a = root.join("src/heavy.ts");
4364 let path_b = root.join("src/other.ts");
4365 let report = make_health_report(&[(&path_a, "doWork"), (&path_b, "render")]);
4366
4367 let base = FxHashSet::from_iter(["complexity:src/other.ts:render:Cyclomatic".to_string()]);
4369 let mut json_val = json!({
4370 "findings": [{}, {}],
4371 });
4372
4373 annotate_health_json(&mut json_val, &report, &root, &base);
4374
4375 assert_eq!(json_val["findings"][0]["introduced"], true);
4376 assert_eq!(json_val["findings"][1]["introduced"], false);
4377 }
4378
4379 #[test]
4380 fn annotate_health_json_is_noop_when_findings_key_absent() {
4381 let root = root();
4382 let report = make_health_report(&[]);
4383 let base = FxHashSet::default();
4384 let mut json_val = json!({"summary": {}});
4385 let original = json_val.clone();
4386
4387 annotate_health_json(&mut json_val, &report, &root, &base);
4388
4389 assert_eq!(json_val, original);
4390 }
4391
4392 fn make_clone_group(files: &[PathBuf], fragment: &str) -> CloneGroup {
4395 CloneGroup {
4396 instances: files
4397 .iter()
4398 .map(|f| CloneInstance {
4399 file: f.clone(),
4400 start_line: 1,
4401 end_line: 5,
4402 start_col: 0,
4403 end_col: 80,
4404 fragment: fragment.to_string(),
4405 })
4406 .collect(),
4407 token_count: 10,
4408 line_count: 5,
4409 similarity: None,
4410 }
4411 }
4412
4413 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
4414 DuplicationReport {
4415 clone_groups: groups,
4416 clone_families: Vec::new(),
4417 mirrored_directories: Vec::new(),
4418 stats: fallow_types::duplicates::DuplicationStats::default(),
4419 }
4420 }
4421
4422 #[test]
4423 fn dupe_group_key_is_stable_for_sorted_deduplicated_files() {
4424 let root = root();
4425 let a = root.join("src/a.ts");
4426 let b = root.join("src/b.ts");
4427 let group_ab = make_clone_group(&[a.clone(), b.clone()], "const x = 1;");
4429 let group_ba = make_clone_group(&[b, a], "const x = 1;");
4430 let key_ab = dupe_group_key(&group_ab, &root);
4431 let key_ba = dupe_group_key(&group_ba, &root);
4432 assert!(key_ab.starts_with("dupe:src/a.ts|src/b.ts:"));
4434 assert!(key_ba.starts_with("dupe:src/a.ts|src/b.ts:"));
4435 assert_eq!(key_ab, key_ba);
4437 }
4438
4439 #[test]
4440 fn preexisting_dupe_group_keys_collects_only_groups_outside_added_lines() {
4441 let root = root();
4442 let a = root.join("src/a.ts");
4443 let b = root.join("src/b.ts");
4444 let c = root.join("src/c.ts");
4445 let untouched = make_clone_group(&[a.clone(), b], "shared scaffolding");
4447 let touched = make_clone_group(&[a, c], "pasted block");
4448 let diff = fallow_output::DiffIndex::from_unified_diff(
4449 "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",
4450 );
4451
4452 let demote = super::preexisting_dupe_group_keys([&untouched, &touched], &root, &diff);
4453
4454 assert!(demote.contains(&dupe_group_key(&untouched, &root)));
4455 assert!(!demote.contains(&dupe_group_key(&touched, &root)));
4456 assert_eq!(demote.len(), 1);
4457 }
4458
4459 #[test]
4460 fn demote_introductions_moves_introduced_keys_to_inherited() {
4461 let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
4462 let mut ledger = AuditDomainLedger::compare(
4463 ["dupe:kept".to_string(), "dupe:demoted".to_string()],
4464 Some(&base),
4465 );
4466 assert_eq!(ledger.introduced_count(), 2);
4467
4468 let demote: FxHashSet<String> = std::iter::once("dupe:demoted".to_string()).collect();
4469 ledger.demote_introductions(&demote);
4470
4471 assert_eq!(ledger.introduced_count(), 1);
4472 assert_eq!(ledger.inherited_count(), 1);
4473 let introduced: Vec<bool> = ledger.introduced().collect();
4474 assert_eq!(introduced, vec![true, false]);
4475 assert_eq!(ledger.demoted_count(), 1);
4476 assert!(ledger.demoted_count() <= ledger.inherited_count());
4477 assert!(ledger.demoted_keys().contains("dupe:demoted"));
4478 let demoted: Vec<bool> = ledger.demoted().collect();
4479 assert_eq!(demoted, vec![false, true]);
4480 }
4481
4482 #[test]
4483 fn demote_introductions_ignores_keys_that_were_not_introduced() {
4484 let base: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
4485 let mut ledger = AuditDomainLedger::compare(
4486 ["dupe:old".to_string(), "dupe:new".to_string()],
4487 Some(&base),
4488 );
4489
4490 let demote: FxHashSet<String> = std::iter::once("dupe:old".to_string()).collect();
4491 ledger.demote_introductions(&demote);
4492
4493 assert_eq!(ledger.demoted_count(), 0);
4494 let demoted: Vec<bool> = ledger.demoted().collect();
4495 assert_eq!(demoted, vec![false, false]);
4496 }
4497
4498 #[test]
4499 fn annotate_domain_demotions_json_marks_only_demoted_entries() {
4500 let mut json_val = json!({
4501 "clone_groups": [{}, {}],
4502 });
4503
4504 super::annotate_domain_demotions_json(
4505 &mut json_val,
4506 "clone_groups",
4507 [false, true],
4508 crate::CloneDemotionReason::NoAddedLines,
4509 );
4510
4511 assert!(json_val["clone_groups"][0].get("demotion_reason").is_none());
4512 assert_eq!(
4513 json_val["clone_groups"][1]["demotion_reason"],
4514 "no-added-lines"
4515 );
4516 }
4517
4518 #[test]
4519 fn dupes_keys_produces_one_key_per_clone_group() {
4520 let root = root();
4521 let a = root.join("src/a.ts");
4522 let b = root.join("src/b.ts");
4523 let groups = vec![
4524 make_clone_group(&[a.clone(), b.clone()], "block one"),
4525 make_clone_group(&[a, b], "block two"),
4526 ];
4527 let report = make_duplication_report(groups);
4528 let keys = dupes_keys(&report, &root);
4529 assert_eq!(keys.len(), 2);
4530 }
4531
4532 #[test]
4533 fn annotate_dupes_json_marks_introduced_and_inherited_flags() {
4534 let root = root();
4535 let a = root.join("src/a.ts");
4536 let b = root.join("src/b.ts");
4537 let group_new = make_clone_group(&[a.clone(), b.clone()], "new block");
4538 let group_old = make_clone_group(&[a, b], "old block");
4539 let old_key = dupe_group_key(&group_old, &root);
4540 let base = FxHashSet::from_iter([old_key]);
4541 let report = make_duplication_report(vec![group_new, group_old]);
4542 let mut json_val = json!({
4543 "clone_groups": [{}, {}],
4544 });
4545
4546 annotate_dupes_json(&mut json_val, &report, &root, &base);
4547
4548 assert_eq!(json_val["clone_groups"][0]["introduced"], true);
4549 assert_eq!(json_val["clone_groups"][1]["introduced"], false);
4550 }
4551
4552 #[test]
4553 fn annotate_dupes_json_is_noop_when_clone_groups_key_absent() {
4554 let root = root();
4555 let report = make_duplication_report(Vec::new());
4556 let base = FxHashSet::default();
4557 let mut json_val = json!({"stats": {}});
4558 let original = json_val.clone();
4559
4560 annotate_dupes_json(&mut json_val, &report, &root, &base);
4561
4562 assert_eq!(json_val, original);
4563 }
4564
4565 #[test]
4568 fn retain_introduced_dead_code_is_noop_when_base_is_none() {
4569 let root = root();
4570 let mut results = sample_results(&root);
4571 let original_file_count = results.unused_files.len();
4572 let original_export_count = results.unused_exports.len();
4573
4574 retain_introduced_dead_code(&mut results, &root, None);
4575
4576 assert_eq!(results.unused_files.len(), original_file_count);
4578 assert_eq!(results.unused_exports.len(), original_export_count);
4579 }
4580
4581 #[test]
4584 fn retain_introduced_dead_code_filters_framework_findings() {
4585 let root = root();
4586 let src = root.join("src/App.vue");
4587 let mut results = AnalysisResults::default();
4588 results
4589 .unprovided_injects
4590 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
4591 path: src.clone(),
4592 key_name: "existing".to_string(),
4593 framework: "vue".to_string(),
4594 line: 1,
4595 col: 0,
4596 }));
4597 results
4598 .unprovided_injects
4599 .push(UnprovidedInjectFinding::with_actions(UnprovidedInject {
4600 path: src.clone(),
4601 key_name: "new".to_string(),
4602 framework: "vue".to_string(),
4603 line: 2,
4604 col: 0,
4605 }));
4606 results
4607 .unrendered_components
4608 .push(UnrenderedComponentFinding::with_actions(
4609 UnrenderedComponent {
4610 path: src,
4611 component_name: "OldWidget".to_string(),
4612 framework: "vue".to_string(),
4613 reachable_via: None,
4614 line: 1,
4615 col: 0,
4616 },
4617 ));
4618
4619 let base = FxHashSet::from_iter([
4620 "unprovided-inject:src/App.vue:existing".to_string(),
4621 "unrendered-component:src/App.vue:OldWidget".to_string(),
4622 ]);
4623
4624 retain_introduced_dead_code(&mut results, &root, Some(&base));
4625
4626 assert_eq!(results.unprovided_injects.len(), 1);
4628 assert_eq!(results.unprovided_injects[0].inject.key_name, "new");
4629 assert!(results.unrendered_components.is_empty());
4630 }
4631
4632 #[test]
4633 fn retain_introduced_dead_code_filters_graph_findings() {
4634 let root = root();
4635 let a = root.join("src/a.ts");
4636 let b = root.join("src/b.ts");
4637 let mut results = AnalysisResults::default();
4638 results
4639 .circular_dependencies
4640 .push(CircularDependencyFinding::with_actions(
4641 CircularDependency {
4642 files: vec![a.clone(), b],
4643 length: 2,
4644 line: 1,
4645 col: 0,
4646 edges: Vec::new(),
4647 is_cross_package: false,
4648 },
4649 ));
4650 results
4651 .re_export_cycles
4652 .push(ReExportCycleFinding::with_actions(ReExportCycle {
4653 files: vec![a],
4654 kind: ReExportCycleKind::SelfLoop,
4655 }));
4656
4657 let base = FxHashSet::from_iter(["circular-dependency:src/a.ts|src/b.ts".to_string()]);
4659
4660 retain_introduced_dead_code(&mut results, &root, Some(&base));
4661
4662 assert!(results.circular_dependencies.is_empty());
4663 assert_eq!(results.re_export_cycles.len(), 1);
4664 }
4665
4666 #[test]
4667 fn audit_ledger_routes_override_severity_and_persists_introduced_flags() {
4668 let root = root();
4669 let config: FallowConfig = serde_json::from_value(json!({
4670 "rules": { "unused-exports": "warn" },
4671 "overrides": [{
4672 "files": ["src/generated/**"],
4673 "rules": { "unused-exports": "error" }
4674 }]
4675 }))
4676 .expect("config");
4677 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4678 let mut results = AnalysisResults::default();
4679 results
4680 .unused_exports
4681 .push(export(&root.join("src/base.ts"), "baseExport"));
4682 results
4683 .unused_exports
4684 .push(export(&root.join("src/generated/new.ts"), "newExport"));
4685 let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
4686
4687 let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
4688
4689 assert_eq!(ledger.classification_count(), 2);
4690 assert_eq!(ledger.introduced_count(), 1);
4691 assert_eq!(ledger.inherited_count(), 1);
4692 assert!(ledger.has_introduced_errors());
4693 assert!(!ledger.has_introduced_warnings());
4694 assert_eq!(ledger.records()[0].effective_severity, Severity::Warn);
4695 assert_eq!(ledger.records()[1].effective_severity, Severity::Error);
4696
4697 ledger.annotate_results(&mut results);
4698 assert_eq!(ledger.classification_count(), 2);
4699 assert_eq!(
4700 results.unused_exports[0].introduced,
4701 Some(AuditIntroduced(false))
4702 );
4703 assert_eq!(
4704 results.unused_exports[1].introduced,
4705 Some(AuditIntroduced(true))
4706 );
4707 }
4708
4709 #[test]
4710 fn audit_ledger_demotes_semantic_only_introductions_but_keeps_syntactic_new_findings() {
4711 let root = root();
4712 let config: FallowConfig = serde_json::from_value(json!({
4713 "rules": { "unused-exports": "error" }
4714 }))
4715 .expect("config");
4716 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4717 let mut results = AnalysisResults::default();
4718 results
4721 .unused_exports
4722 .push(export(&root.join("src/base.ts"), "baseExport"));
4723 results
4724 .unused_exports
4725 .push(export(&root.join("src/new.ts"), "newExport"));
4726 results
4727 .unused_exports
4728 .push(export(&root.join("src/semantic.ts"), "semanticOnly"));
4729 let base = FxHashSet::from_iter(["unused-export:src/base.ts:baseExport".to_string()]);
4730 let head_syntactic = FxHashSet::from_iter([
4731 "unused-export:src/base.ts:baseExport".to_string(),
4732 "unused-export:src/new.ts:newExport".to_string(),
4733 ]);
4734
4735 let mut ledger = dead_code_audit_ledger(&results, &root, &config, Some(&base));
4736 assert_eq!(ledger.introduced_count(), 2);
4737
4738 ledger.demote_unattributable_introductions(&head_syntactic);
4739
4740 assert_eq!(ledger.introduced_count(), 1);
4742 assert!(ledger.has_introduced_errors());
4743 assert_eq!(ledger.inherited_count(), 2);
4744 ledger.annotate_results(&mut results);
4745 assert_eq!(
4746 results.unused_exports[0].introduced,
4747 Some(AuditIntroduced(false))
4748 );
4749 assert_eq!(
4750 results.unused_exports[1].introduced,
4751 Some(AuditIntroduced(true))
4752 );
4753 assert_eq!(
4754 results.unused_exports[2].introduced,
4755 Some(AuditIntroduced(false))
4756 );
4757 }
4758
4759 #[test]
4760 fn audit_ledger_counts_colliding_dead_code_keys_once_but_annotates_each_record() {
4761 let root = root();
4762 let config: FallowConfig = serde_json::from_value(json!({
4763 "rules": { "unused-exports": "error" }
4764 }))
4765 .expect("config");
4766 let config = config.resolve(root.clone(), OutputFormat::Json, 1, false, true, None);
4767 let mut results = AnalysisResults::default();
4768 results
4769 .unused_exports
4770 .push(export(&root.join("src/collision.ts"), "sameExport"));
4771 results
4772 .unused_exports
4773 .push(export(&root.join("src/collision.ts"), "sameExport"));
4774
4775 let ledger = dead_code_audit_ledger(&results, &root, &config, Some(&FxHashSet::default()));
4776
4777 assert_eq!(ledger.classification_count(), 2);
4778 assert_eq!(ledger.records().len(), 2);
4779 assert_eq!(ledger.introduced_count(), 1);
4780 assert_eq!(ledger.inherited_count(), 0);
4781
4782 ledger.annotate_results(&mut results);
4783 assert!(
4784 results
4785 .unused_exports
4786 .iter()
4787 .all(|finding| { finding.introduced == Some(AuditIntroduced(true)) })
4788 );
4789 }
4790
4791 #[test]
4792 fn audit_domain_ledger_counts_colliding_keys_once_but_preserves_record_membership() {
4793 let introduced = AuditDomainLedger::compare(
4794 ["same-key".to_string(), "same-key".to_string()],
4795 Some(&FxHashSet::default()),
4796 );
4797 assert_eq!(introduced.introduced_count(), 1);
4798 assert_eq!(introduced.inherited_count(), 0);
4799 assert_eq!(introduced.introduced().collect::<Vec<_>>(), [true, true]);
4800
4801 let base = FxHashSet::from_iter(["same-key".to_string()]);
4802 let inherited = AuditDomainLedger::compare(
4803 ["same-key".to_string(), "same-key".to_string()],
4804 Some(&base),
4805 );
4806 assert_eq!(inherited.introduced_count(), 0);
4807 assert_eq!(inherited.inherited_count(), 1);
4808 assert_eq!(inherited.introduced().collect::<Vec<_>>(), [false, false]);
4809 }
4810
4811 #[test]
4812 fn stale_suppression_fallback_preserves_introduced_annotation() {
4813 let root = root();
4814 let mut results = AnalysisResults::default();
4815 results.stale_suppressions.push(StaleSuppression {
4816 path: root.join("src/new.ts"),
4817 line: 2,
4818 col: 0,
4819 origin: SuppressionOrigin::Comment {
4820 issue_kind: Some("unused-export".to_string()),
4821 reason: None,
4822 is_file_level: false,
4823 kind_known: true,
4824 },
4825 missing_reason: false,
4826 actions: StaleSuppression::actions_for(false),
4827 effective_severity: None,
4828 });
4829 let mut json = serde_json::to_value(&results).expect("json");
4830
4831 annotate_stale_suppressions_json(&mut json, &results, &root, &FxHashSet::default());
4832
4833 assert_eq!(json["stale_suppressions"][0]["introduced"], true);
4834 }
4835
4836 #[test]
4837 fn remap_keys_for_renames_relocates_path_segments() {
4838 use rustc_hash::FxHashMap;
4839
4840 let mut renames = FxHashMap::default();
4841 renames.insert(
4842 "src/old/impl.ts".to_string(),
4843 "src/renamed/impl.ts".to_string(),
4844 );
4845
4846 let keys: FxHashSet<String> = [
4847 "complexity:src/old/impl.ts:complexFn:[Cyclomatic]".to_string(),
4848 "unused-export:src/old/impl.ts:unusedHelper".to_string(),
4849 "unused-file:src/old/impl.ts".to_string(),
4850 "unused-export:src/other.ts:src/old/impl.ts-unrelated".to_string(),
4851 ]
4852 .into_iter()
4853 .collect();
4854
4855 let remapped = remap_keys_for_renames(&keys, &renames);
4856
4857 assert!(remapped.contains("complexity:src/renamed/impl.ts:complexFn:[Cyclomatic]"));
4858 assert!(remapped.contains("unused-export:src/renamed/impl.ts:unusedHelper"));
4859 assert!(remapped.contains("unused-file:src/renamed/impl.ts"));
4860 assert!(
4861 remapped.contains("unused-export:src/other.ts:src/old/impl.ts-unrelated"),
4862 "non-exact segment matches must pass through untouched"
4863 );
4864 }
4865
4866 #[test]
4867 fn remap_keys_for_renames_recanonicalizes_sorted_path_lists() {
4868 use rustc_hash::FxHashMap;
4869
4870 let mut renames = FxHashMap::default();
4871 renames.insert("src/a.ts".to_string(), "src/z.ts".to_string());
4872
4873 let keys: FxHashSet<String> = [
4874 "dupe:src/a.ts|src/m.ts:120:12:deadbeef".to_string(),
4877 "circular-dependency:src/a.ts|src/m.ts".to_string(),
4878 ]
4879 .into_iter()
4880 .collect();
4881
4882 let remapped = remap_keys_for_renames(&keys, &renames);
4883
4884 assert!(
4885 remapped.contains("dupe:src/m.ts|src/z.ts:120:12:deadbeef"),
4886 "duplicate-group file lists must be re-sorted: {remapped:#?}"
4887 );
4888 assert!(
4889 remapped.contains("circular-dependency:src/m.ts|src/z.ts"),
4890 "cycle file lists must be re-sorted: {remapped:#?}"
4891 );
4892 }
4893
4894 #[test]
4895 fn remap_keys_for_renames_without_matches_is_identity() {
4896 use rustc_hash::FxHashMap;
4897
4898 let mut renames = FxHashMap::default();
4899 renames.insert("src/elsewhere.ts".to_string(), "src/moved.ts".to_string());
4900
4901 let keys: FxHashSet<String> =
4902 std::iter::once("unused-export:src/utils.ts:helper".to_string()).collect();
4903
4904 assert_eq!(remap_keys_for_renames(&keys, &renames), keys);
4905 }
4906
4907 fn production_and_directive_results(root: &Path) -> AnalysisResults {
4909 let page = root.join("src/app.ts");
4910 let mut results = AnalysisResults::default();
4911 results.dev_dependencies_in_production.push(
4912 DevDependencyInProductionFinding::with_actions(DevDependencyInProduction {
4913 package_name: "vite".to_string(),
4914 path: root.join("package.json"),
4915 line: 14,
4916 }),
4917 );
4918 results
4919 .invalid_client_exports
4920 .push(InvalidClientExportFinding::with_actions(
4921 InvalidClientExport {
4922 path: page.clone(),
4923 export_name: "config".to_string(),
4924 directive: "use client".to_string(),
4925 line: 3,
4926 col: 0,
4927 },
4928 ));
4929 results
4930 .mixed_client_server_barrels
4931 .push(MixedClientServerBarrelFinding::with_actions(
4932 MixedClientServerBarrel {
4933 path: page.clone(),
4934 client_origin: "./client".to_string(),
4935 server_origin: "./server".to_string(),
4936 line: 1,
4937 col: 0,
4938 },
4939 ));
4940 results
4941 .misplaced_directives
4942 .push(MisplacedDirectiveFinding::with_actions(
4943 MisplacedDirective {
4944 path: page,
4945 directive: "use server".to_string(),
4946 line: 5,
4947 col: 2,
4948 },
4949 ));
4950 results
4951 }
4952
4953 fn isolated_findings(fixtures: &[AnalysisResults]) -> Vec<(String, AnalysisResults)> {
4956 let mut isolated = Vec::new();
4957 for fixture in fixtures {
4958 let value = serde_json::to_value(fixture).expect("results serialize");
4959 let object = value.as_object().expect("results serialize as an object");
4960 for (collection, items) in object {
4961 let Some(items) = items.as_array() else {
4962 continue;
4963 };
4964 for item in items {
4965 let mut single = serde_json::to_value(AnalysisResults::default())
4966 .expect("empty results serialize");
4967 single[collection.as_str()] = serde_json::Value::Array(vec![item.clone()]);
4968 let results = serde_json::from_value(single)
4969 .unwrap_or_else(|error| panic!("{collection} round-trips: {error}"));
4970 isolated.push((collection.clone(), results));
4971 }
4972 }
4973 }
4974 isolated
4975 }
4976
4977 fn every_rule_config(
4982 base: [&str; 2],
4983 scoped: Option<[&str; 2]>,
4984 ) -> fallow_config::ResolvedConfig {
4985 let every_rule = |severities: [&str; 2]| {
4986 let rules = serde_json::to_value(fallow_config::RulesConfig::default())
4987 .expect("rules serialize");
4988 let names = rules
4989 .as_object()
4990 .expect("rules serialize as an object")
4991 .keys();
4992 serde_json::Value::Object(
4993 names
4994 .enumerate()
4995 .map(|(index, name)| (name.clone(), json!(severities[index % 2])))
4996 .collect(),
4997 )
4998 };
4999 let mut config = json!({ "rules": every_rule(base) });
5000 if let Some(scoped) = scoped {
5001 config["overrides"] = json!([{
5002 "files": [
5003 "package.json",
5004 "pnpm-workspace.yaml",
5005 "src/app.ts",
5006 "src/page.ts",
5007 "src/types.ts",
5008 "src/App.vue",
5009 "src/actions/**",
5010 "app/**",
5011 "src/app/**",
5012 "src/store.ts"
5013 ],
5014 "rules": every_rule(scoped)
5015 }]);
5016 }
5017 let config: FallowConfig = serde_json::from_value(config).expect("config");
5018 config.resolve(root(), OutputFormat::Json, 1, false, true, None)
5019 }
5020
5021 #[test]
5026 fn audit_ledger_errors_match_the_exit_code_rule_for_every_collection() {
5027 let root = root();
5028 let findings = isolated_findings(&[
5029 sample_results(&root),
5030 graph_boundary_catalog_override_results(&root),
5031 type_member_and_dependency_results(&root),
5032 framework_inject_and_render_results(&root),
5033 server_action_load_data_and_route_results(&root),
5034 angular_input_output_and_policy_results(&root),
5035 unused_store_member_results(&root),
5036 production_and_directive_results(&root),
5037 ]);
5038 let empty_base = FxHashSet::default();
5039 let mut covered = std::collections::BTreeSet::new();
5040 let mut mismatches = Vec::new();
5041 for (base, scoped) in [
5042 (["error", "error"], None),
5043 (["warn", "warn"], None),
5044 (["error", "warn"], None),
5045 (["error", "error"], Some(["warn", "warn"])),
5046 (["warn", "warn"], Some(["error", "error"])),
5047 (["off", "off"], Some(["error", "error"])),
5048 (["error", "warn"], Some(["warn", "error"])),
5049 (["warn", "error"], Some(["error", "warn"])),
5050 ] {
5051 let config = every_rule_config(base, scoped);
5052 for (collection, results) in &findings {
5053 let ledger = dead_code_audit_ledger(results, &root, &config, Some(&empty_base));
5054 covered.extend(ledger.records().iter().map(|record| record.collection));
5055 let exit_rule = fallow_engine::error_severity::has_error_severity_issues(
5056 results,
5057 &config.rules,
5058 Some(&config),
5059 false,
5060 );
5061 if ledger.has_introduced_errors() != exit_rule {
5062 mismatches.push(format!(
5063 "{collection} (rules {base:?}, overrides {scoped:?}): ledger {}, exit rule {exit_rule}",
5064 ledger.has_introduced_errors()
5065 ));
5066 }
5067 }
5068 }
5069 assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
5070 let missing: Vec<&str> = super::AuditCollection::ALL
5071 .iter()
5072 .map(|collection| collection.json_key())
5073 .filter(|key| !covered.contains(key))
5074 .collect();
5075 assert!(
5076 missing.is_empty(),
5077 "the fixtures hold no finding in {missing:?}"
5078 );
5079 }
5080}