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