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