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