1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::duplicates::DuplicationReport;
6
7fn relative_path(path: &Path, root: &Path) -> String {
13 match path.strip_prefix(root) {
14 Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
15 Err(_) => {
16 tracing::debug!(
17 path = %path.display(),
18 root = %root.display(),
19 "baseline key: path is not under project root, using absolute path as key"
20 );
21 path.to_string_lossy().replace('\\', "/")
22 }
23 }
24}
25
26fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
27 format!("{}:{package_name}", relative_path(path, root))
28}
29
30fn baseline_contains_dependency(
31 baseline_keys: &FxHashSet<&str>,
32 package_name: &str,
33 path_key: &str,
34) -> bool {
35 baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
36}
37
38fn retain_new_by_keys<T>(
39 items: &mut Vec<T>,
40 baseline_keys: &[String],
41 root: &Path,
42 key_builder: fn(&[T], &Path) -> Vec<String>,
43) {
44 let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
45 let item_keys = key_builder(items, root);
46 let mut key_iter = item_keys.into_iter();
47 items.retain(|_| match key_iter.next() {
48 Some(key) => !baseline_keys.contains(key.as_str()),
49 None => true,
50 });
51}
52
53const STALE_WARN_PERCENT: usize = 25;
61
62#[must_use]
68pub const fn stale_share_warrants_warning(baseline_entries: usize, stale_entries: usize) -> bool {
69 stale_entries > 0 && stale_entries * 100 >= baseline_entries * STALE_WARN_PERCENT
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct BaselineStaleness {
80 pub entries: usize,
82 pub matched: usize,
84 pub current_findings: usize,
88 pub change_scoped: bool,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum BaselineStalenessWarning {
96 None,
98 ZeroOverlap,
100 Partial,
102}
103
104impl BaselineStaleness {
105 #[must_use]
107 pub const fn stale_entries(&self) -> usize {
108 self.entries.saturating_sub(self.matched)
109 }
110
111 #[must_use]
120 pub const fn warning(&self) -> BaselineStalenessWarning {
121 if self.change_scoped || self.entries == 0 || self.current_findings == 0 {
122 return BaselineStalenessWarning::None;
123 }
124 if self.matched == 0 {
125 return BaselineStalenessWarning::ZeroOverlap;
126 }
127 if stale_share_warrants_warning(self.entries, self.stale_entries()) {
128 return BaselineStalenessWarning::Partial;
129 }
130 BaselineStalenessWarning::None
131 }
132
133 #[must_use]
142 pub const fn trips_gate(&self) -> bool {
143 stale_baseline_gate_trips(self.entries, self.matched, self.change_scoped)
144 }
145 #[must_use]
166 pub fn to_envelope(
167 &self,
168 moved_entries: usize,
169 scope_reasons: fallow_output::BaselineScopeReasons,
170 unrecognised_format: bool,
171 ) -> fallow_output::BaselineStaleness {
172 debug_assert_eq!(
173 self.change_scoped,
174 !scope_reasons.is_empty(),
175 "change_scoped and scope_reasons must be derived from the same predicate"
176 );
177 let warning = self.warning();
178 fallow_output::BaselineStaleness {
179 baseline_entries: self.entries,
180 matched_entries: self.matched,
181 stale_entries: self.stale_entries(),
182 current_findings: self.current_findings,
183 change_scoped: self.change_scoped,
184 stale: warning != BaselineStalenessWarning::None,
185 warning: match warning {
186 BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
187 BaselineStalenessWarning::ZeroOverlap => {
188 fallow_output::BaselineStalenessAdvisory::ZeroOverlap
189 }
190 BaselineStalenessWarning::Partial => {
191 fallow_output::BaselineStalenessAdvisory::Partial
192 }
193 },
194 gate_trips: self.trips_gate() || unrecognised_format,
195 moved_entries,
196 unrecognised_format,
197 scope_reasons,
198 }
199 }
200}
201
202#[must_use]
205pub const fn stale_baseline_gate_trips(
206 entries: usize,
207 matched: usize,
208 change_scoped: bool,
209) -> bool {
210 !change_scoped && entries > 0 && matched < entries
211}
212
213#[must_use]
227pub fn declares_baseline_format(json: &str, declared_keys: &[&str]) -> bool {
228 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
229 return false;
230 };
231 value_declares_baseline_format(&value, declared_keys)
232}
233
234fn value_declares_baseline_format(value: &serde_json::Value, declared_keys: &[&str]) -> bool {
236 let Some(object) = value.as_object() else {
237 return false;
238 };
239 declared_keys.iter().any(|key| object.contains_key(*key))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
250#[serde(rename_all = "kebab-case")]
251pub enum BaselineKind {
252 DeadCode,
254 Dupes,
256 Health,
258}
259
260impl BaselineKind {
261 #[must_use]
263 pub const fn as_str(self) -> &'static str {
264 match self {
265 Self::DeadCode => "dead-code",
266 Self::Dupes => "dupes",
267 Self::Health => "health",
268 }
269 }
270
271 #[must_use]
273 pub const fn declared_keys(self) -> &'static [&'static str] {
274 match self {
275 Self::DeadCode => BaselineData::REQUIRED_KEYS,
276 Self::Dupes => DuplicationBaselineData::DECLARED_KEYS,
277 Self::Health => HealthBaselineData::DECLARED_KEYS,
278 }
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum BaselineFileKind {
285 Own,
288 Foreign(String),
291 Unrecognised,
295 NotAnObject,
298}
299
300#[must_use]
310pub fn refuse_baseline_kind_overwrite(save_path: &Path, saving: BaselineKind) -> Option<String> {
311 let existing = std::fs::read_to_string(save_path).ok()?;
312 let BaselineFileKind::Foreign(found) = classify_baseline_file(&existing, saving) else {
313 return None;
314 };
315 Some(format!(
316 "refusing to overwrite the baseline at {}: it was saved by `fallow {found}` and this is a \
317 `fallow {}` save, which would destroy it. Save each command's baseline to its own path.",
318 save_path.display(),
319 saving.as_str(),
320 ))
321}
322
323#[must_use]
333pub fn classify_baseline_file(json: &str, expected: BaselineKind) -> BaselineFileKind {
334 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
335 return BaselineFileKind::NotAnObject;
336 };
337 classify_baseline_value(&value, expected)
338}
339
340#[must_use]
346pub fn classify_baseline_value(
347 value: &serde_json::Value,
348 expected: BaselineKind,
349) -> BaselineFileKind {
350 let Some(object) = value.as_object() else {
351 return BaselineFileKind::NotAnObject;
352 };
353 match object.get("kind").and_then(serde_json::Value::as_str) {
354 Some(token) if token == expected.as_str() => BaselineFileKind::Own,
355 Some(token) => BaselineFileKind::Foreign(token.to_owned()),
356 None => {
359 if value_declares_baseline_format(value, expected.declared_keys()) {
360 BaselineFileKind::Own
361 } else {
362 BaselineFileKind::Unrecognised
363 }
364 }
365 }
366}
367
368#[derive(serde::Serialize, serde::Deserialize)]
370pub struct BaselineData {
371 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
377 kind: Option<BaselineKind>,
378 #[serde(default)]
382 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
383 unused_files: Vec<String>,
384 unused_exports: Vec<String>,
385 unused_types: Vec<String>,
386 #[serde(default)]
387 private_type_leaks: Vec<String>,
388 unused_dependencies: Vec<String>,
392 unused_dev_dependencies: Vec<String>,
396 #[serde(default)]
398 circular_dependencies: Vec<String>,
399 #[serde(default)]
404 re_export_cycles: Vec<String>,
405 #[serde(default)]
409 unused_optional_dependencies: Vec<String>,
410 #[serde(default)]
412 unused_enum_members: Vec<String>,
413 #[serde(default)]
415 unused_class_members: Vec<String>,
416 #[serde(default)]
418 unused_store_members: Vec<String>,
419 #[serde(default)]
421 unprovided_injects: Vec<String>,
422 #[serde(default)]
424 unrendered_components: Vec<String>,
425 #[serde(default)]
427 unused_component_props: Vec<String>,
428 #[serde(default)]
430 unused_component_emits: Vec<String>,
431 #[serde(default)]
433 unused_component_inputs: Vec<String>,
434 #[serde(default)]
436 unused_component_outputs: Vec<String>,
437 #[serde(default)]
439 unused_svelte_events: Vec<String>,
440 #[serde(default)]
442 unused_server_actions: Vec<String>,
443 #[serde(default)]
445 unused_load_data_keys: Vec<String>,
446 #[serde(default)]
448 unresolved_imports: Vec<String>,
449 #[serde(default)]
451 unlisted_dependencies: Vec<String>,
452 #[serde(default)]
454 duplicate_exports: Vec<String>,
455 #[serde(default)]
459 type_only_dependencies: Vec<String>,
460 #[serde(default)]
464 test_only_dependencies: Vec<String>,
465 #[serde(default)]
467 dev_dependencies_in_production: Vec<String>,
468 #[serde(default)]
470 boundary_violations: Vec<String>,
471 #[serde(default)]
473 boundary_coverage_violations: Vec<String>,
474 #[serde(default)]
476 boundary_call_violations: Vec<String>,
477 #[serde(default)]
479 policy_violations: Vec<String>,
480 #[serde(default)]
482 stale_suppressions: Vec<String>,
483 #[serde(default)]
485 unused_catalog_entries: Vec<String>,
486 #[serde(default)]
488 empty_catalog_groups: Vec<String>,
489 #[serde(default)]
491 unresolved_catalog_references: Vec<String>,
492 #[serde(default)]
494 unused_dependency_overrides: Vec<String>,
495 #[serde(default)]
497 misconfigured_dependency_overrides: Vec<String>,
498 #[serde(default)]
500 invalid_client_exports: Vec<String>,
501 #[serde(default)]
503 mixed_client_server_barrels: Vec<String>,
504 #[serde(default)]
507 misplaced_directives: Vec<String>,
508 #[serde(default)]
510 route_collisions: Vec<String>,
511 #[serde(default)]
513 dynamic_segment_name_conflicts: Vec<String>,
514}
515
516impl BaselineData {
517 pub const REQUIRED_KEYS: &'static [&'static str] = &[
523 "unused_files",
524 "unused_exports",
525 "unused_types",
526 "unused_dependencies",
527 "unused_dev_dependencies",
528 ];
529
530 pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
533 Self::from_results_with_identity(
534 results,
535 root,
536 fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
537 )
538 }
539
540 pub fn from_results_with_identity(
544 results: &crate::results::AnalysisResults,
545 root: &Path,
546 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
547 ) -> Self {
548 let file_exports = baseline_file_export_keys(results, root);
549 let member_imports = baseline_member_import_keys(results, root);
550 let dependencies = baseline_dependency_keys(results, root);
551 let graph = baseline_graph_keys(results, root);
552 let catalog = baseline_catalog_keys(results, root);
553
554 Self {
555 kind: Some(BaselineKind::DeadCode),
556 analysis_identity,
557 unused_files: file_exports.unused_files,
558 unused_exports: file_exports.unused_exports,
559 unused_types: file_exports.unused_types,
560 private_type_leaks: file_exports.private_type_leaks,
561 unused_dependencies: dependencies.unused,
562 unused_dev_dependencies: dependencies.unused_dev,
563 circular_dependencies: graph.circular_dependencies,
564 re_export_cycles: graph.re_export_cycles,
565 unused_optional_dependencies: dependencies.unused_optional,
566 unused_enum_members: member_imports.unused_enum_members,
567 unused_class_members: member_imports.unused_class_members,
568 unused_store_members: member_imports.unused_store_members,
569 unprovided_injects: member_imports.unprovided_injects,
570 unrendered_components: member_imports.unrendered_components,
571 unused_component_props: member_imports.unused_component_props,
572 unused_component_emits: member_imports.unused_component_emits,
573 unused_component_inputs: member_imports.unused_component_inputs,
574 unused_component_outputs: member_imports.unused_component_outputs,
575 unused_svelte_events: member_imports.unused_svelte_events,
576 unused_server_actions: member_imports.unused_server_actions,
577 unused_load_data_keys: member_imports.unused_load_data_keys,
578 unresolved_imports: member_imports.unresolved_imports,
579 unlisted_dependencies: dependencies.unlisted,
580 duplicate_exports: member_imports.duplicate_exports,
581 type_only_dependencies: dependencies.type_only,
582 test_only_dependencies: dependencies.test_only,
583 dev_dependencies_in_production: dependencies.dev_in_prod,
584 boundary_violations: graph.boundary_violations,
585 boundary_coverage_violations: graph.boundary_coverage_violations,
586 boundary_call_violations: graph.boundary_call_violations,
587 policy_violations: graph.policy_violations,
588 stale_suppressions: member_imports.stale_suppressions,
589 unused_catalog_entries: catalog.unused_catalog_entries,
590 empty_catalog_groups: catalog.empty_catalog_groups,
591 unresolved_catalog_references: catalog.unresolved_catalog_references,
592 unused_dependency_overrides: catalog.unused_dependency_overrides,
593 misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
594 invalid_client_exports: file_exports.invalid_client_exports,
595 mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
596 misplaced_directives: file_exports.misplaced_directives,
597 route_collisions: file_exports.route_collisions,
598 dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
599 }
600 }
601
602 #[must_use]
604 pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
605 &self.analysis_identity
606 }
607
608 pub fn total_entries(&self) -> usize {
610 self.unused_files.len()
611 + self.unused_exports.len()
612 + self.unused_types.len()
613 + self.private_type_leaks.len()
614 + self.unused_dependencies.len()
615 + self.unused_dev_dependencies.len()
616 + self.circular_dependencies.len()
617 + self.re_export_cycles.len()
618 + self.unused_optional_dependencies.len()
619 + self.unused_enum_members.len()
620 + self.unused_class_members.len()
621 + self.unused_store_members.len()
622 + self.unprovided_injects.len()
623 + self.unrendered_components.len()
624 + self.unused_component_props.len()
625 + self.unused_component_emits.len()
626 + self.unused_component_inputs.len()
627 + self.unused_component_outputs.len()
628 + self.unused_svelte_events.len()
629 + self.unused_server_actions.len()
630 + self.unused_load_data_keys.len()
631 + self.unresolved_imports.len()
632 + self.unlisted_dependencies.len()
633 + self.duplicate_exports.len()
634 + self.type_only_dependencies.len()
635 + self.test_only_dependencies.len()
636 + self.dev_dependencies_in_production.len()
637 + self.boundary_violations.len()
638 + self.boundary_coverage_violations.len()
639 + self.boundary_call_violations.len()
640 + self.policy_violations.len()
641 + self.stale_suppressions.len()
642 + self.unused_catalog_entries.len()
643 + self.empty_catalog_groups.len()
644 + self.unresolved_catalog_references.len()
645 + self.unused_dependency_overrides.len()
646 + self.misconfigured_dependency_overrides.len()
647 + self.invalid_client_exports.len()
648 + self.mixed_client_server_barrels.len()
649 + self.misplaced_directives.len()
650 + self.route_collisions.len()
651 + self.dynamic_segment_name_conflicts.len()
652 }
653}
654
655struct BaselineFileExportKeys {
656 unused_files: Vec<String>,
657 unused_exports: Vec<String>,
658 unused_types: Vec<String>,
659 private_type_leaks: Vec<String>,
660 invalid_client_exports: Vec<String>,
661 mixed_client_server_barrels: Vec<String>,
662 misplaced_directives: Vec<String>,
663 route_collisions: Vec<String>,
664 dynamic_segment_name_conflicts: Vec<String>,
665}
666
667fn baseline_file_export_keys(
668 results: &crate::results::AnalysisResults,
669 root: &Path,
670) -> BaselineFileExportKeys {
671 BaselineFileExportKeys {
672 unused_files: results
673 .unused_files
674 .iter()
675 .map(|f| relative_path(&f.file.path, root))
676 .collect(),
677 unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
678 unused_types: unused_type_baseline_keys(&results.unused_types, root),
679 private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
680 invalid_client_exports: invalid_client_export_baseline_keys(
681 &results.invalid_client_exports,
682 root,
683 ),
684 mixed_client_server_barrels: barrel_baseline_keys(
685 &results.mixed_client_server_barrels,
686 root,
687 ),
688 misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
689 route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
690 dynamic_segment_name_conflicts: results
691 .dynamic_segment_name_conflicts
692 .iter()
693 .map(|c| {
694 format!(
695 "{}:{}",
696 relative_path(&c.conflict.path, root),
697 c.conflict.position
698 )
699 })
700 .collect(),
701 }
702}
703
704fn unused_export_baseline_keys(
705 items: &[crate::results::UnusedExportFinding],
706 root: &Path,
707) -> Vec<String> {
708 items
709 .iter()
710 .map(|e| {
711 format!(
712 "{}:{}",
713 relative_path(&e.export.path, root),
714 e.export.export_name
715 )
716 })
717 .collect()
718}
719
720fn unused_type_baseline_keys(
721 items: &[crate::results::UnusedTypeFinding],
722 root: &Path,
723) -> Vec<String> {
724 items
725 .iter()
726 .map(|e| {
727 format!(
728 "{}:{}",
729 relative_path(&e.export.path, root),
730 e.export.export_name
731 )
732 })
733 .collect()
734}
735
736fn invalid_client_export_baseline_keys(
737 items: &[crate::results::InvalidClientExportFinding],
738 root: &Path,
739) -> Vec<String> {
740 items
741 .iter()
742 .map(|e| {
743 format!(
744 "{}:{}",
745 relative_path(&e.export.path, root),
746 e.export.export_name
747 )
748 })
749 .collect()
750}
751
752fn private_type_leak_baseline_keys(
753 items: &[crate::results::PrivateTypeLeakFinding],
754 root: &Path,
755) -> Vec<String> {
756 items
757 .iter()
758 .map(|e| {
759 format!(
760 "{}:{}->{}",
761 relative_path(&e.leak.path, root),
762 e.leak.export_name,
763 e.leak.type_name
764 )
765 })
766 .collect()
767}
768
769fn barrel_baseline_keys(
770 items: &[crate::results::MixedClientServerBarrelFinding],
771 root: &Path,
772) -> Vec<String> {
773 items
774 .iter()
775 .map(|b| {
776 format!(
777 "{}:{}:{}",
778 relative_path(&b.barrel.path, root),
779 b.barrel.client_origin,
780 b.barrel.server_origin
781 )
782 })
783 .collect()
784}
785
786fn directive_baseline_keys(
787 items: &[crate::results::MisplacedDirectiveFinding],
788 root: &Path,
789) -> Vec<String> {
790 items
791 .iter()
792 .map(|d| {
793 format!(
794 "{}:{}:{}",
795 relative_path(&d.directive_site.path, root),
796 d.directive_site.line,
797 d.directive_site.directive
798 )
799 })
800 .collect()
801}
802
803fn route_collision_baseline_keys(
804 items: &[crate::results::RouteCollisionFinding],
805 root: &Path,
806) -> Vec<String> {
807 items
808 .iter()
809 .map(|c| {
810 format!(
811 "{}:{}",
812 relative_path(&c.collision.path, root),
813 c.collision.url
814 )
815 })
816 .collect()
817}
818
819struct BaselineMemberImportKeys {
820 unused_enum_members: Vec<String>,
821 unused_class_members: Vec<String>,
822 unused_store_members: Vec<String>,
823 unprovided_injects: Vec<String>,
824 unrendered_components: Vec<String>,
825 unused_component_props: Vec<String>,
826 unused_component_emits: Vec<String>,
827 unused_component_inputs: Vec<String>,
828 unused_component_outputs: Vec<String>,
829 unused_svelte_events: Vec<String>,
830 unused_server_actions: Vec<String>,
831 unused_load_data_keys: Vec<String>,
832 unresolved_imports: Vec<String>,
833 duplicate_exports: Vec<String>,
834 stale_suppressions: Vec<String>,
835}
836
837fn baseline_member_import_keys(
838 results: &crate::results::AnalysisResults,
839 root: &Path,
840) -> BaselineMemberImportKeys {
841 BaselineMemberImportKeys {
842 unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
843 unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
844 unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
845 unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
846 unrendered_components: component_baseline_keys(&results.unrendered_components, root),
847 unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
848 unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
849 unused_component_inputs: component_input_baseline_keys(
850 &results.unused_component_inputs,
851 root,
852 ),
853 unused_component_outputs: component_output_baseline_keys(
854 &results.unused_component_outputs,
855 root,
856 ),
857 unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
858 unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
859 unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
860 unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
861 duplicate_exports: results
862 .duplicate_exports
863 .iter()
864 .map(|d| duplicate_export_key(&d.export, root))
865 .collect(),
866 stale_suppressions: results
867 .stale_suppressions
868 .iter()
869 .map(|s| stale_suppression_baseline_key(s, root))
870 .collect(),
871 }
872}
873
874fn stale_suppression_baseline_key(
875 suppression: &crate::results::StaleSuppression,
876 root: &Path,
877) -> String {
878 let rule_id = if suppression.missing_reason {
879 "missing-suppression-reason"
880 } else {
881 "stale-suppression"
882 };
883 format!(
884 "{rule_id}:{}:{}",
885 relative_path(&suppression.path, root),
886 suppression.line
887 )
888}
889
890fn enum_member_baseline_keys(
891 items: &[crate::results::UnusedEnumMemberFinding],
892 root: &Path,
893) -> Vec<String> {
894 items
895 .iter()
896 .map(|m| unused_member_baseline_key(&m.member, root))
897 .collect()
898}
899
900fn class_member_baseline_keys(
901 items: &[crate::results::UnusedClassMemberFinding],
902 root: &Path,
903) -> Vec<String> {
904 items
905 .iter()
906 .map(|m| unused_member_baseline_key(&m.member, root))
907 .collect()
908}
909
910fn store_member_baseline_keys(
911 items: &[crate::results::UnusedStoreMemberFinding],
912 root: &Path,
913) -> Vec<String> {
914 items
915 .iter()
916 .map(|m| unused_member_baseline_key(&m.member, root))
917 .collect()
918}
919
920fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
921 format!(
922 "{}:{}.{}",
923 relative_path(&member.path, root),
924 member.parent_name,
925 member.member_name
926 )
927}
928
929fn inject_baseline_keys(
930 items: &[crate::results::UnprovidedInjectFinding],
931 root: &Path,
932) -> Vec<String> {
933 items
934 .iter()
935 .map(|f| {
936 format!(
937 "{}:{}",
938 relative_path(&f.inject.path, root),
939 f.inject.key_name
940 )
941 })
942 .collect()
943}
944
945fn component_baseline_keys(
946 items: &[crate::results::UnrenderedComponentFinding],
947 root: &Path,
948) -> Vec<String> {
949 items
950 .iter()
951 .map(|c| {
952 format!(
953 "{}:{}",
954 relative_path(&c.component.path, root),
955 c.component.component_name
956 )
957 })
958 .collect()
959}
960
961fn component_prop_baseline_keys(
962 items: &[crate::results::UnusedComponentPropFinding],
963 root: &Path,
964) -> Vec<String> {
965 items
966 .iter()
967 .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
968 .collect()
969}
970
971fn component_emit_baseline_keys(
972 items: &[crate::results::UnusedComponentEmitFinding],
973 root: &Path,
974) -> Vec<String> {
975 items
976 .iter()
977 .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
978 .collect()
979}
980
981fn component_input_baseline_keys(
982 items: &[crate::results::UnusedComponentInputFinding],
983 root: &Path,
984) -> Vec<String> {
985 items
986 .iter()
987 .map(|i| {
988 format!(
989 "{}:{}",
990 relative_path(&i.input.path, root),
991 i.input.input_name
992 )
993 })
994 .collect()
995}
996
997fn component_output_baseline_keys(
998 items: &[crate::results::UnusedComponentOutputFinding],
999 root: &Path,
1000) -> Vec<String> {
1001 items
1002 .iter()
1003 .map(|o| {
1004 format!(
1005 "{}:{}",
1006 relative_path(&o.output.path, root),
1007 o.output.output_name
1008 )
1009 })
1010 .collect()
1011}
1012
1013fn svelte_event_baseline_keys(
1014 items: &[crate::results::UnusedSvelteEventFinding],
1015 root: &Path,
1016) -> Vec<String> {
1017 items
1018 .iter()
1019 .map(|e| {
1020 format!(
1021 "{}:{}",
1022 relative_path(&e.event.path, root),
1023 e.event.event_name
1024 )
1025 })
1026 .collect()
1027}
1028
1029fn server_action_baseline_keys(
1030 items: &[crate::results::UnusedServerActionFinding],
1031 root: &Path,
1032) -> Vec<String> {
1033 items
1034 .iter()
1035 .map(|a| {
1036 format!(
1037 "{}:{}",
1038 relative_path(&a.action.path, root),
1039 a.action.action_name
1040 )
1041 })
1042 .collect()
1043}
1044
1045fn load_data_key_baseline_keys(
1046 items: &[crate::results::UnusedLoadDataKeyFinding],
1047 root: &Path,
1048) -> Vec<String> {
1049 items
1050 .iter()
1051 .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
1052 .collect()
1053}
1054
1055fn unresolved_import_baseline_keys(
1056 items: &[crate::results::UnresolvedImportFinding],
1057 root: &Path,
1058) -> Vec<String> {
1059 items
1060 .iter()
1061 .map(|i| {
1062 format!(
1063 "{}:{}",
1064 relative_path(&i.import.path, root),
1065 i.import.specifier
1066 )
1067 })
1068 .collect()
1069}
1070
1071struct BaselineDependencyKeys {
1072 unused: Vec<String>,
1073 unused_dev: Vec<String>,
1074 unused_optional: Vec<String>,
1075 unlisted: Vec<String>,
1076 type_only: Vec<String>,
1077 test_only: Vec<String>,
1078 dev_in_prod: Vec<String>,
1079}
1080
1081fn baseline_dependency_keys(
1082 results: &crate::results::AnalysisResults,
1083 root: &Path,
1084) -> BaselineDependencyKeys {
1085 BaselineDependencyKeys {
1086 unused: results
1087 .unused_dependencies
1088 .iter()
1089 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1090 .collect(),
1091 unused_dev: results
1092 .unused_dev_dependencies
1093 .iter()
1094 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1095 .collect(),
1096 unused_optional: results
1097 .unused_optional_dependencies
1098 .iter()
1099 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1100 .collect(),
1101 unlisted: results
1102 .unlisted_dependencies
1103 .iter()
1104 .map(|d| d.dep.package_name.clone())
1105 .collect(),
1106 type_only: results
1107 .type_only_dependencies
1108 .iter()
1109 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1110 .collect(),
1111 test_only: results
1112 .test_only_dependencies
1113 .iter()
1114 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1115 .collect(),
1116 dev_in_prod: results
1117 .dev_dependencies_in_production
1118 .iter()
1119 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1120 .collect(),
1121 }
1122}
1123
1124struct BaselineGraphKeys {
1125 circular_dependencies: Vec<String>,
1126 re_export_cycles: Vec<String>,
1127 boundary_violations: Vec<String>,
1128 boundary_coverage_violations: Vec<String>,
1129 boundary_call_violations: Vec<String>,
1130 policy_violations: Vec<String>,
1131}
1132
1133fn baseline_graph_keys(
1134 results: &crate::results::AnalysisResults,
1135 root: &Path,
1136) -> BaselineGraphKeys {
1137 BaselineGraphKeys {
1138 circular_dependencies: results
1139 .circular_dependencies
1140 .iter()
1141 .map(|c| circular_dep_key(&c.cycle, root))
1142 .collect(),
1143 re_export_cycles: results
1144 .re_export_cycles
1145 .iter()
1146 .map(|c| re_export_cycle_key(&c.cycle, root))
1147 .collect(),
1148 boundary_violations: results
1149 .boundary_violations
1150 .iter()
1151 .map(|v| boundary_violation_key(&v.violation, root))
1152 .collect(),
1153 boundary_coverage_violations: results
1154 .boundary_coverage_violations
1155 .iter()
1156 .map(|v| relative_path(&v.violation.path, root))
1157 .collect(),
1158 boundary_call_violations: results
1159 .boundary_call_violations
1160 .iter()
1161 .map(|v| boundary_call_violation_key(&v.violation, root))
1162 .collect(),
1163 policy_violations: results
1164 .policy_violations
1165 .iter()
1166 .map(|v| policy_violation_key(&v.violation, root))
1167 .collect(),
1168 }
1169}
1170
1171struct BaselineCatalogKeys {
1172 unused_catalog_entries: Vec<String>,
1173 empty_catalog_groups: Vec<String>,
1174 unresolved_catalog_references: Vec<String>,
1175 unused_dependency_overrides: Vec<String>,
1176 misconfigured_dependency_overrides: Vec<String>,
1177}
1178
1179fn baseline_catalog_keys(
1180 results: &crate::results::AnalysisResults,
1181 root: &Path,
1182) -> BaselineCatalogKeys {
1183 BaselineCatalogKeys {
1184 unused_catalog_entries: results
1185 .unused_catalog_entries
1186 .iter()
1187 .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
1188 .collect(),
1189 empty_catalog_groups: results
1190 .empty_catalog_groups
1191 .iter()
1192 .map(|g| g.group.catalog_name.clone())
1193 .collect(),
1194 unresolved_catalog_references: results
1195 .unresolved_catalog_references
1196 .iter()
1197 .map(|r| {
1198 format!(
1199 "{}:{}:{}:{}",
1200 relative_path(&r.reference.path, root),
1201 r.reference.line,
1202 r.reference.catalog_name,
1203 r.reference.entry_name,
1204 )
1205 })
1206 .collect(),
1207 unused_dependency_overrides: results
1208 .unused_dependency_overrides
1209 .iter()
1210 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1211 .collect(),
1212 misconfigured_dependency_overrides: results
1213 .misconfigured_dependency_overrides
1214 .iter()
1215 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1216 .collect(),
1217 }
1218}
1219
1220fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
1222 format!(
1223 "{}->{}",
1224 relative_path(&v.from_path, root),
1225 relative_path(&v.to_path, root),
1226 )
1227}
1228
1229fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
1231 format!("{}:{}", relative_path(&v.path, root), v.callee)
1232}
1233
1234fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
1238 format!(
1239 "{}:{}/{}:{}",
1240 relative_path(&v.path, root),
1241 v.pack,
1242 v.rule_id,
1243 v.matched
1244 )
1245}
1246
1247fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
1249 let mut locs: Vec<String> = dup
1250 .locations
1251 .iter()
1252 .map(|l| relative_path(&l.path, root))
1253 .collect();
1254 locs.sort();
1255 format!("{}|{}", dup.export_name, locs.join("|"))
1256}
1257
1258fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
1260 let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
1261 paths.sort();
1262 paths.join("->")
1263}
1264
1265fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
1271 let kind = match cycle.kind {
1272 crate::results::ReExportCycleKind::MultiNode => "multi-node",
1273 crate::results::ReExportCycleKind::SelfLoop => "self-loop",
1274 };
1275 let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
1276 paths.sort();
1277 format!("{kind}:{}", paths.join("<->"))
1278}
1279
1280fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
1281 format!(
1282 "{}:{}->{}",
1283 relative_path(&leak.path, root),
1284 leak.export_name,
1285 leak.type_name
1286 )
1287}
1288
1289fn filter_private_type_leaks(
1290 leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
1291 baseline_keys: &[String],
1292 root: &Path,
1293) {
1294 let baseline_private_type_leaks: FxHashSet<&str> =
1295 baseline_keys.iter().map(String::as_str).collect();
1296 leaks.retain(|entry| {
1297 let key = private_type_leak_key(&entry.leak, root);
1298 !baseline_private_type_leaks.contains(key.as_str())
1299 });
1300}
1301
1302struct BaselineFilterContext<'a> {
1303 baseline: &'a BaselineData,
1304 root: &'a Path,
1305}
1306
1307impl BaselineFilterContext<'_> {
1308 fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
1309 let baseline_circular: FxHashSet<&str> = self
1310 .baseline
1311 .circular_dependencies
1312 .iter()
1313 .map(String::as_str)
1314 .collect();
1315 results.circular_dependencies.retain(|cycle| {
1316 let key = circular_dep_key(&cycle.cycle, self.root);
1317 !baseline_circular.contains(key.as_str())
1318 });
1319
1320 let baseline_re_export_cycles: FxHashSet<&str> = self
1321 .baseline
1322 .re_export_cycles
1323 .iter()
1324 .map(String::as_str)
1325 .collect();
1326 results.re_export_cycles.retain(|cycle| {
1327 let key = re_export_cycle_key(&cycle.cycle, self.root);
1328 !baseline_re_export_cycles.contains(key.as_str())
1329 });
1330
1331 self.filter_unused_members(results);
1332 self.filter_unresolved_and_exports(results);
1333 }
1334
1335 fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
1336 self.filter_enum_class_store_members(results);
1337 self.filter_component_surface_members(results);
1338 self.filter_route_action_members(results);
1339 }
1340
1341 fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1342 let baseline_enum_members: FxHashSet<&str> = self
1343 .baseline
1344 .unused_enum_members
1345 .iter()
1346 .map(String::as_str)
1347 .collect();
1348 results.unused_enum_members.retain(|member| {
1349 let key = format!(
1350 "{}:{}.{}",
1351 relative_path(&member.member.path, self.root),
1352 member.member.parent_name,
1353 member.member.member_name
1354 );
1355 !baseline_enum_members.contains(key.as_str())
1356 });
1357
1358 let baseline_class_members: FxHashSet<&str> = self
1359 .baseline
1360 .unused_class_members
1361 .iter()
1362 .map(String::as_str)
1363 .collect();
1364 results.unused_class_members.retain(|member| {
1365 let key = format!(
1366 "{}:{}.{}",
1367 relative_path(&member.member.path, self.root),
1368 member.member.parent_name,
1369 member.member.member_name
1370 );
1371 !baseline_class_members.contains(key.as_str())
1372 });
1373
1374 let baseline_store_members: FxHashSet<&str> = self
1375 .baseline
1376 .unused_store_members
1377 .iter()
1378 .map(String::as_str)
1379 .collect();
1380 results.unused_store_members.retain(|member| {
1381 let key = format!(
1382 "{}:{}.{}",
1383 relative_path(&member.member.path, self.root),
1384 member.member.parent_name,
1385 member.member.member_name
1386 );
1387 !baseline_store_members.contains(key.as_str())
1388 });
1389 }
1390
1391 fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1392 retain_new_by_keys(
1393 &mut results.unprovided_injects,
1394 &self.baseline.unprovided_injects,
1395 self.root,
1396 inject_baseline_keys,
1397 );
1398 retain_new_by_keys(
1399 &mut results.unrendered_components,
1400 &self.baseline.unrendered_components,
1401 self.root,
1402 component_baseline_keys,
1403 );
1404 retain_new_by_keys(
1405 &mut results.unused_component_props,
1406 &self.baseline.unused_component_props,
1407 self.root,
1408 component_prop_baseline_keys,
1409 );
1410 retain_new_by_keys(
1411 &mut results.unused_component_emits,
1412 &self.baseline.unused_component_emits,
1413 self.root,
1414 component_emit_baseline_keys,
1415 );
1416 retain_new_by_keys(
1417 &mut results.unused_component_inputs,
1418 &self.baseline.unused_component_inputs,
1419 self.root,
1420 component_input_baseline_keys,
1421 );
1422 retain_new_by_keys(
1423 &mut results.unused_component_outputs,
1424 &self.baseline.unused_component_outputs,
1425 self.root,
1426 component_output_baseline_keys,
1427 );
1428 retain_new_by_keys(
1429 &mut results.unused_svelte_events,
1430 &self.baseline.unused_svelte_events,
1431 self.root,
1432 svelte_event_baseline_keys,
1433 );
1434 }
1435
1436 fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1437 let baseline_unused_server_actions: FxHashSet<&str> = self
1438 .baseline
1439 .unused_server_actions
1440 .iter()
1441 .map(String::as_str)
1442 .collect();
1443 results.unused_server_actions.retain(|finding| {
1444 let key = format!(
1445 "{}:{}",
1446 relative_path(&finding.action.path, self.root),
1447 finding.action.action_name
1448 );
1449 !baseline_unused_server_actions.contains(key.as_str())
1450 });
1451
1452 let baseline_unused_load_data_keys: FxHashSet<&str> = self
1453 .baseline
1454 .unused_load_data_keys
1455 .iter()
1456 .map(String::as_str)
1457 .collect();
1458 results.unused_load_data_keys.retain(|finding| {
1459 let key = format!(
1460 "{}:{}",
1461 relative_path(&finding.key.path, self.root),
1462 finding.key.key_name
1463 );
1464 !baseline_unused_load_data_keys.contains(key.as_str())
1465 });
1466 }
1467
1468 fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1469 let baseline_unresolved: FxHashSet<&str> = self
1470 .baseline
1471 .unresolved_imports
1472 .iter()
1473 .map(String::as_str)
1474 .collect();
1475 results.unresolved_imports.retain(|import| {
1476 let key = format!(
1477 "{}:{}",
1478 relative_path(&import.import.path, self.root),
1479 import.import.specifier
1480 );
1481 !baseline_unresolved.contains(key.as_str())
1482 });
1483
1484 let baseline_unlisted: FxHashSet<&str> = self
1485 .baseline
1486 .unlisted_dependencies
1487 .iter()
1488 .map(String::as_str)
1489 .collect();
1490 results
1491 .unlisted_dependencies
1492 .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1493
1494 let baseline_dup_exports: FxHashSet<&str> = self
1495 .baseline
1496 .duplicate_exports
1497 .iter()
1498 .map(String::as_str)
1499 .collect();
1500 results.duplicate_exports.retain(|duplicate| {
1501 let key = duplicate_export_key(&duplicate.export, self.root);
1502 !baseline_dup_exports.contains(key.as_str())
1503 });
1504 }
1505
1506 fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1507 let baseline_optional_deps: FxHashSet<&str> = self
1508 .baseline
1509 .unused_optional_dependencies
1510 .iter()
1511 .map(String::as_str)
1512 .collect();
1513 results.unused_optional_dependencies.retain(|dep| {
1514 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1515 !baseline_contains_dependency(
1516 &baseline_optional_deps,
1517 &dep.dep.package_name,
1518 key.as_str(),
1519 )
1520 });
1521
1522 self.filter_type_and_test_only_dependencies(results);
1523 }
1524
1525 fn filter_type_and_test_only_dependencies(
1526 &self,
1527 results: &mut crate::results::AnalysisResults,
1528 ) {
1529 let baseline_type_only: FxHashSet<&str> = self
1530 .baseline
1531 .type_only_dependencies
1532 .iter()
1533 .map(String::as_str)
1534 .collect();
1535 results.type_only_dependencies.retain(|dep| {
1536 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1537 !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1538 });
1539
1540 let baseline_test_only: FxHashSet<&str> = self
1541 .baseline
1542 .test_only_dependencies
1543 .iter()
1544 .map(String::as_str)
1545 .collect();
1546 results.test_only_dependencies.retain(|dep| {
1547 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1548 !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1549 });
1550
1551 let baseline_dev_in_prod: FxHashSet<&str> = self
1552 .baseline
1553 .dev_dependencies_in_production
1554 .iter()
1555 .map(String::as_str)
1556 .collect();
1557 results.dev_dependencies_in_production.retain(|dep| {
1558 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1559 !baseline_contains_dependency(
1560 &baseline_dev_in_prod,
1561 &dep.dep.package_name,
1562 key.as_str(),
1563 )
1564 });
1565 }
1566
1567 fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1568 let baseline_boundary: FxHashSet<&str> = self
1569 .baseline
1570 .boundary_violations
1571 .iter()
1572 .map(String::as_str)
1573 .collect();
1574 results.boundary_violations.retain(|violation| {
1575 let key = boundary_violation_key(&violation.violation, self.root);
1576 !baseline_boundary.contains(key.as_str())
1577 });
1578
1579 self.filter_boundary_details(results);
1580 self.filter_stale_suppressions(results);
1581 self.filter_invalid_client_exports(results);
1582 self.filter_mixed_client_server_barrels(results);
1583 self.filter_misplaced_directives(results);
1584 self.filter_route_collisions(results);
1585 self.filter_dynamic_segment_name_conflicts(results);
1586 }
1587
1588 fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1589 let baseline_invalid: FxHashSet<&str> = self
1590 .baseline
1591 .invalid_client_exports
1592 .iter()
1593 .map(String::as_str)
1594 .collect();
1595 results.invalid_client_exports.retain(|finding| {
1596 let key = format!(
1597 "{}:{}",
1598 relative_path(&finding.export.path, self.root),
1599 finding.export.export_name
1600 );
1601 !baseline_invalid.contains(key.as_str())
1602 });
1603 }
1604
1605 fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1606 let baseline_barrels: FxHashSet<&str> = self
1607 .baseline
1608 .mixed_client_server_barrels
1609 .iter()
1610 .map(String::as_str)
1611 .collect();
1612 results.mixed_client_server_barrels.retain(|finding| {
1613 let key = format!(
1614 "{}:{}:{}",
1615 relative_path(&finding.barrel.path, self.root),
1616 finding.barrel.client_origin,
1617 finding.barrel.server_origin
1618 );
1619 !baseline_barrels.contains(key.as_str())
1620 });
1621 }
1622
1623 fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1624 let baseline_directives: FxHashSet<&str> = self
1625 .baseline
1626 .misplaced_directives
1627 .iter()
1628 .map(String::as_str)
1629 .collect();
1630 results.misplaced_directives.retain(|finding| {
1631 let key = format!(
1632 "{}:{}:{}",
1633 relative_path(&finding.directive_site.path, self.root),
1634 finding.directive_site.line,
1635 finding.directive_site.directive
1636 );
1637 !baseline_directives.contains(key.as_str())
1638 });
1639 }
1640
1641 fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1642 let baseline_collisions: FxHashSet<&str> = self
1643 .baseline
1644 .route_collisions
1645 .iter()
1646 .map(String::as_str)
1647 .collect();
1648 results.route_collisions.retain(|finding| {
1649 let key = format!(
1650 "{}:{}",
1651 relative_path(&finding.collision.path, self.root),
1652 finding.collision.url
1653 );
1654 !baseline_collisions.contains(key.as_str())
1655 });
1656 }
1657
1658 fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1659 let baseline_conflicts: FxHashSet<&str> = self
1660 .baseline
1661 .dynamic_segment_name_conflicts
1662 .iter()
1663 .map(String::as_str)
1664 .collect();
1665 results.dynamic_segment_name_conflicts.retain(|finding| {
1666 let key = format!(
1667 "{}:{}",
1668 relative_path(&finding.conflict.path, self.root),
1669 finding.conflict.position
1670 );
1671 !baseline_conflicts.contains(key.as_str())
1672 });
1673 }
1674
1675 fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1676 let baseline_boundary_coverage: FxHashSet<&str> = self
1677 .baseline
1678 .boundary_coverage_violations
1679 .iter()
1680 .map(String::as_str)
1681 .collect();
1682 results.boundary_coverage_violations.retain(|violation| {
1683 let key = relative_path(&violation.violation.path, self.root);
1684 !baseline_boundary_coverage.contains(key.as_str())
1685 });
1686
1687 let baseline_boundary_calls: FxHashSet<&str> = self
1688 .baseline
1689 .boundary_call_violations
1690 .iter()
1691 .map(String::as_str)
1692 .collect();
1693 results.boundary_call_violations.retain(|violation| {
1694 let key = boundary_call_violation_key(&violation.violation, self.root);
1695 !baseline_boundary_calls.contains(key.as_str())
1696 });
1697 }
1698
1699 fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1700 let baseline_stale: FxHashSet<&str> = self
1701 .baseline
1702 .stale_suppressions
1703 .iter()
1704 .map(String::as_str)
1705 .collect();
1706 results.stale_suppressions.retain(|suppression| {
1707 let key = stale_suppression_baseline_key(suppression, self.root);
1708 let legacy_key = format!(
1709 "{}:{}",
1710 relative_path(&suppression.path, self.root),
1711 suppression.line
1712 );
1713 !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
1714 });
1715 }
1716
1717 fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
1718 let baseline_catalog: FxHashSet<&str> = self
1719 .baseline
1720 .unused_catalog_entries
1721 .iter()
1722 .map(String::as_str)
1723 .collect();
1724 results.unused_catalog_entries.retain(|entry| {
1725 let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
1726 !baseline_catalog.contains(key.as_str())
1727 });
1728
1729 let baseline_empty_catalog_groups: FxHashSet<&str> = self
1730 .baseline
1731 .empty_catalog_groups
1732 .iter()
1733 .map(String::as_str)
1734 .collect();
1735 results.empty_catalog_groups.retain(|group| {
1736 !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
1737 });
1738
1739 self.filter_pnpm_references_and_overrides(results);
1740 }
1741
1742 fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
1743 let baseline_unresolved: FxHashSet<&str> = self
1744 .baseline
1745 .unresolved_catalog_references
1746 .iter()
1747 .map(String::as_str)
1748 .collect();
1749 results.unresolved_catalog_references.retain(|reference| {
1750 let key = format!(
1751 "{}:{}:{}:{}",
1752 relative_path(&reference.reference.path, self.root),
1753 reference.reference.line,
1754 reference.reference.catalog_name,
1755 reference.reference.entry_name,
1756 );
1757 !baseline_unresolved.contains(key.as_str())
1758 });
1759
1760 self.filter_pnpm_overrides(results);
1761 }
1762
1763 fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
1764 let baseline_unused_overrides: FxHashSet<&str> = self
1765 .baseline
1766 .unused_dependency_overrides
1767 .iter()
1768 .map(String::as_str)
1769 .collect();
1770 results
1771 .unused_dependency_overrides
1772 .retain(|override_entry| {
1773 let key = format!(
1774 "{}:{}",
1775 override_entry.entry.source, override_entry.entry.raw_key
1776 );
1777 !baseline_unused_overrides.contains(key.as_str())
1778 });
1779
1780 let baseline_misconfigured_overrides: FxHashSet<&str> = self
1781 .baseline
1782 .misconfigured_dependency_overrides
1783 .iter()
1784 .map(String::as_str)
1785 .collect();
1786 results
1787 .misconfigured_dependency_overrides
1788 .retain(|override_entry| {
1789 let key = format!(
1790 "{}:{}",
1791 override_entry.entry.source, override_entry.entry.raw_key
1792 );
1793 !baseline_misconfigured_overrides.contains(key.as_str())
1794 });
1795 }
1796}
1797
1798pub fn filter_new_issues(
1800 mut results: crate::results::AnalysisResults,
1801 baseline: &BaselineData,
1802 root: &Path,
1803) -> crate::results::AnalysisResults {
1804 let baseline_files: FxHashSet<&str> =
1805 baseline.unused_files.iter().map(String::as_str).collect();
1806 let baseline_exports: FxHashSet<&str> =
1807 baseline.unused_exports.iter().map(String::as_str).collect();
1808 let baseline_types: FxHashSet<&str> =
1809 baseline.unused_types.iter().map(String::as_str).collect();
1810 let baseline_deps: FxHashSet<&str> = baseline
1811 .unused_dependencies
1812 .iter()
1813 .map(String::as_str)
1814 .collect();
1815 let baseline_dev_deps: FxHashSet<&str> = baseline
1816 .unused_dev_dependencies
1817 .iter()
1818 .map(String::as_str)
1819 .collect();
1820
1821 results
1822 .unused_files
1823 .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
1824 results.unused_exports.retain(|e| {
1825 let key = format!(
1826 "{}:{}",
1827 relative_path(&e.export.path, root),
1828 e.export.export_name
1829 );
1830 !baseline_exports.contains(key.as_str())
1831 });
1832 results.unused_types.retain(|e| {
1833 let key = format!(
1834 "{}:{}",
1835 relative_path(&e.export.path, root),
1836 e.export.export_name
1837 );
1838 !baseline_types.contains(key.as_str())
1839 });
1840 filter_private_type_leaks(
1841 &mut results.private_type_leaks,
1842 &baseline.private_type_leaks,
1843 root,
1844 );
1845 results.unused_dependencies.retain(|d| {
1846 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1847 !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
1848 });
1849 results.unused_dev_dependencies.retain(|d| {
1850 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1851 !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
1852 });
1853
1854 let filter = BaselineFilterContext { baseline, root };
1855 filter.filter_cycles_and_members(&mut results);
1856 filter.filter_dependency_variants(&mut results);
1857 filter.filter_boundaries_and_suppressions(&mut results);
1858 filter.filter_pnpm_entries(&mut results);
1859
1860 results
1861}
1862
1863#[derive(Default, serde::Serialize, serde::Deserialize)]
1874pub struct DuplicationBaselineData {
1875 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
1878 kind: Option<BaselineKind>,
1879 #[serde(default)]
1881 pub clone_groups: Vec<String>,
1882 #[serde(default)]
1884 pub clone_fingerprints: Vec<String>,
1885 #[serde(default)]
1887 pub normalized_clone_fingerprints: Vec<String>,
1888}
1889
1890impl DuplicationBaselineData {
1891 pub const DECLARED_KEYS: &'static [&'static str] = &[
1893 "clone_groups",
1894 "clone_fingerprints",
1895 "normalized_clone_fingerprints",
1896 ];
1897
1898 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
1900 let fingerprints =
1901 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
1902 Self {
1903 kind: Some(BaselineKind::Dupes),
1904 clone_groups: report
1905 .clone_groups
1906 .iter()
1907 .map(|g| clone_group_key(g, root))
1908 .collect(),
1909 clone_fingerprints: report
1910 .clone_groups
1911 .iter()
1912 .map(legacy_clone_group_fingerprint_key)
1913 .collect(),
1914 normalized_clone_fingerprints: report
1915 .clone_groups
1916 .iter()
1917 .map(|group| clone_group_fingerprint_key(group, &fingerprints))
1918 .collect(),
1919 }
1920 }
1921
1922 #[must_use]
1924 pub fn entry_count(&self) -> usize {
1925 if !self.normalized_clone_fingerprints.is_empty() {
1926 self.normalized_clone_fingerprints.len()
1927 } else if !self.clone_fingerprints.is_empty() {
1928 self.clone_fingerprints.len()
1929 } else {
1930 self.clone_groups.len()
1931 }
1932 }
1933}
1934
1935fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
1937 let mut parts: Vec<String> = group
1938 .instances
1939 .iter()
1940 .map(|i| {
1941 format!(
1942 "{}:{}-{}",
1943 relative_path(&i.file, root),
1944 i.start_line,
1945 i.end_line
1946 )
1947 })
1948 .collect();
1949 parts.sort();
1950 parts.join("|")
1951}
1952
1953fn clone_group_fingerprint_key(
1955 group: &crate::duplicates::CloneGroup,
1956 fingerprints: &crate::duplicates::CloneFingerprintSet,
1957) -> String {
1958 fingerprints.ignored_clone_key_for_group(group)
1959}
1960
1961fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
1964 let representative = group
1965 .instances
1966 .iter()
1967 .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
1968 .map_or("", |i| i.fragment.as_str());
1969 let hash = if representative.as_bytes().contains(&b'\r') {
1970 xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
1971 } else {
1972 xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
1973 };
1974 format!(
1975 "{}{:08x}:{}",
1976 crate::duplicates::FINGERPRINT_PREFIX,
1977 hash as u32,
1978 group.instances.len()
1979 )
1980}
1981
1982fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
1983 match remaining.get_mut(key) {
1984 Some(count) if *count > 0 => {
1985 *count -= 1;
1986 true
1987 }
1988 _ => false,
1989 }
1990}
1991
1992pub fn filter_new_clone_groups(
1998 mut report: DuplicationReport,
1999 baseline: &DuplicationBaselineData,
2000 root: &Path,
2001) -> DuplicationReport {
2002 if !baseline.normalized_clone_fingerprints.is_empty() {
2003 let fingerprints =
2004 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2005 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2006 for key in &baseline.normalized_clone_fingerprints {
2007 *remaining.entry(key.as_str()).or_insert(0) += 1;
2008 }
2009 report.clone_groups.retain(|group| {
2010 let key = clone_group_fingerprint_key(group, &fingerprints);
2011 !consume_baseline_key(&mut remaining, &key)
2012 });
2013 } else if baseline.clone_fingerprints.is_empty() {
2014 let baseline_keys: FxHashSet<&str> =
2015 baseline.clone_groups.iter().map(String::as_str).collect();
2016 report.clone_groups.retain(|g| {
2017 let key = clone_group_key(g, root);
2018 !baseline_keys.contains(key.as_str())
2019 });
2020 } else {
2021 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2022 for key in &baseline.clone_fingerprints {
2023 *remaining.entry(key.as_str()).or_insert(0) += 1;
2024 }
2025 report.clone_groups.retain(|group| {
2026 let key = legacy_clone_group_fingerprint_key(group);
2027 !consume_baseline_key(&mut remaining, &key)
2028 });
2029 }
2030
2031 crate::duplicates::refresh_clone_families(&mut report, root);
2032 report.stats = recompute_stats(&report);
2033
2034 report
2035}
2036
2037pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
2042 crate::duplicates::recompute_stats(report)
2043}
2044
2045#[derive(Default, serde::Serialize, serde::Deserialize)]
2059pub struct HealthBaselineData {
2060 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2063 pub(crate) kind: Option<BaselineKind>,
2064 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2066 pub(crate) findings: Vec<String>,
2067 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2069 pub(crate) finding_counts: HealthFindingCountMap,
2070 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2072 pub(crate) identity_finding_counts: HealthFindingCountMap,
2073 #[serde(default)]
2075 pub(crate) runtime_coverage_findings: Vec<String>,
2076 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2083 pub(crate) runtime_coverage_source_hashes: Vec<String>,
2084 #[serde(default)]
2086 pub(crate) target_keys: Vec<String>,
2087}
2088
2089#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2091pub struct HealthBaselineCount {
2092 count: usize,
2093}
2094
2095type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
2096
2097#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2099pub enum HealthBaselineMode {
2100 #[default]
2110 Count,
2111 Identity,
2120}
2121
2122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2123enum HealthFindingDimension {
2124 Complexity,
2125 Crap,
2126}
2127
2128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2129struct HealthFindingCategory {
2130 dimension: HealthFindingDimension,
2131 severity: fallow_output::FindingSeverity,
2132}
2133
2134impl HealthFindingCategory {
2135 const fn key(self) -> &'static str {
2136 match (self.dimension, self.severity) {
2137 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
2138 "complexity_moderate"
2139 }
2140 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
2141 "complexity_high"
2142 }
2143 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
2144 "complexity_critical"
2145 }
2146 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
2147 "crap_moderate"
2148 }
2149 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
2150 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
2151 "crap_critical"
2152 }
2153 }
2154 }
2155}
2156
2157const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
2158 HealthFindingDimension::Complexity,
2159 HealthFindingDimension::Crap,
2160];
2161
2162impl HealthBaselineData {
2163 pub const DECLARED_KEYS: &'static [&'static str] = &[
2167 "findings",
2168 "finding_counts",
2169 "identity_finding_counts",
2170 "runtime_coverage_findings",
2171 "runtime_coverage_source_hashes",
2172 "target_keys",
2173 ];
2174
2175 pub(crate) fn from_findings(
2177 findings: &[fallow_output::ComplexityViolation],
2178 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
2179 targets: &[fallow_output::RefactoringTarget],
2180 root: &Path,
2181 ) -> Self {
2182 Self {
2183 kind: Some(BaselineKind::Health),
2184 findings: Vec::new(),
2185 finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
2186 identity_finding_counts: HealthFindingCountMap::new(),
2187 runtime_coverage_findings: runtime_coverage_findings
2188 .iter()
2189 .map(|f| runtime_coverage_finding_key(f, root))
2190 .collect(),
2191 runtime_coverage_source_hashes: runtime_coverage_findings
2192 .iter()
2193 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
2194 .collect(),
2195 target_keys: targets
2196 .iter()
2197 .map(|t| target_baseline_key(t, root))
2198 .collect(),
2199 }
2200 }
2201
2202 pub(crate) fn finding_entry_count(&self) -> usize {
2203 if !self.finding_counts.is_empty() {
2204 self.finding_counts
2205 .values()
2206 .flat_map(BTreeMap::values)
2207 .map(|entry| entry.count)
2208 .sum()
2209 } else {
2210 self.findings.len()
2211 }
2212 }
2213
2214 #[must_use]
2219 pub(crate) fn with_identity(
2220 mut self,
2221 findings: &[fallow_output::ComplexityViolation],
2222 root: &Path,
2223 ) -> Self {
2224 self.identity_finding_counts =
2225 health_finding_counts(findings, root, HealthBaselineMode::Identity);
2226 self
2227 }
2228
2229 pub(crate) fn lacks_identity_data(&self) -> bool {
2232 self.identity_finding_counts.is_empty()
2233 && (!self.finding_counts.is_empty() || !self.findings.is_empty())
2234 }
2235
2236 fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
2237 match mode {
2238 HealthBaselineMode::Count => &self.finding_counts,
2239 HealthBaselineMode::Identity => &self.identity_finding_counts,
2240 }
2241 }
2242
2243 pub(crate) fn overlap_entries(
2244 &self,
2245 findings: &[fallow_output::ComplexityViolation],
2246 root: &Path,
2247 mode: HealthBaselineMode,
2248 ) -> HealthBaselineOverlap {
2249 let baseline_counts = self.counts_for(mode);
2250 if !baseline_counts.is_empty() {
2251 let current_counts = health_finding_counts(findings, root, mode);
2252 let direct = health_overlap_entry_count(¤t_counts, baseline_counts);
2253 let remapped = (mode == HealthBaselineMode::Identity)
2254 .then(|| {
2255 identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root)
2256 })
2257 .flatten();
2258 match remapped {
2259 Some(remapped_counts) => {
2260 let matched = health_overlap_entry_count(¤t_counts, &remapped_counts);
2261 HealthBaselineOverlap {
2262 matched_entries: matched,
2263 moved_entries: matched.saturating_sub(direct),
2264 }
2265 }
2266 None => HealthBaselineOverlap {
2267 matched_entries: direct,
2268 moved_entries: 0,
2269 },
2270 }
2271 } else {
2272 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
2273 HealthBaselineOverlap {
2274 matched_entries: findings
2275 .iter()
2276 .filter(|finding| {
2277 baseline_keys.contains(health_finding_key(finding, root).as_str())
2278 })
2279 .count(),
2280 moved_entries: 0,
2281 }
2282 }
2283 }
2284}
2285
2286pub(crate) struct HealthBaselineOverlap {
2289 pub(crate) matched_entries: usize,
2291 pub(crate) moved_entries: usize,
2294}
2295
2296fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
2298 format!(
2299 "{}:{}",
2300 relative_path(&target.path, root),
2301 target.category.label()
2302 )
2303}
2304
2305fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
2307 format!(
2308 "{}:{}:{}",
2309 relative_path(&finding.path, root),
2310 finding.name,
2311 finding.line
2312 )
2313}
2314
2315fn health_bucket_key(
2320 finding: &fallow_output::ComplexityViolation,
2321 root: &Path,
2322 mode: HealthBaselineMode,
2323) -> String {
2324 let path = relative_path(&finding.path, root);
2325 match mode {
2326 HealthBaselineMode::Count => path,
2327 HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
2328 }
2329}
2330
2331const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
2335
2336fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
2337 key.split_once('\0')
2338}
2339
2340fn moved_identity_bucket_remaps(
2358 baseline_counts: &HealthFindingCountMap,
2359 current_counts: &HealthFindingCountMap,
2360 root: &Path,
2361) -> Vec<(String, String)> {
2362 let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
2363 for key in current_counts.keys() {
2364 if baseline_counts.contains_key(key) {
2365 continue;
2366 }
2367 if let Some((_, name)) = identity_bucket_parts(key)
2368 && name != ANONYMOUS_FUNCTION_NAME
2369 {
2370 candidates_by_name.entry(name).or_default().push(key);
2371 }
2372 }
2373
2374 let mut proposals: Vec<(&str, &str)> = Vec::new();
2375 let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
2376 for key in baseline_counts.keys() {
2377 if current_counts.contains_key(key.as_str()) {
2378 continue;
2379 }
2380 let Some((path, name)) = identity_bucket_parts(key) else {
2381 continue;
2382 };
2383 if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
2384 continue;
2385 }
2386 if let Some(candidates) = candidates_by_name.get(name)
2387 && let [only_candidate] = candidates.as_slice()
2388 {
2389 proposals.push((key.as_str(), only_candidate));
2390 *claims.entry(only_candidate).or_default() += 1;
2391 }
2392 }
2393
2394 proposals
2395 .into_iter()
2396 .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
2397 .map(|(old, new)| (old.to_string(), new.to_string()))
2398 .collect()
2399}
2400
2401fn identity_counts_with_move_tolerance(
2406 baseline_counts: &HealthFindingCountMap,
2407 current_counts: &HealthFindingCountMap,
2408 root: &Path,
2409) -> Option<HealthFindingCountMap> {
2410 let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
2411 if remaps.is_empty() {
2412 return None;
2413 }
2414 let mut remapped = baseline_counts.clone();
2415 for (old_key, new_key) in remaps {
2416 if let Some(entry) = remapped.remove(&old_key) {
2417 remapped.insert(new_key, entry);
2418 }
2419 }
2420 Some(remapped)
2421}
2422
2423fn health_finding_counts(
2424 findings: &[fallow_output::ComplexityViolation],
2425 root: &Path,
2426 mode: HealthBaselineMode,
2427) -> HealthFindingCountMap {
2428 let mut counts = BTreeMap::new();
2429 for finding in findings {
2430 let bucket = health_bucket_key(finding, root, mode);
2431 let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
2432 for category in health_finding_categories(finding).into_iter().flatten() {
2433 file_counts
2434 .entry(category.key().to_string())
2435 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
2436 .or_insert(HealthBaselineCount { count: 1 });
2437 }
2438 }
2439 counts
2440}
2441
2442fn health_finding_categories(
2443 finding: &fallow_output::ComplexityViolation,
2444) -> [Option<HealthFindingCategory>; 2] {
2445 let complexity_category = HealthFindingCategory {
2446 dimension: HealthFindingDimension::Complexity,
2447 severity: finding.severity,
2448 };
2449 let crap_category = HealthFindingCategory {
2450 dimension: HealthFindingDimension::Crap,
2451 severity: finding.severity,
2452 };
2453 let has_complexity =
2454 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
2455 let has_crap = finding.exceeded.includes_crap();
2456 [
2457 has_complexity.then_some(complexity_category),
2458 has_crap.then_some(crap_category),
2459 ]
2460}
2461
2462fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
2463 match severity {
2464 fallow_output::FindingSeverity::Moderate => 0,
2465 fallow_output::FindingSeverity::High => 1,
2466 fallow_output::FindingSeverity::Critical => 2,
2467 }
2468}
2469
2470fn severity_counts_for_dimension(
2471 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
2472 dimension: HealthFindingDimension,
2473) -> [usize; 3] {
2474 let mut counts = [0; 3];
2475 for severity in [
2476 fallow_output::FindingSeverity::Moderate,
2477 fallow_output::FindingSeverity::High,
2478 fallow_output::FindingSeverity::Critical,
2479 ] {
2480 let category = HealthFindingCategory {
2481 dimension,
2482 severity,
2483 };
2484 counts[severity_index(severity)] = file_counts
2485 .and_then(|entries| entries.get(category.key()))
2486 .map_or(0, |entry| entry.count);
2487 }
2488 counts
2489}
2490
2491fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
2492 let mut available = baseline;
2493 let mut overflow = [false; 3];
2494
2495 for severity_idx in 0..3 {
2496 let compatible = available[severity_idx..].iter().sum::<usize>();
2497 overflow[severity_idx] = compatible < current[severity_idx];
2498
2499 let mut matched = current[severity_idx].min(compatible);
2500 for slot in available.iter_mut().skip(severity_idx) {
2501 let taken = matched.min(*slot);
2502 *slot -= taken;
2503 matched -= taken;
2504 if matched == 0 {
2505 break;
2506 }
2507 }
2508 }
2509
2510 overflow
2511}
2512
2513fn health_overflow_categories(
2514 current_counts: &HealthFindingCountMap,
2515 baseline_counts: &HealthFindingCountMap,
2516) -> FxHashMap<String, FxHashSet<&'static str>> {
2517 let mut overflow_by_path = FxHashMap::default();
2518
2519 for (path, current_file_counts) in current_counts {
2520 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
2521 let baseline_file_counts = baseline_counts.get(path);
2522
2523 for dimension in HEALTH_FINDING_DIMENSIONS {
2524 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
2525 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
2526 let overflow = overflowing_severities(current, baseline);
2527
2528 for severity in [
2529 fallow_output::FindingSeverity::Moderate,
2530 fallow_output::FindingSeverity::High,
2531 fallow_output::FindingSeverity::Critical,
2532 ] {
2533 if overflow[severity_index(severity)] {
2534 overflow_categories.insert(
2535 HealthFindingCategory {
2536 dimension,
2537 severity,
2538 }
2539 .key(),
2540 );
2541 }
2542 }
2543 }
2544
2545 if !overflow_categories.is_empty() {
2546 overflow_by_path.insert(path.clone(), overflow_categories);
2547 }
2548 }
2549
2550 overflow_by_path
2551}
2552
2553fn health_overlap_entry_count(
2554 current_counts: &HealthFindingCountMap,
2555 baseline_counts: &HealthFindingCountMap,
2556) -> usize {
2557 let mut overlap = 0;
2558
2559 for (path, baseline_file_counts) in baseline_counts {
2560 let current_file_counts = current_counts.get(path);
2561
2562 for dimension in HEALTH_FINDING_DIMENSIONS {
2563 let current_total: usize =
2564 severity_counts_for_dimension(current_file_counts, dimension)
2565 .into_iter()
2566 .sum();
2567 let baseline_total: usize =
2568 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
2569 .into_iter()
2570 .sum();
2571 overlap += current_total.min(baseline_total);
2572 }
2573 }
2574
2575 overlap
2576}
2577
2578fn runtime_coverage_finding_key(
2579 finding: &fallow_output::RuntimeCoverageFinding,
2580 _root: &Path,
2581) -> String {
2582 finding
2583 .stable_id
2584 .clone()
2585 .unwrap_or_else(|| finding.id.clone())
2586}
2587
2588fn runtime_coverage_source_hash_key(
2595 finding: &fallow_output::RuntimeCoverageFinding,
2596 root: &Path,
2597) -> Option<String> {
2598 finding.source_hash.as_deref().map(|hash| {
2599 format!(
2600 "{}\0{}\0{}",
2601 relative_path(&finding.path, root),
2602 finding.function,
2603 hash
2604 )
2605 })
2606}
2607
2608pub(crate) fn filter_new_health_findings(
2610 mut findings: Vec<fallow_output::ComplexityViolation>,
2611 baseline: &HealthBaselineData,
2612 root: &Path,
2613 mode: HealthBaselineMode,
2614) -> Vec<fallow_output::ComplexityViolation> {
2615 let baseline_counts = baseline.counts_for(mode);
2616 if !baseline_counts.is_empty() {
2617 let current_counts = health_finding_counts(&findings, root, mode);
2618 let remapped = (mode == HealthBaselineMode::Identity)
2619 .then(|| identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root))
2620 .flatten();
2621 let overflow_categories = health_overflow_categories(
2622 ¤t_counts,
2623 remapped.as_ref().unwrap_or(baseline_counts),
2624 );
2625 findings.retain(|finding| {
2626 let bucket = health_bucket_key(finding, root, mode);
2627 overflow_categories.get(&bucket).is_some_and(|categories| {
2628 health_finding_categories(finding)
2629 .into_iter()
2630 .flatten()
2631 .any(|category| categories.contains(category.key()))
2632 })
2633 });
2634 return findings;
2635 }
2636
2637 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
2638 findings.retain(|f| {
2639 let key = health_finding_key(f, root);
2640 !baseline_keys.contains(key.as_str())
2641 });
2642 findings
2643}
2644
2645pub(crate) fn filter_new_runtime_coverage_findings(
2646 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
2647 baseline: &HealthBaselineData,
2648 root: &Path,
2649) -> Vec<fallow_output::RuntimeCoverageFinding> {
2650 let baseline_keys: FxHashSet<&str> = baseline
2651 .runtime_coverage_findings
2652 .iter()
2653 .map(String::as_str)
2654 .collect();
2655 let baseline_source_hash_keys: FxHashSet<&str> = baseline
2656 .runtime_coverage_source_hashes
2657 .iter()
2658 .map(String::as_str)
2659 .collect();
2660 findings.retain(|finding| {
2661 let suppressed_by_stable_id = finding
2662 .stable_id
2663 .as_deref()
2664 .is_some_and(|id| baseline_keys.contains(id));
2665 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
2666 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
2667 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
2668 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
2669 });
2670 findings
2671}
2672
2673pub(crate) fn filter_new_health_targets(
2675 mut targets: Vec<fallow_output::RefactoringTarget>,
2676 baseline: &HealthBaselineData,
2677 root: &Path,
2678) -> Vec<fallow_output::RefactoringTarget> {
2679 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
2680 targets.retain(|t| {
2681 let key = target_baseline_key(t, root);
2682 !baseline_keys.contains(key.as_str())
2683 });
2684 targets
2685}
2686
2687#[derive(Debug, Clone, serde::Serialize)]
2689pub struct CategoryDelta {
2690 pub current: usize,
2692 pub baseline: usize,
2694 pub delta: i64,
2696}
2697
2698#[derive(Debug, Clone)]
2703pub struct BaselineDeltas {
2704 pub total_delta: i64,
2706 pub per_category: Vec<(String, CategoryDelta)>,
2708}
2709
2710#[cfg(test)]
2711mod tests {
2712 use super::*;
2713
2714 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2715 use crate::results::{
2716 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2717 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2718 UnusedFile,
2719 };
2720 use fallow_types::output_dead_code::{
2721 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2722 };
2723 use std::path::PathBuf;
2724
2725 #[test]
2726 fn stale_share_threshold_matches_the_documented_quarter() {
2727 for (baseline_entries, stale_entries, expected) in [
2728 (100, 24, false),
2729 (100, 25, true),
2730 (4, 2, true),
2731 (29, 2, false),
2732 (5, 1, false),
2733 (0, 0, false),
2734 ] {
2735 assert_eq!(
2736 stale_share_warrants_warning(baseline_entries, stale_entries),
2737 expected,
2738 "{stale_entries} of {baseline_entries} entries"
2739 );
2740 }
2741 }
2742
2743 const fn staleness(
2744 entries: usize,
2745 matched: usize,
2746 current_findings: usize,
2747 ) -> BaselineStaleness {
2748 BaselineStaleness {
2749 entries,
2750 matched,
2751 current_findings,
2752 change_scoped: false,
2753 }
2754 }
2755
2756 #[test]
2757 fn warning_is_silent_when_the_run_found_nothing() {
2758 let staleness = staleness(4, 0, 0);
2759 assert_eq!(staleness.stale_entries(), 4);
2760 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2761 }
2762
2763 #[test]
2764 fn zero_overlap_warns_when_the_run_has_findings() {
2765 assert_eq!(
2766 staleness(4, 0, 4).warning(),
2767 BaselineStalenessWarning::ZeroOverlap
2768 );
2769 }
2770
2771 #[test]
2772 fn partial_warning_needs_the_documented_quarter() {
2773 assert_eq!(
2774 staleness(100, 76, 100).warning(),
2775 BaselineStalenessWarning::None
2776 );
2777 assert_eq!(
2778 staleness(100, 75, 100).warning(),
2779 BaselineStalenessWarning::Partial
2780 );
2781 }
2782
2783 #[test]
2784 fn empty_baseline_never_warns() {
2785 assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
2786 }
2787
2788 #[test]
2789 fn change_scoped_run_never_warns_and_never_trips_the_gate() {
2790 let scoped = BaselineStaleness {
2791 change_scoped: true,
2792 ..staleness(8, 2, 8)
2793 };
2794 assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
2795 assert!(!scoped.trips_gate());
2796 }
2797
2798 #[test]
2802 fn gate_trips_on_one_stale_entry_the_warning_ignores() {
2803 let staleness = staleness(20, 19, 19);
2804 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2805 assert!(staleness.trips_gate());
2806 }
2807
2808 #[test]
2809 fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
2810 let staleness = staleness(4, 0, 0);
2811 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2812 assert!(staleness.trips_gate());
2813 }
2814
2815 #[test]
2816 fn gate_is_inert_on_an_empty_baseline() {
2817 assert!(!staleness(0, 0, 0).trips_gate());
2818 }
2819
2820 #[test]
2821 fn gate_is_inert_when_every_entry_matched() {
2822 assert!(!staleness(4, 4, 4).trips_gate());
2823 }
2824
2825 fn make_results() -> AnalysisResults {
2826 AnalysisResults {
2827 unused_files: vec![
2828 UnusedFileFinding::with_actions(UnusedFile {
2829 path: PathBuf::from("src/old.ts"),
2830 }),
2831 UnusedFileFinding::with_actions(UnusedFile {
2832 path: PathBuf::from("src/dead.ts"),
2833 }),
2834 ],
2835 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2836 path: PathBuf::from("src/utils.ts"),
2837 export_name: "helperA".to_string(),
2838 is_type_only: false,
2839 line: 5,
2840 col: 0,
2841 span_start: 40,
2842 is_re_export: false,
2843 })],
2844 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2845 path: PathBuf::from("src/types.ts"),
2846 export_name: "OldType".to_string(),
2847 is_type_only: true,
2848 line: 10,
2849 col: 0,
2850 span_start: 100,
2851 is_re_export: false,
2852 })],
2853 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2854 package_name: "lodash".to_string(),
2855 location: DependencyLocation::Dependencies,
2856 path: PathBuf::from("package.json"),
2857 line: 5,
2858 used_in_workspaces: Vec::new(),
2859 })],
2860 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2861 UnusedDependency {
2862 package_name: "jest".to_string(),
2863 location: DependencyLocation::DevDependencies,
2864 path: PathBuf::from("package.json"),
2865 line: 5,
2866 used_in_workspaces: Vec::new(),
2867 },
2868 )],
2869 ..Default::default()
2870 }
2871 }
2872
2873 #[test]
2874 fn baseline_from_results_captures_all_fields() {
2875 let results = make_results();
2876 let baseline = BaselineData::from_results(&results, Path::new(""));
2877 assert_eq!(baseline.unused_files.len(), 2);
2878 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2879 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2880 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2881 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2882 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2883 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2884 }
2885
2886 #[test]
2887 fn dependency_baseline_keys_include_package_json_path() {
2888 let root = Path::new("/repo");
2889 let results = AnalysisResults {
2890 unused_dependencies: vec![
2891 UnusedDependencyFinding::with_actions(UnusedDependency {
2892 package_name: "lodash-es".to_string(),
2893 location: DependencyLocation::Dependencies,
2894 path: PathBuf::from("/repo/packages/app-a/package.json"),
2895 line: 5,
2896 used_in_workspaces: Vec::new(),
2897 }),
2898 UnusedDependencyFinding::with_actions(UnusedDependency {
2899 package_name: "lodash-es".to_string(),
2900 location: DependencyLocation::Dependencies,
2901 path: PathBuf::from("/repo/packages/app-b/package.json"),
2902 line: 5,
2903 used_in_workspaces: Vec::new(),
2904 }),
2905 ],
2906 ..Default::default()
2907 };
2908
2909 let baseline = BaselineData::from_results(&results, root);
2910
2911 assert_eq!(
2912 baseline.unused_dependencies,
2913 vec![
2914 "packages/app-a/package.json:lodash-es",
2915 "packages/app-b/package.json:lodash-es"
2916 ]
2917 );
2918 }
2919
2920 #[test]
2921 fn dependency_baseline_filter_matches_path_before_package_name() {
2922 let root = Path::new("/repo");
2923 let results = AnalysisResults {
2924 unused_dependencies: vec![
2925 UnusedDependencyFinding::with_actions(UnusedDependency {
2926 package_name: "lodash-es".to_string(),
2927 location: DependencyLocation::Dependencies,
2928 path: PathBuf::from("/repo/packages/app-a/package.json"),
2929 line: 5,
2930 used_in_workspaces: Vec::new(),
2931 }),
2932 UnusedDependencyFinding::with_actions(UnusedDependency {
2933 package_name: "lodash-es".to_string(),
2934 location: DependencyLocation::Dependencies,
2935 path: PathBuf::from("/repo/packages/app-b/package.json"),
2936 line: 5,
2937 used_in_workspaces: Vec::new(),
2938 }),
2939 ],
2940 ..Default::default()
2941 };
2942 let baseline = BaselineData {
2943 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2944 ..BaselineData::from_results(&AnalysisResults::default(), root)
2945 };
2946
2947 let filtered = filter_new_issues(results, &baseline, root);
2948
2949 assert_eq!(filtered.unused_dependencies.len(), 1);
2950 assert_eq!(
2951 filtered.unused_dependencies[0].dep.path,
2952 PathBuf::from("/repo/packages/app-b/package.json")
2953 );
2954 }
2955
2956 #[test]
2957 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2958 let root = Path::new("/repo");
2959 let results = AnalysisResults {
2960 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2961 package_name: "lodash-es".to_string(),
2962 location: DependencyLocation::Dependencies,
2963 path: PathBuf::from("/repo/packages/app/package.json"),
2964 line: 5,
2965 used_in_workspaces: Vec::new(),
2966 })],
2967 ..Default::default()
2968 };
2969 let baseline = BaselineData {
2970 unused_dependencies: vec!["lodash-es".to_string()],
2971 ..BaselineData::from_results(&AnalysisResults::default(), root)
2972 };
2973
2974 let filtered = filter_new_issues(results, &baseline, root);
2975
2976 assert!(filtered.unused_dependencies.is_empty());
2977 }
2978
2979 #[test]
2980 fn baseline_serialization_roundtrip() {
2981 let results = make_results();
2982 let baseline = BaselineData::from_results(&results, Path::new(""));
2983 let json = serde_json::to_string(&baseline).unwrap();
2984 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2985 assert_eq!(deserialized.unused_files, baseline.unused_files);
2986 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2987 assert_eq!(deserialized.unused_types, baseline.unused_types);
2988 assert_eq!(
2989 deserialized.unused_dependencies,
2990 baseline.unused_dependencies
2991 );
2992 assert_eq!(
2993 deserialized.unused_dev_dependencies,
2994 baseline.unused_dev_dependencies
2995 );
2996 }
2997
2998 #[test]
2999 fn filter_removes_baseline_issues() {
3000 let results = make_results();
3001 let baseline = BaselineData::from_results(&results, Path::new(""));
3002 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3003 assert!(
3004 filtered.unused_files.is_empty(),
3005 "all files were in baseline"
3006 );
3007 assert!(
3008 filtered.unused_exports.is_empty(),
3009 "all exports were in baseline"
3010 );
3011 assert!(
3012 filtered.unused_types.is_empty(),
3013 "all types were in baseline"
3014 );
3015 assert!(
3016 filtered.unused_dependencies.is_empty(),
3017 "all deps were in baseline"
3018 );
3019 assert!(
3020 filtered.unused_dev_dependencies.is_empty(),
3021 "all dev deps were in baseline"
3022 );
3023 }
3024
3025 #[test]
3026 fn filter_keeps_new_issues_not_in_baseline() {
3027 let baseline = BaselineData {
3028 kind: None,
3029 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3030 unused_files: vec!["src/old.ts".to_string()],
3031 unused_exports: vec![],
3032 unused_types: vec![],
3033 private_type_leaks: vec![],
3034 unused_dependencies: vec![],
3035 unused_dev_dependencies: vec![],
3036 circular_dependencies: vec![],
3037 re_export_cycles: vec![],
3038 unused_optional_dependencies: vec![],
3039 unused_enum_members: vec![],
3040 unused_class_members: vec![],
3041 unused_store_members: vec![],
3042 unprovided_injects: vec![],
3043 unrendered_components: vec![],
3044 unused_component_props: vec![],
3045 unused_component_emits: vec![],
3046 unused_component_inputs: vec![],
3047 unused_component_outputs: vec![],
3048 unused_svelte_events: vec![],
3049 unused_server_actions: vec![],
3050 unused_load_data_keys: vec![],
3051 unresolved_imports: vec![],
3052 unlisted_dependencies: vec![],
3053 duplicate_exports: vec![],
3054 type_only_dependencies: vec![],
3055 test_only_dependencies: vec![],
3056 dev_dependencies_in_production: vec![],
3057 boundary_violations: vec![],
3058 boundary_coverage_violations: vec![],
3059 boundary_call_violations: vec![],
3060 policy_violations: vec![],
3061 stale_suppressions: vec![],
3062 unused_catalog_entries: vec![],
3063 empty_catalog_groups: vec![],
3064 unresolved_catalog_references: vec![],
3065 unused_dependency_overrides: vec![],
3066 misconfigured_dependency_overrides: vec![],
3067 invalid_client_exports: vec![],
3068 mixed_client_server_barrels: vec![],
3069 misplaced_directives: vec![],
3070 route_collisions: vec![],
3071 dynamic_segment_name_conflicts: vec![],
3072 };
3073 let results = AnalysisResults {
3074 unused_files: vec![
3075 UnusedFileFinding::with_actions(UnusedFile {
3076 path: PathBuf::from("src/old.ts"),
3077 }),
3078 UnusedFileFinding::with_actions(UnusedFile {
3079 path: PathBuf::from("src/new-dead.ts"),
3080 }),
3081 ],
3082 ..Default::default()
3083 };
3084 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3085 assert_eq!(filtered.unused_files.len(), 1);
3086 assert_eq!(
3087 filtered.unused_files[0].file.path,
3088 PathBuf::from("src/new-dead.ts")
3089 );
3090 }
3091
3092 #[test]
3093 fn filter_with_empty_baseline_keeps_all() {
3094 let baseline = BaselineData {
3095 kind: None,
3096 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3097 unused_files: vec![],
3098 unused_exports: vec![],
3099 unused_types: vec![],
3100 private_type_leaks: vec![],
3101 unused_dependencies: vec![],
3102 unused_dev_dependencies: vec![],
3103 circular_dependencies: vec![],
3104 re_export_cycles: vec![],
3105 unused_optional_dependencies: vec![],
3106 unused_enum_members: vec![],
3107 unused_class_members: vec![],
3108 unused_store_members: vec![],
3109 unprovided_injects: vec![],
3110 unrendered_components: vec![],
3111 unused_component_props: vec![],
3112 unused_component_emits: vec![],
3113 unused_component_inputs: vec![],
3114 unused_component_outputs: vec![],
3115 unused_svelte_events: vec![],
3116 unused_server_actions: vec![],
3117 unused_load_data_keys: vec![],
3118 unresolved_imports: vec![],
3119 unlisted_dependencies: vec![],
3120 duplicate_exports: vec![],
3121 type_only_dependencies: vec![],
3122 test_only_dependencies: vec![],
3123 dev_dependencies_in_production: vec![],
3124 boundary_violations: vec![],
3125 boundary_coverage_violations: vec![],
3126 boundary_call_violations: vec![],
3127 policy_violations: vec![],
3128 stale_suppressions: vec![],
3129 unused_catalog_entries: vec![],
3130 empty_catalog_groups: vec![],
3131 unresolved_catalog_references: vec![],
3132 unused_dependency_overrides: vec![],
3133 misconfigured_dependency_overrides: vec![],
3134 invalid_client_exports: vec![],
3135 mixed_client_server_barrels: vec![],
3136 misplaced_directives: vec![],
3137 route_collisions: vec![],
3138 dynamic_segment_name_conflicts: vec![],
3139 };
3140 let results = make_results();
3141 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3142 assert_eq!(filtered.unused_files.len(), 2);
3143 assert_eq!(filtered.unused_exports.len(), 1);
3144 }
3145
3146 #[test]
3147 fn filter_new_exports_by_file_and_name() {
3148 let baseline = BaselineData {
3149 kind: None,
3150 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3151 unused_files: vec![],
3152 unused_exports: vec!["src/utils.ts:helperA".to_string()],
3153 unused_types: vec![],
3154 private_type_leaks: vec![],
3155 unused_dependencies: vec![],
3156 unused_dev_dependencies: vec![],
3157 circular_dependencies: vec![],
3158 re_export_cycles: vec![],
3159 unused_optional_dependencies: vec![],
3160 unused_enum_members: vec![],
3161 unused_class_members: vec![],
3162 unused_store_members: vec![],
3163 unprovided_injects: vec![],
3164 unrendered_components: vec![],
3165 unused_component_props: vec![],
3166 unused_component_emits: vec![],
3167 unused_component_inputs: vec![],
3168 unused_component_outputs: vec![],
3169 unused_svelte_events: vec![],
3170 unused_server_actions: vec![],
3171 unused_load_data_keys: vec![],
3172 unresolved_imports: vec![],
3173 unlisted_dependencies: vec![],
3174 duplicate_exports: vec![],
3175 type_only_dependencies: vec![],
3176 test_only_dependencies: vec![],
3177 dev_dependencies_in_production: vec![],
3178 boundary_violations: vec![],
3179 boundary_coverage_violations: vec![],
3180 boundary_call_violations: vec![],
3181 policy_violations: vec![],
3182 stale_suppressions: vec![],
3183 unused_catalog_entries: vec![],
3184 empty_catalog_groups: vec![],
3185 unresolved_catalog_references: vec![],
3186 unused_dependency_overrides: vec![],
3187 misconfigured_dependency_overrides: vec![],
3188 invalid_client_exports: vec![],
3189 mixed_client_server_barrels: vec![],
3190 misplaced_directives: vec![],
3191 route_collisions: vec![],
3192 dynamic_segment_name_conflicts: vec![],
3193 };
3194 let results = AnalysisResults {
3195 unused_exports: vec![
3196 UnusedExportFinding::with_actions(UnusedExport {
3197 path: PathBuf::from("src/utils.ts"),
3198 export_name: "helperA".to_string(),
3199 is_type_only: false,
3200 line: 5,
3201 col: 0,
3202 span_start: 40,
3203 is_re_export: false,
3204 }),
3205 UnusedExportFinding::with_actions(UnusedExport {
3206 path: PathBuf::from("src/utils.ts"),
3207 export_name: "helperB".to_string(),
3208 is_type_only: false,
3209 line: 10,
3210 col: 0,
3211 span_start: 80,
3212 is_re_export: false,
3213 }),
3214 ],
3215 ..Default::default()
3216 };
3217 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3218 assert_eq!(filtered.unused_exports.len(), 1);
3219 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
3220 }
3221
3222 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
3223 let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
3224 files.sort_unstable();
3225 let fragment = format!("const source = '{}';", files.join(","));
3226 make_clone_group_with_fragment(&fragment, instances)
3227 }
3228
3229 fn make_clone_group_with_fragment(
3230 fragment: &str,
3231 instances: Vec<(&str, usize, usize)>,
3232 ) -> CloneGroup {
3233 CloneGroup {
3234 instances: instances
3235 .into_iter()
3236 .map(|(file, start, end)| CloneInstance {
3237 file: PathBuf::from(file),
3238 start_line: start,
3239 end_line: end,
3240 start_col: 0,
3241 end_col: 0,
3242 fragment: fragment.to_string(),
3243 })
3244 .collect(),
3245 token_count: 50,
3246 line_count: 10,
3247 similarity: None,
3248 }
3249 }
3250
3251 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3252 DuplicationReport {
3253 clone_groups: groups,
3254 clone_families: vec![],
3255 mirrored_directories: vec![],
3256 stats: DuplicationStats {
3257 total_files: 10,
3258 files_with_clones: 2,
3259 total_lines: 1000,
3260 duplicated_lines: 100,
3261 total_tokens: 5000,
3262 duplicated_tokens: 500,
3263 clone_groups: 1,
3264 clone_families: 0,
3265 clone_instances: 2,
3266 duplication_percentage: 10.0,
3267 clone_groups_below_min_occurrences: 0,
3268 clone_groups_ignored: 0,
3269 near_candidates_skipped: 0,
3270 },
3271 }
3272 }
3273
3274 fn normalized_clone_group_key(group: &CloneGroup) -> String {
3275 let fingerprints =
3276 crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
3277 clone_group_fingerprint_key(group, &fingerprints)
3278 }
3279
3280 #[test]
3281 fn clone_group_key_is_deterministic() {
3282 let root = Path::new("/project");
3283 let group = make_clone_group(vec![
3284 ("/project/src/a.ts", 1, 10),
3285 ("/project/src/b.ts", 5, 15),
3286 ]);
3287 let key1 = clone_group_key(&group, root);
3288 let key2 = clone_group_key(&group, root);
3289 assert_eq!(key1, key2);
3290 }
3291
3292 #[test]
3293 fn clone_group_key_is_sorted() {
3294 let root = Path::new("/project");
3295 let group_ab = make_clone_group(vec![
3296 ("/project/src/a.ts", 1, 10),
3297 ("/project/src/b.ts", 5, 15),
3298 ]);
3299 let group_ba = make_clone_group(vec![
3300 ("/project/src/b.ts", 5, 15),
3301 ("/project/src/a.ts", 1, 10),
3302 ]);
3303 assert_eq!(
3304 clone_group_key(&group_ab, root),
3305 clone_group_key(&group_ba, root),
3306 "key should be stable regardless of instance order"
3307 );
3308 }
3309
3310 #[test]
3311 fn duplication_baseline_roundtrip() {
3312 let root = Path::new("/project");
3313 let group = make_clone_group(vec![
3314 ("/project/src/a.ts", 1, 10),
3315 ("/project/src/b.ts", 5, 15),
3316 ]);
3317 let report = make_duplication_report(vec![group]);
3318 let baseline = DuplicationBaselineData::from_report(&report, root);
3319 let json = serde_json::to_string(&baseline).unwrap();
3320 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
3321 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
3322 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
3323 assert_eq!(
3324 deserialized.normalized_clone_fingerprints,
3325 baseline.normalized_clone_fingerprints
3326 );
3327 assert_eq!(
3328 baseline.normalized_clone_fingerprints.len(),
3329 1,
3330 "a saved baseline carries a normalized key per clone group"
3331 );
3332 }
3333
3334 #[test]
3342 fn the_declared_keys_are_every_key_each_format_writes() {
3343 let duplication = DuplicationBaselineData {
3344 kind: Some(BaselineKind::Dupes),
3345 clone_groups: vec!["src/a.ts:1-10".to_owned()],
3346 clone_fingerprints: vec!["abc".to_owned()],
3347 normalized_clone_fingerprints: vec!["def".to_owned()],
3348 };
3349 assert_eq!(
3350 serialized_keys_without_kind(&duplication),
3351 DuplicationBaselineData::DECLARED_KEYS
3352 );
3353
3354 let counts: HealthFindingCountMap = std::iter::once((
3355 "src/a.ts".to_owned(),
3356 std::iter::once(("complexity".to_owned(), HealthBaselineCount { count: 1 })).collect(),
3357 ))
3358 .collect();
3359 let health = HealthBaselineData {
3360 kind: Some(BaselineKind::Health),
3361 findings: vec!["src/a.ts:run:1".to_owned()],
3362 finding_counts: counts.clone(),
3363 identity_finding_counts: counts,
3364 runtime_coverage_findings: vec!["src/a.ts:run".to_owned()],
3365 runtime_coverage_source_hashes: vec!["src/a.ts\0run\0hash".to_owned()],
3366 target_keys: vec!["src/a.ts:complexity".to_owned()],
3367 };
3368 assert_eq!(
3369 serialized_keys_without_kind(&health),
3370 HealthBaselineData::DECLARED_KEYS
3371 );
3372
3373 for list in [
3374 DuplicationBaselineData::DECLARED_KEYS,
3375 HealthBaselineData::DECLARED_KEYS,
3376 BaselineData::REQUIRED_KEYS,
3377 ] {
3378 assert!(
3379 !list.contains(&"kind"),
3380 "no format may declare the key every format writes: {list:?}"
3381 );
3382 }
3383 }
3384
3385 #[test]
3390 fn the_required_dead_code_keys_are_the_ones_without_a_serde_default() {
3391 let json = serde_json::to_string(&BaselineData::from_results(
3392 &crate::results::AnalysisResults::default(),
3393 Path::new("/project"),
3394 ))
3395 .expect("baseline serializes");
3396 let serde_json::Value::Object(object) =
3397 serde_json::from_str::<serde_json::Value>(&json).expect("object")
3398 else {
3399 panic!("a baseline serializes as an object");
3400 };
3401
3402 for key in object.keys() {
3403 let mut without = object.clone();
3404 without.remove(key);
3405 let loads =
3406 serde_json::from_value::<BaselineData>(serde_json::Value::Object(without)).is_ok();
3407 assert_eq!(
3408 !loads,
3409 BaselineData::REQUIRED_KEYS.contains(&key.as_str()),
3410 "REQUIRED_KEYS must list exactly the keys a load cannot do without, and {key} \
3411 disagrees"
3412 );
3413 }
3414 }
3415
3416 #[test]
3417 fn every_saved_baseline_names_the_command_that_wrote_it() {
3418 let dead_code = serde_json::to_string(&BaselineData::from_results(
3419 &crate::results::AnalysisResults::default(),
3420 Path::new("/project"),
3421 ))
3422 .expect("baseline serializes");
3423 let dupes = serde_json::to_string(&DuplicationBaselineData::from_report(
3424 &make_duplication_report(Vec::new()),
3425 Path::new("/project"),
3426 ))
3427 .expect("baseline serializes");
3428 let health = serde_json::to_string(&HealthBaselineData::from_findings(
3429 &[],
3430 &[],
3431 &[],
3432 Path::new("/project"),
3433 ))
3434 .expect("baseline serializes");
3435
3436 for (json, kind) in [
3437 (&dead_code, BaselineKind::DeadCode),
3438 (&dupes, BaselineKind::Dupes),
3439 (&health, BaselineKind::Health),
3440 ] {
3441 assert_eq!(
3442 serde_json::from_str::<serde_json::Value>(json).expect("object")["kind"],
3443 serde_json::json!(kind.as_str()),
3444 "a saved baseline states which command wrote it: {json}"
3445 );
3446 assert_eq!(classify_baseline_file(json, kind), BaselineFileKind::Own);
3447 for other in [
3448 BaselineKind::DeadCode,
3449 BaselineKind::Dupes,
3450 BaselineKind::Health,
3451 ] {
3452 if other == kind {
3453 continue;
3454 }
3455 assert_eq!(
3456 classify_baseline_file(json, other),
3457 BaselineFileKind::Foreign(kind.as_str().to_owned()),
3458 "and every other command reads it as that command's: {json}"
3459 );
3460 }
3461 }
3462 }
3463
3464 #[test]
3467 fn a_baseline_without_a_kind_is_classified_by_its_keys() {
3468 let dupes =
3469 r#"{"clone_groups":[],"clone_fingerprints":[],"normalized_clone_fingerprints":[]}"#;
3470 let health = r#"{"runtime_coverage_findings":[],"target_keys":[]}"#;
3471 let dead_code = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],"unused_dependencies":[],"unused_dev_dependencies":[]}"#;
3472
3473 for (json, own) in [
3474 (dupes, BaselineKind::Dupes),
3475 (health, BaselineKind::Health),
3476 (dead_code, BaselineKind::DeadCode),
3477 ] {
3478 assert_eq!(classify_baseline_file(json, own), BaselineFileKind::Own);
3479 for other in [
3480 BaselineKind::DeadCode,
3481 BaselineKind::Dupes,
3482 BaselineKind::Health,
3483 ] {
3484 if other == own {
3485 continue;
3486 }
3487 assert_eq!(
3488 classify_baseline_file(json, other),
3489 BaselineFileKind::Unrecognised,
3490 "a file with no kind and none of this format's keys is unrecognised, not \
3491 attributed to a command it never named: {json}"
3492 );
3493 }
3494 }
3495 }
3496
3497 #[test]
3501 fn an_unreadable_kind_never_becomes_a_parse_error() {
3502 assert_eq!(
3503 classify_baseline_file(r#"{"kind":"security"}"#, BaselineKind::Dupes),
3504 BaselineFileKind::Foreign("security".to_owned())
3505 );
3506 assert_eq!(
3507 classify_baseline_file(r#"{"kind":7,"clone_groups":[]}"#, BaselineKind::Dupes),
3508 BaselineFileKind::Own
3509 );
3510 assert_eq!(
3511 classify_baseline_file("[]", BaselineKind::Dupes),
3512 BaselineFileKind::NotAnObject
3513 );
3514 assert_eq!(
3515 classify_baseline_file("not json", BaselineKind::DeadCode),
3516 BaselineFileKind::NotAnObject
3517 );
3518 assert!(
3519 serde_json::from_str::<DuplicationBaselineData>(r#"{"kind":"security"}"#).is_ok(),
3520 "a kind only a newer fallow writes must not break a load"
3521 );
3522 }
3523
3524 #[test]
3525 fn a_save_is_refused_only_over_another_commands_baseline() {
3526 let dir = tempfile::tempdir().expect("tempdir");
3527 let path = dir.path().join("baseline.json");
3528
3529 assert!(
3530 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
3531 "there is nothing to destroy yet"
3532 );
3533
3534 std::fs::write(
3535 &path,
3536 serde_json::to_string(&DuplicationBaselineData::from_report(
3537 &make_duplication_report(Vec::new()),
3538 Path::new("/project"),
3539 ))
3540 .expect("baseline serializes"),
3541 )
3542 .expect("write");
3543 assert!(
3544 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
3545 "re-saving over its own file is the documented workflow"
3546 );
3547 let message = refuse_baseline_kind_overwrite(&path, BaselineKind::Health)
3548 .expect("a health save over a duplication baseline is refused");
3549 assert!(message.contains("`fallow dupes`"), "{message}");
3550 assert!(message.contains("`fallow health`"), "{message}");
3551 assert!(message.contains(&path.display().to_string()), "{message}");
3552
3553 std::fs::write(&path, "{}").expect("write");
3554 assert!(
3555 refuse_baseline_kind_overwrite(&path, BaselineKind::Health).is_none(),
3556 "a file with nothing to identify it carries no claim to protect"
3557 );
3558 }
3559
3560 fn serialized_keys_without_kind<T: serde::Serialize>(value: &T) -> Vec<String> {
3561 let serde_json::Value::Object(object) =
3562 serde_json::to_value(value).expect("baseline serializes")
3563 else {
3564 panic!("a baseline serializes as an object");
3565 };
3566 object
3567 .keys()
3568 .filter(|key| key.as_str() != "kind")
3569 .cloned()
3570 .collect()
3571 }
3572
3573 #[test]
3574 fn a_baseline_saved_from_a_clean_project_still_declares_its_format() {
3575 let empty = serde_json::to_string(&DuplicationBaselineData::default())
3576 .expect("baseline serializes");
3577
3578 assert!(
3579 declares_baseline_format(&empty, DuplicationBaselineData::DECLARED_KEYS),
3580 "an empty duplication baseline is still a duplication baseline: {empty}"
3581 );
3582 assert!(
3583 !declares_baseline_format(&empty, HealthBaselineData::DECLARED_KEYS),
3584 "and it is not a health one: {empty}"
3585 );
3586 assert!(!declares_baseline_format(
3587 "{}",
3588 HealthBaselineData::DECLARED_KEYS
3589 ));
3590 assert!(!declares_baseline_format(
3591 "[]",
3592 HealthBaselineData::DECLARED_KEYS
3593 ));
3594 assert!(!declares_baseline_format(
3595 "not json",
3596 HealthBaselineData::DECLARED_KEYS
3597 ));
3598 }
3599
3600 #[test]
3601 fn filter_new_clone_groups_matches_shifted_clone() {
3602 let root = Path::new("/project");
3603 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3604 "const total = a + b;",
3605 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3606 )]);
3607 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3608
3609 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3610 "const total = a + b;",
3611 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3612 )]);
3613 let filtered = filter_new_clone_groups(shifted, &baseline, root);
3614 assert!(
3615 filtered.clone_groups.is_empty(),
3616 "an unrelated line shift must not resurface a baselined clone"
3617 );
3618 }
3619
3620 #[test]
3621 fn filter_new_clone_groups_reports_extra_copy() {
3622 let root = Path::new("/project");
3623 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3624 "const total = a + b;",
3625 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3626 )]);
3627 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3628
3629 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
3630 "const total = a + b;",
3631 vec![
3632 ("/project/src/a.ts", 10, 20),
3633 ("/project/src/b.ts", 30, 40),
3634 ("/project/src/c.ts", 5, 15),
3635 ],
3636 )]);
3637 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
3638 assert_eq!(
3639 filtered.clone_groups.len(),
3640 1,
3641 "a fresh copy in a third file is a new finding"
3642 );
3643 }
3644
3645 #[test]
3646 fn filter_new_clone_groups_reads_legacy_baseline() {
3647 let root = Path::new("/project");
3648 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
3649 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
3650 assert_eq!(baseline.entry_count(), 1);
3651
3652 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
3653 "const total = a + b;",
3654 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3655 )]);
3656 assert!(
3657 filter_new_clone_groups(unchanged, &baseline, root)
3658 .clone_groups
3659 .is_empty(),
3660 "a legacy baseline still matches on locations"
3661 );
3662
3663 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3664 "const total = a + b;",
3665 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3666 )]);
3667 assert_eq!(
3668 filter_new_clone_groups(shifted, &baseline, root)
3669 .clone_groups
3670 .len(),
3671 1,
3672 "legacy behavior is unchanged: a shift stops matching"
3673 );
3674 }
3675
3676 #[test]
3677 fn clone_group_fingerprint_key_survives_file_rename() {
3678 let before = make_clone_group_with_fragment(
3679 "const total = a + b;",
3680 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3681 );
3682 let after = make_clone_group_with_fragment(
3683 "const total = a + b;",
3684 vec![
3685 ("/project/src/renamed.ts", 10, 20),
3686 ("/project/src/b.ts", 30, 40),
3687 ],
3688 );
3689 assert_eq!(
3690 normalized_clone_group_key(&before),
3691 normalized_clone_group_key(&after),
3692 "renaming a file must not resurface a baselined clone"
3693 );
3694 }
3695
3696 #[test]
3697 fn clone_group_fingerprint_key_does_not_follow_representative_order() {
3698 let mut before = make_clone_group_with_fragment(
3699 "const total = a + b;",
3700 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3701 );
3702 before.instances[1].fragment = "const total = a + b;".to_string();
3703
3704 let mut after = before.clone();
3705 after.instances[0].file = PathBuf::from("/project/src/z.ts");
3706
3707 assert_eq!(
3708 normalized_clone_group_key(&before),
3709 normalized_clone_group_key(&after),
3710 "normalized group identity is independent of representative order"
3711 );
3712 }
3713
3714 #[test]
3715 fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
3716 let root = Path::new("/project");
3717 let group = make_clone_group_with_fragment(
3718 "const total = a + b;",
3719 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3720 );
3721 let legacy_key = legacy_clone_group_fingerprint_key(&group);
3722 let baseline = DuplicationBaselineData {
3723 kind: None,
3724 clone_groups: Vec::new(),
3725 clone_fingerprints: vec![legacy_key.clone()],
3726 normalized_clone_fingerprints: Vec::new(),
3727 };
3728
3729 let filtered = filter_new_clone_groups(
3730 make_duplication_report(vec![group.clone()]),
3731 &baseline,
3732 root,
3733 );
3734 assert!(filtered.clone_groups.is_empty());
3735
3736 let current =
3737 DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
3738 assert_eq!(current.clone_fingerprints, vec![legacy_key]);
3739 assert_ne!(
3740 current.normalized_clone_fingerprints,
3741 current.clone_fingerprints
3742 );
3743 }
3744
3745 #[test]
3746 fn normalized_baseline_survives_formatting_only_edits() {
3747 let root = Path::new("/project");
3748 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3749 "const total = left + right;",
3750 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3751 )]);
3752 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3753
3754 let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
3755 "/* reviewed */\r\nconst total=left + right;",
3756 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
3757 )]);
3758 assert!(
3759 filter_new_clone_groups(formatted, &baseline, root)
3760 .clone_groups
3761 .is_empty()
3762 );
3763 }
3764
3765 #[test]
3766 fn filter_new_clone_groups_removes_baseline() {
3767 let root = Path::new("/project");
3768 let group = make_clone_group(vec![
3769 ("/project/src/a.ts", 1, 10),
3770 ("/project/src/b.ts", 5, 15),
3771 ]);
3772 let report = make_duplication_report(vec![group]);
3773 let baseline = DuplicationBaselineData::from_report(&report, root);
3774 let filtered = filter_new_clone_groups(report, &baseline, root);
3775 assert!(
3776 filtered.clone_groups.is_empty(),
3777 "baseline group should be filtered out"
3778 );
3779 }
3780
3781 #[test]
3782 fn filter_new_clone_groups_keeps_new_groups() {
3783 let root = Path::new("/project");
3784 let baseline_group = make_clone_group(vec![
3785 ("/project/src/a.ts", 1, 10),
3786 ("/project/src/b.ts", 5, 15),
3787 ]);
3788 let new_group = make_clone_group(vec![
3789 ("/project/src/c.ts", 20, 30),
3790 ("/project/src/d.ts", 25, 35),
3791 ]);
3792 let baseline_report = make_duplication_report(vec![baseline_group]);
3793 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3794
3795 let report = make_duplication_report(vec![
3796 make_clone_group(vec![
3797 ("/project/src/a.ts", 1, 10),
3798 ("/project/src/b.ts", 5, 15),
3799 ]),
3800 new_group,
3801 ]);
3802 let filtered = filter_new_clone_groups(report, &baseline, root);
3803 assert_eq!(
3804 filtered.clone_groups.len(),
3805 1,
3806 "only the new group should remain"
3807 );
3808 }
3809
3810 #[test]
3811 fn recompute_stats_after_filtering() {
3812 let root = Path::new("/project");
3813 let group = make_clone_group(vec![
3814 ("/project/src/a.ts", 1, 10),
3815 ("/project/src/b.ts", 5, 15),
3816 ]);
3817 let report = make_duplication_report(vec![group]);
3818 let baseline = DuplicationBaselineData::from_report(&report, root);
3819 let filtered = filter_new_clone_groups(report, &baseline, root);
3820 assert_eq!(filtered.stats.clone_groups, 0);
3821 assert_eq!(filtered.stats.clone_instances, 0);
3822 assert_eq!(filtered.stats.duplicated_lines, 0);
3823 }
3824
3825 #[test]
3826 fn recompute_stats_zero_total_lines() {
3827 let report = DuplicationReport {
3828 clone_groups: vec![],
3829 clone_families: vec![],
3830 mirrored_directories: vec![],
3831 stats: DuplicationStats {
3832 total_files: 0,
3833 files_with_clones: 0,
3834 total_lines: 0,
3835 duplicated_lines: 0,
3836 total_tokens: 0,
3837 duplicated_tokens: 0,
3838 clone_groups: 0,
3839 clone_families: 0,
3840 clone_instances: 0,
3841 duplication_percentage: 0.0,
3842 clone_groups_below_min_occurrences: 0,
3843 clone_groups_ignored: 0,
3844 near_candidates_skipped: 0,
3845 },
3846 };
3847 let stats = super::recompute_stats(&report);
3848 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
3849 }
3850
3851 fn filter_new_health_findings(
3855 findings: Vec<fallow_output::ComplexityViolation>,
3856 baseline: &HealthBaselineData,
3857 root: &Path,
3858 ) -> Vec<fallow_output::ComplexityViolation> {
3859 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
3860 }
3861
3862 fn make_health_finding(
3863 root: &Path,
3864 name: &str,
3865 line: u32,
3866 ) -> fallow_output::ComplexityViolation {
3867 make_health_finding_with(
3868 root,
3869 name,
3870 line,
3871 fallow_output::ExceededThreshold::Both,
3872 fallow_output::FindingSeverity::High,
3873 )
3874 }
3875
3876 fn make_health_finding_with(
3877 root: &Path,
3878 name: &str,
3879 line: u32,
3880 exceeded: fallow_output::ExceededThreshold,
3881 severity: fallow_output::FindingSeverity,
3882 ) -> fallow_output::ComplexityViolation {
3883 fallow_output::ComplexityViolation {
3884 path: root.join("src/utils.ts"),
3885 name: name.to_string(),
3886 line,
3887 col: 0,
3888 cyclomatic: 25,
3889 cognitive: 30,
3890 line_count: 80,
3891 param_count: 0,
3892 react_hook_count: 0,
3893 react_jsx_max_depth: 0,
3894 react_prop_count: 0,
3895 react_hook_profile: None,
3896 exceeded,
3897 severity,
3898 crap: None,
3899 coverage_pct: None,
3900 coverage_tier: None,
3901 coverage_source: None,
3902 inherited_from: None,
3903 component_rollup: None,
3904 contributions: Vec::new(),
3905 effective_thresholds: None,
3906 threshold_source: None,
3907 }
3908 }
3909
3910 #[test]
3911 fn health_baseline_roundtrip() {
3912 let root = PathBuf::from("/project");
3913 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3914 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3915 let json = serde_json::to_string(&baseline).unwrap();
3916 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
3917 assert_eq!(deserialized.findings, baseline.findings);
3918 assert_eq!(baseline.findings, Vec::<String>::new());
3919 assert_eq!(
3920 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
3921 1
3922 );
3923 assert!(!json.contains("parseExpression"));
3924 }
3925
3926 #[test]
3927 fn health_baseline_filters_known_findings() {
3928 let root = PathBuf::from("/project");
3929 let mut findings = vec![
3930 make_health_finding(&root, "parseExpression", 42),
3931 make_health_finding(&root, "newFunction", 100),
3932 ];
3933 findings[1].path = root.join("src/other.ts");
3934 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
3935 let filtered = filter_new_health_findings(findings, &baseline, &root);
3936 assert_eq!(filtered.len(), 1);
3937 assert_eq!(filtered[0].name, "newFunction");
3938 }
3939
3940 #[test]
3941 fn health_baseline_filters_shifted_lines_with_same_category_count() {
3942 let root = PathBuf::from("/project");
3943 let baseline = HealthBaselineData::from_findings(
3944 &[make_health_finding(&root, "parseExpression", 42)],
3945 &[],
3946 &[],
3947 &root,
3948 );
3949 let filtered = filter_new_health_findings(
3950 vec![make_health_finding(&root, "parseExpression", 43)],
3951 &baseline,
3952 &root,
3953 );
3954 assert!(filtered.is_empty());
3955 }
3956
3957 #[test]
3958 fn health_baseline_reports_full_category_when_count_increases() {
3959 let root = PathBuf::from("/project");
3960 let baseline = HealthBaselineData::from_findings(
3961 &[make_health_finding(&root, "parseExpression", 42)],
3962 &[],
3963 &[],
3964 &root,
3965 );
3966 let filtered = filter_new_health_findings(
3967 vec![
3968 make_health_finding(&root, "parseExpression", 43),
3969 make_health_finding(&root, "newFunction", 100),
3970 ],
3971 &baseline,
3972 &root,
3973 );
3974 assert_eq!(filtered.len(), 2);
3975 }
3976
3977 #[test]
3978 fn health_baseline_legacy_findings_still_load() {
3979 let root = PathBuf::from("/project");
3980 let baseline = HealthBaselineData {
3981 kind: None,
3982 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
3983 finding_counts: BTreeMap::new(),
3984 identity_finding_counts: BTreeMap::new(),
3985 target_keys: vec![],
3986 runtime_coverage_findings: vec![],
3987 runtime_coverage_source_hashes: vec![],
3988 };
3989 let filtered = filter_new_health_findings(
3990 vec![make_health_finding(&root, "parseExpression", 42)],
3991 &baseline,
3992 &root,
3993 );
3994 assert!(filtered.is_empty());
3995 }
3996
3997 #[test]
3998 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
3999 let root = PathBuf::from("/project");
4000 let baseline = HealthBaselineData::from_findings(
4001 &[make_health_finding_with(
4002 &root,
4003 "parseExpression",
4004 42,
4005 fallow_output::ExceededThreshold::Crap,
4006 fallow_output::FindingSeverity::High,
4007 )],
4008 &[],
4009 &[],
4010 &root,
4011 );
4012 let filtered = filter_new_health_findings(
4013 vec![
4014 make_health_finding_with(
4015 &root,
4016 "parseExpression",
4017 43,
4018 fallow_output::ExceededThreshold::Crap,
4019 fallow_output::FindingSeverity::High,
4020 ),
4021 make_health_finding(&root, "newComplexityOnlyFunction", 100),
4022 ],
4023 &baseline,
4024 &root,
4025 );
4026 assert_eq!(filtered.len(), 1);
4027 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
4028 }
4029
4030 #[test]
4031 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
4032 let root = PathBuf::from("/project");
4033 let baseline = HealthBaselineData::from_findings(
4034 &[make_health_finding_with(
4035 &root,
4036 "parseExpression",
4037 42,
4038 fallow_output::ExceededThreshold::Both,
4039 fallow_output::FindingSeverity::Critical,
4040 )],
4041 &[],
4042 &[],
4043 &root,
4044 );
4045 let filtered = filter_new_health_findings(
4046 vec![make_health_finding_with(
4047 &root,
4048 "parseExpression",
4049 42,
4050 fallow_output::ExceededThreshold::Both,
4051 fallow_output::FindingSeverity::High,
4052 )],
4053 &baseline,
4054 &root,
4055 );
4056 assert!(filtered.is_empty());
4057 }
4058
4059 #[test]
4060 fn health_baseline_still_reports_worse_current_severity_as_new() {
4061 let root = PathBuf::from("/project");
4062 let baseline = HealthBaselineData::from_findings(
4063 &[make_health_finding_with(
4064 &root,
4065 "parseExpression",
4066 42,
4067 fallow_output::ExceededThreshold::Both,
4068 fallow_output::FindingSeverity::High,
4069 )],
4070 &[],
4071 &[],
4072 &root,
4073 );
4074 let filtered = filter_new_health_findings(
4075 vec![make_health_finding_with(
4076 &root,
4077 "parseExpression",
4078 42,
4079 fallow_output::ExceededThreshold::Both,
4080 fallow_output::FindingSeverity::Critical,
4081 )],
4082 &baseline,
4083 &root,
4084 );
4085 assert_eq!(filtered.len(), 1);
4086 assert_eq!(filtered[0].name, "parseExpression");
4087 assert!(matches!(
4088 filtered[0].severity,
4089 fallow_output::FindingSeverity::Critical
4090 ));
4091 }
4092
4093 #[test]
4094 fn health_baseline_overlap_counts_partial_category_overflow() {
4095 let root = PathBuf::from("/project");
4096 let baseline = HealthBaselineData::from_findings(
4097 &[make_health_finding(&root, "parseExpression", 42)],
4098 &[],
4099 &[],
4100 &root,
4101 );
4102 let overlap = baseline.overlap_entries(
4103 &[
4104 make_health_finding(&root, "parseExpression", 42),
4105 make_health_finding(&root, "newFunction", 100),
4106 ],
4107 &root,
4108 HealthBaselineMode::Count,
4109 );
4110 assert_eq!(overlap.matched_entries, 1);
4111 assert_eq!(overlap.moved_entries, 0);
4112 }
4113
4114 fn identity_baseline(
4116 findings: &[fallow_output::ComplexityViolation],
4117 root: &Path,
4118 ) -> HealthBaselineData {
4119 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
4120 }
4121
4122 #[test]
4123 fn health_identity_baseline_reports_replacement_hotspot() {
4124 let root = PathBuf::from("/project");
4125 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
4126 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
4127
4128 assert!(
4129 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
4130 "count mode keeps the per-file allowance and suppresses the replacement"
4131 );
4132
4133 let filtered = super::filter_new_health_findings(
4134 replacement,
4135 &baseline,
4136 &root,
4137 HealthBaselineMode::Identity,
4138 );
4139 assert_eq!(filtered.len(), 1);
4140 assert_eq!(filtered[0].name, "replacementHotspot");
4141 }
4142
4143 #[test]
4144 fn health_identity_baseline_survives_line_moves() {
4145 let root = PathBuf::from("/project");
4146 let baseline =
4147 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4148 let filtered = super::filter_new_health_findings(
4149 vec![make_health_finding(&root, "parseExpression", 512)],
4150 &baseline,
4151 &root,
4152 HealthBaselineMode::Identity,
4153 );
4154 assert!(filtered.is_empty());
4155 }
4156
4157 #[test]
4158 fn health_identity_baseline_suppresses_severity_improvement() {
4159 let root = PathBuf::from("/project");
4160 let baseline = identity_baseline(
4161 &[make_health_finding_with(
4162 &root,
4163 "parseExpression",
4164 42,
4165 fallow_output::ExceededThreshold::Both,
4166 fallow_output::FindingSeverity::Critical,
4167 )],
4168 &root,
4169 );
4170 let filtered = super::filter_new_health_findings(
4171 vec![make_health_finding_with(
4172 &root,
4173 "parseExpression",
4174 42,
4175 fallow_output::ExceededThreshold::Both,
4176 fallow_output::FindingSeverity::Moderate,
4177 )],
4178 &baseline,
4179 &root,
4180 HealthBaselineMode::Identity,
4181 );
4182 assert!(filtered.is_empty());
4183 }
4184
4185 #[test]
4186 fn health_identity_baseline_reports_added_finding_for_known_function() {
4187 let root = PathBuf::from("/project");
4188 let baseline =
4189 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4190 let filtered = super::filter_new_health_findings(
4191 vec![
4192 make_health_finding(&root, "parseExpression", 42),
4193 make_health_finding(&root, "parseStatement", 90),
4194 ],
4195 &baseline,
4196 &root,
4197 HealthBaselineMode::Identity,
4198 );
4199 assert_eq!(filtered.len(), 1);
4200 assert_eq!(filtered[0].name, "parseStatement");
4201 }
4202
4203 #[test]
4204 fn health_identity_buckets_are_written_only_in_identity_mode() {
4205 let root = PathBuf::from("/project");
4206 let findings = [make_health_finding(&root, "parseExpression", 42)];
4207 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4208 let json = serde_json::to_string(&count_only).unwrap();
4209 assert!(!json.contains("identity_finding_counts"));
4210 assert!(count_only.lacks_identity_data());
4211
4212 let identity = identity_baseline(&findings, &root);
4213 assert!(!identity.lacks_identity_data());
4214 assert_eq!(
4215 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
4216 .count,
4217 1
4218 );
4219 assert!(
4220 !identity.finding_counts.is_empty(),
4221 "an identity baseline stays readable in count mode"
4222 );
4223 }
4224
4225 fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
4226 let mut finding = make_health_finding(root, name, 42);
4227 finding.path = root.join(path);
4228 finding
4229 }
4230
4231 #[test]
4232 fn health_identity_baseline_follows_file_move() {
4233 let root = PathBuf::from("/project");
4234 let baseline =
4235 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4236 let moved = vec![moved_finding(
4237 &root,
4238 "src/parser/utils.ts",
4239 "parseExpression",
4240 )];
4241
4242 let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
4243 assert_eq!(
4244 overlap.matched_entries, 1,
4245 "a followed move counts as matched, not stale"
4246 );
4247 assert_eq!(
4248 overlap.moved_entries, 1,
4249 "the followed move stays observable as a moved entry"
4250 );
4251 let filtered = super::filter_new_health_findings(
4252 moved,
4253 &baseline,
4254 &root,
4255 HealthBaselineMode::Identity,
4256 );
4257 assert!(filtered.is_empty());
4258 }
4259
4260 #[test]
4261 fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
4262 let root = PathBuf::from("/project");
4263 let baseline =
4264 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4265 let filtered = super::filter_new_health_findings(
4266 vec![
4267 moved_finding(&root, "src/a.ts", "parseExpression"),
4268 moved_finding(&root, "src/b.ts", "parseExpression"),
4269 ],
4270 &baseline,
4271 &root,
4272 HealthBaselineMode::Identity,
4273 );
4274 assert_eq!(filtered.len(), 2);
4275 }
4276
4277 #[test]
4278 fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
4279 let root = PathBuf::from("/project");
4280 let baseline = identity_baseline(
4281 &[
4282 moved_finding(&root, "src/a.ts", "parseExpression"),
4283 moved_finding(&root, "src/b.ts", "parseExpression"),
4284 ],
4285 &root,
4286 );
4287 let filtered = super::filter_new_health_findings(
4288 vec![moved_finding(&root, "src/c.ts", "parseExpression")],
4289 &baseline,
4290 &root,
4291 HealthBaselineMode::Identity,
4292 );
4293 assert_eq!(filtered.len(), 1);
4294 }
4295
4296 #[test]
4297 fn health_identity_move_is_not_followed_when_old_path_still_exists() {
4298 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4301 let baseline = identity_baseline(
4302 &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
4303 &root,
4304 );
4305 let filtered = super::filter_new_health_findings(
4306 vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
4307 &baseline,
4308 &root,
4309 HealthBaselineMode::Identity,
4310 );
4311 assert_eq!(filtered.len(), 1);
4312 }
4313
4314 #[test]
4315 fn health_identity_move_is_not_followed_for_anonymous_functions() {
4316 let root = PathBuf::from("/project");
4317 let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
4318 let filtered = super::filter_new_health_findings(
4319 vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
4320 &baseline,
4321 &root,
4322 HealthBaselineMode::Identity,
4323 );
4324 assert_eq!(filtered.len(), 1);
4325 }
4326
4327 #[test]
4328 fn health_count_baseline_does_not_follow_file_moves() {
4329 let root = PathBuf::from("/project");
4330 let baseline = HealthBaselineData::from_findings(
4331 &[make_health_finding(&root, "parseExpression", 42)],
4332 &[],
4333 &[],
4334 &root,
4335 );
4336 let filtered = filter_new_health_findings(
4337 vec![moved_finding(
4338 &root,
4339 "src/parser/utils.ts",
4340 "parseExpression",
4341 )],
4342 &baseline,
4343 &root,
4344 );
4345 assert_eq!(filtered.len(), 1);
4346 }
4347
4348 #[test]
4349 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
4350 let legacy = HealthBaselineData {
4351 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
4352 ..HealthBaselineData::default()
4353 };
4354 assert!(legacy.lacks_identity_data());
4355 assert!(!HealthBaselineData::default().lacks_identity_data());
4356 }
4357
4358 #[test]
4359 fn health_baseline_empty_keeps_all() {
4360 let root = PathBuf::from("/project");
4361 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4362 let baseline = HealthBaselineData {
4363 kind: None,
4364 findings: vec![],
4365 finding_counts: BTreeMap::new(),
4366 identity_finding_counts: BTreeMap::new(),
4367 target_keys: vec![],
4368 runtime_coverage_findings: vec![],
4369 runtime_coverage_source_hashes: vec![],
4370 };
4371 let filtered = filter_new_health_findings(findings, &baseline, &root);
4372 assert_eq!(filtered.len(), 1);
4373 }
4374
4375 #[test]
4376 fn circular_dep_key_is_order_independent() {
4377 use crate::results::CircularDependency;
4378
4379 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
4380 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4381 length: 2,
4382 line: 1,
4383 col: 0,
4384 edges: Vec::new(),
4385 is_cross_package: false,
4386 });
4387 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
4388 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
4389 length: 2,
4390 line: 1,
4391 col: 0,
4392 edges: Vec::new(),
4393 is_cross_package: false,
4394 });
4395 assert_eq!(
4396 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
4397 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
4398 "same files in different order should produce identical keys"
4399 );
4400 }
4401
4402 #[test]
4403 fn circular_dep_key_different_files_different_keys() {
4404 use crate::results::CircularDependency;
4405
4406 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
4407 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4408 length: 2,
4409 line: 1,
4410 col: 0,
4411 edges: Vec::new(),
4412 is_cross_package: false,
4413 });
4414 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
4415 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
4416 length: 2,
4417 line: 1,
4418 col: 0,
4419 edges: Vec::new(),
4420 is_cross_package: false,
4421 });
4422 assert_ne!(
4423 super::circular_dep_key(&dep1.cycle, Path::new("")),
4424 super::circular_dep_key(&dep2.cycle, Path::new("")),
4425 );
4426 }
4427
4428 #[test]
4429 fn circular_dep_key_three_files_order_independent() {
4430 use crate::results::CircularDependency;
4431
4432 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
4433 files: vec![
4434 PathBuf::from("src/a.ts"),
4435 PathBuf::from("src/b.ts"),
4436 PathBuf::from("src/c.ts"),
4437 ],
4438 length: 3,
4439 line: 1,
4440 col: 0,
4441 edges: Vec::new(),
4442 is_cross_package: false,
4443 });
4444 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
4445 files: vec![
4446 PathBuf::from("src/c.ts"),
4447 PathBuf::from("src/a.ts"),
4448 PathBuf::from("src/b.ts"),
4449 ],
4450 length: 3,
4451 line: 1,
4452 col: 0,
4453 edges: Vec::new(),
4454 is_cross_package: false,
4455 });
4456 assert_eq!(
4457 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
4458 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
4459 );
4460 }
4461
4462 #[expect(
4463 clippy::too_many_lines,
4464 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4465 )]
4466 fn make_full_results() -> AnalysisResults {
4467 use crate::results::*;
4468 use crate::source::MemberKind;
4469
4470 let mut r = make_results();
4471 r.circular_dependencies
4472 .push(CircularDependencyFinding::with_actions(
4473 CircularDependency {
4474 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4475 length: 2,
4476 line: 1,
4477 col: 0,
4478 edges: Vec::new(),
4479 is_cross_package: false,
4480 },
4481 ));
4482 r.unused_optional_dependencies
4483 .push(UnusedOptionalDependencyFinding::with_actions(
4484 UnusedDependency {
4485 package_name: "fsevents".to_string(),
4486 location: DependencyLocation::OptionalDependencies,
4487 path: PathBuf::from("package.json"),
4488 line: 15,
4489 used_in_workspaces: Vec::new(),
4490 },
4491 ));
4492 r.unused_enum_members
4493 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
4494 path: PathBuf::from("src/enums.ts"),
4495 parent_name: "Status".to_string(),
4496 member_name: "Deprecated".to_string(),
4497 kind: MemberKind::EnumMember,
4498 line: 8,
4499 col: 0,
4500 }));
4501 r.unused_class_members
4502 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4503 path: PathBuf::from("src/service.ts"),
4504 parent_name: "UserService".to_string(),
4505 member_name: "legacy".to_string(),
4506 kind: MemberKind::ClassMethod,
4507 line: 42,
4508 col: 0,
4509 }));
4510 r.unused_store_members
4511 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4512 path: PathBuf::from("src/store.ts"),
4513 parent_name: "useStore".to_string(),
4514 member_name: "legacyAction".to_string(),
4515 kind: MemberKind::StoreMember,
4516 line: 17,
4517 col: 0,
4518 }));
4519 r.unresolved_imports.push(
4520 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
4521 crate::results::UnresolvedImport {
4522 path: PathBuf::from("src/app.ts"),
4523 specifier: "./missing".to_string(),
4524 line: 3,
4525 col: 0,
4526 specifier_col: 0,
4527 },
4528 ),
4529 );
4530 r.unlisted_dependencies
4531 .push(crate::results::UnlistedDependencyFinding::with_actions(
4532 UnlistedDependency {
4533 package_name: "chalk".to_string(),
4534 imported_from: vec![],
4535 },
4536 ));
4537 r.duplicate_exports
4538 .push(crate::results::DuplicateExportFinding::with_actions(
4539 crate::results::DuplicateExport {
4540 export_name: "Config".to_string(),
4541 locations: vec![
4542 crate::results::DuplicateLocation {
4543 path: PathBuf::from("src/a.ts"),
4544 line: 1,
4545 col: 0,
4546 },
4547 crate::results::DuplicateLocation {
4548 path: PathBuf::from("src/b.ts"),
4549 line: 5,
4550 col: 0,
4551 },
4552 ],
4553 },
4554 ));
4555 r.type_only_dependencies
4556 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
4557 TypeOnlyDependency {
4558 package_name: "zod".to_string(),
4559 path: PathBuf::from("package.json"),
4560 line: 8,
4561 },
4562 ));
4563 r.test_only_dependencies
4564 .push(crate::results::TestOnlyDependencyFinding::with_actions(
4565 TestOnlyDependency {
4566 package_name: "vitest".to_string(),
4567 path: PathBuf::from("package.json"),
4568 line: 10,
4569 },
4570 ));
4571 r.boundary_violations.push(
4572 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
4573 crate::results::BoundaryViolation {
4574 from_path: PathBuf::from("src/ui/btn.ts"),
4575 to_path: PathBuf::from("src/db/query.ts"),
4576 from_zone: "ui".to_string(),
4577 to_zone: "db".to_string(),
4578 import_specifier: "../db/query".to_string(),
4579 line: 1,
4580 col: 0,
4581 },
4582 ),
4583 );
4584 r
4585 }
4586
4587 #[test]
4588 fn baseline_from_results_captures_all_extended_fields() {
4589 let results = make_full_results();
4590 let baseline = BaselineData::from_results(&results, Path::new(""));
4591 assert_eq!(baseline.circular_dependencies.len(), 1);
4592 assert_eq!(
4593 baseline.unused_optional_dependencies,
4594 vec!["package.json:fsevents"]
4595 );
4596 assert_eq!(baseline.unused_enum_members.len(), 1);
4597 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
4598 assert_eq!(baseline.unused_class_members.len(), 1);
4599 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
4600 assert_eq!(baseline.unused_store_members.len(), 1);
4601 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
4602 assert_eq!(baseline.unresolved_imports.len(), 1);
4603 assert!(baseline.unresolved_imports[0].contains("./missing"));
4604 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
4605 assert_eq!(baseline.duplicate_exports.len(), 1);
4606 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
4607 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
4608 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
4609 assert_eq!(baseline.boundary_violations.len(), 1);
4610 assert!(baseline.boundary_violations[0].contains("->"));
4611 }
4612
4613 #[test]
4614 fn filter_removes_all_extended_baseline_issues() {
4615 let results = make_full_results();
4616 let baseline = BaselineData::from_results(&results, Path::new(""));
4617 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4618 assert!(filtered.circular_dependencies.is_empty());
4619 assert!(filtered.unused_optional_dependencies.is_empty());
4620 assert!(filtered.unused_enum_members.is_empty());
4621 assert!(filtered.unused_class_members.is_empty());
4622 assert!(filtered.unused_store_members.is_empty());
4623 assert!(filtered.unresolved_imports.is_empty());
4624 assert!(filtered.unlisted_dependencies.is_empty());
4625 assert!(filtered.duplicate_exports.is_empty());
4626 assert!(filtered.type_only_dependencies.is_empty());
4627 assert!(filtered.test_only_dependencies.is_empty());
4628 assert!(filtered.boundary_violations.is_empty());
4629 }
4630
4631 #[test]
4632 fn filter_keeps_new_circular_deps() {
4633 use crate::results::CircularDependency;
4634 let baseline = BaselineData {
4635 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
4636 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4637 };
4638 let mut results = AnalysisResults::default();
4639 results
4640 .circular_dependencies
4641 .push(CircularDependencyFinding::with_actions(
4642 CircularDependency {
4643 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4644 length: 2,
4645 line: 1,
4646 col: 0,
4647 edges: Vec::new(),
4648 is_cross_package: false,
4649 },
4650 ));
4651 results
4652 .circular_dependencies
4653 .push(CircularDependencyFinding::with_actions(
4654 CircularDependency {
4655 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
4656 length: 2,
4657 line: 5,
4658 col: 0,
4659 edges: Vec::new(),
4660 is_cross_package: false,
4661 },
4662 ));
4663 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4664 assert_eq!(filtered.circular_dependencies.len(), 1);
4665 }
4666
4667 #[test]
4668 fn filter_keeps_new_boundary_violations() {
4669 use crate::results::BoundaryViolation;
4670 let baseline = BaselineData {
4671 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
4672 boundary_coverage_violations: vec![],
4673 boundary_call_violations: vec![],
4674 policy_violations: vec![],
4675 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4676 };
4677 let mut results = AnalysisResults::default();
4678 results
4679 .boundary_violations
4680 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4681 from_path: PathBuf::from("src/a.ts"),
4682 to_path: PathBuf::from("src/b.ts"),
4683 from_zone: "a".to_string(),
4684 to_zone: "b".to_string(),
4685 import_specifier: "../b".to_string(),
4686 line: 1,
4687 col: 0,
4688 }));
4689 results
4690 .boundary_violations
4691 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4692 from_path: PathBuf::from("src/new.ts"),
4693 to_path: PathBuf::from("src/secret.ts"),
4694 from_zone: "new".to_string(),
4695 to_zone: "secret".to_string(),
4696 import_specifier: "../secret".to_string(),
4697 line: 1,
4698 col: 0,
4699 }));
4700 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4701 assert_eq!(filtered.boundary_violations.len(), 1);
4702 }
4703
4704 #[test]
4705 fn health_targets_baseline_filters_known() {
4706 let root = PathBuf::from("/project");
4707 let targets = vec![
4708 fallow_output::RefactoringTarget {
4709 path: root.join("src/complex.ts"),
4710 priority: 80.0,
4711 efficiency: 40.0,
4712 recommendation: "Split file".to_string(),
4713 category: fallow_output::RecommendationCategory::SplitHighImpact,
4714 effort: fallow_output::EffortEstimate::Medium,
4715 confidence: fallow_output::Confidence::Medium,
4716 factors: vec![],
4717 evidence: None,
4718 },
4719 fallow_output::RefactoringTarget {
4720 path: root.join("src/new-issue.ts"),
4721 priority: 60.0,
4722 efficiency: 30.0,
4723 recommendation: "Extract function".to_string(),
4724 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4725 effort: fallow_output::EffortEstimate::Low,
4726 confidence: fallow_output::Confidence::High,
4727 factors: vec![],
4728 evidence: None,
4729 },
4730 ];
4731 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
4732 let filtered = filter_new_health_targets(targets, &baseline, &root);
4733 assert_eq!(filtered.len(), 1);
4734 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
4735 }
4736
4737 #[test]
4738 fn duplicate_export_key_is_sorted() {
4739 use crate::results::{DuplicateExport, DuplicateLocation};
4740 let dup_ab = DuplicateExport {
4741 export_name: "foo".to_string(),
4742 locations: vec![
4743 DuplicateLocation {
4744 path: PathBuf::from("src/a.ts"),
4745 line: 1,
4746 col: 0,
4747 },
4748 DuplicateLocation {
4749 path: PathBuf::from("src/b.ts"),
4750 line: 5,
4751 col: 0,
4752 },
4753 ],
4754 };
4755 let dup_ba = DuplicateExport {
4756 export_name: "foo".to_string(),
4757 locations: vec![
4758 DuplicateLocation {
4759 path: PathBuf::from("src/b.ts"),
4760 line: 5,
4761 col: 0,
4762 },
4763 DuplicateLocation {
4764 path: PathBuf::from("src/a.ts"),
4765 line: 1,
4766 col: 0,
4767 },
4768 ],
4769 };
4770 assert_eq!(
4771 super::duplicate_export_key(&dup_ab, Path::new("")),
4772 super::duplicate_export_key(&dup_ba, Path::new("")),
4773 );
4774 }
4775
4776 #[test]
4777 fn boundary_violation_key_format() {
4778 use crate::results::BoundaryViolation;
4779 let v = BoundaryViolation {
4780 from_path: PathBuf::from("src/ui/btn.ts"),
4781 to_path: PathBuf::from("src/db/query.ts"),
4782 from_zone: "ui".to_string(),
4783 to_zone: "db".to_string(),
4784 import_specifier: "../db/query".to_string(),
4785 line: 1,
4786 col: 0,
4787 };
4788 let key = super::boundary_violation_key(&v, Path::new(""));
4789 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
4790 }
4791
4792 fn make_absolute_results(root: &str) -> AnalysisResults {
4794 use crate::results::*;
4795 use crate::source::MemberKind;
4796
4797 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
4798
4799 AnalysisResults {
4800 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4801 path: p("src/old.ts"),
4802 })],
4803 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
4804 path: p("src/utils.ts"),
4805 export_name: "helper".to_string(),
4806 is_type_only: false,
4807 line: 5,
4808 col: 0,
4809 span_start: 40,
4810 is_re_export: false,
4811 })],
4812 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
4813 package_name: "lodash-es".to_string(),
4814 location: DependencyLocation::Dependencies,
4815 path: p("packages/app/package.json"),
4816 line: 5,
4817 used_in_workspaces: Vec::new(),
4818 })],
4819 circular_dependencies: vec![CircularDependencyFinding::with_actions(
4820 CircularDependency {
4821 files: vec![p("src/a.ts"), p("src/b.ts")],
4822 length: 2,
4823 line: 1,
4824 col: 0,
4825 edges: Vec::new(),
4826 is_cross_package: false,
4827 },
4828 )],
4829 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
4830 path: p("src/enums.ts"),
4831 parent_name: "Status".to_string(),
4832 member_name: "Deprecated".to_string(),
4833 kind: MemberKind::EnumMember,
4834 line: 8,
4835 col: 0,
4836 })],
4837 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
4838 path: p("src/service.ts"),
4839 parent_name: "UserService".to_string(),
4840 member_name: "legacy".to_string(),
4841 kind: MemberKind::ClassMethod,
4842 line: 42,
4843 col: 0,
4844 })],
4845 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
4846 path: p("src/store.ts"),
4847 parent_name: "useStore".to_string(),
4848 member_name: "legacyAction".to_string(),
4849 kind: MemberKind::StoreMember,
4850 line: 17,
4851 col: 0,
4852 })],
4853 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4854 path: p("src/app.ts"),
4855 specifier: "./missing".to_string(),
4856 line: 3,
4857 col: 0,
4858 specifier_col: 0,
4859 })],
4860 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4861 export_name: "Config".to_string(),
4862 locations: vec![
4863 DuplicateLocation {
4864 path: p("src/a.ts"),
4865 line: 1,
4866 col: 0,
4867 },
4868 DuplicateLocation {
4869 path: p("src/b.ts"),
4870 line: 5,
4871 col: 0,
4872 },
4873 ],
4874 })],
4875 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4876 from_path: p("src/ui/btn.ts"),
4877 to_path: p("src/db/query.ts"),
4878 from_zone: "ui".to_string(),
4879 to_zone: "db".to_string(),
4880 import_specifier: "../db/query".to_string(),
4881 line: 1,
4882 col: 0,
4883 })],
4884 ..Default::default()
4885 }
4886 }
4887
4888 #[test]
4891 fn baseline_keys_are_relative_to_root() {
4892 let local_root = Path::new("/Users/dev/project");
4893 let results = make_absolute_results("/Users/dev/project");
4894 let baseline = BaselineData::from_results(&results, local_root);
4895
4896 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
4897 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
4898 assert_eq!(
4899 baseline.unused_dependencies,
4900 vec!["packages/app/package.json:lodash-es"]
4901 );
4902 assert_eq!(
4903 baseline.boundary_violations,
4904 vec!["src/ui/btn.ts->src/db/query.ts"]
4905 );
4906 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
4907 assert_eq!(
4908 baseline.unused_enum_members,
4909 vec!["src/enums.ts:Status.Deprecated"]
4910 );
4911 assert_eq!(
4912 baseline.unused_class_members,
4913 vec!["src/service.ts:UserService.legacy"]
4914 );
4915 assert_eq!(
4916 baseline.unused_store_members,
4917 vec!["src/store.ts:useStore.legacyAction"]
4918 );
4919 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
4920 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
4921
4922 let ci_root = Path::new("/home/runner/work/project/project");
4923 let ci_results = make_absolute_results("/home/runner/work/project/project");
4924
4925 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
4926 assert!(filtered.unused_files.is_empty(), "unused files");
4927 assert!(filtered.unused_exports.is_empty(), "unused exports");
4928 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
4929 assert!(
4930 filtered.boundary_violations.is_empty(),
4931 "boundary violations"
4932 );
4933 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
4934 assert!(filtered.unused_enum_members.is_empty(), "enum members");
4935 assert!(filtered.unused_class_members.is_empty(), "class members");
4936 assert!(filtered.unused_store_members.is_empty(), "store members");
4937 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
4938 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
4939 }
4940
4941 #[test]
4942 fn stale_suppression_baseline_keys_include_missing_reason_state() {
4943 let root = Path::new("/project");
4944 let stale = crate::results::StaleSuppression {
4945 path: root.join("src/file.ts"),
4946 line: 1,
4947 col: 0,
4948 origin: crate::results::SuppressionOrigin::Comment {
4949 issue_kind: Some("unused-export".to_string()),
4950 reason: None,
4951 is_file_level: false,
4952 kind_known: true,
4953 },
4954 missing_reason: false,
4955 actions: crate::results::StaleSuppression::actions_for(false),
4956 };
4957 let missing = crate::results::StaleSuppression {
4958 missing_reason: true,
4959 actions: crate::results::StaleSuppression::actions_for(true),
4960 ..stale.clone()
4961 };
4962 let results = AnalysisResults {
4963 stale_suppressions: vec![stale, missing],
4964 ..Default::default()
4965 };
4966 let baseline = BaselineData::from_results(&results, root);
4967
4968 assert_eq!(
4969 baseline.stale_suppressions,
4970 vec![
4971 "stale-suppression:src/file.ts:1",
4972 "missing-suppression-reason:src/file.ts:1",
4973 ]
4974 );
4975
4976 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
4977 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
4978 let filtered = filter_new_issues(results, &legacy_baseline, root);
4979 assert!(filtered.stale_suppressions.is_empty());
4980 }
4981
4982 fn runtime_finding(
4983 id: &str,
4984 stable_id: Option<&str>,
4985 line: u32,
4986 source_hash: Option<&str>,
4987 ) -> fallow_output::RuntimeCoverageFinding {
4988 fallow_output::RuntimeCoverageFinding {
4989 id: id.to_owned(),
4990 stable_id: stable_id.map(str::to_owned),
4991 source_hash: source_hash.map(str::to_owned),
4992 path: PathBuf::from("src/a.ts"),
4993 function: "alpha".to_owned(),
4994 line,
4995 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
4996 invocations: Some(0),
4997 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
4998 evidence: fallow_output::RuntimeCoverageEvidence {
4999 static_status: "used".to_owned(),
5000 test_coverage: "not_covered".to_owned(),
5001 test_only_reference: None,
5002 v8_tracking: "tracked".to_owned(),
5003 untracked_reason: None,
5004 observation_days: 1,
5005 deployments_observed: 1,
5006 },
5007 actions: vec![],
5008 discriminators: None,
5009 }
5010 }
5011
5012 #[test]
5013 fn legacy_prod_baseline_still_suppresses_finding() {
5014 let baseline = HealthBaselineData {
5015 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
5016 ..HealthBaselineData::default()
5017 };
5018 let findings = vec![runtime_finding(
5019 "fallow:prod:deadbeef",
5020 Some("fallow:fn:00000001"),
5021 14,
5022 None,
5023 )];
5024 let filtered =
5025 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5026 assert!(filtered.is_empty(), "legacy prod id must still suppress");
5027 }
5028
5029 #[test]
5030 fn source_hash_baseline_survives_line_move() {
5031 let root = Path::new("/repo");
5032 let baselined = runtime_finding(
5033 "fallow:prod:deadbeef",
5034 Some("fallow:fn:00000001"),
5035 14,
5036 Some("0123456789abcdef"),
5037 );
5038 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
5039 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
5040
5041 let findings = vec![runtime_finding(
5042 "fallow:prod:99999999",
5043 Some("fallow:fn:cafe0002"),
5044 40,
5045 Some("0123456789abcdef"),
5046 )];
5047 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
5048 assert!(
5049 filtered.is_empty(),
5050 "source_hash baseline must survive a line move despite a changed stable_id and id"
5051 );
5052 }
5053
5054 #[test]
5055 fn unbaselined_finding_is_reported() {
5056 let baseline = HealthBaselineData {
5057 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
5058 ..HealthBaselineData::default()
5059 };
5060 let findings = vec![runtime_finding(
5061 "fallow:prod:abc1234d",
5062 Some("fallow:fn:beefcafe"),
5063 7,
5064 None,
5065 )];
5066 let filtered =
5067 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5068 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
5069 }
5070}