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