1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use fallow_types::identity::{IdentifiedFinding, IdentityPaths};
6
7use crate::duplicates::DuplicationReport;
8
9fn relative_path(path: &Path, root: &Path) -> String {
15 match path.strip_prefix(root) {
16 Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
17 Err(_) => {
18 tracing::debug!(
19 path = %path.display(),
20 root = %root.display(),
21 "baseline key: path is not under project root, using absolute path as key"
22 );
23 path.to_string_lossy().replace('\\', "/")
24 }
25 }
26}
27
28fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
29 format!("{}:{package_name}", relative_path(path, root))
30}
31
32fn baseline_contains_dependency(
33 baseline_keys: &FxHashSet<&str>,
34 package_name: &str,
35 path_key: &str,
36) -> bool {
37 baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
38}
39
40fn retain_new_by_keys<T>(
41 items: &mut Vec<T>,
42 baseline_keys: &[String],
43 root: &Path,
44 key_builder: fn(&[T], &Path) -> Vec<String>,
45) {
46 let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
47 let item_keys = key_builder(items, root);
48 let mut key_iter = item_keys.into_iter();
49 items.retain(|_| match key_iter.next() {
50 Some(key) => !baseline_keys.contains(key.as_str()),
51 None => true,
52 });
53}
54
55macro_rules! with_baseline_fields {
59 ($callback:ident) => {
60 $callback!(
61 unused_files,
62 unused_exports,
63 unused_types,
64 private_type_leaks,
65 deprecated_exports_in_use,
66 unused_dependencies,
67 unused_dev_dependencies,
68 circular_dependencies,
69 re_export_cycles,
70 package_cycles,
71 unused_optional_dependencies,
72 unused_enum_members,
73 unused_class_members,
74 unused_store_members,
75 unprovided_injects,
76 unrendered_components,
77 unused_component_props,
78 absent_component_props,
79 unused_component_emits,
80 unused_component_inputs,
81 unused_component_outputs,
82 unused_svelte_events,
83 unused_server_actions,
84 unused_load_data_keys,
85 unresolved_imports,
86 unlisted_dependencies,
87 duplicate_exports,
88 type_only_dependencies,
89 test_only_dependencies,
90 dev_dependencies_in_production,
91 boundary_violations,
92 boundary_coverage_violations,
93 boundary_call_violations,
94 policy_violations,
95 stale_suppressions,
96 unused_catalog_entries,
97 empty_catalog_groups,
98 unresolved_catalog_references,
99 unused_dependency_overrides,
100 misconfigured_dependency_overrides,
101 invalid_client_exports,
102 mixed_client_server_barrels,
103 misplaced_directives,
104 route_collisions,
105 dynamic_segment_name_conflicts,
106 )
107 };
108}
109
110fn canonical_keys<T: IdentifiedFinding>(items: &[T], paths: &IdentityPaths<'_>) -> Vec<String> {
112 let mut keys: Vec<String> = items.iter().map(|item| item.canonical_key(paths)).collect();
113 keys.sort_unstable();
114 keys
115}
116
117fn retain_unsaved<T: IdentifiedFinding>(
120 items: &mut Vec<T>,
121 saved: &[String],
122 paths: &IdentityPaths<'_>,
123) {
124 if saved.is_empty() {
125 return;
126 }
127 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
128 for key in saved {
129 *remaining.entry(key.as_str()).or_default() += 1;
130 }
131 items.retain(|item| !consume_baseline_key(&mut remaining, &item.canonical_key(paths)));
132}
133
134const STALE_WARN_PERCENT: usize = 25;
142
143#[must_use]
149pub const fn stale_share_warrants_warning(baseline_entries: usize, stale_entries: usize) -> bool {
150 stale_entries > 0 && stale_entries * 100 >= baseline_entries * STALE_WARN_PERCENT
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
160pub struct BaselineStaleness {
161 pub entries: usize,
163 pub matched: usize,
165 pub current_findings: usize,
169 pub change_scoped: bool,
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub enum BaselineStalenessWarning {
177 None,
179 ZeroOverlap,
181 Partial,
183}
184
185impl BaselineStaleness {
186 #[must_use]
188 pub const fn stale_entries(&self) -> usize {
189 self.entries.saturating_sub(self.matched)
190 }
191
192 #[must_use]
201 pub const fn warning(&self) -> BaselineStalenessWarning {
202 if self.change_scoped || self.entries == 0 || self.current_findings == 0 {
203 return BaselineStalenessWarning::None;
204 }
205 if self.matched == 0 {
206 return BaselineStalenessWarning::ZeroOverlap;
207 }
208 if stale_share_warrants_warning(self.entries, self.stale_entries()) {
209 return BaselineStalenessWarning::Partial;
210 }
211 BaselineStalenessWarning::None
212 }
213
214 #[must_use]
223 pub const fn trips_gate(&self) -> bool {
224 stale_baseline_gate_trips(self.entries, self.matched, self.change_scoped)
225 }
226 #[must_use]
247 pub fn to_envelope(
248 &self,
249 moved_entries: usize,
250 scope_reasons: fallow_output::BaselineScopeReasons,
251 unrecognised_format: bool,
252 ) -> fallow_output::BaselineStaleness {
253 debug_assert_eq!(
254 self.change_scoped,
255 !scope_reasons.is_empty(),
256 "change_scoped and scope_reasons must be derived from the same predicate"
257 );
258 let warning = self.warning();
259 fallow_output::BaselineStaleness {
260 baseline_entries: self.entries,
261 matched_entries: self.matched,
262 stale_entries: self.stale_entries(),
263 current_findings: self.current_findings,
264 remaining_findings: None,
265 change_scoped: self.change_scoped,
266 stale: warning != BaselineStalenessWarning::None,
267 warning: match warning {
268 BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
269 BaselineStalenessWarning::ZeroOverlap => {
270 fallow_output::BaselineStalenessAdvisory::ZeroOverlap
271 }
272 BaselineStalenessWarning::Partial => {
273 fallow_output::BaselineStalenessAdvisory::Partial
274 }
275 },
276 gate_trips: self.trips_gate() || unrecognised_format,
277 moved_entries,
278 unrecognised_format,
279 saved_by: None,
280 format: None,
281 scope_reasons,
282 }
283 }
284}
285
286#[must_use]
289pub const fn stale_baseline_gate_trips(
290 entries: usize,
291 matched: usize,
292 change_scoped: bool,
293) -> bool {
294 !change_scoped && entries > 0 && matched < entries
295}
296
297#[must_use]
311pub fn declares_baseline_format(json: &str, declared_keys: &[&str]) -> bool {
312 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
313 return false;
314 };
315 value_declares_baseline_format(&value, declared_keys)
316}
317
318fn value_declares_baseline_format(value: &serde_json::Value, declared_keys: &[&str]) -> bool {
320 let Some(object) = value.as_object() else {
321 return false;
322 };
323 declared_keys.iter().any(|key| object.contains_key(*key))
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
334#[serde(rename_all = "kebab-case")]
335pub enum BaselineKind {
336 DeadCode,
338 Dupes,
340 Health,
342}
343
344impl BaselineKind {
345 #[must_use]
347 pub const fn as_str(self) -> &'static str {
348 match self {
349 Self::DeadCode => "dead-code",
350 Self::Dupes => "dupes",
351 Self::Health => "health",
352 }
353 }
354
355 #[must_use]
358 pub fn from_token(token: &str) -> Option<Self> {
359 [Self::DeadCode, Self::Dupes, Self::Health]
360 .into_iter()
361 .find(|kind| kind.as_str() == token)
362 }
363
364 #[must_use]
366 pub const fn declared_keys(self) -> &'static [&'static str] {
367 match self {
368 Self::DeadCode => BaselineData::REQUIRED_KEYS,
369 Self::Dupes => DuplicationBaselineData::DECLARED_KEYS,
370 Self::Health => HealthBaselineData::DECLARED_KEYS,
371 }
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub enum BaselineFileKind {
378 Own,
381 Foreign(String),
384 Unrecognised,
388 NotAnObject,
391}
392
393impl BaselineFileKind {
394 #[must_use]
401 pub fn saved_by(&self) -> Option<BaselineKind> {
402 match self {
403 Self::Foreign(token) => BaselineKind::from_token(token),
404 Self::Own | Self::Unrecognised | Self::NotAnObject => None,
405 }
406 }
407}
408
409#[must_use]
419pub fn refuse_baseline_kind_overwrite(save_path: &Path, saving: BaselineKind) -> Option<String> {
420 let existing = std::fs::read_to_string(save_path).ok()?;
421 let BaselineFileKind::Foreign(found) = classify_baseline_file(&existing, saving) else {
422 return None;
423 };
424 Some(format!(
425 "refusing to overwrite the baseline at {}: it was saved by `fallow {found}` and this is a \
426 `fallow {}` save, which would destroy it. Save each command's baseline to its own path.",
427 save_path.display(),
428 saving.as_str(),
429 ))
430}
431
432#[must_use]
435pub fn saved_scope_reasons(json: &str) -> Vec<String> {
436 serde_json::from_str::<serde_json::Value>(json)
437 .ok()
438 .and_then(|value| {
439 value.get("scope_reasons")?.as_array().map(|reasons| {
440 reasons
441 .iter()
442 .filter_map(serde_json::Value::as_str)
443 .map(str::to_owned)
444 .collect()
445 })
446 })
447 .unwrap_or_default()
448}
449
450#[must_use]
460pub fn classify_baseline_file(json: &str, expected: BaselineKind) -> BaselineFileKind {
461 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
462 return BaselineFileKind::NotAnObject;
463 };
464 classify_baseline_value(&value, expected)
465}
466
467#[must_use]
473pub fn classify_baseline_value(
474 value: &serde_json::Value,
475 expected: BaselineKind,
476) -> BaselineFileKind {
477 let Some(object) = value.as_object() else {
478 return BaselineFileKind::NotAnObject;
479 };
480 match object.get("kind").and_then(serde_json::Value::as_str) {
481 Some(token) if token == expected.as_str() => BaselineFileKind::Own,
482 Some(token) => BaselineFileKind::Foreign(token.to_owned()),
483 None => {
486 if value_declares_baseline_format(value, expected.declared_keys()) {
487 BaselineFileKind::Own
488 } else {
489 BaselineFileKind::Unrecognised
490 }
491 }
492 }
493}
494
495pub const BASELINE_KEY_SCHEME: &str = fallow_types::identity::DEAD_CODE_ID_SCHEME;
504
505#[derive(serde::Serialize, serde::Deserialize)]
511pub struct BaselineData {
512 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
518 kind: Option<BaselineKind>,
519 #[serde(default)]
523 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
527 identity: Option<String>,
528 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 scope_reasons: Vec<String>,
534 unused_files: Vec<String>,
535 unused_exports: Vec<String>,
536 unused_types: Vec<String>,
537 #[serde(default)]
538 private_type_leaks: Vec<String>,
539 #[serde(default)]
541 deprecated_exports_in_use: Vec<String>,
542 unused_dependencies: Vec<String>,
546 unused_dev_dependencies: Vec<String>,
550 #[serde(default)]
552 circular_dependencies: Vec<String>,
553 #[serde(default)]
558 re_export_cycles: Vec<String>,
559 #[serde(default)]
562 package_cycles: Vec<String>,
563 #[serde(default)]
567 unused_optional_dependencies: Vec<String>,
568 #[serde(default)]
570 unused_enum_members: Vec<String>,
571 #[serde(default)]
573 unused_class_members: Vec<String>,
574 #[serde(default)]
576 unused_store_members: Vec<String>,
577 #[serde(default)]
579 unprovided_injects: Vec<String>,
580 #[serde(default)]
582 unrendered_components: Vec<String>,
583 #[serde(default)]
585 unused_component_props: Vec<String>,
586 #[serde(default)]
587 absent_component_props: Vec<String>,
588 #[serde(default)]
590 unused_component_emits: Vec<String>,
591 #[serde(default)]
593 unused_component_inputs: Vec<String>,
594 #[serde(default)]
596 unused_component_outputs: Vec<String>,
597 #[serde(default)]
599 unused_svelte_events: Vec<String>,
600 #[serde(default)]
602 unused_server_actions: Vec<String>,
603 #[serde(default)]
605 unused_load_data_keys: Vec<String>,
606 #[serde(default)]
608 unresolved_imports: Vec<String>,
609 #[serde(default)]
611 unlisted_dependencies: Vec<String>,
612 #[serde(default)]
614 duplicate_exports: Vec<String>,
615 #[serde(default)]
619 type_only_dependencies: Vec<String>,
620 #[serde(default)]
624 test_only_dependencies: Vec<String>,
625 #[serde(default)]
627 dev_dependencies_in_production: Vec<String>,
628 #[serde(default)]
630 boundary_violations: Vec<String>,
631 #[serde(default)]
633 boundary_coverage_violations: Vec<String>,
634 #[serde(default)]
636 boundary_call_violations: Vec<String>,
637 #[serde(default)]
639 policy_violations: Vec<String>,
640 #[serde(default)]
642 stale_suppressions: Vec<String>,
643 #[serde(default)]
645 unused_catalog_entries: Vec<String>,
646 #[serde(default)]
648 empty_catalog_groups: Vec<String>,
649 #[serde(default)]
651 unresolved_catalog_references: Vec<String>,
652 #[serde(default)]
654 unused_dependency_overrides: Vec<String>,
655 #[serde(default)]
657 misconfigured_dependency_overrides: Vec<String>,
658 #[serde(default)]
660 invalid_client_exports: Vec<String>,
661 #[serde(default)]
663 mixed_client_server_barrels: Vec<String>,
664 #[serde(default)]
667 misplaced_directives: Vec<String>,
668 #[serde(default)]
670 route_collisions: Vec<String>,
671 #[serde(default)]
673 dynamic_segment_name_conflicts: Vec<String>,
674}
675
676impl BaselineData {
677 pub const REQUIRED_KEYS: &'static [&'static str] = &[
683 "unused_files",
684 "unused_exports",
685 "unused_types",
686 "unused_dependencies",
687 "unused_dev_dependencies",
688 ];
689
690 pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
693 Self::from_results_with_identity(
694 results,
695 root,
696 fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
697 )
698 }
699
700 pub fn from_results_with_identity(
707 results: &crate::results::AnalysisResults,
708 root: &Path,
709 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
710 ) -> Self {
711 let paths = IdentityPaths::new(root);
712 macro_rules! build {
713 ($($field:ident),* $(,)?) => {
714 Self {
715 kind: Some(BaselineKind::DeadCode),
716 analysis_identity,
717 identity: Some(BASELINE_KEY_SCHEME.to_owned()),
718 scope_reasons: Vec::new(),
719 $($field: canonical_keys(&results.$field, &paths),)*
720 }
721 };
722 }
723 with_baseline_fields!(build)
724 }
725
726 #[must_use]
728 pub fn with_scope_reasons(mut self, reasons: fallow_output::BaselineScopeReasons) -> Self {
729 self.scope_reasons = reasons
730 .iter()
731 .map(|reason| reason.as_str().to_owned())
732 .collect();
733 self
734 }
735
736 #[cfg(test)]
739 pub(crate) fn legacy_from_results(
740 results: &crate::results::AnalysisResults,
741 root: &Path,
742 ) -> Self {
743 let analysis_identity = fallow_types::semantic::SemanticAnalysisIdentity::syntactic();
744 let file_exports = baseline_file_export_keys(results, root);
745 let member_imports = baseline_member_import_keys(results, root);
746 let dependencies = baseline_dependency_keys(results, root);
747 let graph = baseline_graph_keys(results, root);
748 let catalog = baseline_catalog_keys(results, root);
749
750 Self {
751 kind: Some(BaselineKind::DeadCode),
752 analysis_identity,
753 identity: None,
754 scope_reasons: Vec::new(),
755 unused_files: file_exports.unused_files,
756 unused_exports: file_exports.unused_exports,
757 unused_types: file_exports.unused_types,
758 private_type_leaks: file_exports.private_type_leaks,
759 deprecated_exports_in_use: file_exports.deprecated_exports_in_use,
760 unused_dependencies: dependencies.unused,
761 unused_dev_dependencies: dependencies.unused_dev,
762 circular_dependencies: graph.circular_dependencies,
763 re_export_cycles: graph.re_export_cycles,
764 package_cycles: graph.package_cycles,
765 unused_optional_dependencies: dependencies.unused_optional,
766 unused_enum_members: member_imports.unused_enum_members,
767 unused_class_members: member_imports.unused_class_members,
768 unused_store_members: member_imports.unused_store_members,
769 unprovided_injects: member_imports.unprovided_injects,
770 unrendered_components: member_imports.unrendered_components,
771 unused_component_props: member_imports.unused_component_props,
772 absent_component_props: member_imports.absent_component_props,
773 unused_component_emits: member_imports.unused_component_emits,
774 unused_component_inputs: member_imports.unused_component_inputs,
775 unused_component_outputs: member_imports.unused_component_outputs,
776 unused_svelte_events: member_imports.unused_svelte_events,
777 unused_server_actions: member_imports.unused_server_actions,
778 unused_load_data_keys: member_imports.unused_load_data_keys,
779 unresolved_imports: member_imports.unresolved_imports,
780 unlisted_dependencies: dependencies.unlisted,
781 duplicate_exports: member_imports.duplicate_exports,
782 type_only_dependencies: dependencies.type_only,
783 test_only_dependencies: dependencies.test_only,
784 dev_dependencies_in_production: dependencies.dev_in_prod,
785 boundary_violations: graph.boundary_violations,
786 boundary_coverage_violations: graph.boundary_coverage_violations,
787 boundary_call_violations: graph.boundary_call_violations,
788 policy_violations: graph.policy_violations,
789 stale_suppressions: member_imports.stale_suppressions,
790 unused_catalog_entries: catalog.unused_catalog_entries,
791 empty_catalog_groups: catalog.empty_catalog_groups,
792 unresolved_catalog_references: catalog.unresolved_catalog_references,
793 unused_dependency_overrides: catalog.unused_dependency_overrides,
794 misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
795 invalid_client_exports: file_exports.invalid_client_exports,
796 mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
797 misplaced_directives: file_exports.misplaced_directives,
798 route_collisions: file_exports.route_collisions,
799 dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
800 }
801 }
802
803 #[must_use]
805 pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
806 &self.analysis_identity
807 }
808
809 pub fn total_entries(&self) -> usize {
811 self.unused_files.len()
812 + self.unused_exports.len()
813 + self.unused_types.len()
814 + self.private_type_leaks.len()
815 + self.deprecated_exports_in_use.len()
816 + self.unused_dependencies.len()
817 + self.unused_dev_dependencies.len()
818 + self.circular_dependencies.len()
819 + self.re_export_cycles.len()
820 + self.package_cycles.len()
821 + self.unused_optional_dependencies.len()
822 + self.unused_enum_members.len()
823 + self.unused_class_members.len()
824 + self.unused_store_members.len()
825 + self.unprovided_injects.len()
826 + self.unrendered_components.len()
827 + self.unused_component_props.len()
828 + self.absent_component_props.len()
829 + self.unused_component_emits.len()
830 + self.unused_component_inputs.len()
831 + self.unused_component_outputs.len()
832 + self.unused_svelte_events.len()
833 + self.unused_server_actions.len()
834 + self.unused_load_data_keys.len()
835 + self.unresolved_imports.len()
836 + self.unlisted_dependencies.len()
837 + self.duplicate_exports.len()
838 + self.type_only_dependencies.len()
839 + self.test_only_dependencies.len()
840 + self.dev_dependencies_in_production.len()
841 + self.boundary_violations.len()
842 + self.boundary_coverage_violations.len()
843 + self.boundary_call_violations.len()
844 + self.policy_violations.len()
845 + self.stale_suppressions.len()
846 + self.unused_catalog_entries.len()
847 + self.empty_catalog_groups.len()
848 + self.unresolved_catalog_references.len()
849 + self.unused_dependency_overrides.len()
850 + self.misconfigured_dependency_overrides.len()
851 + self.invalid_client_exports.len()
852 + self.mixed_client_server_barrels.len()
853 + self.misplaced_directives.len()
854 + self.route_collisions.len()
855 + self.dynamic_segment_name_conflicts.len()
856 }
857}
858
859#[cfg(test)]
860struct BaselineFileExportKeys {
861 unused_files: Vec<String>,
862 unused_exports: Vec<String>,
863 unused_types: Vec<String>,
864 private_type_leaks: Vec<String>,
865 deprecated_exports_in_use: Vec<String>,
866 invalid_client_exports: Vec<String>,
867 mixed_client_server_barrels: Vec<String>,
868 misplaced_directives: Vec<String>,
869 route_collisions: Vec<String>,
870 dynamic_segment_name_conflicts: Vec<String>,
871}
872
873#[cfg(test)]
874fn baseline_file_export_keys(
875 results: &crate::results::AnalysisResults,
876 root: &Path,
877) -> BaselineFileExportKeys {
878 BaselineFileExportKeys {
879 unused_files: results
880 .unused_files
881 .iter()
882 .map(|f| relative_path(&f.file.path, root))
883 .collect(),
884 unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
885 unused_types: unused_type_baseline_keys(&results.unused_types, root),
886 private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
887 deprecated_exports_in_use: results
888 .deprecated_exports_in_use
889 .iter()
890 .map(|e| deprecated_export_key(&e.export, root))
891 .collect(),
892 invalid_client_exports: invalid_client_export_baseline_keys(
893 &results.invalid_client_exports,
894 root,
895 ),
896 mixed_client_server_barrels: barrel_baseline_keys(
897 &results.mixed_client_server_barrels,
898 root,
899 ),
900 misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
901 route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
902 dynamic_segment_name_conflicts: results
903 .dynamic_segment_name_conflicts
904 .iter()
905 .map(|c| {
906 format!(
907 "{}:{}",
908 relative_path(&c.conflict.path, root),
909 c.conflict.position
910 )
911 })
912 .collect(),
913 }
914}
915
916#[cfg(test)]
917fn unused_export_baseline_keys(
918 items: &[crate::results::UnusedExportFinding],
919 root: &Path,
920) -> Vec<String> {
921 items
922 .iter()
923 .map(|e| {
924 format!(
925 "{}:{}",
926 relative_path(&e.export.path, root),
927 e.export.export_name
928 )
929 })
930 .collect()
931}
932
933#[cfg(test)]
934fn unused_type_baseline_keys(
935 items: &[crate::results::UnusedTypeFinding],
936 root: &Path,
937) -> Vec<String> {
938 items
939 .iter()
940 .map(|e| {
941 format!(
942 "{}:{}",
943 relative_path(&e.export.path, root),
944 e.export.export_name
945 )
946 })
947 .collect()
948}
949
950#[cfg(test)]
951fn invalid_client_export_baseline_keys(
952 items: &[crate::results::InvalidClientExportFinding],
953 root: &Path,
954) -> Vec<String> {
955 items
956 .iter()
957 .map(|e| {
958 format!(
959 "{}:{}",
960 relative_path(&e.export.path, root),
961 e.export.export_name
962 )
963 })
964 .collect()
965}
966
967#[cfg(test)]
968fn private_type_leak_baseline_keys(
969 items: &[crate::results::PrivateTypeLeakFinding],
970 root: &Path,
971) -> Vec<String> {
972 items
973 .iter()
974 .map(|e| {
975 format!(
976 "{}:{}->{}",
977 relative_path(&e.leak.path, root),
978 e.leak.export_name,
979 e.leak.type_name
980 )
981 })
982 .collect()
983}
984
985#[cfg(test)]
986fn barrel_baseline_keys(
987 items: &[crate::results::MixedClientServerBarrelFinding],
988 root: &Path,
989) -> Vec<String> {
990 items
991 .iter()
992 .map(|b| {
993 format!(
994 "{}:{}:{}",
995 relative_path(&b.barrel.path, root),
996 b.barrel.client_origin,
997 b.barrel.server_origin
998 )
999 })
1000 .collect()
1001}
1002
1003#[cfg(test)]
1004fn directive_baseline_keys(
1005 items: &[crate::results::MisplacedDirectiveFinding],
1006 root: &Path,
1007) -> Vec<String> {
1008 items
1009 .iter()
1010 .map(|d| {
1011 format!(
1012 "{}:{}:{}",
1013 relative_path(&d.directive_site.path, root),
1014 d.directive_site.line,
1015 d.directive_site.directive
1016 )
1017 })
1018 .collect()
1019}
1020
1021#[cfg(test)]
1022fn route_collision_baseline_keys(
1023 items: &[crate::results::RouteCollisionFinding],
1024 root: &Path,
1025) -> Vec<String> {
1026 items
1027 .iter()
1028 .map(|c| {
1029 format!(
1030 "{}:{}",
1031 relative_path(&c.collision.path, root),
1032 c.collision.url
1033 )
1034 })
1035 .collect()
1036}
1037
1038#[cfg(test)]
1039struct BaselineMemberImportKeys {
1040 unused_enum_members: Vec<String>,
1041 unused_class_members: Vec<String>,
1042 unused_store_members: Vec<String>,
1043 unprovided_injects: Vec<String>,
1044 unrendered_components: Vec<String>,
1045 unused_component_props: Vec<String>,
1046 absent_component_props: Vec<String>,
1047 unused_component_emits: Vec<String>,
1048 unused_component_inputs: Vec<String>,
1049 unused_component_outputs: Vec<String>,
1050 unused_svelte_events: Vec<String>,
1051 unused_server_actions: Vec<String>,
1052 unused_load_data_keys: Vec<String>,
1053 unresolved_imports: Vec<String>,
1054 duplicate_exports: Vec<String>,
1055 stale_suppressions: Vec<String>,
1056}
1057
1058#[cfg(test)]
1059fn baseline_member_import_keys(
1060 results: &crate::results::AnalysisResults,
1061 root: &Path,
1062) -> BaselineMemberImportKeys {
1063 BaselineMemberImportKeys {
1064 unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
1065 unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
1066 unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
1067 unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
1068 unrendered_components: component_baseline_keys(&results.unrendered_components, root),
1069 unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
1070 absent_component_props: absent_component_prop_baseline_keys(
1071 &results.absent_component_props,
1072 root,
1073 ),
1074 unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
1075 unused_component_inputs: component_input_baseline_keys(
1076 &results.unused_component_inputs,
1077 root,
1078 ),
1079 unused_component_outputs: component_output_baseline_keys(
1080 &results.unused_component_outputs,
1081 root,
1082 ),
1083 unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
1084 unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
1085 unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
1086 unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
1087 duplicate_exports: results
1088 .duplicate_exports
1089 .iter()
1090 .map(|d| duplicate_export_key(&d.export, root))
1091 .collect(),
1092 stale_suppressions: results
1093 .stale_suppressions
1094 .iter()
1095 .map(|s| stale_suppression_baseline_key(s, root))
1096 .collect(),
1097 }
1098}
1099
1100fn stale_suppression_baseline_key(
1101 suppression: &crate::results::StaleSuppression,
1102 root: &Path,
1103) -> String {
1104 let rule_id = if suppression.missing_reason {
1105 "missing-suppression-reason"
1106 } else {
1107 "stale-suppression"
1108 };
1109 format!(
1110 "{rule_id}:{}:{}",
1111 relative_path(&suppression.path, root),
1112 suppression.line
1113 )
1114}
1115
1116#[cfg(test)]
1117fn enum_member_baseline_keys(
1118 items: &[crate::results::UnusedEnumMemberFinding],
1119 root: &Path,
1120) -> Vec<String> {
1121 items
1122 .iter()
1123 .map(|m| unused_member_baseline_key(&m.member, root))
1124 .collect()
1125}
1126
1127#[cfg(test)]
1128fn class_member_baseline_keys(
1129 items: &[crate::results::UnusedClassMemberFinding],
1130 root: &Path,
1131) -> Vec<String> {
1132 items
1133 .iter()
1134 .map(|m| unused_member_baseline_key(&m.member, root))
1135 .collect()
1136}
1137
1138#[cfg(test)]
1139fn store_member_baseline_keys(
1140 items: &[crate::results::UnusedStoreMemberFinding],
1141 root: &Path,
1142) -> Vec<String> {
1143 items
1144 .iter()
1145 .map(|m| unused_member_baseline_key(&m.member, root))
1146 .collect()
1147}
1148
1149#[cfg(test)]
1150fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
1151 format!(
1152 "{}:{}.{}",
1153 relative_path(&member.path, root),
1154 member.parent_name,
1155 member.member_name
1156 )
1157}
1158
1159fn inject_baseline_keys(
1160 items: &[crate::results::UnprovidedInjectFinding],
1161 root: &Path,
1162) -> Vec<String> {
1163 items
1164 .iter()
1165 .map(|f| {
1166 format!(
1167 "{}:{}",
1168 relative_path(&f.inject.path, root),
1169 f.inject.key_name
1170 )
1171 })
1172 .collect()
1173}
1174
1175fn component_baseline_keys(
1176 items: &[crate::results::UnrenderedComponentFinding],
1177 root: &Path,
1178) -> Vec<String> {
1179 items
1180 .iter()
1181 .map(|c| {
1182 format!(
1183 "{}:{}",
1184 relative_path(&c.component.path, root),
1185 c.component.component_name
1186 )
1187 })
1188 .collect()
1189}
1190
1191fn absent_component_prop_baseline_keys(
1192 items: &[crate::results::AbsentComponentPropFinding],
1193 root: &Path,
1194) -> Vec<String> {
1195 items
1196 .iter()
1197 .map(|finding| {
1198 format!(
1199 "{}:{}:{}",
1200 relative_path(&finding.prop.path, root),
1201 finding.prop.component_name,
1202 finding.prop.prop_name
1203 )
1204 })
1205 .collect()
1206}
1207
1208fn component_prop_baseline_keys(
1209 items: &[crate::results::UnusedComponentPropFinding],
1210 root: &Path,
1211) -> Vec<String> {
1212 items
1213 .iter()
1214 .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
1215 .collect()
1216}
1217
1218fn component_emit_baseline_keys(
1219 items: &[crate::results::UnusedComponentEmitFinding],
1220 root: &Path,
1221) -> Vec<String> {
1222 items
1223 .iter()
1224 .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
1225 .collect()
1226}
1227
1228fn component_input_baseline_keys(
1229 items: &[crate::results::UnusedComponentInputFinding],
1230 root: &Path,
1231) -> Vec<String> {
1232 items
1233 .iter()
1234 .map(|i| {
1235 format!(
1236 "{}:{}",
1237 relative_path(&i.input.path, root),
1238 i.input.input_name
1239 )
1240 })
1241 .collect()
1242}
1243
1244fn component_output_baseline_keys(
1245 items: &[crate::results::UnusedComponentOutputFinding],
1246 root: &Path,
1247) -> Vec<String> {
1248 items
1249 .iter()
1250 .map(|o| {
1251 format!(
1252 "{}:{}",
1253 relative_path(&o.output.path, root),
1254 o.output.output_name
1255 )
1256 })
1257 .collect()
1258}
1259
1260fn svelte_event_baseline_keys(
1261 items: &[crate::results::UnusedSvelteEventFinding],
1262 root: &Path,
1263) -> Vec<String> {
1264 items
1265 .iter()
1266 .map(|e| {
1267 format!(
1268 "{}:{}",
1269 relative_path(&e.event.path, root),
1270 e.event.event_name
1271 )
1272 })
1273 .collect()
1274}
1275
1276#[cfg(test)]
1277fn server_action_baseline_keys(
1278 items: &[crate::results::UnusedServerActionFinding],
1279 root: &Path,
1280) -> Vec<String> {
1281 items
1282 .iter()
1283 .map(|a| {
1284 format!(
1285 "{}:{}",
1286 relative_path(&a.action.path, root),
1287 a.action.action_name
1288 )
1289 })
1290 .collect()
1291}
1292
1293#[cfg(test)]
1294fn load_data_key_baseline_keys(
1295 items: &[crate::results::UnusedLoadDataKeyFinding],
1296 root: &Path,
1297) -> Vec<String> {
1298 items
1299 .iter()
1300 .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
1301 .collect()
1302}
1303
1304#[cfg(test)]
1305fn unresolved_import_baseline_keys(
1306 items: &[crate::results::UnresolvedImportFinding],
1307 root: &Path,
1308) -> Vec<String> {
1309 items
1310 .iter()
1311 .map(|i| {
1312 format!(
1313 "{}:{}",
1314 relative_path(&i.import.path, root),
1315 i.import.specifier
1316 )
1317 })
1318 .collect()
1319}
1320
1321#[cfg(test)]
1322struct BaselineDependencyKeys {
1323 unused: Vec<String>,
1324 unused_dev: Vec<String>,
1325 unused_optional: Vec<String>,
1326 unlisted: Vec<String>,
1327 type_only: Vec<String>,
1328 test_only: Vec<String>,
1329 dev_in_prod: Vec<String>,
1330}
1331
1332#[cfg(test)]
1333fn baseline_dependency_keys(
1334 results: &crate::results::AnalysisResults,
1335 root: &Path,
1336) -> BaselineDependencyKeys {
1337 BaselineDependencyKeys {
1338 unused: results
1339 .unused_dependencies
1340 .iter()
1341 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1342 .collect(),
1343 unused_dev: results
1344 .unused_dev_dependencies
1345 .iter()
1346 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1347 .collect(),
1348 unused_optional: results
1349 .unused_optional_dependencies
1350 .iter()
1351 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1352 .collect(),
1353 unlisted: results
1354 .unlisted_dependencies
1355 .iter()
1356 .map(|d| d.dep.package_name.clone())
1357 .collect(),
1358 type_only: results
1359 .type_only_dependencies
1360 .iter()
1361 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1362 .collect(),
1363 test_only: results
1364 .test_only_dependencies
1365 .iter()
1366 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1367 .collect(),
1368 dev_in_prod: results
1369 .dev_dependencies_in_production
1370 .iter()
1371 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1372 .collect(),
1373 }
1374}
1375
1376#[cfg(test)]
1377struct BaselineGraphKeys {
1378 circular_dependencies: Vec<String>,
1379 re_export_cycles: Vec<String>,
1380 package_cycles: Vec<String>,
1381 boundary_violations: Vec<String>,
1382 boundary_coverage_violations: Vec<String>,
1383 boundary_call_violations: Vec<String>,
1384 policy_violations: Vec<String>,
1385}
1386
1387#[cfg(test)]
1388fn baseline_graph_keys(
1389 results: &crate::results::AnalysisResults,
1390 root: &Path,
1391) -> BaselineGraphKeys {
1392 BaselineGraphKeys {
1393 circular_dependencies: results
1394 .circular_dependencies
1395 .iter()
1396 .map(|c| circular_dep_key(&c.cycle, root))
1397 .collect(),
1398 re_export_cycles: results
1399 .re_export_cycles
1400 .iter()
1401 .map(|c| re_export_cycle_key(&c.cycle, root))
1402 .collect(),
1403 package_cycles: results
1404 .package_cycles
1405 .iter()
1406 .map(|c| package_cycle_key(&c.cycle))
1407 .collect(),
1408 boundary_violations: results
1409 .boundary_violations
1410 .iter()
1411 .map(|v| boundary_violation_key(&v.violation, root))
1412 .collect(),
1413 boundary_coverage_violations: results
1414 .boundary_coverage_violations
1415 .iter()
1416 .map(|v| relative_path(&v.violation.path, root))
1417 .collect(),
1418 boundary_call_violations: results
1419 .boundary_call_violations
1420 .iter()
1421 .map(|v| boundary_call_violation_key(&v.violation, root))
1422 .collect(),
1423 policy_violations: results
1424 .policy_violations
1425 .iter()
1426 .map(|v| policy_violation_key(&v.violation, root))
1427 .collect(),
1428 }
1429}
1430
1431#[cfg(test)]
1432struct BaselineCatalogKeys {
1433 unused_catalog_entries: Vec<String>,
1434 empty_catalog_groups: Vec<String>,
1435 unresolved_catalog_references: Vec<String>,
1436 unused_dependency_overrides: Vec<String>,
1437 misconfigured_dependency_overrides: Vec<String>,
1438}
1439
1440#[cfg(test)]
1441fn baseline_catalog_keys(
1442 results: &crate::results::AnalysisResults,
1443 root: &Path,
1444) -> BaselineCatalogKeys {
1445 BaselineCatalogKeys {
1446 unused_catalog_entries: results
1447 .unused_catalog_entries
1448 .iter()
1449 .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
1450 .collect(),
1451 empty_catalog_groups: results
1452 .empty_catalog_groups
1453 .iter()
1454 .map(|g| g.group.catalog_name.clone())
1455 .collect(),
1456 unresolved_catalog_references: results
1457 .unresolved_catalog_references
1458 .iter()
1459 .map(|r| {
1460 format!(
1461 "{}:{}:{}:{}",
1462 relative_path(&r.reference.path, root),
1463 r.reference.line,
1464 r.reference.catalog_name,
1465 r.reference.entry_name,
1466 )
1467 })
1468 .collect(),
1469 unused_dependency_overrides: results
1470 .unused_dependency_overrides
1471 .iter()
1472 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1473 .collect(),
1474 misconfigured_dependency_overrides: results
1475 .misconfigured_dependency_overrides
1476 .iter()
1477 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1478 .collect(),
1479 }
1480}
1481
1482fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
1484 format!(
1485 "{}->{}",
1486 relative_path(&v.from_path, root),
1487 relative_path(&v.to_path, root),
1488 )
1489}
1490
1491fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
1493 format!("{}:{}", relative_path(&v.path, root), v.callee)
1494}
1495
1496#[cfg(test)]
1500fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
1501 format!(
1502 "{}:{}/{}:{}",
1503 relative_path(&v.path, root),
1504 v.pack,
1505 v.rule_id,
1506 v.matched
1507 )
1508}
1509
1510fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
1512 let mut locs: Vec<String> = dup
1513 .locations
1514 .iter()
1515 .map(|l| relative_path(&l.path, root))
1516 .collect();
1517 locs.sort();
1518 format!("{}|{}", dup.export_name, locs.join("|"))
1519}
1520
1521fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
1523 let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
1524 paths.sort();
1525 paths.join("->")
1526}
1527
1528fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
1534 let kind = match cycle.kind {
1535 crate::results::ReExportCycleKind::MultiNode => "multi-node",
1536 crate::results::ReExportCycleKind::SelfLoop => "self-loop",
1537 };
1538 let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
1539 paths.sort();
1540 format!("{kind}:{}", paths.join("<->"))
1541}
1542
1543fn package_cycle_key(cycle: &crate::results::PackageCycle) -> String {
1547 cycle.packages.join("->")
1548}
1549
1550fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
1551 format!(
1552 "{}:{}->{}",
1553 relative_path(&leak.path, root),
1554 leak.export_name,
1555 leak.type_name
1556 )
1557}
1558
1559fn deprecated_export_key(
1560 export: &fallow_types::results::DeprecatedExportInUse,
1561 root: &Path,
1562) -> String {
1563 format!(
1564 "{}:{}",
1565 relative_path(&export.path, root),
1566 export.export_name
1567 )
1568}
1569
1570fn filter_private_type_leaks(
1571 leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
1572 baseline_keys: &[String],
1573 root: &Path,
1574) {
1575 let baseline_private_type_leaks: FxHashSet<&str> =
1576 baseline_keys.iter().map(String::as_str).collect();
1577 leaks.retain(|entry| {
1578 let key = private_type_leak_key(&entry.leak, root);
1579 !baseline_private_type_leaks.contains(key.as_str())
1580 });
1581}
1582
1583struct BaselineFilterContext<'a> {
1584 baseline: &'a BaselineData,
1585 root: &'a Path,
1586}
1587
1588impl BaselineFilterContext<'_> {
1589 fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
1590 let baseline_circular: FxHashSet<&str> = self
1591 .baseline
1592 .circular_dependencies
1593 .iter()
1594 .map(String::as_str)
1595 .collect();
1596 results.circular_dependencies.retain(|cycle| {
1597 let key = circular_dep_key(&cycle.cycle, self.root);
1598 !baseline_circular.contains(key.as_str())
1599 });
1600
1601 let baseline_re_export_cycles: FxHashSet<&str> = self
1602 .baseline
1603 .re_export_cycles
1604 .iter()
1605 .map(String::as_str)
1606 .collect();
1607 results.re_export_cycles.retain(|cycle| {
1608 let key = re_export_cycle_key(&cycle.cycle, self.root);
1609 !baseline_re_export_cycles.contains(key.as_str())
1610 });
1611
1612 let baseline_package_cycles: FxHashSet<&str> = self
1613 .baseline
1614 .package_cycles
1615 .iter()
1616 .map(String::as_str)
1617 .collect();
1618 results.package_cycles.retain(|cycle| {
1619 !baseline_package_cycles.contains(package_cycle_key(&cycle.cycle).as_str())
1620 });
1621
1622 self.filter_unused_members(results);
1623 self.filter_unresolved_and_exports(results);
1624 }
1625
1626 fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
1627 self.filter_enum_class_store_members(results);
1628 self.filter_component_surface_members(results);
1629 self.filter_route_action_members(results);
1630 }
1631
1632 fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1633 let baseline_enum_members: FxHashSet<&str> = self
1634 .baseline
1635 .unused_enum_members
1636 .iter()
1637 .map(String::as_str)
1638 .collect();
1639 results.unused_enum_members.retain(|member| {
1640 let key = format!(
1641 "{}:{}.{}",
1642 relative_path(&member.member.path, self.root),
1643 member.member.parent_name,
1644 member.member.member_name
1645 );
1646 !baseline_enum_members.contains(key.as_str())
1647 });
1648
1649 let baseline_class_members: FxHashSet<&str> = self
1650 .baseline
1651 .unused_class_members
1652 .iter()
1653 .map(String::as_str)
1654 .collect();
1655 results.unused_class_members.retain(|member| {
1656 let key = format!(
1657 "{}:{}.{}",
1658 relative_path(&member.member.path, self.root),
1659 member.member.parent_name,
1660 member.member.member_name
1661 );
1662 !baseline_class_members.contains(key.as_str())
1663 });
1664
1665 let baseline_store_members: FxHashSet<&str> = self
1666 .baseline
1667 .unused_store_members
1668 .iter()
1669 .map(String::as_str)
1670 .collect();
1671 results.unused_store_members.retain(|member| {
1672 let key = format!(
1673 "{}:{}.{}",
1674 relative_path(&member.member.path, self.root),
1675 member.member.parent_name,
1676 member.member.member_name
1677 );
1678 !baseline_store_members.contains(key.as_str())
1679 });
1680 }
1681
1682 fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1683 retain_new_by_keys(
1684 &mut results.unprovided_injects,
1685 &self.baseline.unprovided_injects,
1686 self.root,
1687 inject_baseline_keys,
1688 );
1689 retain_new_by_keys(
1690 &mut results.unrendered_components,
1691 &self.baseline.unrendered_components,
1692 self.root,
1693 component_baseline_keys,
1694 );
1695 retain_new_by_keys(
1696 &mut results.unused_component_props,
1697 &self.baseline.unused_component_props,
1698 self.root,
1699 component_prop_baseline_keys,
1700 );
1701 retain_new_by_keys(
1702 &mut results.absent_component_props,
1703 &self.baseline.absent_component_props,
1704 self.root,
1705 absent_component_prop_baseline_keys,
1706 );
1707 retain_new_by_keys(
1708 &mut results.unused_component_emits,
1709 &self.baseline.unused_component_emits,
1710 self.root,
1711 component_emit_baseline_keys,
1712 );
1713 retain_new_by_keys(
1714 &mut results.unused_component_inputs,
1715 &self.baseline.unused_component_inputs,
1716 self.root,
1717 component_input_baseline_keys,
1718 );
1719 retain_new_by_keys(
1720 &mut results.unused_component_outputs,
1721 &self.baseline.unused_component_outputs,
1722 self.root,
1723 component_output_baseline_keys,
1724 );
1725 retain_new_by_keys(
1726 &mut results.unused_svelte_events,
1727 &self.baseline.unused_svelte_events,
1728 self.root,
1729 svelte_event_baseline_keys,
1730 );
1731 }
1732
1733 fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1734 let baseline_unused_server_actions: FxHashSet<&str> = self
1735 .baseline
1736 .unused_server_actions
1737 .iter()
1738 .map(String::as_str)
1739 .collect();
1740 results.unused_server_actions.retain(|finding| {
1741 let key = format!(
1742 "{}:{}",
1743 relative_path(&finding.action.path, self.root),
1744 finding.action.action_name
1745 );
1746 !baseline_unused_server_actions.contains(key.as_str())
1747 });
1748
1749 let baseline_unused_load_data_keys: FxHashSet<&str> = self
1750 .baseline
1751 .unused_load_data_keys
1752 .iter()
1753 .map(String::as_str)
1754 .collect();
1755 results.unused_load_data_keys.retain(|finding| {
1756 let key = format!(
1757 "{}:{}",
1758 relative_path(&finding.key.path, self.root),
1759 finding.key.key_name
1760 );
1761 !baseline_unused_load_data_keys.contains(key.as_str())
1762 });
1763 }
1764
1765 fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1766 let baseline_unresolved: FxHashSet<&str> = self
1767 .baseline
1768 .unresolved_imports
1769 .iter()
1770 .map(String::as_str)
1771 .collect();
1772 results.unresolved_imports.retain(|import| {
1773 let key = format!(
1774 "{}:{}",
1775 relative_path(&import.import.path, self.root),
1776 import.import.specifier
1777 );
1778 !baseline_unresolved.contains(key.as_str())
1779 });
1780
1781 let baseline_unlisted: FxHashSet<&str> = self
1782 .baseline
1783 .unlisted_dependencies
1784 .iter()
1785 .map(String::as_str)
1786 .collect();
1787 results
1788 .unlisted_dependencies
1789 .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1790
1791 let baseline_dup_exports: FxHashSet<&str> = self
1792 .baseline
1793 .duplicate_exports
1794 .iter()
1795 .map(String::as_str)
1796 .collect();
1797 results.duplicate_exports.retain(|duplicate| {
1798 let key = duplicate_export_key(&duplicate.export, self.root);
1799 !baseline_dup_exports.contains(key.as_str())
1800 });
1801 }
1802
1803 fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1804 let baseline_optional_deps: FxHashSet<&str> = self
1805 .baseline
1806 .unused_optional_dependencies
1807 .iter()
1808 .map(String::as_str)
1809 .collect();
1810 results.unused_optional_dependencies.retain(|dep| {
1811 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1812 !baseline_contains_dependency(
1813 &baseline_optional_deps,
1814 &dep.dep.package_name,
1815 key.as_str(),
1816 )
1817 });
1818
1819 self.filter_type_and_test_only_dependencies(results);
1820 }
1821
1822 fn filter_type_and_test_only_dependencies(
1823 &self,
1824 results: &mut crate::results::AnalysisResults,
1825 ) {
1826 let baseline_type_only: FxHashSet<&str> = self
1827 .baseline
1828 .type_only_dependencies
1829 .iter()
1830 .map(String::as_str)
1831 .collect();
1832 results.type_only_dependencies.retain(|dep| {
1833 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1834 !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1835 });
1836
1837 let baseline_test_only: FxHashSet<&str> = self
1838 .baseline
1839 .test_only_dependencies
1840 .iter()
1841 .map(String::as_str)
1842 .collect();
1843 results.test_only_dependencies.retain(|dep| {
1844 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1845 !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1846 });
1847
1848 let baseline_dev_in_prod: FxHashSet<&str> = self
1849 .baseline
1850 .dev_dependencies_in_production
1851 .iter()
1852 .map(String::as_str)
1853 .collect();
1854 results.dev_dependencies_in_production.retain(|dep| {
1855 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1856 !baseline_contains_dependency(
1857 &baseline_dev_in_prod,
1858 &dep.dep.package_name,
1859 key.as_str(),
1860 )
1861 });
1862 }
1863
1864 fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1865 let baseline_boundary: FxHashSet<&str> = self
1866 .baseline
1867 .boundary_violations
1868 .iter()
1869 .map(String::as_str)
1870 .collect();
1871 results.boundary_violations.retain(|violation| {
1872 let key = boundary_violation_key(&violation.violation, self.root);
1873 !baseline_boundary.contains(key.as_str())
1874 });
1875
1876 self.filter_boundary_details(results);
1877 self.filter_stale_suppressions(results);
1878 self.filter_invalid_client_exports(results);
1879 self.filter_mixed_client_server_barrels(results);
1880 self.filter_misplaced_directives(results);
1881 self.filter_route_collisions(results);
1882 self.filter_dynamic_segment_name_conflicts(results);
1883 }
1884
1885 fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1886 let baseline_invalid: FxHashSet<&str> = self
1887 .baseline
1888 .invalid_client_exports
1889 .iter()
1890 .map(String::as_str)
1891 .collect();
1892 results.invalid_client_exports.retain(|finding| {
1893 let key = format!(
1894 "{}:{}",
1895 relative_path(&finding.export.path, self.root),
1896 finding.export.export_name
1897 );
1898 !baseline_invalid.contains(key.as_str())
1899 });
1900 }
1901
1902 fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1903 let baseline_barrels: FxHashSet<&str> = self
1904 .baseline
1905 .mixed_client_server_barrels
1906 .iter()
1907 .map(String::as_str)
1908 .collect();
1909 results.mixed_client_server_barrels.retain(|finding| {
1910 let key = format!(
1911 "{}:{}:{}",
1912 relative_path(&finding.barrel.path, self.root),
1913 finding.barrel.client_origin,
1914 finding.barrel.server_origin
1915 );
1916 !baseline_barrels.contains(key.as_str())
1917 });
1918 }
1919
1920 fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1921 let baseline_directives: FxHashSet<&str> = self
1922 .baseline
1923 .misplaced_directives
1924 .iter()
1925 .map(String::as_str)
1926 .collect();
1927 results.misplaced_directives.retain(|finding| {
1928 let key = format!(
1929 "{}:{}:{}",
1930 relative_path(&finding.directive_site.path, self.root),
1931 finding.directive_site.line,
1932 finding.directive_site.directive
1933 );
1934 !baseline_directives.contains(key.as_str())
1935 });
1936 }
1937
1938 fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1939 let baseline_collisions: FxHashSet<&str> = self
1940 .baseline
1941 .route_collisions
1942 .iter()
1943 .map(String::as_str)
1944 .collect();
1945 results.route_collisions.retain(|finding| {
1946 let key = format!(
1947 "{}:{}",
1948 relative_path(&finding.collision.path, self.root),
1949 finding.collision.url
1950 );
1951 !baseline_collisions.contains(key.as_str())
1952 });
1953 }
1954
1955 fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1956 let baseline_conflicts: FxHashSet<&str> = self
1957 .baseline
1958 .dynamic_segment_name_conflicts
1959 .iter()
1960 .map(String::as_str)
1961 .collect();
1962 results.dynamic_segment_name_conflicts.retain(|finding| {
1963 let key = format!(
1964 "{}:{}",
1965 relative_path(&finding.conflict.path, self.root),
1966 finding.conflict.position
1967 );
1968 !baseline_conflicts.contains(key.as_str())
1969 });
1970 }
1971
1972 fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1973 let baseline_boundary_coverage: FxHashSet<&str> = self
1974 .baseline
1975 .boundary_coverage_violations
1976 .iter()
1977 .map(String::as_str)
1978 .collect();
1979 results.boundary_coverage_violations.retain(|violation| {
1980 let key = relative_path(&violation.violation.path, self.root);
1981 !baseline_boundary_coverage.contains(key.as_str())
1982 });
1983
1984 let baseline_boundary_calls: FxHashSet<&str> = self
1985 .baseline
1986 .boundary_call_violations
1987 .iter()
1988 .map(String::as_str)
1989 .collect();
1990 results.boundary_call_violations.retain(|violation| {
1991 let key = boundary_call_violation_key(&violation.violation, self.root);
1992 !baseline_boundary_calls.contains(key.as_str())
1993 });
1994 }
1995
1996 fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1997 let baseline_stale: FxHashSet<&str> = self
1998 .baseline
1999 .stale_suppressions
2000 .iter()
2001 .map(String::as_str)
2002 .collect();
2003 results.stale_suppressions.retain(|suppression| {
2004 let key = stale_suppression_baseline_key(suppression, self.root);
2005 let legacy_key = format!(
2006 "{}:{}",
2007 relative_path(&suppression.path, self.root),
2008 suppression.line
2009 );
2010 !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
2011 });
2012 }
2013
2014 fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
2015 let baseline_catalog: FxHashSet<&str> = self
2016 .baseline
2017 .unused_catalog_entries
2018 .iter()
2019 .map(String::as_str)
2020 .collect();
2021 results.unused_catalog_entries.retain(|entry| {
2022 let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
2023 !baseline_catalog.contains(key.as_str())
2024 });
2025
2026 let baseline_empty_catalog_groups: FxHashSet<&str> = self
2027 .baseline
2028 .empty_catalog_groups
2029 .iter()
2030 .map(String::as_str)
2031 .collect();
2032 results.empty_catalog_groups.retain(|group| {
2033 !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
2034 });
2035
2036 self.filter_pnpm_references_and_overrides(results);
2037 }
2038
2039 fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
2040 let baseline_unresolved: FxHashSet<&str> = self
2041 .baseline
2042 .unresolved_catalog_references
2043 .iter()
2044 .map(String::as_str)
2045 .collect();
2046 results.unresolved_catalog_references.retain(|reference| {
2047 let key = format!(
2048 "{}:{}:{}:{}",
2049 relative_path(&reference.reference.path, self.root),
2050 reference.reference.line,
2051 reference.reference.catalog_name,
2052 reference.reference.entry_name,
2053 );
2054 !baseline_unresolved.contains(key.as_str())
2055 });
2056
2057 self.filter_pnpm_overrides(results);
2058 }
2059
2060 fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
2061 let baseline_unused_overrides: FxHashSet<&str> = self
2062 .baseline
2063 .unused_dependency_overrides
2064 .iter()
2065 .map(String::as_str)
2066 .collect();
2067 results
2068 .unused_dependency_overrides
2069 .retain(|override_entry| {
2070 let key = format!(
2071 "{}:{}",
2072 override_entry.entry.source, override_entry.entry.raw_key
2073 );
2074 !baseline_unused_overrides.contains(key.as_str())
2075 });
2076
2077 let baseline_misconfigured_overrides: FxHashSet<&str> = self
2078 .baseline
2079 .misconfigured_dependency_overrides
2080 .iter()
2081 .map(String::as_str)
2082 .collect();
2083 results
2084 .misconfigured_dependency_overrides
2085 .retain(|override_entry| {
2086 let key = format!(
2087 "{}:{}",
2088 override_entry.entry.source, override_entry.entry.raw_key
2089 );
2090 !baseline_misconfigured_overrides.contains(key.as_str())
2091 });
2092 }
2093}
2094
2095pub fn filter_new_issues(
2100 results: crate::results::AnalysisResults,
2101 baseline: &BaselineData,
2102 root: &Path,
2103) -> crate::results::AnalysisResults {
2104 if baseline.identity.is_some() {
2105 filter_new_issues_by_canonical_keys(results, baseline, root)
2106 } else {
2107 filter_new_issues_by_legacy_keys(results, baseline, root)
2108 }
2109}
2110
2111fn filter_new_issues_by_canonical_keys(
2112 mut results: crate::results::AnalysisResults,
2113 baseline: &BaselineData,
2114 root: &Path,
2115) -> crate::results::AnalysisResults {
2116 let paths = IdentityPaths::new(root);
2117 macro_rules! retain {
2118 ($($field:ident),* $(,)?) => {
2119 $(retain_unsaved(&mut results.$field, &baseline.$field, &paths);)*
2120 };
2121 }
2122 with_baseline_fields!(retain);
2123 results
2124}
2125
2126fn filter_new_issues_by_legacy_keys(
2127 mut results: crate::results::AnalysisResults,
2128 baseline: &BaselineData,
2129 root: &Path,
2130) -> crate::results::AnalysisResults {
2131 let baseline_files: FxHashSet<&str> =
2132 baseline.unused_files.iter().map(String::as_str).collect();
2133 let baseline_exports: FxHashSet<&str> =
2134 baseline.unused_exports.iter().map(String::as_str).collect();
2135 let baseline_types: FxHashSet<&str> =
2136 baseline.unused_types.iter().map(String::as_str).collect();
2137 let baseline_deps: FxHashSet<&str> = baseline
2138 .unused_dependencies
2139 .iter()
2140 .map(String::as_str)
2141 .collect();
2142 let baseline_dev_deps: FxHashSet<&str> = baseline
2143 .unused_dev_dependencies
2144 .iter()
2145 .map(String::as_str)
2146 .collect();
2147
2148 results
2149 .unused_files
2150 .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
2151 results.unused_exports.retain(|e| {
2152 let key = format!(
2153 "{}:{}",
2154 relative_path(&e.export.path, root),
2155 e.export.export_name
2156 );
2157 !baseline_exports.contains(key.as_str())
2158 });
2159 results.unused_types.retain(|e| {
2160 let key = format!(
2161 "{}:{}",
2162 relative_path(&e.export.path, root),
2163 e.export.export_name
2164 );
2165 !baseline_types.contains(key.as_str())
2166 });
2167 filter_private_type_leaks(
2168 &mut results.private_type_leaks,
2169 &baseline.private_type_leaks,
2170 root,
2171 );
2172 let baseline_deprecated: FxHashSet<&str> = baseline
2173 .deprecated_exports_in_use
2174 .iter()
2175 .map(String::as_str)
2176 .collect();
2177 results
2178 .deprecated_exports_in_use
2179 .retain(|e| !baseline_deprecated.contains(deprecated_export_key(&e.export, root).as_str()));
2180 results.unused_dependencies.retain(|d| {
2181 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
2182 !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
2183 });
2184 results.unused_dev_dependencies.retain(|d| {
2185 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
2186 !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
2187 });
2188
2189 let filter = BaselineFilterContext { baseline, root };
2190 filter.filter_cycles_and_members(&mut results);
2191 filter.filter_dependency_variants(&mut results);
2192 filter.filter_boundaries_and_suppressions(&mut results);
2193 filter.filter_pnpm_entries(&mut results);
2194
2195 results
2196}
2197
2198#[derive(Debug, Clone, PartialEq, Eq)]
2200pub enum DeadCodeBaselineOutcome {
2201 Applied {
2203 staleness: BaselineStaleness,
2205 legacy_keys: bool,
2208 },
2209 NotDeadCode {
2213 staleness: BaselineStaleness,
2215 saved_by: Option<BaselineKind>,
2217 },
2218}
2219
2220#[derive(Debug, Clone, PartialEq, Eq)]
2222pub enum DeadCodeBaselineError {
2223 Parse(String),
2226 IncompatibleIdentity(Vec<&'static str>),
2229}
2230
2231pub fn apply_dead_code_baseline(
2248 results: &mut crate::results::AnalysisResults,
2249 content: &str,
2250 root: &Path,
2251 identity: &fallow_types::semantic::SemanticAnalysisIdentity,
2252 change_scoped: bool,
2253) -> Result<DeadCodeBaselineOutcome, DeadCodeBaselineError> {
2254 let parsed = serde_json::from_str::<serde_json::Value>(content)
2255 .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
2256 let saved_by = match classify_baseline_value(&parsed, BaselineKind::DeadCode) {
2257 BaselineFileKind::Own | BaselineFileKind::NotAnObject => None,
2258 foreign @ BaselineFileKind::Foreign(_) => Some(foreign.saved_by()),
2259 BaselineFileKind::Unrecognised => Some(None),
2260 };
2261 if let Some(saved_by) = saved_by {
2262 return Ok(DeadCodeBaselineOutcome::NotDeadCode {
2263 staleness: BaselineStaleness {
2264 entries: 0,
2265 matched: 0,
2266 current_findings: results.total_issues(),
2267 change_scoped,
2268 },
2269 saved_by,
2270 });
2271 }
2272 let baseline = serde_json::from_value::<BaselineData>(parsed)
2273 .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
2274 if let Some(scheme) = baseline.identity.as_deref()
2275 && scheme != BASELINE_KEY_SCHEME
2276 {
2277 return Err(DeadCodeBaselineError::Parse(format!(
2278 "unknown baseline identity `{scheme}`: this fallow version reads `{BASELINE_KEY_SCHEME}` and legacy baselines. Regenerate the file with --save-baseline"
2279 )));
2280 }
2281 let incompatible = baseline.analysis_identity().incompatible_fields(identity);
2282 if !incompatible.is_empty() {
2283 return Err(DeadCodeBaselineError::IncompatibleIdentity(incompatible));
2284 }
2285 let before = results.total_issues();
2286 *results = filter_new_issues(std::mem::take(results), &baseline, root);
2287 Ok(DeadCodeBaselineOutcome::Applied {
2288 staleness: BaselineStaleness {
2289 entries: baseline.total_entries(),
2290 matched: before.saturating_sub(results.total_issues()),
2291 current_findings: before,
2292 change_scoped,
2293 },
2294 legacy_keys: baseline.identity.is_none(),
2295 })
2296}
2297
2298#[derive(Default, serde::Serialize, serde::Deserialize)]
2309pub struct DuplicationBaselineData {
2310 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2313 kind: Option<BaselineKind>,
2314 #[serde(default)]
2316 pub clone_groups: Vec<String>,
2317 #[serde(default)]
2319 pub clone_fingerprints: Vec<String>,
2320 #[serde(default)]
2322 pub normalized_clone_fingerprints: Vec<String>,
2323}
2324
2325impl DuplicationBaselineData {
2326 pub const DECLARED_KEYS: &'static [&'static str] = &[
2328 "clone_groups",
2329 "clone_fingerprints",
2330 "normalized_clone_fingerprints",
2331 ];
2332
2333 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
2335 let fingerprints =
2336 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2337 Self {
2338 kind: Some(BaselineKind::Dupes),
2339 clone_groups: report
2340 .clone_groups
2341 .iter()
2342 .map(|g| clone_group_key(g, root))
2343 .collect(),
2344 clone_fingerprints: report
2345 .clone_groups
2346 .iter()
2347 .map(legacy_clone_group_fingerprint_key)
2348 .collect(),
2349 normalized_clone_fingerprints: report
2350 .clone_groups
2351 .iter()
2352 .map(|group| clone_group_fingerprint_key(group, &fingerprints))
2353 .collect(),
2354 }
2355 }
2356
2357 #[must_use]
2359 pub fn entry_count(&self) -> usize {
2360 if !self.normalized_clone_fingerprints.is_empty() {
2361 self.normalized_clone_fingerprints.len()
2362 } else if !self.clone_fingerprints.is_empty() {
2363 self.clone_fingerprints.len()
2364 } else {
2365 self.clone_groups.len()
2366 }
2367 }
2368}
2369
2370fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
2372 let mut parts: Vec<String> = group
2373 .instances
2374 .iter()
2375 .map(|i| {
2376 format!(
2377 "{}:{}-{}",
2378 relative_path(&i.file, root),
2379 i.start_line,
2380 i.end_line
2381 )
2382 })
2383 .collect();
2384 parts.sort();
2385 parts.join("|")
2386}
2387
2388fn clone_group_fingerprint_key(
2390 group: &crate::duplicates::CloneGroup,
2391 fingerprints: &crate::duplicates::CloneFingerprintSet,
2392) -> String {
2393 fingerprints.ignored_clone_key_for_group(group)
2394}
2395
2396fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
2399 let representative = group
2400 .instances
2401 .iter()
2402 .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
2403 .map_or("", |i| i.fragment.as_str());
2404 let hash = if representative.as_bytes().contains(&b'\r') {
2405 xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
2406 } else {
2407 xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
2408 };
2409 format!(
2410 "{}{:08x}:{}",
2411 crate::duplicates::FINGERPRINT_PREFIX,
2412 hash as u32,
2413 group.instances.len()
2414 )
2415}
2416
2417fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
2418 match remaining.get_mut(key) {
2419 Some(count) if *count > 0 => {
2420 *count -= 1;
2421 true
2422 }
2423 _ => false,
2424 }
2425}
2426
2427pub fn filter_new_clone_groups(
2433 mut report: DuplicationReport,
2434 baseline: &DuplicationBaselineData,
2435 root: &Path,
2436) -> DuplicationReport {
2437 if !baseline.normalized_clone_fingerprints.is_empty() {
2438 let fingerprints =
2439 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2440 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2441 for key in &baseline.normalized_clone_fingerprints {
2442 *remaining.entry(key.as_str()).or_insert(0) += 1;
2443 }
2444 report.clone_groups.retain(|group| {
2445 let key = clone_group_fingerprint_key(group, &fingerprints);
2446 !consume_baseline_key(&mut remaining, &key)
2447 });
2448 } else if baseline.clone_fingerprints.is_empty() {
2449 let baseline_keys: FxHashSet<&str> =
2450 baseline.clone_groups.iter().map(String::as_str).collect();
2451 report.clone_groups.retain(|g| {
2452 let key = clone_group_key(g, root);
2453 !baseline_keys.contains(key.as_str())
2454 });
2455 } else {
2456 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2457 for key in &baseline.clone_fingerprints {
2458 *remaining.entry(key.as_str()).or_insert(0) += 1;
2459 }
2460 report.clone_groups.retain(|group| {
2461 let key = legacy_clone_group_fingerprint_key(group);
2462 !consume_baseline_key(&mut remaining, &key)
2463 });
2464 }
2465
2466 crate::duplicates::refresh_clone_families(&mut report, root);
2467 report.stats = recompute_stats(&report);
2468
2469 report
2470}
2471
2472pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
2477 crate::duplicates::recompute_stats(report)
2478}
2479
2480#[derive(Default, serde::Serialize, serde::Deserialize)]
2494pub struct HealthBaselineData {
2495 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2498 pub(crate) kind: Option<BaselineKind>,
2499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2501 pub(crate) findings: Vec<String>,
2502 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2504 pub(crate) finding_counts: HealthFindingCountMap,
2505 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2507 pub(crate) identity_finding_counts: HealthFindingCountMap,
2508 #[serde(default)]
2510 pub(crate) runtime_coverage_findings: Vec<String>,
2511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2518 pub(crate) runtime_coverage_source_hashes: Vec<String>,
2519 #[serde(default)]
2521 pub(crate) target_keys: Vec<String>,
2522}
2523
2524#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2526pub struct HealthBaselineCount {
2527 count: usize,
2528}
2529
2530type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
2531
2532#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2534pub enum HealthBaselineMode {
2535 #[default]
2545 Count,
2546 Identity,
2555}
2556
2557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2558enum HealthFindingDimension {
2559 Complexity,
2560 Crap,
2561}
2562
2563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2564struct HealthFindingCategory {
2565 dimension: HealthFindingDimension,
2566 severity: fallow_output::FindingSeverity,
2567}
2568
2569impl HealthFindingCategory {
2570 const fn key(self) -> &'static str {
2571 match (self.dimension, self.severity) {
2572 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
2573 "complexity_moderate"
2574 }
2575 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
2576 "complexity_high"
2577 }
2578 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
2579 "complexity_critical"
2580 }
2581 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
2582 "crap_moderate"
2583 }
2584 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
2585 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
2586 "crap_critical"
2587 }
2588 }
2589 }
2590}
2591
2592const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
2593 HealthFindingDimension::Complexity,
2594 HealthFindingDimension::Crap,
2595];
2596
2597impl HealthBaselineData {
2598 pub const DECLARED_KEYS: &'static [&'static str] = &[
2602 "findings",
2603 "finding_counts",
2604 "identity_finding_counts",
2605 "runtime_coverage_findings",
2606 "runtime_coverage_source_hashes",
2607 "target_keys",
2608 ];
2609
2610 pub(crate) fn from_findings(
2612 findings: &[fallow_output::ComplexityViolation],
2613 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
2614 targets: &[fallow_output::RefactoringTarget],
2615 root: &Path,
2616 ) -> Self {
2617 Self {
2618 kind: Some(BaselineKind::Health),
2619 findings: Vec::new(),
2620 finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
2621 identity_finding_counts: HealthFindingCountMap::new(),
2622 runtime_coverage_findings: runtime_coverage_findings
2623 .iter()
2624 .map(|f| runtime_coverage_finding_key(f, root))
2625 .collect(),
2626 runtime_coverage_source_hashes: runtime_coverage_findings
2627 .iter()
2628 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
2629 .collect(),
2630 target_keys: targets
2631 .iter()
2632 .map(|t| target_baseline_key(t, root))
2633 .collect(),
2634 }
2635 }
2636
2637 pub(crate) fn finding_entry_count(&self) -> usize {
2638 if self.finding_counts.is_empty() {
2639 self.findings.len()
2640 } else {
2641 self.finding_counts
2642 .values()
2643 .flat_map(BTreeMap::values)
2644 .map(|entry| entry.count)
2645 .sum()
2646 }
2647 }
2648
2649 #[must_use]
2654 pub(crate) fn with_identity(
2655 mut self,
2656 findings: &[fallow_output::ComplexityViolation],
2657 root: &Path,
2658 ) -> Self {
2659 self.identity_finding_counts =
2660 health_finding_counts(findings, root, HealthBaselineMode::Identity);
2661 self
2662 }
2663
2664 pub(crate) fn lacks_identity_data(&self) -> bool {
2667 self.identity_finding_counts.is_empty()
2668 && (!self.finding_counts.is_empty() || !self.findings.is_empty())
2669 }
2670
2671 fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
2672 match mode {
2673 HealthBaselineMode::Count => &self.finding_counts,
2674 HealthBaselineMode::Identity => &self.identity_finding_counts,
2675 }
2676 }
2677
2678 pub(crate) fn overlap_entries(
2679 &self,
2680 findings: &[fallow_output::ComplexityViolation],
2681 root: &Path,
2682 mode: HealthBaselineMode,
2683 ) -> HealthBaselineOverlap {
2684 let baseline_counts = self.counts_for(mode);
2685 if baseline_counts.is_empty() {
2686 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
2687 HealthBaselineOverlap {
2688 matched_entries: findings
2689 .iter()
2690 .filter(|finding| {
2691 baseline_keys.contains(health_finding_key(finding, root).as_str())
2692 })
2693 .count(),
2694 moved_entries: 0,
2695 }
2696 } else {
2697 let current_counts = health_finding_counts(findings, root, mode);
2698 let direct = health_overlap_entry_count(¤t_counts, baseline_counts);
2699 let remapped = (mode == HealthBaselineMode::Identity)
2700 .then(|| {
2701 identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root)
2702 })
2703 .flatten();
2704 match remapped {
2705 Some(remapped_counts) => {
2706 let matched = health_overlap_entry_count(¤t_counts, &remapped_counts);
2707 HealthBaselineOverlap {
2708 matched_entries: matched,
2709 moved_entries: matched.saturating_sub(direct),
2710 }
2711 }
2712 None => HealthBaselineOverlap {
2713 matched_entries: direct,
2714 moved_entries: 0,
2715 },
2716 }
2717 }
2718 }
2719}
2720
2721pub(crate) struct HealthBaselineOverlap {
2724 pub(crate) matched_entries: usize,
2726 pub(crate) moved_entries: usize,
2729}
2730
2731fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
2733 format!(
2734 "{}:{}",
2735 relative_path(&target.path, root),
2736 target.category.label()
2737 )
2738}
2739
2740fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
2742 format!(
2743 "{}:{}:{}",
2744 relative_path(&finding.path, root),
2745 finding.name,
2746 finding.line
2747 )
2748}
2749
2750fn health_bucket_key(
2755 finding: &fallow_output::ComplexityViolation,
2756 root: &Path,
2757 mode: HealthBaselineMode,
2758) -> String {
2759 let path = relative_path(&finding.path, root);
2760 match mode {
2761 HealthBaselineMode::Count => path,
2762 HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
2763 }
2764}
2765
2766const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
2770
2771fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
2772 key.split_once('\0')
2773}
2774
2775fn moved_identity_bucket_remaps(
2793 baseline_counts: &HealthFindingCountMap,
2794 current_counts: &HealthFindingCountMap,
2795 root: &Path,
2796) -> Vec<(String, String)> {
2797 let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
2798 for key in current_counts.keys() {
2799 if baseline_counts.contains_key(key) {
2800 continue;
2801 }
2802 if let Some((_, name)) = identity_bucket_parts(key)
2803 && name != ANONYMOUS_FUNCTION_NAME
2804 {
2805 candidates_by_name.entry(name).or_default().push(key);
2806 }
2807 }
2808
2809 let mut proposals: Vec<(&str, &str)> = Vec::new();
2810 let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
2811 for key in baseline_counts.keys() {
2812 if current_counts.contains_key(key.as_str()) {
2813 continue;
2814 }
2815 let Some((path, name)) = identity_bucket_parts(key) else {
2816 continue;
2817 };
2818 if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
2819 continue;
2820 }
2821 if let Some(candidates) = candidates_by_name.get(name)
2822 && let [only_candidate] = candidates.as_slice()
2823 {
2824 proposals.push((key.as_str(), only_candidate));
2825 *claims.entry(only_candidate).or_default() += 1;
2826 }
2827 }
2828
2829 proposals
2830 .into_iter()
2831 .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
2832 .map(|(old, new)| (old.to_string(), new.to_string()))
2833 .collect()
2834}
2835
2836fn identity_counts_with_move_tolerance(
2841 baseline_counts: &HealthFindingCountMap,
2842 current_counts: &HealthFindingCountMap,
2843 root: &Path,
2844) -> Option<HealthFindingCountMap> {
2845 let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
2846 if remaps.is_empty() {
2847 return None;
2848 }
2849 let mut remapped = baseline_counts.clone();
2850 for (old_key, new_key) in remaps {
2851 if let Some(entry) = remapped.remove(&old_key) {
2852 remapped.insert(new_key, entry);
2853 }
2854 }
2855 Some(remapped)
2856}
2857
2858fn health_finding_counts(
2859 findings: &[fallow_output::ComplexityViolation],
2860 root: &Path,
2861 mode: HealthBaselineMode,
2862) -> HealthFindingCountMap {
2863 let mut counts = BTreeMap::new();
2864 for finding in findings {
2865 let bucket = health_bucket_key(finding, root, mode);
2866 let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
2867 for category in health_finding_categories(finding).into_iter().flatten() {
2868 file_counts
2869 .entry(category.key().to_string())
2870 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
2871 .or_insert(HealthBaselineCount { count: 1 });
2872 }
2873 }
2874 counts
2875}
2876
2877fn health_finding_categories(
2878 finding: &fallow_output::ComplexityViolation,
2879) -> [Option<HealthFindingCategory>; 2] {
2880 let complexity_category = HealthFindingCategory {
2881 dimension: HealthFindingDimension::Complexity,
2882 severity: finding.severity,
2883 };
2884 let crap_category = HealthFindingCategory {
2885 dimension: HealthFindingDimension::Crap,
2886 severity: finding.severity,
2887 };
2888 let has_complexity =
2889 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
2890 let has_crap = finding.exceeded.includes_crap();
2891 [
2892 has_complexity.then_some(complexity_category),
2893 has_crap.then_some(crap_category),
2894 ]
2895}
2896
2897fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
2898 match severity {
2899 fallow_output::FindingSeverity::Moderate => 0,
2900 fallow_output::FindingSeverity::High => 1,
2901 fallow_output::FindingSeverity::Critical => 2,
2902 }
2903}
2904
2905fn severity_counts_for_dimension(
2906 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
2907 dimension: HealthFindingDimension,
2908) -> [usize; 3] {
2909 let mut counts = [0; 3];
2910 for severity in [
2911 fallow_output::FindingSeverity::Moderate,
2912 fallow_output::FindingSeverity::High,
2913 fallow_output::FindingSeverity::Critical,
2914 ] {
2915 let category = HealthFindingCategory {
2916 dimension,
2917 severity,
2918 };
2919 counts[severity_index(severity)] = file_counts
2920 .and_then(|entries| entries.get(category.key()))
2921 .map_or(0, |entry| entry.count);
2922 }
2923 counts
2924}
2925
2926fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
2927 let mut available = baseline;
2928 let mut overflow = [false; 3];
2929
2930 for severity_idx in 0..3 {
2931 let compatible = available[severity_idx..].iter().sum::<usize>();
2932 overflow[severity_idx] = compatible < current[severity_idx];
2933
2934 let mut matched = current[severity_idx].min(compatible);
2935 for slot in available.iter_mut().skip(severity_idx) {
2936 let taken = matched.min(*slot);
2937 *slot -= taken;
2938 matched -= taken;
2939 if matched == 0 {
2940 break;
2941 }
2942 }
2943 }
2944
2945 overflow
2946}
2947
2948fn health_overflow_categories(
2949 current_counts: &HealthFindingCountMap,
2950 baseline_counts: &HealthFindingCountMap,
2951) -> FxHashMap<String, FxHashSet<&'static str>> {
2952 let mut overflow_by_path = FxHashMap::default();
2953
2954 for (path, current_file_counts) in current_counts {
2955 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
2956 let baseline_file_counts = baseline_counts.get(path);
2957
2958 for dimension in HEALTH_FINDING_DIMENSIONS {
2959 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
2960 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
2961 let overflow = overflowing_severities(current, baseline);
2962
2963 for severity in [
2964 fallow_output::FindingSeverity::Moderate,
2965 fallow_output::FindingSeverity::High,
2966 fallow_output::FindingSeverity::Critical,
2967 ] {
2968 if overflow[severity_index(severity)] {
2969 overflow_categories.insert(
2970 HealthFindingCategory {
2971 dimension,
2972 severity,
2973 }
2974 .key(),
2975 );
2976 }
2977 }
2978 }
2979
2980 if !overflow_categories.is_empty() {
2981 overflow_by_path.insert(path.clone(), overflow_categories);
2982 }
2983 }
2984
2985 overflow_by_path
2986}
2987
2988fn health_overlap_entry_count(
2989 current_counts: &HealthFindingCountMap,
2990 baseline_counts: &HealthFindingCountMap,
2991) -> usize {
2992 let mut overlap = 0;
2993
2994 for (path, baseline_file_counts) in baseline_counts {
2995 let current_file_counts = current_counts.get(path);
2996
2997 for dimension in HEALTH_FINDING_DIMENSIONS {
2998 let current_total: usize =
2999 severity_counts_for_dimension(current_file_counts, dimension)
3000 .into_iter()
3001 .sum();
3002 let baseline_total: usize =
3003 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
3004 .into_iter()
3005 .sum();
3006 overlap += current_total.min(baseline_total);
3007 }
3008 }
3009
3010 overlap
3011}
3012
3013fn runtime_coverage_finding_key(
3014 finding: &fallow_output::RuntimeCoverageFinding,
3015 _root: &Path,
3016) -> String {
3017 finding
3018 .stable_id
3019 .clone()
3020 .unwrap_or_else(|| finding.id.clone())
3021}
3022
3023fn runtime_coverage_source_hash_key(
3030 finding: &fallow_output::RuntimeCoverageFinding,
3031 root: &Path,
3032) -> Option<String> {
3033 finding.source_hash.as_deref().map(|hash| {
3034 format!(
3035 "{}\0{}\0{}",
3036 relative_path(&finding.path, root),
3037 finding.function,
3038 hash
3039 )
3040 })
3041}
3042
3043pub(crate) fn filter_new_health_findings(
3045 mut findings: Vec<fallow_output::ComplexityViolation>,
3046 baseline: &HealthBaselineData,
3047 root: &Path,
3048 mode: HealthBaselineMode,
3049) -> Vec<fallow_output::ComplexityViolation> {
3050 let baseline_counts = baseline.counts_for(mode);
3051 if !baseline_counts.is_empty() {
3052 let current_counts = health_finding_counts(&findings, root, mode);
3053 let remapped = (mode == HealthBaselineMode::Identity)
3054 .then(|| identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root))
3055 .flatten();
3056 let overflow_categories = health_overflow_categories(
3057 ¤t_counts,
3058 remapped.as_ref().unwrap_or(baseline_counts),
3059 );
3060 findings.retain(|finding| {
3061 let bucket = health_bucket_key(finding, root, mode);
3062 overflow_categories.get(&bucket).is_some_and(|categories| {
3063 health_finding_categories(finding)
3064 .into_iter()
3065 .flatten()
3066 .any(|category| categories.contains(category.key()))
3067 })
3068 });
3069 return findings;
3070 }
3071
3072 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
3073 findings.retain(|f| {
3074 let key = health_finding_key(f, root);
3075 !baseline_keys.contains(key.as_str())
3076 });
3077 findings
3078}
3079
3080pub(crate) fn filter_new_runtime_coverage_findings(
3081 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
3082 baseline: &HealthBaselineData,
3083 root: &Path,
3084) -> Vec<fallow_output::RuntimeCoverageFinding> {
3085 let baseline_keys: FxHashSet<&str> = baseline
3086 .runtime_coverage_findings
3087 .iter()
3088 .map(String::as_str)
3089 .collect();
3090 let baseline_source_hash_keys: FxHashSet<&str> = baseline
3091 .runtime_coverage_source_hashes
3092 .iter()
3093 .map(String::as_str)
3094 .collect();
3095 findings.retain(|finding| {
3096 let suppressed_by_stable_id = finding
3097 .stable_id
3098 .as_deref()
3099 .is_some_and(|id| baseline_keys.contains(id));
3100 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
3101 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
3102 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
3103 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
3104 });
3105 findings
3106}
3107
3108pub(crate) fn filter_new_health_targets(
3110 mut targets: Vec<fallow_output::RefactoringTarget>,
3111 baseline: &HealthBaselineData,
3112 root: &Path,
3113) -> Vec<fallow_output::RefactoringTarget> {
3114 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
3115 targets.retain(|t| {
3116 let key = target_baseline_key(t, root);
3117 !baseline_keys.contains(key.as_str())
3118 });
3119 targets
3120}
3121
3122#[derive(Debug, Clone, serde::Serialize)]
3124pub struct CategoryDelta {
3125 pub current: usize,
3127 pub baseline: usize,
3129 pub delta: i64,
3131}
3132
3133#[derive(Debug, Clone)]
3138pub struct BaselineDeltas {
3139 pub total_delta: i64,
3141 pub per_category: Vec<(String, CategoryDelta)>,
3143}
3144
3145#[cfg(test)]
3146mod tests {
3147 use super::*;
3148
3149 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3150 use crate::results::{
3151 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
3152 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
3153 UnusedFile,
3154 };
3155 use fallow_types::output_dead_code::{
3156 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
3157 };
3158 use std::path::PathBuf;
3159
3160 #[test]
3161 fn stale_share_threshold_matches_the_documented_quarter() {
3162 for (baseline_entries, stale_entries, expected) in [
3163 (100, 24, false),
3164 (100, 25, true),
3165 (4, 2, true),
3166 (29, 2, false),
3167 (5, 1, false),
3168 (0, 0, false),
3169 ] {
3170 assert_eq!(
3171 stale_share_warrants_warning(baseline_entries, stale_entries),
3172 expected,
3173 "{stale_entries} of {baseline_entries} entries"
3174 );
3175 }
3176 }
3177
3178 const fn staleness(
3179 entries: usize,
3180 matched: usize,
3181 current_findings: usize,
3182 ) -> BaselineStaleness {
3183 BaselineStaleness {
3184 entries,
3185 matched,
3186 current_findings,
3187 change_scoped: false,
3188 }
3189 }
3190
3191 #[test]
3192 fn warning_is_silent_when_the_run_found_nothing() {
3193 let staleness = staleness(4, 0, 0);
3194 assert_eq!(staleness.stale_entries(), 4);
3195 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3196 }
3197
3198 #[test]
3199 fn zero_overlap_warns_when_the_run_has_findings() {
3200 assert_eq!(
3201 staleness(4, 0, 4).warning(),
3202 BaselineStalenessWarning::ZeroOverlap
3203 );
3204 }
3205
3206 #[test]
3207 fn partial_warning_needs_the_documented_quarter() {
3208 assert_eq!(
3209 staleness(100, 76, 100).warning(),
3210 BaselineStalenessWarning::None
3211 );
3212 assert_eq!(
3213 staleness(100, 75, 100).warning(),
3214 BaselineStalenessWarning::Partial
3215 );
3216 }
3217
3218 #[test]
3219 fn empty_baseline_never_warns() {
3220 assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
3221 }
3222
3223 #[test]
3224 fn change_scoped_run_never_warns_and_never_trips_the_gate() {
3225 let scoped = BaselineStaleness {
3226 change_scoped: true,
3227 ..staleness(8, 2, 8)
3228 };
3229 assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
3230 assert!(!scoped.trips_gate());
3231 }
3232
3233 #[test]
3237 fn gate_trips_on_one_stale_entry_the_warning_ignores() {
3238 let staleness = staleness(20, 19, 19);
3239 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3240 assert!(staleness.trips_gate());
3241 }
3242
3243 #[test]
3244 fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
3245 let staleness = staleness(4, 0, 0);
3246 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3247 assert!(staleness.trips_gate());
3248 }
3249
3250 #[test]
3251 fn gate_is_inert_on_an_empty_baseline() {
3252 assert!(!staleness(0, 0, 0).trips_gate());
3253 }
3254
3255 #[test]
3256 fn gate_is_inert_when_every_entry_matched() {
3257 assert!(!staleness(4, 4, 4).trips_gate());
3258 }
3259
3260 fn make_results() -> AnalysisResults {
3261 AnalysisResults {
3262 unused_files: vec![
3263 UnusedFileFinding::with_actions(UnusedFile {
3264 path: PathBuf::from("src/old.ts"),
3265 }),
3266 UnusedFileFinding::with_actions(UnusedFile {
3267 path: PathBuf::from("src/dead.ts"),
3268 }),
3269 ],
3270 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3271 path: PathBuf::from("src/utils.ts"),
3272 export_name: "helperA".to_string(),
3273 is_type_only: false,
3274 line: 5,
3275 col: 0,
3276 span_start: 40,
3277 is_re_export: false,
3278 deprecated: false,
3279 deprecated_reason: None,
3280 })],
3281 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
3282 path: PathBuf::from("src/types.ts"),
3283 export_name: "OldType".to_string(),
3284 is_type_only: true,
3285 line: 10,
3286 col: 0,
3287 span_start: 100,
3288 is_re_export: false,
3289 deprecated: false,
3290 deprecated_reason: None,
3291 })],
3292 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3293 package_name: "lodash".to_string(),
3294 location: DependencyLocation::Dependencies,
3295 path: PathBuf::from("package.json"),
3296 line: 5,
3297 used_in_workspaces: Vec::new(),
3298 declared_and_imported_in: Vec::new(),
3299 })],
3300 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
3301 UnusedDependency {
3302 package_name: "jest".to_string(),
3303 location: DependencyLocation::DevDependencies,
3304 path: PathBuf::from("package.json"),
3305 line: 5,
3306 used_in_workspaces: Vec::new(),
3307 declared_and_imported_in: Vec::new(),
3308 },
3309 )],
3310 ..Default::default()
3311 }
3312 }
3313
3314 #[test]
3315 fn baseline_from_results_captures_all_fields() {
3316 let results = make_results();
3317 let baseline = BaselineData::from_results(&results, Path::new(""));
3318 assert_eq!(
3319 baseline.unused_files,
3320 vec!["unused-file:src/dead.ts", "unused-file:src/old.ts"]
3321 );
3322 assert_eq!(
3323 baseline.unused_exports,
3324 vec!["unused-export:src/utils.ts:helperA"]
3325 );
3326 assert_eq!(
3327 baseline.unused_types,
3328 vec!["unused-type:src/types.ts:OldType"]
3329 );
3330 assert_eq!(
3331 baseline.unused_dependencies,
3332 vec!["unused-dependency:package.json:lodash"]
3333 );
3334 assert_eq!(
3335 baseline.unused_dev_dependencies,
3336 vec!["unused-dev-dependency:package.json:jest"]
3337 );
3338 }
3339
3340 #[test]
3341 fn dependency_baseline_keys_include_package_json_path() {
3342 let root = Path::new("/repo");
3343 let results = AnalysisResults {
3344 unused_dependencies: vec![
3345 UnusedDependencyFinding::with_actions(UnusedDependency {
3346 package_name: "lodash-es".to_string(),
3347 location: DependencyLocation::Dependencies,
3348 path: PathBuf::from("/repo/packages/app-a/package.json"),
3349 line: 5,
3350 used_in_workspaces: Vec::new(),
3351 declared_and_imported_in: Vec::new(),
3352 }),
3353 UnusedDependencyFinding::with_actions(UnusedDependency {
3354 package_name: "lodash-es".to_string(),
3355 location: DependencyLocation::Dependencies,
3356 path: PathBuf::from("/repo/packages/app-b/package.json"),
3357 line: 5,
3358 used_in_workspaces: Vec::new(),
3359 declared_and_imported_in: Vec::new(),
3360 }),
3361 ],
3362 ..Default::default()
3363 };
3364
3365 let baseline = BaselineData::from_results(&results, root);
3366
3367 assert_eq!(
3368 baseline.unused_dependencies,
3369 vec![
3370 "unused-dependency:packages/app-a/package.json:lodash-es",
3371 "unused-dependency:packages/app-b/package.json:lodash-es"
3372 ]
3373 );
3374 }
3375
3376 #[test]
3377 fn dependency_baseline_filter_matches_path_before_package_name() {
3378 let root = Path::new("/repo");
3379 let results = AnalysisResults {
3380 unused_dependencies: vec![
3381 UnusedDependencyFinding::with_actions(UnusedDependency {
3382 package_name: "lodash-es".to_string(),
3383 location: DependencyLocation::Dependencies,
3384 path: PathBuf::from("/repo/packages/app-a/package.json"),
3385 line: 5,
3386 used_in_workspaces: Vec::new(),
3387 declared_and_imported_in: Vec::new(),
3388 }),
3389 UnusedDependencyFinding::with_actions(UnusedDependency {
3390 package_name: "lodash-es".to_string(),
3391 location: DependencyLocation::Dependencies,
3392 path: PathBuf::from("/repo/packages/app-b/package.json"),
3393 line: 5,
3394 used_in_workspaces: Vec::new(),
3395 declared_and_imported_in: Vec::new(),
3396 }),
3397 ],
3398 ..Default::default()
3399 };
3400 let baseline = BaselineData {
3401 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
3402 ..BaselineData::legacy_from_results(&AnalysisResults::default(), root)
3403 };
3404
3405 let filtered = filter_new_issues(results, &baseline, root);
3406
3407 assert_eq!(filtered.unused_dependencies.len(), 1);
3408 assert_eq!(
3409 filtered.unused_dependencies[0].dep.path,
3410 PathBuf::from("/repo/packages/app-b/package.json")
3411 );
3412 }
3413
3414 #[test]
3415 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
3416 let root = Path::new("/repo");
3417 let results = AnalysisResults {
3418 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3419 package_name: "lodash-es".to_string(),
3420 location: DependencyLocation::Dependencies,
3421 path: PathBuf::from("/repo/packages/app/package.json"),
3422 line: 5,
3423 used_in_workspaces: Vec::new(),
3424 declared_and_imported_in: Vec::new(),
3425 })],
3426 ..Default::default()
3427 };
3428 let baseline = BaselineData {
3429 unused_dependencies: vec!["lodash-es".to_string()],
3430 ..BaselineData::legacy_from_results(&AnalysisResults::default(), root)
3431 };
3432
3433 let filtered = filter_new_issues(results, &baseline, root);
3434
3435 assert!(filtered.unused_dependencies.is_empty());
3436 }
3437
3438 #[test]
3439 fn baseline_serialization_roundtrip() {
3440 let results = make_results();
3441 let baseline = BaselineData::from_results(&results, Path::new(""));
3442 let json = serde_json::to_string(&baseline).unwrap();
3443 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
3444 assert_eq!(deserialized.unused_files, baseline.unused_files);
3445 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
3446 assert_eq!(deserialized.unused_types, baseline.unused_types);
3447 assert_eq!(
3448 deserialized.unused_dependencies,
3449 baseline.unused_dependencies
3450 );
3451 assert_eq!(
3452 deserialized.unused_dev_dependencies,
3453 baseline.unused_dev_dependencies
3454 );
3455 }
3456
3457 #[test]
3458 fn filter_removes_baseline_issues() {
3459 let results = make_results();
3460 let baseline = BaselineData::from_results(&results, Path::new(""));
3461 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3462 assert!(
3463 filtered.unused_files.is_empty(),
3464 "all files were in baseline"
3465 );
3466 assert!(
3467 filtered.unused_exports.is_empty(),
3468 "all exports were in baseline"
3469 );
3470 assert!(
3471 filtered.unused_types.is_empty(),
3472 "all types were in baseline"
3473 );
3474 assert!(
3475 filtered.unused_dependencies.is_empty(),
3476 "all deps were in baseline"
3477 );
3478 assert!(
3479 filtered.unused_dev_dependencies.is_empty(),
3480 "all dev deps were in baseline"
3481 );
3482 }
3483
3484 #[test]
3485 fn filter_keeps_new_issues_not_in_baseline() {
3486 let baseline = BaselineData {
3487 kind: None,
3488 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3489 scope_reasons: Vec::new(),
3490 identity: None,
3491 unused_files: vec!["src/old.ts".to_string()],
3492 unused_exports: vec![],
3493 unused_types: vec![],
3494 private_type_leaks: vec![],
3495 deprecated_exports_in_use: vec![],
3496 unused_dependencies: vec![],
3497 unused_dev_dependencies: vec![],
3498 circular_dependencies: vec![],
3499 re_export_cycles: vec![],
3500 package_cycles: vec![],
3501 unused_optional_dependencies: vec![],
3502 unused_enum_members: vec![],
3503 unused_class_members: vec![],
3504 unused_store_members: vec![],
3505 unprovided_injects: vec![],
3506 unrendered_components: vec![],
3507 unused_component_props: vec![],
3508 absent_component_props: vec![],
3509 unused_component_emits: vec![],
3510 unused_component_inputs: vec![],
3511 unused_component_outputs: vec![],
3512 unused_svelte_events: vec![],
3513 unused_server_actions: vec![],
3514 unused_load_data_keys: vec![],
3515 unresolved_imports: vec![],
3516 unlisted_dependencies: vec![],
3517 duplicate_exports: vec![],
3518 type_only_dependencies: vec![],
3519 test_only_dependencies: vec![],
3520 dev_dependencies_in_production: vec![],
3521 boundary_violations: vec![],
3522 boundary_coverage_violations: vec![],
3523 boundary_call_violations: vec![],
3524 policy_violations: vec![],
3525 stale_suppressions: vec![],
3526 unused_catalog_entries: vec![],
3527 empty_catalog_groups: vec![],
3528 unresolved_catalog_references: vec![],
3529 unused_dependency_overrides: vec![],
3530 misconfigured_dependency_overrides: vec![],
3531 invalid_client_exports: vec![],
3532 mixed_client_server_barrels: vec![],
3533 misplaced_directives: vec![],
3534 route_collisions: vec![],
3535 dynamic_segment_name_conflicts: vec![],
3536 };
3537 let results = AnalysisResults {
3538 unused_files: vec![
3539 UnusedFileFinding::with_actions(UnusedFile {
3540 path: PathBuf::from("src/old.ts"),
3541 }),
3542 UnusedFileFinding::with_actions(UnusedFile {
3543 path: PathBuf::from("src/new-dead.ts"),
3544 }),
3545 ],
3546 ..Default::default()
3547 };
3548 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3549 assert_eq!(filtered.unused_files.len(), 1);
3550 assert_eq!(
3551 filtered.unused_files[0].file.path,
3552 PathBuf::from("src/new-dead.ts")
3553 );
3554 }
3555
3556 #[test]
3557 fn filter_with_empty_baseline_keeps_all() {
3558 let baseline = BaselineData {
3559 kind: None,
3560 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3561 scope_reasons: Vec::new(),
3562 identity: None,
3563 unused_files: vec![],
3564 unused_exports: vec![],
3565 unused_types: vec![],
3566 private_type_leaks: vec![],
3567 deprecated_exports_in_use: vec![],
3568 unused_dependencies: vec![],
3569 unused_dev_dependencies: vec![],
3570 circular_dependencies: vec![],
3571 re_export_cycles: vec![],
3572 package_cycles: vec![],
3573 unused_optional_dependencies: vec![],
3574 unused_enum_members: vec![],
3575 unused_class_members: vec![],
3576 unused_store_members: vec![],
3577 unprovided_injects: vec![],
3578 unrendered_components: vec![],
3579 unused_component_props: vec![],
3580 absent_component_props: vec![],
3581 unused_component_emits: vec![],
3582 unused_component_inputs: vec![],
3583 unused_component_outputs: vec![],
3584 unused_svelte_events: vec![],
3585 unused_server_actions: vec![],
3586 unused_load_data_keys: vec![],
3587 unresolved_imports: vec![],
3588 unlisted_dependencies: vec![],
3589 duplicate_exports: vec![],
3590 type_only_dependencies: vec![],
3591 test_only_dependencies: vec![],
3592 dev_dependencies_in_production: vec![],
3593 boundary_violations: vec![],
3594 boundary_coverage_violations: vec![],
3595 boundary_call_violations: vec![],
3596 policy_violations: vec![],
3597 stale_suppressions: vec![],
3598 unused_catalog_entries: vec![],
3599 empty_catalog_groups: vec![],
3600 unresolved_catalog_references: vec![],
3601 unused_dependency_overrides: vec![],
3602 misconfigured_dependency_overrides: vec![],
3603 invalid_client_exports: vec![],
3604 mixed_client_server_barrels: vec![],
3605 misplaced_directives: vec![],
3606 route_collisions: vec![],
3607 dynamic_segment_name_conflicts: vec![],
3608 };
3609 let results = make_results();
3610 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3611 assert_eq!(filtered.unused_files.len(), 2);
3612 assert_eq!(filtered.unused_exports.len(), 1);
3613 }
3614
3615 #[test]
3616 fn filter_new_exports_by_file_and_name() {
3617 let baseline = BaselineData {
3618 kind: None,
3619 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3620 scope_reasons: Vec::new(),
3621 identity: None,
3622 unused_files: vec![],
3623 unused_exports: vec!["src/utils.ts:helperA".to_string()],
3624 unused_types: vec![],
3625 private_type_leaks: vec![],
3626 deprecated_exports_in_use: vec![],
3627 unused_dependencies: vec![],
3628 unused_dev_dependencies: vec![],
3629 circular_dependencies: vec![],
3630 re_export_cycles: vec![],
3631 package_cycles: vec![],
3632 unused_optional_dependencies: vec![],
3633 unused_enum_members: vec![],
3634 unused_class_members: vec![],
3635 unused_store_members: vec![],
3636 unprovided_injects: vec![],
3637 unrendered_components: vec![],
3638 unused_component_props: vec![],
3639 absent_component_props: vec![],
3640 unused_component_emits: vec![],
3641 unused_component_inputs: vec![],
3642 unused_component_outputs: vec![],
3643 unused_svelte_events: vec![],
3644 unused_server_actions: vec![],
3645 unused_load_data_keys: vec![],
3646 unresolved_imports: vec![],
3647 unlisted_dependencies: vec![],
3648 duplicate_exports: vec![],
3649 type_only_dependencies: vec![],
3650 test_only_dependencies: vec![],
3651 dev_dependencies_in_production: vec![],
3652 boundary_violations: vec![],
3653 boundary_coverage_violations: vec![],
3654 boundary_call_violations: vec![],
3655 policy_violations: vec![],
3656 stale_suppressions: vec![],
3657 unused_catalog_entries: vec![],
3658 empty_catalog_groups: vec![],
3659 unresolved_catalog_references: vec![],
3660 unused_dependency_overrides: vec![],
3661 misconfigured_dependency_overrides: vec![],
3662 invalid_client_exports: vec![],
3663 mixed_client_server_barrels: vec![],
3664 misplaced_directives: vec![],
3665 route_collisions: vec![],
3666 dynamic_segment_name_conflicts: vec![],
3667 };
3668 let results = AnalysisResults {
3669 unused_exports: vec![
3670 UnusedExportFinding::with_actions(UnusedExport {
3671 path: PathBuf::from("src/utils.ts"),
3672 export_name: "helperA".to_string(),
3673 is_type_only: false,
3674 line: 5,
3675 col: 0,
3676 span_start: 40,
3677 is_re_export: false,
3678 deprecated: false,
3679 deprecated_reason: None,
3680 }),
3681 UnusedExportFinding::with_actions(UnusedExport {
3682 path: PathBuf::from("src/utils.ts"),
3683 export_name: "helperB".to_string(),
3684 is_type_only: false,
3685 line: 10,
3686 col: 0,
3687 span_start: 80,
3688 is_re_export: false,
3689 deprecated: false,
3690 deprecated_reason: None,
3691 }),
3692 ],
3693 ..Default::default()
3694 };
3695 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3696 assert_eq!(filtered.unused_exports.len(), 1);
3697 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
3698 }
3699
3700 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
3701 let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
3702 files.sort_unstable();
3703 let fragment = format!("const source = '{}';", files.join(","));
3704 make_clone_group_with_fragment(&fragment, instances)
3705 }
3706
3707 fn make_clone_group_with_fragment(
3708 fragment: &str,
3709 instances: Vec<(&str, usize, usize)>,
3710 ) -> CloneGroup {
3711 CloneGroup {
3712 instances: instances
3713 .into_iter()
3714 .map(|(file, start, end)| CloneInstance {
3715 is_symlink: false,
3716 file: PathBuf::from(file),
3717 start_line: start,
3718 end_line: end,
3719 start_col: 0,
3720 end_col: 0,
3721 fragment: fragment.to_string(),
3722 })
3723 .collect(),
3724 token_count: 50,
3725 line_count: 10,
3726 similarity: None,
3727 }
3728 }
3729
3730 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3731 DuplicationReport {
3732 clone_groups: groups,
3733 clone_families: vec![],
3734 mirrored_directories: vec![],
3735 stats: DuplicationStats {
3736 total_files: 10,
3737 files_with_clones: 2,
3738 total_lines: 1000,
3739 duplicated_lines: 100,
3740 total_tokens: 5000,
3741 duplicated_tokens: 500,
3742 clone_groups: 1,
3743 clone_families: 0,
3744 clone_instances: 2,
3745 duplication_percentage: 10.0,
3746 clone_groups_below_min_occurrences: 0,
3747 clone_groups_ignored: 0,
3748 near_candidates_skipped: 0,
3749 },
3750 }
3751 }
3752
3753 fn normalized_clone_group_key(group: &CloneGroup) -> String {
3754 let fingerprints =
3755 crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
3756 clone_group_fingerprint_key(group, &fingerprints)
3757 }
3758
3759 #[test]
3760 fn clone_group_key_is_deterministic() {
3761 let root = Path::new("/project");
3762 let group = make_clone_group(vec![
3763 ("/project/src/a.ts", 1, 10),
3764 ("/project/src/b.ts", 5, 15),
3765 ]);
3766 let key1 = clone_group_key(&group, root);
3767 let key2 = clone_group_key(&group, root);
3768 assert_eq!(key1, key2);
3769 }
3770
3771 #[test]
3772 fn clone_group_key_is_sorted() {
3773 let root = Path::new("/project");
3774 let group_ab = make_clone_group(vec![
3775 ("/project/src/a.ts", 1, 10),
3776 ("/project/src/b.ts", 5, 15),
3777 ]);
3778 let group_ba = make_clone_group(vec![
3779 ("/project/src/b.ts", 5, 15),
3780 ("/project/src/a.ts", 1, 10),
3781 ]);
3782 assert_eq!(
3783 clone_group_key(&group_ab, root),
3784 clone_group_key(&group_ba, root),
3785 "key should be stable regardless of instance order"
3786 );
3787 }
3788
3789 #[test]
3790 fn duplication_baseline_roundtrip() {
3791 let root = Path::new("/project");
3792 let group = make_clone_group(vec![
3793 ("/project/src/a.ts", 1, 10),
3794 ("/project/src/b.ts", 5, 15),
3795 ]);
3796 let report = make_duplication_report(vec![group]);
3797 let baseline = DuplicationBaselineData::from_report(&report, root);
3798 let json = serde_json::to_string(&baseline).unwrap();
3799 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
3800 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
3801 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
3802 assert_eq!(
3803 deserialized.normalized_clone_fingerprints,
3804 baseline.normalized_clone_fingerprints
3805 );
3806 assert_eq!(
3807 baseline.normalized_clone_fingerprints.len(),
3808 1,
3809 "a saved baseline carries a normalized key per clone group"
3810 );
3811 }
3812
3813 #[test]
3821 fn the_declared_keys_are_every_key_each_format_writes() {
3822 let duplication = DuplicationBaselineData {
3823 kind: Some(BaselineKind::Dupes),
3824 clone_groups: vec!["src/a.ts:1-10".to_owned()],
3825 clone_fingerprints: vec!["abc".to_owned()],
3826 normalized_clone_fingerprints: vec!["def".to_owned()],
3827 };
3828 assert_eq!(
3829 serialized_keys_without_kind(&duplication),
3830 DuplicationBaselineData::DECLARED_KEYS
3831 );
3832
3833 let counts: HealthFindingCountMap = std::iter::once((
3834 "src/a.ts".to_owned(),
3835 std::iter::once(("complexity".to_owned(), HealthBaselineCount { count: 1 })).collect(),
3836 ))
3837 .collect();
3838 let health = HealthBaselineData {
3839 kind: Some(BaselineKind::Health),
3840 findings: vec!["src/a.ts:run:1".to_owned()],
3841 finding_counts: counts.clone(),
3842 identity_finding_counts: counts,
3843 runtime_coverage_findings: vec!["src/a.ts:run".to_owned()],
3844 runtime_coverage_source_hashes: vec!["src/a.ts\0run\0hash".to_owned()],
3845 target_keys: vec!["src/a.ts:complexity".to_owned()],
3846 };
3847 assert_eq!(
3848 serialized_keys_without_kind(&health),
3849 HealthBaselineData::DECLARED_KEYS
3850 );
3851
3852 for list in [
3853 DuplicationBaselineData::DECLARED_KEYS,
3854 HealthBaselineData::DECLARED_KEYS,
3855 BaselineData::REQUIRED_KEYS,
3856 ] {
3857 assert!(
3858 !list.contains(&"kind"),
3859 "no format may declare the key every format writes: {list:?}"
3860 );
3861 }
3862 }
3863
3864 #[test]
3869 fn the_required_dead_code_keys_are_the_ones_without_a_serde_default() {
3870 let json = serde_json::to_string(&BaselineData::from_results(
3871 &crate::results::AnalysisResults::default(),
3872 Path::new("/project"),
3873 ))
3874 .expect("baseline serializes");
3875 let serde_json::Value::Object(object) =
3876 serde_json::from_str::<serde_json::Value>(&json).expect("object")
3877 else {
3878 panic!("a baseline serializes as an object");
3879 };
3880
3881 for key in object.keys() {
3882 let mut without = object.clone();
3883 without.remove(key);
3884 let loads =
3885 serde_json::from_value::<BaselineData>(serde_json::Value::Object(without)).is_ok();
3886 assert_eq!(
3887 !loads,
3888 BaselineData::REQUIRED_KEYS.contains(&key.as_str()),
3889 "REQUIRED_KEYS must list exactly the keys a load cannot do without, and {key} \
3890 disagrees"
3891 );
3892 }
3893 }
3894
3895 #[test]
3896 fn every_saved_baseline_names_the_command_that_wrote_it() {
3897 let dead_code = serde_json::to_string(&BaselineData::from_results(
3898 &crate::results::AnalysisResults::default(),
3899 Path::new("/project"),
3900 ))
3901 .expect("baseline serializes");
3902 let dupes = serde_json::to_string(&DuplicationBaselineData::from_report(
3903 &make_duplication_report(Vec::new()),
3904 Path::new("/project"),
3905 ))
3906 .expect("baseline serializes");
3907 let health = serde_json::to_string(&HealthBaselineData::from_findings(
3908 &[],
3909 &[],
3910 &[],
3911 Path::new("/project"),
3912 ))
3913 .expect("baseline serializes");
3914
3915 for (json, kind) in [
3916 (&dead_code, BaselineKind::DeadCode),
3917 (&dupes, BaselineKind::Dupes),
3918 (&health, BaselineKind::Health),
3919 ] {
3920 assert_eq!(
3921 serde_json::from_str::<serde_json::Value>(json).expect("object")["kind"],
3922 serde_json::json!(kind.as_str()),
3923 "a saved baseline states which command wrote it: {json}"
3924 );
3925 assert_eq!(classify_baseline_file(json, kind), BaselineFileKind::Own);
3926 for other in [
3927 BaselineKind::DeadCode,
3928 BaselineKind::Dupes,
3929 BaselineKind::Health,
3930 ] {
3931 if other == kind {
3932 continue;
3933 }
3934 assert_eq!(
3935 classify_baseline_file(json, other),
3936 BaselineFileKind::Foreign(kind.as_str().to_owned()),
3937 "and every other command reads it as that command's: {json}"
3938 );
3939 }
3940 }
3941 }
3942
3943 #[test]
3946 fn a_baseline_without_a_kind_is_classified_by_its_keys() {
3947 let dupes =
3948 r#"{"clone_groups":[],"clone_fingerprints":[],"normalized_clone_fingerprints":[]}"#;
3949 let health = r#"{"runtime_coverage_findings":[],"target_keys":[]}"#;
3950 let dead_code = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],"unused_dependencies":[],"unused_dev_dependencies":[]}"#;
3951
3952 for (json, own) in [
3953 (dupes, BaselineKind::Dupes),
3954 (health, BaselineKind::Health),
3955 (dead_code, BaselineKind::DeadCode),
3956 ] {
3957 assert_eq!(classify_baseline_file(json, own), BaselineFileKind::Own);
3958 for other in [
3959 BaselineKind::DeadCode,
3960 BaselineKind::Dupes,
3961 BaselineKind::Health,
3962 ] {
3963 if other == own {
3964 continue;
3965 }
3966 assert_eq!(
3967 classify_baseline_file(json, other),
3968 BaselineFileKind::Unrecognised,
3969 "a file with no kind and none of this format's keys is unrecognised, not \
3970 attributed to a command it never named: {json}"
3971 );
3972 }
3973 }
3974 }
3975
3976 #[test]
3980 fn an_unreadable_kind_never_becomes_a_parse_error() {
3981 assert_eq!(
3982 classify_baseline_file(r#"{"kind":"security"}"#, BaselineKind::Dupes),
3983 BaselineFileKind::Foreign("security".to_owned())
3984 );
3985 assert_eq!(
3986 classify_baseline_file(r#"{"kind":7,"clone_groups":[]}"#, BaselineKind::Dupes),
3987 BaselineFileKind::Own
3988 );
3989 assert_eq!(
3990 classify_baseline_file("[]", BaselineKind::Dupes),
3991 BaselineFileKind::NotAnObject
3992 );
3993 assert_eq!(
3994 classify_baseline_file("not json", BaselineKind::DeadCode),
3995 BaselineFileKind::NotAnObject
3996 );
3997 assert!(
3998 serde_json::from_str::<DuplicationBaselineData>(r#"{"kind":"security"}"#).is_ok(),
3999 "a kind only a newer fallow writes must not break a load"
4000 );
4001 }
4002
4003 #[test]
4004 fn a_save_is_refused_only_over_another_commands_baseline() {
4005 let dir = tempfile::tempdir().expect("tempdir");
4006 let path = dir.path().join("baseline.json");
4007
4008 assert!(
4009 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
4010 "there is nothing to destroy yet"
4011 );
4012
4013 std::fs::write(
4014 &path,
4015 serde_json::to_string(&DuplicationBaselineData::from_report(
4016 &make_duplication_report(Vec::new()),
4017 Path::new("/project"),
4018 ))
4019 .expect("baseline serializes"),
4020 )
4021 .expect("write");
4022 assert!(
4023 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
4024 "re-saving over its own file is the documented workflow"
4025 );
4026 let message = refuse_baseline_kind_overwrite(&path, BaselineKind::Health)
4027 .expect("a health save over a duplication baseline is refused");
4028 assert!(message.contains("`fallow dupes`"), "{message}");
4029 assert!(message.contains("`fallow health`"), "{message}");
4030 assert!(message.contains(&path.display().to_string()), "{message}");
4031
4032 std::fs::write(&path, "{}").expect("write");
4033 assert!(
4034 refuse_baseline_kind_overwrite(&path, BaselineKind::Health).is_none(),
4035 "a file with nothing to identify it carries no claim to protect"
4036 );
4037 }
4038
4039 fn serialized_keys_without_kind<T: serde::Serialize>(value: &T) -> Vec<String> {
4040 let serde_json::Value::Object(object) =
4041 serde_json::to_value(value).expect("baseline serializes")
4042 else {
4043 panic!("a baseline serializes as an object");
4044 };
4045 object
4046 .keys()
4047 .filter(|key| key.as_str() != "kind")
4048 .cloned()
4049 .collect()
4050 }
4051
4052 #[test]
4053 fn a_baseline_saved_from_a_clean_project_still_declares_its_format() {
4054 let empty = serde_json::to_string(&DuplicationBaselineData::default())
4055 .expect("baseline serializes");
4056
4057 assert!(
4058 declares_baseline_format(&empty, DuplicationBaselineData::DECLARED_KEYS),
4059 "an empty duplication baseline is still a duplication baseline: {empty}"
4060 );
4061 assert!(
4062 !declares_baseline_format(&empty, HealthBaselineData::DECLARED_KEYS),
4063 "and it is not a health one: {empty}"
4064 );
4065 assert!(!declares_baseline_format(
4066 "{}",
4067 HealthBaselineData::DECLARED_KEYS
4068 ));
4069 assert!(!declares_baseline_format(
4070 "[]",
4071 HealthBaselineData::DECLARED_KEYS
4072 ));
4073 assert!(!declares_baseline_format(
4074 "not json",
4075 HealthBaselineData::DECLARED_KEYS
4076 ));
4077 }
4078
4079 #[test]
4080 fn filter_new_clone_groups_matches_shifted_clone() {
4081 let root = Path::new("/project");
4082 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4083 "const total = a + b;",
4084 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4085 )]);
4086 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4087
4088 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
4089 "const total = a + b;",
4090 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
4091 )]);
4092 let filtered = filter_new_clone_groups(shifted, &baseline, root);
4093 assert!(
4094 filtered.clone_groups.is_empty(),
4095 "an unrelated line shift must not resurface a baselined clone"
4096 );
4097 }
4098
4099 #[test]
4100 fn filter_new_clone_groups_reports_extra_copy() {
4101 let root = Path::new("/project");
4102 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4103 "const total = a + b;",
4104 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4105 )]);
4106 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4107
4108 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
4109 "const total = a + b;",
4110 vec![
4111 ("/project/src/a.ts", 10, 20),
4112 ("/project/src/b.ts", 30, 40),
4113 ("/project/src/c.ts", 5, 15),
4114 ],
4115 )]);
4116 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
4117 assert_eq!(
4118 filtered.clone_groups.len(),
4119 1,
4120 "a fresh copy in a third file is a new finding"
4121 );
4122 }
4123
4124 #[test]
4125 fn filter_new_clone_groups_reads_legacy_baseline() {
4126 let root = Path::new("/project");
4127 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
4128 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
4129 assert_eq!(baseline.entry_count(), 1);
4130
4131 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
4132 "const total = a + b;",
4133 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4134 )]);
4135 assert!(
4136 filter_new_clone_groups(unchanged, &baseline, root)
4137 .clone_groups
4138 .is_empty(),
4139 "a legacy baseline still matches on locations"
4140 );
4141
4142 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
4143 "const total = a + b;",
4144 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
4145 )]);
4146 assert_eq!(
4147 filter_new_clone_groups(shifted, &baseline, root)
4148 .clone_groups
4149 .len(),
4150 1,
4151 "legacy behavior is unchanged: a shift stops matching"
4152 );
4153 }
4154
4155 #[test]
4156 fn clone_group_fingerprint_key_survives_file_rename() {
4157 let before = make_clone_group_with_fragment(
4158 "const total = a + b;",
4159 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4160 );
4161 let after = make_clone_group_with_fragment(
4162 "const total = a + b;",
4163 vec![
4164 ("/project/src/renamed.ts", 10, 20),
4165 ("/project/src/b.ts", 30, 40),
4166 ],
4167 );
4168 assert_eq!(
4169 normalized_clone_group_key(&before),
4170 normalized_clone_group_key(&after),
4171 "renaming a file must not resurface a baselined clone"
4172 );
4173 }
4174
4175 #[test]
4176 fn clone_group_fingerprint_key_does_not_follow_representative_order() {
4177 let mut before = make_clone_group_with_fragment(
4178 "const total = a + b;",
4179 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4180 );
4181 before.instances[1].fragment = "const total = a + b;".to_string();
4182
4183 let mut after = before.clone();
4184 after.instances[0].file = PathBuf::from("/project/src/z.ts");
4185
4186 assert_eq!(
4187 normalized_clone_group_key(&before),
4188 normalized_clone_group_key(&after),
4189 "normalized group identity is independent of representative order"
4190 );
4191 }
4192
4193 #[test]
4194 fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
4195 let root = Path::new("/project");
4196 let group = make_clone_group_with_fragment(
4197 "const total = a + b;",
4198 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4199 );
4200 let legacy_key = legacy_clone_group_fingerprint_key(&group);
4201 let baseline = DuplicationBaselineData {
4202 kind: None,
4203 clone_groups: Vec::new(),
4204 clone_fingerprints: vec![legacy_key.clone()],
4205 normalized_clone_fingerprints: Vec::new(),
4206 };
4207
4208 let filtered = filter_new_clone_groups(
4209 make_duplication_report(vec![group.clone()]),
4210 &baseline,
4211 root,
4212 );
4213 assert!(filtered.clone_groups.is_empty());
4214
4215 let current =
4216 DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
4217 assert_eq!(current.clone_fingerprints, vec![legacy_key]);
4218 assert_ne!(
4219 current.normalized_clone_fingerprints,
4220 current.clone_fingerprints
4221 );
4222 }
4223
4224 #[test]
4225 fn normalized_baseline_survives_formatting_only_edits() {
4226 let root = Path::new("/project");
4227 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4228 "const total = left + right;",
4229 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4230 )]);
4231 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4232
4233 let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
4234 "/* reviewed */\r\nconst total=left + right;",
4235 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
4236 )]);
4237 assert!(
4238 filter_new_clone_groups(formatted, &baseline, root)
4239 .clone_groups
4240 .is_empty()
4241 );
4242 }
4243
4244 #[test]
4245 fn filter_new_clone_groups_removes_baseline() {
4246 let root = Path::new("/project");
4247 let group = make_clone_group(vec![
4248 ("/project/src/a.ts", 1, 10),
4249 ("/project/src/b.ts", 5, 15),
4250 ]);
4251 let report = make_duplication_report(vec![group]);
4252 let baseline = DuplicationBaselineData::from_report(&report, root);
4253 let filtered = filter_new_clone_groups(report, &baseline, root);
4254 assert!(
4255 filtered.clone_groups.is_empty(),
4256 "baseline group should be filtered out"
4257 );
4258 }
4259
4260 #[test]
4261 fn filter_new_clone_groups_keeps_new_groups() {
4262 let root = Path::new("/project");
4263 let baseline_group = make_clone_group(vec![
4264 ("/project/src/a.ts", 1, 10),
4265 ("/project/src/b.ts", 5, 15),
4266 ]);
4267 let new_group = make_clone_group(vec![
4268 ("/project/src/c.ts", 20, 30),
4269 ("/project/src/d.ts", 25, 35),
4270 ]);
4271 let baseline_report = make_duplication_report(vec![baseline_group]);
4272 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4273
4274 let report = make_duplication_report(vec![
4275 make_clone_group(vec![
4276 ("/project/src/a.ts", 1, 10),
4277 ("/project/src/b.ts", 5, 15),
4278 ]),
4279 new_group,
4280 ]);
4281 let filtered = filter_new_clone_groups(report, &baseline, root);
4282 assert_eq!(
4283 filtered.clone_groups.len(),
4284 1,
4285 "only the new group should remain"
4286 );
4287 }
4288
4289 #[test]
4290 fn recompute_stats_after_filtering() {
4291 let root = Path::new("/project");
4292 let group = make_clone_group(vec![
4293 ("/project/src/a.ts", 1, 10),
4294 ("/project/src/b.ts", 5, 15),
4295 ]);
4296 let report = make_duplication_report(vec![group]);
4297 let baseline = DuplicationBaselineData::from_report(&report, root);
4298 let filtered = filter_new_clone_groups(report, &baseline, root);
4299 assert_eq!(filtered.stats.clone_groups, 0);
4300 assert_eq!(filtered.stats.clone_instances, 0);
4301 assert_eq!(filtered.stats.duplicated_lines, 0);
4302 }
4303
4304 #[test]
4305 fn recompute_stats_zero_total_lines() {
4306 let report = DuplicationReport {
4307 clone_groups: vec![],
4308 clone_families: vec![],
4309 mirrored_directories: vec![],
4310 stats: DuplicationStats {
4311 total_files: 0,
4312 files_with_clones: 0,
4313 total_lines: 0,
4314 duplicated_lines: 0,
4315 total_tokens: 0,
4316 duplicated_tokens: 0,
4317 clone_groups: 0,
4318 clone_families: 0,
4319 clone_instances: 0,
4320 duplication_percentage: 0.0,
4321 clone_groups_below_min_occurrences: 0,
4322 clone_groups_ignored: 0,
4323 near_candidates_skipped: 0,
4324 },
4325 };
4326 let stats = super::recompute_stats(&report);
4327 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
4328 }
4329
4330 fn filter_new_health_findings(
4334 findings: Vec<fallow_output::ComplexityViolation>,
4335 baseline: &HealthBaselineData,
4336 root: &Path,
4337 ) -> Vec<fallow_output::ComplexityViolation> {
4338 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
4339 }
4340
4341 fn make_health_finding(
4342 root: &Path,
4343 name: &str,
4344 line: u32,
4345 ) -> fallow_output::ComplexityViolation {
4346 make_health_finding_with(
4347 root,
4348 name,
4349 line,
4350 fallow_output::ExceededThreshold::Both,
4351 fallow_output::FindingSeverity::High,
4352 )
4353 }
4354
4355 fn make_health_finding_with(
4356 root: &Path,
4357 name: &str,
4358 line: u32,
4359 exceeded: fallow_output::ExceededThreshold,
4360 severity: fallow_output::FindingSeverity,
4361 ) -> fallow_output::ComplexityViolation {
4362 fallow_output::ComplexityViolation {
4363 path: root.join("src/utils.ts"),
4364 name: name.to_string(),
4365 line,
4366 col: 0,
4367 cyclomatic: 25,
4368 cognitive: 30,
4369 line_count: 80,
4370 param_count: 0,
4371 react_hook_count: 0,
4372 react_jsx_max_depth: 0,
4373 react_prop_count: 0,
4374 react_hook_profile: None,
4375 exceeded,
4376 severity,
4377 effective_severity: None,
4378 crap: None,
4379 coverage_pct: None,
4380 coverage_tier: None,
4381 coverage_source: None,
4382 inherited_from: None,
4383 component_rollup: None,
4384 contributions: Vec::new(),
4385 effective_thresholds: None,
4386 threshold_source: None,
4387 }
4388 }
4389
4390 #[test]
4391 fn health_baseline_roundtrip() {
4392 let root = PathBuf::from("/project");
4393 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4394 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4395 let json = serde_json::to_string(&baseline).unwrap();
4396 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
4397 assert_eq!(deserialized.findings, baseline.findings);
4398 assert_eq!(baseline.findings, Vec::<String>::new());
4399 assert_eq!(
4400 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
4401 1
4402 );
4403 assert!(!json.contains("parseExpression"));
4404 }
4405
4406 #[test]
4407 fn health_baseline_filters_known_findings() {
4408 let root = PathBuf::from("/project");
4409 let mut findings = vec![
4410 make_health_finding(&root, "parseExpression", 42),
4411 make_health_finding(&root, "newFunction", 100),
4412 ];
4413 findings[1].path = root.join("src/other.ts");
4414 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
4415 let filtered = filter_new_health_findings(findings, &baseline, &root);
4416 assert_eq!(filtered.len(), 1);
4417 assert_eq!(filtered[0].name, "newFunction");
4418 }
4419
4420 #[test]
4421 fn health_baseline_filters_shifted_lines_with_same_category_count() {
4422 let root = PathBuf::from("/project");
4423 let baseline = HealthBaselineData::from_findings(
4424 &[make_health_finding(&root, "parseExpression", 42)],
4425 &[],
4426 &[],
4427 &root,
4428 );
4429 let filtered = filter_new_health_findings(
4430 vec![make_health_finding(&root, "parseExpression", 43)],
4431 &baseline,
4432 &root,
4433 );
4434 assert!(filtered.is_empty());
4435 }
4436
4437 #[test]
4438 fn health_baseline_reports_full_category_when_count_increases() {
4439 let root = PathBuf::from("/project");
4440 let baseline = HealthBaselineData::from_findings(
4441 &[make_health_finding(&root, "parseExpression", 42)],
4442 &[],
4443 &[],
4444 &root,
4445 );
4446 let filtered = filter_new_health_findings(
4447 vec![
4448 make_health_finding(&root, "parseExpression", 43),
4449 make_health_finding(&root, "newFunction", 100),
4450 ],
4451 &baseline,
4452 &root,
4453 );
4454 assert_eq!(filtered.len(), 2);
4455 }
4456
4457 #[test]
4458 fn health_baseline_legacy_findings_still_load() {
4459 let root = PathBuf::from("/project");
4460 let baseline = HealthBaselineData {
4461 kind: None,
4462 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
4463 finding_counts: BTreeMap::new(),
4464 identity_finding_counts: BTreeMap::new(),
4465 target_keys: vec![],
4466 runtime_coverage_findings: vec![],
4467 runtime_coverage_source_hashes: vec![],
4468 };
4469 let filtered = filter_new_health_findings(
4470 vec![make_health_finding(&root, "parseExpression", 42)],
4471 &baseline,
4472 &root,
4473 );
4474 assert!(filtered.is_empty());
4475 }
4476
4477 #[test]
4478 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
4479 let root = PathBuf::from("/project");
4480 let baseline = HealthBaselineData::from_findings(
4481 &[make_health_finding_with(
4482 &root,
4483 "parseExpression",
4484 42,
4485 fallow_output::ExceededThreshold::Crap,
4486 fallow_output::FindingSeverity::High,
4487 )],
4488 &[],
4489 &[],
4490 &root,
4491 );
4492 let filtered = filter_new_health_findings(
4493 vec![
4494 make_health_finding_with(
4495 &root,
4496 "parseExpression",
4497 43,
4498 fallow_output::ExceededThreshold::Crap,
4499 fallow_output::FindingSeverity::High,
4500 ),
4501 make_health_finding(&root, "newComplexityOnlyFunction", 100),
4502 ],
4503 &baseline,
4504 &root,
4505 );
4506 assert_eq!(filtered.len(), 1);
4507 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
4508 }
4509
4510 #[test]
4511 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
4512 let root = PathBuf::from("/project");
4513 let baseline = HealthBaselineData::from_findings(
4514 &[make_health_finding_with(
4515 &root,
4516 "parseExpression",
4517 42,
4518 fallow_output::ExceededThreshold::Both,
4519 fallow_output::FindingSeverity::Critical,
4520 )],
4521 &[],
4522 &[],
4523 &root,
4524 );
4525 let filtered = filter_new_health_findings(
4526 vec![make_health_finding_with(
4527 &root,
4528 "parseExpression",
4529 42,
4530 fallow_output::ExceededThreshold::Both,
4531 fallow_output::FindingSeverity::High,
4532 )],
4533 &baseline,
4534 &root,
4535 );
4536 assert!(filtered.is_empty());
4537 }
4538
4539 #[test]
4540 fn health_baseline_still_reports_worse_current_severity_as_new() {
4541 let root = PathBuf::from("/project");
4542 let baseline = HealthBaselineData::from_findings(
4543 &[make_health_finding_with(
4544 &root,
4545 "parseExpression",
4546 42,
4547 fallow_output::ExceededThreshold::Both,
4548 fallow_output::FindingSeverity::High,
4549 )],
4550 &[],
4551 &[],
4552 &root,
4553 );
4554 let filtered = filter_new_health_findings(
4555 vec![make_health_finding_with(
4556 &root,
4557 "parseExpression",
4558 42,
4559 fallow_output::ExceededThreshold::Both,
4560 fallow_output::FindingSeverity::Critical,
4561 )],
4562 &baseline,
4563 &root,
4564 );
4565 assert_eq!(filtered.len(), 1);
4566 assert_eq!(filtered[0].name, "parseExpression");
4567 assert!(matches!(
4568 filtered[0].severity,
4569 fallow_output::FindingSeverity::Critical
4570 ));
4571 }
4572
4573 #[test]
4574 fn health_baseline_overlap_counts_partial_category_overflow() {
4575 let root = PathBuf::from("/project");
4576 let baseline = HealthBaselineData::from_findings(
4577 &[make_health_finding(&root, "parseExpression", 42)],
4578 &[],
4579 &[],
4580 &root,
4581 );
4582 let overlap = baseline.overlap_entries(
4583 &[
4584 make_health_finding(&root, "parseExpression", 42),
4585 make_health_finding(&root, "newFunction", 100),
4586 ],
4587 &root,
4588 HealthBaselineMode::Count,
4589 );
4590 assert_eq!(overlap.matched_entries, 1);
4591 assert_eq!(overlap.moved_entries, 0);
4592 }
4593
4594 fn identity_baseline(
4596 findings: &[fallow_output::ComplexityViolation],
4597 root: &Path,
4598 ) -> HealthBaselineData {
4599 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
4600 }
4601
4602 #[test]
4603 fn health_identity_baseline_reports_replacement_hotspot() {
4604 let root = PathBuf::from("/project");
4605 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
4606 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
4607
4608 assert!(
4609 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
4610 "count mode keeps the per-file allowance and suppresses the replacement"
4611 );
4612
4613 let filtered = super::filter_new_health_findings(
4614 replacement,
4615 &baseline,
4616 &root,
4617 HealthBaselineMode::Identity,
4618 );
4619 assert_eq!(filtered.len(), 1);
4620 assert_eq!(filtered[0].name, "replacementHotspot");
4621 }
4622
4623 #[test]
4624 fn health_identity_baseline_survives_line_moves() {
4625 let root = PathBuf::from("/project");
4626 let baseline =
4627 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4628 let filtered = super::filter_new_health_findings(
4629 vec![make_health_finding(&root, "parseExpression", 512)],
4630 &baseline,
4631 &root,
4632 HealthBaselineMode::Identity,
4633 );
4634 assert!(filtered.is_empty());
4635 }
4636
4637 #[test]
4638 fn health_identity_baseline_suppresses_severity_improvement() {
4639 let root = PathBuf::from("/project");
4640 let baseline = identity_baseline(
4641 &[make_health_finding_with(
4642 &root,
4643 "parseExpression",
4644 42,
4645 fallow_output::ExceededThreshold::Both,
4646 fallow_output::FindingSeverity::Critical,
4647 )],
4648 &root,
4649 );
4650 let filtered = super::filter_new_health_findings(
4651 vec![make_health_finding_with(
4652 &root,
4653 "parseExpression",
4654 42,
4655 fallow_output::ExceededThreshold::Both,
4656 fallow_output::FindingSeverity::Moderate,
4657 )],
4658 &baseline,
4659 &root,
4660 HealthBaselineMode::Identity,
4661 );
4662 assert!(filtered.is_empty());
4663 }
4664
4665 #[test]
4666 fn health_identity_baseline_reports_added_finding_for_known_function() {
4667 let root = PathBuf::from("/project");
4668 let baseline =
4669 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4670 let filtered = super::filter_new_health_findings(
4671 vec![
4672 make_health_finding(&root, "parseExpression", 42),
4673 make_health_finding(&root, "parseStatement", 90),
4674 ],
4675 &baseline,
4676 &root,
4677 HealthBaselineMode::Identity,
4678 );
4679 assert_eq!(filtered.len(), 1);
4680 assert_eq!(filtered[0].name, "parseStatement");
4681 }
4682
4683 #[test]
4684 fn health_identity_buckets_are_written_only_in_identity_mode() {
4685 let root = PathBuf::from("/project");
4686 let findings = [make_health_finding(&root, "parseExpression", 42)];
4687 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4688 let json = serde_json::to_string(&count_only).unwrap();
4689 assert!(!json.contains("identity_finding_counts"));
4690 assert!(count_only.lacks_identity_data());
4691
4692 let identity = identity_baseline(&findings, &root);
4693 assert!(!identity.lacks_identity_data());
4694 assert_eq!(
4695 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
4696 .count,
4697 1
4698 );
4699 assert!(
4700 !identity.finding_counts.is_empty(),
4701 "an identity baseline stays readable in count mode"
4702 );
4703 }
4704
4705 fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
4706 let mut finding = make_health_finding(root, name, 42);
4707 finding.path = root.join(path);
4708 finding
4709 }
4710
4711 #[test]
4712 fn health_identity_baseline_follows_file_move() {
4713 let root = PathBuf::from("/project");
4714 let baseline =
4715 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4716 let moved = vec![moved_finding(
4717 &root,
4718 "src/parser/utils.ts",
4719 "parseExpression",
4720 )];
4721
4722 let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
4723 assert_eq!(
4724 overlap.matched_entries, 1,
4725 "a followed move counts as matched, not stale"
4726 );
4727 assert_eq!(
4728 overlap.moved_entries, 1,
4729 "the followed move stays observable as a moved entry"
4730 );
4731 let filtered = super::filter_new_health_findings(
4732 moved,
4733 &baseline,
4734 &root,
4735 HealthBaselineMode::Identity,
4736 );
4737 assert!(filtered.is_empty());
4738 }
4739
4740 #[test]
4741 fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
4742 let root = PathBuf::from("/project");
4743 let baseline =
4744 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4745 let filtered = super::filter_new_health_findings(
4746 vec![
4747 moved_finding(&root, "src/a.ts", "parseExpression"),
4748 moved_finding(&root, "src/b.ts", "parseExpression"),
4749 ],
4750 &baseline,
4751 &root,
4752 HealthBaselineMode::Identity,
4753 );
4754 assert_eq!(filtered.len(), 2);
4755 }
4756
4757 #[test]
4758 fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
4759 let root = PathBuf::from("/project");
4760 let baseline = identity_baseline(
4761 &[
4762 moved_finding(&root, "src/a.ts", "parseExpression"),
4763 moved_finding(&root, "src/b.ts", "parseExpression"),
4764 ],
4765 &root,
4766 );
4767 let filtered = super::filter_new_health_findings(
4768 vec![moved_finding(&root, "src/c.ts", "parseExpression")],
4769 &baseline,
4770 &root,
4771 HealthBaselineMode::Identity,
4772 );
4773 assert_eq!(filtered.len(), 1);
4774 }
4775
4776 #[test]
4777 fn health_identity_move_is_not_followed_when_old_path_still_exists() {
4778 let root = std::path::PathBuf::from(
4781 std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
4782 );
4783 let baseline = identity_baseline(
4784 &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
4785 &root,
4786 );
4787 let filtered = super::filter_new_health_findings(
4788 vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
4789 &baseline,
4790 &root,
4791 HealthBaselineMode::Identity,
4792 );
4793 assert_eq!(filtered.len(), 1);
4794 }
4795
4796 #[test]
4797 fn health_identity_move_is_not_followed_for_anonymous_functions() {
4798 let root = PathBuf::from("/project");
4799 let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
4800 let filtered = super::filter_new_health_findings(
4801 vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
4802 &baseline,
4803 &root,
4804 HealthBaselineMode::Identity,
4805 );
4806 assert_eq!(filtered.len(), 1);
4807 }
4808
4809 #[test]
4810 fn health_count_baseline_does_not_follow_file_moves() {
4811 let root = PathBuf::from("/project");
4812 let baseline = HealthBaselineData::from_findings(
4813 &[make_health_finding(&root, "parseExpression", 42)],
4814 &[],
4815 &[],
4816 &root,
4817 );
4818 let filtered = filter_new_health_findings(
4819 vec![moved_finding(
4820 &root,
4821 "src/parser/utils.ts",
4822 "parseExpression",
4823 )],
4824 &baseline,
4825 &root,
4826 );
4827 assert_eq!(filtered.len(), 1);
4828 }
4829
4830 #[test]
4831 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
4832 let legacy = HealthBaselineData {
4833 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
4834 ..HealthBaselineData::default()
4835 };
4836 assert!(legacy.lacks_identity_data());
4837 assert!(!HealthBaselineData::default().lacks_identity_data());
4838 }
4839
4840 #[test]
4841 fn health_baseline_empty_keeps_all() {
4842 let root = PathBuf::from("/project");
4843 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4844 let baseline = HealthBaselineData {
4845 kind: None,
4846 findings: vec![],
4847 finding_counts: BTreeMap::new(),
4848 identity_finding_counts: BTreeMap::new(),
4849 target_keys: vec![],
4850 runtime_coverage_findings: vec![],
4851 runtime_coverage_source_hashes: vec![],
4852 };
4853 let filtered = filter_new_health_findings(findings, &baseline, &root);
4854 assert_eq!(filtered.len(), 1);
4855 }
4856
4857 #[test]
4858 fn circular_dep_key_is_order_independent() {
4859 use crate::results::CircularDependency;
4860
4861 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
4862 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4863 length: 2,
4864 line: 1,
4865 col: 0,
4866 edges: Vec::new(),
4867 is_cross_package: false,
4868 });
4869 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
4870 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
4871 length: 2,
4872 line: 1,
4873 col: 0,
4874 edges: Vec::new(),
4875 is_cross_package: false,
4876 });
4877 assert_eq!(
4878 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
4879 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
4880 "same files in different order should produce identical keys"
4881 );
4882 }
4883
4884 #[test]
4885 fn circular_dep_key_different_files_different_keys() {
4886 use crate::results::CircularDependency;
4887
4888 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
4889 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4890 length: 2,
4891 line: 1,
4892 col: 0,
4893 edges: Vec::new(),
4894 is_cross_package: false,
4895 });
4896 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
4897 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
4898 length: 2,
4899 line: 1,
4900 col: 0,
4901 edges: Vec::new(),
4902 is_cross_package: false,
4903 });
4904 assert_ne!(
4905 super::circular_dep_key(&dep1.cycle, Path::new("")),
4906 super::circular_dep_key(&dep2.cycle, Path::new("")),
4907 );
4908 }
4909
4910 #[test]
4911 fn circular_dep_key_three_files_order_independent() {
4912 use crate::results::CircularDependency;
4913
4914 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
4915 files: vec![
4916 PathBuf::from("src/a.ts"),
4917 PathBuf::from("src/b.ts"),
4918 PathBuf::from("src/c.ts"),
4919 ],
4920 length: 3,
4921 line: 1,
4922 col: 0,
4923 edges: Vec::new(),
4924 is_cross_package: false,
4925 });
4926 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
4927 files: vec![
4928 PathBuf::from("src/c.ts"),
4929 PathBuf::from("src/a.ts"),
4930 PathBuf::from("src/b.ts"),
4931 ],
4932 length: 3,
4933 line: 1,
4934 col: 0,
4935 edges: Vec::new(),
4936 is_cross_package: false,
4937 });
4938 assert_eq!(
4939 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
4940 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
4941 );
4942 }
4943
4944 #[expect(
4945 clippy::too_many_lines,
4946 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4947 )]
4948 fn make_full_results() -> AnalysisResults {
4949 use crate::results::*;
4950 use crate::source::MemberKind;
4951
4952 let mut r = make_results();
4953 r.circular_dependencies
4954 .push(CircularDependencyFinding::with_actions(
4955 CircularDependency {
4956 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4957 length: 2,
4958 line: 1,
4959 col: 0,
4960 edges: Vec::new(),
4961 is_cross_package: false,
4962 },
4963 ));
4964 r.unused_optional_dependencies
4965 .push(UnusedOptionalDependencyFinding::with_actions(
4966 UnusedDependency {
4967 package_name: "fsevents".to_string(),
4968 location: DependencyLocation::OptionalDependencies,
4969 path: PathBuf::from("package.json"),
4970 line: 15,
4971 used_in_workspaces: Vec::new(),
4972 declared_and_imported_in: Vec::new(),
4973 },
4974 ));
4975 r.unused_enum_members
4976 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
4977 path: PathBuf::from("src/enums.ts"),
4978 parent_name: "Status".to_string(),
4979 member_name: "Deprecated".to_string(),
4980 kind: MemberKind::EnumMember,
4981 line: 8,
4982 col: 0,
4983 }));
4984 r.unused_class_members
4985 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4986 path: PathBuf::from("src/service.ts"),
4987 parent_name: "UserService".to_string(),
4988 member_name: "legacy".to_string(),
4989 kind: MemberKind::ClassMethod,
4990 line: 42,
4991 col: 0,
4992 }));
4993 r.unused_store_members
4994 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4995 path: PathBuf::from("src/store.ts"),
4996 parent_name: "useStore".to_string(),
4997 member_name: "legacyAction".to_string(),
4998 kind: MemberKind::StoreMember,
4999 line: 17,
5000 col: 0,
5001 }));
5002 r.unresolved_imports.push(
5003 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
5004 crate::results::UnresolvedImport {
5005 path: PathBuf::from("src/app.ts"),
5006 specifier: "./missing".to_string(),
5007 line: 3,
5008 col: 0,
5009 specifier_col: 0,
5010 },
5011 ),
5012 );
5013 r.unlisted_dependencies
5014 .push(crate::results::UnlistedDependencyFinding::with_actions(
5015 UnlistedDependency {
5016 package_name: "chalk".to_string(),
5017 imported_from: vec![],
5018 },
5019 ));
5020 r.duplicate_exports
5021 .push(crate::results::DuplicateExportFinding::with_actions(
5022 crate::results::DuplicateExport {
5023 export_name: "Config".to_string(),
5024 locations: vec![
5025 crate::results::DuplicateLocation {
5026 path: PathBuf::from("src/a.ts"),
5027 line: 1,
5028 col: 0,
5029 },
5030 crate::results::DuplicateLocation {
5031 path: PathBuf::from("src/b.ts"),
5032 line: 5,
5033 col: 0,
5034 },
5035 ],
5036 },
5037 ));
5038 r.type_only_dependencies
5039 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
5040 TypeOnlyDependency {
5041 package_name: "zod".to_string(),
5042 path: PathBuf::from("package.json"),
5043 line: 8,
5044 },
5045 ));
5046 r.test_only_dependencies
5047 .push(crate::results::TestOnlyDependencyFinding::with_actions(
5048 TestOnlyDependency {
5049 package_name: "vitest".to_string(),
5050 path: PathBuf::from("package.json"),
5051 line: 10,
5052 },
5053 ));
5054 r.boundary_violations.push(
5055 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
5056 crate::results::BoundaryViolation {
5057 from_path: PathBuf::from("src/ui/btn.ts"),
5058 to_path: PathBuf::from("src/db/query.ts"),
5059 from_zone: "ui".to_string(),
5060 to_zone: "db".to_string(),
5061 import_specifier: "../db/query".to_string(),
5062 line: 1,
5063 col: 0,
5064 via_path: None,
5065 },
5066 ),
5067 );
5068 r
5069 }
5070
5071 #[test]
5072 fn baseline_from_results_captures_all_extended_fields() {
5073 let results = make_full_results();
5074 let baseline = BaselineData::from_results(&results, Path::new(""));
5075 assert_eq!(baseline.circular_dependencies.len(), 1);
5076 assert_eq!(
5077 baseline.unused_optional_dependencies,
5078 vec!["unused-optional-dependency:package.json:fsevents"]
5079 );
5080 assert_eq!(baseline.unused_enum_members.len(), 1);
5081 assert!(baseline.unused_enum_members[0].ends_with(":Status:Deprecated"));
5082 assert_eq!(baseline.unused_class_members.len(), 1);
5083 assert!(baseline.unused_class_members[0].ends_with(":UserService:legacy"));
5084 assert_eq!(baseline.unused_store_members.len(), 1);
5085 assert!(baseline.unused_store_members[0].ends_with(":useStore:legacyAction"));
5086 assert_eq!(baseline.unresolved_imports.len(), 1);
5087 assert!(baseline.unresolved_imports[0].ends_with(":./missing"));
5088 assert_eq!(
5089 baseline.unlisted_dependencies,
5090 vec!["unlisted-dependency:chalk"]
5091 );
5092 assert_eq!(baseline.duplicate_exports.len(), 1);
5093 assert!(baseline.duplicate_exports[0].starts_with("duplicate-export:Config:"));
5094 assert_eq!(
5095 baseline.type_only_dependencies,
5096 vec!["type-only-dependency:package.json:zod"]
5097 );
5098 assert_eq!(
5099 baseline.test_only_dependencies,
5100 vec!["test-only-dependency:package.json:vitest"]
5101 );
5102 assert_eq!(baseline.boundary_violations.len(), 1);
5103 assert!(baseline.boundary_violations[0].starts_with("boundary-violation:"));
5104 }
5105
5106 #[test]
5107 fn filter_removes_all_extended_baseline_issues() {
5108 let results = make_full_results();
5109 let baseline = BaselineData::from_results(&results, Path::new(""));
5110 let filtered = filter_new_issues(results, &baseline, Path::new(""));
5111 assert!(filtered.circular_dependencies.is_empty());
5112 assert!(filtered.unused_optional_dependencies.is_empty());
5113 assert!(filtered.unused_enum_members.is_empty());
5114 assert!(filtered.unused_class_members.is_empty());
5115 assert!(filtered.unused_store_members.is_empty());
5116 assert!(filtered.unresolved_imports.is_empty());
5117 assert!(filtered.unlisted_dependencies.is_empty());
5118 assert!(filtered.duplicate_exports.is_empty());
5119 assert!(filtered.type_only_dependencies.is_empty());
5120 assert!(filtered.test_only_dependencies.is_empty());
5121 assert!(filtered.boundary_violations.is_empty());
5122 }
5123
5124 #[test]
5125 fn filter_keeps_new_circular_deps() {
5126 use crate::results::CircularDependency;
5127 let baseline = BaselineData {
5128 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
5129 ..BaselineData::legacy_from_results(&AnalysisResults::default(), Path::new(""))
5130 };
5131 let mut results = AnalysisResults::default();
5132 results
5133 .circular_dependencies
5134 .push(CircularDependencyFinding::with_actions(
5135 CircularDependency {
5136 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
5137 length: 2,
5138 line: 1,
5139 col: 0,
5140 edges: Vec::new(),
5141 is_cross_package: false,
5142 },
5143 ));
5144 results
5145 .circular_dependencies
5146 .push(CircularDependencyFinding::with_actions(
5147 CircularDependency {
5148 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
5149 length: 2,
5150 line: 5,
5151 col: 0,
5152 edges: Vec::new(),
5153 is_cross_package: false,
5154 },
5155 ));
5156 let filtered = filter_new_issues(results, &baseline, Path::new(""));
5157 assert_eq!(filtered.circular_dependencies.len(), 1);
5158 }
5159
5160 #[test]
5161 fn package_cycles_round_trip_through_the_baseline() {
5162 use crate::results::{PackageCycle, PackageCycleEdge, PackageCycleFinding};
5163 let cycle = |packages: &[&str], example: &str| {
5164 PackageCycleFinding::with_actions(PackageCycle {
5165 packages: packages.iter().map(ToString::to_string).collect(),
5166 package_roots: packages
5167 .iter()
5168 .map(|name| {
5169 PathBuf::from(format!("packages/{}", name.trim_start_matches("@x/")))
5170 })
5171 .collect(),
5172 length: packages.len(),
5173 edges: vec![PackageCycleEdge {
5174 from_package: packages[0].to_string(),
5175 to_package: packages[1].to_string(),
5176 path: PathBuf::from(example),
5177 target_path: PathBuf::from("packages/b/src/y.ts"),
5178 line: 1,
5179 col: 0,
5180 type_only: false,
5181 }],
5182 group_truncated: false,
5183 })
5184 };
5185 let mut saved = AnalysisResults::default();
5186 saved
5187 .package_cycles
5188 .push(cycle(&["@x/a", "@x/b"], "packages/a/src/x.ts"));
5189 let baseline = BaselineData::from_results(&saved, Path::new(""));
5190 assert_eq!(
5191 baseline.package_cycles,
5192 vec!["package-cycle:packages/a|packages/b"]
5193 );
5194
5195 let mut results = AnalysisResults::default();
5197 results
5198 .package_cycles
5199 .push(cycle(&["@x/a", "@x/b"], "packages/a/src/other.ts"));
5200 results
5201 .package_cycles
5202 .push(cycle(&["@x/a", "@x/c"], "packages/a/src/x.ts"));
5203 let filtered = filter_new_issues(results, &baseline, Path::new(""));
5204 assert_eq!(filtered.package_cycles.len(), 1);
5205 assert_eq!(filtered.package_cycles[0].cycle.packages, ["@x/a", "@x/c"]);
5206
5207 let mut saved = AnalysisResults::default();
5210 saved.package_cycles.push(cycle(
5211 &["@x/a", "example (examples/one)"],
5212 "packages/a/src/x.ts",
5213 ));
5214 let baseline = BaselineData::from_results(&saved, Path::new(""));
5215 let mut results = AnalysisResults::default();
5216 results.package_cycles.push(cycle(
5217 &["@x/a", "example (examples/two)"],
5218 "packages/a/src/x.ts",
5219 ));
5220 let filtered = filter_new_issues(results, &baseline, Path::new(""));
5221 assert_eq!(filtered.package_cycles.len(), 1);
5222
5223 let mut saved = AnalysisResults::default();
5225 saved
5226 .package_cycles
5227 .push(cycle(&["@x/a", "@x/b"], "packages/a/src/x.ts"));
5228 let legacy = BaselineData::legacy_from_results(&saved, Path::new(""));
5229 assert_eq!(legacy.package_cycles, vec!["@x/a->@x/b"]);
5230 let filtered = filter_new_issues(saved, &legacy, Path::new(""));
5231 assert!(filtered.package_cycles.is_empty());
5232 }
5233
5234 #[test]
5235 fn filter_keeps_new_boundary_violations() {
5236 use crate::results::BoundaryViolation;
5237 let baseline = BaselineData {
5238 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
5239 boundary_coverage_violations: vec![],
5240 boundary_call_violations: vec![],
5241 policy_violations: vec![],
5242 ..BaselineData::legacy_from_results(&AnalysisResults::default(), Path::new(""))
5243 };
5244 let mut results = AnalysisResults::default();
5245 results
5246 .boundary_violations
5247 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
5248 from_path: PathBuf::from("src/a.ts"),
5249 to_path: PathBuf::from("src/b.ts"),
5250 from_zone: "a".to_string(),
5251 to_zone: "b".to_string(),
5252 import_specifier: "../b".to_string(),
5253 line: 1,
5254 col: 0,
5255 via_path: None,
5256 }));
5257 results
5258 .boundary_violations
5259 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
5260 from_path: PathBuf::from("src/new.ts"),
5261 to_path: PathBuf::from("src/secret.ts"),
5262 from_zone: "new".to_string(),
5263 to_zone: "secret".to_string(),
5264 import_specifier: "../secret".to_string(),
5265 line: 1,
5266 col: 0,
5267 via_path: None,
5268 }));
5269 let filtered = filter_new_issues(results, &baseline, Path::new(""));
5270 assert_eq!(filtered.boundary_violations.len(), 1);
5271 }
5272
5273 #[test]
5274 fn health_targets_baseline_filters_known() {
5275 let root = PathBuf::from("/project");
5276 let targets = vec![
5277 fallow_output::RefactoringTarget {
5278 path: root.join("src/complex.ts"),
5279 priority: 80.0,
5280 efficiency: 40.0,
5281 recommendation: "Split file".to_string(),
5282 category: fallow_output::RecommendationCategory::SplitHighImpact,
5283 effort: fallow_output::EffortEstimate::Medium,
5284 confidence: fallow_output::Confidence::Medium,
5285 factors: vec![],
5286 evidence: None,
5287 },
5288 fallow_output::RefactoringTarget {
5289 path: root.join("src/new-issue.ts"),
5290 priority: 60.0,
5291 efficiency: 30.0,
5292 recommendation: "Extract function".to_string(),
5293 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
5294 effort: fallow_output::EffortEstimate::Low,
5295 confidence: fallow_output::Confidence::High,
5296 factors: vec![],
5297 evidence: None,
5298 },
5299 ];
5300 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
5301 let filtered = filter_new_health_targets(targets, &baseline, &root);
5302 assert_eq!(filtered.len(), 1);
5303 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
5304 }
5305
5306 #[test]
5307 fn duplicate_export_key_is_sorted() {
5308 use crate::results::{DuplicateExport, DuplicateLocation};
5309 let dup_ab = DuplicateExport {
5310 export_name: "foo".to_string(),
5311 locations: vec![
5312 DuplicateLocation {
5313 path: PathBuf::from("src/a.ts"),
5314 line: 1,
5315 col: 0,
5316 },
5317 DuplicateLocation {
5318 path: PathBuf::from("src/b.ts"),
5319 line: 5,
5320 col: 0,
5321 },
5322 ],
5323 };
5324 let dup_ba = DuplicateExport {
5325 export_name: "foo".to_string(),
5326 locations: vec![
5327 DuplicateLocation {
5328 path: PathBuf::from("src/b.ts"),
5329 line: 5,
5330 col: 0,
5331 },
5332 DuplicateLocation {
5333 path: PathBuf::from("src/a.ts"),
5334 line: 1,
5335 col: 0,
5336 },
5337 ],
5338 };
5339 assert_eq!(
5340 super::duplicate_export_key(&dup_ab, Path::new("")),
5341 super::duplicate_export_key(&dup_ba, Path::new("")),
5342 );
5343 }
5344
5345 #[test]
5346 fn boundary_violation_key_format() {
5347 use crate::results::BoundaryViolation;
5348 let v = BoundaryViolation {
5349 from_path: PathBuf::from("src/ui/btn.ts"),
5350 to_path: PathBuf::from("src/db/query.ts"),
5351 from_zone: "ui".to_string(),
5352 to_zone: "db".to_string(),
5353 import_specifier: "../db/query".to_string(),
5354 line: 1,
5355 col: 0,
5356 via_path: None,
5357 };
5358 let key = super::boundary_violation_key(&v, Path::new(""));
5359 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
5360 }
5361
5362 fn make_absolute_results(root: &str) -> AnalysisResults {
5364 use crate::results::*;
5365 use crate::source::MemberKind;
5366
5367 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
5368
5369 AnalysisResults {
5370 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
5371 path: p("src/old.ts"),
5372 })],
5373 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
5374 path: p("src/utils.ts"),
5375 export_name: "helper".to_string(),
5376 is_type_only: false,
5377 line: 5,
5378 col: 0,
5379 span_start: 40,
5380 is_re_export: false,
5381 deprecated: false,
5382 deprecated_reason: None,
5383 })],
5384 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
5385 package_name: "lodash-es".to_string(),
5386 location: DependencyLocation::Dependencies,
5387 path: p("packages/app/package.json"),
5388 line: 5,
5389 used_in_workspaces: Vec::new(),
5390 declared_and_imported_in: Vec::new(),
5391 })],
5392 circular_dependencies: vec![CircularDependencyFinding::with_actions(
5393 CircularDependency {
5394 files: vec![p("src/a.ts"), p("src/b.ts")],
5395 length: 2,
5396 line: 1,
5397 col: 0,
5398 edges: Vec::new(),
5399 is_cross_package: false,
5400 },
5401 )],
5402 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
5403 path: p("src/enums.ts"),
5404 parent_name: "Status".to_string(),
5405 member_name: "Deprecated".to_string(),
5406 kind: MemberKind::EnumMember,
5407 line: 8,
5408 col: 0,
5409 })],
5410 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
5411 path: p("src/service.ts"),
5412 parent_name: "UserService".to_string(),
5413 member_name: "legacy".to_string(),
5414 kind: MemberKind::ClassMethod,
5415 line: 42,
5416 col: 0,
5417 })],
5418 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
5419 path: p("src/store.ts"),
5420 parent_name: "useStore".to_string(),
5421 member_name: "legacyAction".to_string(),
5422 kind: MemberKind::StoreMember,
5423 line: 17,
5424 col: 0,
5425 })],
5426 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
5427 path: p("src/app.ts"),
5428 specifier: "./missing".to_string(),
5429 line: 3,
5430 col: 0,
5431 specifier_col: 0,
5432 })],
5433 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
5434 export_name: "Config".to_string(),
5435 locations: vec![
5436 DuplicateLocation {
5437 path: p("src/a.ts"),
5438 line: 1,
5439 col: 0,
5440 },
5441 DuplicateLocation {
5442 path: p("src/b.ts"),
5443 line: 5,
5444 col: 0,
5445 },
5446 ],
5447 })],
5448 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
5449 from_path: p("src/ui/btn.ts"),
5450 to_path: p("src/db/query.ts"),
5451 from_zone: "ui".to_string(),
5452 to_zone: "db".to_string(),
5453 import_specifier: "../db/query".to_string(),
5454 line: 1,
5455 col: 0,
5456 via_path: None,
5457 })],
5458 ..Default::default()
5459 }
5460 }
5461
5462 #[test]
5465 fn baseline_keys_are_relative_to_root() {
5466 let local_root = Path::new("/Users/dev/project");
5467 let results = make_absolute_results("/Users/dev/project");
5468 let baseline = BaselineData::from_results(&results, local_root);
5469
5470 assert_eq!(baseline.unused_files, vec!["unused-file:src/old.ts"]);
5471 assert_eq!(
5472 baseline.unused_exports,
5473 vec!["unused-export:src/utils.ts:helper"]
5474 );
5475 assert_eq!(
5476 baseline.unused_dependencies,
5477 vec!["unused-dependency:packages/app/package.json:lodash-es"]
5478 );
5479 assert_eq!(
5480 baseline.boundary_violations,
5481 vec!["boundary-violation:src/ui/btn.ts:src/db/query.ts"]
5482 );
5483 assert_eq!(
5484 baseline.circular_dependencies,
5485 vec!["circular-dependency:src/a.ts|src/b.ts"]
5486 );
5487 assert_eq!(
5488 baseline.unused_enum_members,
5489 vec!["unused-enum-member:src/enums.ts:Status:Deprecated"]
5490 );
5491 assert_eq!(
5492 baseline.unused_class_members,
5493 vec!["unused-class-member:src/service.ts:UserService:legacy"]
5494 );
5495 assert_eq!(
5496 baseline.unused_store_members,
5497 vec!["unused-store-member:src/store.ts:useStore:legacyAction"]
5498 );
5499 assert_eq!(
5500 baseline.unresolved_imports,
5501 vec!["unresolved-import:src/app.ts:./missing"]
5502 );
5503 assert_eq!(
5504 baseline.duplicate_exports,
5505 vec!["duplicate-export:Config:src/a.ts|src/b.ts"]
5506 );
5507
5508 let ci_root = Path::new("/home/runner/work/project/project");
5509 let ci_results = make_absolute_results("/home/runner/work/project/project");
5510
5511 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
5512 assert!(filtered.unused_files.is_empty(), "unused files");
5513 assert!(filtered.unused_exports.is_empty(), "unused exports");
5514 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
5515 assert!(
5516 filtered.boundary_violations.is_empty(),
5517 "boundary violations"
5518 );
5519 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
5520 assert!(filtered.unused_enum_members.is_empty(), "enum members");
5521 assert!(filtered.unused_class_members.is_empty(), "class members");
5522 assert!(filtered.unused_store_members.is_empty(), "store members");
5523 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
5524 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
5525 }
5526
5527 #[test]
5528 fn stale_suppression_baseline_keys_include_missing_reason_state() {
5529 let root = Path::new("/project");
5530 let stale = crate::results::StaleSuppression {
5531 finding_id: None,
5532 path: root.join("src/file.ts"),
5533 line: 1,
5534 col: 0,
5535 origin: crate::results::SuppressionOrigin::Comment {
5536 issue_kind: Some("unused-export".to_string()),
5537 reason: None,
5538 is_file_level: false,
5539 kind_known: true,
5540 },
5541 missing_reason: false,
5542 actions: crate::results::StaleSuppression::actions_for(false),
5543 effective_severity: None,
5544 };
5545 let missing = crate::results::StaleSuppression {
5546 missing_reason: true,
5547 actions: crate::results::StaleSuppression::actions_for(true),
5548 ..stale.clone()
5549 };
5550 let results = AnalysisResults {
5551 stale_suppressions: vec![stale, missing],
5552 ..Default::default()
5553 };
5554 let baseline = BaselineData::from_results(&results, root);
5555
5556 assert_eq!(
5557 baseline.stale_suppressions,
5558 vec![
5559 "missing-suppression-reason:src/file.ts:comment:unused-export:line",
5560 "stale-suppression:src/file.ts:comment:unused-export:line",
5561 ]
5562 );
5563
5564 let mut legacy_baseline =
5565 BaselineData::legacy_from_results(&AnalysisResults::default(), root);
5566 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
5567 let filtered = filter_new_issues(results, &legacy_baseline, root);
5568 assert!(filtered.stale_suppressions.is_empty());
5569 }
5570
5571 fn runtime_finding(
5572 id: &str,
5573 stable_id: Option<&str>,
5574 line: u32,
5575 source_hash: Option<&str>,
5576 ) -> fallow_output::RuntimeCoverageFinding {
5577 fallow_output::RuntimeCoverageFinding {
5578 id: id.to_owned(),
5579 stable_id: stable_id.map(str::to_owned),
5580 source_hash: source_hash.map(str::to_owned),
5581 path: PathBuf::from("src/a.ts"),
5582 function: "alpha".to_owned(),
5583 line,
5584 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
5585 invocations: Some(0),
5586 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
5587 evidence: fallow_output::RuntimeCoverageEvidence {
5588 static_status: "used".to_owned(),
5589 test_coverage: "not_covered".to_owned(),
5590 test_only_reference: None,
5591 v8_tracking: "tracked".to_owned(),
5592 untracked_reason: None,
5593 observation_days: 1,
5594 deployments_observed: 1,
5595 },
5596 actions: vec![],
5597 discriminators: None,
5598 }
5599 }
5600
5601 #[test]
5602 fn legacy_prod_baseline_still_suppresses_finding() {
5603 let baseline = HealthBaselineData {
5604 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
5605 ..HealthBaselineData::default()
5606 };
5607 let findings = vec![runtime_finding(
5608 "fallow:prod:deadbeef",
5609 Some("fallow:fn:00000001"),
5610 14,
5611 None,
5612 )];
5613 let filtered =
5614 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5615 assert!(filtered.is_empty(), "legacy prod id must still suppress");
5616 }
5617
5618 #[test]
5619 fn source_hash_baseline_survives_line_move() {
5620 let root = Path::new("/repo");
5621 let baselined = runtime_finding(
5622 "fallow:prod:deadbeef",
5623 Some("fallow:fn:00000001"),
5624 14,
5625 Some("0123456789abcdef"),
5626 );
5627 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
5628 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
5629
5630 let findings = vec![runtime_finding(
5631 "fallow:prod:99999999",
5632 Some("fallow:fn:cafe0002"),
5633 40,
5634 Some("0123456789abcdef"),
5635 )];
5636 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
5637 assert!(
5638 filtered.is_empty(),
5639 "source_hash baseline must survive a line move despite a changed stable_id and id"
5640 );
5641 }
5642
5643 #[test]
5644 fn unbaselined_finding_is_reported() {
5645 let baseline = HealthBaselineData {
5646 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
5647 ..HealthBaselineData::default()
5648 };
5649 let findings = vec![runtime_finding(
5650 "fallow:prod:abc1234d",
5651 Some("fallow:fn:beefcafe"),
5652 7,
5653 None,
5654 )];
5655 let filtered =
5656 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5657 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
5658 }
5659
5660 fn line_free_member(
5663 root: &Path,
5664 member: &str,
5665 line: u32,
5666 ) -> crate::results::UnusedClassMemberFinding {
5667 crate::results::UnusedClassMemberFinding::with_actions(crate::results::UnusedMember {
5668 path: root.join("src/service.ts"),
5669 parent_name: "Service".to_owned(),
5670 member_name: member.to_owned(),
5671 kind: fallow_types::extract::MemberKind::ClassMethod,
5672 line,
5673 col: 2,
5674 })
5675 }
5676
5677 fn line_free_suppression(
5678 root: &Path,
5679 line: u32,
5680 reason: Option<&str>,
5681 ) -> crate::results::StaleSuppression {
5682 crate::results::StaleSuppression {
5683 path: root.join("src/flags.ts"),
5684 line,
5685 col: 0,
5686 origin: crate::results::SuppressionOrigin::Comment {
5687 issue_kind: Some("unused-export".to_owned()),
5688 reason: reason.map(str::to_owned),
5689 is_file_level: false,
5690 kind_known: true,
5691 },
5692 missing_reason: false,
5693 finding_id: None,
5694 actions: Vec::new(),
5695 effective_severity: None,
5696 }
5697 }
5698
5699 fn line_free_directive(root: &Path, line: u32) -> crate::results::MisplacedDirectiveFinding {
5700 crate::results::MisplacedDirectiveFinding::with_actions(
5701 crate::results::MisplacedDirective {
5702 path: root.join("src/action.ts"),
5703 directive: "use server".to_owned(),
5704 line,
5705 col: 0,
5706 },
5707 )
5708 }
5709
5710 fn line_free_results(root: &Path, shift: u32, reason: Option<&str>) -> AnalysisResults {
5711 AnalysisResults {
5712 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
5713 path: root.join("src/utils.ts"),
5714 export_name: "helper".to_owned(),
5715 is_type_only: false,
5716 line: 10 + shift,
5717 col: 0,
5718 span_start: 100 + shift * 10,
5719 is_re_export: false,
5720 deprecated: false,
5721 deprecated_reason: None,
5722 })],
5723 stale_suppressions: vec![line_free_suppression(root, 3 + shift, reason)],
5724 misplaced_directives: vec![line_free_directive(root, 5 + shift)],
5725 ..AnalysisResults::default()
5726 }
5727 }
5728
5729 fn reload(baseline: &BaselineData) -> BaselineData {
5730 serde_json::from_str(&serde_json::to_string(baseline).expect("serialize baseline"))
5731 .expect("parse baseline")
5732 }
5733
5734 #[test]
5735 fn saved_baseline_hides_findings_after_a_line_shift() {
5736 let root = PathBuf::from("/project");
5737 let baseline = reload(&BaselineData::from_results(
5738 &line_free_results(&root, 0, None),
5739 &root,
5740 ));
5741
5742 let filtered = filter_new_issues(line_free_results(&root, 20, None), &baseline, &root);
5743
5744 assert_eq!(
5745 filtered.total_issues(),
5746 0,
5747 "a line shift must not make findings new"
5748 );
5749 }
5750
5751 #[test]
5752 fn saved_baseline_ignores_the_suppression_reason_text() {
5753 let root = PathBuf::from("/project");
5754 let baseline = reload(&BaselineData::from_results(
5755 &line_free_results(&root, 0, Some("old reason")),
5756 &root,
5757 ));
5758
5759 let filtered = filter_new_issues(
5760 line_free_results(&root, 7, Some("a new reason")),
5761 &baseline,
5762 &root,
5763 );
5764
5765 assert!(filtered.stale_suppressions.is_empty());
5766 }
5767
5768 #[test]
5769 fn saved_baseline_hides_at_most_the_saved_number_of_occurrences() {
5770 let root = PathBuf::from("/project");
5771 let saved = AnalysisResults {
5772 unused_class_members: vec![line_free_member(&root, "value", 4)],
5773 ..AnalysisResults::default()
5774 };
5775 let baseline = reload(&BaselineData::from_results(&saved, &root));
5776 let current = AnalysisResults {
5777 unused_class_members: vec![
5778 line_free_member(&root, "value", 14),
5779 line_free_member(&root, "value", 18),
5780 ],
5781 ..AnalysisResults::default()
5782 };
5783
5784 let filtered = filter_new_issues(current, &baseline, &root);
5785
5786 assert_eq!(
5787 filtered.unused_class_members.len(),
5788 1,
5789 "one saved occurrence hides one current finding, not two"
5790 );
5791 }
5792
5793 #[test]
5794 fn saved_baseline_declares_the_dc1_identity_and_canonical_keys() {
5795 let root = PathBuf::from("/project");
5796 let mut results = line_free_results(&root, 0, None);
5797 results.unused_class_members = vec![
5798 line_free_member(&root, "value", 4),
5799 line_free_member(&root, "value", 8),
5800 ];
5801
5802 let json: serde_json::Value =
5803 serde_json::to_value(BaselineData::from_results(&results, &root)).unwrap();
5804
5805 assert_eq!(json["identity"], "dc1");
5806 assert_eq!(
5807 json["unused_exports"],
5808 serde_json::json!(["unused-export:src/utils.ts:helper"])
5809 );
5810 assert_eq!(
5811 json["unused_class_members"],
5812 serde_json::json!([
5813 "unused-class-member:src/service.ts:Service:value",
5814 "unused-class-member:src/service.ts:Service:value"
5815 ])
5816 );
5817 assert_eq!(
5818 json["stale_suppressions"],
5819 serde_json::json!(["stale-suppression:src/flags.ts:comment:unused-export:line"])
5820 );
5821 assert_eq!(
5822 json["misplaced_directives"],
5823 serde_json::json!(["misplaced-directive:src/action.ts:use server"])
5824 );
5825 }
5826
5827 #[test]
5828 fn legacy_baseline_still_matches_its_exact_keys() {
5829 let root = PathBuf::from("/project");
5830 let legacy: BaselineData = serde_json::from_value(serde_json::json!({
5831 "unused_files": [],
5832 "unused_exports": ["src/utils.ts:helper"],
5833 "unused_types": [],
5834 "unused_dependencies": [],
5835 "unused_dev_dependencies": [],
5836 "stale_suppressions": ["stale-suppression:src/flags.ts:3"],
5837 "misplaced_directives": ["src/action.ts:5:use server"]
5838 }))
5839 .unwrap();
5840
5841 let unshifted = filter_new_issues(line_free_results(&root, 0, None), &legacy, &root);
5842 let shifted = filter_new_issues(line_free_results(&root, 1, None), &legacy, &root);
5843
5844 assert_eq!(
5845 unshifted.total_issues(),
5846 0,
5847 "a legacy key matches its old form"
5848 );
5849 assert!(shifted.unused_exports.is_empty());
5850 assert_eq!(
5851 shifted.stale_suppressions.len() + shifted.misplaced_directives.len(),
5852 2,
5853 "a legacy key that holds a line matches only that line"
5854 );
5855 }
5856
5857 #[test]
5858 fn a_baseline_with_an_unknown_identity_is_refused() {
5859 let root = PathBuf::from("/project");
5860 let mut results = line_free_results(&root, 0, None);
5861 let content = serde_json::json!({
5862 "identity": "dc9",
5863 "unused_files": [],
5864 "unused_exports": [],
5865 "unused_types": [],
5866 "unused_dependencies": [],
5867 "unused_dev_dependencies": []
5868 })
5869 .to_string();
5870
5871 let outcome = apply_dead_code_baseline(
5872 &mut results,
5873 &content,
5874 &root,
5875 &fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
5876 false,
5877 );
5878
5879 assert!(
5880 matches!(outcome, Err(DeadCodeBaselineError::Parse(ref message)) if message.contains("dc9")),
5881 "{outcome:?}"
5882 );
5883 }
5884
5885 #[test]
5886 fn saved_scope_reasons_reads_only_a_string_array() {
5887 assert_eq!(
5888 saved_scope_reasons(r#"{"scope_reasons":["package-baselines","production"]}"#),
5889 ["package-baselines", "production"]
5890 );
5891 assert!(saved_scope_reasons(r#"{"unused_files":[]}"#).is_empty());
5892 assert!(saved_scope_reasons(r#"{"scope_reasons":"package-baselines"}"#).is_empty());
5893 assert_eq!(
5894 saved_scope_reasons(r#"{"scope_reasons":["diff",7,null]}"#),
5895 ["diff"]
5896 );
5897 assert!(saved_scope_reasons("not json").is_empty());
5898 }
5899
5900 #[test]
5901 fn a_whole_project_save_records_no_scope() {
5902 let root = Path::new("/project");
5903 let saved = serde_json::to_value(BaselineData::from_results(
5904 &AnalysisResults::default(),
5905 root,
5906 ))
5907 .expect("baseline JSON");
5908 assert!(saved.get("scope_reasons").is_none(), "{saved}");
5909 let scoped = serde_json::to_value(
5910 BaselineData::from_results(&AnalysisResults::default(), root).with_scope_reasons(
5911 fallow_output::BaselineScopeReasons::empty()
5912 .with(fallow_output::ScopeReason::PackageBaselines),
5913 ),
5914 )
5915 .expect("baseline JSON");
5916 assert_eq!(
5917 scoped["scope_reasons"],
5918 serde_json::json!(["package-baselines"])
5919 );
5920 }
5921}