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