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]
1708 Count,
1709 Identity,
1713}
1714
1715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1716enum HealthFindingDimension {
1717 Complexity,
1718 Crap,
1719}
1720
1721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1722struct HealthFindingCategory {
1723 dimension: HealthFindingDimension,
1724 severity: fallow_output::FindingSeverity,
1725}
1726
1727impl HealthFindingCategory {
1728 const fn key(self) -> &'static str {
1729 match (self.dimension, self.severity) {
1730 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1731 "complexity_moderate"
1732 }
1733 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1734 "complexity_high"
1735 }
1736 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1737 "complexity_critical"
1738 }
1739 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1740 "crap_moderate"
1741 }
1742 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1743 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1744 "crap_critical"
1745 }
1746 }
1747 }
1748}
1749
1750const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1751 HealthFindingDimension::Complexity,
1752 HealthFindingDimension::Crap,
1753];
1754
1755impl HealthBaselineData {
1756 pub(crate) fn from_findings(
1758 findings: &[fallow_output::ComplexityViolation],
1759 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1760 targets: &[fallow_output::RefactoringTarget],
1761 root: &Path,
1762 ) -> Self {
1763 Self {
1764 findings: Vec::new(),
1765 finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
1766 identity_finding_counts: HealthFindingCountMap::new(),
1767 runtime_coverage_findings: runtime_coverage_findings
1768 .iter()
1769 .map(|f| runtime_coverage_finding_key(f, root))
1770 .collect(),
1771 runtime_coverage_source_hashes: runtime_coverage_findings
1772 .iter()
1773 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1774 .collect(),
1775 target_keys: targets
1776 .iter()
1777 .map(|t| target_baseline_key(t, root))
1778 .collect(),
1779 }
1780 }
1781
1782 pub(crate) fn finding_entry_count(&self) -> usize {
1783 if !self.finding_counts.is_empty() {
1784 self.finding_counts
1785 .values()
1786 .flat_map(BTreeMap::values)
1787 .map(|entry| entry.count)
1788 .sum()
1789 } else {
1790 self.findings.len()
1791 }
1792 }
1793
1794 #[must_use]
1799 pub(crate) fn with_identity(
1800 mut self,
1801 findings: &[fallow_output::ComplexityViolation],
1802 root: &Path,
1803 ) -> Self {
1804 self.identity_finding_counts =
1805 health_finding_counts(findings, root, HealthBaselineMode::Identity);
1806 self
1807 }
1808
1809 pub(crate) fn lacks_identity_data(&self) -> bool {
1812 self.identity_finding_counts.is_empty()
1813 && (!self.finding_counts.is_empty() || !self.findings.is_empty())
1814 }
1815
1816 fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
1817 match mode {
1818 HealthBaselineMode::Count => &self.finding_counts,
1819 HealthBaselineMode::Identity => &self.identity_finding_counts,
1820 }
1821 }
1822
1823 pub(crate) fn overlap_entry_count(
1824 &self,
1825 findings: &[fallow_output::ComplexityViolation],
1826 root: &Path,
1827 mode: HealthBaselineMode,
1828 ) -> usize {
1829 let baseline_counts = self.counts_for(mode);
1830 if !baseline_counts.is_empty() {
1831 let current_counts = health_finding_counts(findings, root, mode);
1832 health_overlap_entry_count(¤t_counts, baseline_counts)
1833 } else {
1834 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
1835 findings
1836 .iter()
1837 .filter(|finding| {
1838 baseline_keys.contains(health_finding_key(finding, root).as_str())
1839 })
1840 .count()
1841 }
1842 }
1843}
1844
1845fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
1847 format!(
1848 "{}:{}",
1849 relative_path(&target.path, root),
1850 target.category.label()
1851 )
1852}
1853
1854fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1856 format!(
1857 "{}:{}:{}",
1858 relative_path(&finding.path, root),
1859 finding.name,
1860 finding.line
1861 )
1862}
1863
1864fn health_bucket_key(
1869 finding: &fallow_output::ComplexityViolation,
1870 root: &Path,
1871 mode: HealthBaselineMode,
1872) -> String {
1873 let path = relative_path(&finding.path, root);
1874 match mode {
1875 HealthBaselineMode::Count => path,
1876 HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
1877 }
1878}
1879
1880fn health_finding_counts(
1881 findings: &[fallow_output::ComplexityViolation],
1882 root: &Path,
1883 mode: HealthBaselineMode,
1884) -> HealthFindingCountMap {
1885 let mut counts = BTreeMap::new();
1886 for finding in findings {
1887 let bucket = health_bucket_key(finding, root, mode);
1888 let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
1889 for category in health_finding_categories(finding).into_iter().flatten() {
1890 file_counts
1891 .entry(category.key().to_string())
1892 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
1893 .or_insert(HealthBaselineCount { count: 1 });
1894 }
1895 }
1896 counts
1897}
1898
1899fn health_finding_categories(
1900 finding: &fallow_output::ComplexityViolation,
1901) -> [Option<HealthFindingCategory>; 2] {
1902 let complexity_category = HealthFindingCategory {
1903 dimension: HealthFindingDimension::Complexity,
1904 severity: finding.severity,
1905 };
1906 let crap_category = HealthFindingCategory {
1907 dimension: HealthFindingDimension::Crap,
1908 severity: finding.severity,
1909 };
1910 let has_complexity =
1911 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
1912 let has_crap = finding.exceeded.includes_crap();
1913 [
1914 has_complexity.then_some(complexity_category),
1915 has_crap.then_some(crap_category),
1916 ]
1917}
1918
1919fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
1920 match severity {
1921 fallow_output::FindingSeverity::Moderate => 0,
1922 fallow_output::FindingSeverity::High => 1,
1923 fallow_output::FindingSeverity::Critical => 2,
1924 }
1925}
1926
1927fn severity_counts_for_dimension(
1928 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
1929 dimension: HealthFindingDimension,
1930) -> [usize; 3] {
1931 let mut counts = [0; 3];
1932 for severity in [
1933 fallow_output::FindingSeverity::Moderate,
1934 fallow_output::FindingSeverity::High,
1935 fallow_output::FindingSeverity::Critical,
1936 ] {
1937 let category = HealthFindingCategory {
1938 dimension,
1939 severity,
1940 };
1941 counts[severity_index(severity)] = file_counts
1942 .and_then(|entries| entries.get(category.key()))
1943 .map_or(0, |entry| entry.count);
1944 }
1945 counts
1946}
1947
1948fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
1949 let mut available = baseline;
1950 let mut overflow = [false; 3];
1951
1952 for severity_idx in 0..3 {
1953 let compatible = available[severity_idx..].iter().sum::<usize>();
1954 overflow[severity_idx] = compatible < current[severity_idx];
1955
1956 let mut matched = current[severity_idx].min(compatible);
1957 for slot in available.iter_mut().skip(severity_idx) {
1958 let taken = matched.min(*slot);
1959 *slot -= taken;
1960 matched -= taken;
1961 if matched == 0 {
1962 break;
1963 }
1964 }
1965 }
1966
1967 overflow
1968}
1969
1970fn health_overflow_categories(
1971 current_counts: &HealthFindingCountMap,
1972 baseline_counts: &HealthFindingCountMap,
1973) -> FxHashMap<String, FxHashSet<&'static str>> {
1974 let mut overflow_by_path = FxHashMap::default();
1975
1976 for (path, current_file_counts) in current_counts {
1977 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
1978 let baseline_file_counts = baseline_counts.get(path);
1979
1980 for dimension in HEALTH_FINDING_DIMENSIONS {
1981 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
1982 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
1983 let overflow = overflowing_severities(current, baseline);
1984
1985 for severity in [
1986 fallow_output::FindingSeverity::Moderate,
1987 fallow_output::FindingSeverity::High,
1988 fallow_output::FindingSeverity::Critical,
1989 ] {
1990 if overflow[severity_index(severity)] {
1991 overflow_categories.insert(
1992 HealthFindingCategory {
1993 dimension,
1994 severity,
1995 }
1996 .key(),
1997 );
1998 }
1999 }
2000 }
2001
2002 if !overflow_categories.is_empty() {
2003 overflow_by_path.insert(path.clone(), overflow_categories);
2004 }
2005 }
2006
2007 overflow_by_path
2008}
2009
2010fn health_overlap_entry_count(
2011 current_counts: &HealthFindingCountMap,
2012 baseline_counts: &HealthFindingCountMap,
2013) -> usize {
2014 let mut overlap = 0;
2015
2016 for (path, baseline_file_counts) in baseline_counts {
2017 let current_file_counts = current_counts.get(path);
2018
2019 for dimension in HEALTH_FINDING_DIMENSIONS {
2020 let current_total: usize =
2021 severity_counts_for_dimension(current_file_counts, dimension)
2022 .into_iter()
2023 .sum();
2024 let baseline_total: usize =
2025 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
2026 .into_iter()
2027 .sum();
2028 overlap += current_total.min(baseline_total);
2029 }
2030 }
2031
2032 overlap
2033}
2034
2035fn runtime_coverage_finding_key(
2036 finding: &fallow_output::RuntimeCoverageFinding,
2037 _root: &Path,
2038) -> String {
2039 finding
2040 .stable_id
2041 .clone()
2042 .unwrap_or_else(|| finding.id.clone())
2043}
2044
2045fn runtime_coverage_source_hash_key(
2052 finding: &fallow_output::RuntimeCoverageFinding,
2053 root: &Path,
2054) -> Option<String> {
2055 finding.source_hash.as_deref().map(|hash| {
2056 format!(
2057 "{}\0{}\0{}",
2058 relative_path(&finding.path, root),
2059 finding.function,
2060 hash
2061 )
2062 })
2063}
2064
2065pub(crate) fn filter_new_health_findings(
2067 mut findings: Vec<fallow_output::ComplexityViolation>,
2068 baseline: &HealthBaselineData,
2069 root: &Path,
2070 mode: HealthBaselineMode,
2071) -> Vec<fallow_output::ComplexityViolation> {
2072 let baseline_counts = baseline.counts_for(mode);
2073 if !baseline_counts.is_empty() {
2074 let current_counts = health_finding_counts(&findings, root, mode);
2075 let overflow_categories = health_overflow_categories(¤t_counts, baseline_counts);
2076 findings.retain(|finding| {
2077 let bucket = health_bucket_key(finding, root, mode);
2078 overflow_categories.get(&bucket).is_some_and(|categories| {
2079 health_finding_categories(finding)
2080 .into_iter()
2081 .flatten()
2082 .any(|category| categories.contains(category.key()))
2083 })
2084 });
2085 return findings;
2086 }
2087
2088 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
2089 findings.retain(|f| {
2090 let key = health_finding_key(f, root);
2091 !baseline_keys.contains(key.as_str())
2092 });
2093 findings
2094}
2095
2096pub(crate) fn filter_new_runtime_coverage_findings(
2097 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
2098 baseline: &HealthBaselineData,
2099 root: &Path,
2100) -> Vec<fallow_output::RuntimeCoverageFinding> {
2101 let baseline_keys: FxHashSet<&str> = baseline
2102 .runtime_coverage_findings
2103 .iter()
2104 .map(String::as_str)
2105 .collect();
2106 let baseline_source_hash_keys: FxHashSet<&str> = baseline
2107 .runtime_coverage_source_hashes
2108 .iter()
2109 .map(String::as_str)
2110 .collect();
2111 findings.retain(|finding| {
2112 let suppressed_by_stable_id = finding
2113 .stable_id
2114 .as_deref()
2115 .is_some_and(|id| baseline_keys.contains(id));
2116 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
2117 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
2118 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
2119 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
2120 });
2121 findings
2122}
2123
2124pub(crate) fn filter_new_health_targets(
2126 mut targets: Vec<fallow_output::RefactoringTarget>,
2127 baseline: &HealthBaselineData,
2128 root: &Path,
2129) -> Vec<fallow_output::RefactoringTarget> {
2130 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
2131 targets.retain(|t| {
2132 let key = target_baseline_key(t, root);
2133 !baseline_keys.contains(key.as_str())
2134 });
2135 targets
2136}
2137
2138#[derive(Debug, Clone, serde::Serialize)]
2140pub struct CategoryDelta {
2141 pub current: usize,
2142 pub baseline: usize,
2143 pub delta: i64,
2144}
2145
2146#[derive(Debug, Clone)]
2151pub struct BaselineDeltas {
2152 pub total_delta: i64,
2154 pub per_category: Vec<(String, CategoryDelta)>,
2156}
2157
2158#[cfg(test)]
2159mod tests {
2160 use super::*;
2161 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2162 use crate::results::{
2163 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2164 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2165 UnusedFile,
2166 };
2167 use fallow_types::output_dead_code::{
2168 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2169 };
2170 use std::path::PathBuf;
2171
2172 fn make_results() -> AnalysisResults {
2173 AnalysisResults {
2174 unused_files: vec![
2175 UnusedFileFinding::with_actions(UnusedFile {
2176 path: PathBuf::from("src/old.ts"),
2177 }),
2178 UnusedFileFinding::with_actions(UnusedFile {
2179 path: PathBuf::from("src/dead.ts"),
2180 }),
2181 ],
2182 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2183 path: PathBuf::from("src/utils.ts"),
2184 export_name: "helperA".to_string(),
2185 is_type_only: false,
2186 line: 5,
2187 col: 0,
2188 span_start: 40,
2189 is_re_export: false,
2190 })],
2191 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2192 path: PathBuf::from("src/types.ts"),
2193 export_name: "OldType".to_string(),
2194 is_type_only: true,
2195 line: 10,
2196 col: 0,
2197 span_start: 100,
2198 is_re_export: false,
2199 })],
2200 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2201 package_name: "lodash".to_string(),
2202 location: DependencyLocation::Dependencies,
2203 path: PathBuf::from("package.json"),
2204 line: 5,
2205 used_in_workspaces: Vec::new(),
2206 })],
2207 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2208 UnusedDependency {
2209 package_name: "jest".to_string(),
2210 location: DependencyLocation::DevDependencies,
2211 path: PathBuf::from("package.json"),
2212 line: 5,
2213 used_in_workspaces: Vec::new(),
2214 },
2215 )],
2216 ..Default::default()
2217 }
2218 }
2219
2220 #[test]
2221 fn baseline_from_results_captures_all_fields() {
2222 let results = make_results();
2223 let baseline = BaselineData::from_results(&results, Path::new(""));
2224 assert_eq!(baseline.unused_files.len(), 2);
2225 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2226 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2227 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2228 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2229 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2230 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2231 }
2232
2233 #[test]
2234 fn dependency_baseline_keys_include_package_json_path() {
2235 let root = Path::new("/repo");
2236 let results = AnalysisResults {
2237 unused_dependencies: vec![
2238 UnusedDependencyFinding::with_actions(UnusedDependency {
2239 package_name: "lodash-es".to_string(),
2240 location: DependencyLocation::Dependencies,
2241 path: PathBuf::from("/repo/packages/app-a/package.json"),
2242 line: 5,
2243 used_in_workspaces: Vec::new(),
2244 }),
2245 UnusedDependencyFinding::with_actions(UnusedDependency {
2246 package_name: "lodash-es".to_string(),
2247 location: DependencyLocation::Dependencies,
2248 path: PathBuf::from("/repo/packages/app-b/package.json"),
2249 line: 5,
2250 used_in_workspaces: Vec::new(),
2251 }),
2252 ],
2253 ..Default::default()
2254 };
2255
2256 let baseline = BaselineData::from_results(&results, root);
2257
2258 assert_eq!(
2259 baseline.unused_dependencies,
2260 vec![
2261 "packages/app-a/package.json:lodash-es",
2262 "packages/app-b/package.json:lodash-es"
2263 ]
2264 );
2265 }
2266
2267 #[test]
2268 fn dependency_baseline_filter_matches_path_before_package_name() {
2269 let root = Path::new("/repo");
2270 let results = AnalysisResults {
2271 unused_dependencies: vec![
2272 UnusedDependencyFinding::with_actions(UnusedDependency {
2273 package_name: "lodash-es".to_string(),
2274 location: DependencyLocation::Dependencies,
2275 path: PathBuf::from("/repo/packages/app-a/package.json"),
2276 line: 5,
2277 used_in_workspaces: Vec::new(),
2278 }),
2279 UnusedDependencyFinding::with_actions(UnusedDependency {
2280 package_name: "lodash-es".to_string(),
2281 location: DependencyLocation::Dependencies,
2282 path: PathBuf::from("/repo/packages/app-b/package.json"),
2283 line: 5,
2284 used_in_workspaces: Vec::new(),
2285 }),
2286 ],
2287 ..Default::default()
2288 };
2289 let baseline = BaselineData {
2290 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2291 ..BaselineData::from_results(&AnalysisResults::default(), root)
2292 };
2293
2294 let filtered = filter_new_issues(results, &baseline, root);
2295
2296 assert_eq!(filtered.unused_dependencies.len(), 1);
2297 assert_eq!(
2298 filtered.unused_dependencies[0].dep.path,
2299 PathBuf::from("/repo/packages/app-b/package.json")
2300 );
2301 }
2302
2303 #[test]
2304 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2305 let root = Path::new("/repo");
2306 let results = AnalysisResults {
2307 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2308 package_name: "lodash-es".to_string(),
2309 location: DependencyLocation::Dependencies,
2310 path: PathBuf::from("/repo/packages/app/package.json"),
2311 line: 5,
2312 used_in_workspaces: Vec::new(),
2313 })],
2314 ..Default::default()
2315 };
2316 let baseline = BaselineData {
2317 unused_dependencies: vec!["lodash-es".to_string()],
2318 ..BaselineData::from_results(&AnalysisResults::default(), root)
2319 };
2320
2321 let filtered = filter_new_issues(results, &baseline, root);
2322
2323 assert!(filtered.unused_dependencies.is_empty());
2324 }
2325
2326 #[test]
2327 fn baseline_serialization_roundtrip() {
2328 let results = make_results();
2329 let baseline = BaselineData::from_results(&results, Path::new(""));
2330 let json = serde_json::to_string(&baseline).unwrap();
2331 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2332 assert_eq!(deserialized.unused_files, baseline.unused_files);
2333 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2334 assert_eq!(deserialized.unused_types, baseline.unused_types);
2335 assert_eq!(
2336 deserialized.unused_dependencies,
2337 baseline.unused_dependencies
2338 );
2339 assert_eq!(
2340 deserialized.unused_dev_dependencies,
2341 baseline.unused_dev_dependencies
2342 );
2343 }
2344
2345 #[test]
2346 fn filter_removes_baseline_issues() {
2347 let results = make_results();
2348 let baseline = BaselineData::from_results(&results, Path::new(""));
2349 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2350 assert!(
2351 filtered.unused_files.is_empty(),
2352 "all files were in baseline"
2353 );
2354 assert!(
2355 filtered.unused_exports.is_empty(),
2356 "all exports were in baseline"
2357 );
2358 assert!(
2359 filtered.unused_types.is_empty(),
2360 "all types were in baseline"
2361 );
2362 assert!(
2363 filtered.unused_dependencies.is_empty(),
2364 "all deps were in baseline"
2365 );
2366 assert!(
2367 filtered.unused_dev_dependencies.is_empty(),
2368 "all dev deps were in baseline"
2369 );
2370 }
2371
2372 #[test]
2373 fn filter_keeps_new_issues_not_in_baseline() {
2374 let baseline = BaselineData {
2375 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2376 unused_files: vec!["src/old.ts".to_string()],
2377 unused_exports: vec![],
2378 unused_types: vec![],
2379 private_type_leaks: vec![],
2380 unused_dependencies: vec![],
2381 unused_dev_dependencies: vec![],
2382 circular_dependencies: vec![],
2383 re_export_cycles: vec![],
2384 unused_optional_dependencies: vec![],
2385 unused_enum_members: vec![],
2386 unused_class_members: vec![],
2387 unused_store_members: vec![],
2388 unprovided_injects: vec![],
2389 unrendered_components: vec![],
2390 unused_component_props: vec![],
2391 unused_component_emits: vec![],
2392 unused_component_inputs: vec![],
2393 unused_component_outputs: vec![],
2394 unused_svelte_events: vec![],
2395 unused_server_actions: vec![],
2396 unused_load_data_keys: vec![],
2397 unresolved_imports: vec![],
2398 unlisted_dependencies: vec![],
2399 duplicate_exports: vec![],
2400 type_only_dependencies: vec![],
2401 test_only_dependencies: vec![],
2402 dev_dependencies_in_production: vec![],
2403 boundary_violations: vec![],
2404 boundary_coverage_violations: vec![],
2405 boundary_call_violations: vec![],
2406 policy_violations: vec![],
2407 stale_suppressions: vec![],
2408 unused_catalog_entries: vec![],
2409 empty_catalog_groups: vec![],
2410 unresolved_catalog_references: vec![],
2411 unused_dependency_overrides: vec![],
2412 misconfigured_dependency_overrides: vec![],
2413 invalid_client_exports: vec![],
2414 mixed_client_server_barrels: vec![],
2415 misplaced_directives: vec![],
2416 route_collisions: vec![],
2417 dynamic_segment_name_conflicts: vec![],
2418 };
2419 let results = AnalysisResults {
2420 unused_files: vec![
2421 UnusedFileFinding::with_actions(UnusedFile {
2422 path: PathBuf::from("src/old.ts"),
2423 }),
2424 UnusedFileFinding::with_actions(UnusedFile {
2425 path: PathBuf::from("src/new-dead.ts"),
2426 }),
2427 ],
2428 ..Default::default()
2429 };
2430 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2431 assert_eq!(filtered.unused_files.len(), 1);
2432 assert_eq!(
2433 filtered.unused_files[0].file.path,
2434 PathBuf::from("src/new-dead.ts")
2435 );
2436 }
2437
2438 #[test]
2439 fn filter_with_empty_baseline_keeps_all() {
2440 let baseline = BaselineData {
2441 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2442 unused_files: vec![],
2443 unused_exports: vec![],
2444 unused_types: vec![],
2445 private_type_leaks: vec![],
2446 unused_dependencies: vec![],
2447 unused_dev_dependencies: vec![],
2448 circular_dependencies: vec![],
2449 re_export_cycles: vec![],
2450 unused_optional_dependencies: vec![],
2451 unused_enum_members: vec![],
2452 unused_class_members: vec![],
2453 unused_store_members: vec![],
2454 unprovided_injects: vec![],
2455 unrendered_components: vec![],
2456 unused_component_props: vec![],
2457 unused_component_emits: vec![],
2458 unused_component_inputs: vec![],
2459 unused_component_outputs: vec![],
2460 unused_svelte_events: vec![],
2461 unused_server_actions: vec![],
2462 unused_load_data_keys: vec![],
2463 unresolved_imports: vec![],
2464 unlisted_dependencies: vec![],
2465 duplicate_exports: vec![],
2466 type_only_dependencies: vec![],
2467 test_only_dependencies: vec![],
2468 dev_dependencies_in_production: vec![],
2469 boundary_violations: vec![],
2470 boundary_coverage_violations: vec![],
2471 boundary_call_violations: vec![],
2472 policy_violations: vec![],
2473 stale_suppressions: vec![],
2474 unused_catalog_entries: vec![],
2475 empty_catalog_groups: vec![],
2476 unresolved_catalog_references: vec![],
2477 unused_dependency_overrides: vec![],
2478 misconfigured_dependency_overrides: vec![],
2479 invalid_client_exports: vec![],
2480 mixed_client_server_barrels: vec![],
2481 misplaced_directives: vec![],
2482 route_collisions: vec![],
2483 dynamic_segment_name_conflicts: vec![],
2484 };
2485 let results = make_results();
2486 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2487 assert_eq!(filtered.unused_files.len(), 2);
2488 assert_eq!(filtered.unused_exports.len(), 1);
2489 }
2490
2491 #[test]
2492 fn filter_new_exports_by_file_and_name() {
2493 let baseline = BaselineData {
2494 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2495 unused_files: vec![],
2496 unused_exports: vec!["src/utils.ts:helperA".to_string()],
2497 unused_types: vec![],
2498 private_type_leaks: vec![],
2499 unused_dependencies: vec![],
2500 unused_dev_dependencies: vec![],
2501 circular_dependencies: vec![],
2502 re_export_cycles: vec![],
2503 unused_optional_dependencies: vec![],
2504 unused_enum_members: vec![],
2505 unused_class_members: vec![],
2506 unused_store_members: vec![],
2507 unprovided_injects: vec![],
2508 unrendered_components: vec![],
2509 unused_component_props: vec![],
2510 unused_component_emits: vec![],
2511 unused_component_inputs: vec![],
2512 unused_component_outputs: vec![],
2513 unused_svelte_events: vec![],
2514 unused_server_actions: vec![],
2515 unused_load_data_keys: vec![],
2516 unresolved_imports: vec![],
2517 unlisted_dependencies: vec![],
2518 duplicate_exports: vec![],
2519 type_only_dependencies: vec![],
2520 test_only_dependencies: vec![],
2521 dev_dependencies_in_production: vec![],
2522 boundary_violations: vec![],
2523 boundary_coverage_violations: vec![],
2524 boundary_call_violations: vec![],
2525 policy_violations: vec![],
2526 stale_suppressions: vec![],
2527 unused_catalog_entries: vec![],
2528 empty_catalog_groups: vec![],
2529 unresolved_catalog_references: vec![],
2530 unused_dependency_overrides: vec![],
2531 misconfigured_dependency_overrides: vec![],
2532 invalid_client_exports: vec![],
2533 mixed_client_server_barrels: vec![],
2534 misplaced_directives: vec![],
2535 route_collisions: vec![],
2536 dynamic_segment_name_conflicts: vec![],
2537 };
2538 let results = AnalysisResults {
2539 unused_exports: vec![
2540 UnusedExportFinding::with_actions(UnusedExport {
2541 path: PathBuf::from("src/utils.ts"),
2542 export_name: "helperA".to_string(),
2543 is_type_only: false,
2544 line: 5,
2545 col: 0,
2546 span_start: 40,
2547 is_re_export: false,
2548 }),
2549 UnusedExportFinding::with_actions(UnusedExport {
2550 path: PathBuf::from("src/utils.ts"),
2551 export_name: "helperB".to_string(),
2552 is_type_only: false,
2553 line: 10,
2554 col: 0,
2555 span_start: 80,
2556 is_re_export: false,
2557 }),
2558 ],
2559 ..Default::default()
2560 };
2561 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2562 assert_eq!(filtered.unused_exports.len(), 1);
2563 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2564 }
2565
2566 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2567 let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
2568 files.sort_unstable();
2569 let fragment = format!("shared body of {}", files.join(","));
2570 make_clone_group_with_fragment(&fragment, instances)
2571 }
2572
2573 fn make_clone_group_with_fragment(
2574 fragment: &str,
2575 instances: Vec<(&str, usize, usize)>,
2576 ) -> CloneGroup {
2577 CloneGroup {
2578 instances: instances
2579 .into_iter()
2580 .map(|(file, start, end)| CloneInstance {
2581 file: PathBuf::from(file),
2582 start_line: start,
2583 end_line: end,
2584 start_col: 0,
2585 end_col: 0,
2586 fragment: fragment.to_string(),
2587 })
2588 .collect(),
2589 token_count: 50,
2590 line_count: 10,
2591 }
2592 }
2593
2594 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
2595 DuplicationReport {
2596 clone_groups: groups,
2597 clone_families: vec![],
2598 mirrored_directories: vec![],
2599 stats: DuplicationStats {
2600 total_files: 10,
2601 files_with_clones: 2,
2602 total_lines: 1000,
2603 duplicated_lines: 100,
2604 total_tokens: 5000,
2605 duplicated_tokens: 500,
2606 clone_groups: 1,
2607 clone_instances: 2,
2608 duplication_percentage: 10.0,
2609 clone_groups_below_min_occurrences: 0,
2610 },
2611 }
2612 }
2613
2614 #[test]
2615 fn clone_group_key_is_deterministic() {
2616 let root = Path::new("/project");
2617 let group = make_clone_group(vec![
2618 ("/project/src/a.ts", 1, 10),
2619 ("/project/src/b.ts", 5, 15),
2620 ]);
2621 let key1 = clone_group_key(&group, root);
2622 let key2 = clone_group_key(&group, root);
2623 assert_eq!(key1, key2);
2624 }
2625
2626 #[test]
2627 fn clone_group_key_is_sorted() {
2628 let root = Path::new("/project");
2629 let group_ab = make_clone_group(vec![
2630 ("/project/src/a.ts", 1, 10),
2631 ("/project/src/b.ts", 5, 15),
2632 ]);
2633 let group_ba = make_clone_group(vec![
2634 ("/project/src/b.ts", 5, 15),
2635 ("/project/src/a.ts", 1, 10),
2636 ]);
2637 assert_eq!(
2638 clone_group_key(&group_ab, root),
2639 clone_group_key(&group_ba, root),
2640 "key should be stable regardless of instance order"
2641 );
2642 }
2643
2644 #[test]
2645 fn duplication_baseline_roundtrip() {
2646 let root = Path::new("/project");
2647 let group = make_clone_group(vec![
2648 ("/project/src/a.ts", 1, 10),
2649 ("/project/src/b.ts", 5, 15),
2650 ]);
2651 let report = make_duplication_report(vec![group]);
2652 let baseline = DuplicationBaselineData::from_report(&report, root);
2653 let json = serde_json::to_string(&baseline).unwrap();
2654 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2655 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2656 assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
2657 assert_eq!(
2658 baseline.clone_fingerprints.len(),
2659 1,
2660 "a saved baseline carries a fingerprint key per clone group"
2661 );
2662 }
2663
2664 #[test]
2665 fn filter_new_clone_groups_matches_shifted_clone() {
2666 let root = Path::new("/project");
2667 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2668 "const total = a + b;",
2669 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2670 )]);
2671 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2672
2673 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2674 "const total = a + b;",
2675 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2676 )]);
2677 let filtered = filter_new_clone_groups(shifted, &baseline, root);
2678 assert!(
2679 filtered.clone_groups.is_empty(),
2680 "an unrelated line shift must not resurface a baselined clone"
2681 );
2682 }
2683
2684 #[test]
2685 fn filter_new_clone_groups_reports_extra_copy() {
2686 let root = Path::new("/project");
2687 let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
2688 "const total = a + b;",
2689 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2690 )]);
2691 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2692
2693 let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
2694 "const total = a + b;",
2695 vec![
2696 ("/project/src/a.ts", 10, 20),
2697 ("/project/src/b.ts", 30, 40),
2698 ("/project/src/c.ts", 5, 15),
2699 ],
2700 )]);
2701 let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
2702 assert_eq!(
2703 filtered.clone_groups.len(),
2704 1,
2705 "a fresh copy in a third file is a new finding"
2706 );
2707 }
2708
2709 #[test]
2710 fn filter_new_clone_groups_reads_legacy_baseline() {
2711 let root = Path::new("/project");
2712 let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
2713 let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
2714 assert_eq!(baseline.entry_count(), 1);
2715
2716 let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
2717 "const total = a + b;",
2718 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2719 )]);
2720 assert!(
2721 filter_new_clone_groups(unchanged, &baseline, root)
2722 .clone_groups
2723 .is_empty(),
2724 "a legacy baseline still matches on locations"
2725 );
2726
2727 let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
2728 "const total = a + b;",
2729 vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
2730 )]);
2731 assert_eq!(
2732 filter_new_clone_groups(shifted, &baseline, root)
2733 .clone_groups
2734 .len(),
2735 1,
2736 "legacy behavior is unchanged: a shift stops matching"
2737 );
2738 }
2739
2740 #[test]
2741 fn clone_group_fingerprint_key_survives_file_rename() {
2742 let before = make_clone_group_with_fragment(
2743 "const total = a + b;",
2744 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2745 );
2746 let after = make_clone_group_with_fragment(
2747 "const total = a + b;",
2748 vec![
2749 ("/project/src/renamed.ts", 10, 20),
2750 ("/project/src/b.ts", 30, 40),
2751 ],
2752 );
2753 assert_eq!(
2754 clone_group_fingerprint_key(&before),
2755 clone_group_fingerprint_key(&after),
2756 "renaming a file must not resurface a baselined clone"
2757 );
2758 }
2759
2760 #[test]
2761 fn clone_group_fingerprint_key_follows_representative_on_rename() {
2762 let mut before = make_clone_group_with_fragment(
2763 "const total = a + b;",
2764 vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
2765 );
2766 before.instances[1].fragment = "const total = a + b;".to_string();
2767
2768 let mut after = before.clone();
2769 after.instances[0].file = PathBuf::from("/project/src/z.ts");
2770
2771 assert_ne!(
2772 clone_group_fingerprint_key(&before),
2773 clone_group_fingerprint_key(&after),
2774 "the representative sorts on (file, line), so a rename past a \
2775 sibling instance rekeys a group whose fragments differ"
2776 );
2777 }
2778
2779 #[test]
2780 fn filter_new_clone_groups_removes_baseline() {
2781 let root = Path::new("/project");
2782 let group = make_clone_group(vec![
2783 ("/project/src/a.ts", 1, 10),
2784 ("/project/src/b.ts", 5, 15),
2785 ]);
2786 let report = make_duplication_report(vec![group]);
2787 let baseline = DuplicationBaselineData::from_report(&report, root);
2788 let filtered = filter_new_clone_groups(report, &baseline, root);
2789 assert!(
2790 filtered.clone_groups.is_empty(),
2791 "baseline group should be filtered out"
2792 );
2793 }
2794
2795 #[test]
2796 fn filter_new_clone_groups_keeps_new_groups() {
2797 let root = Path::new("/project");
2798 let baseline_group = make_clone_group(vec![
2799 ("/project/src/a.ts", 1, 10),
2800 ("/project/src/b.ts", 5, 15),
2801 ]);
2802 let new_group = make_clone_group(vec![
2803 ("/project/src/c.ts", 20, 30),
2804 ("/project/src/d.ts", 25, 35),
2805 ]);
2806 let baseline_report = make_duplication_report(vec![baseline_group]);
2807 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2808
2809 let report = make_duplication_report(vec![
2810 make_clone_group(vec![
2811 ("/project/src/a.ts", 1, 10),
2812 ("/project/src/b.ts", 5, 15),
2813 ]),
2814 new_group,
2815 ]);
2816 let filtered = filter_new_clone_groups(report, &baseline, root);
2817 assert_eq!(
2818 filtered.clone_groups.len(),
2819 1,
2820 "only the new group should remain"
2821 );
2822 }
2823
2824 #[test]
2825 fn recompute_stats_after_filtering() {
2826 let root = Path::new("/project");
2827 let group = make_clone_group(vec![
2828 ("/project/src/a.ts", 1, 10),
2829 ("/project/src/b.ts", 5, 15),
2830 ]);
2831 let report = make_duplication_report(vec![group]);
2832 let baseline = DuplicationBaselineData::from_report(&report, root);
2833 let filtered = filter_new_clone_groups(report, &baseline, root);
2834 assert_eq!(filtered.stats.clone_groups, 0);
2835 assert_eq!(filtered.stats.clone_instances, 0);
2836 assert_eq!(filtered.stats.duplicated_lines, 0);
2837 }
2838
2839 #[test]
2840 fn recompute_stats_zero_total_lines() {
2841 let report = DuplicationReport {
2842 clone_groups: vec![],
2843 clone_families: vec![],
2844 mirrored_directories: vec![],
2845 stats: DuplicationStats {
2846 total_files: 0,
2847 files_with_clones: 0,
2848 total_lines: 0,
2849 duplicated_lines: 0,
2850 total_tokens: 0,
2851 duplicated_tokens: 0,
2852 clone_groups: 0,
2853 clone_instances: 0,
2854 duplication_percentage: 0.0,
2855 clone_groups_below_min_occurrences: 0,
2856 },
2857 };
2858 let stats = super::recompute_stats(&report);
2859 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
2860 }
2861
2862 fn filter_new_health_findings(
2866 findings: Vec<fallow_output::ComplexityViolation>,
2867 baseline: &HealthBaselineData,
2868 root: &Path,
2869 ) -> Vec<fallow_output::ComplexityViolation> {
2870 super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
2871 }
2872
2873 fn make_health_finding(
2874 root: &Path,
2875 name: &str,
2876 line: u32,
2877 ) -> fallow_output::ComplexityViolation {
2878 make_health_finding_with(
2879 root,
2880 name,
2881 line,
2882 fallow_output::ExceededThreshold::Both,
2883 fallow_output::FindingSeverity::High,
2884 )
2885 }
2886
2887 fn make_health_finding_with(
2888 root: &Path,
2889 name: &str,
2890 line: u32,
2891 exceeded: fallow_output::ExceededThreshold,
2892 severity: fallow_output::FindingSeverity,
2893 ) -> fallow_output::ComplexityViolation {
2894 fallow_output::ComplexityViolation {
2895 path: root.join("src/utils.ts"),
2896 name: name.to_string(),
2897 line,
2898 col: 0,
2899 cyclomatic: 25,
2900 cognitive: 30,
2901 line_count: 80,
2902 param_count: 0,
2903 react_hook_count: 0,
2904 react_jsx_max_depth: 0,
2905 react_prop_count: 0,
2906 react_hook_profile: None,
2907 exceeded,
2908 severity,
2909 crap: None,
2910 coverage_pct: None,
2911 coverage_tier: None,
2912 coverage_source: None,
2913 inherited_from: None,
2914 component_rollup: None,
2915 contributions: Vec::new(),
2916 effective_thresholds: None,
2917 threshold_source: None,
2918 }
2919 }
2920
2921 #[test]
2922 fn health_baseline_roundtrip() {
2923 let root = PathBuf::from("/project");
2924 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2925 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
2926 let json = serde_json::to_string(&baseline).unwrap();
2927 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
2928 assert_eq!(deserialized.findings, baseline.findings);
2929 assert_eq!(baseline.findings, Vec::<String>::new());
2930 assert_eq!(
2931 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
2932 1
2933 );
2934 assert!(!json.contains("parseExpression"));
2935 }
2936
2937 #[test]
2938 fn health_baseline_filters_known_findings() {
2939 let root = PathBuf::from("/project");
2940 let mut findings = vec![
2941 make_health_finding(&root, "parseExpression", 42),
2942 make_health_finding(&root, "newFunction", 100),
2943 ];
2944 findings[1].path = root.join("src/other.ts");
2945 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
2946 let filtered = filter_new_health_findings(findings, &baseline, &root);
2947 assert_eq!(filtered.len(), 1);
2948 assert_eq!(filtered[0].name, "newFunction");
2949 }
2950
2951 #[test]
2952 fn health_baseline_filters_shifted_lines_with_same_category_count() {
2953 let root = PathBuf::from("/project");
2954 let baseline = HealthBaselineData::from_findings(
2955 &[make_health_finding(&root, "parseExpression", 42)],
2956 &[],
2957 &[],
2958 &root,
2959 );
2960 let filtered = filter_new_health_findings(
2961 vec![make_health_finding(&root, "parseExpression", 43)],
2962 &baseline,
2963 &root,
2964 );
2965 assert!(filtered.is_empty());
2966 }
2967
2968 #[test]
2969 fn health_baseline_reports_full_category_when_count_increases() {
2970 let root = PathBuf::from("/project");
2971 let baseline = HealthBaselineData::from_findings(
2972 &[make_health_finding(&root, "parseExpression", 42)],
2973 &[],
2974 &[],
2975 &root,
2976 );
2977 let filtered = filter_new_health_findings(
2978 vec![
2979 make_health_finding(&root, "parseExpression", 43),
2980 make_health_finding(&root, "newFunction", 100),
2981 ],
2982 &baseline,
2983 &root,
2984 );
2985 assert_eq!(filtered.len(), 2);
2986 }
2987
2988 #[test]
2989 fn health_baseline_legacy_findings_still_load() {
2990 let root = PathBuf::from("/project");
2991 let baseline = HealthBaselineData {
2992 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
2993 finding_counts: BTreeMap::new(),
2994 identity_finding_counts: BTreeMap::new(),
2995 target_keys: vec![],
2996 runtime_coverage_findings: vec![],
2997 runtime_coverage_source_hashes: vec![],
2998 };
2999 let filtered = filter_new_health_findings(
3000 vec![make_health_finding(&root, "parseExpression", 42)],
3001 &baseline,
3002 &root,
3003 );
3004 assert!(filtered.is_empty());
3005 }
3006
3007 #[test]
3008 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
3009 let root = PathBuf::from("/project");
3010 let baseline = HealthBaselineData::from_findings(
3011 &[make_health_finding_with(
3012 &root,
3013 "parseExpression",
3014 42,
3015 fallow_output::ExceededThreshold::Crap,
3016 fallow_output::FindingSeverity::High,
3017 )],
3018 &[],
3019 &[],
3020 &root,
3021 );
3022 let filtered = filter_new_health_findings(
3023 vec![
3024 make_health_finding_with(
3025 &root,
3026 "parseExpression",
3027 43,
3028 fallow_output::ExceededThreshold::Crap,
3029 fallow_output::FindingSeverity::High,
3030 ),
3031 make_health_finding(&root, "newComplexityOnlyFunction", 100),
3032 ],
3033 &baseline,
3034 &root,
3035 );
3036 assert_eq!(filtered.len(), 1);
3037 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
3038 }
3039
3040 #[test]
3041 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
3042 let root = PathBuf::from("/project");
3043 let baseline = HealthBaselineData::from_findings(
3044 &[make_health_finding_with(
3045 &root,
3046 "parseExpression",
3047 42,
3048 fallow_output::ExceededThreshold::Both,
3049 fallow_output::FindingSeverity::Critical,
3050 )],
3051 &[],
3052 &[],
3053 &root,
3054 );
3055 let filtered = filter_new_health_findings(
3056 vec![make_health_finding_with(
3057 &root,
3058 "parseExpression",
3059 42,
3060 fallow_output::ExceededThreshold::Both,
3061 fallow_output::FindingSeverity::High,
3062 )],
3063 &baseline,
3064 &root,
3065 );
3066 assert!(filtered.is_empty());
3067 }
3068
3069 #[test]
3070 fn health_baseline_still_reports_worse_current_severity_as_new() {
3071 let root = PathBuf::from("/project");
3072 let baseline = HealthBaselineData::from_findings(
3073 &[make_health_finding_with(
3074 &root,
3075 "parseExpression",
3076 42,
3077 fallow_output::ExceededThreshold::Both,
3078 fallow_output::FindingSeverity::High,
3079 )],
3080 &[],
3081 &[],
3082 &root,
3083 );
3084 let filtered = filter_new_health_findings(
3085 vec![make_health_finding_with(
3086 &root,
3087 "parseExpression",
3088 42,
3089 fallow_output::ExceededThreshold::Both,
3090 fallow_output::FindingSeverity::Critical,
3091 )],
3092 &baseline,
3093 &root,
3094 );
3095 assert_eq!(filtered.len(), 1);
3096 assert_eq!(filtered[0].name, "parseExpression");
3097 assert!(matches!(
3098 filtered[0].severity,
3099 fallow_output::FindingSeverity::Critical
3100 ));
3101 }
3102
3103 #[test]
3104 fn health_baseline_overlap_counts_partial_category_overflow() {
3105 let root = PathBuf::from("/project");
3106 let baseline = HealthBaselineData::from_findings(
3107 &[make_health_finding(&root, "parseExpression", 42)],
3108 &[],
3109 &[],
3110 &root,
3111 );
3112 let overlap = baseline.overlap_entry_count(
3113 &[
3114 make_health_finding(&root, "parseExpression", 42),
3115 make_health_finding(&root, "newFunction", 100),
3116 ],
3117 &root,
3118 HealthBaselineMode::Count,
3119 );
3120 assert_eq!(overlap, 1);
3121 }
3122
3123 fn identity_baseline(
3125 findings: &[fallow_output::ComplexityViolation],
3126 root: &Path,
3127 ) -> HealthBaselineData {
3128 HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
3129 }
3130
3131 #[test]
3132 fn health_identity_baseline_reports_replacement_hotspot() {
3133 let root = PathBuf::from("/project");
3134 let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
3135 let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
3136
3137 assert!(
3138 filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
3139 "count mode keeps the per-file allowance and suppresses the replacement"
3140 );
3141
3142 let filtered = super::filter_new_health_findings(
3143 replacement,
3144 &baseline,
3145 &root,
3146 HealthBaselineMode::Identity,
3147 );
3148 assert_eq!(filtered.len(), 1);
3149 assert_eq!(filtered[0].name, "replacementHotspot");
3150 }
3151
3152 #[test]
3153 fn health_identity_baseline_survives_line_moves() {
3154 let root = PathBuf::from("/project");
3155 let baseline =
3156 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3157 let filtered = super::filter_new_health_findings(
3158 vec![make_health_finding(&root, "parseExpression", 512)],
3159 &baseline,
3160 &root,
3161 HealthBaselineMode::Identity,
3162 );
3163 assert!(filtered.is_empty());
3164 }
3165
3166 #[test]
3167 fn health_identity_baseline_suppresses_severity_improvement() {
3168 let root = PathBuf::from("/project");
3169 let baseline = identity_baseline(
3170 &[make_health_finding_with(
3171 &root,
3172 "parseExpression",
3173 42,
3174 fallow_output::ExceededThreshold::Both,
3175 fallow_output::FindingSeverity::Critical,
3176 )],
3177 &root,
3178 );
3179 let filtered = super::filter_new_health_findings(
3180 vec![make_health_finding_with(
3181 &root,
3182 "parseExpression",
3183 42,
3184 fallow_output::ExceededThreshold::Both,
3185 fallow_output::FindingSeverity::Moderate,
3186 )],
3187 &baseline,
3188 &root,
3189 HealthBaselineMode::Identity,
3190 );
3191 assert!(filtered.is_empty());
3192 }
3193
3194 #[test]
3195 fn health_identity_baseline_reports_added_finding_for_known_function() {
3196 let root = PathBuf::from("/project");
3197 let baseline =
3198 identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
3199 let filtered = super::filter_new_health_findings(
3200 vec![
3201 make_health_finding(&root, "parseExpression", 42),
3202 make_health_finding(&root, "parseStatement", 90),
3203 ],
3204 &baseline,
3205 &root,
3206 HealthBaselineMode::Identity,
3207 );
3208 assert_eq!(filtered.len(), 1);
3209 assert_eq!(filtered[0].name, "parseStatement");
3210 }
3211
3212 #[test]
3213 fn health_identity_buckets_are_written_only_in_identity_mode() {
3214 let root = PathBuf::from("/project");
3215 let findings = [make_health_finding(&root, "parseExpression", 42)];
3216 let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
3217 let json = serde_json::to_string(&count_only).unwrap();
3218 assert!(!json.contains("identity_finding_counts"));
3219 assert!(count_only.lacks_identity_data());
3220
3221 let identity = identity_baseline(&findings, &root);
3222 assert!(!identity.lacks_identity_data());
3223 assert_eq!(
3224 identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
3225 .count,
3226 1
3227 );
3228 assert!(
3229 !identity.finding_counts.is_empty(),
3230 "an identity baseline stays readable in count mode"
3231 );
3232 }
3233
3234 #[test]
3235 fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
3236 let legacy = HealthBaselineData {
3237 findings: vec!["src/utils.ts:parseExpression:42".to_string()],
3238 ..HealthBaselineData::default()
3239 };
3240 assert!(legacy.lacks_identity_data());
3241 assert!(!HealthBaselineData::default().lacks_identity_data());
3242 }
3243
3244 #[test]
3245 fn health_baseline_empty_keeps_all() {
3246 let root = PathBuf::from("/project");
3247 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
3248 let baseline = HealthBaselineData {
3249 findings: vec![],
3250 finding_counts: BTreeMap::new(),
3251 identity_finding_counts: BTreeMap::new(),
3252 target_keys: vec![],
3253 runtime_coverage_findings: vec![],
3254 runtime_coverage_source_hashes: vec![],
3255 };
3256 let filtered = filter_new_health_findings(findings, &baseline, &root);
3257 assert_eq!(filtered.len(), 1);
3258 }
3259
3260 #[test]
3261 fn circular_dep_key_is_order_independent() {
3262 use crate::results::CircularDependency;
3263
3264 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
3265 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3266 length: 2,
3267 line: 1,
3268 col: 0,
3269 edges: Vec::new(),
3270 is_cross_package: false,
3271 });
3272 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
3273 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
3274 length: 2,
3275 line: 1,
3276 col: 0,
3277 edges: Vec::new(),
3278 is_cross_package: false,
3279 });
3280 assert_eq!(
3281 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
3282 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
3283 "same files in different order should produce identical keys"
3284 );
3285 }
3286
3287 #[test]
3288 fn circular_dep_key_different_files_different_keys() {
3289 use crate::results::CircularDependency;
3290
3291 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
3292 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3293 length: 2,
3294 line: 1,
3295 col: 0,
3296 edges: Vec::new(),
3297 is_cross_package: false,
3298 });
3299 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
3300 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
3301 length: 2,
3302 line: 1,
3303 col: 0,
3304 edges: Vec::new(),
3305 is_cross_package: false,
3306 });
3307 assert_ne!(
3308 super::circular_dep_key(&dep1.cycle, Path::new("")),
3309 super::circular_dep_key(&dep2.cycle, Path::new("")),
3310 );
3311 }
3312
3313 #[test]
3314 fn circular_dep_key_three_files_order_independent() {
3315 use crate::results::CircularDependency;
3316
3317 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
3318 files: vec![
3319 PathBuf::from("src/a.ts"),
3320 PathBuf::from("src/b.ts"),
3321 PathBuf::from("src/c.ts"),
3322 ],
3323 length: 3,
3324 line: 1,
3325 col: 0,
3326 edges: Vec::new(),
3327 is_cross_package: false,
3328 });
3329 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
3330 files: vec![
3331 PathBuf::from("src/c.ts"),
3332 PathBuf::from("src/a.ts"),
3333 PathBuf::from("src/b.ts"),
3334 ],
3335 length: 3,
3336 line: 1,
3337 col: 0,
3338 edges: Vec::new(),
3339 is_cross_package: false,
3340 });
3341 assert_eq!(
3342 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
3343 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
3344 );
3345 }
3346
3347 #[expect(
3348 clippy::too_many_lines,
3349 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3350 )]
3351 fn make_full_results() -> AnalysisResults {
3352 use crate::results::*;
3353 use crate::source::MemberKind;
3354
3355 let mut r = make_results();
3356 r.circular_dependencies
3357 .push(CircularDependencyFinding::with_actions(
3358 CircularDependency {
3359 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3360 length: 2,
3361 line: 1,
3362 col: 0,
3363 edges: Vec::new(),
3364 is_cross_package: false,
3365 },
3366 ));
3367 r.unused_optional_dependencies
3368 .push(UnusedOptionalDependencyFinding::with_actions(
3369 UnusedDependency {
3370 package_name: "fsevents".to_string(),
3371 location: DependencyLocation::OptionalDependencies,
3372 path: PathBuf::from("package.json"),
3373 line: 15,
3374 used_in_workspaces: Vec::new(),
3375 },
3376 ));
3377 r.unused_enum_members
3378 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
3379 path: PathBuf::from("src/enums.ts"),
3380 parent_name: "Status".to_string(),
3381 member_name: "Deprecated".to_string(),
3382 kind: MemberKind::EnumMember,
3383 line: 8,
3384 col: 0,
3385 }));
3386 r.unused_class_members
3387 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
3388 path: PathBuf::from("src/service.ts"),
3389 parent_name: "UserService".to_string(),
3390 member_name: "legacy".to_string(),
3391 kind: MemberKind::ClassMethod,
3392 line: 42,
3393 col: 0,
3394 }));
3395 r.unused_store_members
3396 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
3397 path: PathBuf::from("src/store.ts"),
3398 parent_name: "useStore".to_string(),
3399 member_name: "legacyAction".to_string(),
3400 kind: MemberKind::StoreMember,
3401 line: 17,
3402 col: 0,
3403 }));
3404 r.unresolved_imports.push(
3405 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
3406 crate::results::UnresolvedImport {
3407 path: PathBuf::from("src/app.ts"),
3408 specifier: "./missing".to_string(),
3409 line: 3,
3410 col: 0,
3411 specifier_col: 0,
3412 },
3413 ),
3414 );
3415 r.unlisted_dependencies
3416 .push(crate::results::UnlistedDependencyFinding::with_actions(
3417 UnlistedDependency {
3418 package_name: "chalk".to_string(),
3419 imported_from: vec![],
3420 },
3421 ));
3422 r.duplicate_exports
3423 .push(crate::results::DuplicateExportFinding::with_actions(
3424 crate::results::DuplicateExport {
3425 export_name: "Config".to_string(),
3426 locations: vec![
3427 crate::results::DuplicateLocation {
3428 path: PathBuf::from("src/a.ts"),
3429 line: 1,
3430 col: 0,
3431 },
3432 crate::results::DuplicateLocation {
3433 path: PathBuf::from("src/b.ts"),
3434 line: 5,
3435 col: 0,
3436 },
3437 ],
3438 },
3439 ));
3440 r.type_only_dependencies
3441 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3442 TypeOnlyDependency {
3443 package_name: "zod".to_string(),
3444 path: PathBuf::from("package.json"),
3445 line: 8,
3446 },
3447 ));
3448 r.test_only_dependencies
3449 .push(crate::results::TestOnlyDependencyFinding::with_actions(
3450 TestOnlyDependency {
3451 package_name: "vitest".to_string(),
3452 path: PathBuf::from("package.json"),
3453 line: 10,
3454 },
3455 ));
3456 r.boundary_violations.push(
3457 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3458 crate::results::BoundaryViolation {
3459 from_path: PathBuf::from("src/ui/btn.ts"),
3460 to_path: PathBuf::from("src/db/query.ts"),
3461 from_zone: "ui".to_string(),
3462 to_zone: "db".to_string(),
3463 import_specifier: "../db/query".to_string(),
3464 line: 1,
3465 col: 0,
3466 },
3467 ),
3468 );
3469 r
3470 }
3471
3472 #[test]
3473 fn baseline_from_results_captures_all_extended_fields() {
3474 let results = make_full_results();
3475 let baseline = BaselineData::from_results(&results, Path::new(""));
3476 assert_eq!(baseline.circular_dependencies.len(), 1);
3477 assert_eq!(
3478 baseline.unused_optional_dependencies,
3479 vec!["package.json:fsevents"]
3480 );
3481 assert_eq!(baseline.unused_enum_members.len(), 1);
3482 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3483 assert_eq!(baseline.unused_class_members.len(), 1);
3484 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3485 assert_eq!(baseline.unused_store_members.len(), 1);
3486 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3487 assert_eq!(baseline.unresolved_imports.len(), 1);
3488 assert!(baseline.unresolved_imports[0].contains("./missing"));
3489 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3490 assert_eq!(baseline.duplicate_exports.len(), 1);
3491 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3492 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3493 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3494 assert_eq!(baseline.boundary_violations.len(), 1);
3495 assert!(baseline.boundary_violations[0].contains("->"));
3496 }
3497
3498 #[test]
3499 fn filter_removes_all_extended_baseline_issues() {
3500 let results = make_full_results();
3501 let baseline = BaselineData::from_results(&results, Path::new(""));
3502 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3503 assert!(filtered.circular_dependencies.is_empty());
3504 assert!(filtered.unused_optional_dependencies.is_empty());
3505 assert!(filtered.unused_enum_members.is_empty());
3506 assert!(filtered.unused_class_members.is_empty());
3507 assert!(filtered.unused_store_members.is_empty());
3508 assert!(filtered.unresolved_imports.is_empty());
3509 assert!(filtered.unlisted_dependencies.is_empty());
3510 assert!(filtered.duplicate_exports.is_empty());
3511 assert!(filtered.type_only_dependencies.is_empty());
3512 assert!(filtered.test_only_dependencies.is_empty());
3513 assert!(filtered.boundary_violations.is_empty());
3514 }
3515
3516 #[test]
3517 fn filter_keeps_new_circular_deps() {
3518 use crate::results::CircularDependency;
3519 let baseline = BaselineData {
3520 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3521 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3522 };
3523 let mut results = AnalysisResults::default();
3524 results
3525 .circular_dependencies
3526 .push(CircularDependencyFinding::with_actions(
3527 CircularDependency {
3528 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3529 length: 2,
3530 line: 1,
3531 col: 0,
3532 edges: Vec::new(),
3533 is_cross_package: false,
3534 },
3535 ));
3536 results
3537 .circular_dependencies
3538 .push(CircularDependencyFinding::with_actions(
3539 CircularDependency {
3540 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3541 length: 2,
3542 line: 5,
3543 col: 0,
3544 edges: Vec::new(),
3545 is_cross_package: false,
3546 },
3547 ));
3548 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3549 assert_eq!(filtered.circular_dependencies.len(), 1);
3550 }
3551
3552 #[test]
3553 fn filter_keeps_new_boundary_violations() {
3554 use crate::results::BoundaryViolation;
3555 let baseline = BaselineData {
3556 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3557 boundary_coverage_violations: vec![],
3558 boundary_call_violations: vec![],
3559 policy_violations: vec![],
3560 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3561 };
3562 let mut results = AnalysisResults::default();
3563 results
3564 .boundary_violations
3565 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3566 from_path: PathBuf::from("src/a.ts"),
3567 to_path: PathBuf::from("src/b.ts"),
3568 from_zone: "a".to_string(),
3569 to_zone: "b".to_string(),
3570 import_specifier: "../b".to_string(),
3571 line: 1,
3572 col: 0,
3573 }));
3574 results
3575 .boundary_violations
3576 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3577 from_path: PathBuf::from("src/new.ts"),
3578 to_path: PathBuf::from("src/secret.ts"),
3579 from_zone: "new".to_string(),
3580 to_zone: "secret".to_string(),
3581 import_specifier: "../secret".to_string(),
3582 line: 1,
3583 col: 0,
3584 }));
3585 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3586 assert_eq!(filtered.boundary_violations.len(), 1);
3587 }
3588
3589 #[test]
3590 fn health_targets_baseline_filters_known() {
3591 let root = PathBuf::from("/project");
3592 let targets = vec![
3593 fallow_output::RefactoringTarget {
3594 path: root.join("src/complex.ts"),
3595 priority: 80.0,
3596 efficiency: 40.0,
3597 recommendation: "Split file".to_string(),
3598 category: fallow_output::RecommendationCategory::SplitHighImpact,
3599 effort: fallow_output::EffortEstimate::Medium,
3600 confidence: fallow_output::Confidence::Medium,
3601 factors: vec![],
3602 evidence: None,
3603 },
3604 fallow_output::RefactoringTarget {
3605 path: root.join("src/new-issue.ts"),
3606 priority: 60.0,
3607 efficiency: 30.0,
3608 recommendation: "Extract function".to_string(),
3609 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3610 effort: fallow_output::EffortEstimate::Low,
3611 confidence: fallow_output::Confidence::High,
3612 factors: vec![],
3613 evidence: None,
3614 },
3615 ];
3616 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3617 let filtered = filter_new_health_targets(targets, &baseline, &root);
3618 assert_eq!(filtered.len(), 1);
3619 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3620 }
3621
3622 #[test]
3623 fn duplicate_export_key_is_sorted() {
3624 use crate::results::{DuplicateExport, DuplicateLocation};
3625 let dup_ab = DuplicateExport {
3626 export_name: "foo".to_string(),
3627 locations: vec![
3628 DuplicateLocation {
3629 path: PathBuf::from("src/a.ts"),
3630 line: 1,
3631 col: 0,
3632 },
3633 DuplicateLocation {
3634 path: PathBuf::from("src/b.ts"),
3635 line: 5,
3636 col: 0,
3637 },
3638 ],
3639 };
3640 let dup_ba = DuplicateExport {
3641 export_name: "foo".to_string(),
3642 locations: vec![
3643 DuplicateLocation {
3644 path: PathBuf::from("src/b.ts"),
3645 line: 5,
3646 col: 0,
3647 },
3648 DuplicateLocation {
3649 path: PathBuf::from("src/a.ts"),
3650 line: 1,
3651 col: 0,
3652 },
3653 ],
3654 };
3655 assert_eq!(
3656 super::duplicate_export_key(&dup_ab, Path::new("")),
3657 super::duplicate_export_key(&dup_ba, Path::new("")),
3658 );
3659 }
3660
3661 #[test]
3662 fn boundary_violation_key_format() {
3663 use crate::results::BoundaryViolation;
3664 let v = BoundaryViolation {
3665 from_path: PathBuf::from("src/ui/btn.ts"),
3666 to_path: PathBuf::from("src/db/query.ts"),
3667 from_zone: "ui".to_string(),
3668 to_zone: "db".to_string(),
3669 import_specifier: "../db/query".to_string(),
3670 line: 1,
3671 col: 0,
3672 };
3673 let key = super::boundary_violation_key(&v, Path::new(""));
3674 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
3675 }
3676
3677 fn make_absolute_results(root: &str) -> AnalysisResults {
3679 use crate::results::*;
3680 use crate::source::MemberKind;
3681
3682 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
3683
3684 AnalysisResults {
3685 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3686 path: p("src/old.ts"),
3687 })],
3688 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3689 path: p("src/utils.ts"),
3690 export_name: "helper".to_string(),
3691 is_type_only: false,
3692 line: 5,
3693 col: 0,
3694 span_start: 40,
3695 is_re_export: false,
3696 })],
3697 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3698 package_name: "lodash-es".to_string(),
3699 location: DependencyLocation::Dependencies,
3700 path: p("packages/app/package.json"),
3701 line: 5,
3702 used_in_workspaces: Vec::new(),
3703 })],
3704 circular_dependencies: vec![CircularDependencyFinding::with_actions(
3705 CircularDependency {
3706 files: vec![p("src/a.ts"), p("src/b.ts")],
3707 length: 2,
3708 line: 1,
3709 col: 0,
3710 edges: Vec::new(),
3711 is_cross_package: false,
3712 },
3713 )],
3714 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
3715 path: p("src/enums.ts"),
3716 parent_name: "Status".to_string(),
3717 member_name: "Deprecated".to_string(),
3718 kind: MemberKind::EnumMember,
3719 line: 8,
3720 col: 0,
3721 })],
3722 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
3723 path: p("src/service.ts"),
3724 parent_name: "UserService".to_string(),
3725 member_name: "legacy".to_string(),
3726 kind: MemberKind::ClassMethod,
3727 line: 42,
3728 col: 0,
3729 })],
3730 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
3731 path: p("src/store.ts"),
3732 parent_name: "useStore".to_string(),
3733 member_name: "legacyAction".to_string(),
3734 kind: MemberKind::StoreMember,
3735 line: 17,
3736 col: 0,
3737 })],
3738 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
3739 path: p("src/app.ts"),
3740 specifier: "./missing".to_string(),
3741 line: 3,
3742 col: 0,
3743 specifier_col: 0,
3744 })],
3745 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
3746 export_name: "Config".to_string(),
3747 locations: vec![
3748 DuplicateLocation {
3749 path: p("src/a.ts"),
3750 line: 1,
3751 col: 0,
3752 },
3753 DuplicateLocation {
3754 path: p("src/b.ts"),
3755 line: 5,
3756 col: 0,
3757 },
3758 ],
3759 })],
3760 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
3761 from_path: p("src/ui/btn.ts"),
3762 to_path: p("src/db/query.ts"),
3763 from_zone: "ui".to_string(),
3764 to_zone: "db".to_string(),
3765 import_specifier: "../db/query".to_string(),
3766 line: 1,
3767 col: 0,
3768 })],
3769 ..Default::default()
3770 }
3771 }
3772
3773 #[test]
3776 fn baseline_keys_are_relative_to_root() {
3777 let local_root = Path::new("/Users/dev/project");
3778 let results = make_absolute_results("/Users/dev/project");
3779 let baseline = BaselineData::from_results(&results, local_root);
3780
3781 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
3782 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
3783 assert_eq!(
3784 baseline.unused_dependencies,
3785 vec!["packages/app/package.json:lodash-es"]
3786 );
3787 assert_eq!(
3788 baseline.boundary_violations,
3789 vec!["src/ui/btn.ts->src/db/query.ts"]
3790 );
3791 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
3792 assert_eq!(
3793 baseline.unused_enum_members,
3794 vec!["src/enums.ts:Status.Deprecated"]
3795 );
3796 assert_eq!(
3797 baseline.unused_class_members,
3798 vec!["src/service.ts:UserService.legacy"]
3799 );
3800 assert_eq!(
3801 baseline.unused_store_members,
3802 vec!["src/store.ts:useStore.legacyAction"]
3803 );
3804 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
3805 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
3806
3807 let ci_root = Path::new("/home/runner/work/project/project");
3808 let ci_results = make_absolute_results("/home/runner/work/project/project");
3809
3810 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
3811 assert!(filtered.unused_files.is_empty(), "unused files");
3812 assert!(filtered.unused_exports.is_empty(), "unused exports");
3813 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
3814 assert!(
3815 filtered.boundary_violations.is_empty(),
3816 "boundary violations"
3817 );
3818 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
3819 assert!(filtered.unused_enum_members.is_empty(), "enum members");
3820 assert!(filtered.unused_class_members.is_empty(), "class members");
3821 assert!(filtered.unused_store_members.is_empty(), "store members");
3822 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
3823 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
3824 }
3825
3826 #[test]
3827 fn stale_suppression_baseline_keys_include_missing_reason_state() {
3828 let root = Path::new("/project");
3829 let stale = crate::results::StaleSuppression {
3830 path: root.join("src/file.ts"),
3831 line: 1,
3832 col: 0,
3833 origin: crate::results::SuppressionOrigin::Comment {
3834 issue_kind: Some("unused-export".to_string()),
3835 reason: None,
3836 is_file_level: false,
3837 kind_known: true,
3838 },
3839 missing_reason: false,
3840 actions: crate::results::StaleSuppression::actions_for(false),
3841 };
3842 let missing = crate::results::StaleSuppression {
3843 missing_reason: true,
3844 actions: crate::results::StaleSuppression::actions_for(true),
3845 ..stale.clone()
3846 };
3847 let results = AnalysisResults {
3848 stale_suppressions: vec![stale, missing],
3849 ..Default::default()
3850 };
3851 let baseline = BaselineData::from_results(&results, root);
3852
3853 assert_eq!(
3854 baseline.stale_suppressions,
3855 vec![
3856 "stale-suppression:src/file.ts:1",
3857 "missing-suppression-reason:src/file.ts:1",
3858 ]
3859 );
3860
3861 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
3862 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
3863 let filtered = filter_new_issues(results, &legacy_baseline, root);
3864 assert!(filtered.stale_suppressions.is_empty());
3865 }
3866
3867 fn runtime_finding(
3868 id: &str,
3869 stable_id: Option<&str>,
3870 line: u32,
3871 source_hash: Option<&str>,
3872 ) -> fallow_output::RuntimeCoverageFinding {
3873 fallow_output::RuntimeCoverageFinding {
3874 id: id.to_owned(),
3875 stable_id: stable_id.map(str::to_owned),
3876 source_hash: source_hash.map(str::to_owned),
3877 path: PathBuf::from("src/a.ts"),
3878 function: "alpha".to_owned(),
3879 line,
3880 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
3881 invocations: Some(0),
3882 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
3883 evidence: fallow_output::RuntimeCoverageEvidence {
3884 static_status: "used".to_owned(),
3885 test_coverage: "not_covered".to_owned(),
3886 v8_tracking: "tracked".to_owned(),
3887 untracked_reason: None,
3888 observation_days: 1,
3889 deployments_observed: 1,
3890 },
3891 actions: vec![],
3892 discriminators: None,
3893 }
3894 }
3895
3896 #[test]
3897 fn legacy_prod_baseline_still_suppresses_finding() {
3898 let baseline = HealthBaselineData {
3899 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
3900 ..HealthBaselineData::default()
3901 };
3902 let findings = vec![runtime_finding(
3903 "fallow:prod:deadbeef",
3904 Some("fallow:fn:00000001"),
3905 14,
3906 None,
3907 )];
3908 let filtered =
3909 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3910 assert!(filtered.is_empty(), "legacy prod id must still suppress");
3911 }
3912
3913 #[test]
3914 fn source_hash_baseline_survives_line_move() {
3915 let root = Path::new("/repo");
3916 let baselined = runtime_finding(
3917 "fallow:prod:deadbeef",
3918 Some("fallow:fn:00000001"),
3919 14,
3920 Some("0123456789abcdef"),
3921 );
3922 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
3923 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
3924
3925 let findings = vec![runtime_finding(
3926 "fallow:prod:99999999",
3927 Some("fallow:fn:cafe0002"),
3928 40,
3929 Some("0123456789abcdef"),
3930 )];
3931 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
3932 assert!(
3933 filtered.is_empty(),
3934 "source_hash baseline must survive a line move despite a changed stable_id and id"
3935 );
3936 }
3937
3938 #[test]
3939 fn unbaselined_finding_is_reported() {
3940 let baseline = HealthBaselineData {
3941 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
3942 ..HealthBaselineData::default()
3943 };
3944 let findings = vec![runtime_finding(
3945 "fallow:prod:abc1234d",
3946 Some("fallow:fn:beefcafe"),
3947 7,
3948 None,
3949 )];
3950 let filtered =
3951 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3952 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
3953 }
3954}