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