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(serde::Serialize, serde::Deserialize)]
1527pub struct DuplicationBaselineData {
1528 pub clone_groups: Vec<String>,
1530}
1531
1532impl DuplicationBaselineData {
1533 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
1535 Self {
1536 clone_groups: report
1537 .clone_groups
1538 .iter()
1539 .map(|g| clone_group_key(g, root))
1540 .collect(),
1541 }
1542 }
1543}
1544
1545fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
1547 let mut parts: Vec<String> = group
1548 .instances
1549 .iter()
1550 .map(|i| {
1551 format!(
1552 "{}:{}-{}",
1553 relative_path(&i.file, root),
1554 i.start_line,
1555 i.end_line
1556 )
1557 })
1558 .collect();
1559 parts.sort();
1560 parts.join("|")
1561}
1562
1563pub fn filter_new_clone_groups(
1565 mut report: DuplicationReport,
1566 baseline: &DuplicationBaselineData,
1567 root: &Path,
1568) -> DuplicationReport {
1569 let baseline_keys: FxHashSet<&str> = baseline.clone_groups.iter().map(String::as_str).collect();
1570
1571 report.clone_groups.retain(|g| {
1572 let key = clone_group_key(g, root);
1573 !baseline_keys.contains(key.as_str())
1574 });
1575
1576 crate::duplicates::refresh_clone_families(&mut report, root);
1577 report.stats = recompute_stats(&report);
1578
1579 report
1580}
1581
1582pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
1587 crate::duplicates::recompute_stats(report)
1588}
1589
1590#[derive(Default, serde::Serialize, serde::Deserialize)]
1597pub struct HealthBaselineData {
1598 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1600 pub(crate) findings: Vec<String>,
1601 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1603 pub(crate) finding_counts: HealthFindingCountMap,
1604 #[serde(default)]
1606 pub(crate) runtime_coverage_findings: Vec<String>,
1607 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1614 pub(crate) runtime_coverage_source_hashes: Vec<String>,
1615 #[serde(default)]
1617 pub(crate) target_keys: Vec<String>,
1618}
1619
1620#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1621pub struct HealthBaselineCount {
1622 count: usize,
1623}
1624
1625type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
1626
1627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1628enum HealthFindingDimension {
1629 Complexity,
1630 Crap,
1631}
1632
1633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1634struct HealthFindingCategory {
1635 dimension: HealthFindingDimension,
1636 severity: fallow_output::FindingSeverity,
1637}
1638
1639impl HealthFindingCategory {
1640 const fn key(self) -> &'static str {
1641 match (self.dimension, self.severity) {
1642 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1643 "complexity_moderate"
1644 }
1645 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1646 "complexity_high"
1647 }
1648 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1649 "complexity_critical"
1650 }
1651 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1652 "crap_moderate"
1653 }
1654 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1655 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1656 "crap_critical"
1657 }
1658 }
1659 }
1660}
1661
1662const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1663 HealthFindingDimension::Complexity,
1664 HealthFindingDimension::Crap,
1665];
1666
1667impl HealthBaselineData {
1668 pub(crate) fn from_findings(
1670 findings: &[fallow_output::ComplexityViolation],
1671 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1672 targets: &[fallow_output::RefactoringTarget],
1673 root: &Path,
1674 ) -> Self {
1675 Self {
1676 findings: Vec::new(),
1677 finding_counts: health_finding_counts(findings, root),
1678 runtime_coverage_findings: runtime_coverage_findings
1679 .iter()
1680 .map(|f| runtime_coverage_finding_key(f, root))
1681 .collect(),
1682 runtime_coverage_source_hashes: runtime_coverage_findings
1683 .iter()
1684 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1685 .collect(),
1686 target_keys: targets
1687 .iter()
1688 .map(|t| target_baseline_key(t, root))
1689 .collect(),
1690 }
1691 }
1692
1693 pub(crate) fn finding_entry_count(&self) -> usize {
1694 if !self.finding_counts.is_empty() {
1695 self.finding_counts
1696 .values()
1697 .flat_map(BTreeMap::values)
1698 .map(|entry| entry.count)
1699 .sum()
1700 } else {
1701 self.findings.len()
1702 }
1703 }
1704
1705 pub(crate) fn overlap_entry_count(
1706 &self,
1707 findings: &[fallow_output::ComplexityViolation],
1708 root: &Path,
1709 ) -> usize {
1710 if !self.finding_counts.is_empty() {
1711 let current_counts = health_finding_counts(findings, root);
1712 health_overlap_entry_count(¤t_counts, &self.finding_counts)
1713 } else {
1714 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
1715 findings
1716 .iter()
1717 .filter(|finding| {
1718 baseline_keys.contains(health_finding_key(finding, root).as_str())
1719 })
1720 .count()
1721 }
1722 }
1723}
1724
1725fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
1727 format!(
1728 "{}:{}",
1729 relative_path(&target.path, root),
1730 target.category.label()
1731 )
1732}
1733
1734fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1736 format!(
1737 "{}:{}:{}",
1738 relative_path(&finding.path, root),
1739 finding.name,
1740 finding.line
1741 )
1742}
1743
1744fn health_finding_counts(
1745 findings: &[fallow_output::ComplexityViolation],
1746 root: &Path,
1747) -> HealthFindingCountMap {
1748 let mut counts = BTreeMap::new();
1749 for finding in findings {
1750 let path = relative_path(&finding.path, root);
1751 let file_counts = counts.entry(path).or_insert_with(BTreeMap::new);
1752 for category in health_finding_categories(finding).into_iter().flatten() {
1753 file_counts
1754 .entry(category.key().to_string())
1755 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
1756 .or_insert(HealthBaselineCount { count: 1 });
1757 }
1758 }
1759 counts
1760}
1761
1762fn health_finding_categories(
1763 finding: &fallow_output::ComplexityViolation,
1764) -> [Option<HealthFindingCategory>; 2] {
1765 let complexity_category = HealthFindingCategory {
1766 dimension: HealthFindingDimension::Complexity,
1767 severity: finding.severity,
1768 };
1769 let crap_category = HealthFindingCategory {
1770 dimension: HealthFindingDimension::Crap,
1771 severity: finding.severity,
1772 };
1773 let has_complexity =
1774 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
1775 let has_crap = finding.exceeded.includes_crap();
1776 [
1777 has_complexity.then_some(complexity_category),
1778 has_crap.then_some(crap_category),
1779 ]
1780}
1781
1782fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
1783 match severity {
1784 fallow_output::FindingSeverity::Moderate => 0,
1785 fallow_output::FindingSeverity::High => 1,
1786 fallow_output::FindingSeverity::Critical => 2,
1787 }
1788}
1789
1790fn severity_counts_for_dimension(
1791 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
1792 dimension: HealthFindingDimension,
1793) -> [usize; 3] {
1794 let mut counts = [0; 3];
1795 for severity in [
1796 fallow_output::FindingSeverity::Moderate,
1797 fallow_output::FindingSeverity::High,
1798 fallow_output::FindingSeverity::Critical,
1799 ] {
1800 let category = HealthFindingCategory {
1801 dimension,
1802 severity,
1803 };
1804 counts[severity_index(severity)] = file_counts
1805 .and_then(|entries| entries.get(category.key()))
1806 .map_or(0, |entry| entry.count);
1807 }
1808 counts
1809}
1810
1811fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
1812 let mut available = baseline;
1813 let mut overflow = [false; 3];
1814
1815 for severity_idx in 0..3 {
1816 let compatible = available[severity_idx..].iter().sum::<usize>();
1817 overflow[severity_idx] = compatible < current[severity_idx];
1818
1819 let mut matched = current[severity_idx].min(compatible);
1820 for slot in available.iter_mut().skip(severity_idx) {
1821 let taken = matched.min(*slot);
1822 *slot -= taken;
1823 matched -= taken;
1824 if matched == 0 {
1825 break;
1826 }
1827 }
1828 }
1829
1830 overflow
1831}
1832
1833fn health_overflow_categories(
1834 current_counts: &HealthFindingCountMap,
1835 baseline_counts: &HealthFindingCountMap,
1836) -> FxHashMap<String, FxHashSet<&'static str>> {
1837 let mut overflow_by_path = FxHashMap::default();
1838
1839 for (path, current_file_counts) in current_counts {
1840 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
1841 let baseline_file_counts = baseline_counts.get(path);
1842
1843 for dimension in HEALTH_FINDING_DIMENSIONS {
1844 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
1845 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
1846 let overflow = overflowing_severities(current, baseline);
1847
1848 for severity in [
1849 fallow_output::FindingSeverity::Moderate,
1850 fallow_output::FindingSeverity::High,
1851 fallow_output::FindingSeverity::Critical,
1852 ] {
1853 if overflow[severity_index(severity)] {
1854 overflow_categories.insert(
1855 HealthFindingCategory {
1856 dimension,
1857 severity,
1858 }
1859 .key(),
1860 );
1861 }
1862 }
1863 }
1864
1865 if !overflow_categories.is_empty() {
1866 overflow_by_path.insert(path.clone(), overflow_categories);
1867 }
1868 }
1869
1870 overflow_by_path
1871}
1872
1873fn health_overlap_entry_count(
1874 current_counts: &HealthFindingCountMap,
1875 baseline_counts: &HealthFindingCountMap,
1876) -> usize {
1877 let mut overlap = 0;
1878
1879 for (path, baseline_file_counts) in baseline_counts {
1880 let current_file_counts = current_counts.get(path);
1881
1882 for dimension in HEALTH_FINDING_DIMENSIONS {
1883 let current_total: usize =
1884 severity_counts_for_dimension(current_file_counts, dimension)
1885 .into_iter()
1886 .sum();
1887 let baseline_total: usize =
1888 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
1889 .into_iter()
1890 .sum();
1891 overlap += current_total.min(baseline_total);
1892 }
1893 }
1894
1895 overlap
1896}
1897
1898fn runtime_coverage_finding_key(
1899 finding: &fallow_output::RuntimeCoverageFinding,
1900 _root: &Path,
1901) -> String {
1902 finding
1903 .stable_id
1904 .clone()
1905 .unwrap_or_else(|| finding.id.clone())
1906}
1907
1908fn runtime_coverage_source_hash_key(
1915 finding: &fallow_output::RuntimeCoverageFinding,
1916 root: &Path,
1917) -> Option<String> {
1918 finding.source_hash.as_deref().map(|hash| {
1919 format!(
1920 "{}\0{}\0{}",
1921 relative_path(&finding.path, root),
1922 finding.function,
1923 hash
1924 )
1925 })
1926}
1927
1928pub(crate) fn filter_new_health_findings(
1930 mut findings: Vec<fallow_output::ComplexityViolation>,
1931 baseline: &HealthBaselineData,
1932 root: &Path,
1933) -> Vec<fallow_output::ComplexityViolation> {
1934 if !baseline.finding_counts.is_empty() {
1935 let current_counts = health_finding_counts(&findings, root);
1936 let overflow_categories =
1937 health_overflow_categories(¤t_counts, &baseline.finding_counts);
1938 findings.retain(|finding| {
1939 let path = relative_path(&finding.path, root);
1940 overflow_categories.get(&path).is_some_and(|categories| {
1941 health_finding_categories(finding)
1942 .into_iter()
1943 .flatten()
1944 .any(|category| categories.contains(category.key()))
1945 })
1946 });
1947 return findings;
1948 }
1949
1950 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
1951 findings.retain(|f| {
1952 let key = health_finding_key(f, root);
1953 !baseline_keys.contains(key.as_str())
1954 });
1955 findings
1956}
1957
1958pub(crate) fn filter_new_runtime_coverage_findings(
1959 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
1960 baseline: &HealthBaselineData,
1961 root: &Path,
1962) -> Vec<fallow_output::RuntimeCoverageFinding> {
1963 let baseline_keys: FxHashSet<&str> = baseline
1964 .runtime_coverage_findings
1965 .iter()
1966 .map(String::as_str)
1967 .collect();
1968 let baseline_source_hash_keys: FxHashSet<&str> = baseline
1969 .runtime_coverage_source_hashes
1970 .iter()
1971 .map(String::as_str)
1972 .collect();
1973 findings.retain(|finding| {
1974 let suppressed_by_stable_id = finding
1975 .stable_id
1976 .as_deref()
1977 .is_some_and(|id| baseline_keys.contains(id));
1978 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
1979 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
1980 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
1981 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
1982 });
1983 findings
1984}
1985
1986pub(crate) fn filter_new_health_targets(
1988 mut targets: Vec<fallow_output::RefactoringTarget>,
1989 baseline: &HealthBaselineData,
1990 root: &Path,
1991) -> Vec<fallow_output::RefactoringTarget> {
1992 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
1993 targets.retain(|t| {
1994 let key = target_baseline_key(t, root);
1995 !baseline_keys.contains(key.as_str())
1996 });
1997 targets
1998}
1999
2000#[derive(Debug, Clone, serde::Serialize)]
2002pub struct CategoryDelta {
2003 pub current: usize,
2004 pub baseline: usize,
2005 pub delta: i64,
2006}
2007
2008#[derive(Debug, Clone)]
2013pub struct BaselineDeltas {
2014 pub total_delta: i64,
2016 pub per_category: Vec<(String, CategoryDelta)>,
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2024 use crate::results::{
2025 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2026 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2027 UnusedFile,
2028 };
2029 use fallow_types::output_dead_code::{
2030 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2031 };
2032 use std::path::PathBuf;
2033
2034 fn make_results() -> AnalysisResults {
2035 AnalysisResults {
2036 unused_files: vec![
2037 UnusedFileFinding::with_actions(UnusedFile {
2038 path: PathBuf::from("src/old.ts"),
2039 }),
2040 UnusedFileFinding::with_actions(UnusedFile {
2041 path: PathBuf::from("src/dead.ts"),
2042 }),
2043 ],
2044 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2045 path: PathBuf::from("src/utils.ts"),
2046 export_name: "helperA".to_string(),
2047 is_type_only: false,
2048 line: 5,
2049 col: 0,
2050 span_start: 40,
2051 is_re_export: false,
2052 })],
2053 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2054 path: PathBuf::from("src/types.ts"),
2055 export_name: "OldType".to_string(),
2056 is_type_only: true,
2057 line: 10,
2058 col: 0,
2059 span_start: 100,
2060 is_re_export: false,
2061 })],
2062 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2063 package_name: "lodash".to_string(),
2064 location: DependencyLocation::Dependencies,
2065 path: PathBuf::from("package.json"),
2066 line: 5,
2067 used_in_workspaces: Vec::new(),
2068 })],
2069 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2070 UnusedDependency {
2071 package_name: "jest".to_string(),
2072 location: DependencyLocation::DevDependencies,
2073 path: PathBuf::from("package.json"),
2074 line: 5,
2075 used_in_workspaces: Vec::new(),
2076 },
2077 )],
2078 ..Default::default()
2079 }
2080 }
2081
2082 #[test]
2083 fn baseline_from_results_captures_all_fields() {
2084 let results = make_results();
2085 let baseline = BaselineData::from_results(&results, Path::new(""));
2086 assert_eq!(baseline.unused_files.len(), 2);
2087 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2088 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2089 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2090 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2091 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2092 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2093 }
2094
2095 #[test]
2096 fn dependency_baseline_keys_include_package_json_path() {
2097 let root = Path::new("/repo");
2098 let results = AnalysisResults {
2099 unused_dependencies: vec![
2100 UnusedDependencyFinding::with_actions(UnusedDependency {
2101 package_name: "lodash-es".to_string(),
2102 location: DependencyLocation::Dependencies,
2103 path: PathBuf::from("/repo/packages/app-a/package.json"),
2104 line: 5,
2105 used_in_workspaces: Vec::new(),
2106 }),
2107 UnusedDependencyFinding::with_actions(UnusedDependency {
2108 package_name: "lodash-es".to_string(),
2109 location: DependencyLocation::Dependencies,
2110 path: PathBuf::from("/repo/packages/app-b/package.json"),
2111 line: 5,
2112 used_in_workspaces: Vec::new(),
2113 }),
2114 ],
2115 ..Default::default()
2116 };
2117
2118 let baseline = BaselineData::from_results(&results, root);
2119
2120 assert_eq!(
2121 baseline.unused_dependencies,
2122 vec![
2123 "packages/app-a/package.json:lodash-es",
2124 "packages/app-b/package.json:lodash-es"
2125 ]
2126 );
2127 }
2128
2129 #[test]
2130 fn dependency_baseline_filter_matches_path_before_package_name() {
2131 let root = Path::new("/repo");
2132 let results = AnalysisResults {
2133 unused_dependencies: vec![
2134 UnusedDependencyFinding::with_actions(UnusedDependency {
2135 package_name: "lodash-es".to_string(),
2136 location: DependencyLocation::Dependencies,
2137 path: PathBuf::from("/repo/packages/app-a/package.json"),
2138 line: 5,
2139 used_in_workspaces: Vec::new(),
2140 }),
2141 UnusedDependencyFinding::with_actions(UnusedDependency {
2142 package_name: "lodash-es".to_string(),
2143 location: DependencyLocation::Dependencies,
2144 path: PathBuf::from("/repo/packages/app-b/package.json"),
2145 line: 5,
2146 used_in_workspaces: Vec::new(),
2147 }),
2148 ],
2149 ..Default::default()
2150 };
2151 let baseline = BaselineData {
2152 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2153 ..BaselineData::from_results(&AnalysisResults::default(), root)
2154 };
2155
2156 let filtered = filter_new_issues(results, &baseline, root);
2157
2158 assert_eq!(filtered.unused_dependencies.len(), 1);
2159 assert_eq!(
2160 filtered.unused_dependencies[0].dep.path,
2161 PathBuf::from("/repo/packages/app-b/package.json")
2162 );
2163 }
2164
2165 #[test]
2166 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2167 let root = Path::new("/repo");
2168 let results = AnalysisResults {
2169 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2170 package_name: "lodash-es".to_string(),
2171 location: DependencyLocation::Dependencies,
2172 path: PathBuf::from("/repo/packages/app/package.json"),
2173 line: 5,
2174 used_in_workspaces: Vec::new(),
2175 })],
2176 ..Default::default()
2177 };
2178 let baseline = BaselineData {
2179 unused_dependencies: vec!["lodash-es".to_string()],
2180 ..BaselineData::from_results(&AnalysisResults::default(), root)
2181 };
2182
2183 let filtered = filter_new_issues(results, &baseline, root);
2184
2185 assert!(filtered.unused_dependencies.is_empty());
2186 }
2187
2188 #[test]
2189 fn baseline_serialization_roundtrip() {
2190 let results = make_results();
2191 let baseline = BaselineData::from_results(&results, Path::new(""));
2192 let json = serde_json::to_string(&baseline).unwrap();
2193 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2194 assert_eq!(deserialized.unused_files, baseline.unused_files);
2195 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2196 assert_eq!(deserialized.unused_types, baseline.unused_types);
2197 assert_eq!(
2198 deserialized.unused_dependencies,
2199 baseline.unused_dependencies
2200 );
2201 assert_eq!(
2202 deserialized.unused_dev_dependencies,
2203 baseline.unused_dev_dependencies
2204 );
2205 }
2206
2207 #[test]
2208 fn filter_removes_baseline_issues() {
2209 let results = make_results();
2210 let baseline = BaselineData::from_results(&results, Path::new(""));
2211 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2212 assert!(
2213 filtered.unused_files.is_empty(),
2214 "all files were in baseline"
2215 );
2216 assert!(
2217 filtered.unused_exports.is_empty(),
2218 "all exports were in baseline"
2219 );
2220 assert!(
2221 filtered.unused_types.is_empty(),
2222 "all types were in baseline"
2223 );
2224 assert!(
2225 filtered.unused_dependencies.is_empty(),
2226 "all deps were in baseline"
2227 );
2228 assert!(
2229 filtered.unused_dev_dependencies.is_empty(),
2230 "all dev deps were in baseline"
2231 );
2232 }
2233
2234 #[test]
2235 fn filter_keeps_new_issues_not_in_baseline() {
2236 let baseline = BaselineData {
2237 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2238 unused_files: vec!["src/old.ts".to_string()],
2239 unused_exports: vec![],
2240 unused_types: vec![],
2241 private_type_leaks: vec![],
2242 unused_dependencies: vec![],
2243 unused_dev_dependencies: vec![],
2244 circular_dependencies: vec![],
2245 re_export_cycles: vec![],
2246 unused_optional_dependencies: vec![],
2247 unused_enum_members: vec![],
2248 unused_class_members: vec![],
2249 unused_store_members: vec![],
2250 unprovided_injects: vec![],
2251 unrendered_components: vec![],
2252 unused_component_props: vec![],
2253 unused_component_emits: vec![],
2254 unused_component_inputs: vec![],
2255 unused_component_outputs: vec![],
2256 unused_svelte_events: vec![],
2257 unused_server_actions: vec![],
2258 unused_load_data_keys: vec![],
2259 unresolved_imports: vec![],
2260 unlisted_dependencies: vec![],
2261 duplicate_exports: vec![],
2262 type_only_dependencies: vec![],
2263 test_only_dependencies: vec![],
2264 dev_dependencies_in_production: vec![],
2265 boundary_violations: vec![],
2266 boundary_coverage_violations: vec![],
2267 boundary_call_violations: vec![],
2268 policy_violations: vec![],
2269 stale_suppressions: vec![],
2270 unused_catalog_entries: vec![],
2271 empty_catalog_groups: vec![],
2272 unresolved_catalog_references: vec![],
2273 unused_dependency_overrides: vec![],
2274 misconfigured_dependency_overrides: vec![],
2275 invalid_client_exports: vec![],
2276 mixed_client_server_barrels: vec![],
2277 misplaced_directives: vec![],
2278 route_collisions: vec![],
2279 dynamic_segment_name_conflicts: vec![],
2280 };
2281 let results = AnalysisResults {
2282 unused_files: vec![
2283 UnusedFileFinding::with_actions(UnusedFile {
2284 path: PathBuf::from("src/old.ts"),
2285 }),
2286 UnusedFileFinding::with_actions(UnusedFile {
2287 path: PathBuf::from("src/new-dead.ts"),
2288 }),
2289 ],
2290 ..Default::default()
2291 };
2292 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2293 assert_eq!(filtered.unused_files.len(), 1);
2294 assert_eq!(
2295 filtered.unused_files[0].file.path,
2296 PathBuf::from("src/new-dead.ts")
2297 );
2298 }
2299
2300 #[test]
2301 fn filter_with_empty_baseline_keeps_all() {
2302 let baseline = BaselineData {
2303 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2304 unused_files: vec![],
2305 unused_exports: vec![],
2306 unused_types: vec![],
2307 private_type_leaks: vec![],
2308 unused_dependencies: vec![],
2309 unused_dev_dependencies: vec![],
2310 circular_dependencies: vec![],
2311 re_export_cycles: vec![],
2312 unused_optional_dependencies: vec![],
2313 unused_enum_members: vec![],
2314 unused_class_members: vec![],
2315 unused_store_members: vec![],
2316 unprovided_injects: vec![],
2317 unrendered_components: vec![],
2318 unused_component_props: vec![],
2319 unused_component_emits: vec![],
2320 unused_component_inputs: vec![],
2321 unused_component_outputs: vec![],
2322 unused_svelte_events: vec![],
2323 unused_server_actions: vec![],
2324 unused_load_data_keys: vec![],
2325 unresolved_imports: vec![],
2326 unlisted_dependencies: vec![],
2327 duplicate_exports: vec![],
2328 type_only_dependencies: vec![],
2329 test_only_dependencies: vec![],
2330 dev_dependencies_in_production: vec![],
2331 boundary_violations: vec![],
2332 boundary_coverage_violations: vec![],
2333 boundary_call_violations: vec![],
2334 policy_violations: vec![],
2335 stale_suppressions: vec![],
2336 unused_catalog_entries: vec![],
2337 empty_catalog_groups: vec![],
2338 unresolved_catalog_references: vec![],
2339 unused_dependency_overrides: vec![],
2340 misconfigured_dependency_overrides: vec![],
2341 invalid_client_exports: vec![],
2342 mixed_client_server_barrels: vec![],
2343 misplaced_directives: vec![],
2344 route_collisions: vec![],
2345 dynamic_segment_name_conflicts: vec![],
2346 };
2347 let results = make_results();
2348 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2349 assert_eq!(filtered.unused_files.len(), 2);
2350 assert_eq!(filtered.unused_exports.len(), 1);
2351 }
2352
2353 #[test]
2354 fn filter_new_exports_by_file_and_name() {
2355 let baseline = BaselineData {
2356 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2357 unused_files: vec![],
2358 unused_exports: vec!["src/utils.ts:helperA".to_string()],
2359 unused_types: vec![],
2360 private_type_leaks: vec![],
2361 unused_dependencies: vec![],
2362 unused_dev_dependencies: vec![],
2363 circular_dependencies: vec![],
2364 re_export_cycles: vec![],
2365 unused_optional_dependencies: vec![],
2366 unused_enum_members: vec![],
2367 unused_class_members: vec![],
2368 unused_store_members: vec![],
2369 unprovided_injects: vec![],
2370 unrendered_components: vec![],
2371 unused_component_props: vec![],
2372 unused_component_emits: vec![],
2373 unused_component_inputs: vec![],
2374 unused_component_outputs: vec![],
2375 unused_svelte_events: vec![],
2376 unused_server_actions: vec![],
2377 unused_load_data_keys: vec![],
2378 unresolved_imports: vec![],
2379 unlisted_dependencies: vec![],
2380 duplicate_exports: vec![],
2381 type_only_dependencies: vec![],
2382 test_only_dependencies: vec![],
2383 dev_dependencies_in_production: vec![],
2384 boundary_violations: vec![],
2385 boundary_coverage_violations: vec![],
2386 boundary_call_violations: vec![],
2387 policy_violations: vec![],
2388 stale_suppressions: vec![],
2389 unused_catalog_entries: vec![],
2390 empty_catalog_groups: vec![],
2391 unresolved_catalog_references: vec![],
2392 unused_dependency_overrides: vec![],
2393 misconfigured_dependency_overrides: vec![],
2394 invalid_client_exports: vec![],
2395 mixed_client_server_barrels: vec![],
2396 misplaced_directives: vec![],
2397 route_collisions: vec![],
2398 dynamic_segment_name_conflicts: vec![],
2399 };
2400 let results = AnalysisResults {
2401 unused_exports: vec![
2402 UnusedExportFinding::with_actions(UnusedExport {
2403 path: PathBuf::from("src/utils.ts"),
2404 export_name: "helperA".to_string(),
2405 is_type_only: false,
2406 line: 5,
2407 col: 0,
2408 span_start: 40,
2409 is_re_export: false,
2410 }),
2411 UnusedExportFinding::with_actions(UnusedExport {
2412 path: PathBuf::from("src/utils.ts"),
2413 export_name: "helperB".to_string(),
2414 is_type_only: false,
2415 line: 10,
2416 col: 0,
2417 span_start: 80,
2418 is_re_export: false,
2419 }),
2420 ],
2421 ..Default::default()
2422 };
2423 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2424 assert_eq!(filtered.unused_exports.len(), 1);
2425 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2426 }
2427
2428 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2429 CloneGroup {
2430 instances: instances
2431 .into_iter()
2432 .map(|(file, start, end)| CloneInstance {
2433 file: PathBuf::from(file),
2434 start_line: start,
2435 end_line: end,
2436 start_col: 0,
2437 end_col: 0,
2438 fragment: String::new(),
2439 })
2440 .collect(),
2441 token_count: 50,
2442 line_count: 10,
2443 }
2444 }
2445
2446 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
2447 DuplicationReport {
2448 clone_groups: groups,
2449 clone_families: vec![],
2450 mirrored_directories: vec![],
2451 stats: DuplicationStats {
2452 total_files: 10,
2453 files_with_clones: 2,
2454 total_lines: 1000,
2455 duplicated_lines: 100,
2456 total_tokens: 5000,
2457 duplicated_tokens: 500,
2458 clone_groups: 1,
2459 clone_instances: 2,
2460 duplication_percentage: 10.0,
2461 clone_groups_below_min_occurrences: 0,
2462 },
2463 }
2464 }
2465
2466 #[test]
2467 fn clone_group_key_is_deterministic() {
2468 let root = Path::new("/project");
2469 let group = make_clone_group(vec![
2470 ("/project/src/a.ts", 1, 10),
2471 ("/project/src/b.ts", 5, 15),
2472 ]);
2473 let key1 = clone_group_key(&group, root);
2474 let key2 = clone_group_key(&group, root);
2475 assert_eq!(key1, key2);
2476 }
2477
2478 #[test]
2479 fn clone_group_key_is_sorted() {
2480 let root = Path::new("/project");
2481 let group_ab = make_clone_group(vec![
2482 ("/project/src/a.ts", 1, 10),
2483 ("/project/src/b.ts", 5, 15),
2484 ]);
2485 let group_ba = make_clone_group(vec![
2486 ("/project/src/b.ts", 5, 15),
2487 ("/project/src/a.ts", 1, 10),
2488 ]);
2489 assert_eq!(
2490 clone_group_key(&group_ab, root),
2491 clone_group_key(&group_ba, root),
2492 "key should be stable regardless of instance order"
2493 );
2494 }
2495
2496 #[test]
2497 fn duplication_baseline_roundtrip() {
2498 let root = Path::new("/project");
2499 let group = make_clone_group(vec![
2500 ("/project/src/a.ts", 1, 10),
2501 ("/project/src/b.ts", 5, 15),
2502 ]);
2503 let report = make_duplication_report(vec![group]);
2504 let baseline = DuplicationBaselineData::from_report(&report, root);
2505 let json = serde_json::to_string(&baseline).unwrap();
2506 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2507 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2508 }
2509
2510 #[test]
2511 fn filter_new_clone_groups_removes_baseline() {
2512 let root = Path::new("/project");
2513 let group = make_clone_group(vec![
2514 ("/project/src/a.ts", 1, 10),
2515 ("/project/src/b.ts", 5, 15),
2516 ]);
2517 let report = make_duplication_report(vec![group]);
2518 let baseline = DuplicationBaselineData::from_report(&report, root);
2519 let filtered = filter_new_clone_groups(report, &baseline, root);
2520 assert!(
2521 filtered.clone_groups.is_empty(),
2522 "baseline group should be filtered out"
2523 );
2524 }
2525
2526 #[test]
2527 fn filter_new_clone_groups_keeps_new_groups() {
2528 let root = Path::new("/project");
2529 let baseline_group = make_clone_group(vec![
2530 ("/project/src/a.ts", 1, 10),
2531 ("/project/src/b.ts", 5, 15),
2532 ]);
2533 let new_group = make_clone_group(vec![
2534 ("/project/src/c.ts", 20, 30),
2535 ("/project/src/d.ts", 25, 35),
2536 ]);
2537 let baseline_report = make_duplication_report(vec![baseline_group]);
2538 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2539
2540 let report = make_duplication_report(vec![
2541 make_clone_group(vec![
2542 ("/project/src/a.ts", 1, 10),
2543 ("/project/src/b.ts", 5, 15),
2544 ]),
2545 new_group,
2546 ]);
2547 let filtered = filter_new_clone_groups(report, &baseline, root);
2548 assert_eq!(
2549 filtered.clone_groups.len(),
2550 1,
2551 "only the new group should remain"
2552 );
2553 }
2554
2555 #[test]
2556 fn recompute_stats_after_filtering() {
2557 let root = Path::new("/project");
2558 let group = make_clone_group(vec![
2559 ("/project/src/a.ts", 1, 10),
2560 ("/project/src/b.ts", 5, 15),
2561 ]);
2562 let report = make_duplication_report(vec![group]);
2563 let baseline = DuplicationBaselineData::from_report(&report, root);
2564 let filtered = filter_new_clone_groups(report, &baseline, root);
2565 assert_eq!(filtered.stats.clone_groups, 0);
2566 assert_eq!(filtered.stats.clone_instances, 0);
2567 assert_eq!(filtered.stats.duplicated_lines, 0);
2568 }
2569
2570 #[test]
2571 fn recompute_stats_zero_total_lines() {
2572 let report = DuplicationReport {
2573 clone_groups: vec![],
2574 clone_families: vec![],
2575 mirrored_directories: vec![],
2576 stats: DuplicationStats {
2577 total_files: 0,
2578 files_with_clones: 0,
2579 total_lines: 0,
2580 duplicated_lines: 0,
2581 total_tokens: 0,
2582 duplicated_tokens: 0,
2583 clone_groups: 0,
2584 clone_instances: 0,
2585 duplication_percentage: 0.0,
2586 clone_groups_below_min_occurrences: 0,
2587 },
2588 };
2589 let stats = super::recompute_stats(&report);
2590 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
2591 }
2592
2593 fn make_health_finding(
2594 root: &Path,
2595 name: &str,
2596 line: u32,
2597 ) -> fallow_output::ComplexityViolation {
2598 make_health_finding_with(
2599 root,
2600 name,
2601 line,
2602 fallow_output::ExceededThreshold::Both,
2603 fallow_output::FindingSeverity::High,
2604 )
2605 }
2606
2607 fn make_health_finding_with(
2608 root: &Path,
2609 name: &str,
2610 line: u32,
2611 exceeded: fallow_output::ExceededThreshold,
2612 severity: fallow_output::FindingSeverity,
2613 ) -> fallow_output::ComplexityViolation {
2614 fallow_output::ComplexityViolation {
2615 path: root.join("src/utils.ts"),
2616 name: name.to_string(),
2617 line,
2618 col: 0,
2619 cyclomatic: 25,
2620 cognitive: 30,
2621 line_count: 80,
2622 param_count: 0,
2623 react_hook_count: 0,
2624 react_jsx_max_depth: 0,
2625 react_prop_count: 0,
2626 react_hook_profile: None,
2627 exceeded,
2628 severity,
2629 crap: None,
2630 coverage_pct: None,
2631 coverage_tier: None,
2632 coverage_source: None,
2633 inherited_from: None,
2634 component_rollup: None,
2635 contributions: Vec::new(),
2636 effective_thresholds: None,
2637 threshold_source: None,
2638 }
2639 }
2640
2641 #[test]
2642 fn health_baseline_roundtrip() {
2643 let root = PathBuf::from("/project");
2644 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2645 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
2646 let json = serde_json::to_string(&baseline).unwrap();
2647 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
2648 assert_eq!(deserialized.findings, baseline.findings);
2649 assert_eq!(baseline.findings, Vec::<String>::new());
2650 assert_eq!(
2651 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
2652 1
2653 );
2654 assert!(!json.contains("parseExpression"));
2655 }
2656
2657 #[test]
2658 fn health_baseline_filters_known_findings() {
2659 let root = PathBuf::from("/project");
2660 let mut findings = vec![
2661 make_health_finding(&root, "parseExpression", 42),
2662 make_health_finding(&root, "newFunction", 100),
2663 ];
2664 findings[1].path = root.join("src/other.ts");
2665 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
2666 let filtered = filter_new_health_findings(findings, &baseline, &root);
2667 assert_eq!(filtered.len(), 1);
2668 assert_eq!(filtered[0].name, "newFunction");
2669 }
2670
2671 #[test]
2672 fn health_baseline_filters_shifted_lines_with_same_category_count() {
2673 let root = PathBuf::from("/project");
2674 let baseline = HealthBaselineData::from_findings(
2675 &[make_health_finding(&root, "parseExpression", 42)],
2676 &[],
2677 &[],
2678 &root,
2679 );
2680 let filtered = filter_new_health_findings(
2681 vec![make_health_finding(&root, "parseExpression", 43)],
2682 &baseline,
2683 &root,
2684 );
2685 assert!(filtered.is_empty());
2686 }
2687
2688 #[test]
2689 fn health_baseline_reports_full_category_when_count_increases() {
2690 let root = PathBuf::from("/project");
2691 let baseline = HealthBaselineData::from_findings(
2692 &[make_health_finding(&root, "parseExpression", 42)],
2693 &[],
2694 &[],
2695 &root,
2696 );
2697 let filtered = filter_new_health_findings(
2698 vec![
2699 make_health_finding(&root, "parseExpression", 43),
2700 make_health_finding(&root, "newFunction", 100),
2701 ],
2702 &baseline,
2703 &root,
2704 );
2705 assert_eq!(filtered.len(), 2);
2706 }
2707
2708 #[test]
2709 fn health_baseline_legacy_findings_still_load() {
2710 let root = PathBuf::from("/project");
2711 let baseline = HealthBaselineData {
2712 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
2713 finding_counts: BTreeMap::new(),
2714 target_keys: vec![],
2715 runtime_coverage_findings: vec![],
2716 runtime_coverage_source_hashes: vec![],
2717 };
2718 let filtered = filter_new_health_findings(
2719 vec![make_health_finding(&root, "parseExpression", 42)],
2720 &baseline,
2721 &root,
2722 );
2723 assert!(filtered.is_empty());
2724 }
2725
2726 #[test]
2727 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
2728 let root = PathBuf::from("/project");
2729 let baseline = HealthBaselineData::from_findings(
2730 &[make_health_finding_with(
2731 &root,
2732 "parseExpression",
2733 42,
2734 fallow_output::ExceededThreshold::Crap,
2735 fallow_output::FindingSeverity::High,
2736 )],
2737 &[],
2738 &[],
2739 &root,
2740 );
2741 let filtered = filter_new_health_findings(
2742 vec![
2743 make_health_finding_with(
2744 &root,
2745 "parseExpression",
2746 43,
2747 fallow_output::ExceededThreshold::Crap,
2748 fallow_output::FindingSeverity::High,
2749 ),
2750 make_health_finding(&root, "newComplexityOnlyFunction", 100),
2751 ],
2752 &baseline,
2753 &root,
2754 );
2755 assert_eq!(filtered.len(), 1);
2756 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
2757 }
2758
2759 #[test]
2760 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
2761 let root = PathBuf::from("/project");
2762 let baseline = HealthBaselineData::from_findings(
2763 &[make_health_finding_with(
2764 &root,
2765 "parseExpression",
2766 42,
2767 fallow_output::ExceededThreshold::Both,
2768 fallow_output::FindingSeverity::Critical,
2769 )],
2770 &[],
2771 &[],
2772 &root,
2773 );
2774 let filtered = filter_new_health_findings(
2775 vec![make_health_finding_with(
2776 &root,
2777 "parseExpression",
2778 42,
2779 fallow_output::ExceededThreshold::Both,
2780 fallow_output::FindingSeverity::High,
2781 )],
2782 &baseline,
2783 &root,
2784 );
2785 assert!(filtered.is_empty());
2786 }
2787
2788 #[test]
2789 fn health_baseline_still_reports_worse_current_severity_as_new() {
2790 let root = PathBuf::from("/project");
2791 let baseline = HealthBaselineData::from_findings(
2792 &[make_health_finding_with(
2793 &root,
2794 "parseExpression",
2795 42,
2796 fallow_output::ExceededThreshold::Both,
2797 fallow_output::FindingSeverity::High,
2798 )],
2799 &[],
2800 &[],
2801 &root,
2802 );
2803 let filtered = filter_new_health_findings(
2804 vec![make_health_finding_with(
2805 &root,
2806 "parseExpression",
2807 42,
2808 fallow_output::ExceededThreshold::Both,
2809 fallow_output::FindingSeverity::Critical,
2810 )],
2811 &baseline,
2812 &root,
2813 );
2814 assert_eq!(filtered.len(), 1);
2815 assert_eq!(filtered[0].name, "parseExpression");
2816 assert!(matches!(
2817 filtered[0].severity,
2818 fallow_output::FindingSeverity::Critical
2819 ));
2820 }
2821
2822 #[test]
2823 fn health_baseline_overlap_counts_partial_category_overflow() {
2824 let root = PathBuf::from("/project");
2825 let baseline = HealthBaselineData::from_findings(
2826 &[make_health_finding(&root, "parseExpression", 42)],
2827 &[],
2828 &[],
2829 &root,
2830 );
2831 let overlap = baseline.overlap_entry_count(
2832 &[
2833 make_health_finding(&root, "parseExpression", 42),
2834 make_health_finding(&root, "newFunction", 100),
2835 ],
2836 &root,
2837 );
2838 assert_eq!(overlap, 1);
2839 }
2840
2841 #[test]
2842 fn health_baseline_empty_keeps_all() {
2843 let root = PathBuf::from("/project");
2844 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2845 let baseline = HealthBaselineData {
2846 findings: vec![],
2847 finding_counts: BTreeMap::new(),
2848 target_keys: vec![],
2849 runtime_coverage_findings: vec![],
2850 runtime_coverage_source_hashes: vec![],
2851 };
2852 let filtered = filter_new_health_findings(findings, &baseline, &root);
2853 assert_eq!(filtered.len(), 1);
2854 }
2855
2856 #[test]
2857 fn circular_dep_key_is_order_independent() {
2858 use crate::results::CircularDependency;
2859
2860 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
2861 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2862 length: 2,
2863 line: 1,
2864 col: 0,
2865 edges: Vec::new(),
2866 is_cross_package: false,
2867 });
2868 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
2869 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
2870 length: 2,
2871 line: 1,
2872 col: 0,
2873 edges: Vec::new(),
2874 is_cross_package: false,
2875 });
2876 assert_eq!(
2877 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
2878 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
2879 "same files in different order should produce identical keys"
2880 );
2881 }
2882
2883 #[test]
2884 fn circular_dep_key_different_files_different_keys() {
2885 use crate::results::CircularDependency;
2886
2887 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
2888 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2889 length: 2,
2890 line: 1,
2891 col: 0,
2892 edges: Vec::new(),
2893 is_cross_package: false,
2894 });
2895 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
2896 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
2897 length: 2,
2898 line: 1,
2899 col: 0,
2900 edges: Vec::new(),
2901 is_cross_package: false,
2902 });
2903 assert_ne!(
2904 super::circular_dep_key(&dep1.cycle, Path::new("")),
2905 super::circular_dep_key(&dep2.cycle, Path::new("")),
2906 );
2907 }
2908
2909 #[test]
2910 fn circular_dep_key_three_files_order_independent() {
2911 use crate::results::CircularDependency;
2912
2913 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
2914 files: vec![
2915 PathBuf::from("src/a.ts"),
2916 PathBuf::from("src/b.ts"),
2917 PathBuf::from("src/c.ts"),
2918 ],
2919 length: 3,
2920 line: 1,
2921 col: 0,
2922 edges: Vec::new(),
2923 is_cross_package: false,
2924 });
2925 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
2926 files: vec![
2927 PathBuf::from("src/c.ts"),
2928 PathBuf::from("src/a.ts"),
2929 PathBuf::from("src/b.ts"),
2930 ],
2931 length: 3,
2932 line: 1,
2933 col: 0,
2934 edges: Vec::new(),
2935 is_cross_package: false,
2936 });
2937 assert_eq!(
2938 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
2939 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
2940 );
2941 }
2942
2943 #[expect(
2944 clippy::too_many_lines,
2945 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2946 )]
2947 fn make_full_results() -> AnalysisResults {
2948 use crate::results::*;
2949 use crate::source::MemberKind;
2950
2951 let mut r = make_results();
2952 r.circular_dependencies
2953 .push(CircularDependencyFinding::with_actions(
2954 CircularDependency {
2955 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2956 length: 2,
2957 line: 1,
2958 col: 0,
2959 edges: Vec::new(),
2960 is_cross_package: false,
2961 },
2962 ));
2963 r.unused_optional_dependencies
2964 .push(UnusedOptionalDependencyFinding::with_actions(
2965 UnusedDependency {
2966 package_name: "fsevents".to_string(),
2967 location: DependencyLocation::OptionalDependencies,
2968 path: PathBuf::from("package.json"),
2969 line: 15,
2970 used_in_workspaces: Vec::new(),
2971 },
2972 ));
2973 r.unused_enum_members
2974 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2975 path: PathBuf::from("src/enums.ts"),
2976 parent_name: "Status".to_string(),
2977 member_name: "Deprecated".to_string(),
2978 kind: MemberKind::EnumMember,
2979 line: 8,
2980 col: 0,
2981 }));
2982 r.unused_class_members
2983 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2984 path: PathBuf::from("src/service.ts"),
2985 parent_name: "UserService".to_string(),
2986 member_name: "legacy".to_string(),
2987 kind: MemberKind::ClassMethod,
2988 line: 42,
2989 col: 0,
2990 }));
2991 r.unused_store_members
2992 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2993 path: PathBuf::from("src/store.ts"),
2994 parent_name: "useStore".to_string(),
2995 member_name: "legacyAction".to_string(),
2996 kind: MemberKind::StoreMember,
2997 line: 17,
2998 col: 0,
2999 }));
3000 r.unresolved_imports.push(
3001 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
3002 crate::results::UnresolvedImport {
3003 path: PathBuf::from("src/app.ts"),
3004 specifier: "./missing".to_string(),
3005 line: 3,
3006 col: 0,
3007 specifier_col: 0,
3008 },
3009 ),
3010 );
3011 r.unlisted_dependencies
3012 .push(crate::results::UnlistedDependencyFinding::with_actions(
3013 UnlistedDependency {
3014 package_name: "chalk".to_string(),
3015 imported_from: vec![],
3016 },
3017 ));
3018 r.duplicate_exports
3019 .push(crate::results::DuplicateExportFinding::with_actions(
3020 crate::results::DuplicateExport {
3021 export_name: "Config".to_string(),
3022 locations: vec![
3023 crate::results::DuplicateLocation {
3024 path: PathBuf::from("src/a.ts"),
3025 line: 1,
3026 col: 0,
3027 },
3028 crate::results::DuplicateLocation {
3029 path: PathBuf::from("src/b.ts"),
3030 line: 5,
3031 col: 0,
3032 },
3033 ],
3034 },
3035 ));
3036 r.type_only_dependencies
3037 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3038 TypeOnlyDependency {
3039 package_name: "zod".to_string(),
3040 path: PathBuf::from("package.json"),
3041 line: 8,
3042 },
3043 ));
3044 r.test_only_dependencies
3045 .push(crate::results::TestOnlyDependencyFinding::with_actions(
3046 TestOnlyDependency {
3047 package_name: "vitest".to_string(),
3048 path: PathBuf::from("package.json"),
3049 line: 10,
3050 },
3051 ));
3052 r.boundary_violations.push(
3053 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3054 crate::results::BoundaryViolation {
3055 from_path: PathBuf::from("src/ui/btn.ts"),
3056 to_path: PathBuf::from("src/db/query.ts"),
3057 from_zone: "ui".to_string(),
3058 to_zone: "db".to_string(),
3059 import_specifier: "../db/query".to_string(),
3060 line: 1,
3061 col: 0,
3062 },
3063 ),
3064 );
3065 r
3066 }
3067
3068 #[test]
3069 fn baseline_from_results_captures_all_extended_fields() {
3070 let results = make_full_results();
3071 let baseline = BaselineData::from_results(&results, Path::new(""));
3072 assert_eq!(baseline.circular_dependencies.len(), 1);
3073 assert_eq!(
3074 baseline.unused_optional_dependencies,
3075 vec!["package.json:fsevents"]
3076 );
3077 assert_eq!(baseline.unused_enum_members.len(), 1);
3078 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3079 assert_eq!(baseline.unused_class_members.len(), 1);
3080 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3081 assert_eq!(baseline.unused_store_members.len(), 1);
3082 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3083 assert_eq!(baseline.unresolved_imports.len(), 1);
3084 assert!(baseline.unresolved_imports[0].contains("./missing"));
3085 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3086 assert_eq!(baseline.duplicate_exports.len(), 1);
3087 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3088 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3089 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3090 assert_eq!(baseline.boundary_violations.len(), 1);
3091 assert!(baseline.boundary_violations[0].contains("->"));
3092 }
3093
3094 #[test]
3095 fn filter_removes_all_extended_baseline_issues() {
3096 let results = make_full_results();
3097 let baseline = BaselineData::from_results(&results, Path::new(""));
3098 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3099 assert!(filtered.circular_dependencies.is_empty());
3100 assert!(filtered.unused_optional_dependencies.is_empty());
3101 assert!(filtered.unused_enum_members.is_empty());
3102 assert!(filtered.unused_class_members.is_empty());
3103 assert!(filtered.unused_store_members.is_empty());
3104 assert!(filtered.unresolved_imports.is_empty());
3105 assert!(filtered.unlisted_dependencies.is_empty());
3106 assert!(filtered.duplicate_exports.is_empty());
3107 assert!(filtered.type_only_dependencies.is_empty());
3108 assert!(filtered.test_only_dependencies.is_empty());
3109 assert!(filtered.boundary_violations.is_empty());
3110 }
3111
3112 #[test]
3113 fn filter_keeps_new_circular_deps() {
3114 use crate::results::CircularDependency;
3115 let baseline = BaselineData {
3116 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3117 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3118 };
3119 let mut results = AnalysisResults::default();
3120 results
3121 .circular_dependencies
3122 .push(CircularDependencyFinding::with_actions(
3123 CircularDependency {
3124 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3125 length: 2,
3126 line: 1,
3127 col: 0,
3128 edges: Vec::new(),
3129 is_cross_package: false,
3130 },
3131 ));
3132 results
3133 .circular_dependencies
3134 .push(CircularDependencyFinding::with_actions(
3135 CircularDependency {
3136 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3137 length: 2,
3138 line: 5,
3139 col: 0,
3140 edges: Vec::new(),
3141 is_cross_package: false,
3142 },
3143 ));
3144 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3145 assert_eq!(filtered.circular_dependencies.len(), 1);
3146 }
3147
3148 #[test]
3149 fn filter_keeps_new_boundary_violations() {
3150 use crate::results::BoundaryViolation;
3151 let baseline = BaselineData {
3152 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3153 boundary_coverage_violations: vec![],
3154 boundary_call_violations: vec![],
3155 policy_violations: vec![],
3156 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3157 };
3158 let mut results = AnalysisResults::default();
3159 results
3160 .boundary_violations
3161 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3162 from_path: PathBuf::from("src/a.ts"),
3163 to_path: PathBuf::from("src/b.ts"),
3164 from_zone: "a".to_string(),
3165 to_zone: "b".to_string(),
3166 import_specifier: "../b".to_string(),
3167 line: 1,
3168 col: 0,
3169 }));
3170 results
3171 .boundary_violations
3172 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3173 from_path: PathBuf::from("src/new.ts"),
3174 to_path: PathBuf::from("src/secret.ts"),
3175 from_zone: "new".to_string(),
3176 to_zone: "secret".to_string(),
3177 import_specifier: "../secret".to_string(),
3178 line: 1,
3179 col: 0,
3180 }));
3181 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3182 assert_eq!(filtered.boundary_violations.len(), 1);
3183 }
3184
3185 #[test]
3186 fn health_targets_baseline_filters_known() {
3187 let root = PathBuf::from("/project");
3188 let targets = vec![
3189 fallow_output::RefactoringTarget {
3190 path: root.join("src/complex.ts"),
3191 priority: 80.0,
3192 efficiency: 40.0,
3193 recommendation: "Split file".to_string(),
3194 category: fallow_output::RecommendationCategory::SplitHighImpact,
3195 effort: fallow_output::EffortEstimate::Medium,
3196 confidence: fallow_output::Confidence::Medium,
3197 factors: vec![],
3198 evidence: None,
3199 },
3200 fallow_output::RefactoringTarget {
3201 path: root.join("src/new-issue.ts"),
3202 priority: 60.0,
3203 efficiency: 30.0,
3204 recommendation: "Extract function".to_string(),
3205 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3206 effort: fallow_output::EffortEstimate::Low,
3207 confidence: fallow_output::Confidence::High,
3208 factors: vec![],
3209 evidence: None,
3210 },
3211 ];
3212 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3213 let filtered = filter_new_health_targets(targets, &baseline, &root);
3214 assert_eq!(filtered.len(), 1);
3215 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3216 }
3217
3218 #[test]
3219 fn duplicate_export_key_is_sorted() {
3220 use crate::results::{DuplicateExport, DuplicateLocation};
3221 let dup_ab = DuplicateExport {
3222 export_name: "foo".to_string(),
3223 locations: vec![
3224 DuplicateLocation {
3225 path: PathBuf::from("src/a.ts"),
3226 line: 1,
3227 col: 0,
3228 },
3229 DuplicateLocation {
3230 path: PathBuf::from("src/b.ts"),
3231 line: 5,
3232 col: 0,
3233 },
3234 ],
3235 };
3236 let dup_ba = DuplicateExport {
3237 export_name: "foo".to_string(),
3238 locations: vec![
3239 DuplicateLocation {
3240 path: PathBuf::from("src/b.ts"),
3241 line: 5,
3242 col: 0,
3243 },
3244 DuplicateLocation {
3245 path: PathBuf::from("src/a.ts"),
3246 line: 1,
3247 col: 0,
3248 },
3249 ],
3250 };
3251 assert_eq!(
3252 super::duplicate_export_key(&dup_ab, Path::new("")),
3253 super::duplicate_export_key(&dup_ba, Path::new("")),
3254 );
3255 }
3256
3257 #[test]
3258 fn boundary_violation_key_format() {
3259 use crate::results::BoundaryViolation;
3260 let v = BoundaryViolation {
3261 from_path: PathBuf::from("src/ui/btn.ts"),
3262 to_path: PathBuf::from("src/db/query.ts"),
3263 from_zone: "ui".to_string(),
3264 to_zone: "db".to_string(),
3265 import_specifier: "../db/query".to_string(),
3266 line: 1,
3267 col: 0,
3268 };
3269 let key = super::boundary_violation_key(&v, Path::new(""));
3270 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
3271 }
3272
3273 fn make_absolute_results(root: &str) -> AnalysisResults {
3275 use crate::results::*;
3276 use crate::source::MemberKind;
3277
3278 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
3279
3280 AnalysisResults {
3281 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3282 path: p("src/old.ts"),
3283 })],
3284 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3285 path: p("src/utils.ts"),
3286 export_name: "helper".to_string(),
3287 is_type_only: false,
3288 line: 5,
3289 col: 0,
3290 span_start: 40,
3291 is_re_export: false,
3292 })],
3293 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3294 package_name: "lodash-es".to_string(),
3295 location: DependencyLocation::Dependencies,
3296 path: p("packages/app/package.json"),
3297 line: 5,
3298 used_in_workspaces: Vec::new(),
3299 })],
3300 circular_dependencies: vec![CircularDependencyFinding::with_actions(
3301 CircularDependency {
3302 files: vec![p("src/a.ts"), p("src/b.ts")],
3303 length: 2,
3304 line: 1,
3305 col: 0,
3306 edges: Vec::new(),
3307 is_cross_package: false,
3308 },
3309 )],
3310 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
3311 path: p("src/enums.ts"),
3312 parent_name: "Status".to_string(),
3313 member_name: "Deprecated".to_string(),
3314 kind: MemberKind::EnumMember,
3315 line: 8,
3316 col: 0,
3317 })],
3318 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
3319 path: p("src/service.ts"),
3320 parent_name: "UserService".to_string(),
3321 member_name: "legacy".to_string(),
3322 kind: MemberKind::ClassMethod,
3323 line: 42,
3324 col: 0,
3325 })],
3326 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
3327 path: p("src/store.ts"),
3328 parent_name: "useStore".to_string(),
3329 member_name: "legacyAction".to_string(),
3330 kind: MemberKind::StoreMember,
3331 line: 17,
3332 col: 0,
3333 })],
3334 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
3335 path: p("src/app.ts"),
3336 specifier: "./missing".to_string(),
3337 line: 3,
3338 col: 0,
3339 specifier_col: 0,
3340 })],
3341 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
3342 export_name: "Config".to_string(),
3343 locations: vec![
3344 DuplicateLocation {
3345 path: p("src/a.ts"),
3346 line: 1,
3347 col: 0,
3348 },
3349 DuplicateLocation {
3350 path: p("src/b.ts"),
3351 line: 5,
3352 col: 0,
3353 },
3354 ],
3355 })],
3356 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
3357 from_path: p("src/ui/btn.ts"),
3358 to_path: p("src/db/query.ts"),
3359 from_zone: "ui".to_string(),
3360 to_zone: "db".to_string(),
3361 import_specifier: "../db/query".to_string(),
3362 line: 1,
3363 col: 0,
3364 })],
3365 ..Default::default()
3366 }
3367 }
3368
3369 #[test]
3372 fn baseline_keys_are_relative_to_root() {
3373 let local_root = Path::new("/Users/dev/project");
3374 let results = make_absolute_results("/Users/dev/project");
3375 let baseline = BaselineData::from_results(&results, local_root);
3376
3377 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
3378 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
3379 assert_eq!(
3380 baseline.unused_dependencies,
3381 vec!["packages/app/package.json:lodash-es"]
3382 );
3383 assert_eq!(
3384 baseline.boundary_violations,
3385 vec!["src/ui/btn.ts->src/db/query.ts"]
3386 );
3387 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
3388 assert_eq!(
3389 baseline.unused_enum_members,
3390 vec!["src/enums.ts:Status.Deprecated"]
3391 );
3392 assert_eq!(
3393 baseline.unused_class_members,
3394 vec!["src/service.ts:UserService.legacy"]
3395 );
3396 assert_eq!(
3397 baseline.unused_store_members,
3398 vec!["src/store.ts:useStore.legacyAction"]
3399 );
3400 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
3401 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
3402
3403 let ci_root = Path::new("/home/runner/work/project/project");
3404 let ci_results = make_absolute_results("/home/runner/work/project/project");
3405
3406 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
3407 assert!(filtered.unused_files.is_empty(), "unused files");
3408 assert!(filtered.unused_exports.is_empty(), "unused exports");
3409 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
3410 assert!(
3411 filtered.boundary_violations.is_empty(),
3412 "boundary violations"
3413 );
3414 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
3415 assert!(filtered.unused_enum_members.is_empty(), "enum members");
3416 assert!(filtered.unused_class_members.is_empty(), "class members");
3417 assert!(filtered.unused_store_members.is_empty(), "store members");
3418 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
3419 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
3420 }
3421
3422 #[test]
3423 fn stale_suppression_baseline_keys_include_missing_reason_state() {
3424 let root = Path::new("/project");
3425 let stale = crate::results::StaleSuppression {
3426 path: root.join("src/file.ts"),
3427 line: 1,
3428 col: 0,
3429 origin: crate::results::SuppressionOrigin::Comment {
3430 issue_kind: Some("unused-export".to_string()),
3431 reason: None,
3432 is_file_level: false,
3433 kind_known: true,
3434 },
3435 missing_reason: false,
3436 actions: crate::results::StaleSuppression::actions_for(false),
3437 };
3438 let missing = crate::results::StaleSuppression {
3439 missing_reason: true,
3440 actions: crate::results::StaleSuppression::actions_for(true),
3441 ..stale.clone()
3442 };
3443 let results = AnalysisResults {
3444 stale_suppressions: vec![stale, missing],
3445 ..Default::default()
3446 };
3447 let baseline = BaselineData::from_results(&results, root);
3448
3449 assert_eq!(
3450 baseline.stale_suppressions,
3451 vec![
3452 "stale-suppression:src/file.ts:1",
3453 "missing-suppression-reason:src/file.ts:1",
3454 ]
3455 );
3456
3457 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
3458 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
3459 let filtered = filter_new_issues(results, &legacy_baseline, root);
3460 assert!(filtered.stale_suppressions.is_empty());
3461 }
3462
3463 fn runtime_finding(
3464 id: &str,
3465 stable_id: Option<&str>,
3466 line: u32,
3467 source_hash: Option<&str>,
3468 ) -> fallow_output::RuntimeCoverageFinding {
3469 fallow_output::RuntimeCoverageFinding {
3470 id: id.to_owned(),
3471 stable_id: stable_id.map(str::to_owned),
3472 source_hash: source_hash.map(str::to_owned),
3473 path: PathBuf::from("src/a.ts"),
3474 function: "alpha".to_owned(),
3475 line,
3476 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
3477 invocations: Some(0),
3478 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
3479 evidence: fallow_output::RuntimeCoverageEvidence {
3480 static_status: "used".to_owned(),
3481 test_coverage: "not_covered".to_owned(),
3482 v8_tracking: "tracked".to_owned(),
3483 untracked_reason: None,
3484 observation_days: 1,
3485 deployments_observed: 1,
3486 },
3487 actions: vec![],
3488 discriminators: None,
3489 }
3490 }
3491
3492 #[test]
3493 fn legacy_prod_baseline_still_suppresses_finding() {
3494 let baseline = HealthBaselineData {
3495 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
3496 ..HealthBaselineData::default()
3497 };
3498 let findings = vec![runtime_finding(
3499 "fallow:prod:deadbeef",
3500 Some("fallow:fn:00000001"),
3501 14,
3502 None,
3503 )];
3504 let filtered =
3505 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3506 assert!(filtered.is_empty(), "legacy prod id must still suppress");
3507 }
3508
3509 #[test]
3510 fn source_hash_baseline_survives_line_move() {
3511 let root = Path::new("/repo");
3512 let baselined = runtime_finding(
3513 "fallow:prod:deadbeef",
3514 Some("fallow:fn:00000001"),
3515 14,
3516 Some("0123456789abcdef"),
3517 );
3518 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
3519 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
3520
3521 let findings = vec![runtime_finding(
3522 "fallow:prod:99999999",
3523 Some("fallow:fn:cafe0002"),
3524 40,
3525 Some("0123456789abcdef"),
3526 )];
3527 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
3528 assert!(
3529 filtered.is_empty(),
3530 "source_hash baseline must survive a line move despite a changed stable_id and id"
3531 );
3532 }
3533
3534 #[test]
3535 fn unbaselined_finding_is_reported() {
3536 let baseline = HealthBaselineData {
3537 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
3538 ..HealthBaselineData::default()
3539 };
3540 let findings = vec![runtime_finding(
3541 "fallow:prod:abc1234d",
3542 Some("fallow:fn:beefcafe"),
3543 7,
3544 None,
3545 )];
3546 let filtered =
3547 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3548 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
3549 }
3550}