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