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