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