1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::duplicates::DuplicationReport;
6
7fn relative_path(path: &Path, root: &Path) -> String {
13 match path.strip_prefix(root) {
14 Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
15 Err(_) => {
16 tracing::debug!(
17 path = %path.display(),
18 root = %root.display(),
19 "baseline key: path is not under project root, using absolute path as key"
20 );
21 path.to_string_lossy().replace('\\', "/")
22 }
23 }
24}
25
26fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
27 format!("{}:{package_name}", relative_path(path, root))
28}
29
30fn baseline_contains_dependency(
31 baseline_keys: &FxHashSet<&str>,
32 package_name: &str,
33 path_key: &str,
34) -> bool {
35 baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
36}
37
38fn retain_new_by_keys<T>(
39 items: &mut Vec<T>,
40 baseline_keys: &[String],
41 root: &Path,
42 key_builder: fn(&[T], &Path) -> Vec<String>,
43) {
44 let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
45 let item_keys = key_builder(items, root);
46 let mut key_iter = item_keys.into_iter();
47 items.retain(|_| match key_iter.next() {
48 Some(key) => !baseline_keys.contains(key.as_str()),
49 None => true,
50 });
51}
52
53const STALE_WARN_PERCENT: usize = 25;
61
62#[must_use]
68pub const fn stale_share_warrants_warning(baseline_entries: usize, stale_entries: usize) -> bool {
69 stale_entries > 0 && stale_entries * 100 >= baseline_entries * STALE_WARN_PERCENT
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct BaselineStaleness {
80 pub entries: usize,
82 pub matched: usize,
84 pub current_findings: usize,
88 pub change_scoped: bool,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum BaselineStalenessWarning {
96 None,
98 ZeroOverlap,
100 Partial,
102}
103
104impl BaselineStaleness {
105 #[must_use]
107 pub const fn stale_entries(&self) -> usize {
108 self.entries.saturating_sub(self.matched)
109 }
110
111 #[must_use]
120 pub const fn warning(&self) -> BaselineStalenessWarning {
121 if self.change_scoped || self.entries == 0 || self.current_findings == 0 {
122 return BaselineStalenessWarning::None;
123 }
124 if self.matched == 0 {
125 return BaselineStalenessWarning::ZeroOverlap;
126 }
127 if stale_share_warrants_warning(self.entries, self.stale_entries()) {
128 return BaselineStalenessWarning::Partial;
129 }
130 BaselineStalenessWarning::None
131 }
132
133 #[must_use]
142 pub const fn trips_gate(&self) -> bool {
143 stale_baseline_gate_trips(self.entries, self.matched, self.change_scoped)
144 }
145 #[must_use]
166 pub fn to_envelope(
167 &self,
168 moved_entries: usize,
169 scope_reasons: fallow_output::BaselineScopeReasons,
170 unrecognised_format: bool,
171 ) -> fallow_output::BaselineStaleness {
172 debug_assert_eq!(
173 self.change_scoped,
174 !scope_reasons.is_empty(),
175 "change_scoped and scope_reasons must be derived from the same predicate"
176 );
177 let warning = self.warning();
178 fallow_output::BaselineStaleness {
179 baseline_entries: self.entries,
180 matched_entries: self.matched,
181 stale_entries: self.stale_entries(),
182 current_findings: self.current_findings,
183 change_scoped: self.change_scoped,
184 stale: warning != BaselineStalenessWarning::None,
185 warning: match warning {
186 BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
187 BaselineStalenessWarning::ZeroOverlap => {
188 fallow_output::BaselineStalenessAdvisory::ZeroOverlap
189 }
190 BaselineStalenessWarning::Partial => {
191 fallow_output::BaselineStalenessAdvisory::Partial
192 }
193 },
194 gate_trips: self.trips_gate() || unrecognised_format,
195 moved_entries,
196 unrecognised_format,
197 saved_by: None,
198 scope_reasons,
199 }
200 }
201}
202
203#[must_use]
206pub const fn stale_baseline_gate_trips(
207 entries: usize,
208 matched: usize,
209 change_scoped: bool,
210) -> bool {
211 !change_scoped && entries > 0 && matched < entries
212}
213
214#[must_use]
228pub fn declares_baseline_format(json: &str, declared_keys: &[&str]) -> bool {
229 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
230 return false;
231 };
232 value_declares_baseline_format(&value, declared_keys)
233}
234
235fn value_declares_baseline_format(value: &serde_json::Value, declared_keys: &[&str]) -> bool {
237 let Some(object) = value.as_object() else {
238 return false;
239 };
240 declared_keys.iter().any(|key| object.contains_key(*key))
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
251#[serde(rename_all = "kebab-case")]
252pub enum BaselineKind {
253 DeadCode,
255 Dupes,
257 Health,
259}
260
261impl BaselineKind {
262 #[must_use]
264 pub const fn as_str(self) -> &'static str {
265 match self {
266 Self::DeadCode => "dead-code",
267 Self::Dupes => "dupes",
268 Self::Health => "health",
269 }
270 }
271
272 #[must_use]
275 pub fn from_token(token: &str) -> Option<Self> {
276 [Self::DeadCode, Self::Dupes, Self::Health]
277 .into_iter()
278 .find(|kind| kind.as_str() == token)
279 }
280
281 #[must_use]
283 pub const fn declared_keys(self) -> &'static [&'static str] {
284 match self {
285 Self::DeadCode => BaselineData::REQUIRED_KEYS,
286 Self::Dupes => DuplicationBaselineData::DECLARED_KEYS,
287 Self::Health => HealthBaselineData::DECLARED_KEYS,
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum BaselineFileKind {
295 Own,
298 Foreign(String),
301 Unrecognised,
305 NotAnObject,
308}
309
310impl BaselineFileKind {
311 #[must_use]
318 pub fn saved_by(&self) -> Option<BaselineKind> {
319 match self {
320 Self::Foreign(token) => BaselineKind::from_token(token),
321 Self::Own | Self::Unrecognised | Self::NotAnObject => None,
322 }
323 }
324}
325
326#[must_use]
336pub fn refuse_baseline_kind_overwrite(save_path: &Path, saving: BaselineKind) -> Option<String> {
337 let existing = std::fs::read_to_string(save_path).ok()?;
338 let BaselineFileKind::Foreign(found) = classify_baseline_file(&existing, saving) else {
339 return None;
340 };
341 Some(format!(
342 "refusing to overwrite the baseline at {}: it was saved by `fallow {found}` and this is a \
343 `fallow {}` save, which would destroy it. Save each command's baseline to its own path.",
344 save_path.display(),
345 saving.as_str(),
346 ))
347}
348
349#[must_use]
359pub fn classify_baseline_file(json: &str, expected: BaselineKind) -> BaselineFileKind {
360 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
361 return BaselineFileKind::NotAnObject;
362 };
363 classify_baseline_value(&value, expected)
364}
365
366#[must_use]
372pub fn classify_baseline_value(
373 value: &serde_json::Value,
374 expected: BaselineKind,
375) -> BaselineFileKind {
376 let Some(object) = value.as_object() else {
377 return BaselineFileKind::NotAnObject;
378 };
379 match object.get("kind").and_then(serde_json::Value::as_str) {
380 Some(token) if token == expected.as_str() => BaselineFileKind::Own,
381 Some(token) => BaselineFileKind::Foreign(token.to_owned()),
382 None => {
385 if value_declares_baseline_format(value, expected.declared_keys()) {
386 BaselineFileKind::Own
387 } else {
388 BaselineFileKind::Unrecognised
389 }
390 }
391 }
392}
393
394#[derive(serde::Serialize, serde::Deserialize)]
396pub struct BaselineData {
397 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
403 kind: Option<BaselineKind>,
404 #[serde(default)]
408 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
409 unused_files: Vec<String>,
410 unused_exports: Vec<String>,
411 unused_types: Vec<String>,
412 #[serde(default)]
413 private_type_leaks: Vec<String>,
414 #[serde(default)]
416 deprecated_exports_in_use: Vec<String>,
417 unused_dependencies: Vec<String>,
421 unused_dev_dependencies: Vec<String>,
425 #[serde(default)]
427 circular_dependencies: Vec<String>,
428 #[serde(default)]
433 re_export_cycles: Vec<String>,
434 #[serde(default)]
438 unused_optional_dependencies: Vec<String>,
439 #[serde(default)]
441 unused_enum_members: Vec<String>,
442 #[serde(default)]
444 unused_class_members: Vec<String>,
445 #[serde(default)]
447 unused_store_members: Vec<String>,
448 #[serde(default)]
450 unprovided_injects: Vec<String>,
451 #[serde(default)]
453 unrendered_components: Vec<String>,
454 #[serde(default)]
456 unused_component_props: Vec<String>,
457 #[serde(default)]
459 unused_component_emits: Vec<String>,
460 #[serde(default)]
462 unused_component_inputs: Vec<String>,
463 #[serde(default)]
465 unused_component_outputs: Vec<String>,
466 #[serde(default)]
468 unused_svelte_events: Vec<String>,
469 #[serde(default)]
471 unused_server_actions: Vec<String>,
472 #[serde(default)]
474 unused_load_data_keys: Vec<String>,
475 #[serde(default)]
477 unresolved_imports: Vec<String>,
478 #[serde(default)]
480 unlisted_dependencies: Vec<String>,
481 #[serde(default)]
483 duplicate_exports: Vec<String>,
484 #[serde(default)]
488 type_only_dependencies: Vec<String>,
489 #[serde(default)]
493 test_only_dependencies: Vec<String>,
494 #[serde(default)]
496 dev_dependencies_in_production: Vec<String>,
497 #[serde(default)]
499 boundary_violations: Vec<String>,
500 #[serde(default)]
502 boundary_coverage_violations: Vec<String>,
503 #[serde(default)]
505 boundary_call_violations: Vec<String>,
506 #[serde(default)]
508 policy_violations: Vec<String>,
509 #[serde(default)]
511 stale_suppressions: Vec<String>,
512 #[serde(default)]
514 unused_catalog_entries: Vec<String>,
515 #[serde(default)]
517 empty_catalog_groups: Vec<String>,
518 #[serde(default)]
520 unresolved_catalog_references: Vec<String>,
521 #[serde(default)]
523 unused_dependency_overrides: Vec<String>,
524 #[serde(default)]
526 misconfigured_dependency_overrides: Vec<String>,
527 #[serde(default)]
529 invalid_client_exports: Vec<String>,
530 #[serde(default)]
532 mixed_client_server_barrels: Vec<String>,
533 #[serde(default)]
536 misplaced_directives: Vec<String>,
537 #[serde(default)]
539 route_collisions: Vec<String>,
540 #[serde(default)]
542 dynamic_segment_name_conflicts: Vec<String>,
543}
544
545impl BaselineData {
546 pub const REQUIRED_KEYS: &'static [&'static str] = &[
552 "unused_files",
553 "unused_exports",
554 "unused_types",
555 "unused_dependencies",
556 "unused_dev_dependencies",
557 ];
558
559 pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
562 Self::from_results_with_identity(
563 results,
564 root,
565 fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
566 )
567 }
568
569 pub fn from_results_with_identity(
573 results: &crate::results::AnalysisResults,
574 root: &Path,
575 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
576 ) -> Self {
577 let file_exports = baseline_file_export_keys(results, root);
578 let member_imports = baseline_member_import_keys(results, root);
579 let dependencies = baseline_dependency_keys(results, root);
580 let graph = baseline_graph_keys(results, root);
581 let catalog = baseline_catalog_keys(results, root);
582
583 Self {
584 kind: Some(BaselineKind::DeadCode),
585 analysis_identity,
586 unused_files: file_exports.unused_files,
587 unused_exports: file_exports.unused_exports,
588 unused_types: file_exports.unused_types,
589 private_type_leaks: file_exports.private_type_leaks,
590 deprecated_exports_in_use: file_exports.deprecated_exports_in_use,
591 unused_dependencies: dependencies.unused,
592 unused_dev_dependencies: dependencies.unused_dev,
593 circular_dependencies: graph.circular_dependencies,
594 re_export_cycles: graph.re_export_cycles,
595 unused_optional_dependencies: dependencies.unused_optional,
596 unused_enum_members: member_imports.unused_enum_members,
597 unused_class_members: member_imports.unused_class_members,
598 unused_store_members: member_imports.unused_store_members,
599 unprovided_injects: member_imports.unprovided_injects,
600 unrendered_components: member_imports.unrendered_components,
601 unused_component_props: member_imports.unused_component_props,
602 unused_component_emits: member_imports.unused_component_emits,
603 unused_component_inputs: member_imports.unused_component_inputs,
604 unused_component_outputs: member_imports.unused_component_outputs,
605 unused_svelte_events: member_imports.unused_svelte_events,
606 unused_server_actions: member_imports.unused_server_actions,
607 unused_load_data_keys: member_imports.unused_load_data_keys,
608 unresolved_imports: member_imports.unresolved_imports,
609 unlisted_dependencies: dependencies.unlisted,
610 duplicate_exports: member_imports.duplicate_exports,
611 type_only_dependencies: dependencies.type_only,
612 test_only_dependencies: dependencies.test_only,
613 dev_dependencies_in_production: dependencies.dev_in_prod,
614 boundary_violations: graph.boundary_violations,
615 boundary_coverage_violations: graph.boundary_coverage_violations,
616 boundary_call_violations: graph.boundary_call_violations,
617 policy_violations: graph.policy_violations,
618 stale_suppressions: member_imports.stale_suppressions,
619 unused_catalog_entries: catalog.unused_catalog_entries,
620 empty_catalog_groups: catalog.empty_catalog_groups,
621 unresolved_catalog_references: catalog.unresolved_catalog_references,
622 unused_dependency_overrides: catalog.unused_dependency_overrides,
623 misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
624 invalid_client_exports: file_exports.invalid_client_exports,
625 mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
626 misplaced_directives: file_exports.misplaced_directives,
627 route_collisions: file_exports.route_collisions,
628 dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
629 }
630 }
631
632 #[must_use]
634 pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
635 &self.analysis_identity
636 }
637
638 pub fn total_entries(&self) -> usize {
640 self.unused_files.len()
641 + self.unused_exports.len()
642 + self.unused_types.len()
643 + self.private_type_leaks.len()
644 + self.deprecated_exports_in_use.len()
645 + self.unused_dependencies.len()
646 + self.unused_dev_dependencies.len()
647 + self.circular_dependencies.len()
648 + self.re_export_cycles.len()
649 + self.unused_optional_dependencies.len()
650 + self.unused_enum_members.len()
651 + self.unused_class_members.len()
652 + self.unused_store_members.len()
653 + self.unprovided_injects.len()
654 + self.unrendered_components.len()
655 + self.unused_component_props.len()
656 + self.unused_component_emits.len()
657 + self.unused_component_inputs.len()
658 + self.unused_component_outputs.len()
659 + self.unused_svelte_events.len()
660 + self.unused_server_actions.len()
661 + self.unused_load_data_keys.len()
662 + self.unresolved_imports.len()
663 + self.unlisted_dependencies.len()
664 + self.duplicate_exports.len()
665 + self.type_only_dependencies.len()
666 + self.test_only_dependencies.len()
667 + self.dev_dependencies_in_production.len()
668 + self.boundary_violations.len()
669 + self.boundary_coverage_violations.len()
670 + self.boundary_call_violations.len()
671 + self.policy_violations.len()
672 + self.stale_suppressions.len()
673 + self.unused_catalog_entries.len()
674 + self.empty_catalog_groups.len()
675 + self.unresolved_catalog_references.len()
676 + self.unused_dependency_overrides.len()
677 + self.misconfigured_dependency_overrides.len()
678 + self.invalid_client_exports.len()
679 + self.mixed_client_server_barrels.len()
680 + self.misplaced_directives.len()
681 + self.route_collisions.len()
682 + self.dynamic_segment_name_conflicts.len()
683 }
684}
685
686struct BaselineFileExportKeys {
687 unused_files: Vec<String>,
688 unused_exports: Vec<String>,
689 unused_types: Vec<String>,
690 private_type_leaks: Vec<String>,
691 deprecated_exports_in_use: Vec<String>,
692 invalid_client_exports: Vec<String>,
693 mixed_client_server_barrels: Vec<String>,
694 misplaced_directives: Vec<String>,
695 route_collisions: Vec<String>,
696 dynamic_segment_name_conflicts: Vec<String>,
697}
698
699fn baseline_file_export_keys(
700 results: &crate::results::AnalysisResults,
701 root: &Path,
702) -> BaselineFileExportKeys {
703 BaselineFileExportKeys {
704 unused_files: results
705 .unused_files
706 .iter()
707 .map(|f| relative_path(&f.file.path, root))
708 .collect(),
709 unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
710 unused_types: unused_type_baseline_keys(&results.unused_types, root),
711 private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
712 deprecated_exports_in_use: results
713 .deprecated_exports_in_use
714 .iter()
715 .map(|e| deprecated_export_key(&e.export, root))
716 .collect(),
717 invalid_client_exports: invalid_client_export_baseline_keys(
718 &results.invalid_client_exports,
719 root,
720 ),
721 mixed_client_server_barrels: barrel_baseline_keys(
722 &results.mixed_client_server_barrels,
723 root,
724 ),
725 misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
726 route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
727 dynamic_segment_name_conflicts: results
728 .dynamic_segment_name_conflicts
729 .iter()
730 .map(|c| {
731 format!(
732 "{}:{}",
733 relative_path(&c.conflict.path, root),
734 c.conflict.position
735 )
736 })
737 .collect(),
738 }
739}
740
741fn unused_export_baseline_keys(
742 items: &[crate::results::UnusedExportFinding],
743 root: &Path,
744) -> Vec<String> {
745 items
746 .iter()
747 .map(|e| {
748 format!(
749 "{}:{}",
750 relative_path(&e.export.path, root),
751 e.export.export_name
752 )
753 })
754 .collect()
755}
756
757fn unused_type_baseline_keys(
758 items: &[crate::results::UnusedTypeFinding],
759 root: &Path,
760) -> Vec<String> {
761 items
762 .iter()
763 .map(|e| {
764 format!(
765 "{}:{}",
766 relative_path(&e.export.path, root),
767 e.export.export_name
768 )
769 })
770 .collect()
771}
772
773fn invalid_client_export_baseline_keys(
774 items: &[crate::results::InvalidClientExportFinding],
775 root: &Path,
776) -> Vec<String> {
777 items
778 .iter()
779 .map(|e| {
780 format!(
781 "{}:{}",
782 relative_path(&e.export.path, root),
783 e.export.export_name
784 )
785 })
786 .collect()
787}
788
789fn private_type_leak_baseline_keys(
790 items: &[crate::results::PrivateTypeLeakFinding],
791 root: &Path,
792) -> Vec<String> {
793 items
794 .iter()
795 .map(|e| {
796 format!(
797 "{}:{}->{}",
798 relative_path(&e.leak.path, root),
799 e.leak.export_name,
800 e.leak.type_name
801 )
802 })
803 .collect()
804}
805
806fn barrel_baseline_keys(
807 items: &[crate::results::MixedClientServerBarrelFinding],
808 root: &Path,
809) -> Vec<String> {
810 items
811 .iter()
812 .map(|b| {
813 format!(
814 "{}:{}:{}",
815 relative_path(&b.barrel.path, root),
816 b.barrel.client_origin,
817 b.barrel.server_origin
818 )
819 })
820 .collect()
821}
822
823fn directive_baseline_keys(
824 items: &[crate::results::MisplacedDirectiveFinding],
825 root: &Path,
826) -> Vec<String> {
827 items
828 .iter()
829 .map(|d| {
830 format!(
831 "{}:{}:{}",
832 relative_path(&d.directive_site.path, root),
833 d.directive_site.line,
834 d.directive_site.directive
835 )
836 })
837 .collect()
838}
839
840fn route_collision_baseline_keys(
841 items: &[crate::results::RouteCollisionFinding],
842 root: &Path,
843) -> Vec<String> {
844 items
845 .iter()
846 .map(|c| {
847 format!(
848 "{}:{}",
849 relative_path(&c.collision.path, root),
850 c.collision.url
851 )
852 })
853 .collect()
854}
855
856struct BaselineMemberImportKeys {
857 unused_enum_members: Vec<String>,
858 unused_class_members: Vec<String>,
859 unused_store_members: Vec<String>,
860 unprovided_injects: Vec<String>,
861 unrendered_components: Vec<String>,
862 unused_component_props: Vec<String>,
863 unused_component_emits: Vec<String>,
864 unused_component_inputs: Vec<String>,
865 unused_component_outputs: Vec<String>,
866 unused_svelte_events: Vec<String>,
867 unused_server_actions: Vec<String>,
868 unused_load_data_keys: Vec<String>,
869 unresolved_imports: Vec<String>,
870 duplicate_exports: Vec<String>,
871 stale_suppressions: Vec<String>,
872}
873
874fn baseline_member_import_keys(
875 results: &crate::results::AnalysisResults,
876 root: &Path,
877) -> BaselineMemberImportKeys {
878 BaselineMemberImportKeys {
879 unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
880 unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
881 unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
882 unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
883 unrendered_components: component_baseline_keys(&results.unrendered_components, root),
884 unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
885 unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
886 unused_component_inputs: component_input_baseline_keys(
887 &results.unused_component_inputs,
888 root,
889 ),
890 unused_component_outputs: component_output_baseline_keys(
891 &results.unused_component_outputs,
892 root,
893 ),
894 unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
895 unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
896 unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
897 unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
898 duplicate_exports: results
899 .duplicate_exports
900 .iter()
901 .map(|d| duplicate_export_key(&d.export, root))
902 .collect(),
903 stale_suppressions: results
904 .stale_suppressions
905 .iter()
906 .map(|s| stale_suppression_baseline_key(s, root))
907 .collect(),
908 }
909}
910
911fn stale_suppression_baseline_key(
912 suppression: &crate::results::StaleSuppression,
913 root: &Path,
914) -> String {
915 let rule_id = if suppression.missing_reason {
916 "missing-suppression-reason"
917 } else {
918 "stale-suppression"
919 };
920 format!(
921 "{rule_id}:{}:{}",
922 relative_path(&suppression.path, root),
923 suppression.line
924 )
925}
926
927fn enum_member_baseline_keys(
928 items: &[crate::results::UnusedEnumMemberFinding],
929 root: &Path,
930) -> Vec<String> {
931 items
932 .iter()
933 .map(|m| unused_member_baseline_key(&m.member, root))
934 .collect()
935}
936
937fn class_member_baseline_keys(
938 items: &[crate::results::UnusedClassMemberFinding],
939 root: &Path,
940) -> Vec<String> {
941 items
942 .iter()
943 .map(|m| unused_member_baseline_key(&m.member, root))
944 .collect()
945}
946
947fn store_member_baseline_keys(
948 items: &[crate::results::UnusedStoreMemberFinding],
949 root: &Path,
950) -> Vec<String> {
951 items
952 .iter()
953 .map(|m| unused_member_baseline_key(&m.member, root))
954 .collect()
955}
956
957fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
958 format!(
959 "{}:{}.{}",
960 relative_path(&member.path, root),
961 member.parent_name,
962 member.member_name
963 )
964}
965
966fn inject_baseline_keys(
967 items: &[crate::results::UnprovidedInjectFinding],
968 root: &Path,
969) -> Vec<String> {
970 items
971 .iter()
972 .map(|f| {
973 format!(
974 "{}:{}",
975 relative_path(&f.inject.path, root),
976 f.inject.key_name
977 )
978 })
979 .collect()
980}
981
982fn component_baseline_keys(
983 items: &[crate::results::UnrenderedComponentFinding],
984 root: &Path,
985) -> Vec<String> {
986 items
987 .iter()
988 .map(|c| {
989 format!(
990 "{}:{}",
991 relative_path(&c.component.path, root),
992 c.component.component_name
993 )
994 })
995 .collect()
996}
997
998fn component_prop_baseline_keys(
999 items: &[crate::results::UnusedComponentPropFinding],
1000 root: &Path,
1001) -> Vec<String> {
1002 items
1003 .iter()
1004 .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
1005 .collect()
1006}
1007
1008fn component_emit_baseline_keys(
1009 items: &[crate::results::UnusedComponentEmitFinding],
1010 root: &Path,
1011) -> Vec<String> {
1012 items
1013 .iter()
1014 .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
1015 .collect()
1016}
1017
1018fn component_input_baseline_keys(
1019 items: &[crate::results::UnusedComponentInputFinding],
1020 root: &Path,
1021) -> Vec<String> {
1022 items
1023 .iter()
1024 .map(|i| {
1025 format!(
1026 "{}:{}",
1027 relative_path(&i.input.path, root),
1028 i.input.input_name
1029 )
1030 })
1031 .collect()
1032}
1033
1034fn component_output_baseline_keys(
1035 items: &[crate::results::UnusedComponentOutputFinding],
1036 root: &Path,
1037) -> Vec<String> {
1038 items
1039 .iter()
1040 .map(|o| {
1041 format!(
1042 "{}:{}",
1043 relative_path(&o.output.path, root),
1044 o.output.output_name
1045 )
1046 })
1047 .collect()
1048}
1049
1050fn svelte_event_baseline_keys(
1051 items: &[crate::results::UnusedSvelteEventFinding],
1052 root: &Path,
1053) -> Vec<String> {
1054 items
1055 .iter()
1056 .map(|e| {
1057 format!(
1058 "{}:{}",
1059 relative_path(&e.event.path, root),
1060 e.event.event_name
1061 )
1062 })
1063 .collect()
1064}
1065
1066fn server_action_baseline_keys(
1067 items: &[crate::results::UnusedServerActionFinding],
1068 root: &Path,
1069) -> Vec<String> {
1070 items
1071 .iter()
1072 .map(|a| {
1073 format!(
1074 "{}:{}",
1075 relative_path(&a.action.path, root),
1076 a.action.action_name
1077 )
1078 })
1079 .collect()
1080}
1081
1082fn load_data_key_baseline_keys(
1083 items: &[crate::results::UnusedLoadDataKeyFinding],
1084 root: &Path,
1085) -> Vec<String> {
1086 items
1087 .iter()
1088 .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
1089 .collect()
1090}
1091
1092fn unresolved_import_baseline_keys(
1093 items: &[crate::results::UnresolvedImportFinding],
1094 root: &Path,
1095) -> Vec<String> {
1096 items
1097 .iter()
1098 .map(|i| {
1099 format!(
1100 "{}:{}",
1101 relative_path(&i.import.path, root),
1102 i.import.specifier
1103 )
1104 })
1105 .collect()
1106}
1107
1108struct BaselineDependencyKeys {
1109 unused: Vec<String>,
1110 unused_dev: Vec<String>,
1111 unused_optional: Vec<String>,
1112 unlisted: Vec<String>,
1113 type_only: Vec<String>,
1114 test_only: Vec<String>,
1115 dev_in_prod: Vec<String>,
1116}
1117
1118fn baseline_dependency_keys(
1119 results: &crate::results::AnalysisResults,
1120 root: &Path,
1121) -> BaselineDependencyKeys {
1122 BaselineDependencyKeys {
1123 unused: results
1124 .unused_dependencies
1125 .iter()
1126 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1127 .collect(),
1128 unused_dev: results
1129 .unused_dev_dependencies
1130 .iter()
1131 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1132 .collect(),
1133 unused_optional: results
1134 .unused_optional_dependencies
1135 .iter()
1136 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1137 .collect(),
1138 unlisted: results
1139 .unlisted_dependencies
1140 .iter()
1141 .map(|d| d.dep.package_name.clone())
1142 .collect(),
1143 type_only: results
1144 .type_only_dependencies
1145 .iter()
1146 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1147 .collect(),
1148 test_only: results
1149 .test_only_dependencies
1150 .iter()
1151 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1152 .collect(),
1153 dev_in_prod: results
1154 .dev_dependencies_in_production
1155 .iter()
1156 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1157 .collect(),
1158 }
1159}
1160
1161struct BaselineGraphKeys {
1162 circular_dependencies: Vec<String>,
1163 re_export_cycles: Vec<String>,
1164 boundary_violations: Vec<String>,
1165 boundary_coverage_violations: Vec<String>,
1166 boundary_call_violations: Vec<String>,
1167 policy_violations: Vec<String>,
1168}
1169
1170fn baseline_graph_keys(
1171 results: &crate::results::AnalysisResults,
1172 root: &Path,
1173) -> BaselineGraphKeys {
1174 BaselineGraphKeys {
1175 circular_dependencies: results
1176 .circular_dependencies
1177 .iter()
1178 .map(|c| circular_dep_key(&c.cycle, root))
1179 .collect(),
1180 re_export_cycles: results
1181 .re_export_cycles
1182 .iter()
1183 .map(|c| re_export_cycle_key(&c.cycle, root))
1184 .collect(),
1185 boundary_violations: results
1186 .boundary_violations
1187 .iter()
1188 .map(|v| boundary_violation_key(&v.violation, root))
1189 .collect(),
1190 boundary_coverage_violations: results
1191 .boundary_coverage_violations
1192 .iter()
1193 .map(|v| relative_path(&v.violation.path, root))
1194 .collect(),
1195 boundary_call_violations: results
1196 .boundary_call_violations
1197 .iter()
1198 .map(|v| boundary_call_violation_key(&v.violation, root))
1199 .collect(),
1200 policy_violations: results
1201 .policy_violations
1202 .iter()
1203 .map(|v| policy_violation_key(&v.violation, root))
1204 .collect(),
1205 }
1206}
1207
1208struct BaselineCatalogKeys {
1209 unused_catalog_entries: Vec<String>,
1210 empty_catalog_groups: Vec<String>,
1211 unresolved_catalog_references: Vec<String>,
1212 unused_dependency_overrides: Vec<String>,
1213 misconfigured_dependency_overrides: Vec<String>,
1214}
1215
1216fn baseline_catalog_keys(
1217 results: &crate::results::AnalysisResults,
1218 root: &Path,
1219) -> BaselineCatalogKeys {
1220 BaselineCatalogKeys {
1221 unused_catalog_entries: results
1222 .unused_catalog_entries
1223 .iter()
1224 .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
1225 .collect(),
1226 empty_catalog_groups: results
1227 .empty_catalog_groups
1228 .iter()
1229 .map(|g| g.group.catalog_name.clone())
1230 .collect(),
1231 unresolved_catalog_references: results
1232 .unresolved_catalog_references
1233 .iter()
1234 .map(|r| {
1235 format!(
1236 "{}:{}:{}:{}",
1237 relative_path(&r.reference.path, root),
1238 r.reference.line,
1239 r.reference.catalog_name,
1240 r.reference.entry_name,
1241 )
1242 })
1243 .collect(),
1244 unused_dependency_overrides: results
1245 .unused_dependency_overrides
1246 .iter()
1247 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1248 .collect(),
1249 misconfigured_dependency_overrides: results
1250 .misconfigured_dependency_overrides
1251 .iter()
1252 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1253 .collect(),
1254 }
1255}
1256
1257fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
1259 format!(
1260 "{}->{}",
1261 relative_path(&v.from_path, root),
1262 relative_path(&v.to_path, root),
1263 )
1264}
1265
1266fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
1268 format!("{}:{}", relative_path(&v.path, root), v.callee)
1269}
1270
1271fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
1275 format!(
1276 "{}:{}/{}:{}",
1277 relative_path(&v.path, root),
1278 v.pack,
1279 v.rule_id,
1280 v.matched
1281 )
1282}
1283
1284fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
1286 let mut locs: Vec<String> = dup
1287 .locations
1288 .iter()
1289 .map(|l| relative_path(&l.path, root))
1290 .collect();
1291 locs.sort();
1292 format!("{}|{}", dup.export_name, locs.join("|"))
1293}
1294
1295fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
1297 let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
1298 paths.sort();
1299 paths.join("->")
1300}
1301
1302fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
1308 let kind = match cycle.kind {
1309 crate::results::ReExportCycleKind::MultiNode => "multi-node",
1310 crate::results::ReExportCycleKind::SelfLoop => "self-loop",
1311 };
1312 let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
1313 paths.sort();
1314 format!("{kind}:{}", paths.join("<->"))
1315}
1316
1317fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
1318 format!(
1319 "{}:{}->{}",
1320 relative_path(&leak.path, root),
1321 leak.export_name,
1322 leak.type_name
1323 )
1324}
1325
1326fn deprecated_export_key(
1327 export: &fallow_types::results::DeprecatedExportInUse,
1328 root: &Path,
1329) -> String {
1330 format!(
1331 "{}:{}",
1332 relative_path(&export.path, root),
1333 export.export_name
1334 )
1335}
1336
1337fn filter_private_type_leaks(
1338 leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
1339 baseline_keys: &[String],
1340 root: &Path,
1341) {
1342 let baseline_private_type_leaks: FxHashSet<&str> =
1343 baseline_keys.iter().map(String::as_str).collect();
1344 leaks.retain(|entry| {
1345 let key = private_type_leak_key(&entry.leak, root);
1346 !baseline_private_type_leaks.contains(key.as_str())
1347 });
1348}
1349
1350struct BaselineFilterContext<'a> {
1351 baseline: &'a BaselineData,
1352 root: &'a Path,
1353}
1354
1355impl BaselineFilterContext<'_> {
1356 fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
1357 let baseline_circular: FxHashSet<&str> = self
1358 .baseline
1359 .circular_dependencies
1360 .iter()
1361 .map(String::as_str)
1362 .collect();
1363 results.circular_dependencies.retain(|cycle| {
1364 let key = circular_dep_key(&cycle.cycle, self.root);
1365 !baseline_circular.contains(key.as_str())
1366 });
1367
1368 let baseline_re_export_cycles: FxHashSet<&str> = self
1369 .baseline
1370 .re_export_cycles
1371 .iter()
1372 .map(String::as_str)
1373 .collect();
1374 results.re_export_cycles.retain(|cycle| {
1375 let key = re_export_cycle_key(&cycle.cycle, self.root);
1376 !baseline_re_export_cycles.contains(key.as_str())
1377 });
1378
1379 self.filter_unused_members(results);
1380 self.filter_unresolved_and_exports(results);
1381 }
1382
1383 fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
1384 self.filter_enum_class_store_members(results);
1385 self.filter_component_surface_members(results);
1386 self.filter_route_action_members(results);
1387 }
1388
1389 fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1390 let baseline_enum_members: FxHashSet<&str> = self
1391 .baseline
1392 .unused_enum_members
1393 .iter()
1394 .map(String::as_str)
1395 .collect();
1396 results.unused_enum_members.retain(|member| {
1397 let key = format!(
1398 "{}:{}.{}",
1399 relative_path(&member.member.path, self.root),
1400 member.member.parent_name,
1401 member.member.member_name
1402 );
1403 !baseline_enum_members.contains(key.as_str())
1404 });
1405
1406 let baseline_class_members: FxHashSet<&str> = self
1407 .baseline
1408 .unused_class_members
1409 .iter()
1410 .map(String::as_str)
1411 .collect();
1412 results.unused_class_members.retain(|member| {
1413 let key = format!(
1414 "{}:{}.{}",
1415 relative_path(&member.member.path, self.root),
1416 member.member.parent_name,
1417 member.member.member_name
1418 );
1419 !baseline_class_members.contains(key.as_str())
1420 });
1421
1422 let baseline_store_members: FxHashSet<&str> = self
1423 .baseline
1424 .unused_store_members
1425 .iter()
1426 .map(String::as_str)
1427 .collect();
1428 results.unused_store_members.retain(|member| {
1429 let key = format!(
1430 "{}:{}.{}",
1431 relative_path(&member.member.path, self.root),
1432 member.member.parent_name,
1433 member.member.member_name
1434 );
1435 !baseline_store_members.contains(key.as_str())
1436 });
1437 }
1438
1439 fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1440 retain_new_by_keys(
1441 &mut results.unprovided_injects,
1442 &self.baseline.unprovided_injects,
1443 self.root,
1444 inject_baseline_keys,
1445 );
1446 retain_new_by_keys(
1447 &mut results.unrendered_components,
1448 &self.baseline.unrendered_components,
1449 self.root,
1450 component_baseline_keys,
1451 );
1452 retain_new_by_keys(
1453 &mut results.unused_component_props,
1454 &self.baseline.unused_component_props,
1455 self.root,
1456 component_prop_baseline_keys,
1457 );
1458 retain_new_by_keys(
1459 &mut results.unused_component_emits,
1460 &self.baseline.unused_component_emits,
1461 self.root,
1462 component_emit_baseline_keys,
1463 );
1464 retain_new_by_keys(
1465 &mut results.unused_component_inputs,
1466 &self.baseline.unused_component_inputs,
1467 self.root,
1468 component_input_baseline_keys,
1469 );
1470 retain_new_by_keys(
1471 &mut results.unused_component_outputs,
1472 &self.baseline.unused_component_outputs,
1473 self.root,
1474 component_output_baseline_keys,
1475 );
1476 retain_new_by_keys(
1477 &mut results.unused_svelte_events,
1478 &self.baseline.unused_svelte_events,
1479 self.root,
1480 svelte_event_baseline_keys,
1481 );
1482 }
1483
1484 fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1485 let baseline_unused_server_actions: FxHashSet<&str> = self
1486 .baseline
1487 .unused_server_actions
1488 .iter()
1489 .map(String::as_str)
1490 .collect();
1491 results.unused_server_actions.retain(|finding| {
1492 let key = format!(
1493 "{}:{}",
1494 relative_path(&finding.action.path, self.root),
1495 finding.action.action_name
1496 );
1497 !baseline_unused_server_actions.contains(key.as_str())
1498 });
1499
1500 let baseline_unused_load_data_keys: FxHashSet<&str> = self
1501 .baseline
1502 .unused_load_data_keys
1503 .iter()
1504 .map(String::as_str)
1505 .collect();
1506 results.unused_load_data_keys.retain(|finding| {
1507 let key = format!(
1508 "{}:{}",
1509 relative_path(&finding.key.path, self.root),
1510 finding.key.key_name
1511 );
1512 !baseline_unused_load_data_keys.contains(key.as_str())
1513 });
1514 }
1515
1516 fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1517 let baseline_unresolved: FxHashSet<&str> = self
1518 .baseline
1519 .unresolved_imports
1520 .iter()
1521 .map(String::as_str)
1522 .collect();
1523 results.unresolved_imports.retain(|import| {
1524 let key = format!(
1525 "{}:{}",
1526 relative_path(&import.import.path, self.root),
1527 import.import.specifier
1528 );
1529 !baseline_unresolved.contains(key.as_str())
1530 });
1531
1532 let baseline_unlisted: FxHashSet<&str> = self
1533 .baseline
1534 .unlisted_dependencies
1535 .iter()
1536 .map(String::as_str)
1537 .collect();
1538 results
1539 .unlisted_dependencies
1540 .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1541
1542 let baseline_dup_exports: FxHashSet<&str> = self
1543 .baseline
1544 .duplicate_exports
1545 .iter()
1546 .map(String::as_str)
1547 .collect();
1548 results.duplicate_exports.retain(|duplicate| {
1549 let key = duplicate_export_key(&duplicate.export, self.root);
1550 !baseline_dup_exports.contains(key.as_str())
1551 });
1552 }
1553
1554 fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1555 let baseline_optional_deps: FxHashSet<&str> = self
1556 .baseline
1557 .unused_optional_dependencies
1558 .iter()
1559 .map(String::as_str)
1560 .collect();
1561 results.unused_optional_dependencies.retain(|dep| {
1562 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1563 !baseline_contains_dependency(
1564 &baseline_optional_deps,
1565 &dep.dep.package_name,
1566 key.as_str(),
1567 )
1568 });
1569
1570 self.filter_type_and_test_only_dependencies(results);
1571 }
1572
1573 fn filter_type_and_test_only_dependencies(
1574 &self,
1575 results: &mut crate::results::AnalysisResults,
1576 ) {
1577 let baseline_type_only: FxHashSet<&str> = self
1578 .baseline
1579 .type_only_dependencies
1580 .iter()
1581 .map(String::as_str)
1582 .collect();
1583 results.type_only_dependencies.retain(|dep| {
1584 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1585 !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1586 });
1587
1588 let baseline_test_only: FxHashSet<&str> = self
1589 .baseline
1590 .test_only_dependencies
1591 .iter()
1592 .map(String::as_str)
1593 .collect();
1594 results.test_only_dependencies.retain(|dep| {
1595 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1596 !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1597 });
1598
1599 let baseline_dev_in_prod: FxHashSet<&str> = self
1600 .baseline
1601 .dev_dependencies_in_production
1602 .iter()
1603 .map(String::as_str)
1604 .collect();
1605 results.dev_dependencies_in_production.retain(|dep| {
1606 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1607 !baseline_contains_dependency(
1608 &baseline_dev_in_prod,
1609 &dep.dep.package_name,
1610 key.as_str(),
1611 )
1612 });
1613 }
1614
1615 fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1616 let baseline_boundary: FxHashSet<&str> = self
1617 .baseline
1618 .boundary_violations
1619 .iter()
1620 .map(String::as_str)
1621 .collect();
1622 results.boundary_violations.retain(|violation| {
1623 let key = boundary_violation_key(&violation.violation, self.root);
1624 !baseline_boundary.contains(key.as_str())
1625 });
1626
1627 self.filter_boundary_details(results);
1628 self.filter_stale_suppressions(results);
1629 self.filter_invalid_client_exports(results);
1630 self.filter_mixed_client_server_barrels(results);
1631 self.filter_misplaced_directives(results);
1632 self.filter_route_collisions(results);
1633 self.filter_dynamic_segment_name_conflicts(results);
1634 }
1635
1636 fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1637 let baseline_invalid: FxHashSet<&str> = self
1638 .baseline
1639 .invalid_client_exports
1640 .iter()
1641 .map(String::as_str)
1642 .collect();
1643 results.invalid_client_exports.retain(|finding| {
1644 let key = format!(
1645 "{}:{}",
1646 relative_path(&finding.export.path, self.root),
1647 finding.export.export_name
1648 );
1649 !baseline_invalid.contains(key.as_str())
1650 });
1651 }
1652
1653 fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1654 let baseline_barrels: FxHashSet<&str> = self
1655 .baseline
1656 .mixed_client_server_barrels
1657 .iter()
1658 .map(String::as_str)
1659 .collect();
1660 results.mixed_client_server_barrels.retain(|finding| {
1661 let key = format!(
1662 "{}:{}:{}",
1663 relative_path(&finding.barrel.path, self.root),
1664 finding.barrel.client_origin,
1665 finding.barrel.server_origin
1666 );
1667 !baseline_barrels.contains(key.as_str())
1668 });
1669 }
1670
1671 fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1672 let baseline_directives: FxHashSet<&str> = self
1673 .baseline
1674 .misplaced_directives
1675 .iter()
1676 .map(String::as_str)
1677 .collect();
1678 results.misplaced_directives.retain(|finding| {
1679 let key = format!(
1680 "{}:{}:{}",
1681 relative_path(&finding.directive_site.path, self.root),
1682 finding.directive_site.line,
1683 finding.directive_site.directive
1684 );
1685 !baseline_directives.contains(key.as_str())
1686 });
1687 }
1688
1689 fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1690 let baseline_collisions: FxHashSet<&str> = self
1691 .baseline
1692 .route_collisions
1693 .iter()
1694 .map(String::as_str)
1695 .collect();
1696 results.route_collisions.retain(|finding| {
1697 let key = format!(
1698 "{}:{}",
1699 relative_path(&finding.collision.path, self.root),
1700 finding.collision.url
1701 );
1702 !baseline_collisions.contains(key.as_str())
1703 });
1704 }
1705
1706 fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1707 let baseline_conflicts: FxHashSet<&str> = self
1708 .baseline
1709 .dynamic_segment_name_conflicts
1710 .iter()
1711 .map(String::as_str)
1712 .collect();
1713 results.dynamic_segment_name_conflicts.retain(|finding| {
1714 let key = format!(
1715 "{}:{}",
1716 relative_path(&finding.conflict.path, self.root),
1717 finding.conflict.position
1718 );
1719 !baseline_conflicts.contains(key.as_str())
1720 });
1721 }
1722
1723 fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1724 let baseline_boundary_coverage: FxHashSet<&str> = self
1725 .baseline
1726 .boundary_coverage_violations
1727 .iter()
1728 .map(String::as_str)
1729 .collect();
1730 results.boundary_coverage_violations.retain(|violation| {
1731 let key = relative_path(&violation.violation.path, self.root);
1732 !baseline_boundary_coverage.contains(key.as_str())
1733 });
1734
1735 let baseline_boundary_calls: FxHashSet<&str> = self
1736 .baseline
1737 .boundary_call_violations
1738 .iter()
1739 .map(String::as_str)
1740 .collect();
1741 results.boundary_call_violations.retain(|violation| {
1742 let key = boundary_call_violation_key(&violation.violation, self.root);
1743 !baseline_boundary_calls.contains(key.as_str())
1744 });
1745 }
1746
1747 fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1748 let baseline_stale: FxHashSet<&str> = self
1749 .baseline
1750 .stale_suppressions
1751 .iter()
1752 .map(String::as_str)
1753 .collect();
1754 results.stale_suppressions.retain(|suppression| {
1755 let key = stale_suppression_baseline_key(suppression, self.root);
1756 let legacy_key = format!(
1757 "{}:{}",
1758 relative_path(&suppression.path, self.root),
1759 suppression.line
1760 );
1761 !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
1762 });
1763 }
1764
1765 fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
1766 let baseline_catalog: FxHashSet<&str> = self
1767 .baseline
1768 .unused_catalog_entries
1769 .iter()
1770 .map(String::as_str)
1771 .collect();
1772 results.unused_catalog_entries.retain(|entry| {
1773 let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
1774 !baseline_catalog.contains(key.as_str())
1775 });
1776
1777 let baseline_empty_catalog_groups: FxHashSet<&str> = self
1778 .baseline
1779 .empty_catalog_groups
1780 .iter()
1781 .map(String::as_str)
1782 .collect();
1783 results.empty_catalog_groups.retain(|group| {
1784 !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
1785 });
1786
1787 self.filter_pnpm_references_and_overrides(results);
1788 }
1789
1790 fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
1791 let baseline_unresolved: FxHashSet<&str> = self
1792 .baseline
1793 .unresolved_catalog_references
1794 .iter()
1795 .map(String::as_str)
1796 .collect();
1797 results.unresolved_catalog_references.retain(|reference| {
1798 let key = format!(
1799 "{}:{}:{}:{}",
1800 relative_path(&reference.reference.path, self.root),
1801 reference.reference.line,
1802 reference.reference.catalog_name,
1803 reference.reference.entry_name,
1804 );
1805 !baseline_unresolved.contains(key.as_str())
1806 });
1807
1808 self.filter_pnpm_overrides(results);
1809 }
1810
1811 fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
1812 let baseline_unused_overrides: FxHashSet<&str> = self
1813 .baseline
1814 .unused_dependency_overrides
1815 .iter()
1816 .map(String::as_str)
1817 .collect();
1818 results
1819 .unused_dependency_overrides
1820 .retain(|override_entry| {
1821 let key = format!(
1822 "{}:{}",
1823 override_entry.entry.source, override_entry.entry.raw_key
1824 );
1825 !baseline_unused_overrides.contains(key.as_str())
1826 });
1827
1828 let baseline_misconfigured_overrides: FxHashSet<&str> = self
1829 .baseline
1830 .misconfigured_dependency_overrides
1831 .iter()
1832 .map(String::as_str)
1833 .collect();
1834 results
1835 .misconfigured_dependency_overrides
1836 .retain(|override_entry| {
1837 let key = format!(
1838 "{}:{}",
1839 override_entry.entry.source, override_entry.entry.raw_key
1840 );
1841 !baseline_misconfigured_overrides.contains(key.as_str())
1842 });
1843 }
1844}
1845
1846pub fn filter_new_issues(
1848 mut results: crate::results::AnalysisResults,
1849 baseline: &BaselineData,
1850 root: &Path,
1851) -> crate::results::AnalysisResults {
1852 let baseline_files: FxHashSet<&str> =
1853 baseline.unused_files.iter().map(String::as_str).collect();
1854 let baseline_exports: FxHashSet<&str> =
1855 baseline.unused_exports.iter().map(String::as_str).collect();
1856 let baseline_types: FxHashSet<&str> =
1857 baseline.unused_types.iter().map(String::as_str).collect();
1858 let baseline_deps: FxHashSet<&str> = baseline
1859 .unused_dependencies
1860 .iter()
1861 .map(String::as_str)
1862 .collect();
1863 let baseline_dev_deps: FxHashSet<&str> = baseline
1864 .unused_dev_dependencies
1865 .iter()
1866 .map(String::as_str)
1867 .collect();
1868
1869 results
1870 .unused_files
1871 .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
1872 results.unused_exports.retain(|e| {
1873 let key = format!(
1874 "{}:{}",
1875 relative_path(&e.export.path, root),
1876 e.export.export_name
1877 );
1878 !baseline_exports.contains(key.as_str())
1879 });
1880 results.unused_types.retain(|e| {
1881 let key = format!(
1882 "{}:{}",
1883 relative_path(&e.export.path, root),
1884 e.export.export_name
1885 );
1886 !baseline_types.contains(key.as_str())
1887 });
1888 filter_private_type_leaks(
1889 &mut results.private_type_leaks,
1890 &baseline.private_type_leaks,
1891 root,
1892 );
1893 let baseline_deprecated: FxHashSet<&str> = baseline
1894 .deprecated_exports_in_use
1895 .iter()
1896 .map(String::as_str)
1897 .collect();
1898 results
1899 .deprecated_exports_in_use
1900 .retain(|e| !baseline_deprecated.contains(deprecated_export_key(&e.export, root).as_str()));
1901 results.unused_dependencies.retain(|d| {
1902 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1903 !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
1904 });
1905 results.unused_dev_dependencies.retain(|d| {
1906 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1907 !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
1908 });
1909
1910 let filter = BaselineFilterContext { baseline, root };
1911 filter.filter_cycles_and_members(&mut results);
1912 filter.filter_dependency_variants(&mut results);
1913 filter.filter_boundaries_and_suppressions(&mut results);
1914 filter.filter_pnpm_entries(&mut results);
1915
1916 results
1917}
1918
1919#[derive(Debug, Clone, PartialEq, Eq)]
1921pub enum DeadCodeBaselineOutcome {
1922 Applied(BaselineStaleness),
1924 NotDeadCode {
1928 staleness: BaselineStaleness,
1930 saved_by: Option<BaselineKind>,
1932 },
1933}
1934
1935#[derive(Debug, Clone, PartialEq, Eq)]
1937pub enum DeadCodeBaselineError {
1938 Parse(String),
1941 IncompatibleIdentity(Vec<&'static str>),
1944}
1945
1946pub fn apply_dead_code_baseline(
1963 results: &mut crate::results::AnalysisResults,
1964 content: &str,
1965 root: &Path,
1966 identity: &fallow_types::semantic::SemanticAnalysisIdentity,
1967 change_scoped: bool,
1968) -> Result<DeadCodeBaselineOutcome, DeadCodeBaselineError> {
1969 let parsed = serde_json::from_str::<serde_json::Value>(content)
1970 .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
1971 let saved_by = match classify_baseline_value(&parsed, BaselineKind::DeadCode) {
1972 BaselineFileKind::Own | BaselineFileKind::NotAnObject => None,
1973 foreign @ BaselineFileKind::Foreign(_) => Some(foreign.saved_by()),
1974 BaselineFileKind::Unrecognised => Some(None),
1975 };
1976 if let Some(saved_by) = saved_by {
1977 return Ok(DeadCodeBaselineOutcome::NotDeadCode {
1978 staleness: BaselineStaleness {
1979 entries: 0,
1980 matched: 0,
1981 current_findings: results.total_issues(),
1982 change_scoped,
1983 },
1984 saved_by,
1985 });
1986 }
1987 let baseline = serde_json::from_value::<BaselineData>(parsed)
1988 .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
1989 let incompatible = baseline.analysis_identity().incompatible_fields(identity);
1990 if !incompatible.is_empty() {
1991 return Err(DeadCodeBaselineError::IncompatibleIdentity(incompatible));
1992 }
1993 let before = results.total_issues();
1994 *results = filter_new_issues(std::mem::take(results), &baseline, root);
1995 Ok(DeadCodeBaselineOutcome::Applied(BaselineStaleness {
1996 entries: baseline.total_entries(),
1997 matched: before.saturating_sub(results.total_issues()),
1998 current_findings: before,
1999 change_scoped,
2000 }))
2001}
2002
2003#[derive(Default, serde::Serialize, serde::Deserialize)]
2014pub struct DuplicationBaselineData {
2015 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2018 kind: Option<BaselineKind>,
2019 #[serde(default)]
2021 pub clone_groups: Vec<String>,
2022 #[serde(default)]
2024 pub clone_fingerprints: Vec<String>,
2025 #[serde(default)]
2027 pub normalized_clone_fingerprints: Vec<String>,
2028}
2029
2030impl DuplicationBaselineData {
2031 pub const DECLARED_KEYS: &'static [&'static str] = &[
2033 "clone_groups",
2034 "clone_fingerprints",
2035 "normalized_clone_fingerprints",
2036 ];
2037
2038 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
2040 let fingerprints =
2041 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2042 Self {
2043 kind: Some(BaselineKind::Dupes),
2044 clone_groups: report
2045 .clone_groups
2046 .iter()
2047 .map(|g| clone_group_key(g, root))
2048 .collect(),
2049 clone_fingerprints: report
2050 .clone_groups
2051 .iter()
2052 .map(legacy_clone_group_fingerprint_key)
2053 .collect(),
2054 normalized_clone_fingerprints: report
2055 .clone_groups
2056 .iter()
2057 .map(|group| clone_group_fingerprint_key(group, &fingerprints))
2058 .collect(),
2059 }
2060 }
2061
2062 #[must_use]
2064 pub fn entry_count(&self) -> usize {
2065 if !self.normalized_clone_fingerprints.is_empty() {
2066 self.normalized_clone_fingerprints.len()
2067 } else if !self.clone_fingerprints.is_empty() {
2068 self.clone_fingerprints.len()
2069 } else {
2070 self.clone_groups.len()
2071 }
2072 }
2073}
2074
2075fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
2077 let mut parts: Vec<String> = group
2078 .instances
2079 .iter()
2080 .map(|i| {
2081 format!(
2082 "{}:{}-{}",
2083 relative_path(&i.file, root),
2084 i.start_line,
2085 i.end_line
2086 )
2087 })
2088 .collect();
2089 parts.sort();
2090 parts.join("|")
2091}
2092
2093fn clone_group_fingerprint_key(
2095 group: &crate::duplicates::CloneGroup,
2096 fingerprints: &crate::duplicates::CloneFingerprintSet,
2097) -> String {
2098 fingerprints.ignored_clone_key_for_group(group)
2099}
2100
2101fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
2104 let representative = group
2105 .instances
2106 .iter()
2107 .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
2108 .map_or("", |i| i.fragment.as_str());
2109 let hash = if representative.as_bytes().contains(&b'\r') {
2110 xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
2111 } else {
2112 xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
2113 };
2114 format!(
2115 "{}{:08x}:{}",
2116 crate::duplicates::FINGERPRINT_PREFIX,
2117 hash as u32,
2118 group.instances.len()
2119 )
2120}
2121
2122fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
2123 match remaining.get_mut(key) {
2124 Some(count) if *count > 0 => {
2125 *count -= 1;
2126 true
2127 }
2128 _ => false,
2129 }
2130}
2131
2132pub fn filter_new_clone_groups(
2138 mut report: DuplicationReport,
2139 baseline: &DuplicationBaselineData,
2140 root: &Path,
2141) -> DuplicationReport {
2142 if !baseline.normalized_clone_fingerprints.is_empty() {
2143 let fingerprints =
2144 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2145 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2146 for key in &baseline.normalized_clone_fingerprints {
2147 *remaining.entry(key.as_str()).or_insert(0) += 1;
2148 }
2149 report.clone_groups.retain(|group| {
2150 let key = clone_group_fingerprint_key(group, &fingerprints);
2151 !consume_baseline_key(&mut remaining, &key)
2152 });
2153 } else if baseline.clone_fingerprints.is_empty() {
2154 let baseline_keys: FxHashSet<&str> =
2155 baseline.clone_groups.iter().map(String::as_str).collect();
2156 report.clone_groups.retain(|g| {
2157 let key = clone_group_key(g, root);
2158 !baseline_keys.contains(key.as_str())
2159 });
2160 } else {
2161 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2162 for key in &baseline.clone_fingerprints {
2163 *remaining.entry(key.as_str()).or_insert(0) += 1;
2164 }
2165 report.clone_groups.retain(|group| {
2166 let key = legacy_clone_group_fingerprint_key(group);
2167 !consume_baseline_key(&mut remaining, &key)
2168 });
2169 }
2170
2171 crate::duplicates::refresh_clone_families(&mut report, root);
2172 report.stats = recompute_stats(&report);
2173
2174 report
2175}
2176
2177pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
2182 crate::duplicates::recompute_stats(report)
2183}
2184
2185#[derive(Default, serde::Serialize, serde::Deserialize)]
2199pub struct HealthBaselineData {
2200 #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2203 pub(crate) kind: Option<BaselineKind>,
2204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2206 pub(crate) findings: Vec<String>,
2207 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2209 pub(crate) finding_counts: HealthFindingCountMap,
2210 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2212 pub(crate) identity_finding_counts: HealthFindingCountMap,
2213 #[serde(default)]
2215 pub(crate) runtime_coverage_findings: Vec<String>,
2216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2223 pub(crate) runtime_coverage_source_hashes: Vec<String>,
2224 #[serde(default)]
2226 pub(crate) target_keys: Vec<String>,
2227}
2228
2229#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2231pub struct HealthBaselineCount {
2232 count: usize,
2233}
2234
2235type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
2236
2237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2239pub enum HealthBaselineMode {
2240 #[default]
2250 Count,
2251 Identity,
2260}
2261
2262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2263enum HealthFindingDimension {
2264 Complexity,
2265 Crap,
2266}
2267
2268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2269struct HealthFindingCategory {
2270 dimension: HealthFindingDimension,
2271 severity: fallow_output::FindingSeverity,
2272}
2273
2274impl HealthFindingCategory {
2275 const fn key(self) -> &'static str {
2276 match (self.dimension, self.severity) {
2277 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
2278 "complexity_moderate"
2279 }
2280 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
2281 "complexity_high"
2282 }
2283 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
2284 "complexity_critical"
2285 }
2286 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
2287 "crap_moderate"
2288 }
2289 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
2290 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
2291 "crap_critical"
2292 }
2293 }
2294 }
2295}
2296
2297const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
2298 HealthFindingDimension::Complexity,
2299 HealthFindingDimension::Crap,
2300];
2301
2302impl HealthBaselineData {
2303 pub const DECLARED_KEYS: &'static [&'static str] = &[
2307 "findings",
2308 "finding_counts",
2309 "identity_finding_counts",
2310 "runtime_coverage_findings",
2311 "runtime_coverage_source_hashes",
2312 "target_keys",
2313 ];
2314
2315 pub(crate) fn from_findings(
2317 findings: &[fallow_output::ComplexityViolation],
2318 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
2319 targets: &[fallow_output::RefactoringTarget],
2320 root: &Path,
2321 ) -> Self {
2322 Self {
2323 kind: Some(BaselineKind::Health),
2324 findings: Vec::new(),
2325 finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
2326 identity_finding_counts: HealthFindingCountMap::new(),
2327 runtime_coverage_findings: runtime_coverage_findings
2328 .iter()
2329 .map(|f| runtime_coverage_finding_key(f, root))
2330 .collect(),
2331 runtime_coverage_source_hashes: runtime_coverage_findings
2332 .iter()
2333 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
2334 .collect(),
2335 target_keys: targets
2336 .iter()
2337 .map(|t| target_baseline_key(t, root))
2338 .collect(),
2339 }
2340 }
2341
2342 pub(crate) fn finding_entry_count(&self) -> usize {
2343 if !self.finding_counts.is_empty() {
2344 self.finding_counts
2345 .values()
2346 .flat_map(BTreeMap::values)
2347 .map(|entry| entry.count)
2348 .sum()
2349 } else {
2350 self.findings.len()
2351 }
2352 }
2353
2354 #[must_use]
2359 pub(crate) fn with_identity(
2360 mut self,
2361 findings: &[fallow_output::ComplexityViolation],
2362 root: &Path,
2363 ) -> Self {
2364 self.identity_finding_counts =
2365 health_finding_counts(findings, root, HealthBaselineMode::Identity);
2366 self
2367 }
2368
2369 pub(crate) fn lacks_identity_data(&self) -> bool {
2372 self.identity_finding_counts.is_empty()
2373 && (!self.finding_counts.is_empty() || !self.findings.is_empty())
2374 }
2375
2376 fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
2377 match mode {
2378 HealthBaselineMode::Count => &self.finding_counts,
2379 HealthBaselineMode::Identity => &self.identity_finding_counts,
2380 }
2381 }
2382
2383 pub(crate) fn overlap_entries(
2384 &self,
2385 findings: &[fallow_output::ComplexityViolation],
2386 root: &Path,
2387 mode: HealthBaselineMode,
2388 ) -> HealthBaselineOverlap {
2389 let baseline_counts = self.counts_for(mode);
2390 if !baseline_counts.is_empty() {
2391 let current_counts = health_finding_counts(findings, root, mode);
2392 let direct = health_overlap_entry_count(¤t_counts, baseline_counts);
2393 let remapped = (mode == HealthBaselineMode::Identity)
2394 .then(|| {
2395 identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root)
2396 })
2397 .flatten();
2398 match remapped {
2399 Some(remapped_counts) => {
2400 let matched = health_overlap_entry_count(¤t_counts, &remapped_counts);
2401 HealthBaselineOverlap {
2402 matched_entries: matched,
2403 moved_entries: matched.saturating_sub(direct),
2404 }
2405 }
2406 None => HealthBaselineOverlap {
2407 matched_entries: direct,
2408 moved_entries: 0,
2409 },
2410 }
2411 } else {
2412 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
2413 HealthBaselineOverlap {
2414 matched_entries: findings
2415 .iter()
2416 .filter(|finding| {
2417 baseline_keys.contains(health_finding_key(finding, root).as_str())
2418 })
2419 .count(),
2420 moved_entries: 0,
2421 }
2422 }
2423 }
2424}
2425
2426pub(crate) struct HealthBaselineOverlap {
2429 pub(crate) matched_entries: usize,
2431 pub(crate) moved_entries: usize,
2434}
2435
2436fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
2438 format!(
2439 "{}:{}",
2440 relative_path(&target.path, root),
2441 target.category.label()
2442 )
2443}
2444
2445fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
2447 format!(
2448 "{}:{}:{}",
2449 relative_path(&finding.path, root),
2450 finding.name,
2451 finding.line
2452 )
2453}
2454
2455fn health_bucket_key(
2460 finding: &fallow_output::ComplexityViolation,
2461 root: &Path,
2462 mode: HealthBaselineMode,
2463) -> String {
2464 let path = relative_path(&finding.path, root);
2465 match mode {
2466 HealthBaselineMode::Count => path,
2467 HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
2468 }
2469}
2470
2471const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
2475
2476fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
2477 key.split_once('\0')
2478}
2479
2480fn moved_identity_bucket_remaps(
2498 baseline_counts: &HealthFindingCountMap,
2499 current_counts: &HealthFindingCountMap,
2500 root: &Path,
2501) -> Vec<(String, String)> {
2502 let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
2503 for key in current_counts.keys() {
2504 if baseline_counts.contains_key(key) {
2505 continue;
2506 }
2507 if let Some((_, name)) = identity_bucket_parts(key)
2508 && name != ANONYMOUS_FUNCTION_NAME
2509 {
2510 candidates_by_name.entry(name).or_default().push(key);
2511 }
2512 }
2513
2514 let mut proposals: Vec<(&str, &str)> = Vec::new();
2515 let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
2516 for key in baseline_counts.keys() {
2517 if current_counts.contains_key(key.as_str()) {
2518 continue;
2519 }
2520 let Some((path, name)) = identity_bucket_parts(key) else {
2521 continue;
2522 };
2523 if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
2524 continue;
2525 }
2526 if let Some(candidates) = candidates_by_name.get(name)
2527 && let [only_candidate] = candidates.as_slice()
2528 {
2529 proposals.push((key.as_str(), only_candidate));
2530 *claims.entry(only_candidate).or_default() += 1;
2531 }
2532 }
2533
2534 proposals
2535 .into_iter()
2536 .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
2537 .map(|(old, new)| (old.to_string(), new.to_string()))
2538 .collect()
2539}
2540
2541fn identity_counts_with_move_tolerance(
2546 baseline_counts: &HealthFindingCountMap,
2547 current_counts: &HealthFindingCountMap,
2548 root: &Path,
2549) -> Option<HealthFindingCountMap> {
2550 let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
2551 if remaps.is_empty() {
2552 return None;
2553 }
2554 let mut remapped = baseline_counts.clone();
2555 for (old_key, new_key) in remaps {
2556 if let Some(entry) = remapped.remove(&old_key) {
2557 remapped.insert(new_key, entry);
2558 }
2559 }
2560 Some(remapped)
2561}
2562
2563fn health_finding_counts(
2564 findings: &[fallow_output::ComplexityViolation],
2565 root: &Path,
2566 mode: HealthBaselineMode,
2567) -> HealthFindingCountMap {
2568 let mut counts = BTreeMap::new();
2569 for finding in findings {
2570 let bucket = health_bucket_key(finding, root, mode);
2571 let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
2572 for category in health_finding_categories(finding).into_iter().flatten() {
2573 file_counts
2574 .entry(category.key().to_string())
2575 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
2576 .or_insert(HealthBaselineCount { count: 1 });
2577 }
2578 }
2579 counts
2580}
2581
2582fn health_finding_categories(
2583 finding: &fallow_output::ComplexityViolation,
2584) -> [Option<HealthFindingCategory>; 2] {
2585 let complexity_category = HealthFindingCategory {
2586 dimension: HealthFindingDimension::Complexity,
2587 severity: finding.severity,
2588 };
2589 let crap_category = HealthFindingCategory {
2590 dimension: HealthFindingDimension::Crap,
2591 severity: finding.severity,
2592 };
2593 let has_complexity =
2594 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
2595 let has_crap = finding.exceeded.includes_crap();
2596 [
2597 has_complexity.then_some(complexity_category),
2598 has_crap.then_some(crap_category),
2599 ]
2600}
2601
2602fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
2603 match severity {
2604 fallow_output::FindingSeverity::Moderate => 0,
2605 fallow_output::FindingSeverity::High => 1,
2606 fallow_output::FindingSeverity::Critical => 2,
2607 }
2608}
2609
2610fn severity_counts_for_dimension(
2611 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
2612 dimension: HealthFindingDimension,
2613) -> [usize; 3] {
2614 let mut counts = [0; 3];
2615 for severity in [
2616 fallow_output::FindingSeverity::Moderate,
2617 fallow_output::FindingSeverity::High,
2618 fallow_output::FindingSeverity::Critical,
2619 ] {
2620 let category = HealthFindingCategory {
2621 dimension,
2622 severity,
2623 };
2624 counts[severity_index(severity)] = file_counts
2625 .and_then(|entries| entries.get(category.key()))
2626 .map_or(0, |entry| entry.count);
2627 }
2628 counts
2629}
2630
2631fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
2632 let mut available = baseline;
2633 let mut overflow = [false; 3];
2634
2635 for severity_idx in 0..3 {
2636 let compatible = available[severity_idx..].iter().sum::<usize>();
2637 overflow[severity_idx] = compatible < current[severity_idx];
2638
2639 let mut matched = current[severity_idx].min(compatible);
2640 for slot in available.iter_mut().skip(severity_idx) {
2641 let taken = matched.min(*slot);
2642 *slot -= taken;
2643 matched -= taken;
2644 if matched == 0 {
2645 break;
2646 }
2647 }
2648 }
2649
2650 overflow
2651}
2652
2653fn health_overflow_categories(
2654 current_counts: &HealthFindingCountMap,
2655 baseline_counts: &HealthFindingCountMap,
2656) -> FxHashMap<String, FxHashSet<&'static str>> {
2657 let mut overflow_by_path = FxHashMap::default();
2658
2659 for (path, current_file_counts) in current_counts {
2660 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
2661 let baseline_file_counts = baseline_counts.get(path);
2662
2663 for dimension in HEALTH_FINDING_DIMENSIONS {
2664 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
2665 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
2666 let overflow = overflowing_severities(current, baseline);
2667
2668 for severity in [
2669 fallow_output::FindingSeverity::Moderate,
2670 fallow_output::FindingSeverity::High,
2671 fallow_output::FindingSeverity::Critical,
2672 ] {
2673 if overflow[severity_index(severity)] {
2674 overflow_categories.insert(
2675 HealthFindingCategory {
2676 dimension,
2677 severity,
2678 }
2679 .key(),
2680 );
2681 }
2682 }
2683 }
2684
2685 if !overflow_categories.is_empty() {
2686 overflow_by_path.insert(path.clone(), overflow_categories);
2687 }
2688 }
2689
2690 overflow_by_path
2691}
2692
2693fn health_overlap_entry_count(
2694 current_counts: &HealthFindingCountMap,
2695 baseline_counts: &HealthFindingCountMap,
2696) -> usize {
2697 let mut overlap = 0;
2698
2699 for (path, baseline_file_counts) in baseline_counts {
2700 let current_file_counts = current_counts.get(path);
2701
2702 for dimension in HEALTH_FINDING_DIMENSIONS {
2703 let current_total: usize =
2704 severity_counts_for_dimension(current_file_counts, dimension)
2705 .into_iter()
2706 .sum();
2707 let baseline_total: usize =
2708 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
2709 .into_iter()
2710 .sum();
2711 overlap += current_total.min(baseline_total);
2712 }
2713 }
2714
2715 overlap
2716}
2717
2718fn runtime_coverage_finding_key(
2719 finding: &fallow_output::RuntimeCoverageFinding,
2720 _root: &Path,
2721) -> String {
2722 finding
2723 .stable_id
2724 .clone()
2725 .unwrap_or_else(|| finding.id.clone())
2726}
2727
2728fn runtime_coverage_source_hash_key(
2735 finding: &fallow_output::RuntimeCoverageFinding,
2736 root: &Path,
2737) -> Option<String> {
2738 finding.source_hash.as_deref().map(|hash| {
2739 format!(
2740 "{}\0{}\0{}",
2741 relative_path(&finding.path, root),
2742 finding.function,
2743 hash
2744 )
2745 })
2746}
2747
2748pub(crate) fn filter_new_health_findings(
2750 mut findings: Vec<fallow_output::ComplexityViolation>,
2751 baseline: &HealthBaselineData,
2752 root: &Path,
2753 mode: HealthBaselineMode,
2754) -> Vec<fallow_output::ComplexityViolation> {
2755 let baseline_counts = baseline.counts_for(mode);
2756 if !baseline_counts.is_empty() {
2757 let current_counts = health_finding_counts(&findings, root, mode);
2758 let remapped = (mode == HealthBaselineMode::Identity)
2759 .then(|| identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root))
2760 .flatten();
2761 let overflow_categories = health_overflow_categories(
2762 ¤t_counts,
2763 remapped.as_ref().unwrap_or(baseline_counts),
2764 );
2765 findings.retain(|finding| {
2766 let bucket = health_bucket_key(finding, root, mode);
2767 overflow_categories.get(&bucket).is_some_and(|categories| {
2768 health_finding_categories(finding)
2769 .into_iter()
2770 .flatten()
2771 .any(|category| categories.contains(category.key()))
2772 })
2773 });
2774 return findings;
2775 }
2776
2777 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
2778 findings.retain(|f| {
2779 let key = health_finding_key(f, root);
2780 !baseline_keys.contains(key.as_str())
2781 });
2782 findings
2783}
2784
2785pub(crate) fn filter_new_runtime_coverage_findings(
2786 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
2787 baseline: &HealthBaselineData,
2788 root: &Path,
2789) -> Vec<fallow_output::RuntimeCoverageFinding> {
2790 let baseline_keys: FxHashSet<&str> = baseline
2791 .runtime_coverage_findings
2792 .iter()
2793 .map(String::as_str)
2794 .collect();
2795 let baseline_source_hash_keys: FxHashSet<&str> = baseline
2796 .runtime_coverage_source_hashes
2797 .iter()
2798 .map(String::as_str)
2799 .collect();
2800 findings.retain(|finding| {
2801 let suppressed_by_stable_id = finding
2802 .stable_id
2803 .as_deref()
2804 .is_some_and(|id| baseline_keys.contains(id));
2805 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
2806 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
2807 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
2808 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
2809 });
2810 findings
2811}
2812
2813pub(crate) fn filter_new_health_targets(
2815 mut targets: Vec<fallow_output::RefactoringTarget>,
2816 baseline: &HealthBaselineData,
2817 root: &Path,
2818) -> Vec<fallow_output::RefactoringTarget> {
2819 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
2820 targets.retain(|t| {
2821 let key = target_baseline_key(t, root);
2822 !baseline_keys.contains(key.as_str())
2823 });
2824 targets
2825}
2826
2827#[derive(Debug, Clone, serde::Serialize)]
2829pub struct CategoryDelta {
2830 pub current: usize,
2832 pub baseline: usize,
2834 pub delta: i64,
2836}
2837
2838#[derive(Debug, Clone)]
2843pub struct BaselineDeltas {
2844 pub total_delta: i64,
2846 pub per_category: Vec<(String, CategoryDelta)>,
2848}
2849
2850#[cfg(test)]
2851mod tests {
2852 use super::*;
2853
2854 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2855 use crate::results::{
2856 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2857 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2858 UnusedFile,
2859 };
2860 use fallow_types::output_dead_code::{
2861 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2862 };
2863 use std::path::PathBuf;
2864
2865 #[test]
2866 fn stale_share_threshold_matches_the_documented_quarter() {
2867 for (baseline_entries, stale_entries, expected) in [
2868 (100, 24, false),
2869 (100, 25, true),
2870 (4, 2, true),
2871 (29, 2, false),
2872 (5, 1, false),
2873 (0, 0, false),
2874 ] {
2875 assert_eq!(
2876 stale_share_warrants_warning(baseline_entries, stale_entries),
2877 expected,
2878 "{stale_entries} of {baseline_entries} entries"
2879 );
2880 }
2881 }
2882
2883 const fn staleness(
2884 entries: usize,
2885 matched: usize,
2886 current_findings: usize,
2887 ) -> BaselineStaleness {
2888 BaselineStaleness {
2889 entries,
2890 matched,
2891 current_findings,
2892 change_scoped: false,
2893 }
2894 }
2895
2896 #[test]
2897 fn warning_is_silent_when_the_run_found_nothing() {
2898 let staleness = staleness(4, 0, 0);
2899 assert_eq!(staleness.stale_entries(), 4);
2900 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2901 }
2902
2903 #[test]
2904 fn zero_overlap_warns_when_the_run_has_findings() {
2905 assert_eq!(
2906 staleness(4, 0, 4).warning(),
2907 BaselineStalenessWarning::ZeroOverlap
2908 );
2909 }
2910
2911 #[test]
2912 fn partial_warning_needs_the_documented_quarter() {
2913 assert_eq!(
2914 staleness(100, 76, 100).warning(),
2915 BaselineStalenessWarning::None
2916 );
2917 assert_eq!(
2918 staleness(100, 75, 100).warning(),
2919 BaselineStalenessWarning::Partial
2920 );
2921 }
2922
2923 #[test]
2924 fn empty_baseline_never_warns() {
2925 assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
2926 }
2927
2928 #[test]
2929 fn change_scoped_run_never_warns_and_never_trips_the_gate() {
2930 let scoped = BaselineStaleness {
2931 change_scoped: true,
2932 ..staleness(8, 2, 8)
2933 };
2934 assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
2935 assert!(!scoped.trips_gate());
2936 }
2937
2938 #[test]
2942 fn gate_trips_on_one_stale_entry_the_warning_ignores() {
2943 let staleness = staleness(20, 19, 19);
2944 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2945 assert!(staleness.trips_gate());
2946 }
2947
2948 #[test]
2949 fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
2950 let staleness = staleness(4, 0, 0);
2951 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2952 assert!(staleness.trips_gate());
2953 }
2954
2955 #[test]
2956 fn gate_is_inert_on_an_empty_baseline() {
2957 assert!(!staleness(0, 0, 0).trips_gate());
2958 }
2959
2960 #[test]
2961 fn gate_is_inert_when_every_entry_matched() {
2962 assert!(!staleness(4, 4, 4).trips_gate());
2963 }
2964
2965 fn make_results() -> AnalysisResults {
2966 AnalysisResults {
2967 unused_files: vec![
2968 UnusedFileFinding::with_actions(UnusedFile {
2969 path: PathBuf::from("src/old.ts"),
2970 }),
2971 UnusedFileFinding::with_actions(UnusedFile {
2972 path: PathBuf::from("src/dead.ts"),
2973 }),
2974 ],
2975 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2976 path: PathBuf::from("src/utils.ts"),
2977 export_name: "helperA".to_string(),
2978 is_type_only: false,
2979 line: 5,
2980 col: 0,
2981 span_start: 40,
2982 is_re_export: false,
2983 deprecated: false,
2984 deprecated_reason: None,
2985 })],
2986 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2987 path: PathBuf::from("src/types.ts"),
2988 export_name: "OldType".to_string(),
2989 is_type_only: true,
2990 line: 10,
2991 col: 0,
2992 span_start: 100,
2993 is_re_export: false,
2994 deprecated: false,
2995 deprecated_reason: None,
2996 })],
2997 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2998 package_name: "lodash".to_string(),
2999 location: DependencyLocation::Dependencies,
3000 path: PathBuf::from("package.json"),
3001 line: 5,
3002 used_in_workspaces: Vec::new(),
3003 })],
3004 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
3005 UnusedDependency {
3006 package_name: "jest".to_string(),
3007 location: DependencyLocation::DevDependencies,
3008 path: PathBuf::from("package.json"),
3009 line: 5,
3010 used_in_workspaces: Vec::new(),
3011 },
3012 )],
3013 ..Default::default()
3014 }
3015 }
3016
3017 #[test]
3018 fn baseline_from_results_captures_all_fields() {
3019 let results = make_results();
3020 let baseline = BaselineData::from_results(&results, Path::new(""));
3021 assert_eq!(baseline.unused_files.len(), 2);
3022 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
3023 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
3024 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
3025 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
3026 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
3027 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
3028 }
3029
3030 #[test]
3031 fn dependency_baseline_keys_include_package_json_path() {
3032 let root = Path::new("/repo");
3033 let results = AnalysisResults {
3034 unused_dependencies: vec![
3035 UnusedDependencyFinding::with_actions(UnusedDependency {
3036 package_name: "lodash-es".to_string(),
3037 location: DependencyLocation::Dependencies,
3038 path: PathBuf::from("/repo/packages/app-a/package.json"),
3039 line: 5,
3040 used_in_workspaces: Vec::new(),
3041 }),
3042 UnusedDependencyFinding::with_actions(UnusedDependency {
3043 package_name: "lodash-es".to_string(),
3044 location: DependencyLocation::Dependencies,
3045 path: PathBuf::from("/repo/packages/app-b/package.json"),
3046 line: 5,
3047 used_in_workspaces: Vec::new(),
3048 }),
3049 ],
3050 ..Default::default()
3051 };
3052
3053 let baseline = BaselineData::from_results(&results, root);
3054
3055 assert_eq!(
3056 baseline.unused_dependencies,
3057 vec![
3058 "packages/app-a/package.json:lodash-es",
3059 "packages/app-b/package.json:lodash-es"
3060 ]
3061 );
3062 }
3063
3064 #[test]
3065 fn dependency_baseline_filter_matches_path_before_package_name() {
3066 let root = Path::new("/repo");
3067 let results = AnalysisResults {
3068 unused_dependencies: vec![
3069 UnusedDependencyFinding::with_actions(UnusedDependency {
3070 package_name: "lodash-es".to_string(),
3071 location: DependencyLocation::Dependencies,
3072 path: PathBuf::from("/repo/packages/app-a/package.json"),
3073 line: 5,
3074 used_in_workspaces: Vec::new(),
3075 }),
3076 UnusedDependencyFinding::with_actions(UnusedDependency {
3077 package_name: "lodash-es".to_string(),
3078 location: DependencyLocation::Dependencies,
3079 path: PathBuf::from("/repo/packages/app-b/package.json"),
3080 line: 5,
3081 used_in_workspaces: Vec::new(),
3082 }),
3083 ],
3084 ..Default::default()
3085 };
3086 let baseline = BaselineData {
3087 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
3088 ..BaselineData::from_results(&AnalysisResults::default(), root)
3089 };
3090
3091 let filtered = filter_new_issues(results, &baseline, root);
3092
3093 assert_eq!(filtered.unused_dependencies.len(), 1);
3094 assert_eq!(
3095 filtered.unused_dependencies[0].dep.path,
3096 PathBuf::from("/repo/packages/app-b/package.json")
3097 );
3098 }
3099
3100 #[test]
3101 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
3102 let root = Path::new("/repo");
3103 let results = AnalysisResults {
3104 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3105 package_name: "lodash-es".to_string(),
3106 location: DependencyLocation::Dependencies,
3107 path: PathBuf::from("/repo/packages/app/package.json"),
3108 line: 5,
3109 used_in_workspaces: Vec::new(),
3110 })],
3111 ..Default::default()
3112 };
3113 let baseline = BaselineData {
3114 unused_dependencies: vec!["lodash-es".to_string()],
3115 ..BaselineData::from_results(&AnalysisResults::default(), root)
3116 };
3117
3118 let filtered = filter_new_issues(results, &baseline, root);
3119
3120 assert!(filtered.unused_dependencies.is_empty());
3121 }
3122
3123 #[test]
3124 fn baseline_serialization_roundtrip() {
3125 let results = make_results();
3126 let baseline = BaselineData::from_results(&results, Path::new(""));
3127 let json = serde_json::to_string(&baseline).unwrap();
3128 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
3129 assert_eq!(deserialized.unused_files, baseline.unused_files);
3130 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
3131 assert_eq!(deserialized.unused_types, baseline.unused_types);
3132 assert_eq!(
3133 deserialized.unused_dependencies,
3134 baseline.unused_dependencies
3135 );
3136 assert_eq!(
3137 deserialized.unused_dev_dependencies,
3138 baseline.unused_dev_dependencies
3139 );
3140 }
3141
3142 #[test]
3143 fn filter_removes_baseline_issues() {
3144 let results = make_results();
3145 let baseline = BaselineData::from_results(&results, Path::new(""));
3146 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3147 assert!(
3148 filtered.unused_files.is_empty(),
3149 "all files were in baseline"
3150 );
3151 assert!(
3152 filtered.unused_exports.is_empty(),
3153 "all exports were in baseline"
3154 );
3155 assert!(
3156 filtered.unused_types.is_empty(),
3157 "all types were in baseline"
3158 );
3159 assert!(
3160 filtered.unused_dependencies.is_empty(),
3161 "all deps were in baseline"
3162 );
3163 assert!(
3164 filtered.unused_dev_dependencies.is_empty(),
3165 "all dev deps were in baseline"
3166 );
3167 }
3168
3169 #[test]
3170 fn filter_keeps_new_issues_not_in_baseline() {
3171 let baseline = BaselineData {
3172 kind: None,
3173 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3174 unused_files: vec!["src/old.ts".to_string()],
3175 unused_exports: vec![],
3176 unused_types: vec![],
3177 private_type_leaks: vec![],
3178 deprecated_exports_in_use: vec![],
3179 unused_dependencies: vec![],
3180 unused_dev_dependencies: vec![],
3181 circular_dependencies: vec![],
3182 re_export_cycles: vec![],
3183 unused_optional_dependencies: vec![],
3184 unused_enum_members: vec![],
3185 unused_class_members: vec![],
3186 unused_store_members: vec![],
3187 unprovided_injects: vec![],
3188 unrendered_components: vec![],
3189 unused_component_props: vec![],
3190 unused_component_emits: vec![],
3191 unused_component_inputs: vec![],
3192 unused_component_outputs: vec![],
3193 unused_svelte_events: vec![],
3194 unused_server_actions: vec![],
3195 unused_load_data_keys: vec![],
3196 unresolved_imports: vec![],
3197 unlisted_dependencies: vec![],
3198 duplicate_exports: vec![],
3199 type_only_dependencies: vec![],
3200 test_only_dependencies: vec![],
3201 dev_dependencies_in_production: vec![],
3202 boundary_violations: vec![],
3203 boundary_coverage_violations: vec![],
3204 boundary_call_violations: vec![],
3205 policy_violations: vec![],
3206 stale_suppressions: vec![],
3207 unused_catalog_entries: vec![],
3208 empty_catalog_groups: vec![],
3209 unresolved_catalog_references: vec![],
3210 unused_dependency_overrides: vec![],
3211 misconfigured_dependency_overrides: vec![],
3212 invalid_client_exports: vec![],
3213 mixed_client_server_barrels: vec![],
3214 misplaced_directives: vec![],
3215 route_collisions: vec![],
3216 dynamic_segment_name_conflicts: vec![],
3217 };
3218 let results = AnalysisResults {
3219 unused_files: vec![
3220 UnusedFileFinding::with_actions(UnusedFile {
3221 path: PathBuf::from("src/old.ts"),
3222 }),
3223 UnusedFileFinding::with_actions(UnusedFile {
3224 path: PathBuf::from("src/new-dead.ts"),
3225 }),
3226 ],
3227 ..Default::default()
3228 };
3229 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3230 assert_eq!(filtered.unused_files.len(), 1);
3231 assert_eq!(
3232 filtered.unused_files[0].file.path,
3233 PathBuf::from("src/new-dead.ts")
3234 );
3235 }
3236
3237 #[test]
3238 fn filter_with_empty_baseline_keeps_all() {
3239 let baseline = BaselineData {
3240 kind: None,
3241 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3242 unused_files: vec![],
3243 unused_exports: vec![],
3244 unused_types: vec![],
3245 private_type_leaks: vec![],
3246 deprecated_exports_in_use: vec![],
3247 unused_dependencies: vec![],
3248 unused_dev_dependencies: vec![],
3249 circular_dependencies: vec![],
3250 re_export_cycles: vec![],
3251 unused_optional_dependencies: vec![],
3252 unused_enum_members: vec![],
3253 unused_class_members: vec![],
3254 unused_store_members: vec![],
3255 unprovided_injects: vec![],
3256 unrendered_components: vec![],
3257 unused_component_props: vec![],
3258 unused_component_emits: vec![],
3259 unused_component_inputs: vec![],
3260 unused_component_outputs: vec![],
3261 unused_svelte_events: vec![],
3262 unused_server_actions: vec![],
3263 unused_load_data_keys: vec![],
3264 unresolved_imports: vec![],
3265 unlisted_dependencies: vec![],
3266 duplicate_exports: vec![],
3267 type_only_dependencies: vec![],
3268 test_only_dependencies: vec![],
3269 dev_dependencies_in_production: vec![],
3270 boundary_violations: vec![],
3271 boundary_coverage_violations: vec![],
3272 boundary_call_violations: vec![],
3273 policy_violations: vec![],
3274 stale_suppressions: vec![],
3275 unused_catalog_entries: vec![],
3276 empty_catalog_groups: vec![],
3277 unresolved_catalog_references: vec![],
3278 unused_dependency_overrides: vec![],
3279 misconfigured_dependency_overrides: vec![],
3280 invalid_client_exports: vec![],
3281 mixed_client_server_barrels: vec![],
3282 misplaced_directives: vec![],
3283 route_collisions: vec![],
3284 dynamic_segment_name_conflicts: vec![],
3285 };
3286 let results = make_results();
3287 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3288 assert_eq!(filtered.unused_files.len(), 2);
3289 assert_eq!(filtered.unused_exports.len(), 1);
3290 }
3291
3292 #[test]
3293 fn filter_new_exports_by_file_and_name() {
3294 let baseline = BaselineData {
3295 kind: None,
3296 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3297 unused_files: vec![],
3298 unused_exports: vec!["src/utils.ts:helperA".to_string()],
3299 unused_types: vec![],
3300 private_type_leaks: vec![],
3301 deprecated_exports_in_use: vec![],
3302 unused_dependencies: vec![],
3303 unused_dev_dependencies: vec![],
3304 circular_dependencies: vec![],
3305 re_export_cycles: vec![],
3306 unused_optional_dependencies: vec![],
3307 unused_enum_members: vec![],
3308 unused_class_members: vec![],
3309 unused_store_members: vec![],
3310 unprovided_injects: vec![],
3311 unrendered_components: vec![],
3312 unused_component_props: vec![],
3313 unused_component_emits: vec![],
3314 unused_component_inputs: vec![],
3315 unused_component_outputs: vec![],
3316 unused_svelte_events: vec![],
3317 unused_server_actions: vec![],
3318 unused_load_data_keys: vec![],
3319 unresolved_imports: vec![],
3320 unlisted_dependencies: vec![],
3321 duplicate_exports: vec![],
3322 type_only_dependencies: vec![],
3323 test_only_dependencies: vec![],
3324 dev_dependencies_in_production: vec![],
3325 boundary_violations: vec![],
3326 boundary_coverage_violations: vec![],
3327 boundary_call_violations: vec![],
3328 policy_violations: vec![],
3329 stale_suppressions: vec![],
3330 unused_catalog_entries: vec![],
3331 empty_catalog_groups: vec![],
3332 unresolved_catalog_references: vec![],
3333 unused_dependency_overrides: vec![],
3334 misconfigured_dependency_overrides: vec![],
3335 invalid_client_exports: vec![],
3336 mixed_client_server_barrels: vec![],
3337 misplaced_directives: vec![],
3338 route_collisions: vec![],
3339 dynamic_segment_name_conflicts: vec![],
3340 };
3341 let results = AnalysisResults {
3342 unused_exports: vec![
3343 UnusedExportFinding::with_actions(UnusedExport {
3344 path: PathBuf::from("src/utils.ts"),
3345 export_name: "helperA".to_string(),
3346 is_type_only: false,
3347 line: 5,
3348 col: 0,
3349 span_start: 40,
3350 is_re_export: false,
3351 deprecated: false,
3352 deprecated_reason: None,
3353 }),
3354 UnusedExportFinding::with_actions(UnusedExport {
3355 path: PathBuf::from("src/utils.ts"),
3356 export_name: "helperB".to_string(),
3357 is_type_only: false,
3358 line: 10,
3359 col: 0,
3360 span_start: 80,
3361 is_re_export: false,
3362 deprecated: false,
3363 deprecated_reason: None,
3364 }),
3365 ],
3366 ..Default::default()
3367 };
3368 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3369 assert_eq!(filtered.unused_exports.len(), 1);
3370 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
3371 }
3372
3373 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
3374 let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
3375 files.sort_unstable();
3376 let fragment = format!("const source = '{}';", files.join(","));
3377 make_clone_group_with_fragment(&fragment, instances)
3378 }
3379
3380 fn make_clone_group_with_fragment(
3381 fragment: &str,
3382 instances: Vec<(&str, usize, usize)>,
3383 ) -> CloneGroup {
3384 CloneGroup {
3385 instances: instances
3386 .into_iter()
3387 .map(|(file, start, end)| CloneInstance {
3388 file: PathBuf::from(file),
3389 start_line: start,
3390 end_line: end,
3391 start_col: 0,
3392 end_col: 0,
3393 fragment: fragment.to_string(),
3394 })
3395 .collect(),
3396 token_count: 50,
3397 line_count: 10,
3398 similarity: None,
3399 }
3400 }
3401
3402 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3403 DuplicationReport {
3404 clone_groups: groups,
3405 clone_families: vec![],
3406 mirrored_directories: vec![],
3407 stats: DuplicationStats {
3408 total_files: 10,
3409 files_with_clones: 2,
3410 total_lines: 1000,
3411 duplicated_lines: 100,
3412 total_tokens: 5000,
3413 duplicated_tokens: 500,
3414 clone_groups: 1,
3415 clone_families: 0,
3416 clone_instances: 2,
3417 duplication_percentage: 10.0,
3418 clone_groups_below_min_occurrences: 0,
3419 clone_groups_ignored: 0,
3420 near_candidates_skipped: 0,
3421 },
3422 }
3423 }
3424
3425 fn normalized_clone_group_key(group: &CloneGroup) -> String {
3426 let fingerprints =
3427 crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
3428 clone_group_fingerprint_key(group, &fingerprints)
3429 }
3430
3431 #[test]
3432 fn clone_group_key_is_deterministic() {
3433 let root = Path::new("/project");
3434 let group = make_clone_group(vec![
3435 ("/project/src/a.ts", 1, 10),
3436 ("/project/src/b.ts", 5, 15),
3437 ]);
3438 let key1 = clone_group_key(&group, root);
3439 let key2 = clone_group_key(&group, root);
3440 assert_eq!(key1, key2);
3441 }
3442
3443 #[test]
3444 fn clone_group_key_is_sorted() {
3445 let root = Path::new("/project");
3446 let group_ab = make_clone_group(vec![
3447 ("/project/src/a.ts", 1, 10),
3448 ("/project/src/b.ts", 5, 15),
3449 ]);
3450 let group_ba = make_clone_group(vec![
3451 ("/project/src/b.ts", 5, 15),
3452 ("/project/src/a.ts", 1, 10),
3453 ]);
3454 assert_eq!(
3455 clone_group_key(&group_ab, root),
3456 clone_group_key(&group_ba, root),
3457 "key should be stable regardless of instance order"
3458 );
3459 }
3460
3461 #[test]
3462 fn duplication_baseline_roundtrip() {
3463 let root = Path::new("/project");
3464 let group = make_clone_group(vec![
3465 ("/project/src/a.ts", 1, 10),
3466 ("/project/src/b.ts", 5, 15),
3467 ]);
3468 let report = make_duplication_report(vec![group]);
3469 let baseline = DuplicationBaselineData::from_report(&report, root);
3470 let json = serde_json::to_string(&baseline).unwrap();
3471 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
3472 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
3473 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
3474 assert_eq!(
3475 deserialized.normalized_clone_fingerprints,
3476 baseline.normalized_clone_fingerprints
3477 );
3478 assert_eq!(
3479 baseline.normalized_clone_fingerprints.len(),
3480 1,
3481 "a saved baseline carries a normalized key per clone group"
3482 );
3483 }
3484
3485 #[test]
3493 fn the_declared_keys_are_every_key_each_format_writes() {
3494 let duplication = DuplicationBaselineData {
3495 kind: Some(BaselineKind::Dupes),
3496 clone_groups: vec!["src/a.ts:1-10".to_owned()],
3497 clone_fingerprints: vec!["abc".to_owned()],
3498 normalized_clone_fingerprints: vec!["def".to_owned()],
3499 };
3500 assert_eq!(
3501 serialized_keys_without_kind(&duplication),
3502 DuplicationBaselineData::DECLARED_KEYS
3503 );
3504
3505 let counts: HealthFindingCountMap = std::iter::once((
3506 "src/a.ts".to_owned(),
3507 std::iter::once(("complexity".to_owned(), HealthBaselineCount { count: 1 })).collect(),
3508 ))
3509 .collect();
3510 let health = HealthBaselineData {
3511 kind: Some(BaselineKind::Health),
3512 findings: vec!["src/a.ts:run:1".to_owned()],
3513 finding_counts: counts.clone(),
3514 identity_finding_counts: counts,
3515 runtime_coverage_findings: vec!["src/a.ts:run".to_owned()],
3516 runtime_coverage_source_hashes: vec!["src/a.ts\0run\0hash".to_owned()],
3517 target_keys: vec!["src/a.ts:complexity".to_owned()],
3518 };
3519 assert_eq!(
3520 serialized_keys_without_kind(&health),
3521 HealthBaselineData::DECLARED_KEYS
3522 );
3523
3524 for list in [
3525 DuplicationBaselineData::DECLARED_KEYS,
3526 HealthBaselineData::DECLARED_KEYS,
3527 BaselineData::REQUIRED_KEYS,
3528 ] {
3529 assert!(
3530 !list.contains(&"kind"),
3531 "no format may declare the key every format writes: {list:?}"
3532 );
3533 }
3534 }
3535
3536 #[test]
3541 fn the_required_dead_code_keys_are_the_ones_without_a_serde_default() {
3542 let json = serde_json::to_string(&BaselineData::from_results(
3543 &crate::results::AnalysisResults::default(),
3544 Path::new("/project"),
3545 ))
3546 .expect("baseline serializes");
3547 let serde_json::Value::Object(object) =
3548 serde_json::from_str::<serde_json::Value>(&json).expect("object")
3549 else {
3550 panic!("a baseline serializes as an object");
3551 };
3552
3553 for key in object.keys() {
3554 let mut without = object.clone();
3555 without.remove(key);
3556 let loads =
3557 serde_json::from_value::<BaselineData>(serde_json::Value::Object(without)).is_ok();
3558 assert_eq!(
3559 !loads,
3560 BaselineData::REQUIRED_KEYS.contains(&key.as_str()),
3561 "REQUIRED_KEYS must list exactly the keys a load cannot do without, and {key} \
3562 disagrees"
3563 );
3564 }
3565 }
3566
3567 #[test]
3568 fn every_saved_baseline_names_the_command_that_wrote_it() {
3569 let dead_code = serde_json::to_string(&BaselineData::from_results(
3570 &crate::results::AnalysisResults::default(),
3571 Path::new("/project"),
3572 ))
3573 .expect("baseline serializes");
3574 let dupes = serde_json::to_string(&DuplicationBaselineData::from_report(
3575 &make_duplication_report(Vec::new()),
3576 Path::new("/project"),
3577 ))
3578 .expect("baseline serializes");
3579 let health = serde_json::to_string(&HealthBaselineData::from_findings(
3580 &[],
3581 &[],
3582 &[],
3583 Path::new("/project"),
3584 ))
3585 .expect("baseline serializes");
3586
3587 for (json, kind) in [
3588 (&dead_code, BaselineKind::DeadCode),
3589 (&dupes, BaselineKind::Dupes),
3590 (&health, BaselineKind::Health),
3591 ] {
3592 assert_eq!(
3593 serde_json::from_str::<serde_json::Value>(json).expect("object")["kind"],
3594 serde_json::json!(kind.as_str()),
3595 "a saved baseline states which command wrote it: {json}"
3596 );
3597 assert_eq!(classify_baseline_file(json, kind), BaselineFileKind::Own);
3598 for other in [
3599 BaselineKind::DeadCode,
3600 BaselineKind::Dupes,
3601 BaselineKind::Health,
3602 ] {
3603 if other == kind {
3604 continue;
3605 }
3606 assert_eq!(
3607 classify_baseline_file(json, other),
3608 BaselineFileKind::Foreign(kind.as_str().to_owned()),
3609 "and every other command reads it as that command's: {json}"
3610 );
3611 }
3612 }
3613 }
3614
3615 #[test]
3618 fn a_baseline_without_a_kind_is_classified_by_its_keys() {
3619 let dupes =
3620 r#"{"clone_groups":[],"clone_fingerprints":[],"normalized_clone_fingerprints":[]}"#;
3621 let health = r#"{"runtime_coverage_findings":[],"target_keys":[]}"#;
3622 let dead_code = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],"unused_dependencies":[],"unused_dev_dependencies":[]}"#;
3623
3624 for (json, own) in [
3625 (dupes, BaselineKind::Dupes),
3626 (health, BaselineKind::Health),
3627 (dead_code, BaselineKind::DeadCode),
3628 ] {
3629 assert_eq!(classify_baseline_file(json, own), BaselineFileKind::Own);
3630 for other in [
3631 BaselineKind::DeadCode,
3632 BaselineKind::Dupes,
3633 BaselineKind::Health,
3634 ] {
3635 if other == own {
3636 continue;
3637 }
3638 assert_eq!(
3639 classify_baseline_file(json, other),
3640 BaselineFileKind::Unrecognised,
3641 "a file with no kind and none of this format's keys is unrecognised, not \
3642 attributed to a command it never named: {json}"
3643 );
3644 }
3645 }
3646 }
3647
3648 #[test]
3652 fn an_unreadable_kind_never_becomes_a_parse_error() {
3653 assert_eq!(
3654 classify_baseline_file(r#"{"kind":"security"}"#, BaselineKind::Dupes),
3655 BaselineFileKind::Foreign("security".to_owned())
3656 );
3657 assert_eq!(
3658 classify_baseline_file(r#"{"kind":7,"clone_groups":[]}"#, BaselineKind::Dupes),
3659 BaselineFileKind::Own
3660 );
3661 assert_eq!(
3662 classify_baseline_file("[]", BaselineKind::Dupes),
3663 BaselineFileKind::NotAnObject
3664 );
3665 assert_eq!(
3666 classify_baseline_file("not json", BaselineKind::DeadCode),
3667 BaselineFileKind::NotAnObject
3668 );
3669 assert!(
3670 serde_json::from_str::<DuplicationBaselineData>(r#"{"kind":"security"}"#).is_ok(),
3671 "a kind only a newer fallow writes must not break a load"
3672 );
3673 }
3674
3675 #[test]
3676 fn a_save_is_refused_only_over_another_commands_baseline() {
3677 let dir = tempfile::tempdir().expect("tempdir");
3678 let path = dir.path().join("baseline.json");
3679
3680 assert!(
3681 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
3682 "there is nothing to destroy yet"
3683 );
3684
3685 std::fs::write(
3686 &path,
3687 serde_json::to_string(&DuplicationBaselineData::from_report(
3688 &make_duplication_report(Vec::new()),
3689 Path::new("/project"),
3690 ))
3691 .expect("baseline serializes"),
3692 )
3693 .expect("write");
3694 assert!(
3695 refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
3696 "re-saving over its own file is the documented workflow"
3697 );
3698 let message = refuse_baseline_kind_overwrite(&path, BaselineKind::Health)
3699 .expect("a health save over a duplication baseline is refused");
3700 assert!(message.contains("`fallow dupes`"), "{message}");
3701 assert!(message.contains("`fallow health`"), "{message}");
3702 assert!(message.contains(&path.display().to_string()), "{message}");
3703
3704 std::fs::write(&path, "{}").expect("write");
3705 assert!(
3706 refuse_baseline_kind_overwrite(&path, BaselineKind::Health).is_none(),
3707 "a file with nothing to identify it carries no claim to protect"
3708 );
3709 }
3710
3711 fn serialized_keys_without_kind<T: serde::Serialize>(value: &T) -> Vec<String> {
3712 let serde_json::Value::Object(object) =
3713 serde_json::to_value(value).expect("baseline serializes")
3714 else {
3715 panic!("a baseline serializes as an object");
3716 };
3717 object
3718 .keys()
3719 .filter(|key| key.as_str() != "kind")
3720 .cloned()
3721 .collect()
3722 }
3723
3724 #[test]
3725 fn a_baseline_saved_from_a_clean_project_still_declares_its_format() {
3726 let empty = serde_json::to_string(&DuplicationBaselineData::default())
3727 .expect("baseline serializes");
3728
3729 assert!(
3730 declares_baseline_format(&empty, DuplicationBaselineData::DECLARED_KEYS),
3731 "an empty duplication baseline is still a duplication baseline: {empty}"
3732 );
3733 assert!(
3734 !declares_baseline_format(&empty, HealthBaselineData::DECLARED_KEYS),
3735 "and it is not a health one: {empty}"
3736 );
3737 assert!(!declares_baseline_format(
3738 "{}",
3739 HealthBaselineData::DECLARED_KEYS
3740 ));
3741 assert!(!declares_baseline_format(
3742 "[]",
3743 HealthBaselineData::DECLARED_KEYS
3744 ));
3745 assert!(!declares_baseline_format(
3746 "not json",
3747 HealthBaselineData::DECLARED_KEYS
3748 ));
3749 }
3750
3751 #[test]
3752 fn filter_new_clone_groups_matches_shifted_clone() {
3753 let root = Path::new("/project");
3754 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3755 "const total = a + b;",
3756 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3757 )]);
3758 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3759
3760 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3761 "const total = a + b;",
3762 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3763 )]);
3764 let filtered = filter_new_clone_groups(shifted, &baseline, root);
3765 assert!(
3766 filtered.clone_groups.is_empty(),
3767 "an unrelated line shift must not resurface a baselined clone"
3768 );
3769 }
3770
3771 #[test]
3772 fn filter_new_clone_groups_reports_extra_copy() {
3773 let root = Path::new("/project");
3774 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3775 "const total = a + b;",
3776 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3777 )]);
3778 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3779
3780 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
3781 "const total = a + b;",
3782 vec![
3783 ("/project/src/a.ts", 10, 20),
3784 ("/project/src/b.ts", 30, 40),
3785 ("/project/src/c.ts", 5, 15),
3786 ],
3787 )]);
3788 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
3789 assert_eq!(
3790 filtered.clone_groups.len(),
3791 1,
3792 "a fresh copy in a third file is a new finding"
3793 );
3794 }
3795
3796 #[test]
3797 fn filter_new_clone_groups_reads_legacy_baseline() {
3798 let root = Path::new("/project");
3799 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
3800 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
3801 assert_eq!(baseline.entry_count(), 1);
3802
3803 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
3804 "const total = a + b;",
3805 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3806 )]);
3807 assert!(
3808 filter_new_clone_groups(unchanged, &baseline, root)
3809 .clone_groups
3810 .is_empty(),
3811 "a legacy baseline still matches on locations"
3812 );
3813
3814 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3815 "const total = a + b;",
3816 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3817 )]);
3818 assert_eq!(
3819 filter_new_clone_groups(shifted, &baseline, root)
3820 .clone_groups
3821 .len(),
3822 1,
3823 "legacy behavior is unchanged: a shift stops matching"
3824 );
3825 }
3826
3827 #[test]
3828 fn clone_group_fingerprint_key_survives_file_rename() {
3829 let before = make_clone_group_with_fragment(
3830 "const total = a + b;",
3831 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3832 );
3833 let after = make_clone_group_with_fragment(
3834 "const total = a + b;",
3835 vec![
3836 ("/project/src/renamed.ts", 10, 20),
3837 ("/project/src/b.ts", 30, 40),
3838 ],
3839 );
3840 assert_eq!(
3841 normalized_clone_group_key(&before),
3842 normalized_clone_group_key(&after),
3843 "renaming a file must not resurface a baselined clone"
3844 );
3845 }
3846
3847 #[test]
3848 fn clone_group_fingerprint_key_does_not_follow_representative_order() {
3849 let mut before = make_clone_group_with_fragment(
3850 "const total = a + b;",
3851 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3852 );
3853 before.instances[1].fragment = "const total = a + b;".to_string();
3854
3855 let mut after = before.clone();
3856 after.instances[0].file = PathBuf::from("/project/src/z.ts");
3857
3858 assert_eq!(
3859 normalized_clone_group_key(&before),
3860 normalized_clone_group_key(&after),
3861 "normalized group identity is independent of representative order"
3862 );
3863 }
3864
3865 #[test]
3866 fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
3867 let root = Path::new("/project");
3868 let group = make_clone_group_with_fragment(
3869 "const total = a + b;",
3870 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3871 );
3872 let legacy_key = legacy_clone_group_fingerprint_key(&group);
3873 let baseline = DuplicationBaselineData {
3874 kind: None,
3875 clone_groups: Vec::new(),
3876 clone_fingerprints: vec![legacy_key.clone()],
3877 normalized_clone_fingerprints: Vec::new(),
3878 };
3879
3880 let filtered = filter_new_clone_groups(
3881 make_duplication_report(vec![group.clone()]),
3882 &baseline,
3883 root,
3884 );
3885 assert!(filtered.clone_groups.is_empty());
3886
3887 let current =
3888 DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
3889 assert_eq!(current.clone_fingerprints, vec![legacy_key]);
3890 assert_ne!(
3891 current.normalized_clone_fingerprints,
3892 current.clone_fingerprints
3893 );
3894 }
3895
3896 #[test]
3897 fn normalized_baseline_survives_formatting_only_edits() {
3898 let root = Path::new("/project");
3899 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3900 "const total = left + right;",
3901 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3902 )]);
3903 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3904
3905 let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
3906 "/* reviewed */\r\nconst total=left + right;",
3907 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
3908 )]);
3909 assert!(
3910 filter_new_clone_groups(formatted, &baseline, root)
3911 .clone_groups
3912 .is_empty()
3913 );
3914 }
3915
3916 #[test]
3917 fn filter_new_clone_groups_removes_baseline() {
3918 let root = Path::new("/project");
3919 let group = make_clone_group(vec![
3920 ("/project/src/a.ts", 1, 10),
3921 ("/project/src/b.ts", 5, 15),
3922 ]);
3923 let report = make_duplication_report(vec![group]);
3924 let baseline = DuplicationBaselineData::from_report(&report, root);
3925 let filtered = filter_new_clone_groups(report, &baseline, root);
3926 assert!(
3927 filtered.clone_groups.is_empty(),
3928 "baseline group should be filtered out"
3929 );
3930 }
3931
3932 #[test]
3933 fn filter_new_clone_groups_keeps_new_groups() {
3934 let root = Path::new("/project");
3935 let baseline_group = make_clone_group(vec![
3936 ("/project/src/a.ts", 1, 10),
3937 ("/project/src/b.ts", 5, 15),
3938 ]);
3939 let new_group = make_clone_group(vec![
3940 ("/project/src/c.ts", 20, 30),
3941 ("/project/src/d.ts", 25, 35),
3942 ]);
3943 let baseline_report = make_duplication_report(vec![baseline_group]);
3944 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3945
3946 let report = make_duplication_report(vec![
3947 make_clone_group(vec![
3948 ("/project/src/a.ts", 1, 10),
3949 ("/project/src/b.ts", 5, 15),
3950 ]),
3951 new_group,
3952 ]);
3953 let filtered = filter_new_clone_groups(report, &baseline, root);
3954 assert_eq!(
3955 filtered.clone_groups.len(),
3956 1,
3957 "only the new group should remain"
3958 );
3959 }
3960
3961 #[test]
3962 fn recompute_stats_after_filtering() {
3963 let root = Path::new("/project");
3964 let group = make_clone_group(vec![
3965 ("/project/src/a.ts", 1, 10),
3966 ("/project/src/b.ts", 5, 15),
3967 ]);
3968 let report = make_duplication_report(vec![group]);
3969 let baseline = DuplicationBaselineData::from_report(&report, root);
3970 let filtered = filter_new_clone_groups(report, &baseline, root);
3971 assert_eq!(filtered.stats.clone_groups, 0);
3972 assert_eq!(filtered.stats.clone_instances, 0);
3973 assert_eq!(filtered.stats.duplicated_lines, 0);
3974 }
3975
3976 #[test]
3977 fn recompute_stats_zero_total_lines() {
3978 let report = DuplicationReport {
3979 clone_groups: vec![],
3980 clone_families: vec![],
3981 mirrored_directories: vec![],
3982 stats: DuplicationStats {
3983 total_files: 0,
3984 files_with_clones: 0,
3985 total_lines: 0,
3986 duplicated_lines: 0,
3987 total_tokens: 0,
3988 duplicated_tokens: 0,
3989 clone_groups: 0,
3990 clone_families: 0,
3991 clone_instances: 0,
3992 duplication_percentage: 0.0,
3993 clone_groups_below_min_occurrences: 0,
3994 clone_groups_ignored: 0,
3995 near_candidates_skipped: 0,
3996 },
3997 };
3998 let stats = super::recompute_stats(&report);
3999 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
4000 }
4001
4002 fn filter_new_health_findings(
4006 findings: Vec<fallow_output::ComplexityViolation>,
4007 baseline: &HealthBaselineData,
4008 root: &Path,
4009 ) -> Vec<fallow_output::ComplexityViolation> {
4010 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
4011 }
4012
4013 fn make_health_finding(
4014 root: &Path,
4015 name: &str,
4016 line: u32,
4017 ) -> fallow_output::ComplexityViolation {
4018 make_health_finding_with(
4019 root,
4020 name,
4021 line,
4022 fallow_output::ExceededThreshold::Both,
4023 fallow_output::FindingSeverity::High,
4024 )
4025 }
4026
4027 fn make_health_finding_with(
4028 root: &Path,
4029 name: &str,
4030 line: u32,
4031 exceeded: fallow_output::ExceededThreshold,
4032 severity: fallow_output::FindingSeverity,
4033 ) -> fallow_output::ComplexityViolation {
4034 fallow_output::ComplexityViolation {
4035 path: root.join("src/utils.ts"),
4036 name: name.to_string(),
4037 line,
4038 col: 0,
4039 cyclomatic: 25,
4040 cognitive: 30,
4041 line_count: 80,
4042 param_count: 0,
4043 react_hook_count: 0,
4044 react_jsx_max_depth: 0,
4045 react_prop_count: 0,
4046 react_hook_profile: None,
4047 exceeded,
4048 severity,
4049 effective_severity: None,
4050 crap: None,
4051 coverage_pct: None,
4052 coverage_tier: None,
4053 coverage_source: None,
4054 inherited_from: None,
4055 component_rollup: None,
4056 contributions: Vec::new(),
4057 effective_thresholds: None,
4058 threshold_source: None,
4059 }
4060 }
4061
4062 #[test]
4063 fn health_baseline_roundtrip() {
4064 let root = PathBuf::from("/project");
4065 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4066 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4067 let json = serde_json::to_string(&baseline).unwrap();
4068 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
4069 assert_eq!(deserialized.findings, baseline.findings);
4070 assert_eq!(baseline.findings, Vec::<String>::new());
4071 assert_eq!(
4072 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
4073 1
4074 );
4075 assert!(!json.contains("parseExpression"));
4076 }
4077
4078 #[test]
4079 fn health_baseline_filters_known_findings() {
4080 let root = PathBuf::from("/project");
4081 let mut findings = vec![
4082 make_health_finding(&root, "parseExpression", 42),
4083 make_health_finding(&root, "newFunction", 100),
4084 ];
4085 findings[1].path = root.join("src/other.ts");
4086 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
4087 let filtered = filter_new_health_findings(findings, &baseline, &root);
4088 assert_eq!(filtered.len(), 1);
4089 assert_eq!(filtered[0].name, "newFunction");
4090 }
4091
4092 #[test]
4093 fn health_baseline_filters_shifted_lines_with_same_category_count() {
4094 let root = PathBuf::from("/project");
4095 let baseline = HealthBaselineData::from_findings(
4096 &[make_health_finding(&root, "parseExpression", 42)],
4097 &[],
4098 &[],
4099 &root,
4100 );
4101 let filtered = filter_new_health_findings(
4102 vec![make_health_finding(&root, "parseExpression", 43)],
4103 &baseline,
4104 &root,
4105 );
4106 assert!(filtered.is_empty());
4107 }
4108
4109 #[test]
4110 fn health_baseline_reports_full_category_when_count_increases() {
4111 let root = PathBuf::from("/project");
4112 let baseline = HealthBaselineData::from_findings(
4113 &[make_health_finding(&root, "parseExpression", 42)],
4114 &[],
4115 &[],
4116 &root,
4117 );
4118 let filtered = filter_new_health_findings(
4119 vec![
4120 make_health_finding(&root, "parseExpression", 43),
4121 make_health_finding(&root, "newFunction", 100),
4122 ],
4123 &baseline,
4124 &root,
4125 );
4126 assert_eq!(filtered.len(), 2);
4127 }
4128
4129 #[test]
4130 fn health_baseline_legacy_findings_still_load() {
4131 let root = PathBuf::from("/project");
4132 let baseline = HealthBaselineData {
4133 kind: None,
4134 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
4135 finding_counts: BTreeMap::new(),
4136 identity_finding_counts: BTreeMap::new(),
4137 target_keys: vec![],
4138 runtime_coverage_findings: vec![],
4139 runtime_coverage_source_hashes: vec![],
4140 };
4141 let filtered = filter_new_health_findings(
4142 vec![make_health_finding(&root, "parseExpression", 42)],
4143 &baseline,
4144 &root,
4145 );
4146 assert!(filtered.is_empty());
4147 }
4148
4149 #[test]
4150 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
4151 let root = PathBuf::from("/project");
4152 let baseline = HealthBaselineData::from_findings(
4153 &[make_health_finding_with(
4154 &root,
4155 "parseExpression",
4156 42,
4157 fallow_output::ExceededThreshold::Crap,
4158 fallow_output::FindingSeverity::High,
4159 )],
4160 &[],
4161 &[],
4162 &root,
4163 );
4164 let filtered = filter_new_health_findings(
4165 vec![
4166 make_health_finding_with(
4167 &root,
4168 "parseExpression",
4169 43,
4170 fallow_output::ExceededThreshold::Crap,
4171 fallow_output::FindingSeverity::High,
4172 ),
4173 make_health_finding(&root, "newComplexityOnlyFunction", 100),
4174 ],
4175 &baseline,
4176 &root,
4177 );
4178 assert_eq!(filtered.len(), 1);
4179 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
4180 }
4181
4182 #[test]
4183 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
4184 let root = PathBuf::from("/project");
4185 let baseline = HealthBaselineData::from_findings(
4186 &[make_health_finding_with(
4187 &root,
4188 "parseExpression",
4189 42,
4190 fallow_output::ExceededThreshold::Both,
4191 fallow_output::FindingSeverity::Critical,
4192 )],
4193 &[],
4194 &[],
4195 &root,
4196 );
4197 let filtered = filter_new_health_findings(
4198 vec![make_health_finding_with(
4199 &root,
4200 "parseExpression",
4201 42,
4202 fallow_output::ExceededThreshold::Both,
4203 fallow_output::FindingSeverity::High,
4204 )],
4205 &baseline,
4206 &root,
4207 );
4208 assert!(filtered.is_empty());
4209 }
4210
4211 #[test]
4212 fn health_baseline_still_reports_worse_current_severity_as_new() {
4213 let root = PathBuf::from("/project");
4214 let baseline = HealthBaselineData::from_findings(
4215 &[make_health_finding_with(
4216 &root,
4217 "parseExpression",
4218 42,
4219 fallow_output::ExceededThreshold::Both,
4220 fallow_output::FindingSeverity::High,
4221 )],
4222 &[],
4223 &[],
4224 &root,
4225 );
4226 let filtered = filter_new_health_findings(
4227 vec![make_health_finding_with(
4228 &root,
4229 "parseExpression",
4230 42,
4231 fallow_output::ExceededThreshold::Both,
4232 fallow_output::FindingSeverity::Critical,
4233 )],
4234 &baseline,
4235 &root,
4236 );
4237 assert_eq!(filtered.len(), 1);
4238 assert_eq!(filtered[0].name, "parseExpression");
4239 assert!(matches!(
4240 filtered[0].severity,
4241 fallow_output::FindingSeverity::Critical
4242 ));
4243 }
4244
4245 #[test]
4246 fn health_baseline_overlap_counts_partial_category_overflow() {
4247 let root = PathBuf::from("/project");
4248 let baseline = HealthBaselineData::from_findings(
4249 &[make_health_finding(&root, "parseExpression", 42)],
4250 &[],
4251 &[],
4252 &root,
4253 );
4254 let overlap = baseline.overlap_entries(
4255 &[
4256 make_health_finding(&root, "parseExpression", 42),
4257 make_health_finding(&root, "newFunction", 100),
4258 ],
4259 &root,
4260 HealthBaselineMode::Count,
4261 );
4262 assert_eq!(overlap.matched_entries, 1);
4263 assert_eq!(overlap.moved_entries, 0);
4264 }
4265
4266 fn identity_baseline(
4268 findings: &[fallow_output::ComplexityViolation],
4269 root: &Path,
4270 ) -> HealthBaselineData {
4271 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
4272 }
4273
4274 #[test]
4275 fn health_identity_baseline_reports_replacement_hotspot() {
4276 let root = PathBuf::from("/project");
4277 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
4278 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
4279
4280 assert!(
4281 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
4282 "count mode keeps the per-file allowance and suppresses the replacement"
4283 );
4284
4285 let filtered = super::filter_new_health_findings(
4286 replacement,
4287 &baseline,
4288 &root,
4289 HealthBaselineMode::Identity,
4290 );
4291 assert_eq!(filtered.len(), 1);
4292 assert_eq!(filtered[0].name, "replacementHotspot");
4293 }
4294
4295 #[test]
4296 fn health_identity_baseline_survives_line_moves() {
4297 let root = PathBuf::from("/project");
4298 let baseline =
4299 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4300 let filtered = super::filter_new_health_findings(
4301 vec![make_health_finding(&root, "parseExpression", 512)],
4302 &baseline,
4303 &root,
4304 HealthBaselineMode::Identity,
4305 );
4306 assert!(filtered.is_empty());
4307 }
4308
4309 #[test]
4310 fn health_identity_baseline_suppresses_severity_improvement() {
4311 let root = PathBuf::from("/project");
4312 let baseline = identity_baseline(
4313 &[make_health_finding_with(
4314 &root,
4315 "parseExpression",
4316 42,
4317 fallow_output::ExceededThreshold::Both,
4318 fallow_output::FindingSeverity::Critical,
4319 )],
4320 &root,
4321 );
4322 let filtered = super::filter_new_health_findings(
4323 vec![make_health_finding_with(
4324 &root,
4325 "parseExpression",
4326 42,
4327 fallow_output::ExceededThreshold::Both,
4328 fallow_output::FindingSeverity::Moderate,
4329 )],
4330 &baseline,
4331 &root,
4332 HealthBaselineMode::Identity,
4333 );
4334 assert!(filtered.is_empty());
4335 }
4336
4337 #[test]
4338 fn health_identity_baseline_reports_added_finding_for_known_function() {
4339 let root = PathBuf::from("/project");
4340 let baseline =
4341 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4342 let filtered = super::filter_new_health_findings(
4343 vec![
4344 make_health_finding(&root, "parseExpression", 42),
4345 make_health_finding(&root, "parseStatement", 90),
4346 ],
4347 &baseline,
4348 &root,
4349 HealthBaselineMode::Identity,
4350 );
4351 assert_eq!(filtered.len(), 1);
4352 assert_eq!(filtered[0].name, "parseStatement");
4353 }
4354
4355 #[test]
4356 fn health_identity_buckets_are_written_only_in_identity_mode() {
4357 let root = PathBuf::from("/project");
4358 let findings = [make_health_finding(&root, "parseExpression", 42)];
4359 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4360 let json = serde_json::to_string(&count_only).unwrap();
4361 assert!(!json.contains("identity_finding_counts"));
4362 assert!(count_only.lacks_identity_data());
4363
4364 let identity = identity_baseline(&findings, &root);
4365 assert!(!identity.lacks_identity_data());
4366 assert_eq!(
4367 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
4368 .count,
4369 1
4370 );
4371 assert!(
4372 !identity.finding_counts.is_empty(),
4373 "an identity baseline stays readable in count mode"
4374 );
4375 }
4376
4377 fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
4378 let mut finding = make_health_finding(root, name, 42);
4379 finding.path = root.join(path);
4380 finding
4381 }
4382
4383 #[test]
4384 fn health_identity_baseline_follows_file_move() {
4385 let root = PathBuf::from("/project");
4386 let baseline =
4387 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4388 let moved = vec![moved_finding(
4389 &root,
4390 "src/parser/utils.ts",
4391 "parseExpression",
4392 )];
4393
4394 let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
4395 assert_eq!(
4396 overlap.matched_entries, 1,
4397 "a followed move counts as matched, not stale"
4398 );
4399 assert_eq!(
4400 overlap.moved_entries, 1,
4401 "the followed move stays observable as a moved entry"
4402 );
4403 let filtered = super::filter_new_health_findings(
4404 moved,
4405 &baseline,
4406 &root,
4407 HealthBaselineMode::Identity,
4408 );
4409 assert!(filtered.is_empty());
4410 }
4411
4412 #[test]
4413 fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
4414 let root = PathBuf::from("/project");
4415 let baseline =
4416 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
4417 let filtered = super::filter_new_health_findings(
4418 vec![
4419 moved_finding(&root, "src/a.ts", "parseExpression"),
4420 moved_finding(&root, "src/b.ts", "parseExpression"),
4421 ],
4422 &baseline,
4423 &root,
4424 HealthBaselineMode::Identity,
4425 );
4426 assert_eq!(filtered.len(), 2);
4427 }
4428
4429 #[test]
4430 fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
4431 let root = PathBuf::from("/project");
4432 let baseline = identity_baseline(
4433 &[
4434 moved_finding(&root, "src/a.ts", "parseExpression"),
4435 moved_finding(&root, "src/b.ts", "parseExpression"),
4436 ],
4437 &root,
4438 );
4439 let filtered = super::filter_new_health_findings(
4440 vec![moved_finding(&root, "src/c.ts", "parseExpression")],
4441 &baseline,
4442 &root,
4443 HealthBaselineMode::Identity,
4444 );
4445 assert_eq!(filtered.len(), 1);
4446 }
4447
4448 #[test]
4449 fn health_identity_move_is_not_followed_when_old_path_still_exists() {
4450 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4453 let baseline = identity_baseline(
4454 &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
4455 &root,
4456 );
4457 let filtered = super::filter_new_health_findings(
4458 vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
4459 &baseline,
4460 &root,
4461 HealthBaselineMode::Identity,
4462 );
4463 assert_eq!(filtered.len(), 1);
4464 }
4465
4466 #[test]
4467 fn health_identity_move_is_not_followed_for_anonymous_functions() {
4468 let root = PathBuf::from("/project");
4469 let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
4470 let filtered = super::filter_new_health_findings(
4471 vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
4472 &baseline,
4473 &root,
4474 HealthBaselineMode::Identity,
4475 );
4476 assert_eq!(filtered.len(), 1);
4477 }
4478
4479 #[test]
4480 fn health_count_baseline_does_not_follow_file_moves() {
4481 let root = PathBuf::from("/project");
4482 let baseline = HealthBaselineData::from_findings(
4483 &[make_health_finding(&root, "parseExpression", 42)],
4484 &[],
4485 &[],
4486 &root,
4487 );
4488 let filtered = filter_new_health_findings(
4489 vec![moved_finding(
4490 &root,
4491 "src/parser/utils.ts",
4492 "parseExpression",
4493 )],
4494 &baseline,
4495 &root,
4496 );
4497 assert_eq!(filtered.len(), 1);
4498 }
4499
4500 #[test]
4501 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
4502 let legacy = HealthBaselineData {
4503 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
4504 ..HealthBaselineData::default()
4505 };
4506 assert!(legacy.lacks_identity_data());
4507 assert!(!HealthBaselineData::default().lacks_identity_data());
4508 }
4509
4510 #[test]
4511 fn health_baseline_empty_keeps_all() {
4512 let root = PathBuf::from("/project");
4513 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4514 let baseline = HealthBaselineData {
4515 kind: None,
4516 findings: vec![],
4517 finding_counts: BTreeMap::new(),
4518 identity_finding_counts: BTreeMap::new(),
4519 target_keys: vec![],
4520 runtime_coverage_findings: vec![],
4521 runtime_coverage_source_hashes: vec![],
4522 };
4523 let filtered = filter_new_health_findings(findings, &baseline, &root);
4524 assert_eq!(filtered.len(), 1);
4525 }
4526
4527 #[test]
4528 fn circular_dep_key_is_order_independent() {
4529 use crate::results::CircularDependency;
4530
4531 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
4532 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4533 length: 2,
4534 line: 1,
4535 col: 0,
4536 edges: Vec::new(),
4537 is_cross_package: false,
4538 });
4539 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
4540 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
4541 length: 2,
4542 line: 1,
4543 col: 0,
4544 edges: Vec::new(),
4545 is_cross_package: false,
4546 });
4547 assert_eq!(
4548 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
4549 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
4550 "same files in different order should produce identical keys"
4551 );
4552 }
4553
4554 #[test]
4555 fn circular_dep_key_different_files_different_keys() {
4556 use crate::results::CircularDependency;
4557
4558 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
4559 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4560 length: 2,
4561 line: 1,
4562 col: 0,
4563 edges: Vec::new(),
4564 is_cross_package: false,
4565 });
4566 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
4567 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
4568 length: 2,
4569 line: 1,
4570 col: 0,
4571 edges: Vec::new(),
4572 is_cross_package: false,
4573 });
4574 assert_ne!(
4575 super::circular_dep_key(&dep1.cycle, Path::new("")),
4576 super::circular_dep_key(&dep2.cycle, Path::new("")),
4577 );
4578 }
4579
4580 #[test]
4581 fn circular_dep_key_three_files_order_independent() {
4582 use crate::results::CircularDependency;
4583
4584 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
4585 files: vec![
4586 PathBuf::from("src/a.ts"),
4587 PathBuf::from("src/b.ts"),
4588 PathBuf::from("src/c.ts"),
4589 ],
4590 length: 3,
4591 line: 1,
4592 col: 0,
4593 edges: Vec::new(),
4594 is_cross_package: false,
4595 });
4596 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
4597 files: vec![
4598 PathBuf::from("src/c.ts"),
4599 PathBuf::from("src/a.ts"),
4600 PathBuf::from("src/b.ts"),
4601 ],
4602 length: 3,
4603 line: 1,
4604 col: 0,
4605 edges: Vec::new(),
4606 is_cross_package: false,
4607 });
4608 assert_eq!(
4609 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
4610 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
4611 );
4612 }
4613
4614 #[expect(
4615 clippy::too_many_lines,
4616 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4617 )]
4618 fn make_full_results() -> AnalysisResults {
4619 use crate::results::*;
4620 use crate::source::MemberKind;
4621
4622 let mut r = make_results();
4623 r.circular_dependencies
4624 .push(CircularDependencyFinding::with_actions(
4625 CircularDependency {
4626 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4627 length: 2,
4628 line: 1,
4629 col: 0,
4630 edges: Vec::new(),
4631 is_cross_package: false,
4632 },
4633 ));
4634 r.unused_optional_dependencies
4635 .push(UnusedOptionalDependencyFinding::with_actions(
4636 UnusedDependency {
4637 package_name: "fsevents".to_string(),
4638 location: DependencyLocation::OptionalDependencies,
4639 path: PathBuf::from("package.json"),
4640 line: 15,
4641 used_in_workspaces: Vec::new(),
4642 },
4643 ));
4644 r.unused_enum_members
4645 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
4646 path: PathBuf::from("src/enums.ts"),
4647 parent_name: "Status".to_string(),
4648 member_name: "Deprecated".to_string(),
4649 kind: MemberKind::EnumMember,
4650 line: 8,
4651 col: 0,
4652 }));
4653 r.unused_class_members
4654 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4655 path: PathBuf::from("src/service.ts"),
4656 parent_name: "UserService".to_string(),
4657 member_name: "legacy".to_string(),
4658 kind: MemberKind::ClassMethod,
4659 line: 42,
4660 col: 0,
4661 }));
4662 r.unused_store_members
4663 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4664 path: PathBuf::from("src/store.ts"),
4665 parent_name: "useStore".to_string(),
4666 member_name: "legacyAction".to_string(),
4667 kind: MemberKind::StoreMember,
4668 line: 17,
4669 col: 0,
4670 }));
4671 r.unresolved_imports.push(
4672 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
4673 crate::results::UnresolvedImport {
4674 path: PathBuf::from("src/app.ts"),
4675 specifier: "./missing".to_string(),
4676 line: 3,
4677 col: 0,
4678 specifier_col: 0,
4679 },
4680 ),
4681 );
4682 r.unlisted_dependencies
4683 .push(crate::results::UnlistedDependencyFinding::with_actions(
4684 UnlistedDependency {
4685 package_name: "chalk".to_string(),
4686 imported_from: vec![],
4687 },
4688 ));
4689 r.duplicate_exports
4690 .push(crate::results::DuplicateExportFinding::with_actions(
4691 crate::results::DuplicateExport {
4692 export_name: "Config".to_string(),
4693 locations: vec![
4694 crate::results::DuplicateLocation {
4695 path: PathBuf::from("src/a.ts"),
4696 line: 1,
4697 col: 0,
4698 },
4699 crate::results::DuplicateLocation {
4700 path: PathBuf::from("src/b.ts"),
4701 line: 5,
4702 col: 0,
4703 },
4704 ],
4705 },
4706 ));
4707 r.type_only_dependencies
4708 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
4709 TypeOnlyDependency {
4710 package_name: "zod".to_string(),
4711 path: PathBuf::from("package.json"),
4712 line: 8,
4713 },
4714 ));
4715 r.test_only_dependencies
4716 .push(crate::results::TestOnlyDependencyFinding::with_actions(
4717 TestOnlyDependency {
4718 package_name: "vitest".to_string(),
4719 path: PathBuf::from("package.json"),
4720 line: 10,
4721 },
4722 ));
4723 r.boundary_violations.push(
4724 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
4725 crate::results::BoundaryViolation {
4726 from_path: PathBuf::from("src/ui/btn.ts"),
4727 to_path: PathBuf::from("src/db/query.ts"),
4728 from_zone: "ui".to_string(),
4729 to_zone: "db".to_string(),
4730 import_specifier: "../db/query".to_string(),
4731 line: 1,
4732 col: 0,
4733 },
4734 ),
4735 );
4736 r
4737 }
4738
4739 #[test]
4740 fn baseline_from_results_captures_all_extended_fields() {
4741 let results = make_full_results();
4742 let baseline = BaselineData::from_results(&results, Path::new(""));
4743 assert_eq!(baseline.circular_dependencies.len(), 1);
4744 assert_eq!(
4745 baseline.unused_optional_dependencies,
4746 vec!["package.json:fsevents"]
4747 );
4748 assert_eq!(baseline.unused_enum_members.len(), 1);
4749 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
4750 assert_eq!(baseline.unused_class_members.len(), 1);
4751 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
4752 assert_eq!(baseline.unused_store_members.len(), 1);
4753 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
4754 assert_eq!(baseline.unresolved_imports.len(), 1);
4755 assert!(baseline.unresolved_imports[0].contains("./missing"));
4756 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
4757 assert_eq!(baseline.duplicate_exports.len(), 1);
4758 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
4759 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
4760 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
4761 assert_eq!(baseline.boundary_violations.len(), 1);
4762 assert!(baseline.boundary_violations[0].contains("->"));
4763 }
4764
4765 #[test]
4766 fn filter_removes_all_extended_baseline_issues() {
4767 let results = make_full_results();
4768 let baseline = BaselineData::from_results(&results, Path::new(""));
4769 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4770 assert!(filtered.circular_dependencies.is_empty());
4771 assert!(filtered.unused_optional_dependencies.is_empty());
4772 assert!(filtered.unused_enum_members.is_empty());
4773 assert!(filtered.unused_class_members.is_empty());
4774 assert!(filtered.unused_store_members.is_empty());
4775 assert!(filtered.unresolved_imports.is_empty());
4776 assert!(filtered.unlisted_dependencies.is_empty());
4777 assert!(filtered.duplicate_exports.is_empty());
4778 assert!(filtered.type_only_dependencies.is_empty());
4779 assert!(filtered.test_only_dependencies.is_empty());
4780 assert!(filtered.boundary_violations.is_empty());
4781 }
4782
4783 #[test]
4784 fn filter_keeps_new_circular_deps() {
4785 use crate::results::CircularDependency;
4786 let baseline = BaselineData {
4787 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
4788 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4789 };
4790 let mut results = AnalysisResults::default();
4791 results
4792 .circular_dependencies
4793 .push(CircularDependencyFinding::with_actions(
4794 CircularDependency {
4795 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4796 length: 2,
4797 line: 1,
4798 col: 0,
4799 edges: Vec::new(),
4800 is_cross_package: false,
4801 },
4802 ));
4803 results
4804 .circular_dependencies
4805 .push(CircularDependencyFinding::with_actions(
4806 CircularDependency {
4807 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
4808 length: 2,
4809 line: 5,
4810 col: 0,
4811 edges: Vec::new(),
4812 is_cross_package: false,
4813 },
4814 ));
4815 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4816 assert_eq!(filtered.circular_dependencies.len(), 1);
4817 }
4818
4819 #[test]
4820 fn filter_keeps_new_boundary_violations() {
4821 use crate::results::BoundaryViolation;
4822 let baseline = BaselineData {
4823 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
4824 boundary_coverage_violations: vec![],
4825 boundary_call_violations: vec![],
4826 policy_violations: vec![],
4827 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4828 };
4829 let mut results = AnalysisResults::default();
4830 results
4831 .boundary_violations
4832 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4833 from_path: PathBuf::from("src/a.ts"),
4834 to_path: PathBuf::from("src/b.ts"),
4835 from_zone: "a".to_string(),
4836 to_zone: "b".to_string(),
4837 import_specifier: "../b".to_string(),
4838 line: 1,
4839 col: 0,
4840 }));
4841 results
4842 .boundary_violations
4843 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4844 from_path: PathBuf::from("src/new.ts"),
4845 to_path: PathBuf::from("src/secret.ts"),
4846 from_zone: "new".to_string(),
4847 to_zone: "secret".to_string(),
4848 import_specifier: "../secret".to_string(),
4849 line: 1,
4850 col: 0,
4851 }));
4852 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4853 assert_eq!(filtered.boundary_violations.len(), 1);
4854 }
4855
4856 #[test]
4857 fn health_targets_baseline_filters_known() {
4858 let root = PathBuf::from("/project");
4859 let targets = vec![
4860 fallow_output::RefactoringTarget {
4861 path: root.join("src/complex.ts"),
4862 priority: 80.0,
4863 efficiency: 40.0,
4864 recommendation: "Split file".to_string(),
4865 category: fallow_output::RecommendationCategory::SplitHighImpact,
4866 effort: fallow_output::EffortEstimate::Medium,
4867 confidence: fallow_output::Confidence::Medium,
4868 factors: vec![],
4869 evidence: None,
4870 },
4871 fallow_output::RefactoringTarget {
4872 path: root.join("src/new-issue.ts"),
4873 priority: 60.0,
4874 efficiency: 30.0,
4875 recommendation: "Extract function".to_string(),
4876 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4877 effort: fallow_output::EffortEstimate::Low,
4878 confidence: fallow_output::Confidence::High,
4879 factors: vec![],
4880 evidence: None,
4881 },
4882 ];
4883 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
4884 let filtered = filter_new_health_targets(targets, &baseline, &root);
4885 assert_eq!(filtered.len(), 1);
4886 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
4887 }
4888
4889 #[test]
4890 fn duplicate_export_key_is_sorted() {
4891 use crate::results::{DuplicateExport, DuplicateLocation};
4892 let dup_ab = DuplicateExport {
4893 export_name: "foo".to_string(),
4894 locations: vec![
4895 DuplicateLocation {
4896 path: PathBuf::from("src/a.ts"),
4897 line: 1,
4898 col: 0,
4899 },
4900 DuplicateLocation {
4901 path: PathBuf::from("src/b.ts"),
4902 line: 5,
4903 col: 0,
4904 },
4905 ],
4906 };
4907 let dup_ba = DuplicateExport {
4908 export_name: "foo".to_string(),
4909 locations: vec![
4910 DuplicateLocation {
4911 path: PathBuf::from("src/b.ts"),
4912 line: 5,
4913 col: 0,
4914 },
4915 DuplicateLocation {
4916 path: PathBuf::from("src/a.ts"),
4917 line: 1,
4918 col: 0,
4919 },
4920 ],
4921 };
4922 assert_eq!(
4923 super::duplicate_export_key(&dup_ab, Path::new("")),
4924 super::duplicate_export_key(&dup_ba, Path::new("")),
4925 );
4926 }
4927
4928 #[test]
4929 fn boundary_violation_key_format() {
4930 use crate::results::BoundaryViolation;
4931 let v = BoundaryViolation {
4932 from_path: PathBuf::from("src/ui/btn.ts"),
4933 to_path: PathBuf::from("src/db/query.ts"),
4934 from_zone: "ui".to_string(),
4935 to_zone: "db".to_string(),
4936 import_specifier: "../db/query".to_string(),
4937 line: 1,
4938 col: 0,
4939 };
4940 let key = super::boundary_violation_key(&v, Path::new(""));
4941 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
4942 }
4943
4944 fn make_absolute_results(root: &str) -> AnalysisResults {
4946 use crate::results::*;
4947 use crate::source::MemberKind;
4948
4949 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
4950
4951 AnalysisResults {
4952 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4953 path: p("src/old.ts"),
4954 })],
4955 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
4956 path: p("src/utils.ts"),
4957 export_name: "helper".to_string(),
4958 is_type_only: false,
4959 line: 5,
4960 col: 0,
4961 span_start: 40,
4962 is_re_export: false,
4963 deprecated: false,
4964 deprecated_reason: None,
4965 })],
4966 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
4967 package_name: "lodash-es".to_string(),
4968 location: DependencyLocation::Dependencies,
4969 path: p("packages/app/package.json"),
4970 line: 5,
4971 used_in_workspaces: Vec::new(),
4972 })],
4973 circular_dependencies: vec![CircularDependencyFinding::with_actions(
4974 CircularDependency {
4975 files: vec![p("src/a.ts"), p("src/b.ts")],
4976 length: 2,
4977 line: 1,
4978 col: 0,
4979 edges: Vec::new(),
4980 is_cross_package: false,
4981 },
4982 )],
4983 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
4984 path: p("src/enums.ts"),
4985 parent_name: "Status".to_string(),
4986 member_name: "Deprecated".to_string(),
4987 kind: MemberKind::EnumMember,
4988 line: 8,
4989 col: 0,
4990 })],
4991 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
4992 path: p("src/service.ts"),
4993 parent_name: "UserService".to_string(),
4994 member_name: "legacy".to_string(),
4995 kind: MemberKind::ClassMethod,
4996 line: 42,
4997 col: 0,
4998 })],
4999 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
5000 path: p("src/store.ts"),
5001 parent_name: "useStore".to_string(),
5002 member_name: "legacyAction".to_string(),
5003 kind: MemberKind::StoreMember,
5004 line: 17,
5005 col: 0,
5006 })],
5007 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
5008 path: p("src/app.ts"),
5009 specifier: "./missing".to_string(),
5010 line: 3,
5011 col: 0,
5012 specifier_col: 0,
5013 })],
5014 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
5015 export_name: "Config".to_string(),
5016 locations: vec![
5017 DuplicateLocation {
5018 path: p("src/a.ts"),
5019 line: 1,
5020 col: 0,
5021 },
5022 DuplicateLocation {
5023 path: p("src/b.ts"),
5024 line: 5,
5025 col: 0,
5026 },
5027 ],
5028 })],
5029 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
5030 from_path: p("src/ui/btn.ts"),
5031 to_path: p("src/db/query.ts"),
5032 from_zone: "ui".to_string(),
5033 to_zone: "db".to_string(),
5034 import_specifier: "../db/query".to_string(),
5035 line: 1,
5036 col: 0,
5037 })],
5038 ..Default::default()
5039 }
5040 }
5041
5042 #[test]
5045 fn baseline_keys_are_relative_to_root() {
5046 let local_root = Path::new("/Users/dev/project");
5047 let results = make_absolute_results("/Users/dev/project");
5048 let baseline = BaselineData::from_results(&results, local_root);
5049
5050 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
5051 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
5052 assert_eq!(
5053 baseline.unused_dependencies,
5054 vec!["packages/app/package.json:lodash-es"]
5055 );
5056 assert_eq!(
5057 baseline.boundary_violations,
5058 vec!["src/ui/btn.ts->src/db/query.ts"]
5059 );
5060 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
5061 assert_eq!(
5062 baseline.unused_enum_members,
5063 vec!["src/enums.ts:Status.Deprecated"]
5064 );
5065 assert_eq!(
5066 baseline.unused_class_members,
5067 vec!["src/service.ts:UserService.legacy"]
5068 );
5069 assert_eq!(
5070 baseline.unused_store_members,
5071 vec!["src/store.ts:useStore.legacyAction"]
5072 );
5073 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
5074 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
5075
5076 let ci_root = Path::new("/home/runner/work/project/project");
5077 let ci_results = make_absolute_results("/home/runner/work/project/project");
5078
5079 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
5080 assert!(filtered.unused_files.is_empty(), "unused files");
5081 assert!(filtered.unused_exports.is_empty(), "unused exports");
5082 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
5083 assert!(
5084 filtered.boundary_violations.is_empty(),
5085 "boundary violations"
5086 );
5087 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
5088 assert!(filtered.unused_enum_members.is_empty(), "enum members");
5089 assert!(filtered.unused_class_members.is_empty(), "class members");
5090 assert!(filtered.unused_store_members.is_empty(), "store members");
5091 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
5092 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
5093 }
5094
5095 #[test]
5096 fn stale_suppression_baseline_keys_include_missing_reason_state() {
5097 let root = Path::new("/project");
5098 let stale = crate::results::StaleSuppression {
5099 path: root.join("src/file.ts"),
5100 line: 1,
5101 col: 0,
5102 origin: crate::results::SuppressionOrigin::Comment {
5103 issue_kind: Some("unused-export".to_string()),
5104 reason: None,
5105 is_file_level: false,
5106 kind_known: true,
5107 },
5108 missing_reason: false,
5109 actions: crate::results::StaleSuppression::actions_for(false),
5110 effective_severity: None,
5111 };
5112 let missing = crate::results::StaleSuppression {
5113 missing_reason: true,
5114 actions: crate::results::StaleSuppression::actions_for(true),
5115 ..stale.clone()
5116 };
5117 let results = AnalysisResults {
5118 stale_suppressions: vec![stale, missing],
5119 ..Default::default()
5120 };
5121 let baseline = BaselineData::from_results(&results, root);
5122
5123 assert_eq!(
5124 baseline.stale_suppressions,
5125 vec![
5126 "stale-suppression:src/file.ts:1",
5127 "missing-suppression-reason:src/file.ts:1",
5128 ]
5129 );
5130
5131 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
5132 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
5133 let filtered = filter_new_issues(results, &legacy_baseline, root);
5134 assert!(filtered.stale_suppressions.is_empty());
5135 }
5136
5137 fn runtime_finding(
5138 id: &str,
5139 stable_id: Option<&str>,
5140 line: u32,
5141 source_hash: Option<&str>,
5142 ) -> fallow_output::RuntimeCoverageFinding {
5143 fallow_output::RuntimeCoverageFinding {
5144 id: id.to_owned(),
5145 stable_id: stable_id.map(str::to_owned),
5146 source_hash: source_hash.map(str::to_owned),
5147 path: PathBuf::from("src/a.ts"),
5148 function: "alpha".to_owned(),
5149 line,
5150 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
5151 invocations: Some(0),
5152 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
5153 evidence: fallow_output::RuntimeCoverageEvidence {
5154 static_status: "used".to_owned(),
5155 test_coverage: "not_covered".to_owned(),
5156 test_only_reference: None,
5157 v8_tracking: "tracked".to_owned(),
5158 untracked_reason: None,
5159 observation_days: 1,
5160 deployments_observed: 1,
5161 },
5162 actions: vec![],
5163 discriminators: None,
5164 }
5165 }
5166
5167 #[test]
5168 fn legacy_prod_baseline_still_suppresses_finding() {
5169 let baseline = HealthBaselineData {
5170 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
5171 ..HealthBaselineData::default()
5172 };
5173 let findings = vec![runtime_finding(
5174 "fallow:prod:deadbeef",
5175 Some("fallow:fn:00000001"),
5176 14,
5177 None,
5178 )];
5179 let filtered =
5180 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5181 assert!(filtered.is_empty(), "legacy prod id must still suppress");
5182 }
5183
5184 #[test]
5185 fn source_hash_baseline_survives_line_move() {
5186 let root = Path::new("/repo");
5187 let baselined = runtime_finding(
5188 "fallow:prod:deadbeef",
5189 Some("fallow:fn:00000001"),
5190 14,
5191 Some("0123456789abcdef"),
5192 );
5193 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
5194 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
5195
5196 let findings = vec![runtime_finding(
5197 "fallow:prod:99999999",
5198 Some("fallow:fn:cafe0002"),
5199 40,
5200 Some("0123456789abcdef"),
5201 )];
5202 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
5203 assert!(
5204 filtered.is_empty(),
5205 "source_hash baseline must survive a line move despite a changed stable_id and id"
5206 );
5207 }
5208
5209 #[test]
5210 fn unbaselined_finding_is_reported() {
5211 let baseline = HealthBaselineData {
5212 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
5213 ..HealthBaselineData::default()
5214 };
5215 let findings = vec![runtime_finding(
5216 "fallow:prod:abc1234d",
5217 Some("fallow:fn:beefcafe"),
5218 7,
5219 None,
5220 )];
5221 let filtered =
5222 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
5223 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
5224 }
5225}