Skip to main content

fallow_engine/
baseline.rs

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