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