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 pub unused_files: Vec<String>,
57 pub unused_exports: Vec<String>,
58 pub unused_types: Vec<String>,
59 #[serde(default)]
60 pub private_type_leaks: Vec<String>,
61 pub unused_dependencies: Vec<String>,
65 pub unused_dev_dependencies: Vec<String>,
69 #[serde(default)]
71 pub circular_dependencies: Vec<String>,
72 #[serde(default)]
77 pub re_export_cycles: Vec<String>,
78 #[serde(default)]
82 pub unused_optional_dependencies: Vec<String>,
83 #[serde(default)]
85 pub unused_enum_members: Vec<String>,
86 #[serde(default)]
88 pub unused_class_members: Vec<String>,
89 #[serde(default)]
91 pub unused_store_members: Vec<String>,
92 #[serde(default)]
94 pub unprovided_injects: Vec<String>,
95 #[serde(default)]
97 pub unrendered_components: Vec<String>,
98 #[serde(default)]
100 pub unused_component_props: Vec<String>,
101 #[serde(default)]
103 pub unused_component_emits: Vec<String>,
104 #[serde(default)]
106 pub unused_component_inputs: Vec<String>,
107 #[serde(default)]
109 pub unused_component_outputs: Vec<String>,
110 #[serde(default)]
112 pub unused_svelte_events: Vec<String>,
113 #[serde(default)]
115 pub unused_server_actions: Vec<String>,
116 #[serde(default)]
118 pub unused_load_data_keys: Vec<String>,
119 #[serde(default)]
121 pub unresolved_imports: Vec<String>,
122 #[serde(default)]
124 pub unlisted_dependencies: Vec<String>,
125 #[serde(default)]
127 pub duplicate_exports: Vec<String>,
128 #[serde(default)]
132 pub type_only_dependencies: Vec<String>,
133 #[serde(default)]
137 pub test_only_dependencies: Vec<String>,
138 #[serde(default)]
140 pub dev_dependencies_in_production: Vec<String>,
141 #[serde(default)]
143 pub boundary_violations: Vec<String>,
144 #[serde(default)]
146 pub boundary_coverage_violations: Vec<String>,
147 #[serde(default)]
149 pub boundary_call_violations: Vec<String>,
150 #[serde(default)]
152 pub policy_violations: Vec<String>,
153 #[serde(default)]
155 pub stale_suppressions: Vec<String>,
156 #[serde(default)]
158 pub unused_catalog_entries: Vec<String>,
159 #[serde(default)]
161 pub empty_catalog_groups: Vec<String>,
162 #[serde(default)]
164 pub unresolved_catalog_references: Vec<String>,
165 #[serde(default)]
167 pub unused_dependency_overrides: Vec<String>,
168 #[serde(default)]
170 pub misconfigured_dependency_overrides: Vec<String>,
171 #[serde(default)]
173 pub invalid_client_exports: Vec<String>,
174 #[serde(default)]
176 pub mixed_client_server_barrels: Vec<String>,
177 #[serde(default)]
180 pub misplaced_directives: Vec<String>,
181 #[serde(default)]
183 pub route_collisions: Vec<String>,
184 #[serde(default)]
186 pub dynamic_segment_name_conflicts: Vec<String>,
187}
188
189impl BaselineData {
190 pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
191 let file_exports = baseline_file_export_keys(results, root);
192 let member_imports = baseline_member_import_keys(results, root);
193 let dependencies = baseline_dependency_keys(results, root);
194 let graph = baseline_graph_keys(results, root);
195 let catalog = baseline_catalog_keys(results, root);
196
197 Self {
198 unused_files: file_exports.unused_files,
199 unused_exports: file_exports.unused_exports,
200 unused_types: file_exports.unused_types,
201 private_type_leaks: file_exports.private_type_leaks,
202 unused_dependencies: dependencies.unused,
203 unused_dev_dependencies: dependencies.unused_dev,
204 circular_dependencies: graph.circular_dependencies,
205 re_export_cycles: graph.re_export_cycles,
206 unused_optional_dependencies: dependencies.unused_optional,
207 unused_enum_members: member_imports.unused_enum_members,
208 unused_class_members: member_imports.unused_class_members,
209 unused_store_members: member_imports.unused_store_members,
210 unprovided_injects: member_imports.unprovided_injects,
211 unrendered_components: member_imports.unrendered_components,
212 unused_component_props: member_imports.unused_component_props,
213 unused_component_emits: member_imports.unused_component_emits,
214 unused_component_inputs: member_imports.unused_component_inputs,
215 unused_component_outputs: member_imports.unused_component_outputs,
216 unused_svelte_events: member_imports.unused_svelte_events,
217 unused_server_actions: member_imports.unused_server_actions,
218 unused_load_data_keys: member_imports.unused_load_data_keys,
219 unresolved_imports: member_imports.unresolved_imports,
220 unlisted_dependencies: dependencies.unlisted,
221 duplicate_exports: member_imports.duplicate_exports,
222 type_only_dependencies: dependencies.type_only,
223 test_only_dependencies: dependencies.test_only,
224 dev_dependencies_in_production: dependencies.dev_in_prod,
225 boundary_violations: graph.boundary_violations,
226 boundary_coverage_violations: graph.boundary_coverage_violations,
227 boundary_call_violations: graph.boundary_call_violations,
228 policy_violations: graph.policy_violations,
229 stale_suppressions: member_imports.stale_suppressions,
230 unused_catalog_entries: catalog.unused_catalog_entries,
231 empty_catalog_groups: catalog.empty_catalog_groups,
232 unresolved_catalog_references: catalog.unresolved_catalog_references,
233 unused_dependency_overrides: catalog.unused_dependency_overrides,
234 misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
235 invalid_client_exports: file_exports.invalid_client_exports,
236 mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
237 misplaced_directives: file_exports.misplaced_directives,
238 route_collisions: file_exports.route_collisions,
239 dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
240 }
241 }
242
243 pub fn total_entries(&self) -> usize {
245 self.unused_files.len()
246 + self.unused_exports.len()
247 + self.unused_types.len()
248 + self.private_type_leaks.len()
249 + self.unused_dependencies.len()
250 + self.unused_dev_dependencies.len()
251 + self.circular_dependencies.len()
252 + self.re_export_cycles.len()
253 + self.unused_optional_dependencies.len()
254 + self.unused_enum_members.len()
255 + self.unused_class_members.len()
256 + self.unused_store_members.len()
257 + self.unprovided_injects.len()
258 + self.unrendered_components.len()
259 + self.unused_component_props.len()
260 + self.unused_component_emits.len()
261 + self.unused_component_inputs.len()
262 + self.unused_component_outputs.len()
263 + self.unused_svelte_events.len()
264 + self.unused_server_actions.len()
265 + self.unused_load_data_keys.len()
266 + self.unresolved_imports.len()
267 + self.unlisted_dependencies.len()
268 + self.duplicate_exports.len()
269 + self.type_only_dependencies.len()
270 + self.test_only_dependencies.len()
271 + self.dev_dependencies_in_production.len()
272 + self.boundary_violations.len()
273 + self.boundary_coverage_violations.len()
274 + self.boundary_call_violations.len()
275 + self.policy_violations.len()
276 + self.stale_suppressions.len()
277 + self.unused_catalog_entries.len()
278 + self.empty_catalog_groups.len()
279 + self.unresolved_catalog_references.len()
280 + self.unused_dependency_overrides.len()
281 + self.misconfigured_dependency_overrides.len()
282 + self.invalid_client_exports.len()
283 + self.mixed_client_server_barrels.len()
284 + self.misplaced_directives.len()
285 + self.route_collisions.len()
286 + self.dynamic_segment_name_conflicts.len()
287 }
288}
289
290struct BaselineFileExportKeys {
291 unused_files: Vec<String>,
292 unused_exports: Vec<String>,
293 unused_types: Vec<String>,
294 private_type_leaks: Vec<String>,
295 invalid_client_exports: Vec<String>,
296 mixed_client_server_barrels: Vec<String>,
297 misplaced_directives: Vec<String>,
298 route_collisions: Vec<String>,
299 dynamic_segment_name_conflicts: Vec<String>,
300}
301
302fn baseline_file_export_keys(
303 results: &crate::results::AnalysisResults,
304 root: &Path,
305) -> BaselineFileExportKeys {
306 BaselineFileExportKeys {
307 unused_files: results
308 .unused_files
309 .iter()
310 .map(|f| relative_path(&f.file.path, root))
311 .collect(),
312 unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
313 unused_types: unused_type_baseline_keys(&results.unused_types, root),
314 private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
315 invalid_client_exports: invalid_client_export_baseline_keys(
316 &results.invalid_client_exports,
317 root,
318 ),
319 mixed_client_server_barrels: barrel_baseline_keys(
320 &results.mixed_client_server_barrels,
321 root,
322 ),
323 misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
324 route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
325 dynamic_segment_name_conflicts: results
326 .dynamic_segment_name_conflicts
327 .iter()
328 .map(|c| {
329 format!(
330 "{}:{}",
331 relative_path(&c.conflict.path, root),
332 c.conflict.position
333 )
334 })
335 .collect(),
336 }
337}
338
339fn unused_export_baseline_keys(
340 items: &[crate::results::UnusedExportFinding],
341 root: &Path,
342) -> Vec<String> {
343 items
344 .iter()
345 .map(|e| {
346 format!(
347 "{}:{}",
348 relative_path(&e.export.path, root),
349 e.export.export_name
350 )
351 })
352 .collect()
353}
354
355fn unused_type_baseline_keys(
356 items: &[crate::results::UnusedTypeFinding],
357 root: &Path,
358) -> Vec<String> {
359 items
360 .iter()
361 .map(|e| {
362 format!(
363 "{}:{}",
364 relative_path(&e.export.path, root),
365 e.export.export_name
366 )
367 })
368 .collect()
369}
370
371fn invalid_client_export_baseline_keys(
372 items: &[crate::results::InvalidClientExportFinding],
373 root: &Path,
374) -> Vec<String> {
375 items
376 .iter()
377 .map(|e| {
378 format!(
379 "{}:{}",
380 relative_path(&e.export.path, root),
381 e.export.export_name
382 )
383 })
384 .collect()
385}
386
387fn private_type_leak_baseline_keys(
388 items: &[crate::results::PrivateTypeLeakFinding],
389 root: &Path,
390) -> Vec<String> {
391 items
392 .iter()
393 .map(|e| {
394 format!(
395 "{}:{}->{}",
396 relative_path(&e.leak.path, root),
397 e.leak.export_name,
398 e.leak.type_name
399 )
400 })
401 .collect()
402}
403
404fn barrel_baseline_keys(
405 items: &[crate::results::MixedClientServerBarrelFinding],
406 root: &Path,
407) -> Vec<String> {
408 items
409 .iter()
410 .map(|b| {
411 format!(
412 "{}:{}:{}",
413 relative_path(&b.barrel.path, root),
414 b.barrel.client_origin,
415 b.barrel.server_origin
416 )
417 })
418 .collect()
419}
420
421fn directive_baseline_keys(
422 items: &[crate::results::MisplacedDirectiveFinding],
423 root: &Path,
424) -> Vec<String> {
425 items
426 .iter()
427 .map(|d| {
428 format!(
429 "{}:{}:{}",
430 relative_path(&d.directive_site.path, root),
431 d.directive_site.line,
432 d.directive_site.directive
433 )
434 })
435 .collect()
436}
437
438fn route_collision_baseline_keys(
439 items: &[crate::results::RouteCollisionFinding],
440 root: &Path,
441) -> Vec<String> {
442 items
443 .iter()
444 .map(|c| {
445 format!(
446 "{}:{}",
447 relative_path(&c.collision.path, root),
448 c.collision.url
449 )
450 })
451 .collect()
452}
453
454struct BaselineMemberImportKeys {
455 unused_enum_members: Vec<String>,
456 unused_class_members: Vec<String>,
457 unused_store_members: Vec<String>,
458 unprovided_injects: Vec<String>,
459 unrendered_components: Vec<String>,
460 unused_component_props: Vec<String>,
461 unused_component_emits: Vec<String>,
462 unused_component_inputs: Vec<String>,
463 unused_component_outputs: Vec<String>,
464 unused_svelte_events: Vec<String>,
465 unused_server_actions: Vec<String>,
466 unused_load_data_keys: Vec<String>,
467 unresolved_imports: Vec<String>,
468 duplicate_exports: Vec<String>,
469 stale_suppressions: Vec<String>,
470}
471
472fn baseline_member_import_keys(
473 results: &crate::results::AnalysisResults,
474 root: &Path,
475) -> BaselineMemberImportKeys {
476 BaselineMemberImportKeys {
477 unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
478 unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
479 unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
480 unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
481 unrendered_components: component_baseline_keys(&results.unrendered_components, root),
482 unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
483 unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
484 unused_component_inputs: component_input_baseline_keys(
485 &results.unused_component_inputs,
486 root,
487 ),
488 unused_component_outputs: component_output_baseline_keys(
489 &results.unused_component_outputs,
490 root,
491 ),
492 unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
493 unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
494 unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
495 unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
496 duplicate_exports: results
497 .duplicate_exports
498 .iter()
499 .map(|d| duplicate_export_key(&d.export, root))
500 .collect(),
501 stale_suppressions: results
502 .stale_suppressions
503 .iter()
504 .map(|s| stale_suppression_baseline_key(s, root))
505 .collect(),
506 }
507}
508
509fn stale_suppression_baseline_key(
510 suppression: &crate::results::StaleSuppression,
511 root: &Path,
512) -> String {
513 let rule_id = if suppression.missing_reason {
514 "missing-suppression-reason"
515 } else {
516 "stale-suppression"
517 };
518 format!(
519 "{rule_id}:{}:{}",
520 relative_path(&suppression.path, root),
521 suppression.line
522 )
523}
524
525fn enum_member_baseline_keys(
526 items: &[crate::results::UnusedEnumMemberFinding],
527 root: &Path,
528) -> Vec<String> {
529 items
530 .iter()
531 .map(|m| unused_member_baseline_key(&m.member, root))
532 .collect()
533}
534
535fn class_member_baseline_keys(
536 items: &[crate::results::UnusedClassMemberFinding],
537 root: &Path,
538) -> Vec<String> {
539 items
540 .iter()
541 .map(|m| unused_member_baseline_key(&m.member, root))
542 .collect()
543}
544
545fn store_member_baseline_keys(
546 items: &[crate::results::UnusedStoreMemberFinding],
547 root: &Path,
548) -> Vec<String> {
549 items
550 .iter()
551 .map(|m| unused_member_baseline_key(&m.member, root))
552 .collect()
553}
554
555fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
556 format!(
557 "{}:{}.{}",
558 relative_path(&member.path, root),
559 member.parent_name,
560 member.member_name
561 )
562}
563
564fn inject_baseline_keys(
565 items: &[crate::results::UnprovidedInjectFinding],
566 root: &Path,
567) -> Vec<String> {
568 items
569 .iter()
570 .map(|f| {
571 format!(
572 "{}:{}",
573 relative_path(&f.inject.path, root),
574 f.inject.key_name
575 )
576 })
577 .collect()
578}
579
580fn component_baseline_keys(
581 items: &[crate::results::UnrenderedComponentFinding],
582 root: &Path,
583) -> Vec<String> {
584 items
585 .iter()
586 .map(|c| {
587 format!(
588 "{}:{}",
589 relative_path(&c.component.path, root),
590 c.component.component_name
591 )
592 })
593 .collect()
594}
595
596fn component_prop_baseline_keys(
597 items: &[crate::results::UnusedComponentPropFinding],
598 root: &Path,
599) -> Vec<String> {
600 items
601 .iter()
602 .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
603 .collect()
604}
605
606fn component_emit_baseline_keys(
607 items: &[crate::results::UnusedComponentEmitFinding],
608 root: &Path,
609) -> Vec<String> {
610 items
611 .iter()
612 .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
613 .collect()
614}
615
616fn component_input_baseline_keys(
617 items: &[crate::results::UnusedComponentInputFinding],
618 root: &Path,
619) -> Vec<String> {
620 items
621 .iter()
622 .map(|i| {
623 format!(
624 "{}:{}",
625 relative_path(&i.input.path, root),
626 i.input.input_name
627 )
628 })
629 .collect()
630}
631
632fn component_output_baseline_keys(
633 items: &[crate::results::UnusedComponentOutputFinding],
634 root: &Path,
635) -> Vec<String> {
636 items
637 .iter()
638 .map(|o| {
639 format!(
640 "{}:{}",
641 relative_path(&o.output.path, root),
642 o.output.output_name
643 )
644 })
645 .collect()
646}
647
648fn svelte_event_baseline_keys(
649 items: &[crate::results::UnusedSvelteEventFinding],
650 root: &Path,
651) -> Vec<String> {
652 items
653 .iter()
654 .map(|e| {
655 format!(
656 "{}:{}",
657 relative_path(&e.event.path, root),
658 e.event.event_name
659 )
660 })
661 .collect()
662}
663
664fn server_action_baseline_keys(
665 items: &[crate::results::UnusedServerActionFinding],
666 root: &Path,
667) -> Vec<String> {
668 items
669 .iter()
670 .map(|a| {
671 format!(
672 "{}:{}",
673 relative_path(&a.action.path, root),
674 a.action.action_name
675 )
676 })
677 .collect()
678}
679
680fn load_data_key_baseline_keys(
681 items: &[crate::results::UnusedLoadDataKeyFinding],
682 root: &Path,
683) -> Vec<String> {
684 items
685 .iter()
686 .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
687 .collect()
688}
689
690fn unresolved_import_baseline_keys(
691 items: &[crate::results::UnresolvedImportFinding],
692 root: &Path,
693) -> Vec<String> {
694 items
695 .iter()
696 .map(|i| {
697 format!(
698 "{}:{}",
699 relative_path(&i.import.path, root),
700 i.import.specifier
701 )
702 })
703 .collect()
704}
705
706struct BaselineDependencyKeys {
707 unused: Vec<String>,
708 unused_dev: Vec<String>,
709 unused_optional: Vec<String>,
710 unlisted: Vec<String>,
711 type_only: Vec<String>,
712 test_only: Vec<String>,
713 dev_in_prod: Vec<String>,
714}
715
716fn baseline_dependency_keys(
717 results: &crate::results::AnalysisResults,
718 root: &Path,
719) -> BaselineDependencyKeys {
720 BaselineDependencyKeys {
721 unused: results
722 .unused_dependencies
723 .iter()
724 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
725 .collect(),
726 unused_dev: results
727 .unused_dev_dependencies
728 .iter()
729 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
730 .collect(),
731 unused_optional: results
732 .unused_optional_dependencies
733 .iter()
734 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
735 .collect(),
736 unlisted: results
737 .unlisted_dependencies
738 .iter()
739 .map(|d| d.dep.package_name.clone())
740 .collect(),
741 type_only: results
742 .type_only_dependencies
743 .iter()
744 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
745 .collect(),
746 test_only: results
747 .test_only_dependencies
748 .iter()
749 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
750 .collect(),
751 dev_in_prod: results
752 .dev_dependencies_in_production
753 .iter()
754 .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
755 .collect(),
756 }
757}
758
759struct BaselineGraphKeys {
760 circular_dependencies: Vec<String>,
761 re_export_cycles: Vec<String>,
762 boundary_violations: Vec<String>,
763 boundary_coverage_violations: Vec<String>,
764 boundary_call_violations: Vec<String>,
765 policy_violations: Vec<String>,
766}
767
768fn baseline_graph_keys(
769 results: &crate::results::AnalysisResults,
770 root: &Path,
771) -> BaselineGraphKeys {
772 BaselineGraphKeys {
773 circular_dependencies: results
774 .circular_dependencies
775 .iter()
776 .map(|c| circular_dep_key(&c.cycle, root))
777 .collect(),
778 re_export_cycles: results
779 .re_export_cycles
780 .iter()
781 .map(|c| re_export_cycle_key(&c.cycle, root))
782 .collect(),
783 boundary_violations: results
784 .boundary_violations
785 .iter()
786 .map(|v| boundary_violation_key(&v.violation, root))
787 .collect(),
788 boundary_coverage_violations: results
789 .boundary_coverage_violations
790 .iter()
791 .map(|v| relative_path(&v.violation.path, root))
792 .collect(),
793 boundary_call_violations: results
794 .boundary_call_violations
795 .iter()
796 .map(|v| boundary_call_violation_key(&v.violation, root))
797 .collect(),
798 policy_violations: results
799 .policy_violations
800 .iter()
801 .map(|v| policy_violation_key(&v.violation, root))
802 .collect(),
803 }
804}
805
806struct BaselineCatalogKeys {
807 unused_catalog_entries: Vec<String>,
808 empty_catalog_groups: Vec<String>,
809 unresolved_catalog_references: Vec<String>,
810 unused_dependency_overrides: Vec<String>,
811 misconfigured_dependency_overrides: Vec<String>,
812}
813
814fn baseline_catalog_keys(
815 results: &crate::results::AnalysisResults,
816 root: &Path,
817) -> BaselineCatalogKeys {
818 BaselineCatalogKeys {
819 unused_catalog_entries: results
820 .unused_catalog_entries
821 .iter()
822 .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
823 .collect(),
824 empty_catalog_groups: results
825 .empty_catalog_groups
826 .iter()
827 .map(|g| g.group.catalog_name.clone())
828 .collect(),
829 unresolved_catalog_references: results
830 .unresolved_catalog_references
831 .iter()
832 .map(|r| {
833 format!(
834 "{}:{}:{}:{}",
835 relative_path(&r.reference.path, root),
836 r.reference.line,
837 r.reference.catalog_name,
838 r.reference.entry_name,
839 )
840 })
841 .collect(),
842 unused_dependency_overrides: results
843 .unused_dependency_overrides
844 .iter()
845 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
846 .collect(),
847 misconfigured_dependency_overrides: results
848 .misconfigured_dependency_overrides
849 .iter()
850 .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
851 .collect(),
852 }
853}
854
855fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
857 format!(
858 "{}->{}",
859 relative_path(&v.from_path, root),
860 relative_path(&v.to_path, root),
861 )
862}
863
864fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
866 format!("{}:{}", relative_path(&v.path, root), v.callee)
867}
868
869fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
873 format!(
874 "{}:{}/{}:{}",
875 relative_path(&v.path, root),
876 v.pack,
877 v.rule_id,
878 v.matched
879 )
880}
881
882fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
884 let mut locs: Vec<String> = dup
885 .locations
886 .iter()
887 .map(|l| relative_path(&l.path, root))
888 .collect();
889 locs.sort();
890 format!("{}|{}", dup.export_name, locs.join("|"))
891}
892
893fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
895 let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
896 paths.sort();
897 paths.join("->")
898}
899
900fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
906 let kind = match cycle.kind {
907 crate::results::ReExportCycleKind::MultiNode => "multi-node",
908 crate::results::ReExportCycleKind::SelfLoop => "self-loop",
909 };
910 let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
911 paths.sort();
912 format!("{kind}:{}", paths.join("<->"))
913}
914
915fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
916 format!(
917 "{}:{}->{}",
918 relative_path(&leak.path, root),
919 leak.export_name,
920 leak.type_name
921 )
922}
923
924fn filter_private_type_leaks(
925 leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
926 baseline_keys: &[String],
927 root: &Path,
928) {
929 let baseline_private_type_leaks: FxHashSet<&str> =
930 baseline_keys.iter().map(String::as_str).collect();
931 leaks.retain(|entry| {
932 let key = private_type_leak_key(&entry.leak, root);
933 !baseline_private_type_leaks.contains(key.as_str())
934 });
935}
936
937struct BaselineFilterContext<'a> {
938 baseline: &'a BaselineData,
939 root: &'a Path,
940}
941
942impl BaselineFilterContext<'_> {
943 fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
944 let baseline_circular: FxHashSet<&str> = self
945 .baseline
946 .circular_dependencies
947 .iter()
948 .map(String::as_str)
949 .collect();
950 results.circular_dependencies.retain(|cycle| {
951 let key = circular_dep_key(&cycle.cycle, self.root);
952 !baseline_circular.contains(key.as_str())
953 });
954
955 let baseline_re_export_cycles: FxHashSet<&str> = self
956 .baseline
957 .re_export_cycles
958 .iter()
959 .map(String::as_str)
960 .collect();
961 results.re_export_cycles.retain(|cycle| {
962 let key = re_export_cycle_key(&cycle.cycle, self.root);
963 !baseline_re_export_cycles.contains(key.as_str())
964 });
965
966 self.filter_unused_members(results);
967 self.filter_unresolved_and_exports(results);
968 }
969
970 fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
971 self.filter_enum_class_store_members(results);
972 self.filter_component_surface_members(results);
973 self.filter_route_action_members(results);
974 }
975
976 fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
977 let baseline_enum_members: FxHashSet<&str> = self
978 .baseline
979 .unused_enum_members
980 .iter()
981 .map(String::as_str)
982 .collect();
983 results.unused_enum_members.retain(|member| {
984 let key = format!(
985 "{}:{}.{}",
986 relative_path(&member.member.path, self.root),
987 member.member.parent_name,
988 member.member.member_name
989 );
990 !baseline_enum_members.contains(key.as_str())
991 });
992
993 let baseline_class_members: FxHashSet<&str> = self
994 .baseline
995 .unused_class_members
996 .iter()
997 .map(String::as_str)
998 .collect();
999 results.unused_class_members.retain(|member| {
1000 let key = format!(
1001 "{}:{}.{}",
1002 relative_path(&member.member.path, self.root),
1003 member.member.parent_name,
1004 member.member.member_name
1005 );
1006 !baseline_class_members.contains(key.as_str())
1007 });
1008
1009 let baseline_store_members: FxHashSet<&str> = self
1010 .baseline
1011 .unused_store_members
1012 .iter()
1013 .map(String::as_str)
1014 .collect();
1015 results.unused_store_members.retain(|member| {
1016 let key = format!(
1017 "{}:{}.{}",
1018 relative_path(&member.member.path, self.root),
1019 member.member.parent_name,
1020 member.member.member_name
1021 );
1022 !baseline_store_members.contains(key.as_str())
1023 });
1024 }
1025
1026 fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1027 retain_new_by_keys(
1028 &mut results.unprovided_injects,
1029 &self.baseline.unprovided_injects,
1030 self.root,
1031 inject_baseline_keys,
1032 );
1033 retain_new_by_keys(
1034 &mut results.unrendered_components,
1035 &self.baseline.unrendered_components,
1036 self.root,
1037 component_baseline_keys,
1038 );
1039 retain_new_by_keys(
1040 &mut results.unused_component_props,
1041 &self.baseline.unused_component_props,
1042 self.root,
1043 component_prop_baseline_keys,
1044 );
1045 retain_new_by_keys(
1046 &mut results.unused_component_emits,
1047 &self.baseline.unused_component_emits,
1048 self.root,
1049 component_emit_baseline_keys,
1050 );
1051 retain_new_by_keys(
1052 &mut results.unused_component_inputs,
1053 &self.baseline.unused_component_inputs,
1054 self.root,
1055 component_input_baseline_keys,
1056 );
1057 retain_new_by_keys(
1058 &mut results.unused_component_outputs,
1059 &self.baseline.unused_component_outputs,
1060 self.root,
1061 component_output_baseline_keys,
1062 );
1063 retain_new_by_keys(
1064 &mut results.unused_svelte_events,
1065 &self.baseline.unused_svelte_events,
1066 self.root,
1067 svelte_event_baseline_keys,
1068 );
1069 }
1070
1071 fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1072 let baseline_unused_server_actions: FxHashSet<&str> = self
1073 .baseline
1074 .unused_server_actions
1075 .iter()
1076 .map(String::as_str)
1077 .collect();
1078 results.unused_server_actions.retain(|finding| {
1079 let key = format!(
1080 "{}:{}",
1081 relative_path(&finding.action.path, self.root),
1082 finding.action.action_name
1083 );
1084 !baseline_unused_server_actions.contains(key.as_str())
1085 });
1086
1087 let baseline_unused_load_data_keys: FxHashSet<&str> = self
1088 .baseline
1089 .unused_load_data_keys
1090 .iter()
1091 .map(String::as_str)
1092 .collect();
1093 results.unused_load_data_keys.retain(|finding| {
1094 let key = format!(
1095 "{}:{}",
1096 relative_path(&finding.key.path, self.root),
1097 finding.key.key_name
1098 );
1099 !baseline_unused_load_data_keys.contains(key.as_str())
1100 });
1101 }
1102
1103 fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1104 let baseline_unresolved: FxHashSet<&str> = self
1105 .baseline
1106 .unresolved_imports
1107 .iter()
1108 .map(String::as_str)
1109 .collect();
1110 results.unresolved_imports.retain(|import| {
1111 let key = format!(
1112 "{}:{}",
1113 relative_path(&import.import.path, self.root),
1114 import.import.specifier
1115 );
1116 !baseline_unresolved.contains(key.as_str())
1117 });
1118
1119 let baseline_unlisted: FxHashSet<&str> = self
1120 .baseline
1121 .unlisted_dependencies
1122 .iter()
1123 .map(String::as_str)
1124 .collect();
1125 results
1126 .unlisted_dependencies
1127 .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1128
1129 let baseline_dup_exports: FxHashSet<&str> = self
1130 .baseline
1131 .duplicate_exports
1132 .iter()
1133 .map(String::as_str)
1134 .collect();
1135 results.duplicate_exports.retain(|duplicate| {
1136 let key = duplicate_export_key(&duplicate.export, self.root);
1137 !baseline_dup_exports.contains(key.as_str())
1138 });
1139 }
1140
1141 fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1142 let baseline_optional_deps: FxHashSet<&str> = self
1143 .baseline
1144 .unused_optional_dependencies
1145 .iter()
1146 .map(String::as_str)
1147 .collect();
1148 results.unused_optional_dependencies.retain(|dep| {
1149 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1150 !baseline_contains_dependency(
1151 &baseline_optional_deps,
1152 &dep.dep.package_name,
1153 key.as_str(),
1154 )
1155 });
1156
1157 self.filter_type_and_test_only_dependencies(results);
1158 }
1159
1160 fn filter_type_and_test_only_dependencies(
1161 &self,
1162 results: &mut crate::results::AnalysisResults,
1163 ) {
1164 let baseline_type_only: FxHashSet<&str> = self
1165 .baseline
1166 .type_only_dependencies
1167 .iter()
1168 .map(String::as_str)
1169 .collect();
1170 results.type_only_dependencies.retain(|dep| {
1171 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1172 !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1173 });
1174
1175 let baseline_test_only: FxHashSet<&str> = self
1176 .baseline
1177 .test_only_dependencies
1178 .iter()
1179 .map(String::as_str)
1180 .collect();
1181 results.test_only_dependencies.retain(|dep| {
1182 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1183 !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1184 });
1185
1186 let baseline_dev_in_prod: FxHashSet<&str> = self
1187 .baseline
1188 .dev_dependencies_in_production
1189 .iter()
1190 .map(String::as_str)
1191 .collect();
1192 results.dev_dependencies_in_production.retain(|dep| {
1193 let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1194 !baseline_contains_dependency(
1195 &baseline_dev_in_prod,
1196 &dep.dep.package_name,
1197 key.as_str(),
1198 )
1199 });
1200 }
1201
1202 fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1203 let baseline_boundary: FxHashSet<&str> = self
1204 .baseline
1205 .boundary_violations
1206 .iter()
1207 .map(String::as_str)
1208 .collect();
1209 results.boundary_violations.retain(|violation| {
1210 let key = boundary_violation_key(&violation.violation, self.root);
1211 !baseline_boundary.contains(key.as_str())
1212 });
1213
1214 self.filter_boundary_details(results);
1215 self.filter_stale_suppressions(results);
1216 self.filter_invalid_client_exports(results);
1217 self.filter_mixed_client_server_barrels(results);
1218 self.filter_misplaced_directives(results);
1219 self.filter_route_collisions(results);
1220 self.filter_dynamic_segment_name_conflicts(results);
1221 }
1222
1223 fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1224 let baseline_invalid: FxHashSet<&str> = self
1225 .baseline
1226 .invalid_client_exports
1227 .iter()
1228 .map(String::as_str)
1229 .collect();
1230 results.invalid_client_exports.retain(|finding| {
1231 let key = format!(
1232 "{}:{}",
1233 relative_path(&finding.export.path, self.root),
1234 finding.export.export_name
1235 );
1236 !baseline_invalid.contains(key.as_str())
1237 });
1238 }
1239
1240 fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
1241 let baseline_barrels: FxHashSet<&str> = self
1242 .baseline
1243 .mixed_client_server_barrels
1244 .iter()
1245 .map(String::as_str)
1246 .collect();
1247 results.mixed_client_server_barrels.retain(|finding| {
1248 let key = format!(
1249 "{}:{}:{}",
1250 relative_path(&finding.barrel.path, self.root),
1251 finding.barrel.client_origin,
1252 finding.barrel.server_origin
1253 );
1254 !baseline_barrels.contains(key.as_str())
1255 });
1256 }
1257
1258 fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
1259 let baseline_directives: FxHashSet<&str> = self
1260 .baseline
1261 .misplaced_directives
1262 .iter()
1263 .map(String::as_str)
1264 .collect();
1265 results.misplaced_directives.retain(|finding| {
1266 let key = format!(
1267 "{}:{}:{}",
1268 relative_path(&finding.directive_site.path, self.root),
1269 finding.directive_site.line,
1270 finding.directive_site.directive
1271 );
1272 !baseline_directives.contains(key.as_str())
1273 });
1274 }
1275
1276 fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
1277 let baseline_collisions: FxHashSet<&str> = self
1278 .baseline
1279 .route_collisions
1280 .iter()
1281 .map(String::as_str)
1282 .collect();
1283 results.route_collisions.retain(|finding| {
1284 let key = format!(
1285 "{}:{}",
1286 relative_path(&finding.collision.path, self.root),
1287 finding.collision.url
1288 );
1289 !baseline_collisions.contains(key.as_str())
1290 });
1291 }
1292
1293 fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
1294 let baseline_conflicts: FxHashSet<&str> = self
1295 .baseline
1296 .dynamic_segment_name_conflicts
1297 .iter()
1298 .map(String::as_str)
1299 .collect();
1300 results.dynamic_segment_name_conflicts.retain(|finding| {
1301 let key = format!(
1302 "{}:{}",
1303 relative_path(&finding.conflict.path, self.root),
1304 finding.conflict.position
1305 );
1306 !baseline_conflicts.contains(key.as_str())
1307 });
1308 }
1309
1310 fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
1311 let baseline_boundary_coverage: FxHashSet<&str> = self
1312 .baseline
1313 .boundary_coverage_violations
1314 .iter()
1315 .map(String::as_str)
1316 .collect();
1317 results.boundary_coverage_violations.retain(|violation| {
1318 let key = relative_path(&violation.violation.path, self.root);
1319 !baseline_boundary_coverage.contains(key.as_str())
1320 });
1321
1322 let baseline_boundary_calls: FxHashSet<&str> = self
1323 .baseline
1324 .boundary_call_violations
1325 .iter()
1326 .map(String::as_str)
1327 .collect();
1328 results.boundary_call_violations.retain(|violation| {
1329 let key = boundary_call_violation_key(&violation.violation, self.root);
1330 !baseline_boundary_calls.contains(key.as_str())
1331 });
1332 }
1333
1334 fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1335 let baseline_stale: FxHashSet<&str> = self
1336 .baseline
1337 .stale_suppressions
1338 .iter()
1339 .map(String::as_str)
1340 .collect();
1341 results.stale_suppressions.retain(|suppression| {
1342 let key = stale_suppression_baseline_key(suppression, self.root);
1343 let legacy_key = format!(
1344 "{}:{}",
1345 relative_path(&suppression.path, self.root),
1346 suppression.line
1347 );
1348 !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
1349 });
1350 }
1351
1352 fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
1353 let baseline_catalog: FxHashSet<&str> = self
1354 .baseline
1355 .unused_catalog_entries
1356 .iter()
1357 .map(String::as_str)
1358 .collect();
1359 results.unused_catalog_entries.retain(|entry| {
1360 let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
1361 !baseline_catalog.contains(key.as_str())
1362 });
1363
1364 let baseline_empty_catalog_groups: FxHashSet<&str> = self
1365 .baseline
1366 .empty_catalog_groups
1367 .iter()
1368 .map(String::as_str)
1369 .collect();
1370 results.empty_catalog_groups.retain(|group| {
1371 !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
1372 });
1373
1374 self.filter_pnpm_references_and_overrides(results);
1375 }
1376
1377 fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
1378 let baseline_unresolved: FxHashSet<&str> = self
1379 .baseline
1380 .unresolved_catalog_references
1381 .iter()
1382 .map(String::as_str)
1383 .collect();
1384 results.unresolved_catalog_references.retain(|reference| {
1385 let key = format!(
1386 "{}:{}:{}:{}",
1387 relative_path(&reference.reference.path, self.root),
1388 reference.reference.line,
1389 reference.reference.catalog_name,
1390 reference.reference.entry_name,
1391 );
1392 !baseline_unresolved.contains(key.as_str())
1393 });
1394
1395 self.filter_pnpm_overrides(results);
1396 }
1397
1398 fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
1399 let baseline_unused_overrides: FxHashSet<&str> = self
1400 .baseline
1401 .unused_dependency_overrides
1402 .iter()
1403 .map(String::as_str)
1404 .collect();
1405 results
1406 .unused_dependency_overrides
1407 .retain(|override_entry| {
1408 let key = format!(
1409 "{}:{}",
1410 override_entry.entry.source, override_entry.entry.raw_key
1411 );
1412 !baseline_unused_overrides.contains(key.as_str())
1413 });
1414
1415 let baseline_misconfigured_overrides: FxHashSet<&str> = self
1416 .baseline
1417 .misconfigured_dependency_overrides
1418 .iter()
1419 .map(String::as_str)
1420 .collect();
1421 results
1422 .misconfigured_dependency_overrides
1423 .retain(|override_entry| {
1424 let key = format!(
1425 "{}:{}",
1426 override_entry.entry.source, override_entry.entry.raw_key
1427 );
1428 !baseline_misconfigured_overrides.contains(key.as_str())
1429 });
1430 }
1431}
1432
1433pub fn filter_new_issues(
1435 mut results: crate::results::AnalysisResults,
1436 baseline: &BaselineData,
1437 root: &Path,
1438) -> crate::results::AnalysisResults {
1439 let baseline_files: FxHashSet<&str> =
1440 baseline.unused_files.iter().map(String::as_str).collect();
1441 let baseline_exports: FxHashSet<&str> =
1442 baseline.unused_exports.iter().map(String::as_str).collect();
1443 let baseline_types: FxHashSet<&str> =
1444 baseline.unused_types.iter().map(String::as_str).collect();
1445 let baseline_deps: FxHashSet<&str> = baseline
1446 .unused_dependencies
1447 .iter()
1448 .map(String::as_str)
1449 .collect();
1450 let baseline_dev_deps: FxHashSet<&str> = baseline
1451 .unused_dev_dependencies
1452 .iter()
1453 .map(String::as_str)
1454 .collect();
1455
1456 results
1457 .unused_files
1458 .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
1459 results.unused_exports.retain(|e| {
1460 let key = format!(
1461 "{}:{}",
1462 relative_path(&e.export.path, root),
1463 e.export.export_name
1464 );
1465 !baseline_exports.contains(key.as_str())
1466 });
1467 results.unused_types.retain(|e| {
1468 let key = format!(
1469 "{}:{}",
1470 relative_path(&e.export.path, root),
1471 e.export.export_name
1472 );
1473 !baseline_types.contains(key.as_str())
1474 });
1475 filter_private_type_leaks(
1476 &mut results.private_type_leaks,
1477 &baseline.private_type_leaks,
1478 root,
1479 );
1480 results.unused_dependencies.retain(|d| {
1481 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1482 !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
1483 });
1484 results.unused_dev_dependencies.retain(|d| {
1485 let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
1486 !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
1487 });
1488
1489 let filter = BaselineFilterContext { baseline, root };
1490 filter.filter_cycles_and_members(&mut results);
1491 filter.filter_dependency_variants(&mut results);
1492 filter.filter_boundaries_and_suppressions(&mut results);
1493 filter.filter_pnpm_entries(&mut results);
1494
1495 results
1496}
1497
1498#[derive(serde::Serialize, serde::Deserialize)]
1504pub struct DuplicationBaselineData {
1505 pub clone_groups: Vec<String>,
1507}
1508
1509impl DuplicationBaselineData {
1510 pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
1512 Self {
1513 clone_groups: report
1514 .clone_groups
1515 .iter()
1516 .map(|g| clone_group_key(g, root))
1517 .collect(),
1518 }
1519 }
1520}
1521
1522fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
1524 let mut parts: Vec<String> = group
1525 .instances
1526 .iter()
1527 .map(|i| {
1528 format!(
1529 "{}:{}-{}",
1530 relative_path(&i.file, root),
1531 i.start_line,
1532 i.end_line
1533 )
1534 })
1535 .collect();
1536 parts.sort();
1537 parts.join("|")
1538}
1539
1540pub fn filter_new_clone_groups(
1542 mut report: DuplicationReport,
1543 baseline: &DuplicationBaselineData,
1544 root: &Path,
1545) -> DuplicationReport {
1546 let baseline_keys: FxHashSet<&str> = baseline.clone_groups.iter().map(String::as_str).collect();
1547
1548 report.clone_groups.retain(|g| {
1549 let key = clone_group_key(g, root);
1550 !baseline_keys.contains(key.as_str())
1551 });
1552
1553 crate::duplicates::refresh_clone_families(&mut report, root);
1554 report.stats = recompute_stats(&report);
1555
1556 report
1557}
1558
1559pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
1564 crate::duplicates::recompute_stats(report)
1565}
1566
1567#[derive(Default, serde::Serialize, serde::Deserialize)]
1574pub struct HealthBaselineData {
1575 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1577 pub findings: Vec<String>,
1578 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1580 pub finding_counts: HealthFindingCountMap,
1581 #[serde(default)]
1583 pub runtime_coverage_findings: Vec<String>,
1584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1591 pub runtime_coverage_source_hashes: Vec<String>,
1592 #[serde(default)]
1594 pub target_keys: Vec<String>,
1595}
1596
1597#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1598pub struct HealthBaselineCount {
1599 pub count: usize,
1600}
1601
1602type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
1603
1604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1605enum HealthFindingDimension {
1606 Complexity,
1607 Crap,
1608}
1609
1610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1611struct HealthFindingCategory {
1612 dimension: HealthFindingDimension,
1613 severity: fallow_output::FindingSeverity,
1614}
1615
1616impl HealthFindingCategory {
1617 const fn key(self) -> &'static str {
1618 match (self.dimension, self.severity) {
1619 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
1620 "complexity_moderate"
1621 }
1622 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
1623 "complexity_high"
1624 }
1625 (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
1626 "complexity_critical"
1627 }
1628 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
1629 "crap_moderate"
1630 }
1631 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
1632 (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
1633 "crap_critical"
1634 }
1635 }
1636 }
1637}
1638
1639const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
1640 HealthFindingDimension::Complexity,
1641 HealthFindingDimension::Crap,
1642];
1643
1644impl HealthBaselineData {
1645 pub fn from_findings(
1647 findings: &[fallow_output::ComplexityViolation],
1648 runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
1649 targets: &[fallow_output::RefactoringTarget],
1650 root: &Path,
1651 ) -> Self {
1652 Self {
1653 findings: Vec::new(),
1654 finding_counts: health_finding_counts(findings, root),
1655 runtime_coverage_findings: runtime_coverage_findings
1656 .iter()
1657 .map(|f| runtime_coverage_finding_key(f, root))
1658 .collect(),
1659 runtime_coverage_source_hashes: runtime_coverage_findings
1660 .iter()
1661 .filter_map(|f| runtime_coverage_source_hash_key(f, root))
1662 .collect(),
1663 target_keys: targets
1664 .iter()
1665 .map(|t| target_baseline_key(t, root))
1666 .collect(),
1667 }
1668 }
1669
1670 pub fn finding_entry_count(&self) -> usize {
1671 if !self.finding_counts.is_empty() {
1672 self.finding_counts
1673 .values()
1674 .flat_map(BTreeMap::values)
1675 .map(|entry| entry.count)
1676 .sum()
1677 } else {
1678 self.findings.len()
1679 }
1680 }
1681
1682 pub fn overlap_entry_count(
1683 &self,
1684 findings: &[fallow_output::ComplexityViolation],
1685 root: &Path,
1686 ) -> usize {
1687 if !self.finding_counts.is_empty() {
1688 let current_counts = health_finding_counts(findings, root);
1689 health_overlap_entry_count(¤t_counts, &self.finding_counts)
1690 } else {
1691 let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
1692 findings
1693 .iter()
1694 .filter(|finding| {
1695 baseline_keys.contains(health_finding_key(finding, root).as_str())
1696 })
1697 .count()
1698 }
1699 }
1700}
1701
1702fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
1704 format!(
1705 "{}:{}",
1706 relative_path(&target.path, root),
1707 target.category.label()
1708 )
1709}
1710
1711fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
1713 format!(
1714 "{}:{}:{}",
1715 relative_path(&finding.path, root),
1716 finding.name,
1717 finding.line
1718 )
1719}
1720
1721fn health_finding_counts(
1722 findings: &[fallow_output::ComplexityViolation],
1723 root: &Path,
1724) -> HealthFindingCountMap {
1725 let mut counts = BTreeMap::new();
1726 for finding in findings {
1727 let path = relative_path(&finding.path, root);
1728 let file_counts = counts.entry(path).or_insert_with(BTreeMap::new);
1729 for category in health_finding_categories(finding).into_iter().flatten() {
1730 file_counts
1731 .entry(category.key().to_string())
1732 .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
1733 .or_insert(HealthBaselineCount { count: 1 });
1734 }
1735 }
1736 counts
1737}
1738
1739fn health_finding_categories(
1740 finding: &fallow_output::ComplexityViolation,
1741) -> [Option<HealthFindingCategory>; 2] {
1742 let complexity_category = HealthFindingCategory {
1743 dimension: HealthFindingDimension::Complexity,
1744 severity: finding.severity,
1745 };
1746 let crap_category = HealthFindingCategory {
1747 dimension: HealthFindingDimension::Crap,
1748 severity: finding.severity,
1749 };
1750 let has_complexity =
1751 finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
1752 let has_crap = finding.exceeded.includes_crap();
1753 [
1754 has_complexity.then_some(complexity_category),
1755 has_crap.then_some(crap_category),
1756 ]
1757}
1758
1759fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
1760 match severity {
1761 fallow_output::FindingSeverity::Moderate => 0,
1762 fallow_output::FindingSeverity::High => 1,
1763 fallow_output::FindingSeverity::Critical => 2,
1764 }
1765}
1766
1767fn severity_counts_for_dimension(
1768 file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
1769 dimension: HealthFindingDimension,
1770) -> [usize; 3] {
1771 let mut counts = [0; 3];
1772 for severity in [
1773 fallow_output::FindingSeverity::Moderate,
1774 fallow_output::FindingSeverity::High,
1775 fallow_output::FindingSeverity::Critical,
1776 ] {
1777 let category = HealthFindingCategory {
1778 dimension,
1779 severity,
1780 };
1781 counts[severity_index(severity)] = file_counts
1782 .and_then(|entries| entries.get(category.key()))
1783 .map_or(0, |entry| entry.count);
1784 }
1785 counts
1786}
1787
1788fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
1789 let mut available = baseline;
1790 let mut overflow = [false; 3];
1791
1792 for severity_idx in 0..3 {
1793 let compatible = available[severity_idx..].iter().sum::<usize>();
1794 overflow[severity_idx] = compatible < current[severity_idx];
1795
1796 let mut matched = current[severity_idx].min(compatible);
1797 for slot in available.iter_mut().skip(severity_idx) {
1798 let taken = matched.min(*slot);
1799 *slot -= taken;
1800 matched -= taken;
1801 if matched == 0 {
1802 break;
1803 }
1804 }
1805 }
1806
1807 overflow
1808}
1809
1810fn health_overflow_categories(
1811 current_counts: &HealthFindingCountMap,
1812 baseline_counts: &HealthFindingCountMap,
1813) -> FxHashMap<String, FxHashSet<&'static str>> {
1814 let mut overflow_by_path = FxHashMap::default();
1815
1816 for (path, current_file_counts) in current_counts {
1817 let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
1818 let baseline_file_counts = baseline_counts.get(path);
1819
1820 for dimension in HEALTH_FINDING_DIMENSIONS {
1821 let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
1822 let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
1823 let overflow = overflowing_severities(current, baseline);
1824
1825 for severity in [
1826 fallow_output::FindingSeverity::Moderate,
1827 fallow_output::FindingSeverity::High,
1828 fallow_output::FindingSeverity::Critical,
1829 ] {
1830 if overflow[severity_index(severity)] {
1831 overflow_categories.insert(
1832 HealthFindingCategory {
1833 dimension,
1834 severity,
1835 }
1836 .key(),
1837 );
1838 }
1839 }
1840 }
1841
1842 if !overflow_categories.is_empty() {
1843 overflow_by_path.insert(path.clone(), overflow_categories);
1844 }
1845 }
1846
1847 overflow_by_path
1848}
1849
1850fn health_overlap_entry_count(
1851 current_counts: &HealthFindingCountMap,
1852 baseline_counts: &HealthFindingCountMap,
1853) -> usize {
1854 let mut overlap = 0;
1855
1856 for (path, baseline_file_counts) in baseline_counts {
1857 let current_file_counts = current_counts.get(path);
1858
1859 for dimension in HEALTH_FINDING_DIMENSIONS {
1860 let current_total: usize =
1861 severity_counts_for_dimension(current_file_counts, dimension)
1862 .into_iter()
1863 .sum();
1864 let baseline_total: usize =
1865 severity_counts_for_dimension(Some(baseline_file_counts), dimension)
1866 .into_iter()
1867 .sum();
1868 overlap += current_total.min(baseline_total);
1869 }
1870 }
1871
1872 overlap
1873}
1874
1875fn runtime_coverage_finding_key(
1876 finding: &fallow_output::RuntimeCoverageFinding,
1877 _root: &Path,
1878) -> String {
1879 finding
1880 .stable_id
1881 .clone()
1882 .unwrap_or_else(|| finding.id.clone())
1883}
1884
1885fn runtime_coverage_source_hash_key(
1892 finding: &fallow_output::RuntimeCoverageFinding,
1893 root: &Path,
1894) -> Option<String> {
1895 finding.source_hash.as_deref().map(|hash| {
1896 format!(
1897 "{}\0{}\0{}",
1898 relative_path(&finding.path, root),
1899 finding.function,
1900 hash
1901 )
1902 })
1903}
1904
1905pub fn filter_new_health_findings(
1907 mut findings: Vec<fallow_output::ComplexityViolation>,
1908 baseline: &HealthBaselineData,
1909 root: &Path,
1910) -> Vec<fallow_output::ComplexityViolation> {
1911 if !baseline.finding_counts.is_empty() {
1912 let current_counts = health_finding_counts(&findings, root);
1913 let overflow_categories =
1914 health_overflow_categories(¤t_counts, &baseline.finding_counts);
1915 findings.retain(|finding| {
1916 let path = relative_path(&finding.path, root);
1917 overflow_categories.get(&path).is_some_and(|categories| {
1918 health_finding_categories(finding)
1919 .into_iter()
1920 .flatten()
1921 .any(|category| categories.contains(category.key()))
1922 })
1923 });
1924 return findings;
1925 }
1926
1927 let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
1928 findings.retain(|f| {
1929 let key = health_finding_key(f, root);
1930 !baseline_keys.contains(key.as_str())
1931 });
1932 findings
1933}
1934
1935pub fn filter_new_runtime_coverage_findings(
1936 mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
1937 baseline: &HealthBaselineData,
1938 root: &Path,
1939) -> Vec<fallow_output::RuntimeCoverageFinding> {
1940 let baseline_keys: FxHashSet<&str> = baseline
1941 .runtime_coverage_findings
1942 .iter()
1943 .map(String::as_str)
1944 .collect();
1945 let baseline_source_hash_keys: FxHashSet<&str> = baseline
1946 .runtime_coverage_source_hashes
1947 .iter()
1948 .map(String::as_str)
1949 .collect();
1950 findings.retain(|finding| {
1951 let suppressed_by_stable_id = finding
1952 .stable_id
1953 .as_deref()
1954 .is_some_and(|id| baseline_keys.contains(id));
1955 let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
1956 let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
1957 .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
1958 !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
1959 });
1960 findings
1961}
1962
1963pub fn filter_new_health_targets(
1965 mut targets: Vec<fallow_output::RefactoringTarget>,
1966 baseline: &HealthBaselineData,
1967 root: &Path,
1968) -> Vec<fallow_output::RefactoringTarget> {
1969 let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
1970 targets.retain(|t| {
1971 let key = target_baseline_key(t, root);
1972 !baseline_keys.contains(key.as_str())
1973 });
1974 targets
1975}
1976
1977#[derive(Debug, Clone, serde::Serialize)]
1979pub struct CategoryDelta {
1980 pub current: usize,
1981 pub baseline: usize,
1982 pub delta: i64,
1983}
1984
1985#[derive(Debug, Clone)]
1990pub struct BaselineDeltas {
1991 pub total_delta: i64,
1993 pub per_category: Vec<(String, CategoryDelta)>,
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999 use super::*;
2000 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2001 use crate::results::{
2002 AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
2003 UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
2004 UnusedFile,
2005 };
2006 use fallow_types::output_dead_code::{
2007 UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
2008 };
2009 use std::path::PathBuf;
2010
2011 fn make_results() -> AnalysisResults {
2012 AnalysisResults {
2013 unused_files: vec![
2014 UnusedFileFinding::with_actions(UnusedFile {
2015 path: PathBuf::from("src/old.ts"),
2016 }),
2017 UnusedFileFinding::with_actions(UnusedFile {
2018 path: PathBuf::from("src/dead.ts"),
2019 }),
2020 ],
2021 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
2022 path: PathBuf::from("src/utils.ts"),
2023 export_name: "helperA".to_string(),
2024 is_type_only: false,
2025 line: 5,
2026 col: 0,
2027 span_start: 40,
2028 is_re_export: false,
2029 })],
2030 unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
2031 path: PathBuf::from("src/types.ts"),
2032 export_name: "OldType".to_string(),
2033 is_type_only: true,
2034 line: 10,
2035 col: 0,
2036 span_start: 100,
2037 is_re_export: false,
2038 })],
2039 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2040 package_name: "lodash".to_string(),
2041 location: DependencyLocation::Dependencies,
2042 path: PathBuf::from("package.json"),
2043 line: 5,
2044 used_in_workspaces: Vec::new(),
2045 })],
2046 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
2047 UnusedDependency {
2048 package_name: "jest".to_string(),
2049 location: DependencyLocation::DevDependencies,
2050 path: PathBuf::from("package.json"),
2051 line: 5,
2052 used_in_workspaces: Vec::new(),
2053 },
2054 )],
2055 ..Default::default()
2056 }
2057 }
2058
2059 #[test]
2060 fn baseline_from_results_captures_all_fields() {
2061 let results = make_results();
2062 let baseline = BaselineData::from_results(&results, Path::new(""));
2063 assert_eq!(baseline.unused_files.len(), 2);
2064 assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
2065 assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
2066 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
2067 assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
2068 assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
2069 assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
2070 }
2071
2072 #[test]
2073 fn dependency_baseline_keys_include_package_json_path() {
2074 let root = Path::new("/repo");
2075 let results = AnalysisResults {
2076 unused_dependencies: vec![
2077 UnusedDependencyFinding::with_actions(UnusedDependency {
2078 package_name: "lodash-es".to_string(),
2079 location: DependencyLocation::Dependencies,
2080 path: PathBuf::from("/repo/packages/app-a/package.json"),
2081 line: 5,
2082 used_in_workspaces: Vec::new(),
2083 }),
2084 UnusedDependencyFinding::with_actions(UnusedDependency {
2085 package_name: "lodash-es".to_string(),
2086 location: DependencyLocation::Dependencies,
2087 path: PathBuf::from("/repo/packages/app-b/package.json"),
2088 line: 5,
2089 used_in_workspaces: Vec::new(),
2090 }),
2091 ],
2092 ..Default::default()
2093 };
2094
2095 let baseline = BaselineData::from_results(&results, root);
2096
2097 assert_eq!(
2098 baseline.unused_dependencies,
2099 vec![
2100 "packages/app-a/package.json:lodash-es",
2101 "packages/app-b/package.json:lodash-es"
2102 ]
2103 );
2104 }
2105
2106 #[test]
2107 fn dependency_baseline_filter_matches_path_before_package_name() {
2108 let root = Path::new("/repo");
2109 let results = AnalysisResults {
2110 unused_dependencies: vec![
2111 UnusedDependencyFinding::with_actions(UnusedDependency {
2112 package_name: "lodash-es".to_string(),
2113 location: DependencyLocation::Dependencies,
2114 path: PathBuf::from("/repo/packages/app-a/package.json"),
2115 line: 5,
2116 used_in_workspaces: Vec::new(),
2117 }),
2118 UnusedDependencyFinding::with_actions(UnusedDependency {
2119 package_name: "lodash-es".to_string(),
2120 location: DependencyLocation::Dependencies,
2121 path: PathBuf::from("/repo/packages/app-b/package.json"),
2122 line: 5,
2123 used_in_workspaces: Vec::new(),
2124 }),
2125 ],
2126 ..Default::default()
2127 };
2128 let baseline = BaselineData {
2129 unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
2130 ..BaselineData::from_results(&AnalysisResults::default(), root)
2131 };
2132
2133 let filtered = filter_new_issues(results, &baseline, root);
2134
2135 assert_eq!(filtered.unused_dependencies.len(), 1);
2136 assert_eq!(
2137 filtered.unused_dependencies[0].dep.path,
2138 PathBuf::from("/repo/packages/app-b/package.json")
2139 );
2140 }
2141
2142 #[test]
2143 fn dependency_baseline_filter_supports_legacy_package_only_keys() {
2144 let root = Path::new("/repo");
2145 let results = AnalysisResults {
2146 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
2147 package_name: "lodash-es".to_string(),
2148 location: DependencyLocation::Dependencies,
2149 path: PathBuf::from("/repo/packages/app/package.json"),
2150 line: 5,
2151 used_in_workspaces: Vec::new(),
2152 })],
2153 ..Default::default()
2154 };
2155 let baseline = BaselineData {
2156 unused_dependencies: vec!["lodash-es".to_string()],
2157 ..BaselineData::from_results(&AnalysisResults::default(), root)
2158 };
2159
2160 let filtered = filter_new_issues(results, &baseline, root);
2161
2162 assert!(filtered.unused_dependencies.is_empty());
2163 }
2164
2165 #[test]
2166 fn baseline_serialization_roundtrip() {
2167 let results = make_results();
2168 let baseline = BaselineData::from_results(&results, Path::new(""));
2169 let json = serde_json::to_string(&baseline).unwrap();
2170 let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
2171 assert_eq!(deserialized.unused_files, baseline.unused_files);
2172 assert_eq!(deserialized.unused_exports, baseline.unused_exports);
2173 assert_eq!(deserialized.unused_types, baseline.unused_types);
2174 assert_eq!(
2175 deserialized.unused_dependencies,
2176 baseline.unused_dependencies
2177 );
2178 assert_eq!(
2179 deserialized.unused_dev_dependencies,
2180 baseline.unused_dev_dependencies
2181 );
2182 }
2183
2184 #[test]
2185 fn filter_removes_baseline_issues() {
2186 let results = make_results();
2187 let baseline = BaselineData::from_results(&results, Path::new(""));
2188 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2189 assert!(
2190 filtered.unused_files.is_empty(),
2191 "all files were in baseline"
2192 );
2193 assert!(
2194 filtered.unused_exports.is_empty(),
2195 "all exports were in baseline"
2196 );
2197 assert!(
2198 filtered.unused_types.is_empty(),
2199 "all types were in baseline"
2200 );
2201 assert!(
2202 filtered.unused_dependencies.is_empty(),
2203 "all deps were in baseline"
2204 );
2205 assert!(
2206 filtered.unused_dev_dependencies.is_empty(),
2207 "all dev deps were in baseline"
2208 );
2209 }
2210
2211 #[test]
2212 fn filter_keeps_new_issues_not_in_baseline() {
2213 let baseline = BaselineData {
2214 unused_files: vec!["src/old.ts".to_string()],
2215 unused_exports: vec![],
2216 unused_types: vec![],
2217 private_type_leaks: vec![],
2218 unused_dependencies: vec![],
2219 unused_dev_dependencies: vec![],
2220 circular_dependencies: vec![],
2221 re_export_cycles: vec![],
2222 unused_optional_dependencies: vec![],
2223 unused_enum_members: vec![],
2224 unused_class_members: vec![],
2225 unused_store_members: vec![],
2226 unprovided_injects: vec![],
2227 unrendered_components: vec![],
2228 unused_component_props: vec![],
2229 unused_component_emits: vec![],
2230 unused_component_inputs: vec![],
2231 unused_component_outputs: vec![],
2232 unused_svelte_events: vec![],
2233 unused_server_actions: vec![],
2234 unused_load_data_keys: vec![],
2235 unresolved_imports: vec![],
2236 unlisted_dependencies: vec![],
2237 duplicate_exports: vec![],
2238 type_only_dependencies: vec![],
2239 test_only_dependencies: vec![],
2240 dev_dependencies_in_production: vec![],
2241 boundary_violations: vec![],
2242 boundary_coverage_violations: vec![],
2243 boundary_call_violations: vec![],
2244 policy_violations: vec![],
2245 stale_suppressions: vec![],
2246 unused_catalog_entries: vec![],
2247 empty_catalog_groups: vec![],
2248 unresolved_catalog_references: vec![],
2249 unused_dependency_overrides: vec![],
2250 misconfigured_dependency_overrides: vec![],
2251 invalid_client_exports: vec![],
2252 mixed_client_server_barrels: vec![],
2253 misplaced_directives: vec![],
2254 route_collisions: vec![],
2255 dynamic_segment_name_conflicts: vec![],
2256 };
2257 let results = AnalysisResults {
2258 unused_files: vec![
2259 UnusedFileFinding::with_actions(UnusedFile {
2260 path: PathBuf::from("src/old.ts"),
2261 }),
2262 UnusedFileFinding::with_actions(UnusedFile {
2263 path: PathBuf::from("src/new-dead.ts"),
2264 }),
2265 ],
2266 ..Default::default()
2267 };
2268 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2269 assert_eq!(filtered.unused_files.len(), 1);
2270 assert_eq!(
2271 filtered.unused_files[0].file.path,
2272 PathBuf::from("src/new-dead.ts")
2273 );
2274 }
2275
2276 #[test]
2277 fn filter_with_empty_baseline_keeps_all() {
2278 let baseline = BaselineData {
2279 unused_files: vec![],
2280 unused_exports: vec![],
2281 unused_types: vec![],
2282 private_type_leaks: vec![],
2283 unused_dependencies: vec![],
2284 unused_dev_dependencies: vec![],
2285 circular_dependencies: vec![],
2286 re_export_cycles: vec![],
2287 unused_optional_dependencies: vec![],
2288 unused_enum_members: vec![],
2289 unused_class_members: vec![],
2290 unused_store_members: vec![],
2291 unprovided_injects: vec![],
2292 unrendered_components: vec![],
2293 unused_component_props: vec![],
2294 unused_component_emits: vec![],
2295 unused_component_inputs: vec![],
2296 unused_component_outputs: vec![],
2297 unused_svelte_events: vec![],
2298 unused_server_actions: vec![],
2299 unused_load_data_keys: vec![],
2300 unresolved_imports: vec![],
2301 unlisted_dependencies: vec![],
2302 duplicate_exports: vec![],
2303 type_only_dependencies: vec![],
2304 test_only_dependencies: vec![],
2305 dev_dependencies_in_production: vec![],
2306 boundary_violations: vec![],
2307 boundary_coverage_violations: vec![],
2308 boundary_call_violations: vec![],
2309 policy_violations: vec![],
2310 stale_suppressions: vec![],
2311 unused_catalog_entries: vec![],
2312 empty_catalog_groups: vec![],
2313 unresolved_catalog_references: vec![],
2314 unused_dependency_overrides: vec![],
2315 misconfigured_dependency_overrides: vec![],
2316 invalid_client_exports: vec![],
2317 mixed_client_server_barrels: vec![],
2318 misplaced_directives: vec![],
2319 route_collisions: vec![],
2320 dynamic_segment_name_conflicts: vec![],
2321 };
2322 let results = make_results();
2323 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2324 assert_eq!(filtered.unused_files.len(), 2);
2325 assert_eq!(filtered.unused_exports.len(), 1);
2326 }
2327
2328 #[test]
2329 fn filter_new_exports_by_file_and_name() {
2330 let baseline = BaselineData {
2331 unused_files: vec![],
2332 unused_exports: vec!["src/utils.ts:helperA".to_string()],
2333 unused_types: vec![],
2334 private_type_leaks: vec![],
2335 unused_dependencies: vec![],
2336 unused_dev_dependencies: vec![],
2337 circular_dependencies: vec![],
2338 re_export_cycles: vec![],
2339 unused_optional_dependencies: vec![],
2340 unused_enum_members: vec![],
2341 unused_class_members: vec![],
2342 unused_store_members: vec![],
2343 unprovided_injects: vec![],
2344 unrendered_components: vec![],
2345 unused_component_props: vec![],
2346 unused_component_emits: vec![],
2347 unused_component_inputs: vec![],
2348 unused_component_outputs: vec![],
2349 unused_svelte_events: vec![],
2350 unused_server_actions: vec![],
2351 unused_load_data_keys: vec![],
2352 unresolved_imports: vec![],
2353 unlisted_dependencies: vec![],
2354 duplicate_exports: vec![],
2355 type_only_dependencies: vec![],
2356 test_only_dependencies: vec![],
2357 dev_dependencies_in_production: vec![],
2358 boundary_violations: vec![],
2359 boundary_coverage_violations: vec![],
2360 boundary_call_violations: vec![],
2361 policy_violations: vec![],
2362 stale_suppressions: vec![],
2363 unused_catalog_entries: vec![],
2364 empty_catalog_groups: vec![],
2365 unresolved_catalog_references: vec![],
2366 unused_dependency_overrides: vec![],
2367 misconfigured_dependency_overrides: vec![],
2368 invalid_client_exports: vec![],
2369 mixed_client_server_barrels: vec![],
2370 misplaced_directives: vec![],
2371 route_collisions: vec![],
2372 dynamic_segment_name_conflicts: vec![],
2373 };
2374 let results = AnalysisResults {
2375 unused_exports: vec![
2376 UnusedExportFinding::with_actions(UnusedExport {
2377 path: PathBuf::from("src/utils.ts"),
2378 export_name: "helperA".to_string(),
2379 is_type_only: false,
2380 line: 5,
2381 col: 0,
2382 span_start: 40,
2383 is_re_export: false,
2384 }),
2385 UnusedExportFinding::with_actions(UnusedExport {
2386 path: PathBuf::from("src/utils.ts"),
2387 export_name: "helperB".to_string(),
2388 is_type_only: false,
2389 line: 10,
2390 col: 0,
2391 span_start: 80,
2392 is_re_export: false,
2393 }),
2394 ],
2395 ..Default::default()
2396 };
2397 let filtered = filter_new_issues(results, &baseline, Path::new(""));
2398 assert_eq!(filtered.unused_exports.len(), 1);
2399 assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
2400 }
2401
2402 fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
2403 CloneGroup {
2404 instances: instances
2405 .into_iter()
2406 .map(|(file, start, end)| CloneInstance {
2407 file: PathBuf::from(file),
2408 start_line: start,
2409 end_line: end,
2410 start_col: 0,
2411 end_col: 0,
2412 fragment: String::new(),
2413 })
2414 .collect(),
2415 token_count: 50,
2416 line_count: 10,
2417 }
2418 }
2419
2420 fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
2421 DuplicationReport {
2422 clone_groups: groups,
2423 clone_families: vec![],
2424 mirrored_directories: vec![],
2425 stats: DuplicationStats {
2426 total_files: 10,
2427 files_with_clones: 2,
2428 total_lines: 1000,
2429 duplicated_lines: 100,
2430 total_tokens: 5000,
2431 duplicated_tokens: 500,
2432 clone_groups: 1,
2433 clone_instances: 2,
2434 duplication_percentage: 10.0,
2435 clone_groups_below_min_occurrences: 0,
2436 },
2437 }
2438 }
2439
2440 #[test]
2441 fn clone_group_key_is_deterministic() {
2442 let root = Path::new("/project");
2443 let group = make_clone_group(vec![
2444 ("/project/src/a.ts", 1, 10),
2445 ("/project/src/b.ts", 5, 15),
2446 ]);
2447 let key1 = clone_group_key(&group, root);
2448 let key2 = clone_group_key(&group, root);
2449 assert_eq!(key1, key2);
2450 }
2451
2452 #[test]
2453 fn clone_group_key_is_sorted() {
2454 let root = Path::new("/project");
2455 let group_ab = make_clone_group(vec![
2456 ("/project/src/a.ts", 1, 10),
2457 ("/project/src/b.ts", 5, 15),
2458 ]);
2459 let group_ba = make_clone_group(vec![
2460 ("/project/src/b.ts", 5, 15),
2461 ("/project/src/a.ts", 1, 10),
2462 ]);
2463 assert_eq!(
2464 clone_group_key(&group_ab, root),
2465 clone_group_key(&group_ba, root),
2466 "key should be stable regardless of instance order"
2467 );
2468 }
2469
2470 #[test]
2471 fn duplication_baseline_roundtrip() {
2472 let root = Path::new("/project");
2473 let group = make_clone_group(vec![
2474 ("/project/src/a.ts", 1, 10),
2475 ("/project/src/b.ts", 5, 15),
2476 ]);
2477 let report = make_duplication_report(vec![group]);
2478 let baseline = DuplicationBaselineData::from_report(&report, root);
2479 let json = serde_json::to_string(&baseline).unwrap();
2480 let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
2481 assert_eq!(deserialized.clone_groups, baseline.clone_groups);
2482 }
2483
2484 #[test]
2485 fn filter_new_clone_groups_removes_baseline() {
2486 let root = Path::new("/project");
2487 let group = make_clone_group(vec![
2488 ("/project/src/a.ts", 1, 10),
2489 ("/project/src/b.ts", 5, 15),
2490 ]);
2491 let report = make_duplication_report(vec![group]);
2492 let baseline = DuplicationBaselineData::from_report(&report, root);
2493 let filtered = filter_new_clone_groups(report, &baseline, root);
2494 assert!(
2495 filtered.clone_groups.is_empty(),
2496 "baseline group should be filtered out"
2497 );
2498 }
2499
2500 #[test]
2501 fn filter_new_clone_groups_keeps_new_groups() {
2502 let root = Path::new("/project");
2503 let baseline_group = make_clone_group(vec![
2504 ("/project/src/a.ts", 1, 10),
2505 ("/project/src/b.ts", 5, 15),
2506 ]);
2507 let new_group = make_clone_group(vec![
2508 ("/project/src/c.ts", 20, 30),
2509 ("/project/src/d.ts", 25, 35),
2510 ]);
2511 let baseline_report = make_duplication_report(vec![baseline_group]);
2512 let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
2513
2514 let report = make_duplication_report(vec![
2515 make_clone_group(vec![
2516 ("/project/src/a.ts", 1, 10),
2517 ("/project/src/b.ts", 5, 15),
2518 ]),
2519 new_group,
2520 ]);
2521 let filtered = filter_new_clone_groups(report, &baseline, root);
2522 assert_eq!(
2523 filtered.clone_groups.len(),
2524 1,
2525 "only the new group should remain"
2526 );
2527 }
2528
2529 #[test]
2530 fn recompute_stats_after_filtering() {
2531 let root = Path::new("/project");
2532 let group = make_clone_group(vec![
2533 ("/project/src/a.ts", 1, 10),
2534 ("/project/src/b.ts", 5, 15),
2535 ]);
2536 let report = make_duplication_report(vec![group]);
2537 let baseline = DuplicationBaselineData::from_report(&report, root);
2538 let filtered = filter_new_clone_groups(report, &baseline, root);
2539 assert_eq!(filtered.stats.clone_groups, 0);
2540 assert_eq!(filtered.stats.clone_instances, 0);
2541 assert_eq!(filtered.stats.duplicated_lines, 0);
2542 }
2543
2544 #[test]
2545 fn recompute_stats_zero_total_lines() {
2546 let report = DuplicationReport {
2547 clone_groups: vec![],
2548 clone_families: vec![],
2549 mirrored_directories: vec![],
2550 stats: DuplicationStats {
2551 total_files: 0,
2552 files_with_clones: 0,
2553 total_lines: 0,
2554 duplicated_lines: 0,
2555 total_tokens: 0,
2556 duplicated_tokens: 0,
2557 clone_groups: 0,
2558 clone_instances: 0,
2559 duplication_percentage: 0.0,
2560 clone_groups_below_min_occurrences: 0,
2561 },
2562 };
2563 let stats = super::recompute_stats(&report);
2564 assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
2565 }
2566
2567 fn make_health_finding(
2568 root: &Path,
2569 name: &str,
2570 line: u32,
2571 ) -> fallow_output::ComplexityViolation {
2572 make_health_finding_with(
2573 root,
2574 name,
2575 line,
2576 fallow_output::ExceededThreshold::Both,
2577 fallow_output::FindingSeverity::High,
2578 )
2579 }
2580
2581 fn make_health_finding_with(
2582 root: &Path,
2583 name: &str,
2584 line: u32,
2585 exceeded: fallow_output::ExceededThreshold,
2586 severity: fallow_output::FindingSeverity,
2587 ) -> fallow_output::ComplexityViolation {
2588 fallow_output::ComplexityViolation {
2589 path: root.join("src/utils.ts"),
2590 name: name.to_string(),
2591 line,
2592 col: 0,
2593 cyclomatic: 25,
2594 cognitive: 30,
2595 line_count: 80,
2596 param_count: 0,
2597 react_hook_count: 0,
2598 react_jsx_max_depth: 0,
2599 react_prop_count: 0,
2600 react_hook_profile: None,
2601 exceeded,
2602 severity,
2603 crap: None,
2604 coverage_pct: None,
2605 coverage_tier: None,
2606 coverage_source: None,
2607 inherited_from: None,
2608 component_rollup: None,
2609 contributions: Vec::new(),
2610 effective_thresholds: None,
2611 threshold_source: None,
2612 }
2613 }
2614
2615 #[test]
2616 fn health_baseline_roundtrip() {
2617 let root = PathBuf::from("/project");
2618 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2619 let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
2620 let json = serde_json::to_string(&baseline).unwrap();
2621 let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
2622 assert_eq!(deserialized.findings, baseline.findings);
2623 assert_eq!(baseline.findings, Vec::<String>::new());
2624 assert_eq!(
2625 deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
2626 1
2627 );
2628 assert!(!json.contains("parseExpression"));
2629 }
2630
2631 #[test]
2632 fn health_baseline_filters_known_findings() {
2633 let root = PathBuf::from("/project");
2634 let mut findings = vec![
2635 make_health_finding(&root, "parseExpression", 42),
2636 make_health_finding(&root, "newFunction", 100),
2637 ];
2638 findings[1].path = root.join("src/other.ts");
2639 let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
2640 let filtered = filter_new_health_findings(findings, &baseline, &root);
2641 assert_eq!(filtered.len(), 1);
2642 assert_eq!(filtered[0].name, "newFunction");
2643 }
2644
2645 #[test]
2646 fn health_baseline_filters_shifted_lines_with_same_category_count() {
2647 let root = PathBuf::from("/project");
2648 let baseline = HealthBaselineData::from_findings(
2649 &[make_health_finding(&root, "parseExpression", 42)],
2650 &[],
2651 &[],
2652 &root,
2653 );
2654 let filtered = filter_new_health_findings(
2655 vec![make_health_finding(&root, "parseExpression", 43)],
2656 &baseline,
2657 &root,
2658 );
2659 assert!(filtered.is_empty());
2660 }
2661
2662 #[test]
2663 fn health_baseline_reports_full_category_when_count_increases() {
2664 let root = PathBuf::from("/project");
2665 let baseline = HealthBaselineData::from_findings(
2666 &[make_health_finding(&root, "parseExpression", 42)],
2667 &[],
2668 &[],
2669 &root,
2670 );
2671 let filtered = filter_new_health_findings(
2672 vec![
2673 make_health_finding(&root, "parseExpression", 43),
2674 make_health_finding(&root, "newFunction", 100),
2675 ],
2676 &baseline,
2677 &root,
2678 );
2679 assert_eq!(filtered.len(), 2);
2680 }
2681
2682 #[test]
2683 fn health_baseline_legacy_findings_still_load() {
2684 let root = PathBuf::from("/project");
2685 let baseline = HealthBaselineData {
2686 findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
2687 finding_counts: BTreeMap::new(),
2688 target_keys: vec![],
2689 runtime_coverage_findings: vec![],
2690 runtime_coverage_source_hashes: vec![],
2691 };
2692 let filtered = filter_new_health_findings(
2693 vec![make_health_finding(&root, "parseExpression", 42)],
2694 &baseline,
2695 &root,
2696 );
2697 assert!(filtered.is_empty());
2698 }
2699
2700 #[test]
2701 fn health_baseline_keeps_crap_categories_separate_from_complexity() {
2702 let root = PathBuf::from("/project");
2703 let baseline = HealthBaselineData::from_findings(
2704 &[make_health_finding_with(
2705 &root,
2706 "parseExpression",
2707 42,
2708 fallow_output::ExceededThreshold::Crap,
2709 fallow_output::FindingSeverity::High,
2710 )],
2711 &[],
2712 &[],
2713 &root,
2714 );
2715 let filtered = filter_new_health_findings(
2716 vec![
2717 make_health_finding_with(
2718 &root,
2719 "parseExpression",
2720 43,
2721 fallow_output::ExceededThreshold::Crap,
2722 fallow_output::FindingSeverity::High,
2723 ),
2724 make_health_finding(&root, "newComplexityOnlyFunction", 100),
2725 ],
2726 &baseline,
2727 &root,
2728 );
2729 assert_eq!(filtered.len(), 1);
2730 assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
2731 }
2732
2733 #[test]
2734 fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
2735 let root = PathBuf::from("/project");
2736 let baseline = HealthBaselineData::from_findings(
2737 &[make_health_finding_with(
2738 &root,
2739 "parseExpression",
2740 42,
2741 fallow_output::ExceededThreshold::Both,
2742 fallow_output::FindingSeverity::Critical,
2743 )],
2744 &[],
2745 &[],
2746 &root,
2747 );
2748 let filtered = filter_new_health_findings(
2749 vec![make_health_finding_with(
2750 &root,
2751 "parseExpression",
2752 42,
2753 fallow_output::ExceededThreshold::Both,
2754 fallow_output::FindingSeverity::High,
2755 )],
2756 &baseline,
2757 &root,
2758 );
2759 assert!(filtered.is_empty());
2760 }
2761
2762 #[test]
2763 fn health_baseline_still_reports_worse_current_severity_as_new() {
2764 let root = PathBuf::from("/project");
2765 let baseline = HealthBaselineData::from_findings(
2766 &[make_health_finding_with(
2767 &root,
2768 "parseExpression",
2769 42,
2770 fallow_output::ExceededThreshold::Both,
2771 fallow_output::FindingSeverity::High,
2772 )],
2773 &[],
2774 &[],
2775 &root,
2776 );
2777 let filtered = filter_new_health_findings(
2778 vec![make_health_finding_with(
2779 &root,
2780 "parseExpression",
2781 42,
2782 fallow_output::ExceededThreshold::Both,
2783 fallow_output::FindingSeverity::Critical,
2784 )],
2785 &baseline,
2786 &root,
2787 );
2788 assert_eq!(filtered.len(), 1);
2789 assert_eq!(filtered[0].name, "parseExpression");
2790 assert!(matches!(
2791 filtered[0].severity,
2792 fallow_output::FindingSeverity::Critical
2793 ));
2794 }
2795
2796 #[test]
2797 fn health_baseline_overlap_counts_partial_category_overflow() {
2798 let root = PathBuf::from("/project");
2799 let baseline = HealthBaselineData::from_findings(
2800 &[make_health_finding(&root, "parseExpression", 42)],
2801 &[],
2802 &[],
2803 &root,
2804 );
2805 let overlap = baseline.overlap_entry_count(
2806 &[
2807 make_health_finding(&root, "parseExpression", 42),
2808 make_health_finding(&root, "newFunction", 100),
2809 ],
2810 &root,
2811 );
2812 assert_eq!(overlap, 1);
2813 }
2814
2815 #[test]
2816 fn health_baseline_empty_keeps_all() {
2817 let root = PathBuf::from("/project");
2818 let findings = vec![make_health_finding(&root, "parseExpression", 42)];
2819 let baseline = HealthBaselineData {
2820 findings: vec![],
2821 finding_counts: BTreeMap::new(),
2822 target_keys: vec![],
2823 runtime_coverage_findings: vec![],
2824 runtime_coverage_source_hashes: vec![],
2825 };
2826 let filtered = filter_new_health_findings(findings, &baseline, &root);
2827 assert_eq!(filtered.len(), 1);
2828 }
2829
2830 #[test]
2831 fn circular_dep_key_is_order_independent() {
2832 use crate::results::CircularDependency;
2833
2834 let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
2835 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2836 length: 2,
2837 line: 1,
2838 col: 0,
2839 edges: Vec::new(),
2840 is_cross_package: false,
2841 });
2842 let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
2843 files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
2844 length: 2,
2845 line: 1,
2846 col: 0,
2847 edges: Vec::new(),
2848 is_cross_package: false,
2849 });
2850 assert_eq!(
2851 super::circular_dep_key(&dep_ab.cycle, Path::new("")),
2852 super::circular_dep_key(&dep_ba.cycle, Path::new("")),
2853 "same files in different order should produce identical keys"
2854 );
2855 }
2856
2857 #[test]
2858 fn circular_dep_key_different_files_different_keys() {
2859 use crate::results::CircularDependency;
2860
2861 let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
2862 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2863 length: 2,
2864 line: 1,
2865 col: 0,
2866 edges: Vec::new(),
2867 is_cross_package: false,
2868 });
2869 let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
2870 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
2871 length: 2,
2872 line: 1,
2873 col: 0,
2874 edges: Vec::new(),
2875 is_cross_package: false,
2876 });
2877 assert_ne!(
2878 super::circular_dep_key(&dep1.cycle, Path::new("")),
2879 super::circular_dep_key(&dep2.cycle, Path::new("")),
2880 );
2881 }
2882
2883 #[test]
2884 fn circular_dep_key_three_files_order_independent() {
2885 use crate::results::CircularDependency;
2886
2887 let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
2888 files: vec![
2889 PathBuf::from("src/a.ts"),
2890 PathBuf::from("src/b.ts"),
2891 PathBuf::from("src/c.ts"),
2892 ],
2893 length: 3,
2894 line: 1,
2895 col: 0,
2896 edges: Vec::new(),
2897 is_cross_package: false,
2898 });
2899 let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
2900 files: vec![
2901 PathBuf::from("src/c.ts"),
2902 PathBuf::from("src/a.ts"),
2903 PathBuf::from("src/b.ts"),
2904 ],
2905 length: 3,
2906 line: 1,
2907 col: 0,
2908 edges: Vec::new(),
2909 is_cross_package: false,
2910 });
2911 assert_eq!(
2912 super::circular_dep_key(&dep_abc.cycle, Path::new("")),
2913 super::circular_dep_key(&dep_cab.cycle, Path::new("")),
2914 );
2915 }
2916
2917 #[expect(
2918 clippy::too_many_lines,
2919 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2920 )]
2921 fn make_full_results() -> AnalysisResults {
2922 use crate::results::*;
2923 use crate::source::MemberKind;
2924
2925 let mut r = make_results();
2926 r.circular_dependencies
2927 .push(CircularDependencyFinding::with_actions(
2928 CircularDependency {
2929 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
2930 length: 2,
2931 line: 1,
2932 col: 0,
2933 edges: Vec::new(),
2934 is_cross_package: false,
2935 },
2936 ));
2937 r.unused_optional_dependencies
2938 .push(UnusedOptionalDependencyFinding::with_actions(
2939 UnusedDependency {
2940 package_name: "fsevents".to_string(),
2941 location: DependencyLocation::OptionalDependencies,
2942 path: PathBuf::from("package.json"),
2943 line: 15,
2944 used_in_workspaces: Vec::new(),
2945 },
2946 ));
2947 r.unused_enum_members
2948 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
2949 path: PathBuf::from("src/enums.ts"),
2950 parent_name: "Status".to_string(),
2951 member_name: "Deprecated".to_string(),
2952 kind: MemberKind::EnumMember,
2953 line: 8,
2954 col: 0,
2955 }));
2956 r.unused_class_members
2957 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2958 path: PathBuf::from("src/service.ts"),
2959 parent_name: "UserService".to_string(),
2960 member_name: "legacy".to_string(),
2961 kind: MemberKind::ClassMethod,
2962 line: 42,
2963 col: 0,
2964 }));
2965 r.unused_store_members
2966 .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
2967 path: PathBuf::from("src/store.ts"),
2968 parent_name: "useStore".to_string(),
2969 member_name: "legacyAction".to_string(),
2970 kind: MemberKind::StoreMember,
2971 line: 17,
2972 col: 0,
2973 }));
2974 r.unresolved_imports.push(
2975 fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
2976 crate::results::UnresolvedImport {
2977 path: PathBuf::from("src/app.ts"),
2978 specifier: "./missing".to_string(),
2979 line: 3,
2980 col: 0,
2981 specifier_col: 0,
2982 },
2983 ),
2984 );
2985 r.unlisted_dependencies
2986 .push(crate::results::UnlistedDependencyFinding::with_actions(
2987 UnlistedDependency {
2988 package_name: "chalk".to_string(),
2989 imported_from: vec![],
2990 },
2991 ));
2992 r.duplicate_exports
2993 .push(crate::results::DuplicateExportFinding::with_actions(
2994 crate::results::DuplicateExport {
2995 export_name: "Config".to_string(),
2996 locations: vec![
2997 crate::results::DuplicateLocation {
2998 path: PathBuf::from("src/a.ts"),
2999 line: 1,
3000 col: 0,
3001 },
3002 crate::results::DuplicateLocation {
3003 path: PathBuf::from("src/b.ts"),
3004 line: 5,
3005 col: 0,
3006 },
3007 ],
3008 },
3009 ));
3010 r.type_only_dependencies
3011 .push(crate::results::TypeOnlyDependencyFinding::with_actions(
3012 TypeOnlyDependency {
3013 package_name: "zod".to_string(),
3014 path: PathBuf::from("package.json"),
3015 line: 8,
3016 },
3017 ));
3018 r.test_only_dependencies
3019 .push(crate::results::TestOnlyDependencyFinding::with_actions(
3020 TestOnlyDependency {
3021 package_name: "vitest".to_string(),
3022 path: PathBuf::from("package.json"),
3023 line: 10,
3024 },
3025 ));
3026 r.boundary_violations.push(
3027 fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
3028 crate::results::BoundaryViolation {
3029 from_path: PathBuf::from("src/ui/btn.ts"),
3030 to_path: PathBuf::from("src/db/query.ts"),
3031 from_zone: "ui".to_string(),
3032 to_zone: "db".to_string(),
3033 import_specifier: "../db/query".to_string(),
3034 line: 1,
3035 col: 0,
3036 },
3037 ),
3038 );
3039 r
3040 }
3041
3042 #[test]
3043 fn baseline_from_results_captures_all_extended_fields() {
3044 let results = make_full_results();
3045 let baseline = BaselineData::from_results(&results, Path::new(""));
3046 assert_eq!(baseline.circular_dependencies.len(), 1);
3047 assert_eq!(
3048 baseline.unused_optional_dependencies,
3049 vec!["package.json:fsevents"]
3050 );
3051 assert_eq!(baseline.unused_enum_members.len(), 1);
3052 assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
3053 assert_eq!(baseline.unused_class_members.len(), 1);
3054 assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
3055 assert_eq!(baseline.unused_store_members.len(), 1);
3056 assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
3057 assert_eq!(baseline.unresolved_imports.len(), 1);
3058 assert!(baseline.unresolved_imports[0].contains("./missing"));
3059 assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
3060 assert_eq!(baseline.duplicate_exports.len(), 1);
3061 assert!(baseline.duplicate_exports[0].starts_with("Config|"));
3062 assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
3063 assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
3064 assert_eq!(baseline.boundary_violations.len(), 1);
3065 assert!(baseline.boundary_violations[0].contains("->"));
3066 }
3067
3068 #[test]
3069 fn filter_removes_all_extended_baseline_issues() {
3070 let results = make_full_results();
3071 let baseline = BaselineData::from_results(&results, Path::new(""));
3072 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3073 assert!(filtered.circular_dependencies.is_empty());
3074 assert!(filtered.unused_optional_dependencies.is_empty());
3075 assert!(filtered.unused_enum_members.is_empty());
3076 assert!(filtered.unused_class_members.is_empty());
3077 assert!(filtered.unused_store_members.is_empty());
3078 assert!(filtered.unresolved_imports.is_empty());
3079 assert!(filtered.unlisted_dependencies.is_empty());
3080 assert!(filtered.duplicate_exports.is_empty());
3081 assert!(filtered.type_only_dependencies.is_empty());
3082 assert!(filtered.test_only_dependencies.is_empty());
3083 assert!(filtered.boundary_violations.is_empty());
3084 }
3085
3086 #[test]
3087 fn filter_keeps_new_circular_deps() {
3088 use crate::results::CircularDependency;
3089 let baseline = BaselineData {
3090 circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
3091 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3092 };
3093 let mut results = AnalysisResults::default();
3094 results
3095 .circular_dependencies
3096 .push(CircularDependencyFinding::with_actions(
3097 CircularDependency {
3098 files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
3099 length: 2,
3100 line: 1,
3101 col: 0,
3102 edges: Vec::new(),
3103 is_cross_package: false,
3104 },
3105 ));
3106 results
3107 .circular_dependencies
3108 .push(CircularDependencyFinding::with_actions(
3109 CircularDependency {
3110 files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
3111 length: 2,
3112 line: 5,
3113 col: 0,
3114 edges: Vec::new(),
3115 is_cross_package: false,
3116 },
3117 ));
3118 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3119 assert_eq!(filtered.circular_dependencies.len(), 1);
3120 }
3121
3122 #[test]
3123 fn filter_keeps_new_boundary_violations() {
3124 use crate::results::BoundaryViolation;
3125 let baseline = BaselineData {
3126 boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
3127 boundary_coverage_violations: vec![],
3128 boundary_call_violations: vec![],
3129 policy_violations: vec![],
3130 ..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
3131 };
3132 let mut results = AnalysisResults::default();
3133 results
3134 .boundary_violations
3135 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3136 from_path: PathBuf::from("src/a.ts"),
3137 to_path: PathBuf::from("src/b.ts"),
3138 from_zone: "a".to_string(),
3139 to_zone: "b".to_string(),
3140 import_specifier: "../b".to_string(),
3141 line: 1,
3142 col: 0,
3143 }));
3144 results
3145 .boundary_violations
3146 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
3147 from_path: PathBuf::from("src/new.ts"),
3148 to_path: PathBuf::from("src/secret.ts"),
3149 from_zone: "new".to_string(),
3150 to_zone: "secret".to_string(),
3151 import_specifier: "../secret".to_string(),
3152 line: 1,
3153 col: 0,
3154 }));
3155 let filtered = filter_new_issues(results, &baseline, Path::new(""));
3156 assert_eq!(filtered.boundary_violations.len(), 1);
3157 }
3158
3159 #[test]
3160 fn health_targets_baseline_filters_known() {
3161 let root = PathBuf::from("/project");
3162 let targets = vec![
3163 fallow_output::RefactoringTarget {
3164 path: root.join("src/complex.ts"),
3165 priority: 80.0,
3166 efficiency: 40.0,
3167 recommendation: "Split file".to_string(),
3168 category: fallow_output::RecommendationCategory::SplitHighImpact,
3169 effort: fallow_output::EffortEstimate::Medium,
3170 confidence: fallow_output::Confidence::Medium,
3171 factors: vec![],
3172 evidence: None,
3173 },
3174 fallow_output::RefactoringTarget {
3175 path: root.join("src/new-issue.ts"),
3176 priority: 60.0,
3177 efficiency: 30.0,
3178 recommendation: "Extract function".to_string(),
3179 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
3180 effort: fallow_output::EffortEstimate::Low,
3181 confidence: fallow_output::Confidence::High,
3182 factors: vec![],
3183 evidence: None,
3184 },
3185 ];
3186 let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
3187 let filtered = filter_new_health_targets(targets, &baseline, &root);
3188 assert_eq!(filtered.len(), 1);
3189 assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
3190 }
3191
3192 #[test]
3193 fn duplicate_export_key_is_sorted() {
3194 use crate::results::{DuplicateExport, DuplicateLocation};
3195 let dup_ab = DuplicateExport {
3196 export_name: "foo".to_string(),
3197 locations: vec![
3198 DuplicateLocation {
3199 path: PathBuf::from("src/a.ts"),
3200 line: 1,
3201 col: 0,
3202 },
3203 DuplicateLocation {
3204 path: PathBuf::from("src/b.ts"),
3205 line: 5,
3206 col: 0,
3207 },
3208 ],
3209 };
3210 let dup_ba = DuplicateExport {
3211 export_name: "foo".to_string(),
3212 locations: vec![
3213 DuplicateLocation {
3214 path: PathBuf::from("src/b.ts"),
3215 line: 5,
3216 col: 0,
3217 },
3218 DuplicateLocation {
3219 path: PathBuf::from("src/a.ts"),
3220 line: 1,
3221 col: 0,
3222 },
3223 ],
3224 };
3225 assert_eq!(
3226 super::duplicate_export_key(&dup_ab, Path::new("")),
3227 super::duplicate_export_key(&dup_ba, Path::new("")),
3228 );
3229 }
3230
3231 #[test]
3232 fn boundary_violation_key_format() {
3233 use crate::results::BoundaryViolation;
3234 let v = BoundaryViolation {
3235 from_path: PathBuf::from("src/ui/btn.ts"),
3236 to_path: PathBuf::from("src/db/query.ts"),
3237 from_zone: "ui".to_string(),
3238 to_zone: "db".to_string(),
3239 import_specifier: "../db/query".to_string(),
3240 line: 1,
3241 col: 0,
3242 };
3243 let key = super::boundary_violation_key(&v, Path::new(""));
3244 assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
3245 }
3246
3247 fn make_absolute_results(root: &str) -> AnalysisResults {
3249 use crate::results::*;
3250 use crate::source::MemberKind;
3251
3252 let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
3253
3254 AnalysisResults {
3255 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
3256 path: p("src/old.ts"),
3257 })],
3258 unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3259 path: p("src/utils.ts"),
3260 export_name: "helper".to_string(),
3261 is_type_only: false,
3262 line: 5,
3263 col: 0,
3264 span_start: 40,
3265 is_re_export: false,
3266 })],
3267 unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3268 package_name: "lodash-es".to_string(),
3269 location: DependencyLocation::Dependencies,
3270 path: p("packages/app/package.json"),
3271 line: 5,
3272 used_in_workspaces: Vec::new(),
3273 })],
3274 circular_dependencies: vec![CircularDependencyFinding::with_actions(
3275 CircularDependency {
3276 files: vec![p("src/a.ts"), p("src/b.ts")],
3277 length: 2,
3278 line: 1,
3279 col: 0,
3280 edges: Vec::new(),
3281 is_cross_package: false,
3282 },
3283 )],
3284 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
3285 path: p("src/enums.ts"),
3286 parent_name: "Status".to_string(),
3287 member_name: "Deprecated".to_string(),
3288 kind: MemberKind::EnumMember,
3289 line: 8,
3290 col: 0,
3291 })],
3292 unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
3293 path: p("src/service.ts"),
3294 parent_name: "UserService".to_string(),
3295 member_name: "legacy".to_string(),
3296 kind: MemberKind::ClassMethod,
3297 line: 42,
3298 col: 0,
3299 })],
3300 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
3301 path: p("src/store.ts"),
3302 parent_name: "useStore".to_string(),
3303 member_name: "legacyAction".to_string(),
3304 kind: MemberKind::StoreMember,
3305 line: 17,
3306 col: 0,
3307 })],
3308 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
3309 path: p("src/app.ts"),
3310 specifier: "./missing".to_string(),
3311 line: 3,
3312 col: 0,
3313 specifier_col: 0,
3314 })],
3315 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
3316 export_name: "Config".to_string(),
3317 locations: vec![
3318 DuplicateLocation {
3319 path: p("src/a.ts"),
3320 line: 1,
3321 col: 0,
3322 },
3323 DuplicateLocation {
3324 path: p("src/b.ts"),
3325 line: 5,
3326 col: 0,
3327 },
3328 ],
3329 })],
3330 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
3331 from_path: p("src/ui/btn.ts"),
3332 to_path: p("src/db/query.ts"),
3333 from_zone: "ui".to_string(),
3334 to_zone: "db".to_string(),
3335 import_specifier: "../db/query".to_string(),
3336 line: 1,
3337 col: 0,
3338 })],
3339 ..Default::default()
3340 }
3341 }
3342
3343 #[test]
3346 fn baseline_keys_are_relative_to_root() {
3347 let local_root = Path::new("/Users/dev/project");
3348 let results = make_absolute_results("/Users/dev/project");
3349 let baseline = BaselineData::from_results(&results, local_root);
3350
3351 assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
3352 assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
3353 assert_eq!(
3354 baseline.unused_dependencies,
3355 vec!["packages/app/package.json:lodash-es"]
3356 );
3357 assert_eq!(
3358 baseline.boundary_violations,
3359 vec!["src/ui/btn.ts->src/db/query.ts"]
3360 );
3361 assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
3362 assert_eq!(
3363 baseline.unused_enum_members,
3364 vec!["src/enums.ts:Status.Deprecated"]
3365 );
3366 assert_eq!(
3367 baseline.unused_class_members,
3368 vec!["src/service.ts:UserService.legacy"]
3369 );
3370 assert_eq!(
3371 baseline.unused_store_members,
3372 vec!["src/store.ts:useStore.legacyAction"]
3373 );
3374 assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
3375 assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
3376
3377 let ci_root = Path::new("/home/runner/work/project/project");
3378 let ci_results = make_absolute_results("/home/runner/work/project/project");
3379
3380 let filtered = filter_new_issues(ci_results, &baseline, ci_root);
3381 assert!(filtered.unused_files.is_empty(), "unused files");
3382 assert!(filtered.unused_exports.is_empty(), "unused exports");
3383 assert!(filtered.unused_dependencies.is_empty(), "unused deps");
3384 assert!(
3385 filtered.boundary_violations.is_empty(),
3386 "boundary violations"
3387 );
3388 assert!(filtered.circular_dependencies.is_empty(), "circular deps");
3389 assert!(filtered.unused_enum_members.is_empty(), "enum members");
3390 assert!(filtered.unused_class_members.is_empty(), "class members");
3391 assert!(filtered.unused_store_members.is_empty(), "store members");
3392 assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
3393 assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
3394 }
3395
3396 #[test]
3397 fn stale_suppression_baseline_keys_include_missing_reason_state() {
3398 let root = Path::new("/project");
3399 let stale = crate::results::StaleSuppression {
3400 path: root.join("src/file.ts"),
3401 line: 1,
3402 col: 0,
3403 origin: crate::results::SuppressionOrigin::Comment {
3404 issue_kind: Some("unused-export".to_string()),
3405 reason: None,
3406 is_file_level: false,
3407 kind_known: true,
3408 },
3409 missing_reason: false,
3410 actions: crate::results::StaleSuppression::actions_for(false),
3411 };
3412 let missing = crate::results::StaleSuppression {
3413 missing_reason: true,
3414 actions: crate::results::StaleSuppression::actions_for(true),
3415 ..stale.clone()
3416 };
3417 let results = AnalysisResults {
3418 stale_suppressions: vec![stale, missing],
3419 ..Default::default()
3420 };
3421 let baseline = BaselineData::from_results(&results, root);
3422
3423 assert_eq!(
3424 baseline.stale_suppressions,
3425 vec![
3426 "stale-suppression:src/file.ts:1",
3427 "missing-suppression-reason:src/file.ts:1",
3428 ]
3429 );
3430
3431 let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
3432 legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
3433 let filtered = filter_new_issues(results, &legacy_baseline, root);
3434 assert!(filtered.stale_suppressions.is_empty());
3435 }
3436
3437 fn runtime_finding(
3438 id: &str,
3439 stable_id: Option<&str>,
3440 line: u32,
3441 source_hash: Option<&str>,
3442 ) -> fallow_output::RuntimeCoverageFinding {
3443 fallow_output::RuntimeCoverageFinding {
3444 id: id.to_owned(),
3445 stable_id: stable_id.map(str::to_owned),
3446 source_hash: source_hash.map(str::to_owned),
3447 path: PathBuf::from("src/a.ts"),
3448 function: "alpha".to_owned(),
3449 line,
3450 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
3451 invocations: Some(0),
3452 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
3453 evidence: fallow_output::RuntimeCoverageEvidence {
3454 static_status: "used".to_owned(),
3455 test_coverage: "not_covered".to_owned(),
3456 v8_tracking: "tracked".to_owned(),
3457 untracked_reason: None,
3458 observation_days: 1,
3459 deployments_observed: 1,
3460 },
3461 actions: vec![],
3462 discriminators: None,
3463 }
3464 }
3465
3466 #[test]
3467 fn legacy_prod_baseline_still_suppresses_finding() {
3468 let baseline = HealthBaselineData {
3469 runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
3470 ..HealthBaselineData::default()
3471 };
3472 let findings = vec![runtime_finding(
3473 "fallow:prod:deadbeef",
3474 Some("fallow:fn:00000001"),
3475 14,
3476 None,
3477 )];
3478 let filtered =
3479 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3480 assert!(filtered.is_empty(), "legacy prod id must still suppress");
3481 }
3482
3483 #[test]
3484 fn source_hash_baseline_survives_line_move() {
3485 let root = Path::new("/repo");
3486 let baselined = runtime_finding(
3487 "fallow:prod:deadbeef",
3488 Some("fallow:fn:00000001"),
3489 14,
3490 Some("0123456789abcdef"),
3491 );
3492 let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
3493 assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
3494
3495 let findings = vec![runtime_finding(
3496 "fallow:prod:99999999",
3497 Some("fallow:fn:cafe0002"),
3498 40,
3499 Some("0123456789abcdef"),
3500 )];
3501 let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
3502 assert!(
3503 filtered.is_empty(),
3504 "source_hash baseline must survive a line move despite a changed stable_id and id"
3505 );
3506 }
3507
3508 #[test]
3509 fn unbaselined_finding_is_reported() {
3510 let baseline = HealthBaselineData {
3511 runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
3512 ..HealthBaselineData::default()
3513 };
3514 let findings = vec![runtime_finding(
3515 "fallow:prod:abc1234d",
3516 Some("fallow:fn:beefcafe"),
3517 7,
3518 None,
3519 )];
3520 let filtered =
3521 filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
3522 assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
3523 }
3524}