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