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