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_instances: 2,
2796 duplication_percentage: 10.0,
2797 clone_groups_below_min_occurrences: 0,
2798 clone_groups_ignored: 0,
2799 near_candidates_skipped: 0,
2800 },
2801 }
2802 }
2803
2804 fn normalized_clone_group_key(group: &CloneGroup) -> String {
2805 let fingerprints =
2806 crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
2807 clone_group_fingerprint_key(group, &fingerprints)
2808 }
2809
2810 #[test]
2811 fn clone_group_key_is_deterministic() {
2812 let root = Path::new("/project");
2813 let group = make_clone_group(vec![
2814 ("/project/src/a.ts", 1, 10),
2815 ("/project/src/b.ts", 5, 15),
2816 ]);
2817 let key1 = clone_group_key(&group, root);
2818 let key2 = clone_group_key(&group, root);
2819 assert_eq!(key1, key2);
2820 }
2821
2822 #[test]
2823 fn clone_group_key_is_sorted() {
2824 let root = Path::new("/project");
2825 let group_ab = make_clone_group(vec![
2826 ("/project/src/a.ts", 1, 10),
2827 ("/project/src/b.ts", 5, 15),
2828 ]);
2829 let group_ba = make_clone_group(vec![
2830 ("/project/src/b.ts", 5, 15),
2831 ("/project/src/a.ts", 1, 10),
2832 ]);
2833 assert_eq!(
2834 clone_group_key(&group_ab, root),
2835 clone_group_key(&group_ba, root),
2836 "key should be stable regardless of instance order"
2837 );
2838 }
2839
2840 #[test]
2841 fn duplication_baseline_roundtrip() {
2842 let root = Path::new("/project");
2843 let group = make_clone_group(vec![
2844 ("/project/src/a.ts", 1, 10),
2845 ("/project/src/b.ts", 5, 15),
2846 ]);
2847 let report = make_duplication_report(vec![group]);
2848 let baseline = DuplicationBaselineData::from_report(&report, root);
2849 let json = serde_json::to_string(&baseline).unwrap();
2850 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2851 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2852 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
2853 assert_eq!(
2854 deserialized.normalized_clone_fingerprints,
2855 baseline.normalized_clone_fingerprints
2856 );
2857 assert_eq!(
2858 baseline.normalized_clone_fingerprints.len(),
2859 1,
2860 "a saved baseline carries a normalized key per clone group"
2861 );
2862 }
2863
2864 #[test]
2865 fn filter_new_clone_groups_matches_shifted_clone() {
2866 let root = Path::new("/project");
2867 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2868 "const total = a + b;",
2869 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2870 )]);
2871 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2872
2873 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2874 "const total = a + b;",
2875 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2876 )]);
2877 let filtered = filter_new_clone_groups(shifted, &baseline, root);
2878 assert!(
2879 filtered.clone_groups.is_empty(),
2880 "an unrelated line shift must not resurface a baselined clone"
2881 );
2882 }
2883
2884 #[test]
2885 fn filter_new_clone_groups_reports_extra_copy() {
2886 let root = Path::new("/project");
2887 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2888 "const total = a + b;",
2889 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2890 )]);
2891 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2892
2893 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
2894 "const total = a + b;",
2895 vec![
2896 ("/project/src/a.ts", 10, 20),
2897 ("/project/src/b.ts", 30, 40),
2898 ("/project/src/c.ts", 5, 15),
2899 ],
2900 )]);
2901 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
2902 assert_eq!(
2903 filtered.clone_groups.len(),
2904 1,
2905 "a fresh copy in a third file is a new finding"
2906 );
2907 }
2908
2909 #[test]
2910 fn filter_new_clone_groups_reads_legacy_baseline() {
2911 let root = Path::new("/project");
2912 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
2913 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
2914 assert_eq!(baseline.entry_count(), 1);
2915
2916 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
2917 "const total = a + b;",
2918 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2919 )]);
2920 assert!(
2921 filter_new_clone_groups(unchanged, &baseline, root)
2922 .clone_groups
2923 .is_empty(),
2924 "a legacy baseline still matches on locations"
2925 );
2926
2927 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2928 "const total = a + b;",
2929 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2930 )]);
2931 assert_eq!(
2932 filter_new_clone_groups(shifted, &baseline, root)
2933 .clone_groups
2934 .len(),
2935 1,
2936 "legacy behavior is unchanged: a shift stops matching"
2937 );
2938 }
2939
2940 #[test]
2941 fn clone_group_fingerprint_key_survives_file_rename() {
2942 let before = make_clone_group_with_fragment(
2943 "const total = a + b;",
2944 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2945 );
2946 let after = make_clone_group_with_fragment(
2947 "const total = a + b;",
2948 vec![
2949 ("/project/src/renamed.ts", 10, 20),
2950 ("/project/src/b.ts", 30, 40),
2951 ],
2952 );
2953 assert_eq!(
2954 normalized_clone_group_key(&before),
2955 normalized_clone_group_key(&after),
2956 "renaming a file must not resurface a baselined clone"
2957 );
2958 }
2959
2960 #[test]
2961 fn clone_group_fingerprint_key_does_not_follow_representative_order() {
2962 let mut before = make_clone_group_with_fragment(
2963 "const total = a + b;",
2964 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2965 );
2966 before.instances[1].fragment = "const total = a + b;".to_string();
2967
2968 let mut after = before.clone();
2969 after.instances[0].file = PathBuf::from("/project/src/z.ts");
2970
2971 assert_eq!(
2972 normalized_clone_group_key(&before),
2973 normalized_clone_group_key(&after),
2974 "normalized group identity is independent of representative order"
2975 );
2976 }
2977
2978 #[test]
2979 fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
2980 let root = Path::new("/project");
2981 let group = make_clone_group_with_fragment(
2982 "const total = a + b;",
2983 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2984 );
2985 let legacy_key = legacy_clone_group_fingerprint_key(&group);
2986 let baseline = DuplicationBaselineData {
2987 clone_groups: Vec::new(),
2988 clone_fingerprints: vec![legacy_key.clone()],
2989 normalized_clone_fingerprints: Vec::new(),
2990 };
2991
2992 let filtered = filter_new_clone_groups(
2993 make_duplication_report(vec![group.clone()]),
2994 &baseline,
2995 root,
2996 );
2997 assert!(filtered.clone_groups.is_empty());
2998
2999 let current =
3000 DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
3001 assert_eq!(current.clone_fingerprints, vec![legacy_key]);
3002 assert_ne!(
3003 current.normalized_clone_fingerprints,
3004 current.clone_fingerprints
3005 );
3006 }
3007
3008 #[test]
3009 fn normalized_baseline_survives_formatting_only_edits() {
3010 let root = Path::new("/project");
3011 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
3012 "const total = left + right;",
3013 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
3014 )]);
3015 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3016
3017 let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
3018 "/* reviewed */\r\nconst total=left + right;",
3019 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
3020 )]);
3021 assert!(
3022 filter_new_clone_groups(formatted, &baseline, root)
3023 .clone_groups
3024 .is_empty()
3025 );
3026 }
3027
3028 #[test]
3029 fn filter_new_clone_groups_removes_baseline() {
3030 let root = Path::new("/project");
3031 let group = make_clone_group(vec![
3032 ("/project/src/a.ts", 1, 10),
3033 ("/project/src/b.ts", 5, 15),
3034 ]);
3035 let report = make_duplication_report(vec![group]);
3036 let baseline = DuplicationBaselineData::from_report(&report, root);
3037 let filtered = filter_new_clone_groups(report, &baseline, root);
3038 assert!(
3039 filtered.clone_groups.is_empty(),
3040 "baseline group should be filtered out"
3041 );
3042 }
3043
3044 #[test]
3045 fn filter_new_clone_groups_keeps_new_groups() {
3046 let root = Path::new("/project");
3047 let baseline_group = make_clone_group(vec![
3048 ("/project/src/a.ts", 1, 10),
3049 ("/project/src/b.ts", 5, 15),
3050 ]);
3051 let new_group = make_clone_group(vec![
3052 ("/project/src/c.ts", 20, 30),
3053 ("/project/src/d.ts", 25, 35),
3054 ]);
3055 let baseline_report = make_duplication_report(vec![baseline_group]);
3056 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
3057
3058 let report = make_duplication_report(vec![
3059 make_clone_group(vec![
3060 ("/project/src/a.ts", 1, 10),
3061 ("/project/src/b.ts", 5, 15),
3062 ]),
3063 new_group,
3064 ]);
3065 let filtered = filter_new_clone_groups(report, &baseline, root);
3066 assert_eq!(
3067 filtered.clone_groups.len(),
3068 1,
3069 "only the new group should remain"
3070 );
3071 }
3072
3073 #[test]
3074 fn recompute_stats_after_filtering() {
3075 let root = Path::new("/project");
3076 let group = make_clone_group(vec![
3077 ("/project/src/a.ts", 1, 10),
3078 ("/project/src/b.ts", 5, 15),
3079 ]);
3080 let report = make_duplication_report(vec![group]);
3081 let baseline = DuplicationBaselineData::from_report(&report, root);
3082 let filtered = filter_new_clone_groups(report, &baseline, root);
3083 assert_eq!(filtered.stats.clone_groups, 0);
3084 assert_eq!(filtered.stats.clone_instances, 0);
3085 assert_eq!(filtered.stats.duplicated_lines, 0);
3086 }
3087
3088 #[test]
3089 fn recompute_stats_zero_total_lines() {
3090 let report = DuplicationReport {
3091 clone_groups: vec![],
3092 clone_families: vec![],
3093 mirrored_directories: vec![],
3094 stats: DuplicationStats {
3095 total_files: 0,
3096 files_with_clones: 0,
3097 total_lines: 0,
3098 duplicated_lines: 0,
3099 total_tokens: 0,
3100 duplicated_tokens: 0,
3101 clone_groups: 0,
3102 clone_instances: 0,
3103 duplication_percentage: 0.0,
3104 clone_groups_below_min_occurrences: 0,
3105 clone_groups_ignored: 0,
3106 near_candidates_skipped: 0,
3107 },
3108 };
3109 let stats = super::recompute_stats(&report);
3110 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
3111 }
3112
3113 fn filter_new_health_findings(
3117 findings: Vec<fallow_output::ComplexityViolation>,
3118 baseline: &HealthBaselineData,
3119 root: &Path,
3120 ) -> Vec<fallow_output::ComplexityViolation> {
3121 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
3122 }
3123
3124 fn make_health_finding(
3125 root: &Path,
3126 name: &str,
3127 line: u32,
3128 ) -> fallow_output::ComplexityViolation {
3129 make_health_finding_with(
3130 root,
3131 name,
3132 line,
3133 fallow_output::ExceededThreshold::Both,
3134 fallow_output::FindingSeverity::High,
3135 )
3136 }
3137
3138 fn make_health_finding_with(
3139 root: &Path,
3140 name: &str,
3141 line: u32,
3142 exceeded: fallow_output::ExceededThreshold,
3143 severity: fallow_output::FindingSeverity,
3144 ) -> fallow_output::ComplexityViolation {
3145 fallow_output::ComplexityViolation {
3146 path: root.join("src/utils.ts"),
3147 name: name.to_string(),
3148 line,
3149 col: 0,
3150 cyclomatic: 25,
3151 cognitive: 30,
3152 line_count: 80,
3153 param_count: 0,
3154 react_hook_count: 0,
3155 react_jsx_max_depth: 0,
3156 react_prop_count: 0,
3157 react_hook_profile: None,
3158 exceeded,
3159 severity,
3160 crap: None,
3161 coverage_pct: None,
3162 coverage_tier: None,
3163 coverage_source: None,
3164 inherited_from: None,
3165 component_rollup: None,
3166 contributions: Vec::new(),
3167 effective_thresholds: None,
3168 threshold_source: None,
3169 }
3170 }
3171
3172 #[test]
3173 fn health_baseline_roundtrip() {
3174 let root = PathBuf::from("/project");
3175 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3176 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3177 let json = serde_json::to_string(&baseline).unwrap();
3178 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
3179 assert_eq!(deserialized.findings, baseline.findings);
3180 assert_eq!(baseline.findings, Vec::<String>::new());
3181 assert_eq!(
3182 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
3183 1
3184 );
3185 assert!(!json.contains("parseExpression"));
3186 }
3187
3188 #[test]
3189 fn health_baseline_filters_known_findings() {
3190 let root = PathBuf::from("/project");
3191 let mut findings = vec![
3192 make_health_finding(&root, "parseExpression", 42),
3193 make_health_finding(&root, "newFunction", 100),
3194 ];
3195 findings[1].path = root.join("src/other.ts");
3196 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
3197 let filtered = filter_new_health_findings(findings, &baseline, &root);
3198 assert_eq!(filtered.len(), 1);
3199 assert_eq!(filtered[0].name, "newFunction");
3200 }
3201
3202 #[test]
3203 fn health_baseline_filters_shifted_lines_with_same_category_count() {
3204 let root = PathBuf::from("/project");
3205 let baseline = HealthBaselineData::from_findings(
3206 &[make_health_finding(&root, "parseExpression", 42)],
3207 &[],
3208 &[],
3209 &root,
3210 );
3211 let filtered = filter_new_health_findings(
3212 vec![make_health_finding(&root, "parseExpression", 43)],
3213 &baseline,
3214 &root,
3215 );
3216 assert!(filtered.is_empty());
3217 }
3218
3219 #[test]
3220 fn health_baseline_reports_full_category_when_count_increases() {
3221 let root = PathBuf::from("/project");
3222 let baseline = HealthBaselineData::from_findings(
3223 &[make_health_finding(&root, "parseExpression", 42)],
3224 &[],
3225 &[],
3226 &root,
3227 );
3228 let filtered = filter_new_health_findings(
3229 vec![
3230 make_health_finding(&root, "parseExpression", 43),
3231 make_health_finding(&root, "newFunction", 100),
3232 ],
3233 &baseline,
3234 &root,
3235 );
3236 assert_eq!(filtered.len(), 2);
3237 }
3238
3239 #[test]
3240 fn health_baseline_legacy_findings_still_load() {
3241 let root = PathBuf::from("/project");
3242 let baseline = HealthBaselineData {
3243 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
3244 finding_counts: BTreeMap::new(),
3245 identity_finding_counts: BTreeMap::new(),
3246 target_keys: vec![],
3247 runtime_coverage_findings: vec![],
3248 runtime_coverage_source_hashes: vec![],
3249 };
3250 let filtered = filter_new_health_findings(
3251 vec![make_health_finding(&root, "parseExpression", 42)],
3252 &baseline,
3253 &root,
3254 );
3255 assert!(filtered.is_empty());
3256 }
3257
3258 #[test]
3259 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
3260 let root = PathBuf::from("/project");
3261 let baseline = HealthBaselineData::from_findings(
3262 &[make_health_finding_with(
3263 &root,
3264 "parseExpression",
3265 42,
3266 fallow_output::ExceededThreshold::Crap,
3267 fallow_output::FindingSeverity::High,
3268 )],
3269 &[],
3270 &[],
3271 &root,
3272 );
3273 let filtered = filter_new_health_findings(
3274 vec![
3275 make_health_finding_with(
3276 &root,
3277 "parseExpression",
3278 43,
3279 fallow_output::ExceededThreshold::Crap,
3280 fallow_output::FindingSeverity::High,
3281 ),
3282 make_health_finding(&root, "newComplexityOnlyFunction", 100),
3283 ],
3284 &baseline,
3285 &root,
3286 );
3287 assert_eq!(filtered.len(), 1);
3288 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
3289 }
3290
3291 #[test]
3292 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
3293 let root = PathBuf::from("/project");
3294 let baseline = HealthBaselineData::from_findings(
3295 &[make_health_finding_with(
3296 &root,
3297 "parseExpression",
3298 42,
3299 fallow_output::ExceededThreshold::Both,
3300 fallow_output::FindingSeverity::Critical,
3301 )],
3302 &[],
3303 &[],
3304 &root,
3305 );
3306 let filtered = filter_new_health_findings(
3307 vec![make_health_finding_with(
3308 &root,
3309 "parseExpression",
3310 42,
3311 fallow_output::ExceededThreshold::Both,
3312 fallow_output::FindingSeverity::High,
3313 )],
3314 &baseline,
3315 &root,
3316 );
3317 assert!(filtered.is_empty());
3318 }
3319
3320 #[test]
3321 fn health_baseline_still_reports_worse_current_severity_as_new() {
3322 let root = PathBuf::from("/project");
3323 let baseline = HealthBaselineData::from_findings(
3324 &[make_health_finding_with(
3325 &root,
3326 "parseExpression",
3327 42,
3328 fallow_output::ExceededThreshold::Both,
3329 fallow_output::FindingSeverity::High,
3330 )],
3331 &[],
3332 &[],
3333 &root,
3334 );
3335 let filtered = filter_new_health_findings(
3336 vec![make_health_finding_with(
3337 &root,
3338 "parseExpression",
3339 42,
3340 fallow_output::ExceededThreshold::Both,
3341 fallow_output::FindingSeverity::Critical,
3342 )],
3343 &baseline,
3344 &root,
3345 );
3346 assert_eq!(filtered.len(), 1);
3347 assert_eq!(filtered[0].name, "parseExpression");
3348 assert!(matches!(
3349 filtered[0].severity,
3350 fallow_output::FindingSeverity::Critical
3351 ));
3352 }
3353
3354 #[test]
3355 fn health_baseline_overlap_counts_partial_category_overflow() {
3356 let root = PathBuf::from("/project");
3357 let baseline = HealthBaselineData::from_findings(
3358 &[make_health_finding(&root, "parseExpression", 42)],
3359 &[],
3360 &[],
3361 &root,
3362 );
3363 let overlap = baseline.overlap_entries(
3364 &[
3365 make_health_finding(&root, "parseExpression", 42),
3366 make_health_finding(&root, "newFunction", 100),
3367 ],
3368 &root,
3369 HealthBaselineMode::Count,
3370 );
3371 assert_eq!(overlap.matched_entries, 1);
3372 assert_eq!(overlap.moved_entries, 0);
3373 }
3374
3375 fn identity_baseline(
3377 findings: &[fallow_output::ComplexityViolation],
3378 root: &Path,
3379 ) -> HealthBaselineData {
3380 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
3381 }
3382
3383 #[test]
3384 fn health_identity_baseline_reports_replacement_hotspot() {
3385 let root = PathBuf::from("/project");
3386 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
3387 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
3388
3389 assert!(
3390 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
3391 "count mode keeps the per-file allowance and suppresses the replacement"
3392 );
3393
3394 let filtered = super::filter_new_health_findings(
3395 replacement,
3396 &baseline,
3397 &root,
3398 HealthBaselineMode::Identity,
3399 );
3400 assert_eq!(filtered.len(), 1);
3401 assert_eq!(filtered[0].name, "replacementHotspot");
3402 }
3403
3404 #[test]
3405 fn health_identity_baseline_survives_line_moves() {
3406 let root = PathBuf::from("/project");
3407 let baseline =
3408 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3409 let filtered = super::filter_new_health_findings(
3410 vec![make_health_finding(&root, "parseExpression", 512)],
3411 &baseline,
3412 &root,
3413 HealthBaselineMode::Identity,
3414 );
3415 assert!(filtered.is_empty());
3416 }
3417
3418 #[test]
3419 fn health_identity_baseline_suppresses_severity_improvement() {
3420 let root = PathBuf::from("/project");
3421 let baseline = identity_baseline(
3422 &[make_health_finding_with(
3423 &root,
3424 "parseExpression",
3425 42,
3426 fallow_output::ExceededThreshold::Both,
3427 fallow_output::FindingSeverity::Critical,
3428 )],
3429 &root,
3430 );
3431 let filtered = super::filter_new_health_findings(
3432 vec![make_health_finding_with(
3433 &root,
3434 "parseExpression",
3435 42,
3436 fallow_output::ExceededThreshold::Both,
3437 fallow_output::FindingSeverity::Moderate,
3438 )],
3439 &baseline,
3440 &root,
3441 HealthBaselineMode::Identity,
3442 );
3443 assert!(filtered.is_empty());
3444 }
3445
3446 #[test]
3447 fn health_identity_baseline_reports_added_finding_for_known_function() {
3448 let root = PathBuf::from("/project");
3449 let baseline =
3450 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3451 let filtered = super::filter_new_health_findings(
3452 vec![
3453 make_health_finding(&root, "parseExpression", 42),
3454 make_health_finding(&root, "parseStatement", 90),
3455 ],
3456 &baseline,
3457 &root,
3458 HealthBaselineMode::Identity,
3459 );
3460 assert_eq!(filtered.len(), 1);
3461 assert_eq!(filtered[0].name, "parseStatement");
3462 }
3463
3464 #[test]
3465 fn health_identity_buckets_are_written_only_in_identity_mode() {
3466 let root = PathBuf::from("/project");
3467 let findings = [make_health_finding(&root, "parseExpression", 42)];
3468 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3469 let json = serde_json::to_string(&count_only).unwrap();
3470 assert!(!json.contains("identity_finding_counts"));
3471 assert!(count_only.lacks_identity_data());
3472
3473 let identity = identity_baseline(&findings, &root);
3474 assert!(!identity.lacks_identity_data());
3475 assert_eq!(
3476 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
3477 .count,
3478 1
3479 );
3480 assert!(
3481 !identity.finding_counts.is_empty(),
3482 "an identity baseline stays readable in count mode"
3483 );
3484 }
3485
3486 fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
3487 let mut finding = make_health_finding(root, name, 42);
3488 finding.path = root.join(path);
3489 finding
3490 }
3491
3492 #[test]
3493 fn health_identity_baseline_follows_file_move() {
3494 let root = PathBuf::from("/project");
3495 let baseline =
3496 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3497 let moved = vec![moved_finding(
3498 &root,
3499 "src/parser/utils.ts",
3500 "parseExpression",
3501 )];
3502
3503 let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
3504 assert_eq!(
3505 overlap.matched_entries, 1,
3506 "a followed move counts as matched, not stale"
3507 );
3508 assert_eq!(
3509 overlap.moved_entries, 1,
3510 "the followed move stays observable as a moved entry"
3511 );
3512 let filtered = super::filter_new_health_findings(
3513 moved,
3514 &baseline,
3515 &root,
3516 HealthBaselineMode::Identity,
3517 );
3518 assert!(filtered.is_empty());
3519 }
3520
3521 #[test]
3522 fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
3523 let root = PathBuf::from("/project");
3524 let baseline =
3525 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3526 let filtered = super::filter_new_health_findings(
3527 vec![
3528 moved_finding(&root, "src/a.ts", "parseExpression"),
3529 moved_finding(&root, "src/b.ts", "parseExpression"),
3530 ],
3531 &baseline,
3532 &root,
3533 HealthBaselineMode::Identity,
3534 );
3535 assert_eq!(filtered.len(), 2);
3536 }
3537
3538 #[test]
3539 fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
3540 let root = PathBuf::from("/project");
3541 let baseline = identity_baseline(
3542 &[
3543 moved_finding(&root, "src/a.ts", "parseExpression"),
3544 moved_finding(&root, "src/b.ts", "parseExpression"),
3545 ],
3546 &root,
3547 );
3548 let filtered = super::filter_new_health_findings(
3549 vec![moved_finding(&root, "src/c.ts", "parseExpression")],
3550 &baseline,
3551 &root,
3552 HealthBaselineMode::Identity,
3553 );
3554 assert_eq!(filtered.len(), 1);
3555 }
3556
3557 #[test]
3558 fn health_identity_move_is_not_followed_when_old_path_still_exists() {
3559 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3562 let baseline = identity_baseline(
3563 &[moved_finding(&root, "src/baseline.rs", "parseExpression")],
3564 &root,
3565 );
3566 let filtered = super::filter_new_health_findings(
3567 vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
3568 &baseline,
3569 &root,
3570 HealthBaselineMode::Identity,
3571 );
3572 assert_eq!(filtered.len(), 1);
3573 }
3574
3575 #[test]
3576 fn health_identity_move_is_not_followed_for_anonymous_functions() {
3577 let root = PathBuf::from("/project");
3578 let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
3579 let filtered = super::filter_new_health_findings(
3580 vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
3581 &baseline,
3582 &root,
3583 HealthBaselineMode::Identity,
3584 );
3585 assert_eq!(filtered.len(), 1);
3586 }
3587
3588 #[test]
3589 fn health_count_baseline_does_not_follow_file_moves() {
3590 let root = PathBuf::from("/project");
3591 let baseline = HealthBaselineData::from_findings(
3592 &[make_health_finding(&root, "parseExpression", 42)],
3593 &[],
3594 &[],
3595 &root,
3596 );
3597 let filtered = filter_new_health_findings(
3598 vec![moved_finding(
3599 &root,
3600 "src/parser/utils.ts",
3601 "parseExpression",
3602 )],
3603 &baseline,
3604 &root,
3605 );
3606 assert_eq!(filtered.len(), 1);
3607 }
3608
3609 #[test]
3610 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
3611 let legacy = HealthBaselineData {
3612 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
3613 ..HealthBaselineData::default()
3614 };
3615 assert!(legacy.lacks_identity_data());
3616 assert!(!HealthBaselineData::default().lacks_identity_data());
3617 }
3618
3619 #[test]
3620 fn health_baseline_empty_keeps_all() {
3621 let root = PathBuf::from("/project");
3622 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3623 let baseline = HealthBaselineData {
3624 findings: vec![],
3625 finding_counts: BTreeMap::new(),
3626 identity_finding_counts: BTreeMap::new(),
3627 target_keys: vec![],
3628 runtime_coverage_findings: vec![],
3629 runtime_coverage_source_hashes: vec![],
3630 };
3631 let filtered = filter_new_health_findings(findings, &baseline, &root);
3632 assert_eq!(filtered.len(), 1);
3633 }
3634
3635 #[test]
3636 fn circular_dep_key_is_order_independent() {
3637 use crate::results::CircularDependency;
3638
3639 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
3640 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3641 length: 2,
3642 line: 1,
3643 col: 0,
3644 edges: Vec::new(),
3645 is_cross_package: false,
3646 });
3647 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
3648 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
3649 length: 2,
3650 line: 1,
3651 col: 0,
3652 edges: Vec::new(),
3653 is_cross_package: false,
3654 });
3655 assert_eq!(
3656 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
3657 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
3658 "same files in different order should produce identical keys"
3659 );
3660 }
3661
3662 #[test]
3663 fn circular_dep_key_different_files_different_keys() {
3664 use crate::results::CircularDependency;
3665
3666 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
3667 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3668 length: 2,
3669 line: 1,
3670 col: 0,
3671 edges: Vec::new(),
3672 is_cross_package: false,
3673 });
3674 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
3675 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
3676 length: 2,
3677 line: 1,
3678 col: 0,
3679 edges: Vec::new(),
3680 is_cross_package: false,
3681 });
3682 assert_ne!(
3683 super::circular_dep_key(&dep1.cycle, Path::new("")),
3684 super::circular_dep_key(&dep2.cycle, Path::new("")),
3685 );
3686 }
3687
3688 #[test]
3689 fn circular_dep_key_three_files_order_independent() {
3690 use crate::results::CircularDependency;
3691
3692 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
3693 files: vec![
3694 PathBuf::from("src/a.ts"),
3695 PathBuf::from("src/b.ts"),
3696 PathBuf::from("src/c.ts"),
3697 ],
3698 length: 3,
3699 line: 1,
3700 col: 0,
3701 edges: Vec::new(),
3702 is_cross_package: false,
3703 });
3704 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
3705 files: vec![
3706 PathBuf::from("src/c.ts"),
3707 PathBuf::from("src/a.ts"),
3708 PathBuf::from("src/b.ts"),
3709 ],
3710 length: 3,
3711 line: 1,
3712 col: 0,
3713 edges: Vec::new(),
3714 is_cross_package: false,
3715 });
3716 assert_eq!(
3717 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
3718 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
3719 );
3720 }
3721
3722 #[expect(
3723 clippy::too_many_lines,
3724 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3725 )]
3726 fn make_full_results() -> AnalysisResults {
3727 use crate::results::*;
3728 use crate::source::MemberKind;
3729
3730 let mut r = make_results();
3731 r.circular_dependencies
3732 .push(CircularDependencyFinding::with_actions(
3733 CircularDependency {
3734 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3735 length: 2,
3736 line: 1,
3737 col: 0,
3738 edges: Vec::new(),
3739 is_cross_package: false,
3740 },
3741 ));
3742 r.unused_optional_dependencies
3743 .push(UnusedOptionalDependencyFinding::with_actions(
3744 UnusedDependency {
3745 package_name: "fsevents".to_string(),
3746 location: DependencyLocation::OptionalDependencies,
3747 path: PathBuf::from("package.json"),
3748 line: 15,
3749 used_in_workspaces: Vec::new(),
3750 },
3751 ));
3752 r.unused_enum_members
3753 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3754 path: PathBuf::from("src/enums.ts"),
3755 parent_name: "Status".to_string(),
3756 member_name: "Deprecated".to_string(),
3757 kind: MemberKind::EnumMember,
3758 line: 8,
3759 col: 0,
3760 }));
3761 r.unused_class_members
3762 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3763 path: PathBuf::from("src/service.ts"),
3764 parent_name: "UserService".to_string(),
3765 member_name: "legacy".to_string(),
3766 kind: MemberKind::ClassMethod,
3767 line: 42,
3768 col: 0,
3769 }));
3770 r.unused_store_members
3771 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
3772 path: PathBuf::from("src/store.ts"),
3773 parent_name: "useStore".to_string(),
3774 member_name: "legacyAction".to_string(),
3775 kind: MemberKind::StoreMember,
3776 line: 17,
3777 col: 0,
3778 }));
3779 r.unresolved_imports.push(
3780 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
3781 crate::results::UnresolvedImport {
3782 path: PathBuf::from("src/app.ts"),
3783 specifier: "./missing".to_string(),
3784 line: 3,
3785 col: 0,
3786 specifier_col: 0,
3787 },
3788 ),
3789 );
3790 r.unlisted_dependencies
3791 .push(crate::results::UnlistedDependencyFinding::with_actions(
3792 UnlistedDependency {
3793 package_name: "chalk".to_string(),
3794 imported_from: vec![],
3795 },
3796 ));
3797 r.duplicate_exports
3798 .push(crate::results::DuplicateExportFinding::with_actions(
3799 crate::results::DuplicateExport {
3800 export_name: "Config".to_string(),
3801 locations: vec![
3802 crate::results::DuplicateLocation {
3803 path: PathBuf::from("src/a.ts"),
3804 line: 1,
3805 col: 0,
3806 },
3807 crate::results::DuplicateLocation {
3808 path: PathBuf::from("src/b.ts"),
3809 line: 5,
3810 col: 0,
3811 },
3812 ],
3813 },
3814 ));
3815 r.type_only_dependencies
3816 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3817 TypeOnlyDependency {
3818 package_name: "zod".to_string(),
3819 path: PathBuf::from("package.json"),
3820 line: 8,
3821 },
3822 ));
3823 r.test_only_dependencies
3824 .push(crate::results::TestOnlyDependencyFinding::with_actions(
3825 TestOnlyDependency {
3826 package_name: "vitest".to_string(),
3827 path: PathBuf::from("package.json"),
3828 line: 10,
3829 },
3830 ));
3831 r.boundary_violations.push(
3832 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3833 crate::results::BoundaryViolation {
3834 from_path: PathBuf::from("src/ui/btn.ts"),
3835 to_path: PathBuf::from("src/db/query.ts"),
3836 from_zone: "ui".to_string(),
3837 to_zone: "db".to_string(),
3838 import_specifier: "../db/query".to_string(),
3839 line: 1,
3840 col: 0,
3841 },
3842 ),
3843 );
3844 r
3845 }
3846
3847 #[test]
3848 fn baseline_from_results_captures_all_extended_fields() {
3849 let results = make_full_results();
3850 let baseline = BaselineData::from_results(&results, Path::new(""));
3851 assert_eq!(baseline.circular_dependencies.len(), 1);
3852 assert_eq!(
3853 baseline.unused_optional_dependencies,
3854 vec!["package.json:fsevents"]
3855 );
3856 assert_eq!(baseline.unused_enum_members.len(), 1);
3857 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3858 assert_eq!(baseline.unused_class_members.len(), 1);
3859 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3860 assert_eq!(baseline.unused_store_members.len(), 1);
3861 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3862 assert_eq!(baseline.unresolved_imports.len(), 1);
3863 assert!(baseline.unresolved_imports[0].contains("./missing"));
3864 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3865 assert_eq!(baseline.duplicate_exports.len(), 1);
3866 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3867 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3868 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3869 assert_eq!(baseline.boundary_violations.len(), 1);
3870 assert!(baseline.boundary_violations[0].contains("->"));
3871 }
3872
3873 #[test]
3874 fn filter_removes_all_extended_baseline_issues() {
3875 let results = make_full_results();
3876 let baseline = BaselineData::from_results(&results, Path::new(""));
3877 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3878 assert!(filtered.circular_dependencies.is_empty());
3879 assert!(filtered.unused_optional_dependencies.is_empty());
3880 assert!(filtered.unused_enum_members.is_empty());
3881 assert!(filtered.unused_class_members.is_empty());
3882 assert!(filtered.unused_store_members.is_empty());
3883 assert!(filtered.unresolved_imports.is_empty());
3884 assert!(filtered.unlisted_dependencies.is_empty());
3885 assert!(filtered.duplicate_exports.is_empty());
3886 assert!(filtered.type_only_dependencies.is_empty());
3887 assert!(filtered.test_only_dependencies.is_empty());
3888 assert!(filtered.boundary_violations.is_empty());
3889 }
3890
3891 #[test]
3892 fn filter_keeps_new_circular_deps() {
3893 use crate::results::CircularDependency;
3894 let baseline = BaselineData {
3895 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3896 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3897 };
3898 let mut results = AnalysisResults::default();
3899 results
3900 .circular_dependencies
3901 .push(CircularDependencyFinding::with_actions(
3902 CircularDependency {
3903 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3904 length: 2,
3905 line: 1,
3906 col: 0,
3907 edges: Vec::new(),
3908 is_cross_package: false,
3909 },
3910 ));
3911 results
3912 .circular_dependencies
3913 .push(CircularDependencyFinding::with_actions(
3914 CircularDependency {
3915 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3916 length: 2,
3917 line: 5,
3918 col: 0,
3919 edges: Vec::new(),
3920 is_cross_package: false,
3921 },
3922 ));
3923 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3924 assert_eq!(filtered.circular_dependencies.len(), 1);
3925 }
3926
3927 #[test]
3928 fn filter_keeps_new_boundary_violations() {
3929 use crate::results::BoundaryViolation;
3930 let baseline = BaselineData {
3931 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3932 boundary_coverage_violations: vec![],
3933 boundary_call_violations: vec![],
3934 policy_violations: vec![],
3935 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3936 };
3937 let mut results = AnalysisResults::default();
3938 results
3939 .boundary_violations
3940 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3941 from_path: PathBuf::from("src/a.ts"),
3942 to_path: PathBuf::from("src/b.ts"),
3943 from_zone: "a".to_string(),
3944 to_zone: "b".to_string(),
3945 import_specifier: "../b".to_string(),
3946 line: 1,
3947 col: 0,
3948 }));
3949 results
3950 .boundary_violations
3951 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3952 from_path: PathBuf::from("src/new.ts"),
3953 to_path: PathBuf::from("src/secret.ts"),
3954 from_zone: "new".to_string(),
3955 to_zone: "secret".to_string(),
3956 import_specifier: "../secret".to_string(),
3957 line: 1,
3958 col: 0,
3959 }));
3960 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3961 assert_eq!(filtered.boundary_violations.len(), 1);
3962 }
3963
3964 #[test]
3965 fn health_targets_baseline_filters_known() {
3966 let root = PathBuf::from("/project");
3967 let targets = vec![
3968 fallow_output::RefactoringTarget {
3969 path: root.join("src/complex.ts"),
3970 priority: 80.0,
3971 efficiency: 40.0,
3972 recommendation: "Split file".to_string(),
3973 category: fallow_output::RecommendationCategory::SplitHighImpact,
3974 effort: fallow_output::EffortEstimate::Medium,
3975 confidence: fallow_output::Confidence::Medium,
3976 factors: vec![],
3977 evidence: None,
3978 },
3979 fallow_output::RefactoringTarget {
3980 path: root.join("src/new-issue.ts"),
3981 priority: 60.0,
3982 efficiency: 30.0,
3983 recommendation: "Extract function".to_string(),
3984 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3985 effort: fallow_output::EffortEstimate::Low,
3986 confidence: fallow_output::Confidence::High,
3987 factors: vec![],
3988 evidence: None,
3989 },
3990 ];
3991 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3992 let filtered = filter_new_health_targets(targets, &baseline, &root);
3993 assert_eq!(filtered.len(), 1);
3994 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3995 }
3996
3997 #[test]
3998 fn duplicate_export_key_is_sorted() {
3999 use crate::results::{DuplicateExport, DuplicateLocation};
4000 let dup_ab = DuplicateExport {
4001 export_name: "foo".to_string(),
4002 locations: vec![
4003 DuplicateLocation {
4004 path: PathBuf::from("src/a.ts"),
4005 line: 1,
4006 col: 0,
4007 },
4008 DuplicateLocation {
4009 path: PathBuf::from("src/b.ts"),
4010 line: 5,
4011 col: 0,
4012 },
4013 ],
4014 };
4015 let dup_ba = DuplicateExport {
4016 export_name: "foo".to_string(),
4017 locations: vec![
4018 DuplicateLocation {
4019 path: PathBuf::from("src/b.ts"),
4020 line: 5,
4021 col: 0,
4022 },
4023 DuplicateLocation {
4024 path: PathBuf::from("src/a.ts"),
4025 line: 1,
4026 col: 0,
4027 },
4028 ],
4029 };
4030 assert_eq!(
4031 super::duplicate_export_key(&dup_ab, Path::new("")),
4032 super::duplicate_export_key(&dup_ba, Path::new("")),
4033 );
4034 }
4035
4036 #[test]
4037 fn boundary_violation_key_format() {
4038 use crate::results::BoundaryViolation;
4039 let v = BoundaryViolation {
4040 from_path: PathBuf::from("src/ui/btn.ts"),
4041 to_path: PathBuf::from("src/db/query.ts"),
4042 from_zone: "ui".to_string(),
4043 to_zone: "db".to_string(),
4044 import_specifier: "../db/query".to_string(),
4045 line: 1,
4046 col: 0,
4047 };
4048 let key = super::boundary_violation_key(&v, Path::new(""));
4049 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
4050 }
4051
4052 fn make_absolute_results(root: &str) -> AnalysisResults {
4054 use crate::results::*;
4055 use crate::source::MemberKind;
4056
4057 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
4058
4059 AnalysisResults {
4060 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
4061 path: p("src/old.ts"),
4062 })],
4063 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
4064 path: p("src/utils.ts"),
4065 export_name: "helper".to_string(),
4066 is_type_only: false,
4067 line: 5,
4068 col: 0,
4069 span_start: 40,
4070 is_re_export: false,
4071 })],
4072 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
4073 package_name: "lodash-es".to_string(),
4074 location: DependencyLocation::Dependencies,
4075 path: p("packages/app/package.json"),
4076 line: 5,
4077 used_in_workspaces: Vec::new(),
4078 })],
4079 circular_dependencies: vec![CircularDependencyFinding::with_actions(
4080 CircularDependency {
4081 files: vec![p("src/a.ts"), p("src/b.ts")],
4082 length: 2,
4083 line: 1,
4084 col: 0,
4085 edges: Vec::new(),
4086 is_cross_package: false,
4087 },
4088 )],
4089 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
4090 path: p("src/enums.ts"),
4091 parent_name: "Status".to_string(),
4092 member_name: "Deprecated".to_string(),
4093 kind: MemberKind::EnumMember,
4094 line: 8,
4095 col: 0,
4096 })],
4097 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
4098 path: p("src/service.ts"),
4099 parent_name: "UserService".to_string(),
4100 member_name: "legacy".to_string(),
4101 kind: MemberKind::ClassMethod,
4102 line: 42,
4103 col: 0,
4104 })],
4105 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
4106 path: p("src/store.ts"),
4107 parent_name: "useStore".to_string(),
4108 member_name: "legacyAction".to_string(),
4109 kind: MemberKind::StoreMember,
4110 line: 17,
4111 col: 0,
4112 })],
4113 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
4114 path: p("src/app.ts"),
4115 specifier: "./missing".to_string(),
4116 line: 3,
4117 col: 0,
4118 specifier_col: 0,
4119 })],
4120 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
4121 export_name: "Config".to_string(),
4122 locations: vec![
4123 DuplicateLocation {
4124 path: p("src/a.ts"),
4125 line: 1,
4126 col: 0,
4127 },
4128 DuplicateLocation {
4129 path: p("src/b.ts"),
4130 line: 5,
4131 col: 0,
4132 },
4133 ],
4134 })],
4135 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
4136 from_path: p("src/ui/btn.ts"),
4137 to_path: p("src/db/query.ts"),
4138 from_zone: "ui".to_string(),
4139 to_zone: "db".to_string(),
4140 import_specifier: "../db/query".to_string(),
4141 line: 1,
4142 col: 0,
4143 })],
4144 ..Default::default()
4145 }
4146 }
4147
4148 #[test]
4151 fn baseline_keys_are_relative_to_root() {
4152 let local_root = Path::new("/Users/dev/project");
4153 let results = make_absolute_results("/Users/dev/project");
4154 let baseline = BaselineData::from_results(&results, local_root);
4155
4156 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
4157 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
4158 assert_eq!(
4159 baseline.unused_dependencies,
4160 vec!["packages/app/package.json:lodash-es"]
4161 );
4162 assert_eq!(
4163 baseline.boundary_violations,
4164 vec!["src/ui/btn.ts->src/db/query.ts"]
4165 );
4166 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
4167 assert_eq!(
4168 baseline.unused_enum_members,
4169 vec!["src/enums.ts:Status.Deprecated"]
4170 );
4171 assert_eq!(
4172 baseline.unused_class_members,
4173 vec!["src/service.ts:UserService.legacy"]
4174 );
4175 assert_eq!(
4176 baseline.unused_store_members,
4177 vec!["src/store.ts:useStore.legacyAction"]
4178 );
4179 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
4180 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
4181
4182 let ci_root = Path::new("/home/runner/work/project/project");
4183 let ci_results = make_absolute_results("/home/runner/work/project/project");
4184
4185 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
4186 assert!(filtered.unused_files.is_empty(), "unused files");
4187 assert!(filtered.unused_exports.is_empty(), "unused exports");
4188 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
4189 assert!(
4190 filtered.boundary_violations.is_empty(),
4191 "boundary violations"
4192 );
4193 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
4194 assert!(filtered.unused_enum_members.is_empty(), "enum members");
4195 assert!(filtered.unused_class_members.is_empty(), "class members");
4196 assert!(filtered.unused_store_members.is_empty(), "store members");
4197 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
4198 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
4199 }
4200
4201 #[test]
4202 fn stale_suppression_baseline_keys_include_missing_reason_state() {
4203 let root = Path::new("/project");
4204 let stale = crate::results::StaleSuppression {
4205 path: root.join("src/file.ts"),
4206 line: 1,
4207 col: 0,
4208 origin: crate::results::SuppressionOrigin::Comment {
4209 issue_kind: Some("unused-export".to_string()),
4210 reason: None,
4211 is_file_level: false,
4212 kind_known: true,
4213 },
4214 missing_reason: false,
4215 actions: crate::results::StaleSuppression::actions_for(false),
4216 };
4217 let missing = crate::results::StaleSuppression {
4218 missing_reason: true,
4219 actions: crate::results::StaleSuppression::actions_for(true),
4220 ..stale.clone()
4221 };
4222 let results = AnalysisResults {
4223 stale_suppressions: vec![stale, missing],
4224 ..Default::default()
4225 };
4226 let baseline = BaselineData::from_results(&results, root);
4227
4228 assert_eq!(
4229 baseline.stale_suppressions,
4230 vec![
4231 "stale-suppression:src/file.ts:1",
4232 "missing-suppression-reason:src/file.ts:1",
4233 ]
4234 );
4235
4236 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
4237 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
4238 let filtered = filter_new_issues(results, &legacy_baseline, root);
4239 assert!(filtered.stale_suppressions.is_empty());
4240 }
4241
4242 fn runtime_finding(
4243 id: &str,
4244 stable_id: Option<&str>,
4245 line: u32,
4246 source_hash: Option<&str>,
4247 ) -> fallow_output::RuntimeCoverageFinding {
4248 fallow_output::RuntimeCoverageFinding {
4249 id: id.to_owned(),
4250 stable_id: stable_id.map(str::to_owned),
4251 source_hash: source_hash.map(str::to_owned),
4252 path: PathBuf::from("src/a.ts"),
4253 function: "alpha".to_owned(),
4254 line,
4255 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
4256 invocations: Some(0),
4257 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
4258 evidence: fallow_output::RuntimeCoverageEvidence {
4259 static_status: "used".to_owned(),
4260 test_coverage: "not_covered".to_owned(),
4261 v8_tracking: "tracked".to_owned(),
4262 untracked_reason: None,
4263 observation_days: 1,
4264 deployments_observed: 1,
4265 },
4266 actions: vec![],
4267 discriminators: None,
4268 }
4269 }
4270
4271 #[test]
4272 fn legacy_prod_baseline_still_suppresses_finding() {
4273 let baseline = HealthBaselineData {
4274 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
4275 ..HealthBaselineData::default()
4276 };
4277 let findings = vec![runtime_finding(
4278 "fallow:prod:deadbeef",
4279 Some("fallow:fn:00000001"),
4280 14,
4281 None,
4282 )];
4283 let filtered =
4284 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4285 assert!(filtered.is_empty(), "legacy prod id must still suppress");
4286 }
4287
4288 #[test]
4289 fn source_hash_baseline_survives_line_move() {
4290 let root = Path::new("/repo");
4291 let baselined = runtime_finding(
4292 "fallow:prod:deadbeef",
4293 Some("fallow:fn:00000001"),
4294 14,
4295 Some("0123456789abcdef"),
4296 );
4297 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
4298 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
4299
4300 let findings = vec![runtime_finding(
4301 "fallow:prod:99999999",
4302 Some("fallow:fn:cafe0002"),
4303 40,
4304 Some("0123456789abcdef"),
4305 )];
4306 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
4307 assert!(
4308 filtered.is_empty(),
4309 "source_hash baseline must survive a line move despite a changed stable_id and id"
4310 );
4311 }
4312
4313 #[test]
4314 fn unbaselined_finding_is_reported() {
4315 let baseline = HealthBaselineData {
4316 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
4317 ..HealthBaselineData::default()
4318 };
4319 let findings = vec![runtime_finding(
4320 "fallow:prod:abc1234d",
4321 Some("fallow:fn:beefcafe"),
4322 7,
4323 None,
4324 )];
4325 let filtered =
4326 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
4327 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
4328 }
4329}