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