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