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]
152 pub fn to_envelope(&self, moved_entries: usize) -> fallow_output::BaselineStaleness {
153 let warning = self.warning();
154 fallow_output::BaselineStaleness {
155 baseline_entries: self.entries,
156 matched_entries: self.matched,
157 stale_entries: self.stale_entries(),
158 current_findings: self.current_findings,
159 change_scoped: self.change_scoped,
160 stale: warning != BaselineStalenessWarning::None,
161 warning: match warning {
162 BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
163 BaselineStalenessWarning::ZeroOverlap => {
164 fallow_output::BaselineStalenessAdvisory::ZeroOverlap
165 }
166 BaselineStalenessWarning::Partial => {
167 fallow_output::BaselineStalenessAdvisory::Partial
168 }
169 },
170 gate_trips: self.trips_gate(),
171 moved_entries,
172 }
173 }
174}
175
176#[must_use]
179pub const fn stale_baseline_gate_trips(
180 entries: usize,
181 matched: usize,
182 change_scoped: bool,
183) -> bool {
184 !change_scoped && entries > 0 && matched < entries
185}
186
187#[derive(serde::Serialize, serde::Deserialize)]
189pub struct BaselineData {
190 #[serde(default)]
194 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
195 unused_files: Vec<String>,
196 unused_exports: Vec<String>,
197 unused_types: Vec<String>,
198 #[serde(default)]
199 private_type_leaks: Vec<String>,
200 unused_dependencies: Vec<String>,
204 unused_dev_dependencies: Vec<String>,
208 #[serde(default)]
210 circular_dependencies: Vec<String>,
211 #[serde(default)]
216 re_export_cycles: Vec<String>,
217 #[serde(default)]
221 unused_optional_dependencies: Vec<String>,
222 #[serde(default)]
224 unused_enum_members: Vec<String>,
225 #[serde(default)]
227 unused_class_members: Vec<String>,
228 #[serde(default)]
230 unused_store_members: Vec<String>,
231 #[serde(default)]
233 unprovided_injects: Vec<String>,
234 #[serde(default)]
236 unrendered_components: Vec<String>,
237 #[serde(default)]
239 unused_component_props: Vec<String>,
240 #[serde(default)]
242 unused_component_emits: Vec<String>,
243 #[serde(default)]
245 unused_component_inputs: Vec<String>,
246 #[serde(default)]
248 unused_component_outputs: Vec<String>,
249 #[serde(default)]
251 unused_svelte_events: Vec<String>,
252 #[serde(default)]
254 unused_server_actions: Vec<String>,
255 #[serde(default)]
257 unused_load_data_keys: Vec<String>,
258 #[serde(default)]
260 unresolved_imports: Vec<String>,
261 #[serde(default)]
263 unlisted_dependencies: Vec<String>,
264 #[serde(default)]
266 duplicate_exports: Vec<String>,
267 #[serde(default)]
271 type_only_dependencies: Vec<String>,
272 #[serde(default)]
276 test_only_dependencies: Vec<String>,
277 #[serde(default)]
279 dev_dependencies_in_production: Vec<String>,
280 #[serde(default)]
282 boundary_violations: Vec<String>,
283 #[serde(default)]
285 boundary_coverage_violations: Vec<String>,
286 #[serde(default)]
288 boundary_call_violations: Vec<String>,
289 #[serde(default)]
291 policy_violations: Vec<String>,
292 #[serde(default)]
294 stale_suppressions: Vec<String>,
295 #[serde(default)]
297 unused_catalog_entries: Vec<String>,
298 #[serde(default)]
300 empty_catalog_groups: Vec<String>,
301 #[serde(default)]
303 unresolved_catalog_references: Vec<String>,
304 #[serde(default)]
306 unused_dependency_overrides: Vec<String>,
307 #[serde(default)]
309 misconfigured_dependency_overrides: Vec<String>,
310 #[serde(default)]
312 invalid_client_exports: Vec<String>,
313 #[serde(default)]
315 mixed_client_server_barrels: Vec<String>,
316 #[serde(default)]
319 misplaced_directives: Vec<String>,
320 #[serde(default)]
322 route_collisions: Vec<String>,
323 #[serde(default)]
325 dynamic_segment_name_conflicts: Vec<String>,
326}
327
328impl BaselineData {
329 pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
332 Self::from_results_with_identity(
333 results,
334 root,
335 fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
336 )
337 }
338
339 pub fn from_results_with_identity(
343 results: &crate::results::AnalysisResults,
344 root: &Path,
345 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
346 ) -> Self {
347 let file_exports = baseline_file_export_keys(results, root);
348 let member_imports = baseline_member_import_keys(results, root);
349 let dependencies = baseline_dependency_keys(results, root);
350 let graph = baseline_graph_keys(results, root);
351 let catalog = baseline_catalog_keys(results, root);
352
353 Self {
354 analysis_identity,
355 unused_files: file_exports.unused_files,
356 unused_exports: file_exports.unused_exports,
357 unused_types: file_exports.unused_types,
358 private_type_leaks: file_exports.private_type_leaks,
359 unused_dependencies: dependencies.unused,
360 unused_dev_dependencies: dependencies.unused_dev,
361 circular_dependencies: graph.circular_dependencies,
362 re_export_cycles: graph.re_export_cycles,
363 unused_optional_dependencies: dependencies.unused_optional,
364 unused_enum_members: member_imports.unused_enum_members,
365 unused_class_members: member_imports.unused_class_members,
366 unused_store_members: member_imports.unused_store_members,
367 unprovided_injects: member_imports.unprovided_injects,
368 unrendered_components: member_imports.unrendered_components,
369 unused_component_props: member_imports.unused_component_props,
370 unused_component_emits: member_imports.unused_component_emits,
371 unused_component_inputs: member_imports.unused_component_inputs,
372 unused_component_outputs: member_imports.unused_component_outputs,
373 unused_svelte_events: member_imports.unused_svelte_events,
374 unused_server_actions: member_imports.unused_server_actions,
375 unused_load_data_keys: member_imports.unused_load_data_keys,
376 unresolved_imports: member_imports.unresolved_imports,
377 unlisted_dependencies: dependencies.unlisted,
378 duplicate_exports: member_imports.duplicate_exports,
379 type_only_dependencies: dependencies.type_only,
380 test_only_dependencies: dependencies.test_only,
381 dev_dependencies_in_production: dependencies.dev_in_prod,
382 boundary_violations: graph.boundary_violations,
383 boundary_coverage_violations: graph.boundary_coverage_violations,
384 boundary_call_violations: graph.boundary_call_violations,
385 policy_violations: graph.policy_violations,
386 stale_suppressions: member_imports.stale_suppressions,
387 unused_catalog_entries: catalog.unused_catalog_entries,
388 empty_catalog_groups: catalog.empty_catalog_groups,
389 unresolved_catalog_references: catalog.unresolved_catalog_references,
390 unused_dependency_overrides: catalog.unused_dependency_overrides,
391 misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
392 invalid_client_exports: file_exports.invalid_client_exports,
393 mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
394 misplaced_directives: file_exports.misplaced_directives,
395 route_collisions: file_exports.route_collisions,
396 dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
397 }
398 }
399
400 #[must_use]
402 pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
403 &self.analysis_identity
404 }
405
406 pub fn total_entries(&self) -> usize {
408 self.unused_files.len()
409 + self.unused_exports.len()
410 + self.unused_types.len()
411 + self.private_type_leaks.len()
412 + self.unused_dependencies.len()
413 + self.unused_dev_dependencies.len()
414 + self.circular_dependencies.len()
415 + self.re_export_cycles.len()
416 + self.unused_optional_dependencies.len()
417 + self.unused_enum_members.len()
418 + self.unused_class_members.len()
419 + self.unused_store_members.len()
420 + self.unprovided_injects.len()
421 + self.unrendered_components.len()
422 + self.unused_component_props.len()
423 + self.unused_component_emits.len()
424 + self.unused_component_inputs.len()
425 + self.unused_component_outputs.len()
426 + self.unused_svelte_events.len()
427 + self.unused_server_actions.len()
428 + self.unused_load_data_keys.len()
429 + self.unresolved_imports.len()
430 + self.unlisted_dependencies.len()
431 + self.duplicate_exports.len()
432 + self.type_only_dependencies.len()
433 + self.test_only_dependencies.len()
434 + self.dev_dependencies_in_production.len()
435 + self.boundary_violations.len()
436 + self.boundary_coverage_violations.len()
437 + self.boundary_call_violations.len()
438 + self.policy_violations.len()
439 + self.stale_suppressions.len()
440 + self.unused_catalog_entries.len()
441 + self.empty_catalog_groups.len()
442 + self.unresolved_catalog_references.len()
443 + self.unused_dependency_overrides.len()
444 + self.misconfigured_dependency_overrides.len()
445 + self.invalid_client_exports.len()
446 + self.mixed_client_server_barrels.len()
447 + self.misplaced_directives.len()
448 + self.route_collisions.len()
449 + self.dynamic_segment_name_conflicts.len()
450 }
451}
452
453struct BaselineFileExportKeys {
454 unused_files: Vec<String>,
455 unused_exports: Vec<String>,
456 unused_types: Vec<String>,
457 private_type_leaks: Vec<String>,
458 invalid_client_exports: Vec<String>,
459 mixed_client_server_barrels: Vec<String>,
460 misplaced_directives: Vec<String>,
461 route_collisions: Vec<String>,
462 dynamic_segment_name_conflicts: Vec<String>,
463}
464
465fn baseline_file_export_keys(
466 results: &crate::results::AnalysisResults,
467 root: &Path,
468) -> BaselineFileExportKeys {
469 BaselineFileExportKeys {
470 unused_files: results
471 .unused_files
472 .iter()
473 .map(|f| relative_path(&f.file.path, root))
474 .collect(),
475 unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
476 unused_types: unused_type_baseline_keys(&results.unused_types, root),
477 private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
478 invalid_client_exports: invalid_client_export_baseline_keys(
479 &results.invalid_client_exports,
480 root,
481 ),
482 mixed_client_server_barrels: barrel_baseline_keys(
483 &results.mixed_client_server_barrels,
484 root,
485 ),
486 misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
487 route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
488 dynamic_segment_name_conflicts: results
489 .dynamic_segment_name_conflicts
490 .iter()
491 .map(|c| {
492 format!(
493 "{}:{}",
494 relative_path(&c.conflict.path, root),
495 c.conflict.position
496 )
497 })
498 .collect(),
499 }
500}
501
502fn unused_export_baseline_keys(
503 items: &[crate::results::UnusedExportFinding],
504 root: &Path,
505) -> Vec<String> {
506 items
507 .iter()
508 .map(|e| {
509 format!(
510 "{}:{}",
511 relative_path(&e.export.path, root),
512 e.export.export_name
513 )
514 })
515 .collect()
516}
517
518fn unused_type_baseline_keys(
519 items: &[crate::results::UnusedTypeFinding],
520 root: &Path,
521) -> Vec<String> {
522 items
523 .iter()
524 .map(|e| {
525 format!(
526 "{}:{}",
527 relative_path(&e.export.path, root),
528 e.export.export_name
529 )
530 })
531 .collect()
532}
533
534fn invalid_client_export_baseline_keys(
535 items: &[crate::results::InvalidClientExportFinding],
536 root: &Path,
537) -> Vec<String> {
538 items
539 .iter()
540 .map(|e| {
541 format!(
542 "{}:{}",
543 relative_path(&e.export.path, root),
544 e.export.export_name
545 )
546 })
547 .collect()
548}
549
550fn private_type_leak_baseline_keys(
551 items: &[crate::results::PrivateTypeLeakFinding],
552 root: &Path,
553) -> Vec<String> {
554 items
555 .iter()
556 .map(|e| {
557 format!(
558 "{}:{}->{}",
559 relative_path(&e.leak.path, root),
560 e.leak.export_name,
561 e.leak.type_name
562 )
563 })
564 .collect()
565}
566
567fn barrel_baseline_keys(
568 items: &[crate::results::MixedClientServerBarrelFinding],
569 root: &Path,
570) -> Vec<String> {
571 items
572 .iter()
573 .map(|b| {
574 format!(
575 "{}:{}:{}",
576 relative_path(&b.barrel.path, root),
577 b.barrel.client_origin,
578 b.barrel.server_origin
579 )
580 })
581 .collect()
582}
583
584fn directive_baseline_keys(
585 items: &[crate::results::MisplacedDirectiveFinding],
586 root: &Path,
587) -> Vec<String> {
588 items
589 .iter()
590 .map(|d| {
591 format!(
592 "{}:{}:{}",
593 relative_path(&d.directive_site.path, root),
594 d.directive_site.line,
595 d.directive_site.directive
596 )
597 })
598 .collect()
599}
600
601fn route_collision_baseline_keys(
602 items: &[crate::results::RouteCollisionFinding],
603 root: &Path,
604) -> Vec<String> {
605 items
606 .iter()
607 .map(|c| {
608 format!(
609 "{}:{}",
610 relative_path(&c.collision.path, root),
611 c.collision.url
612 )
613 })
614 .collect()
615}
616
617struct BaselineMemberImportKeys {
618 unused_enum_members: Vec<String>,
619 unused_class_members: Vec<String>,
620 unused_store_members: Vec<String>,
621 unprovided_injects: Vec<String>,
622 unrendered_components: Vec<String>,
623 unused_component_props: Vec<String>,
624 unused_component_emits: Vec<String>,
625 unused_component_inputs: Vec<String>,
626 unused_component_outputs: Vec<String>,
627 unused_svelte_events: Vec<String>,
628 unused_server_actions: Vec<String>,
629 unused_load_data_keys: Vec<String>,
630 unresolved_imports: Vec<String>,
631 duplicate_exports: Vec<String>,
632 stale_suppressions: Vec<String>,
633}
634
635fn baseline_member_import_keys(
636 results: &crate::results::AnalysisResults,
637 root: &Path,
638) -> BaselineMemberImportKeys {
639 BaselineMemberImportKeys {
640 unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
641 unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
642 unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
643 unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
644 unrendered_components: component_baseline_keys(&results.unrendered_components, root),
645 unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
646 unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
647 unused_component_inputs: component_input_baseline_keys(
648 &results.unused_component_inputs,
649 root,
650 ),
651 unused_component_outputs: component_output_baseline_keys(
652 &results.unused_component_outputs,
653 root,
654 ),
655 unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
656 unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
657 unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
658 unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
659 duplicate_exports: results
660 .duplicate_exports
661 .iter()
662 .map(|d| duplicate_export_key(&d.export, root))
663 .collect(),
664 stale_suppressions: results
665 .stale_suppressions
666 .iter()
667 .map(|s| stale_suppression_baseline_key(s, root))
668 .collect(),
669 }
670}
671
672fn stale_suppression_baseline_key(
673 suppression: &crate::results::StaleSuppression,
674 root: &Path,
675) -> String {
676 let rule_id = if suppression.missing_reason {
677 "missing-suppression-reason"
678 } else {
679 "stale-suppression"
680 };
681 format!(
682 "{rule_id}:{}:{}",
683 relative_path(&suppression.path, root),
684 suppression.line
685 )
686}
687
688fn enum_member_baseline_keys(
689 items: &[crate::results::UnusedEnumMemberFinding],
690 root: &Path,
691) -> Vec<String> {
692 items
693 .iter()
694 .map(|m| unused_member_baseline_key(&m.member, root))
695 .collect()
696}
697
698fn class_member_baseline_keys(
699 items: &[crate::results::UnusedClassMemberFinding],
700 root: &Path,
701) -> Vec<String> {
702 items
703 .iter()
704 .map(|m| unused_member_baseline_key(&m.member, root))
705 .collect()
706}
707
708fn store_member_baseline_keys(
709 items: &[crate::results::UnusedStoreMemberFinding],
710 root: &Path,
711) -> Vec<String> {
712 items
713 .iter()
714 .map(|m| unused_member_baseline_key(&m.member, root))
715 .collect()
716}
717
718fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
719 format!(
720 "{}:{}.{}",
721 relative_path(&member.path, root),
722 member.parent_name,
723 member.member_name
724 )
725}
726
727fn inject_baseline_keys(
728 items: &[crate::results::UnprovidedInjectFinding],
729 root: &Path,
730) -> Vec<String> {
731 items
732 .iter()
733 .map(|f| {
734 format!(
735 "{}:{}",
736 relative_path(&f.inject.path, root),
737 f.inject.key_name
738 )
739 })
740 .collect()
741}
742
743fn component_baseline_keys(
744 items: &[crate::results::UnrenderedComponentFinding],
745 root: &Path,
746) -> Vec<String> {
747 items
748 .iter()
749 .map(|c| {
750 format!(
751 "{}:{}",
752 relative_path(&c.component.path, root),
753 c.component.component_name
754 )
755 })
756 .collect()
757}
758
759fn component_prop_baseline_keys(
760 items: &[crate::results::UnusedComponentPropFinding],
761 root: &Path,
762) -> Vec<String> {
763 items
764 .iter()
765 .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
766 .collect()
767}
768
769fn component_emit_baseline_keys(
770 items: &[crate::results::UnusedComponentEmitFinding],
771 root: &Path,
772) -> Vec<String> {
773 items
774 .iter()
775 .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
776 .collect()
777}
778
779fn component_input_baseline_keys(
780 items: &[crate::results::UnusedComponentInputFinding],
781 root: &Path,
782) -> Vec<String> {
783 items
784 .iter()
785 .map(|i| {
786 format!(
787 "{}:{}",
788 relative_path(&i.input.path, root),
789 i.input.input_name
790 )
791 })
792 .collect()
793}
794
795fn component_output_baseline_keys(
796 items: &[crate::results::UnusedComponentOutputFinding],
797 root: &Path,
798) -> Vec<String> {
799 items
800 .iter()
801 .map(|o| {
802 format!(
803 "{}:{}",
804 relative_path(&o.output.path, root),
805 o.output.output_name
806 )
807 })
808 .collect()
809}
810
811fn svelte_event_baseline_keys(
812 items: &[crate::results::UnusedSvelteEventFinding],
813 root: &Path,
814) -> Vec<String> {
815 items
816 .iter()
817 .map(|e| {
818 format!(
819 "{}:{}",
820 relative_path(&e.event.path, root),
821 e.event.event_name
822 )
823 })
824 .collect()
825}
826
827fn server_action_baseline_keys(
828 items: &[crate::results::UnusedServerActionFinding],
829 root: &Path,
830) -> Vec<String> {
831 items
832 .iter()
833 .map(|a| {
834 format!(
835 "{}:{}",
836 relative_path(&a.action.path, root),
837 a.action.action_name
838 )
839 })
840 .collect()
841}
842
843fn load_data_key_baseline_keys(
844 items: &[crate::results::UnusedLoadDataKeyFinding],
845 root: &Path,
846) -> Vec<String> {
847 items
848 .iter()
849 .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
850 .collect()
851}
852
853fn unresolved_import_baseline_keys(
854 items: &[crate::results::UnresolvedImportFinding],
855 root: &Path,
856) -> Vec<String> {
857 items
858 .iter()
859 .map(|i| {
860 format!(
861 "{}:{}",
862 relative_path(&i.import.path, root),
863 i.import.specifier
864 )
865 })
866 .collect()
867}
868
869struct BaselineDependencyKeys {
870 unused: Vec<String>,
871 unused_dev: Vec<String>,
872 unused_optional: Vec<String>,
873 unlisted: Vec<String>,
874 type_only: Vec<String>,
875 test_only: Vec<String>,
876 dev_in_prod: Vec<String>,
877}
878
879fn baseline_dependency_keys(
880 results: &crate::results::AnalysisResults,
881 root: &Path,
882) -> BaselineDependencyKeys {
883 BaselineDependencyKeys {
884 unused: results
885 .unused_dependencies
886 .iter()
887 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
888 .collect(),
889 unused_dev: results
890 .unused_dev_dependencies
891 .iter()
892 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
893 .collect(),
894 unused_optional: results
895 .unused_optional_dependencies
896 .iter()
897 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
898 .collect(),
899 unlisted: results
900 .unlisted_dependencies
901 .iter()
902 .map(|d| d.dep.package_name.clone())
903 .collect(),
904 type_only: results
905 .type_only_dependencies
906 .iter()
907 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
908 .collect(),
909 test_only: results
910 .test_only_dependencies
911 .iter()
912 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
913 .collect(),
914 dev_in_prod: results
915 .dev_dependencies_in_production
916 .iter()
917 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
918 .collect(),
919 }
920}
921
922struct BaselineGraphKeys {
923 circular_dependencies: Vec<String>,
924 re_export_cycles: Vec<String>,
925 boundary_violations: Vec<String>,
926 boundary_coverage_violations: Vec<String>,
927 boundary_call_violations: Vec<String>,
928 policy_violations: Vec<String>,
929}
930
931fn baseline_graph_keys(
932 results: &crate::results::AnalysisResults,
933 root: &Path,
934) -> BaselineGraphKeys {
935 BaselineGraphKeys {
936 circular_dependencies: results
937 .circular_dependencies
938 .iter()
939 .map(|c| circular_dep_key(&c.cycle, root))
940 .collect(),
941 re_export_cycles: results
942 .re_export_cycles
943 .iter()
944 .map(|c| re_export_cycle_key(&c.cycle, root))
945 .collect(),
946 boundary_violations: results
947 .boundary_violations
948 .iter()
949 .map(|v| boundary_violation_key(&v.violation, root))
950 .collect(),
951 boundary_coverage_violations: results
952 .boundary_coverage_violations
953 .iter()
954 .map(|v| relative_path(&v.violation.path, root))
955 .collect(),
956 boundary_call_violations: results
957 .boundary_call_violations
958 .iter()
959 .map(|v| boundary_call_violation_key(&v.violation, root))
960 .collect(),
961 policy_violations: results
962 .policy_violations
963 .iter()
964 .map(|v| policy_violation_key(&v.violation, root))
965 .collect(),
966 }
967}
968
969struct BaselineCatalogKeys {
970 unused_catalog_entries: Vec<String>,
971 empty_catalog_groups: Vec<String>,
972 unresolved_catalog_references: Vec<String>,
973 unused_dependency_overrides: Vec<String>,
974 misconfigured_dependency_overrides: Vec<String>,
975}
976
977fn baseline_catalog_keys(
978 results: &crate::results::AnalysisResults,
979 root: &Path,
980) -> BaselineCatalogKeys {
981 BaselineCatalogKeys {
982 unused_catalog_entries: results
983 .unused_catalog_entries
984 .iter()
985 .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
986 .collect(),
987 empty_catalog_groups: results
988 .empty_catalog_groups
989 .iter()
990 .map(|g| g.group.catalog_name.clone())
991 .collect(),
992 unresolved_catalog_references: results
993 .unresolved_catalog_references
994 .iter()
995 .map(|r| {
996 format!(
997 "{}:{}:{}:{}",
998 relative_path(&r.reference.path, root),
999 r.reference.line,
1000 r.reference.catalog_name,
1001 r.reference.entry_name,
1002 )
1003 })
1004 .collect(),
1005 unused_dependency_overrides: results
1006 .unused_dependency_overrides
1007 .iter()
1008 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1009 .collect(),
1010 misconfigured_dependency_overrides: results
1011 .misconfigured_dependency_overrides
1012 .iter()
1013 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1014 .collect(),
1015 }
1016}
1017
1018fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
1020 format!(
1021 "{}->{}",
1022 relative_path(&v.from_path, root),
1023 relative_path(&v.to_path, root),
1024 )
1025}
1026
1027fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
1029 format!("{}:{}", relative_path(&v.path, root), v.callee)
1030}
1031
1032fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
1036 format!(
1037 "{}:{}/{}:{}",
1038 relative_path(&v.path, root),
1039 v.pack,
1040 v.rule_id,
1041 v.matched
1042 )
1043}
1044
1045fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
1047 let mut locs: Vec<String> = dup
1048 .locations
1049 .iter()
1050 .map(|l| relative_path(&l.path, root))
1051 .collect();
1052 locs.sort();
1053 format!("{}|{}", dup.export_name, locs.join("|"))
1054}
1055
1056fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
1058 let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
1059 paths.sort();
1060 paths.join("->")
1061}
1062
1063fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
1069 let kind = match cycle.kind {
1070 crate::results::ReExportCycleKind::MultiNode => "multi-node",
1071 crate::results::ReExportCycleKind::SelfLoop => "self-loop",
1072 };
1073 let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
1074 paths.sort();
1075 format!("{kind}:{}", paths.join("<->"))
1076}
1077
1078fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
1079 format!(
1080 "{}:{}->{}",
1081 relative_path(&leak.path, root),
1082 leak.export_name,
1083 leak.type_name
1084 )
1085}
1086
1087fn filter_private_type_leaks(
1088 leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
1089 baseline_keys: &[String],
1090 root: &Path,
1091) {
1092 let baseline_private_type_leaks: FxHashSet<&str> =
1093 baseline_keys.iter().map(String::as_str).collect();
1094 leaks.retain(|entry| {
1095 let key = private_type_leak_key(&entry.leak, root);
1096 !baseline_private_type_leaks.contains(key.as_str())
1097 });
1098}
1099
1100struct BaselineFilterContext<'a> {
1101 baseline: &'a BaselineData,
1102 root: &'a Path,
1103}
1104
1105impl BaselineFilterContext<'_> {
1106 fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
1107 let baseline_circular: FxHashSet<&str> = self
1108 .baseline
1109 .circular_dependencies
1110 .iter()
1111 .map(String::as_str)
1112 .collect();
1113 results.circular_dependencies.retain(|cycle| {
1114 let key = circular_dep_key(&cycle.cycle, self.root);
1115 !baseline_circular.contains(key.as_str())
1116 });
1117
1118 let baseline_re_export_cycles: FxHashSet<&str> = self
1119 .baseline
1120 .re_export_cycles
1121 .iter()
1122 .map(String::as_str)
1123 .collect();
1124 results.re_export_cycles.retain(|cycle| {
1125 let key = re_export_cycle_key(&cycle.cycle, self.root);
1126 !baseline_re_export_cycles.contains(key.as_str())
1127 });
1128
1129 self.filter_unused_members(results);
1130 self.filter_unresolved_and_exports(results);
1131 }
1132
1133 fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
1134 self.filter_enum_class_store_members(results);
1135 self.filter_component_surface_members(results);
1136 self.filter_route_action_members(results);
1137 }
1138
1139 fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1140 let baseline_enum_members: FxHashSet<&str> = self
1141 .baseline
1142 .unused_enum_members
1143 .iter()
1144 .map(String::as_str)
1145 .collect();
1146 results.unused_enum_members.retain(|member| {
1147 let key = format!(
1148 "{}:{}.{}",
1149 relative_path(&member.member.path, self.root),
1150 member.member.parent_name,
1151 member.member.member_name
1152 );
1153 !baseline_enum_members.contains(key.as_str())
1154 });
1155
1156 let baseline_class_members: FxHashSet<&str> = self
1157 .baseline
1158 .unused_class_members
1159 .iter()
1160 .map(String::as_str)
1161 .collect();
1162 results.unused_class_members.retain(|member| {
1163 let key = format!(
1164 "{}:{}.{}",
1165 relative_path(&member.member.path, self.root),
1166 member.member.parent_name,
1167 member.member.member_name
1168 );
1169 !baseline_class_members.contains(key.as_str())
1170 });
1171
1172 let baseline_store_members: FxHashSet<&str> = self
1173 .baseline
1174 .unused_store_members
1175 .iter()
1176 .map(String::as_str)
1177 .collect();
1178 results.unused_store_members.retain(|member| {
1179 let key = format!(
1180 "{}:{}.{}",
1181 relative_path(&member.member.path, self.root),
1182 member.member.parent_name,
1183 member.member.member_name
1184 );
1185 !baseline_store_members.contains(key.as_str())
1186 });
1187 }
1188
1189 fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1190 retain_new_by_keys(
1191 &mut results.unprovided_injects,
1192 &self.baseline.unprovided_injects,
1193 self.root,
1194 inject_baseline_keys,
1195 );
1196 retain_new_by_keys(
1197 &mut results.unrendered_components,
1198 &self.baseline.unrendered_components,
1199 self.root,
1200 component_baseline_keys,
1201 );
1202 retain_new_by_keys(
1203 &mut results.unused_component_props,
1204 &self.baseline.unused_component_props,
1205 self.root,
1206 component_prop_baseline_keys,
1207 );
1208 retain_new_by_keys(
1209 &mut results.unused_component_emits,
1210 &self.baseline.unused_component_emits,
1211 self.root,
1212 component_emit_baseline_keys,
1213 );
1214 retain_new_by_keys(
1215 &mut results.unused_component_inputs,
1216 &self.baseline.unused_component_inputs,
1217 self.root,
1218 component_input_baseline_keys,
1219 );
1220 retain_new_by_keys(
1221 &mut results.unused_component_outputs,
1222 &self.baseline.unused_component_outputs,
1223 self.root,
1224 component_output_baseline_keys,
1225 );
1226 retain_new_by_keys(
1227 &mut results.unused_svelte_events,
1228 &self.baseline.unused_svelte_events,
1229 self.root,
1230 svelte_event_baseline_keys,
1231 );
1232 }
1233
1234 fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1235 let baseline_unused_server_actions: FxHashSet<&str> = self
1236 .baseline
1237 .unused_server_actions
1238 .iter()
1239 .map(String::as_str)
1240 .collect();
1241 results.unused_server_actions.retain(|finding| {
1242 let key = format!(
1243 "{}:{}",
1244 relative_path(&finding.action.path, self.root),
1245 finding.action.action_name
1246 );
1247 !baseline_unused_server_actions.contains(key.as_str())
1248 });
1249
1250 let baseline_unused_load_data_keys: FxHashSet<&str> = self
1251 .baseline
1252 .unused_load_data_keys
1253 .iter()
1254 .map(String::as_str)
1255 .collect();
1256 results.unused_load_data_keys.retain(|finding| {
1257 let key = format!(
1258 "{}:{}",
1259 relative_path(&finding.key.path, self.root),
1260 finding.key.key_name
1261 );
1262 !baseline_unused_load_data_keys.contains(key.as_str())
1263 });
1264 }
1265
1266 fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1267 let baseline_unresolved: FxHashSet<&str> = self
1268 .baseline
1269 .unresolved_imports
1270 .iter()
1271 .map(String::as_str)
1272 .collect();
1273 results.unresolved_imports.retain(|import| {
1274 let key = format!(
1275 "{}:{}",
1276 relative_path(&import.import.path, self.root),
1277 import.import.specifier
1278 );
1279 !baseline_unresolved.contains(key.as_str())
1280 });
1281
1282 let baseline_unlisted: FxHashSet<&str> = self
1283 .baseline
1284 .unlisted_dependencies
1285 .iter()
1286 .map(String::as_str)
1287 .collect();
1288 results
1289 .unlisted_dependencies
1290 .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1291
1292 let baseline_dup_exports: FxHashSet<&str> = self
1293 .baseline
1294 .duplicate_exports
1295 .iter()
1296 .map(String::as_str)
1297 .collect();
1298 results.duplicate_exports.retain(|duplicate| {
1299 let key = duplicate_export_key(&duplicate.export, self.root);
1300 !baseline_dup_exports.contains(key.as_str())
1301 });
1302 }
1303
1304 fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1305 let baseline_optional_deps: FxHashSet<&str> = self
1306 .baseline
1307 .unused_optional_dependencies
1308 .iter()
1309 .map(String::as_str)
1310 .collect();
1311 results.unused_optional_dependencies.retain(|dep| {
1312 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1313 !baseline_contains_dependency(
1314 &baseline_optional_deps,
1315 &dep.dep.package_name,
1316 key.as_str(),
1317 )
1318 });
1319
1320 self.filter_type_and_test_only_dependencies(results);
1321 }
1322
1323 fn filter_type_and_test_only_dependencies(
1324 &self,
1325 results: &mut crate::results::AnalysisResults,
1326 ) {
1327 let baseline_type_only: FxHashSet<&str> = self
1328 .baseline
1329 .type_only_dependencies
1330 .iter()
1331 .map(String::as_str)
1332 .collect();
1333 results.type_only_dependencies.retain(|dep| {
1334 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1335 !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1336 });
1337
1338 let baseline_test_only: FxHashSet<&str> = self
1339 .baseline
1340 .test_only_dependencies
1341 .iter()
1342 .map(String::as_str)
1343 .collect();
1344 results.test_only_dependencies.retain(|dep| {
1345 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1346 !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1347 });
1348
1349 let baseline_dev_in_prod: FxHashSet<&str> = self
1350 .baseline
1351 .dev_dependencies_in_production
1352 .iter()
1353 .map(String::as_str)
1354 .collect();
1355 results.dev_dependencies_in_production.retain(|dep| {
1356 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1357 !baseline_contains_dependency(
1358 &baseline_dev_in_prod,
1359 &dep.dep.package_name,
1360 key.as_str(),
1361 )
1362 });
1363 }
1364
1365 fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1366 let baseline_boundary: FxHashSet<&str> = self
1367 .baseline
1368 .boundary_violations
1369 .iter()
1370 .map(String::as_str)
1371 .collect();
1372 results.boundary_violations.retain(|violation| {
1373 let key = boundary_violation_key(&violation.violation, self.root);
1374 !baseline_boundary.contains(key.as_str())
1375 });
1376
1377 self.filter_boundary_details(results);
1378 self.filter_stale_suppressions(results);
1379 self.filter_invalid_client_exports(results);
1380 self.filter_mixed_client_server_barrels(results);
1381 self.filter_misplaced_directives(results);
1382 self.filter_route_collisions(results);
1383 self.filter_dynamic_segment_name_conflicts(results);
1384 }
1385
1386 fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1387 let baseline_invalid: FxHashSet<&str> = self
1388 .baseline
1389 .invalid_client_exports
1390 .iter()
1391 .map(String::as_str)
1392 .collect();
1393 results.invalid_client_exports.retain(|finding| {
1394 let key = format!(
1395 "{}:{}",
1396 relative_path(&finding.export.path, self.root),
1397 finding.export.export_name
1398 );
1399 !baseline_invalid.contains(key.as_str())
1400 });
1401 }
1402
1403 fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1404 let baseline_barrels: FxHashSet<&str> = self
1405 .baseline
1406 .mixed_client_server_barrels
1407 .iter()
1408 .map(String::as_str)
1409 .collect();
1410 results.mixed_client_server_barrels.retain(|finding| {
1411 let key = format!(
1412 "{}:{}:{}",
1413 relative_path(&finding.barrel.path, self.root),
1414 finding.barrel.client_origin,
1415 finding.barrel.server_origin
1416 );
1417 !baseline_barrels.contains(key.as_str())
1418 });
1419 }
1420
1421 fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1422 let baseline_directives: FxHashSet<&str> = self
1423 .baseline
1424 .misplaced_directives
1425 .iter()
1426 .map(String::as_str)
1427 .collect();
1428 results.misplaced_directives.retain(|finding| {
1429 let key = format!(
1430 "{}:{}:{}",
1431 relative_path(&finding.directive_site.path, self.root),
1432 finding.directive_site.line,
1433 finding.directive_site.directive
1434 );
1435 !baseline_directives.contains(key.as_str())
1436 });
1437 }
1438
1439 fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1440 let baseline_collisions: FxHashSet<&str> = self
1441 .baseline
1442 .route_collisions
1443 .iter()
1444 .map(String::as_str)
1445 .collect();
1446 results.route_collisions.retain(|finding| {
1447 let key = format!(
1448 "{}:{}",
1449 relative_path(&finding.collision.path, self.root),
1450 finding.collision.url
1451 );
1452 !baseline_collisions.contains(key.as_str())
1453 });
1454 }
1455
1456 fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1457 let baseline_conflicts: FxHashSet<&str> = self
1458 .baseline
1459 .dynamic_segment_name_conflicts
1460 .iter()
1461 .map(String::as_str)
1462 .collect();
1463 results.dynamic_segment_name_conflicts.retain(|finding| {
1464 let key = format!(
1465 "{}:{}",
1466 relative_path(&finding.conflict.path, self.root),
1467 finding.conflict.position
1468 );
1469 !baseline_conflicts.contains(key.as_str())
1470 });
1471 }
1472
1473 fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1474 let baseline_boundary_coverage: FxHashSet<&str> = self
1475 .baseline
1476 .boundary_coverage_violations
1477 .iter()
1478 .map(String::as_str)
1479 .collect();
1480 results.boundary_coverage_violations.retain(|violation| {
1481 let key = relative_path(&violation.violation.path, self.root);
1482 !baseline_boundary_coverage.contains(key.as_str())
1483 });
1484
1485 let baseline_boundary_calls: FxHashSet<&str> = self
1486 .baseline
1487 .boundary_call_violations
1488 .iter()
1489 .map(String::as_str)
1490 .collect();
1491 results.boundary_call_violations.retain(|violation| {
1492 let key = boundary_call_violation_key(&violation.violation, self.root);
1493 !baseline_boundary_calls.contains(key.as_str())
1494 });
1495 }
1496
1497 fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1498 let baseline_stale: FxHashSet<&str> = self
1499 .baseline
1500 .stale_suppressions
1501 .iter()
1502 .map(String::as_str)
1503 .collect();
1504 results.stale_suppressions.retain(|suppression| {
1505 let key = stale_suppression_baseline_key(suppression, self.root);
1506 let legacy_key = format!(
1507 "{}:{}",
1508 relative_path(&suppression.path, self.root),
1509 suppression.line
1510 );
1511 !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
1512 });
1513 }
1514
1515 fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
1516 let baseline_catalog: FxHashSet<&str> = self
1517 .baseline
1518 .unused_catalog_entries
1519 .iter()
1520 .map(String::as_str)
1521 .collect();
1522 results.unused_catalog_entries.retain(|entry| {
1523 let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
1524 !baseline_catalog.contains(key.as_str())
1525 });
1526
1527 let baseline_empty_catalog_groups: FxHashSet<&str> = self
1528 .baseline
1529 .empty_catalog_groups
1530 .iter()
1531 .map(String::as_str)
1532 .collect();
1533 results.empty_catalog_groups.retain(|group| {
1534 !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
1535 });
1536
1537 self.filter_pnpm_references_and_overrides(results);
1538 }
1539
1540 fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
1541 let baseline_unresolved: FxHashSet<&str> = self
1542 .baseline
1543 .unresolved_catalog_references
1544 .iter()
1545 .map(String::as_str)
1546 .collect();
1547 results.unresolved_catalog_references.retain(|reference| {
1548 let key = format!(
1549 "{}:{}:{}:{}",
1550 relative_path(&reference.reference.path, self.root),
1551 reference.reference.line,
1552 reference.reference.catalog_name,
1553 reference.reference.entry_name,
1554 );
1555 !baseline_unresolved.contains(key.as_str())
1556 });
1557
1558 self.filter_pnpm_overrides(results);
1559 }
1560
1561 fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
1562 let baseline_unused_overrides: FxHashSet<&str> = self
1563 .baseline
1564 .unused_dependency_overrides
1565 .iter()
1566 .map(String::as_str)
1567 .collect();
1568 results
1569 .unused_dependency_overrides
1570 .retain(|override_entry| {
1571 let key = format!(
1572 "{}:{}",
1573 override_entry.entry.source, override_entry.entry.raw_key
1574 );
1575 !baseline_unused_overrides.contains(key.as_str())
1576 });
1577
1578 let baseline_misconfigured_overrides: FxHashSet<&str> = self
1579 .baseline
1580 .misconfigured_dependency_overrides
1581 .iter()
1582 .map(String::as_str)
1583 .collect();
1584 results
1585 .misconfigured_dependency_overrides
1586 .retain(|override_entry| {
1587 let key = format!(
1588 "{}:{}",
1589 override_entry.entry.source, override_entry.entry.raw_key
1590 );
1591 !baseline_misconfigured_overrides.contains(key.as_str())
1592 });
1593 }
1594}
1595
1596pub fn filter_new_issues(
1598 mut results: crate::results::AnalysisResults,
1599 baseline: &BaselineData,
1600 root: &Path,
1601) -> crate::results::AnalysisResults {
1602 let baseline_files: FxHashSet<&str> =
1603 baseline.unused_files.iter().map(String::as_str).collect();
1604 let baseline_exports: FxHashSet<&str> =
1605 baseline.unused_exports.iter().map(String::as_str).collect();
1606 let baseline_types: FxHashSet<&str> =
1607 baseline.unused_types.iter().map(String::as_str).collect();
1608 let baseline_deps: FxHashSet<&str> = baseline
1609 .unused_dependencies
1610 .iter()
1611 .map(String::as_str)
1612 .collect();
1613 let baseline_dev_deps: FxHashSet<&str> = baseline
1614 .unused_dev_dependencies
1615 .iter()
1616 .map(String::as_str)
1617 .collect();
1618
1619 results
1620 .unused_files
1621 .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
1622 results.unused_exports.retain(|e| {
1623 let key = format!(
1624 "{}:{}",
1625 relative_path(&e.export.path, root),
1626 e.export.export_name
1627 );
1628 !baseline_exports.contains(key.as_str())
1629 });
1630 results.unused_types.retain(|e| {
1631 let key = format!(
1632 "{}:{}",
1633 relative_path(&e.export.path, root),
1634 e.export.export_name
1635 );
1636 !baseline_types.contains(key.as_str())
1637 });
1638 filter_private_type_leaks(
1639 &mut results.private_type_leaks,
1640 &baseline.private_type_leaks,
1641 root,
1642 );
1643 results.unused_dependencies.retain(|d| {
1644 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1645 !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
1646 });
1647 results.unused_dev_dependencies.retain(|d| {
1648 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1649 !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
1650 });
1651
1652 let filter = BaselineFilterContext { baseline, root };
1653 filter.filter_cycles_and_members(&mut results);
1654 filter.filter_dependency_variants(&mut results);
1655 filter.filter_boundaries_and_suppressions(&mut results);
1656 filter.filter_pnpm_entries(&mut results);
1657
1658 results
1659}
1660
1661#[derive(Default, serde::Serialize, serde::Deserialize)]
1672pub struct DuplicationBaselineData {
1673 #[serde(default)]
1675 pub clone_groups: Vec<String>,
1676 #[serde(default)]
1678 pub clone_fingerprints: Vec<String>,
1679 #[serde(default)]
1681 pub normalized_clone_fingerprints: Vec<String>,
1682}
1683
1684impl DuplicationBaselineData {
1685 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
1687 let fingerprints =
1688 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
1689 Self {
1690 clone_groups: report
1691 .clone_groups
1692 .iter()
1693 .map(|g| clone_group_key(g, root))
1694 .collect(),
1695 clone_fingerprints: report
1696 .clone_groups
1697 .iter()
1698 .map(legacy_clone_group_fingerprint_key)
1699 .collect(),
1700 normalized_clone_fingerprints: report
1701 .clone_groups
1702 .iter()
1703 .map(|group| clone_group_fingerprint_key(group, &fingerprints))
1704 .collect(),
1705 }
1706 }
1707
1708 #[must_use]
1710 pub fn entry_count(&self) -> usize {
1711 if !self.normalized_clone_fingerprints.is_empty() {
1712 self.normalized_clone_fingerprints.len()
1713 } else if !self.clone_fingerprints.is_empty() {
1714 self.clone_fingerprints.len()
1715 } else {
1716 self.clone_groups.len()
1717 }
1718 }
1719}
1720
1721fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
1723 let mut parts: Vec<String> = group
1724 .instances
1725 .iter()
1726 .map(|i| {
1727 format!(
1728 "{}:{}-{}",
1729 relative_path(&i.file, root),
1730 i.start_line,
1731 i.end_line
1732 )
1733 })
1734 .collect();
1735 parts.sort();
1736 parts.join("|")
1737}
1738
1739fn clone_group_fingerprint_key(
1741 group: &crate::duplicates::CloneGroup,
1742 fingerprints: &crate::duplicates::CloneFingerprintSet,
1743) -> String {
1744 fingerprints.ignored_clone_key_for_group(group)
1745}
1746
1747fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
1750 let representative = group
1751 .instances
1752 .iter()
1753 .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
1754 .map_or("", |i| i.fragment.as_str());
1755 let hash = if representative.as_bytes().contains(&b'\r') {
1756 xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
1757 } else {
1758 xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
1759 };
1760 format!(
1761 "{}{:08x}:{}",
1762 crate::duplicates::FINGERPRINT_PREFIX,
1763 hash as u32,
1764 group.instances.len()
1765 )
1766}
1767
1768fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
1769 match remaining.get_mut(key) {
1770 Some(count) if *count > 0 => {
1771 *count -= 1;
1772 true
1773 }
1774 _ => false,
1775 }
1776}
1777
1778pub fn filter_new_clone_groups(
1784 mut report: DuplicationReport,
1785 baseline: &DuplicationBaselineData,
1786 root: &Path,
1787) -> DuplicationReport {
1788 if !baseline.normalized_clone_fingerprints.is_empty() {
1789 let fingerprints =
1790 crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
1791 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
1792 for key in &baseline.normalized_clone_fingerprints {
1793 *remaining.entry(key.as_str()).or_insert(0) += 1;
1794 }
1795 report.clone_groups.retain(|group| {
1796 let key = clone_group_fingerprint_key(group, &fingerprints);
1797 !consume_baseline_key(&mut remaining, &key)
1798 });
1799 } else if baseline.clone_fingerprints.is_empty() {
1800 let baseline_keys: FxHashSet<&str> =
1801 baseline.clone_groups.iter().map(String::as_str).collect();
1802 report.clone_groups.retain(|g| {
1803 let key = clone_group_key(g, root);
1804 !baseline_keys.contains(key.as_str())
1805 });
1806 } else {
1807 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
1808 for key in &baseline.clone_fingerprints {
1809 *remaining.entry(key.as_str()).or_insert(0) += 1;
1810 }
1811 report.clone_groups.retain(|group| {
1812 let key = legacy_clone_group_fingerprint_key(group);
1813 !consume_baseline_key(&mut remaining, &key)
1814 });
1815 }
1816
1817 crate::duplicates::refresh_clone_families(&mut report, root);
1818 report.stats = recompute_stats(&report);
1819
1820 report
1821}
1822
1823pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
1828 crate::duplicates::recompute_stats(report)
1829}
1830
1831#[derive(Default, serde::Serialize, serde::Deserialize)]
1845pub struct HealthBaselineData {
1846 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1848 pub(crate) findings: Vec<String>,
1849 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1851 pub(crate) finding_counts: HealthFindingCountMap,
1852 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1854 pub(crate) identity_finding_counts: HealthFindingCountMap,
1855 #[serde(default)]
1857 pub(crate) runtime_coverage_findings: Vec<String>,
1858 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1865 pub(crate) runtime_coverage_source_hashes: Vec<String>,
1866 #[serde(default)]
1868 pub(crate) target_keys: Vec<String>,
1869}
1870
1871#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1873pub struct HealthBaselineCount {
1874 count: usize,
1875}
1876
1877type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
1878
1879#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1881pub enum HealthBaselineMode {
1882 #[default]
1892 Count,
1893 Identity,
1902}
1903
1904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1905enum HealthFindingDimension {
1906 Complexity,
1907 Crap,
1908}
1909
1910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1911struct HealthFindingCategory {
1912 dimension: HealthFindingDimension,
1913 severity: fallow_output::FindingSeverity,
1914}
1915
1916impl HealthFindingCategory {
1917 const fn key(self) -> &'static str {
1918 match (self.dimension, self.severity) {
1919 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1920 "complexity_moderate"
1921 }
1922 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1923 "complexity_high"
1924 }
1925 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1926 "complexity_critical"
1927 }
1928 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1929 "crap_moderate"
1930 }
1931 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1932 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1933 "crap_critical"
1934 }
1935 }
1936 }
1937}
1938
1939const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1940 HealthFindingDimension::Complexity,
1941 HealthFindingDimension::Crap,
1942];
1943
1944impl HealthBaselineData {
1945 pub(crate) fn from_findings(
1947 findings: &[fallow_output::ComplexityViolation],
1948 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1949 targets: &[fallow_output::RefactoringTarget],
1950 root: &Path,
1951 ) -> Self {
1952 Self {
1953 findings: Vec::new(),
1954 finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
1955 identity_finding_counts: HealthFindingCountMap::new(),
1956 runtime_coverage_findings: runtime_coverage_findings
1957 .iter()
1958 .map(|f| runtime_coverage_finding_key(f, root))
1959 .collect(),
1960 runtime_coverage_source_hashes: runtime_coverage_findings
1961 .iter()
1962 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1963 .collect(),
1964 target_keys: targets
1965 .iter()
1966 .map(|t| target_baseline_key(t, root))
1967 .collect(),
1968 }
1969 }
1970
1971 pub(crate) fn finding_entry_count(&self) -> usize {
1972 if !self.finding_counts.is_empty() {
1973 self.finding_counts
1974 .values()
1975 .flat_map(BTreeMap::values)
1976 .map(|entry| entry.count)
1977 .sum()
1978 } else {
1979 self.findings.len()
1980 }
1981 }
1982
1983 #[must_use]
1988 pub(crate) fn with_identity(
1989 mut self,
1990 findings: &[fallow_output::ComplexityViolation],
1991 root: &Path,
1992 ) -> Self {
1993 self.identity_finding_counts =
1994 health_finding_counts(findings, root, HealthBaselineMode::Identity);
1995 self
1996 }
1997
1998 pub(crate) fn lacks_identity_data(&self) -> bool {
2001 self.identity_finding_counts.is_empty()
2002 && (!self.finding_counts.is_empty() || !self.findings.is_empty())
2003 }
2004
2005 fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
2006 match mode {
2007 HealthBaselineMode::Count => &self.finding_counts,
2008 HealthBaselineMode::Identity => &self.identity_finding_counts,
2009 }
2010 }
2011
2012 pub(crate) fn overlap_entries(
2013 &self,
2014 findings: &[fallow_output::ComplexityViolation],
2015 root: &Path,
2016 mode: HealthBaselineMode,
2017 ) -> HealthBaselineOverlap {
2018 let baseline_counts = self.counts_for(mode);
2019 if !baseline_counts.is_empty() {
2020 let current_counts = health_finding_counts(findings, root, mode);
2021 let direct = health_overlap_entry_count(¤t_counts, baseline_counts);
2022 let remapped = (mode == HealthBaselineMode::Identity)
2023 .then(|| {
2024 identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root)
2025 })
2026 .flatten();
2027 match remapped {
2028 Some(remapped_counts) => {
2029 let matched = health_overlap_entry_count(¤t_counts, &remapped_counts);
2030 HealthBaselineOverlap {
2031 matched_entries: matched,
2032 moved_entries: matched.saturating_sub(direct),
2033 }
2034 }
2035 None => HealthBaselineOverlap {
2036 matched_entries: direct,
2037 moved_entries: 0,
2038 },
2039 }
2040 } else {
2041 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
2042 HealthBaselineOverlap {
2043 matched_entries: findings
2044 .iter()
2045 .filter(|finding| {
2046 baseline_keys.contains(health_finding_key(finding, root).as_str())
2047 })
2048 .count(),
2049 moved_entries: 0,
2050 }
2051 }
2052 }
2053}
2054
2055pub(crate) struct HealthBaselineOverlap {
2058 pub(crate) matched_entries: usize,
2060 pub(crate) moved_entries: usize,
2063}
2064
2065fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
2067 format!(
2068 "{}:{}",
2069 relative_path(&target.path, root),
2070 target.category.label()
2071 )
2072}
2073
2074fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
2076 format!(
2077 "{}:{}:{}",
2078 relative_path(&finding.path, root),
2079 finding.name,
2080 finding.line
2081 )
2082}
2083
2084fn health_bucket_key(
2089 finding: &fallow_output::ComplexityViolation,
2090 root: &Path,
2091 mode: HealthBaselineMode,
2092) -> String {
2093 let path = relative_path(&finding.path, root);
2094 match mode {
2095 HealthBaselineMode::Count => path,
2096 HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
2097 }
2098}
2099
2100const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
2104
2105fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
2106 key.split_once('\0')
2107}
2108
2109fn moved_identity_bucket_remaps(
2127 baseline_counts: &HealthFindingCountMap,
2128 current_counts: &HealthFindingCountMap,
2129 root: &Path,
2130) -> Vec<(String, String)> {
2131 let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
2132 for key in current_counts.keys() {
2133 if baseline_counts.contains_key(key) {
2134 continue;
2135 }
2136 if let Some((_, name)) = identity_bucket_parts(key)
2137 && name != ANONYMOUS_FUNCTION_NAME
2138 {
2139 candidates_by_name.entry(name).or_default().push(key);
2140 }
2141 }
2142
2143 let mut proposals: Vec<(&str, &str)> = Vec::new();
2144 let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
2145 for key in baseline_counts.keys() {
2146 if current_counts.contains_key(key.as_str()) {
2147 continue;
2148 }
2149 let Some((path, name)) = identity_bucket_parts(key) else {
2150 continue;
2151 };
2152 if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
2153 continue;
2154 }
2155 if let Some(candidates) = candidates_by_name.get(name)
2156 && let [only_candidate] = candidates.as_slice()
2157 {
2158 proposals.push((key.as_str(), only_candidate));
2159 *claims.entry(only_candidate).or_default() += 1;
2160 }
2161 }
2162
2163 proposals
2164 .into_iter()
2165 .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
2166 .map(|(old, new)| (old.to_string(), new.to_string()))
2167 .collect()
2168}
2169
2170fn identity_counts_with_move_tolerance(
2175 baseline_counts: &HealthFindingCountMap,
2176 current_counts: &HealthFindingCountMap,
2177 root: &Path,
2178) -> Option<HealthFindingCountMap> {
2179 let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
2180 if remaps.is_empty() {
2181 return None;
2182 }
2183 let mut remapped = baseline_counts.clone();
2184 for (old_key, new_key) in remaps {
2185 if let Some(entry) = remapped.remove(&old_key) {
2186 remapped.insert(new_key, entry);
2187 }
2188 }
2189 Some(remapped)
2190}
2191
2192fn health_finding_counts(
2193 findings: &[fallow_output::ComplexityViolation],
2194 root: &Path,
2195 mode: HealthBaselineMode,
2196) -> HealthFindingCountMap {
2197 let mut counts = BTreeMap::new();
2198 for finding in findings {
2199 let bucket = health_bucket_key(finding, root, mode);
2200 let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
2201 for category in health_finding_categories(finding).into_iter().flatten() {
2202 file_counts
2203 .entry(category.key().to_string())
2204 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
2205 .or_insert(HealthBaselineCount { count: 1 });
2206 }
2207 }
2208 counts
2209}
2210
2211fn health_finding_categories(
2212 finding: &fallow_output::ComplexityViolation,
2213) -> [Option<HealthFindingCategory>; 2] {
2214 let complexity_category = HealthFindingCategory {
2215 dimension: HealthFindingDimension::Complexity,
2216 severity: finding.severity,
2217 };
2218 let crap_category = HealthFindingCategory {
2219 dimension: HealthFindingDimension::Crap,
2220 severity: finding.severity,
2221 };
2222 let has_complexity =
2223 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
2224 let has_crap = finding.exceeded.includes_crap();
2225 [
2226 has_complexity.then_some(complexity_category),
2227 has_crap.then_some(crap_category),
2228 ]
2229}
2230
2231fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
2232 match severity {
2233 fallow_output::FindingSeverity::Moderate => 0,
2234 fallow_output::FindingSeverity::High => 1,
2235 fallow_output::FindingSeverity::Critical => 2,
2236 }
2237}
2238
2239fn severity_counts_for_dimension(
2240 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
2241 dimension: HealthFindingDimension,
2242) -> [usize; 3] {
2243 let mut counts = [0; 3];
2244 for severity in [
2245 fallow_output::FindingSeverity::Moderate,
2246 fallow_output::FindingSeverity::High,
2247 fallow_output::FindingSeverity::Critical,
2248 ] {
2249 let category = HealthFindingCategory {
2250 dimension,
2251 severity,
2252 };
2253 counts[severity_index(severity)] = file_counts
2254 .and_then(|entries| entries.get(category.key()))
2255 .map_or(0, |entry| entry.count);
2256 }
2257 counts
2258}
2259
2260fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
2261 let mut available = baseline;
2262 let mut overflow = [false; 3];
2263
2264 for severity_idx in 0..3 {
2265 let compatible = available[severity_idx..].iter().sum::<usize>();
2266 overflow[severity_idx] = compatible < current[severity_idx];
2267
2268 let mut matched = current[severity_idx].min(compatible);
2269 for slot in available.iter_mut().skip(severity_idx) {
2270 let taken = matched.min(*slot);
2271 *slot -= taken;
2272 matched -= taken;
2273 if matched == 0 {
2274 break;
2275 }
2276 }
2277 }
2278
2279 overflow
2280}
2281
2282fn health_overflow_categories(
2283 current_counts: &HealthFindingCountMap,
2284 baseline_counts: &HealthFindingCountMap,
2285) -> FxHashMap<String, FxHashSet<&'static str>> {
2286 let mut overflow_by_path = FxHashMap::default();
2287
2288 for (path, current_file_counts) in current_counts {
2289 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
2290 let baseline_file_counts = baseline_counts.get(path);
2291
2292 for dimension in HEALTH_FINDING_DIMENSIONS {
2293 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
2294 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
2295 let overflow = overflowing_severities(current, baseline);
2296
2297 for severity in [
2298 fallow_output::FindingSeverity::Moderate,
2299 fallow_output::FindingSeverity::High,
2300 fallow_output::FindingSeverity::Critical,
2301 ] {
2302 if overflow[severity_index(severity)] {
2303 overflow_categories.insert(
2304 HealthFindingCategory {
2305 dimension,
2306 severity,
2307 }
2308 .key(),
2309 );
2310 }
2311 }
2312 }
2313
2314 if !overflow_categories.is_empty() {
2315 overflow_by_path.insert(path.clone(), overflow_categories);
2316 }
2317 }
2318
2319 overflow_by_path
2320}
2321
2322fn health_overlap_entry_count(
2323 current_counts: &HealthFindingCountMap,
2324 baseline_counts: &HealthFindingCountMap,
2325) -> usize {
2326 let mut overlap = 0;
2327
2328 for (path, baseline_file_counts) in baseline_counts {
2329 let current_file_counts = current_counts.get(path);
2330
2331 for dimension in HEALTH_FINDING_DIMENSIONS {
2332 let current_total: usize =
2333 severity_counts_for_dimension(current_file_counts, dimension)
2334 .into_iter()
2335 .sum();
2336 let baseline_total: usize =
2337 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
2338 .into_iter()
2339 .sum();
2340 overlap += current_total.min(baseline_total);
2341 }
2342 }
2343
2344 overlap
2345}
2346
2347fn runtime_coverage_finding_key(
2348 finding: &fallow_output::RuntimeCoverageFinding,
2349 _root: &Path,
2350) -> String {
2351 finding
2352 .stable_id
2353 .clone()
2354 .unwrap_or_else(|| finding.id.clone())
2355}
2356
2357fn runtime_coverage_source_hash_key(
2364 finding: &fallow_output::RuntimeCoverageFinding,
2365 root: &Path,
2366) -> Option<String> {
2367 finding.source_hash.as_deref().map(|hash| {
2368 format!(
2369 "{}\0{}\0{}",
2370 relative_path(&finding.path, root),
2371 finding.function,
2372 hash
2373 )
2374 })
2375}
2376
2377pub(crate) fn filter_new_health_findings(
2379 mut findings: Vec<fallow_output::ComplexityViolation>,
2380 baseline: &HealthBaselineData,
2381 root: &Path,
2382 mode: HealthBaselineMode,
2383) -> Vec<fallow_output::ComplexityViolation> {
2384 let baseline_counts = baseline.counts_for(mode);
2385 if !baseline_counts.is_empty() {
2386 let current_counts = health_finding_counts(&findings, root, mode);
2387 let remapped = (mode == HealthBaselineMode::Identity)
2388 .then(|| identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root))
2389 .flatten();
2390 let overflow_categories = health_overflow_categories(
2391 ¤t_counts,
2392 remapped.as_ref().unwrap_or(baseline_counts),
2393 );
2394 findings.retain(|finding| {
2395 let bucket = health_bucket_key(finding, root, mode);
2396 overflow_categories.get(&bucket).is_some_and(|categories| {
2397 health_finding_categories(finding)
2398 .into_iter()
2399 .flatten()
2400 .any(|category| categories.contains(category.key()))
2401 })
2402 });
2403 return findings;
2404 }
2405
2406 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
2407 findings.retain(|f| {
2408 let key = health_finding_key(f, root);
2409 !baseline_keys.contains(key.as_str())
2410 });
2411 findings
2412}
2413
2414pub(crate) fn filter_new_runtime_coverage_findings(
2415 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
2416 baseline: &HealthBaselineData,
2417 root: &Path,
2418) -> Vec<fallow_output::RuntimeCoverageFinding> {
2419 let baseline_keys: FxHashSet<&str> = baseline
2420 .runtime_coverage_findings
2421 .iter()
2422 .map(String::as_str)
2423 .collect();
2424 let baseline_source_hash_keys: FxHashSet<&str> = baseline
2425 .runtime_coverage_source_hashes
2426 .iter()
2427 .map(String::as_str)
2428 .collect();
2429 findings.retain(|finding| {
2430 let suppressed_by_stable_id = finding
2431 .stable_id
2432 .as_deref()
2433 .is_some_and(|id| baseline_keys.contains(id));
2434 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
2435 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
2436 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
2437 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
2438 });
2439 findings
2440}
2441
2442pub(crate) fn filter_new_health_targets(
2444 mut targets: Vec<fallow_output::RefactoringTarget>,
2445 baseline: &HealthBaselineData,
2446 root: &Path,
2447) -> Vec<fallow_output::RefactoringTarget> {
2448 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
2449 targets.retain(|t| {
2450 let key = target_baseline_key(t, root);
2451 !baseline_keys.contains(key.as_str())
2452 });
2453 targets
2454}
2455
2456#[derive(Debug, Clone, serde::Serialize)]
2458pub struct CategoryDelta {
2459 pub current: usize,
2461 pub baseline: usize,
2463 pub delta: i64,
2465}
2466
2467#[derive(Debug, Clone)]
2472pub struct BaselineDeltas {
2473 pub total_delta: i64,
2475 pub per_category: Vec<(String, CategoryDelta)>,
2477}
2478
2479#[cfg(test)]
2480mod tests {
2481 use super::*;
2482
2483 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2484 use crate::results::{
2485 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2486 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2487 UnusedFile,
2488 };
2489 use fallow_types::output_dead_code::{
2490 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2491 };
2492 use std::path::PathBuf;
2493
2494 #[test]
2495 fn stale_share_threshold_matches_the_documented_quarter() {
2496 for (baseline_entries, stale_entries, expected) in [
2497 (100, 24, false),
2498 (100, 25, true),
2499 (4, 2, true),
2500 (29, 2, false),
2501 (5, 1, false),
2502 (0, 0, false),
2503 ] {
2504 assert_eq!(
2505 stale_share_warrants_warning(baseline_entries, stale_entries),
2506 expected,
2507 "{stale_entries} of {baseline_entries} entries"
2508 );
2509 }
2510 }
2511
2512 const fn staleness(
2513 entries: usize,
2514 matched: usize,
2515 current_findings: usize,
2516 ) -> BaselineStaleness {
2517 BaselineStaleness {
2518 entries,
2519 matched,
2520 current_findings,
2521 change_scoped: false,
2522 }
2523 }
2524
2525 #[test]
2526 fn warning_is_silent_when_the_run_found_nothing() {
2527 let staleness = staleness(4, 0, 0);
2528 assert_eq!(staleness.stale_entries(), 4);
2529 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2530 }
2531
2532 #[test]
2533 fn zero_overlap_warns_when_the_run_has_findings() {
2534 assert_eq!(
2535 staleness(4, 0, 4).warning(),
2536 BaselineStalenessWarning::ZeroOverlap
2537 );
2538 }
2539
2540 #[test]
2541 fn partial_warning_needs_the_documented_quarter() {
2542 assert_eq!(
2543 staleness(100, 76, 100).warning(),
2544 BaselineStalenessWarning::None
2545 );
2546 assert_eq!(
2547 staleness(100, 75, 100).warning(),
2548 BaselineStalenessWarning::Partial
2549 );
2550 }
2551
2552 #[test]
2553 fn empty_baseline_never_warns() {
2554 assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
2555 }
2556
2557 #[test]
2558 fn change_scoped_run_never_warns_and_never_trips_the_gate() {
2559 let scoped = BaselineStaleness {
2560 change_scoped: true,
2561 ..staleness(8, 2, 8)
2562 };
2563 assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
2564 assert!(!scoped.trips_gate());
2565 }
2566
2567 #[test]
2571 fn gate_trips_on_one_stale_entry_the_warning_ignores() {
2572 let staleness = staleness(20, 19, 19);
2573 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2574 assert!(staleness.trips_gate());
2575 }
2576
2577 #[test]
2578 fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
2579 let staleness = staleness(4, 0, 0);
2580 assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
2581 assert!(staleness.trips_gate());
2582 }
2583
2584 #[test]
2585 fn gate_is_inert_on_an_empty_baseline() {
2586 assert!(!staleness(0, 0, 0).trips_gate());
2587 }
2588
2589 #[test]
2590 fn gate_is_inert_when_every_entry_matched() {
2591 assert!(!staleness(4, 4, 4).trips_gate());
2592 }
2593
2594 fn make_results() -> AnalysisResults {
2595 AnalysisResults {
2596 unused_files: vec![
2597 UnusedFileFinding::with_actions(UnusedFile {
2598 path: PathBuf::from("src/old.ts"),
2599 }),
2600 UnusedFileFinding::with_actions(UnusedFile {
2601 path: PathBuf::from("src/dead.ts"),
2602 }),
2603 ],
2604 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2605 path: PathBuf::from("src/utils.ts"),
2606 export_name: "helperA".to_string(),
2607 is_type_only: false,
2608 line: 5,
2609 col: 0,
2610 span_start: 40,
2611 is_re_export: false,
2612 })],
2613 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2614 path: PathBuf::from("src/types.ts"),
2615 export_name: "OldType".to_string(),
2616 is_type_only: true,
2617 line: 10,
2618 col: 0,
2619 span_start: 100,
2620 is_re_export: false,
2621 })],
2622 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2623 package_name: "lodash".to_string(),
2624 location: DependencyLocation::Dependencies,
2625 path: PathBuf::from("package.json"),
2626 line: 5,
2627 used_in_workspaces: Vec::new(),
2628 })],
2629 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2630 UnusedDependency {
2631 package_name: "jest".to_string(),
2632 location: DependencyLocation::DevDependencies,
2633 path: PathBuf::from("package.json"),
2634 line: 5,
2635 used_in_workspaces: Vec::new(),
2636 },
2637 )],
2638 ..Default::default()
2639 }
2640 }
2641
2642 #[test]
2643 fn baseline_from_results_captures_all_fields() {
2644 let results = make_results();
2645 let baseline = BaselineData::from_results(&results, Path::new(""));
2646 assert_eq!(baseline.unused_files.len(), 2);
2647 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2648 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2649 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2650 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2651 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2652 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2653 }
2654
2655 #[test]
2656 fn dependency_baseline_keys_include_package_json_path() {
2657 let root = Path::new("/repo");
2658 let results = AnalysisResults {
2659 unused_dependencies: vec![
2660 UnusedDependencyFinding::with_actions(UnusedDependency {
2661 package_name: "lodash-es".to_string(),
2662 location: DependencyLocation::Dependencies,
2663 path: PathBuf::from("/repo/packages/app-a/package.json"),
2664 line: 5,
2665 used_in_workspaces: Vec::new(),
2666 }),
2667 UnusedDependencyFinding::with_actions(UnusedDependency {
2668 package_name: "lodash-es".to_string(),
2669 location: DependencyLocation::Dependencies,
2670 path: PathBuf::from("/repo/packages/app-b/package.json"),
2671 line: 5,
2672 used_in_workspaces: Vec::new(),
2673 }),
2674 ],
2675 ..Default::default()
2676 };
2677
2678 let baseline = BaselineData::from_results(&results, root);
2679
2680 assert_eq!(
2681 baseline.unused_dependencies,
2682 vec![
2683 "packages/app-a/package.json:lodash-es",
2684 "packages/app-b/package.json:lodash-es"
2685 ]
2686 );
2687 }
2688
2689 #[test]
2690 fn dependency_baseline_filter_matches_path_before_package_name() {
2691 let root = Path::new("/repo");
2692 let results = AnalysisResults {
2693 unused_dependencies: vec![
2694 UnusedDependencyFinding::with_actions(UnusedDependency {
2695 package_name: "lodash-es".to_string(),
2696 location: DependencyLocation::Dependencies,
2697 path: PathBuf::from("/repo/packages/app-a/package.json"),
2698 line: 5,
2699 used_in_workspaces: Vec::new(),
2700 }),
2701 UnusedDependencyFinding::with_actions(UnusedDependency {
2702 package_name: "lodash-es".to_string(),
2703 location: DependencyLocation::Dependencies,
2704 path: PathBuf::from("/repo/packages/app-b/package.json"),
2705 line: 5,
2706 used_in_workspaces: Vec::new(),
2707 }),
2708 ],
2709 ..Default::default()
2710 };
2711 let baseline = BaselineData {
2712 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2713 ..BaselineData::from_results(&AnalysisResults::default(), root)
2714 };
2715
2716 let filtered = filter_new_issues(results, &baseline, root);
2717
2718 assert_eq!(filtered.unused_dependencies.len(), 1);
2719 assert_eq!(
2720 filtered.unused_dependencies[0].dep.path,
2721 PathBuf::from("/repo/packages/app-b/package.json")
2722 );
2723 }
2724
2725 #[test]
2726 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2727 let root = Path::new("/repo");
2728 let results = AnalysisResults {
2729 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2730 package_name: "lodash-es".to_string(),
2731 location: DependencyLocation::Dependencies,
2732 path: PathBuf::from("/repo/packages/app/package.json"),
2733 line: 5,
2734 used_in_workspaces: Vec::new(),
2735 })],
2736 ..Default::default()
2737 };
2738 let baseline = BaselineData {
2739 unused_dependencies: vec!["lodash-es".to_string()],
2740 ..BaselineData::from_results(&AnalysisResults::default(), root)
2741 };
2742
2743 let filtered = filter_new_issues(results, &baseline, root);
2744
2745 assert!(filtered.unused_dependencies.is_empty());
2746 }
2747
2748 #[test]
2749 fn baseline_serialization_roundtrip() {
2750 let results = make_results();
2751 let baseline = BaselineData::from_results(&results, Path::new(""));
2752 let json = serde_json::to_string(&baseline).unwrap();
2753 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2754 assert_eq!(deserialized.unused_files, baseline.unused_files);
2755 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2756 assert_eq!(deserialized.unused_types, baseline.unused_types);
2757 assert_eq!(
2758 deserialized.unused_dependencies,
2759 baseline.unused_dependencies
2760 );
2761 assert_eq!(
2762 deserialized.unused_dev_dependencies,
2763 baseline.unused_dev_dependencies
2764 );
2765 }
2766
2767 #[test]
2768 fn filter_removes_baseline_issues() {
2769 let results = make_results();
2770 let baseline = BaselineData::from_results(&results, Path::new(""));
2771 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2772 assert!(
2773 filtered.unused_files.is_empty(),
2774 "all files were in baseline"
2775 );
2776 assert!(
2777 filtered.unused_exports.is_empty(),
2778 "all exports were in baseline"
2779 );
2780 assert!(
2781 filtered.unused_types.is_empty(),
2782 "all types were in baseline"
2783 );
2784 assert!(
2785 filtered.unused_dependencies.is_empty(),
2786 "all deps were in baseline"
2787 );
2788 assert!(
2789 filtered.unused_dev_dependencies.is_empty(),
2790 "all dev deps were in baseline"
2791 );
2792 }
2793
2794 #[test]
2795 fn filter_keeps_new_issues_not_in_baseline() {
2796 let baseline = BaselineData {
2797 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2798 unused_files: vec!["src/old.ts".to_string()],
2799 unused_exports: vec![],
2800 unused_types: vec![],
2801 private_type_leaks: vec![],
2802 unused_dependencies: vec![],
2803 unused_dev_dependencies: vec![],
2804 circular_dependencies: vec![],
2805 re_export_cycles: vec![],
2806 unused_optional_dependencies: vec![],
2807 unused_enum_members: vec![],
2808 unused_class_members: vec![],
2809 unused_store_members: vec![],
2810 unprovided_injects: vec![],
2811 unrendered_components: vec![],
2812 unused_component_props: vec![],
2813 unused_component_emits: vec![],
2814 unused_component_inputs: vec![],
2815 unused_component_outputs: vec![],
2816 unused_svelte_events: vec![],
2817 unused_server_actions: vec![],
2818 unused_load_data_keys: vec![],
2819 unresolved_imports: vec![],
2820 unlisted_dependencies: vec![],
2821 duplicate_exports: vec![],
2822 type_only_dependencies: vec![],
2823 test_only_dependencies: vec![],
2824 dev_dependencies_in_production: vec![],
2825 boundary_violations: vec![],
2826 boundary_coverage_violations: vec![],
2827 boundary_call_violations: vec![],
2828 policy_violations: vec![],
2829 stale_suppressions: vec![],
2830 unused_catalog_entries: vec![],
2831 empty_catalog_groups: vec![],
2832 unresolved_catalog_references: vec![],
2833 unused_dependency_overrides: vec![],
2834 misconfigured_dependency_overrides: vec![],
2835 invalid_client_exports: vec![],
2836 mixed_client_server_barrels: vec![],
2837 misplaced_directives: vec![],
2838 route_collisions: vec![],
2839 dynamic_segment_name_conflicts: vec![],
2840 };
2841 let results = AnalysisResults {
2842 unused_files: vec![
2843 UnusedFileFinding::with_actions(UnusedFile {
2844 path: PathBuf::from("src/old.ts"),
2845 }),
2846 UnusedFileFinding::with_actions(UnusedFile {
2847 path: PathBuf::from("src/new-dead.ts"),
2848 }),
2849 ],
2850 ..Default::default()
2851 };
2852 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2853 assert_eq!(filtered.unused_files.len(), 1);
2854 assert_eq!(
2855 filtered.unused_files[0].file.path,
2856 PathBuf::from("src/new-dead.ts")
2857 );
2858 }
2859
2860 #[test]
2861 fn filter_with_empty_baseline_keeps_all() {
2862 let baseline = BaselineData {
2863 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2864 unused_files: vec![],
2865 unused_exports: vec![],
2866 unused_types: vec![],
2867 private_type_leaks: vec![],
2868 unused_dependencies: vec![],
2869 unused_dev_dependencies: vec![],
2870 circular_dependencies: vec![],
2871 re_export_cycles: vec![],
2872 unused_optional_dependencies: vec![],
2873 unused_enum_members: vec![],
2874 unused_class_members: vec![],
2875 unused_store_members: vec![],
2876 unprovided_injects: vec![],
2877 unrendered_components: vec![],
2878 unused_component_props: vec![],
2879 unused_component_emits: vec![],
2880 unused_component_inputs: vec![],
2881 unused_component_outputs: vec![],
2882 unused_svelte_events: vec![],
2883 unused_server_actions: vec![],
2884 unused_load_data_keys: vec![],
2885 unresolved_imports: vec![],
2886 unlisted_dependencies: vec![],
2887 duplicate_exports: vec![],
2888 type_only_dependencies: vec![],
2889 test_only_dependencies: vec![],
2890 dev_dependencies_in_production: vec![],
2891 boundary_violations: vec![],
2892 boundary_coverage_violations: vec![],
2893 boundary_call_violations: vec![],
2894 policy_violations: vec![],
2895 stale_suppressions: vec![],
2896 unused_catalog_entries: vec![],
2897 empty_catalog_groups: vec![],
2898 unresolved_catalog_references: vec![],
2899 unused_dependency_overrides: vec![],
2900 misconfigured_dependency_overrides: vec![],
2901 invalid_client_exports: vec![],
2902 mixed_client_server_barrels: vec![],
2903 misplaced_directives: vec![],
2904 route_collisions: vec![],
2905 dynamic_segment_name_conflicts: vec![],
2906 };
2907 let results = make_results();
2908 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2909 assert_eq!(filtered.unused_files.len(), 2);
2910 assert_eq!(filtered.unused_exports.len(), 1);
2911 }
2912
2913 #[test]
2914 fn filter_new_exports_by_file_and_name() {
2915 let baseline = BaselineData {
2916 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2917 unused_files: vec![],
2918 unused_exports: vec!["src/utils.ts:helperA".to_string()],
2919 unused_types: vec![],
2920 private_type_leaks: vec![],
2921 unused_dependencies: vec![],
2922 unused_dev_dependencies: vec![],
2923 circular_dependencies: vec![],
2924 re_export_cycles: vec![],
2925 unused_optional_dependencies: vec![],
2926 unused_enum_members: vec![],
2927 unused_class_members: vec![],
2928 unused_store_members: vec![],
2929 unprovided_injects: vec![],
2930 unrendered_components: vec![],
2931 unused_component_props: vec![],
2932 unused_component_emits: vec![],
2933 unused_component_inputs: vec![],
2934 unused_component_outputs: vec![],
2935 unused_svelte_events: vec![],
2936 unused_server_actions: vec![],
2937 unused_load_data_keys: vec![],
2938 unresolved_imports: vec![],
2939 unlisted_dependencies: vec![],
2940 duplicate_exports: vec![],
2941 type_only_dependencies: vec![],
2942 test_only_dependencies: vec![],
2943 dev_dependencies_in_production: vec![],
2944 boundary_violations: vec![],
2945 boundary_coverage_violations: vec![],
2946 boundary_call_violations: vec![],
2947 policy_violations: vec![],
2948 stale_suppressions: vec![],
2949 unused_catalog_entries: vec![],
2950 empty_catalog_groups: vec![],
2951 unresolved_catalog_references: vec![],
2952 unused_dependency_overrides: vec![],
2953 misconfigured_dependency_overrides: vec![],
2954 invalid_client_exports: vec![],
2955 mixed_client_server_barrels: vec![],
2956 misplaced_directives: vec![],
2957 route_collisions: vec![],
2958 dynamic_segment_name_conflicts: vec![],
2959 };
2960 let results = AnalysisResults {
2961 unused_exports: vec![
2962 UnusedExportFinding::with_actions(UnusedExport {
2963 path: PathBuf::from("src/utils.ts"),
2964 export_name: "helperA".to_string(),
2965 is_type_only: false,
2966 line: 5,
2967 col: 0,
2968 span_start: 40,
2969 is_re_export: false,
2970 }),
2971 UnusedExportFinding::with_actions(UnusedExport {
2972 path: PathBuf::from("src/utils.ts"),
2973 export_name: "helperB".to_string(),
2974 is_type_only: false,
2975 line: 10,
2976 col: 0,
2977 span_start: 80,
2978 is_re_export: false,
2979 }),
2980 ],
2981 ..Default::default()
2982 };
2983 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2984 assert_eq!(filtered.unused_exports.len(), 1);
2985 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2986 }
2987
2988 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2989 let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
2990 files.sort_unstable();
2991 let fragment = format!("const source = '{}';", files.join(","));
2992 make_clone_group_with_fragment(&fragment, instances)
2993 }
2994
2995 fn make_clone_group_with_fragment(
2996 fragment: &str,
2997 instances: Vec<(&str, usize, usize)>,
2998 ) -> CloneGroup {
2999 CloneGroup {
3000 instances: instances
3001 .into_iter()
3002 .map(|(file, start, end)| CloneInstance {
3003 file: PathBuf::from(file),
3004 start_line: start,
3005 end_line: end,
3006 start_col: 0,
3007 end_col: 0,
3008 fragment: fragment.to_string(),
3009 })
3010 .collect(),
3011 token_count: 50,
3012 line_count: 10,
3013 similarity: None,
3014 }
3015 }
3016
3017 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
3018 DuplicationReport {
3019 clone_groups: groups,
3020 clone_families: vec![],
3021 mirrored_directories: vec![],
3022 stats: DuplicationStats {
3023 total_files: 10,
3024 files_with_clones: 2,
3025 total_lines: 1000,
3026 duplicated_lines: 100,
3027 total_tokens: 5000,
3028 duplicated_tokens: 500,
3029 clone_groups: 1,
3030 clone_families: 0,
3031 clone_instances: 2,
3032 duplication_percentage: 10.0,
3033 clone_groups_below_min_occurrences: 0,
3034 clone_groups_ignored: 0,
3035 near_candidates_skipped: 0,
3036 },
3037 }
3038 }
3039
3040 fn normalized_clone_group_key(group: &CloneGroup) -> String {
3041 let fingerprints =
3042 crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
3043 clone_group_fingerprint_key(group, &fingerprints)
3044 }
3045
3046 #[test]
3047 fn clone_group_key_is_deterministic() {
3048 let root = Path::new("/project");
3049 let group = make_clone_group(vec![
3050 ("/project/src/a.ts", 1, 10),
3051 ("/project/src/b.ts", 5, 15),
3052 ]);
3053 let key1 = clone_group_key(&group, root);
3054 let key2 = clone_group_key(&group, root);
3055 assert_eq!(key1, key2);
3056 }
3057
3058 #[test]
3059 fn clone_group_key_is_sorted() {
3060 let root = Path::new("/project");
3061 let group_ab = make_clone_group(vec![
3062 ("/project/src/a.ts", 1, 10),
3063 ("/project/src/b.ts", 5, 15),
3064 ]);
3065 let group_ba = make_clone_group(vec![
3066 ("/project/src/b.ts", 5, 15),
3067 ("/project/src/a.ts", 1, 10),
3068 ]);
3069 assert_eq!(
3070 clone_group_key(&group_ab, root),
3071 clone_group_key(&group_ba, root),
3072 "key should be stable regardless of instance order"
3073 );
3074 }
3075
3076 #[test]
3077 fn duplication_baseline_roundtrip() {
3078 let root = Path::new("/project");
3079 let group = make_clone_group(vec![
3080 ("/project/src/a.ts", 1, 10),
3081 ("/project/src/b.ts", 5, 15),
3082 ]);
3083 let report = make_duplication_report(vec![group]);
3084 let baseline = DuplicationBaselineData::from_report(&report, root);
3085 let json = serde_json::to_string(&baseline).unwrap();
3086 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
3087 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
3088 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
3089 assert_eq!(
3090 deserialized.normalized_clone_fingerprints,
3091 baseline.normalized_clone_fingerprints
3092 );
3093 assert_eq!(
3094 baseline.normalized_clone_fingerprints.len(),
3095 1,
3096 "a saved baseline carries a normalized key per clone group"
3097 );
3098 }
3099
3100 #[test]
3101 fn filter_new_clone_groups_matches_shifted_clone() {
3102 let root = Path::new("/project");
3103 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3104 "const total = a + b;",
3105 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3106 )]);
3107 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3108
3109 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3110 "const total = a + b;",
3111 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3112 )]);
3113 let filtered = filter_new_clone_groups(shifted, &baseline, root);
3114 assert!(
3115 filtered.clone_groups.is_empty(),
3116 "an unrelated line shift must not resurface a baselined clone"
3117 );
3118 }
3119
3120 #[test]
3121 fn filter_new_clone_groups_reports_extra_copy() {
3122 let root = Path::new("/project");
3123 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3124 "const total = a + b;",
3125 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3126 )]);
3127 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3128
3129 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
3130 "const total = a + b;",
3131 vec![
3132 ("/project/src/a.ts", 10, 20),
3133 ("/project/src/b.ts", 30, 40),
3134 ("/project/src/c.ts", 5, 15),
3135 ],
3136 )]);
3137 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
3138 assert_eq!(
3139 filtered.clone_groups.len(),
3140 1,
3141 "a fresh copy in a third file is a new finding"
3142 );
3143 }
3144
3145 #[test]
3146 fn filter_new_clone_groups_reads_legacy_baseline() {
3147 let root = Path::new("/project");
3148 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
3149 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
3150 assert_eq!(baseline.entry_count(), 1);
3151
3152 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
3153 "const total = a + b;",
3154 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3155 )]);
3156 assert!(
3157 filter_new_clone_groups(unchanged, &baseline, root)
3158 .clone_groups
3159 .is_empty(),
3160 "a legacy baseline still matches on locations"
3161 );
3162
3163 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
3164 "const total = a + b;",
3165 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
3166 )]);
3167 assert_eq!(
3168 filter_new_clone_groups(shifted, &baseline, root)
3169 .clone_groups
3170 .len(),
3171 1,
3172 "legacy behavior is unchanged: a shift stops matching"
3173 );
3174 }
3175
3176 #[test]
3177 fn clone_group_fingerprint_key_survives_file_rename() {
3178 let before = make_clone_group_with_fragment(
3179 "const total = a + b;",
3180 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3181 );
3182 let after = make_clone_group_with_fragment(
3183 "const total = a + b;",
3184 vec![
3185 ("/project/src/renamed.ts", 10, 20),
3186 ("/project/src/b.ts", 30, 40),
3187 ],
3188 );
3189 assert_eq!(
3190 normalized_clone_group_key(&before),
3191 normalized_clone_group_key(&after),
3192 "renaming a file must not resurface a baselined clone"
3193 );
3194 }
3195
3196 #[test]
3197 fn clone_group_fingerprint_key_does_not_follow_representative_order() {
3198 let mut before = make_clone_group_with_fragment(
3199 "const total = a + b;",
3200 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3201 );
3202 before.instances[1].fragment = "const total = a + b;".to_string();
3203
3204 let mut after = before.clone();
3205 after.instances[0].file = PathBuf::from("/project/src/z.ts");
3206
3207 assert_eq!(
3208 normalized_clone_group_key(&before),
3209 normalized_clone_group_key(&after),
3210 "normalized group identity is independent of representative order"
3211 );
3212 }
3213
3214 #[test]
3215 fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
3216 let root = Path::new("/project");
3217 let group = make_clone_group_with_fragment(
3218 "const total = a + b;",
3219 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3220 );
3221 let legacy_key = legacy_clone_group_fingerprint_key(&group);
3222 let baseline = DuplicationBaselineData {
3223 clone_groups: Vec::new(),
3224 clone_fingerprints: vec![legacy_key.clone()],
3225 normalized_clone_fingerprints: Vec::new(),
3226 };
3227
3228 let filtered = filter_new_clone_groups(
3229 make_duplication_report(vec![group.clone()]),
3230 &baseline,
3231 root,
3232 );
3233 assert!(filtered.clone_groups.is_empty());
3234
3235 let current =
3236 DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
3237 assert_eq!(current.clone_fingerprints, vec![legacy_key]);
3238 assert_ne!(
3239 current.normalized_clone_fingerprints,
3240 current.clone_fingerprints
3241 );
3242 }
3243
3244 #[test]
3245 fn normalized_baseline_survives_formatting_only_edits() {
3246 let root = Path::new("/project");
3247 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3248 "const total = left + right;",
3249 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3250 )]);
3251 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3252
3253 let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
3254 "/* reviewed */\r\nconst total=left + right;",
3255 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
3256 )]);
3257 assert!(
3258 filter_new_clone_groups(formatted, &baseline, root)
3259 .clone_groups
3260 .is_empty()
3261 );
3262 }
3263
3264 #[test]
3265 fn filter_new_clone_groups_removes_baseline() {
3266 let root = Path::new("/project");
3267 let group = make_clone_group(vec![
3268 ("/project/src/a.ts", 1, 10),
3269 ("/project/src/b.ts", 5, 15),
3270 ]);
3271 let report = make_duplication_report(vec![group]);
3272 let baseline = DuplicationBaselineData::from_report(&report, root);
3273 let filtered = filter_new_clone_groups(report, &baseline, root);
3274 assert!(
3275 filtered.clone_groups.is_empty(),
3276 "baseline group should be filtered out"
3277 );
3278 }
3279
3280 #[test]
3281 fn filter_new_clone_groups_keeps_new_groups() {
3282 let root = Path::new("/project");
3283 let baseline_group = make_clone_group(vec![
3284 ("/project/src/a.ts", 1, 10),
3285 ("/project/src/b.ts", 5, 15),
3286 ]);
3287 let new_group = make_clone_group(vec![
3288 ("/project/src/c.ts", 20, 30),
3289 ("/project/src/d.ts", 25, 35),
3290 ]);
3291 let baseline_report = make_duplication_report(vec![baseline_group]);
3292 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3293
3294 let report = make_duplication_report(vec![
3295 make_clone_group(vec![
3296 ("/project/src/a.ts", 1, 10),
3297 ("/project/src/b.ts", 5, 15),
3298 ]),
3299 new_group,
3300 ]);
3301 let filtered = filter_new_clone_groups(report, &baseline, root);
3302 assert_eq!(
3303 filtered.clone_groups.len(),
3304 1,
3305 "only the new group should remain"
3306 );
3307 }
3308
3309 #[test]
3310 fn recompute_stats_after_filtering() {
3311 let root = Path::new("/project");
3312 let group = make_clone_group(vec![
3313 ("/project/src/a.ts", 1, 10),
3314 ("/project/src/b.ts", 5, 15),
3315 ]);
3316 let report = make_duplication_report(vec![group]);
3317 let baseline = DuplicationBaselineData::from_report(&report, root);
3318 let filtered = filter_new_clone_groups(report, &baseline, root);
3319 assert_eq!(filtered.stats.clone_groups, 0);
3320 assert_eq!(filtered.stats.clone_instances, 0);
3321 assert_eq!(filtered.stats.duplicated_lines, 0);
3322 }
3323
3324 #[test]
3325 fn recompute_stats_zero_total_lines() {
3326 let report = DuplicationReport {
3327 clone_groups: vec![],
3328 clone_families: vec![],
3329 mirrored_directories: vec![],
3330 stats: DuplicationStats {
3331 total_files: 0,
3332 files_with_clones: 0,
3333 total_lines: 0,
3334 duplicated_lines: 0,
3335 total_tokens: 0,
3336 duplicated_tokens: 0,
3337 clone_groups: 0,
3338 clone_families: 0,
3339 clone_instances: 0,
3340 duplication_percentage: 0.0,
3341 clone_groups_below_min_occurrences: 0,
3342 clone_groups_ignored: 0,
3343 near_candidates_skipped: 0,
3344 },
3345 };
3346 let stats = super::recompute_stats(&report);
3347 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
3348 }
3349
3350 fn filter_new_health_findings(
3354 findings: Vec<fallow_output::ComplexityViolation>,
3355 baseline: &HealthBaselineData,
3356 root: &Path,
3357 ) -> Vec<fallow_output::ComplexityViolation> {
3358 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
3359 }
3360
3361 fn make_health_finding(
3362 root: &Path,
3363 name: &str,
3364 line: u32,
3365 ) -> fallow_output::ComplexityViolation {
3366 make_health_finding_with(
3367 root,
3368 name,
3369 line,
3370 fallow_output::ExceededThreshold::Both,
3371 fallow_output::FindingSeverity::High,
3372 )
3373 }
3374
3375 fn make_health_finding_with(
3376 root: &Path,
3377 name: &str,
3378 line: u32,
3379 exceeded: fallow_output::ExceededThreshold,
3380 severity: fallow_output::FindingSeverity,
3381 ) -> fallow_output::ComplexityViolation {
3382 fallow_output::ComplexityViolation {
3383 path: root.join("src/utils.ts"),
3384 name: name.to_string(),
3385 line,
3386 col: 0,
3387 cyclomatic: 25,
3388 cognitive: 30,
3389 line_count: 80,
3390 param_count: 0,
3391 react_hook_count: 0,
3392 react_jsx_max_depth: 0,
3393 react_prop_count: 0,
3394 react_hook_profile: None,
3395 exceeded,
3396 severity,
3397 crap: None,
3398 coverage_pct: None,
3399 coverage_tier: None,
3400 coverage_source: None,
3401 inherited_from: None,
3402 component_rollup: None,
3403 contributions: Vec::new(),
3404 effective_thresholds: None,
3405 threshold_source: None,
3406 }
3407 }
3408
3409 #[test]
3410 fn health_baseline_roundtrip() {
3411 let root = PathBuf::from("/project");
3412 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3413 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3414 let json = serde_json::to_string(&baseline).unwrap();
3415 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
3416 assert_eq!(deserialized.findings, baseline.findings);
3417 assert_eq!(baseline.findings, Vec::<String>::new());
3418 assert_eq!(
3419 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
3420 1
3421 );
3422 assert!(!json.contains("parseExpression"));
3423 }
3424
3425 #[test]
3426 fn health_baseline_filters_known_findings() {
3427 let root = PathBuf::from("/project");
3428 let mut findings = vec![
3429 make_health_finding(&root, "parseExpression", 42),
3430 make_health_finding(&root, "newFunction", 100),
3431 ];
3432 findings[1].path = root.join("src/other.ts");
3433 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
3434 let filtered = filter_new_health_findings(findings, &baseline, &root);
3435 assert_eq!(filtered.len(), 1);
3436 assert_eq!(filtered[0].name, "newFunction");
3437 }
3438
3439 #[test]
3440 fn health_baseline_filters_shifted_lines_with_same_category_count() {
3441 let root = PathBuf::from("/project");
3442 let baseline = HealthBaselineData::from_findings(
3443 &[make_health_finding(&root, "parseExpression", 42)],
3444 &[],
3445 &[],
3446 &root,
3447 );
3448 let filtered = filter_new_health_findings(
3449 vec![make_health_finding(&root, "parseExpression", 43)],
3450 &baseline,
3451 &root,
3452 );
3453 assert!(filtered.is_empty());
3454 }
3455
3456 #[test]
3457 fn health_baseline_reports_full_category_when_count_increases() {
3458 let root = PathBuf::from("/project");
3459 let baseline = HealthBaselineData::from_findings(
3460 &[make_health_finding(&root, "parseExpression", 42)],
3461 &[],
3462 &[],
3463 &root,
3464 );
3465 let filtered = filter_new_health_findings(
3466 vec![
3467 make_health_finding(&root, "parseExpression", 43),
3468 make_health_finding(&root, "newFunction", 100),
3469 ],
3470 &baseline,
3471 &root,
3472 );
3473 assert_eq!(filtered.len(), 2);
3474 }
3475
3476 #[test]
3477 fn health_baseline_legacy_findings_still_load() {
3478 let root = PathBuf::from("/project");
3479 let baseline = HealthBaselineData {
3480 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
3481 finding_counts: BTreeMap::new(),
3482 identity_finding_counts: BTreeMap::new(),
3483 target_keys: vec![],
3484 runtime_coverage_findings: vec![],
3485 runtime_coverage_source_hashes: vec![],
3486 };
3487 let filtered = filter_new_health_findings(
3488 vec![make_health_finding(&root, "parseExpression", 42)],
3489 &baseline,
3490 &root,
3491 );
3492 assert!(filtered.is_empty());
3493 }
3494
3495 #[test]
3496 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
3497 let root = PathBuf::from("/project");
3498 let baseline = HealthBaselineData::from_findings(
3499 &[make_health_finding_with(
3500 &root,
3501 "parseExpression",
3502 42,
3503 fallow_output::ExceededThreshold::Crap,
3504 fallow_output::FindingSeverity::High,
3505 )],
3506 &[],
3507 &[],
3508 &root,
3509 );
3510 let filtered = filter_new_health_findings(
3511 vec![
3512 make_health_finding_with(
3513 &root,
3514 "parseExpression",
3515 43,
3516 fallow_output::ExceededThreshold::Crap,
3517 fallow_output::FindingSeverity::High,
3518 ),
3519 make_health_finding(&root, "newComplexityOnlyFunction", 100),
3520 ],
3521 &baseline,
3522 &root,
3523 );
3524 assert_eq!(filtered.len(), 1);
3525 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
3526 }
3527
3528 #[test]
3529 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
3530 let root = PathBuf::from("/project");
3531 let baseline = HealthBaselineData::from_findings(
3532 &[make_health_finding_with(
3533 &root,
3534 "parseExpression",
3535 42,
3536 fallow_output::ExceededThreshold::Both,
3537 fallow_output::FindingSeverity::Critical,
3538 )],
3539 &[],
3540 &[],
3541 &root,
3542 );
3543 let filtered = filter_new_health_findings(
3544 vec![make_health_finding_with(
3545 &root,
3546 "parseExpression",
3547 42,
3548 fallow_output::ExceededThreshold::Both,
3549 fallow_output::FindingSeverity::High,
3550 )],
3551 &baseline,
3552 &root,
3553 );
3554 assert!(filtered.is_empty());
3555 }
3556
3557 #[test]
3558 fn health_baseline_still_reports_worse_current_severity_as_new() {
3559 let root = PathBuf::from("/project");
3560 let baseline = HealthBaselineData::from_findings(
3561 &[make_health_finding_with(
3562 &root,
3563 "parseExpression",
3564 42,
3565 fallow_output::ExceededThreshold::Both,
3566 fallow_output::FindingSeverity::High,
3567 )],
3568 &[],
3569 &[],
3570 &root,
3571 );
3572 let filtered = filter_new_health_findings(
3573 vec![make_health_finding_with(
3574 &root,
3575 "parseExpression",
3576 42,
3577 fallow_output::ExceededThreshold::Both,
3578 fallow_output::FindingSeverity::Critical,
3579 )],
3580 &baseline,
3581 &root,
3582 );
3583 assert_eq!(filtered.len(), 1);
3584 assert_eq!(filtered[0].name, "parseExpression");
3585 assert!(matches!(
3586 filtered[0].severity,
3587 fallow_output::FindingSeverity::Critical
3588 ));
3589 }
3590
3591 #[test]
3592 fn health_baseline_overlap_counts_partial_category_overflow() {
3593 let root = PathBuf::from("/project");
3594 let baseline = HealthBaselineData::from_findings(
3595 &[make_health_finding(&root, "parseExpression", 42)],
3596 &[],
3597 &[],
3598 &root,
3599 );
3600 let overlap = baseline.overlap_entries(
3601 &[
3602 make_health_finding(&root, "parseExpression", 42),
3603 make_health_finding(&root, "newFunction", 100),
3604 ],
3605 &root,
3606 HealthBaselineMode::Count,
3607 );
3608 assert_eq!(overlap.matched_entries, 1);
3609 assert_eq!(overlap.moved_entries, 0);
3610 }
3611
3612 fn identity_baseline(
3614 findings: &[fallow_output::ComplexityViolation],
3615 root: &Path,
3616 ) -> HealthBaselineData {
3617 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
3618 }
3619
3620 #[test]
3621 fn health_identity_baseline_reports_replacement_hotspot() {
3622 let root = PathBuf::from("/project");
3623 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
3624 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
3625
3626 assert!(
3627 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
3628 "count mode keeps the per-file allowance and suppresses the replacement"
3629 );
3630
3631 let filtered = super::filter_new_health_findings(
3632 replacement,
3633 &baseline,
3634 &root,
3635 HealthBaselineMode::Identity,
3636 );
3637 assert_eq!(filtered.len(), 1);
3638 assert_eq!(filtered[0].name, "replacementHotspot");
3639 }
3640
3641 #[test]
3642 fn health_identity_baseline_survives_line_moves() {
3643 let root = PathBuf::from("/project");
3644 let baseline =
3645 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3646 let filtered = super::filter_new_health_findings(
3647 vec![make_health_finding(&root, "parseExpression", 512)],
3648 &baseline,
3649 &root,
3650 HealthBaselineMode::Identity,
3651 );
3652 assert!(filtered.is_empty());
3653 }
3654
3655 #[test]
3656 fn health_identity_baseline_suppresses_severity_improvement() {
3657 let root = PathBuf::from("/project");
3658 let baseline = identity_baseline(
3659 &[make_health_finding_with(
3660 &root,
3661 "parseExpression",
3662 42,
3663 fallow_output::ExceededThreshold::Both,
3664 fallow_output::FindingSeverity::Critical,
3665 )],
3666 &root,
3667 );
3668 let filtered = super::filter_new_health_findings(
3669 vec![make_health_finding_with(
3670 &root,
3671 "parseExpression",
3672 42,
3673 fallow_output::ExceededThreshold::Both,
3674 fallow_output::FindingSeverity::Moderate,
3675 )],
3676 &baseline,
3677 &root,
3678 HealthBaselineMode::Identity,
3679 );
3680 assert!(filtered.is_empty());
3681 }
3682
3683 #[test]
3684 fn health_identity_baseline_reports_added_finding_for_known_function() {
3685 let root = PathBuf::from("/project");
3686 let baseline =
3687 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3688 let filtered = super::filter_new_health_findings(
3689 vec![
3690 make_health_finding(&root, "parseExpression", 42),
3691 make_health_finding(&root, "parseStatement", 90),
3692 ],
3693 &baseline,
3694 &root,
3695 HealthBaselineMode::Identity,
3696 );
3697 assert_eq!(filtered.len(), 1);
3698 assert_eq!(filtered[0].name, "parseStatement");
3699 }
3700
3701 #[test]
3702 fn health_identity_buckets_are_written_only_in_identity_mode() {
3703 let root = PathBuf::from("/project");
3704 let findings = [make_health_finding(&root, "parseExpression", 42)];
3705 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3706 let json = serde_json::to_string(&count_only).unwrap();
3707 assert!(!json.contains("identity_finding_counts"));
3708 assert!(count_only.lacks_identity_data());
3709
3710 let identity = identity_baseline(&findings, &root);
3711 assert!(!identity.lacks_identity_data());
3712 assert_eq!(
3713 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
3714 .count,
3715 1
3716 );
3717 assert!(
3718 !identity.finding_counts.is_empty(),
3719 "an identity baseline stays readable in count mode"
3720 );
3721 }
3722
3723 fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
3724 let mut finding = make_health_finding(root, name, 42);
3725 finding.path = root.join(path);
3726 finding
3727 }
3728
3729 #[test]
3730 fn health_identity_baseline_follows_file_move() {
3731 let root = PathBuf::from("/project");
3732 let baseline =
3733 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3734 let moved = vec![moved_finding(
3735 &root,
3736 "src/parser/utils.ts",
3737 "parseExpression",
3738 )];
3739
3740 let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
3741 assert_eq!(
3742 overlap.matched_entries, 1,
3743 "a followed move counts as matched, not stale"
3744 );
3745 assert_eq!(
3746 overlap.moved_entries, 1,
3747 "the followed move stays observable as a moved entry"
3748 );
3749 let filtered = super::filter_new_health_findings(
3750 moved,
3751 &baseline,
3752 &root,
3753 HealthBaselineMode::Identity,
3754 );
3755 assert!(filtered.is_empty());
3756 }
3757
3758 #[test]
3759 fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
3760 let root = PathBuf::from("/project");
3761 let baseline =
3762 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3763 let filtered = super::filter_new_health_findings(
3764 vec![
3765 moved_finding(&root, "src/a.ts", "parseExpression"),
3766 moved_finding(&root, "src/b.ts", "parseExpression"),
3767 ],
3768 &baseline,
3769 &root,
3770 HealthBaselineMode::Identity,
3771 );
3772 assert_eq!(filtered.len(), 2);
3773 }
3774
3775 #[test]
3776 fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
3777 let root = PathBuf::from("/project");
3778 let baseline = identity_baseline(
3779 &[
3780 moved_finding(&root, "src/a.ts", "parseExpression"),
3781 moved_finding(&root, "src/b.ts", "parseExpression"),
3782 ],
3783 &root,
3784 );
3785 let filtered = super::filter_new_health_findings(
3786 vec![moved_finding(&root, "src/c.ts", "parseExpression")],
3787 &baseline,
3788 &root,
3789 HealthBaselineMode::Identity,
3790 );
3791 assert_eq!(filtered.len(), 1);
3792 }
3793
3794 #[test]
3795 fn health_identity_move_is_not_followed_when_old_path_still_exists() {
3796 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3799 let baseline = identity_baseline(
3800 &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
3801 &root,
3802 );
3803 let filtered = super::filter_new_health_findings(
3804 vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
3805 &baseline,
3806 &root,
3807 HealthBaselineMode::Identity,
3808 );
3809 assert_eq!(filtered.len(), 1);
3810 }
3811
3812 #[test]
3813 fn health_identity_move_is_not_followed_for_anonymous_functions() {
3814 let root = PathBuf::from("/project");
3815 let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
3816 let filtered = super::filter_new_health_findings(
3817 vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
3818 &baseline,
3819 &root,
3820 HealthBaselineMode::Identity,
3821 );
3822 assert_eq!(filtered.len(), 1);
3823 }
3824
3825 #[test]
3826 fn health_count_baseline_does_not_follow_file_moves() {
3827 let root = PathBuf::from("/project");
3828 let baseline = HealthBaselineData::from_findings(
3829 &[make_health_finding(&root, "parseExpression", 42)],
3830 &[],
3831 &[],
3832 &root,
3833 );
3834 let filtered = filter_new_health_findings(
3835 vec![moved_finding(
3836 &root,
3837 "src/parser/utils.ts",
3838 "parseExpression",
3839 )],
3840 &baseline,
3841 &root,
3842 );
3843 assert_eq!(filtered.len(), 1);
3844 }
3845
3846 #[test]
3847 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
3848 let legacy = HealthBaselineData {
3849 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
3850 ..HealthBaselineData::default()
3851 };
3852 assert!(legacy.lacks_identity_data());
3853 assert!(!HealthBaselineData::default().lacks_identity_data());
3854 }
3855
3856 #[test]
3857 fn health_baseline_empty_keeps_all() {
3858 let root = PathBuf::from("/project");
3859 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3860 let baseline = HealthBaselineData {
3861 findings: vec![],
3862 finding_counts: BTreeMap::new(),
3863 identity_finding_counts: BTreeMap::new(),
3864 target_keys: vec![],
3865 runtime_coverage_findings: vec![],
3866 runtime_coverage_source_hashes: vec![],
3867 };
3868 let filtered = filter_new_health_findings(findings, &baseline, &root);
3869 assert_eq!(filtered.len(), 1);
3870 }
3871
3872 #[test]
3873 fn circular_dep_key_is_order_independent() {
3874 use crate::results::CircularDependency;
3875
3876 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
3877 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3878 length: 2,
3879 line: 1,
3880 col: 0,
3881 edges: Vec::new(),
3882 is_cross_package: false,
3883 });
3884 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
3885 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
3886 length: 2,
3887 line: 1,
3888 col: 0,
3889 edges: Vec::new(),
3890 is_cross_package: false,
3891 });
3892 assert_eq!(
3893 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
3894 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
3895 "same files in different order should produce identical keys"
3896 );
3897 }
3898
3899 #[test]
3900 fn circular_dep_key_different_files_different_keys() {
3901 use crate::results::CircularDependency;
3902
3903 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
3904 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3905 length: 2,
3906 line: 1,
3907 col: 0,
3908 edges: Vec::new(),
3909 is_cross_package: false,
3910 });
3911 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
3912 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
3913 length: 2,
3914 line: 1,
3915 col: 0,
3916 edges: Vec::new(),
3917 is_cross_package: false,
3918 });
3919 assert_ne!(
3920 super::circular_dep_key(&dep1.cycle, Path::new("")),
3921 super::circular_dep_key(&dep2.cycle, Path::new("")),
3922 );
3923 }
3924
3925 #[test]
3926 fn circular_dep_key_three_files_order_independent() {
3927 use crate::results::CircularDependency;
3928
3929 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
3930 files: vec![
3931 PathBuf::from("src/a.ts"),
3932 PathBuf::from("src/b.ts"),
3933 PathBuf::from("src/c.ts"),
3934 ],
3935 length: 3,
3936 line: 1,
3937 col: 0,
3938 edges: Vec::new(),
3939 is_cross_package: false,
3940 });
3941 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
3942 files: vec![
3943 PathBuf::from("src/c.ts"),
3944 PathBuf::from("src/a.ts"),
3945 PathBuf::from("src/b.ts"),
3946 ],
3947 length: 3,
3948 line: 1,
3949 col: 0,
3950 edges: Vec::new(),
3951 is_cross_package: false,
3952 });
3953 assert_eq!(
3954 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
3955 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
3956 );
3957 }
3958
3959 #[expect(
3960 clippy::too_many_lines,
3961 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3962 )]
3963 fn make_full_results() -> AnalysisResults {
3964 use crate::results::*;
3965 use crate::source::MemberKind;
3966
3967 let mut r = make_results();
3968 r.circular_dependencies
3969 .push(CircularDependencyFinding::with_actions(
3970 CircularDependency {
3971 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3972 length: 2,
3973 line: 1,
3974 col: 0,
3975 edges: Vec::new(),
3976 is_cross_package: false,
3977 },
3978 ));
3979 r.unused_optional_dependencies
3980 .push(UnusedOptionalDependencyFinding::with_actions(
3981 UnusedDependency {
3982 package_name: "fsevents".to_string(),
3983 location: DependencyLocation::OptionalDependencies,
3984 path: PathBuf::from("package.json"),
3985 line: 15,
3986 used_in_workspaces: Vec::new(),
3987 },
3988 ));
3989 r.unused_enum_members
3990 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3991 path: PathBuf::from("src/enums.ts"),
3992 parent_name: "Status".to_string(),
3993 member_name: "Deprecated".to_string(),
3994 kind: MemberKind::EnumMember,
3995 line: 8,
3996 col: 0,
3997 }));
3998 r.unused_class_members
3999 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
4000 path: PathBuf::from("src/service.ts"),
4001 parent_name: "UserService".to_string(),
4002 member_name: "legacy".to_string(),
4003 kind: MemberKind::ClassMethod,
4004 line: 42,
4005 col: 0,
4006 }));
4007 r.unused_store_members
4008 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
4009 path: PathBuf::from("src/store.ts"),
4010 parent_name: "useStore".to_string(),
4011 member_name: "legacyAction".to_string(),
4012 kind: MemberKind::StoreMember,
4013 line: 17,
4014 col: 0,
4015 }));
4016 r.unresolved_imports.push(
4017 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
4018 crate::results::UnresolvedImport {
4019 path: PathBuf::from("src/app.ts"),
4020 specifier: "./missing".to_string(),
4021 line: 3,
4022 col: 0,
4023 specifier_col: 0,
4024 },
4025 ),
4026 );
4027 r.unlisted_dependencies
4028 .push(crate::results::UnlistedDependencyFinding::with_actions(
4029 UnlistedDependency {
4030 package_name: "chalk".to_string(),
4031 imported_from: vec![],
4032 },
4033 ));
4034 r.duplicate_exports
4035 .push(crate::results::DuplicateExportFinding::with_actions(
4036 crate::results::DuplicateExport {
4037 export_name: "Config".to_string(),
4038 locations: vec![
4039 crate::results::DuplicateLocation {
4040 path: PathBuf::from("src/a.ts"),
4041 line: 1,
4042 col: 0,
4043 },
4044 crate::results::DuplicateLocation {
4045 path: PathBuf::from("src/b.ts"),
4046 line: 5,
4047 col: 0,
4048 },
4049 ],
4050 },
4051 ));
4052 r.type_only_dependencies
4053 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
4054 TypeOnlyDependency {
4055 package_name: "zod".to_string(),
4056 path: PathBuf::from("package.json"),
4057 line: 8,
4058 },
4059 ));
4060 r.test_only_dependencies
4061 .push(crate::results::TestOnlyDependencyFinding::with_actions(
4062 TestOnlyDependency {
4063 package_name: "vitest".to_string(),
4064 path: PathBuf::from("package.json"),
4065 line: 10,
4066 },
4067 ));
4068 r.boundary_violations.push(
4069 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
4070 crate::results::BoundaryViolation {
4071 from_path: PathBuf::from("src/ui/btn.ts"),
4072 to_path: PathBuf::from("src/db/query.ts"),
4073 from_zone: "ui".to_string(),
4074 to_zone: "db".to_string(),
4075 import_specifier: "../db/query".to_string(),
4076 line: 1,
4077 col: 0,
4078 },
4079 ),
4080 );
4081 r
4082 }
4083
4084 #[test]
4085 fn baseline_from_results_captures_all_extended_fields() {
4086 let results = make_full_results();
4087 let baseline = BaselineData::from_results(&results, Path::new(""));
4088 assert_eq!(baseline.circular_dependencies.len(), 1);
4089 assert_eq!(
4090 baseline.unused_optional_dependencies,
4091 vec!["package.json:fsevents"]
4092 );
4093 assert_eq!(baseline.unused_enum_members.len(), 1);
4094 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
4095 assert_eq!(baseline.unused_class_members.len(), 1);
4096 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
4097 assert_eq!(baseline.unused_store_members.len(), 1);
4098 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
4099 assert_eq!(baseline.unresolved_imports.len(), 1);
4100 assert!(baseline.unresolved_imports[0].contains("./missing"));
4101 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
4102 assert_eq!(baseline.duplicate_exports.len(), 1);
4103 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
4104 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
4105 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
4106 assert_eq!(baseline.boundary_violations.len(), 1);
4107 assert!(baseline.boundary_violations[0].contains("->"));
4108 }
4109
4110 #[test]
4111 fn filter_removes_all_extended_baseline_issues() {
4112 let results = make_full_results();
4113 let baseline = BaselineData::from_results(&results, Path::new(""));
4114 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4115 assert!(filtered.circular_dependencies.is_empty());
4116 assert!(filtered.unused_optional_dependencies.is_empty());
4117 assert!(filtered.unused_enum_members.is_empty());
4118 assert!(filtered.unused_class_members.is_empty());
4119 assert!(filtered.unused_store_members.is_empty());
4120 assert!(filtered.unresolved_imports.is_empty());
4121 assert!(filtered.unlisted_dependencies.is_empty());
4122 assert!(filtered.duplicate_exports.is_empty());
4123 assert!(filtered.type_only_dependencies.is_empty());
4124 assert!(filtered.test_only_dependencies.is_empty());
4125 assert!(filtered.boundary_violations.is_empty());
4126 }
4127
4128 #[test]
4129 fn filter_keeps_new_circular_deps() {
4130 use crate::results::CircularDependency;
4131 let baseline = BaselineData {
4132 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
4133 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4134 };
4135 let mut results = AnalysisResults::default();
4136 results
4137 .circular_dependencies
4138 .push(CircularDependencyFinding::with_actions(
4139 CircularDependency {
4140 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
4141 length: 2,
4142 line: 1,
4143 col: 0,
4144 edges: Vec::new(),
4145 is_cross_package: false,
4146 },
4147 ));
4148 results
4149 .circular_dependencies
4150 .push(CircularDependencyFinding::with_actions(
4151 CircularDependency {
4152 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
4153 length: 2,
4154 line: 5,
4155 col: 0,
4156 edges: Vec::new(),
4157 is_cross_package: false,
4158 },
4159 ));
4160 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4161 assert_eq!(filtered.circular_dependencies.len(), 1);
4162 }
4163
4164 #[test]
4165 fn filter_keeps_new_boundary_violations() {
4166 use crate::results::BoundaryViolation;
4167 let baseline = BaselineData {
4168 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
4169 boundary_coverage_violations: vec![],
4170 boundary_call_violations: vec![],
4171 policy_violations: vec![],
4172 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
4173 };
4174 let mut results = AnalysisResults::default();
4175 results
4176 .boundary_violations
4177 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4178 from_path: PathBuf::from("src/a.ts"),
4179 to_path: PathBuf::from("src/b.ts"),
4180 from_zone: "a".to_string(),
4181 to_zone: "b".to_string(),
4182 import_specifier: "../b".to_string(),
4183 line: 1,
4184 col: 0,
4185 }));
4186 results
4187 .boundary_violations
4188 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4189 from_path: PathBuf::from("src/new.ts"),
4190 to_path: PathBuf::from("src/secret.ts"),
4191 from_zone: "new".to_string(),
4192 to_zone: "secret".to_string(),
4193 import_specifier: "../secret".to_string(),
4194 line: 1,
4195 col: 0,
4196 }));
4197 let filtered = filter_new_issues(results, &baseline, Path::new(""));
4198 assert_eq!(filtered.boundary_violations.len(), 1);
4199 }
4200
4201 #[test]
4202 fn health_targets_baseline_filters_known() {
4203 let root = PathBuf::from("/project");
4204 let targets = vec![
4205 fallow_output::RefactoringTarget {
4206 path: root.join("src/complex.ts"),
4207 priority: 80.0,
4208 efficiency: 40.0,
4209 recommendation: "Split file".to_string(),
4210 category: fallow_output::RecommendationCategory::SplitHighImpact,
4211 effort: fallow_output::EffortEstimate::Medium,
4212 confidence: fallow_output::Confidence::Medium,
4213 factors: vec![],
4214 evidence: None,
4215 },
4216 fallow_output::RefactoringTarget {
4217 path: root.join("src/new-issue.ts"),
4218 priority: 60.0,
4219 efficiency: 30.0,
4220 recommendation: "Extract function".to_string(),
4221 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4222 effort: fallow_output::EffortEstimate::Low,
4223 confidence: fallow_output::Confidence::High,
4224 factors: vec![],
4225 evidence: None,
4226 },
4227 ];
4228 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
4229 let filtered = filter_new_health_targets(targets, &baseline, &root);
4230 assert_eq!(filtered.len(), 1);
4231 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
4232 }
4233
4234 #[test]
4235 fn duplicate_export_key_is_sorted() {
4236 use crate::results::{DuplicateExport, DuplicateLocation};
4237 let dup_ab = DuplicateExport {
4238 export_name: "foo".to_string(),
4239 locations: vec![
4240 DuplicateLocation {
4241 path: PathBuf::from("src/a.ts"),
4242 line: 1,
4243 col: 0,
4244 },
4245 DuplicateLocation {
4246 path: PathBuf::from("src/b.ts"),
4247 line: 5,
4248 col: 0,
4249 },
4250 ],
4251 };
4252 let dup_ba = DuplicateExport {
4253 export_name: "foo".to_string(),
4254 locations: vec![
4255 DuplicateLocation {
4256 path: PathBuf::from("src/b.ts"),
4257 line: 5,
4258 col: 0,
4259 },
4260 DuplicateLocation {
4261 path: PathBuf::from("src/a.ts"),
4262 line: 1,
4263 col: 0,
4264 },
4265 ],
4266 };
4267 assert_eq!(
4268 super::duplicate_export_key(&dup_ab, Path::new("")),
4269 super::duplicate_export_key(&dup_ba, Path::new("")),
4270 );
4271 }
4272
4273 #[test]
4274 fn boundary_violation_key_format() {
4275 use crate::results::BoundaryViolation;
4276 let v = BoundaryViolation {
4277 from_path: PathBuf::from("src/ui/btn.ts"),
4278 to_path: PathBuf::from("src/db/query.ts"),
4279 from_zone: "ui".to_string(),
4280 to_zone: "db".to_string(),
4281 import_specifier: "../db/query".to_string(),
4282 line: 1,
4283 col: 0,
4284 };
4285 let key = super::boundary_violation_key(&v, Path::new(""));
4286 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
4287 }
4288
4289 fn make_absolute_results(root: &str) -> AnalysisResults {
4291 use crate::results::*;
4292 use crate::source::MemberKind;
4293
4294 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
4295
4296 AnalysisResults {
4297 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4298 path: p("src/old.ts"),
4299 })],
4300 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
4301 path: p("src/utils.ts"),
4302 export_name: "helper".to_string(),
4303 is_type_only: false,
4304 line: 5,
4305 col: 0,
4306 span_start: 40,
4307 is_re_export: false,
4308 })],
4309 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
4310 package_name: "lodash-es".to_string(),
4311 location: DependencyLocation::Dependencies,
4312 path: p("packages/app/package.json"),
4313 line: 5,
4314 used_in_workspaces: Vec::new(),
4315 })],
4316 circular_dependencies: vec![CircularDependencyFinding::with_actions(
4317 CircularDependency {
4318 files: vec![p("src/a.ts"), p("src/b.ts")],
4319 length: 2,
4320 line: 1,
4321 col: 0,
4322 edges: Vec::new(),
4323 is_cross_package: false,
4324 },
4325 )],
4326 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
4327 path: p("src/enums.ts"),
4328 parent_name: "Status".to_string(),
4329 member_name: "Deprecated".to_string(),
4330 kind: MemberKind::EnumMember,
4331 line: 8,
4332 col: 0,
4333 })],
4334 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
4335 path: p("src/service.ts"),
4336 parent_name: "UserService".to_string(),
4337 member_name: "legacy".to_string(),
4338 kind: MemberKind::ClassMethod,
4339 line: 42,
4340 col: 0,
4341 })],
4342 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
4343 path: p("src/store.ts"),
4344 parent_name: "useStore".to_string(),
4345 member_name: "legacyAction".to_string(),
4346 kind: MemberKind::StoreMember,
4347 line: 17,
4348 col: 0,
4349 })],
4350 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4351 path: p("src/app.ts"),
4352 specifier: "./missing".to_string(),
4353 line: 3,
4354 col: 0,
4355 specifier_col: 0,
4356 })],
4357 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4358 export_name: "Config".to_string(),
4359 locations: vec![
4360 DuplicateLocation {
4361 path: p("src/a.ts"),
4362 line: 1,
4363 col: 0,
4364 },
4365 DuplicateLocation {
4366 path: p("src/b.ts"),
4367 line: 5,
4368 col: 0,
4369 },
4370 ],
4371 })],
4372 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4373 from_path: p("src/ui/btn.ts"),
4374 to_path: p("src/db/query.ts"),
4375 from_zone: "ui".to_string(),
4376 to_zone: "db".to_string(),
4377 import_specifier: "../db/query".to_string(),
4378 line: 1,
4379 col: 0,
4380 })],
4381 ..Default::default()
4382 }
4383 }
4384
4385 #[test]
4388 fn baseline_keys_are_relative_to_root() {
4389 let local_root = Path::new("/Users/dev/project");
4390 let results = make_absolute_results("/Users/dev/project");
4391 let baseline = BaselineData::from_results(&results, local_root);
4392
4393 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
4394 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
4395 assert_eq!(
4396 baseline.unused_dependencies,
4397 vec!["packages/app/package.json:lodash-es"]
4398 );
4399 assert_eq!(
4400 baseline.boundary_violations,
4401 vec!["src/ui/btn.ts->src/db/query.ts"]
4402 );
4403 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
4404 assert_eq!(
4405 baseline.unused_enum_members,
4406 vec!["src/enums.ts:Status.Deprecated"]
4407 );
4408 assert_eq!(
4409 baseline.unused_class_members,
4410 vec!["src/service.ts:UserService.legacy"]
4411 );
4412 assert_eq!(
4413 baseline.unused_store_members,
4414 vec!["src/store.ts:useStore.legacyAction"]
4415 );
4416 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
4417 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
4418
4419 let ci_root = Path::new("/home/runner/work/project/project");
4420 let ci_results = make_absolute_results("/home/runner/work/project/project");
4421
4422 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
4423 assert!(filtered.unused_files.is_empty(), "unused files");
4424 assert!(filtered.unused_exports.is_empty(), "unused exports");
4425 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
4426 assert!(
4427 filtered.boundary_violations.is_empty(),
4428 "boundary violations"
4429 );
4430 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
4431 assert!(filtered.unused_enum_members.is_empty(), "enum members");
4432 assert!(filtered.unused_class_members.is_empty(), "class members");
4433 assert!(filtered.unused_store_members.is_empty(), "store members");
4434 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
4435 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
4436 }
4437
4438 #[test]
4439 fn stale_suppression_baseline_keys_include_missing_reason_state() {
4440 let root = Path::new("/project");
4441 let stale = crate::results::StaleSuppression {
4442 path: root.join("src/file.ts"),
4443 line: 1,
4444 col: 0,
4445 origin: crate::results::SuppressionOrigin::Comment {
4446 issue_kind: Some("unused-export".to_string()),
4447 reason: None,
4448 is_file_level: false,
4449 kind_known: true,
4450 },
4451 missing_reason: false,
4452 actions: crate::results::StaleSuppression::actions_for(false),
4453 };
4454 let missing = crate::results::StaleSuppression {
4455 missing_reason: true,
4456 actions: crate::results::StaleSuppression::actions_for(true),
4457 ..stale.clone()
4458 };
4459 let results = AnalysisResults {
4460 stale_suppressions: vec![stale, missing],
4461 ..Default::default()
4462 };
4463 let baseline = BaselineData::from_results(&results, root);
4464
4465 assert_eq!(
4466 baseline.stale_suppressions,
4467 vec![
4468 "stale-suppression:src/file.ts:1",
4469 "missing-suppression-reason:src/file.ts:1",
4470 ]
4471 );
4472
4473 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
4474 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
4475 let filtered = filter_new_issues(results, &legacy_baseline, root);
4476 assert!(filtered.stale_suppressions.is_empty());
4477 }
4478
4479 fn runtime_finding(
4480 id: &str,
4481 stable_id: Option<&str>,
4482 line: u32,
4483 source_hash: Option<&str>,
4484 ) -> fallow_output::RuntimeCoverageFinding {
4485 fallow_output::RuntimeCoverageFinding {
4486 id: id.to_owned(),
4487 stable_id: stable_id.map(str::to_owned),
4488 source_hash: source_hash.map(str::to_owned),
4489 path: PathBuf::from("src/a.ts"),
4490 function: "alpha".to_owned(),
4491 line,
4492 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
4493 invocations: Some(0),
4494 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
4495 evidence: fallow_output::RuntimeCoverageEvidence {
4496 static_status: "used".to_owned(),
4497 test_coverage: "not_covered".to_owned(),
4498 test_only_reference: None,
4499 v8_tracking: "tracked".to_owned(),
4500 untracked_reason: None,
4501 observation_days: 1,
4502 deployments_observed: 1,
4503 },
4504 actions: vec![],
4505 discriminators: None,
4506 }
4507 }
4508
4509 #[test]
4510 fn legacy_prod_baseline_still_suppresses_finding() {
4511 let baseline = HealthBaselineData {
4512 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
4513 ..HealthBaselineData::default()
4514 };
4515 let findings = vec![runtime_finding(
4516 "fallow:prod:deadbeef",
4517 Some("fallow:fn:00000001"),
4518 14,
4519 None,
4520 )];
4521 let filtered =
4522 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4523 assert!(filtered.is_empty(), "legacy prod id must still suppress");
4524 }
4525
4526 #[test]
4527 fn source_hash_baseline_survives_line_move() {
4528 let root = Path::new("/repo");
4529 let baselined = runtime_finding(
4530 "fallow:prod:deadbeef",
4531 Some("fallow:fn:00000001"),
4532 14,
4533 Some("0123456789abcdef"),
4534 );
4535 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
4536 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
4537
4538 let findings = vec![runtime_finding(
4539 "fallow:prod:99999999",
4540 Some("fallow:fn:cafe0002"),
4541 40,
4542 Some("0123456789abcdef"),
4543 )];
4544 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
4545 assert!(
4546 filtered.is_empty(),
4547 "source_hash baseline must survive a line move despite a changed stable_id and id"
4548 );
4549 }
4550
4551 #[test]
4552 fn unbaselined_finding_is_reported() {
4553 let baseline = HealthBaselineData {
4554 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
4555 ..HealthBaselineData::default()
4556 };
4557 let findings = vec![runtime_finding(
4558 "fallow:prod:abc1234d",
4559 Some("fallow:fn:beefcafe"),
4560 7,
4561 None,
4562 )];
4563 let filtered =
4564 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4565 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
4566 }
4567}