Skip to main content

fallow_engine/baseline/
mod.rs

1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use fallow_types::extract::is_anonymous_function_name;
6use fallow_types::identity::{IdentifiedFinding, IdentityPaths};
7
8use crate::duplicates::DuplicationReport;
9
10/// Strip the project root from a path to produce a portable relative key.
11///
12/// Both `path` and `root` must be in the same form (both canonicalized or both
13/// not) for `strip_prefix` to succeed. The analysis pipeline keeps all paths
14/// non-canonicalized, so this invariant holds in practice.
15fn relative_path(path: &Path, root: &Path) -> String {
16    match path.strip_prefix(root) {
17        Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
18        Err(_) => {
19            tracing::debug!(
20                path = %path.display(),
21                root = %root.display(),
22                "baseline key: path is not under project root, using absolute path as key"
23            );
24            path.to_string_lossy().replace('\\', "/")
25        }
26    }
27}
28
29fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
30    format!("{}:{package_name}", relative_path(path, root))
31}
32
33fn baseline_contains_dependency(
34    baseline_keys: &FxHashSet<&str>,
35    package_name: &str,
36    path_key: &str,
37) -> bool {
38    baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
39}
40
41fn retain_new_by_keys<T>(
42    items: &mut Vec<T>,
43    baseline_keys: &[String],
44    root: &Path,
45    key_builder: fn(&[T], &Path) -> Vec<String>,
46) {
47    let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
48    let item_keys = key_builder(items, root);
49    let mut key_iter = item_keys.into_iter();
50    items.retain(|_| match key_iter.next() {
51        Some(key) => !baseline_keys.contains(key.as_str()),
52        None => true,
53    });
54}
55
56/// Call `$callback!` with every finding field of a dead-code baseline. The
57/// save path and the canonical filter read this one list, so a field cannot
58/// be saved without being matched.
59macro_rules! with_baseline_fields {
60    ($callback:ident) => {
61        $callback!(
62            unused_files,
63            unused_exports,
64            unused_types,
65            private_type_leaks,
66            deprecated_exports_in_use,
67            unused_dependencies,
68            unused_dev_dependencies,
69            circular_dependencies,
70            re_export_cycles,
71            package_cycles,
72            unused_optional_dependencies,
73            unused_enum_members,
74            unused_class_members,
75            unused_store_members,
76            unprovided_injects,
77            unrendered_components,
78            unused_component_props,
79            absent_component_props,
80            unused_component_emits,
81            unused_component_inputs,
82            unused_component_outputs,
83            unused_svelte_events,
84            unused_effect_layers,
85            unused_tagged_errors,
86            unproduced_catch_tags,
87            unused_server_actions,
88            unused_load_data_keys,
89            unresolved_imports,
90            unlisted_dependencies,
91            duplicate_exports,
92            type_only_dependencies,
93            test_only_dependencies,
94            dev_dependencies_in_production,
95            boundary_violations,
96            boundary_coverage_violations,
97            boundary_call_violations,
98            policy_violations,
99            stale_suppressions,
100            unused_catalog_entries,
101            empty_catalog_groups,
102            unresolved_catalog_references,
103            unused_dependency_overrides,
104            misconfigured_dependency_overrides,
105            invalid_client_exports,
106            mixed_client_server_barrels,
107            misplaced_directives,
108            route_collisions,
109            dynamic_segment_name_conflicts,
110            prop_drilling_chains,
111        )
112    };
113}
114
115mod prune;
116
117pub use prune::{
118    BaselinePrune, BaselinePruneRefusal, PrunedEntry, prune_dead_code_baseline,
119    prune_dupes_baseline, prune_health_baseline,
120};
121
122/// The sorted canonical keys of `items`, one for each occurrence.
123fn canonical_keys<T: IdentifiedFinding>(items: &[T], paths: &IdentityPaths<'_>) -> Vec<String> {
124    let mut keys: Vec<String> = items.iter().map(|item| item.canonical_key(paths)).collect();
125    keys.sort_unstable();
126    keys
127}
128
129/// Remove the findings that a saved occurrence covers. Each saved key hides
130/// one current finding with the same canonical key, in input order.
131fn retain_unsaved<T: IdentifiedFinding>(
132    items: &mut Vec<T>,
133    saved: &[String],
134    paths: &IdentityPaths<'_>,
135) {
136    if saved.is_empty() {
137        return;
138    }
139    let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
140    for key in saved {
141        *remaining.entry(key.as_str()).or_default() += 1;
142    }
143    items.retain(|item| !consume_baseline_key(&mut remaining, &item.canonical_key(paths)));
144}
145
146/// Stale fraction (in percent) at which a partial-staleness warning fires.
147///
148/// A little drift is the normal state of a living baseline, so warning on any
149/// stale entry would train people to ignore the note. A quarter of the
150/// baseline matching nothing means the gate protects meaningfully less than
151/// what was saved. Shared by the dead-code and health baselines so the two
152/// warnings cannot drift apart.
153const STALE_WARN_PERCENT: usize = 25;
154
155/// True when `stale_entries` out of `baseline_entries` is a large enough share
156/// to be worth warning about.
157///
158/// The threshold alone; [`BaselineStaleness::warning`] owns the surrounding
159/// guards and is what commands call.
160#[must_use]
161pub const fn stale_share_warrants_warning(baseline_entries: usize, stale_entries: usize) -> bool {
162    stale_entries > 0 && stale_entries * 100 >= baseline_entries * STALE_WARN_PERCENT
163}
164
165/// One run's view of a loaded baseline: everything needed to decide whether the
166/// baseline still describes the project.
167///
168/// Every command that accepts `--baseline` builds one of these and asks it the
169/// same two questions, so `dead-code`, `dupes` and `health` cannot answer
170/// "is this baseline stale enough to say something" three different ways.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub struct BaselineStaleness {
173    /// Entries saved in the baseline file.
174    pub entries: usize,
175    /// Baseline entries that matched something in this run.
176    pub matched: usize,
177    /// Findings this run produced before the baseline filtered them. Zero means
178    /// there was nothing to compare, either because the project is clean or the
179    /// scope was empty, so staleness cannot be judged.
180    pub current_findings: usize,
181    /// True when this run analyzed only part of the project, so a
182    /// whole-project baseline matches less of it for reasons that are not rot.
183    pub change_scoped: bool,
184}
185
186/// Which advisory warning a loaded baseline earns, if any.
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum BaselineStalenessWarning {
189    /// Say nothing: the baseline is fresh enough, or this run cannot judge it.
190    None,
191    /// Nothing in the baseline matched, and there were findings to match.
192    ZeroOverlap,
193    /// A large enough share of the baseline matched nothing.
194    Partial,
195}
196
197impl BaselineStaleness {
198    /// Entries that matched no current finding on this run.
199    #[must_use]
200    pub const fn stale_entries(&self) -> usize {
201        self.entries.saturating_sub(self.matched)
202    }
203
204    /// The advisory warning this run prints on stderr by default.
205    ///
206    /// Silent for a run narrowed to part of the project, because such a run
207    /// legitimately sees only a slice of a whole-project baseline and re-saving
208    /// from it would drop every entry the run never looked at. Silent as well
209    /// when the run produced no findings: a cleaned project and a rotted
210    /// baseline look identical from here, and the re-save advice is wrong when
211    /// the right move is deleting the file.
212    #[must_use]
213    pub const fn warning(&self) -> BaselineStalenessWarning {
214        if self.change_scoped || self.entries == 0 || self.current_findings == 0 {
215            return BaselineStalenessWarning::None;
216        }
217        if self.matched == 0 {
218            return BaselineStalenessWarning::ZeroOverlap;
219        }
220        if stale_share_warrants_warning(self.entries, self.stale_entries()) {
221            return BaselineStalenessWarning::Partial;
222        }
223        BaselineStalenessWarning::None
224    }
225
226    /// Whether the opt-in `--fail-on-stale-baseline` gate fires.
227    ///
228    /// Deliberately stricter than [`Self::warning`]: any stale entry counts.
229    /// The quarter threshold exists to keep an unasked-for line from training
230    /// people to ignore it, and the empty-run silence exists because an
231    /// advisory cannot tell rot from success; a repository that passes the flag
232    /// has asked for both. Change-scope stays the one shared suppression,
233    /// because a narrowed run still cannot judge a whole-project baseline.
234    #[must_use]
235    pub const fn trips_gate(&self) -> bool {
236        stale_baseline_gate_trips(self.entries, self.matched, self.change_scoped)
237    }
238    /// This run's machine-readable view of the baseline, for the JSON envelope.
239    ///
240    /// Every member is derived here rather than in a consumer, so the advisory
241    /// threshold and the gate rule have exactly one implementation. Only
242    /// `health` can follow a file move; the commands that match entries by
243    /// fingerprint pass `0`.
244    ///
245    /// `scope_reasons` and `unrecognised_format` come from the caller because
246    /// only the command knows which channels it read and which format it read
247    /// the file as; this struct carries the counts the analysis needs and
248    /// nothing more. `change_scoped` and `scope_reasons` must agree, which is
249    /// why every caller derives the boolean from the same reason set it passes
250    /// here.
251    ///
252    /// `unrecognised_format` widens `gate_trips` here, at the single site that
253    /// builds it, so the opt-in gate, the exit code, every CI surface that reads
254    /// the boolean and the MCP sentences move together: a file this command
255    /// cannot read as its own suppresses nothing, which no count can express.
256    /// Unlike the count rule it is not suppressed by `change_scoped`, because
257    /// telling a foreign file from this command's own needs no project-wide run.
258    #[must_use]
259    pub fn to_envelope(
260        &self,
261        moved_entries: usize,
262        scope_reasons: fallow_output::BaselineScopeReasons,
263        unrecognised_format: bool,
264    ) -> fallow_output::BaselineStaleness {
265        debug_assert_eq!(
266            self.change_scoped,
267            !scope_reasons.is_empty(),
268            "change_scoped and scope_reasons must be derived from the same predicate"
269        );
270        let warning = self.warning();
271        fallow_output::BaselineStaleness {
272            baseline_entries: self.entries,
273            matched_entries: self.matched,
274            stale_entries: self.stale_entries(),
275            current_findings: self.current_findings,
276            remaining_findings: None,
277            change_scoped: self.change_scoped,
278            stale: warning != BaselineStalenessWarning::None,
279            warning: match warning {
280                BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
281                BaselineStalenessWarning::ZeroOverlap => {
282                    fallow_output::BaselineStalenessAdvisory::ZeroOverlap
283                }
284                BaselineStalenessWarning::Partial => {
285                    fallow_output::BaselineStalenessAdvisory::Partial
286                }
287            },
288            gate_trips: self.trips_gate() || unrecognised_format,
289            moved_entries,
290            unrecognised_format,
291            saved_by: None,
292            format: None,
293            scope_reasons,
294        }
295    }
296}
297
298/// [`BaselineStaleness::trips_gate`] over the three counts it reads, for
299/// callers that carry the numbers in their own output type.
300#[must_use]
301pub const fn stale_baseline_gate_trips(
302    entries: usize,
303    matched: usize,
304    change_scoped: bool,
305) -> bool {
306    !change_scoped && entries > 0 && matched < entries
307}
308
309/// Whether a baseline file is written in the format it was loaded as.
310///
311/// The duplication and health formats give every field a serde default, so a
312/// baseline another command saved, and an object with nothing in it, both
313/// deserialize into zero entries and are indistinguishable from this command's
314/// own baseline saved from a project that had nothing to record. Asking which
315/// keys the file actually carries separates them: a file that declares at
316/// least one key of the format it was read as is that command's baseline,
317/// however empty it is.
318///
319/// False for anything that is not a JSON object, including a file this crate
320/// would refuse to deserialize; those paths report their own error before a
321/// caller gets here.
322#[must_use]
323pub fn declares_baseline_format(json: &str, declared_keys: &[&str]) -> bool {
324    let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
325        return false;
326    };
327    value_declares_baseline_format(&value, declared_keys)
328}
329
330/// [`declares_baseline_format`] for a caller that has already parsed the file.
331fn value_declares_baseline_format(value: &serde_json::Value, declared_keys: &[&str]) -> bool {
332    let Some(object) = value.as_object() else {
333        return false;
334    };
335    declared_keys.iter().any(|key| object.contains_key(*key))
336}
337
338/// Which command saved a baseline file.
339///
340/// Written as the top-level `kind` of every baseline this version saves, spelled
341/// exactly as the envelope root kinds, so a file states which command can read
342/// it instead of leaving three formats to be told apart by the keys they happen
343/// to carry. A file saved before the member exists carries no `kind`, which
344/// [`classify_baseline_file`] falls back from.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum BaselineKind {
348    /// Saved by `fallow dead-code` (or `fallow check`), and by
349    /// `fallow architecture`.
350    ///
351    /// The kind names the baseline format, not the command: `fallow
352    /// architecture` runs the dead-code analysis, so it writes and reads this
353    /// format although its JSON envelope has `kind: "architecture"`.
354    DeadCode,
355    /// Saved by `fallow dupes`.
356    Dupes,
357    /// Saved by `fallow health`.
358    Health,
359}
360
361impl BaselineKind {
362    /// The command that removes only the stale entries of the baseline at
363    /// `path`.
364    #[must_use]
365    pub fn prune_command(self, path: &Path) -> String {
366        // The prune flags resolve a relative path against the project root,
367        // while `--baseline` resolves it against the working directory. An
368        // absolute path names the same file in both.
369        let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
370        let quoted = crate::shell_quote::shell_quote(&absolute.display().to_string());
371        format!(
372            "fallow baselines prune --{}-baseline {quoted}",
373            self.as_str()
374        )
375    }
376
377    /// The token this kind is written as, which is also the command to run.
378    #[must_use]
379    pub const fn as_str(self) -> &'static str {
380        match self {
381            Self::DeadCode => "dead-code",
382            Self::Dupes => "dupes",
383            Self::Health => "health",
384        }
385    }
386
387    /// The kind a `kind` token names, or `None` for a token this version does
388    /// not know (for example one that only a newer fallow writes).
389    #[must_use]
390    pub fn from_token(token: &str) -> Option<Self> {
391        [Self::DeadCode, Self::Dupes, Self::Health]
392            .into_iter()
393            .find(|kind| kind.as_str() == token)
394    }
395
396    /// The keys that identify this format in a file carrying no `kind`.
397    #[must_use]
398    pub const fn declared_keys(self) -> &'static [&'static str] {
399        match self {
400            Self::DeadCode => BaselineData::REQUIRED_KEYS,
401            Self::Dupes => DuplicationBaselineData::DECLARED_KEYS,
402            Self::Health => HealthBaselineData::DECLARED_KEYS,
403        }
404    }
405}
406
407/// What a saved baseline file is, read as one command's format.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub enum BaselineFileKind {
410    /// This command's own baseline: it names this command in `kind`, or it
411    /// carries no `kind` and at least one key of this command's format.
412    Own,
413    /// A baseline another command saved, carrying the `kind` as written so a
414    /// message can quote it, including a token only a newer fallow writes.
415    Foreign(String),
416    /// No `kind` and no key of this command's format: a baseline another
417    /// command saved before `kind` existed, or an object with nothing of this
418    /// command's in it.
419    Unrecognised,
420    /// Not a JSON object, so it says nothing about which command wrote it and
421    /// the caller's own parse error is the honest report.
422    NotAnObject,
423}
424
425impl BaselineFileKind {
426    /// The known command that saved a file another command wrote.
427    ///
428    /// `None` for this command's own file, for a file that names no writer, and
429    /// for a `kind` token this version does not know. The CLI computes this one
430    /// time per loaded baseline and uses it for the stderr note and for
431    /// `baseline_staleness.saved_by`, so the two cannot disagree.
432    #[must_use]
433    pub fn saved_by(&self) -> Option<BaselineKind> {
434        match self {
435            Self::Foreign(token) => BaselineKind::from_token(token),
436            Self::Own | Self::Unrecognised | Self::NotAnObject => None,
437        }
438    }
439}
440
441/// Why a `--save-baseline` must not overwrite the file at `save_path`, or `None`
442/// when the save may proceed.
443///
444/// A save serializes a fresh struct over the whole file, so one command's save
445/// aimed at another command's baseline destroys it with nothing left to recover
446/// from. Read from the destination's own `kind`: a file saved before that member
447/// existed carries nothing to identify it and is still overwritten silently, and
448/// an unreadable or absent destination is not a guard condition, exactly as the
449/// health identity-overwrite guard treats one.
450#[must_use]
451pub fn refuse_baseline_kind_overwrite(save_path: &Path, saving: BaselineKind) -> Option<String> {
452    let existing = std::fs::read_to_string(save_path).ok()?;
453    let BaselineFileKind::Foreign(found) = classify_baseline_file(&existing, saving) else {
454        return None;
455    };
456    Some(format!(
457        "refusing to overwrite the baseline at {}: it was saved by `fallow {found}` and this is a \
458         `fallow {}` save, which would destroy it. Save each command's baseline to its own path.",
459        save_path.display(),
460        saving.as_str(),
461    ))
462}
463
464/// The `scope_reasons` a dead-code baseline file recorded when it was saved.
465/// Empty for a whole-project save, a legacy file, or a file that is not JSON.
466#[must_use]
467pub fn saved_scope_reasons(json: &str) -> Vec<String> {
468    serde_json::from_str::<serde_json::Value>(json)
469        .ok()
470        .and_then(|value| {
471            value.get("scope_reasons")?.as_array().map(|reasons| {
472                reasons
473                    .iter()
474                    .filter_map(serde_json::Value::as_str)
475                    .map(str::to_owned)
476                    .collect()
477            })
478        })
479        .unwrap_or_default()
480}
481
482/// Which command a saved baseline file belongs to, read as `expected`'s format.
483///
484/// `kind` decides whenever the file carries one, which is every baseline saved
485/// from this version onward. A file without one predates the member, so the keys
486/// it carries decide instead, exactly as [`declares_baseline_format`] documents.
487///
488/// Reads the raw file rather than a deserialized struct, because the one format
489/// with required fields (`dead-code`) has to answer this before its own parse
490/// error hides the answer.
491#[must_use]
492pub fn classify_baseline_file(json: &str, expected: BaselineKind) -> BaselineFileKind {
493    let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
494        return BaselineFileKind::NotAnObject;
495    };
496    classify_baseline_value(&value, expected)
497}
498
499/// [`classify_baseline_file`] for a caller that parses the file itself.
500///
501/// The `dead-code` read needs both this answer and the deserialized struct, and
502/// its format is the one with required fields, so it parses once into a value and
503/// asks here rather than paying for a second parse of the same bytes.
504#[must_use]
505pub fn classify_baseline_value(
506    value: &serde_json::Value,
507    expected: BaselineKind,
508) -> BaselineFileKind {
509    let Some(object) = value.as_object() else {
510        return BaselineFileKind::NotAnObject;
511    };
512    match object.get("kind").and_then(serde_json::Value::as_str) {
513        Some(token) if token == expected.as_str() => BaselineFileKind::Own,
514        Some(token) => BaselineFileKind::Foreign(token.to_owned()),
515        // A `kind` that is not a string is no statement about the writer, so the
516        // keys answer as they do for a file that carries no `kind` at all.
517        None => {
518            if value_declares_baseline_format(value, expected.declared_keys()) {
519                BaselineFileKind::Own
520            } else {
521                BaselineFileKind::Unrecognised
522            }
523        }
524    }
525}
526
527/// The key scheme of a dead-code baseline that stores canonical keys.
528///
529/// A baseline with `"identity": "dc1"` stores, for each finding, the canonical
530/// key from [`fallow_types::identity`] (`<rule>:<path>:<name>...`, never a
531/// line), once for each occurrence. N saved occurrences of a key hide at most
532/// N current findings with that key. A baseline without `identity` is a legacy
533/// baseline: each entry keeps its old form (some old forms hold a line) and
534/// hides every current finding with that exact key.
535pub const BASELINE_KEY_SCHEME: &str = fallow_types::identity::DEAD_CODE_ID_SCHEME;
536
537/// Baseline data for comparison.
538///
539/// The per-field notes below give the legacy key forms. A baseline with an
540/// `identity` stores canonical keys in every field; see
541/// [`BASELINE_KEY_SCHEME`].
542#[derive(serde::Serialize, serde::Deserialize)]
543pub struct BaselineData {
544    /// The command that saved this file, written on every save and never read
545    /// back through this struct: [`classify_baseline_file`] reads it off the raw
546    /// file, before the required fields below can reject another command's
547    /// baseline. `skip_deserializing` keeps a `kind` only a newer fallow writes
548    /// from turning a loadable file into a parse error.
549    #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
550    kind: Option<BaselineKind>,
551    /// Compatibility identity for the analysis that produced this baseline.
552    /// Legacy baselines deserialize as syntactic and are never silently
553    /// compared with type-aware output.
554    #[serde(default)]
555    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
556    /// The key scheme of the entries: [`BASELINE_KEY_SCHEME`] for canonical,
557    /// count-matched keys, absent for a legacy baseline.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    identity: Option<String>,
560    /// The `scope_reasons` of the run that saved this file, absent for a
561    /// whole-project save. A listed reason means the file holds only the
562    /// findings in that scope, so a wider run that loads it can report old
563    /// findings as new. Read back with [`saved_scope_reasons`].
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    scope_reasons: Vec<String>,
566    unused_files: Vec<String>,
567    unused_exports: Vec<String>,
568    unused_types: Vec<String>,
569    #[serde(default)]
570    private_type_leaks: Vec<String>,
571    /// Deprecated exports still in use, keyed by `path:export_name`.
572    #[serde(default)]
573    deprecated_exports_in_use: Vec<String>,
574    /// Unused dependencies, keyed by `package.json:package_name`. Legacy
575    /// bare `package_name` keys are still matched for back-compat with
576    /// baselines saved by older fallow versions.
577    unused_dependencies: Vec<String>,
578    /// Unused dev dependencies, keyed by `package.json:package_name`. Legacy
579    /// bare `package_name` keys are still matched for back-compat with
580    /// baselines saved by older fallow versions.
581    unused_dev_dependencies: Vec<String>,
582    /// Circular dependency chains, keyed by sorted file paths joined with `->`.
583    #[serde(default)]
584    circular_dependencies: Vec<String>,
585    /// Re-export cycles, keyed by `kind:sorted_file_paths_joined_with_<->`
586    /// (where `kind` is `multi-node` or `self-loop`). The kind prefix keeps
587    /// self-loops from keyspace-colliding with future single-file multi-node
588    /// shapes.
589    #[serde(default)]
590    re_export_cycles: Vec<String>,
591    /// Package cycles, keyed by package names in canonical cycle order joined
592    /// with `->`.
593    #[serde(default)]
594    package_cycles: Vec<String>,
595    /// Unused optional dependencies, keyed by `package.json:package_name`.
596    /// Legacy bare `package_name` keys are still matched for back-compat
597    /// with baselines saved by older fallow versions.
598    #[serde(default)]
599    unused_optional_dependencies: Vec<String>,
600    /// Unused enum members, keyed by `file:parent.member`.
601    #[serde(default)]
602    unused_enum_members: Vec<String>,
603    /// Unused class members, keyed by `file:parent.member`.
604    #[serde(default)]
605    unused_class_members: Vec<String>,
606    /// Unused store members, keyed by `file:parent.member`.
607    #[serde(default)]
608    unused_store_members: Vec<String>,
609    /// Unprovided injects, keyed by `file:key_name`.
610    #[serde(default)]
611    unprovided_injects: Vec<String>,
612    /// Unrendered components, keyed by `file:component_name`.
613    #[serde(default)]
614    unrendered_components: Vec<String>,
615    /// Unused component props, keyed by `file:prop_name`.
616    #[serde(default)]
617    unused_component_props: Vec<String>,
618    #[serde(default)]
619    absent_component_props: Vec<String>,
620    /// Unused component emits, keyed by `file:emit_name`.
621    #[serde(default)]
622    unused_component_emits: Vec<String>,
623    /// Unused component inputs, keyed by `file:input_name`.
624    #[serde(default)]
625    unused_component_inputs: Vec<String>,
626    /// Unused component outputs, keyed by `file:output_name`.
627    #[serde(default)]
628    unused_component_outputs: Vec<String>,
629    /// Unused Svelte dispatched events, keyed by `file:event_name`.
630    #[serde(default)]
631    unused_svelte_events: Vec<String>,
632    /// Unused Effect layers, keyed by `file:layer_name`.
633    #[serde(default)]
634    unused_effect_layers: Vec<String>,
635    /// Unused tagged errors, keyed by `file:class_name`.
636    #[serde(default)]
637    unused_tagged_errors: Vec<String>,
638    /// Unproduced catch tags, keyed by `file:tag`.
639    #[serde(default)]
640    unproduced_catch_tags: Vec<String>,
641    /// Unused server actions, keyed by `file:action_name`.
642    #[serde(default)]
643    unused_server_actions: Vec<String>,
644    /// Unused SvelteKit load() data keys, keyed by `file:key_name`.
645    #[serde(default)]
646    unused_load_data_keys: Vec<String>,
647    /// Unresolved imports, keyed by `file:specifier`.
648    #[serde(default)]
649    unresolved_imports: Vec<String>,
650    /// Unlisted dependencies, keyed by package name.
651    #[serde(default)]
652    unlisted_dependencies: Vec<String>,
653    /// Duplicate exports, keyed by export name.
654    #[serde(default)]
655    duplicate_exports: Vec<String>,
656    /// Type-only dependencies, keyed by `package.json:package_name`. Legacy
657    /// bare `package_name` keys are still matched for back-compat with
658    /// baselines saved by older fallow versions.
659    #[serde(default)]
660    type_only_dependencies: Vec<String>,
661    /// Test-only dependencies, keyed by `package.json:package_name`. Legacy
662    /// bare `package_name` keys are still matched for back-compat with
663    /// baselines saved by older fallow versions.
664    #[serde(default)]
665    test_only_dependencies: Vec<String>,
666    /// Dev dependencies used in production, keyed by `package.json:package_name`.
667    #[serde(default)]
668    dev_dependencies_in_production: Vec<String>,
669    /// Boundary violations, keyed by `from_path->to_path`.
670    #[serde(default)]
671    boundary_violations: Vec<String>,
672    /// Boundary coverage violations, keyed by `path`.
673    #[serde(default)]
674    boundary_coverage_violations: Vec<String>,
675    /// Boundary call violations, keyed by `path:callee`.
676    #[serde(default)]
677    boundary_call_violations: Vec<String>,
678    /// Rule-pack policy violations, keyed by `path:pack/rule_id:matched`.
679    #[serde(default)]
680    policy_violations: Vec<String>,
681    /// Stale suppressions, keyed by `file:line`.
682    #[serde(default)]
683    stale_suppressions: Vec<String>,
684    /// Unused pnpm catalog entries, keyed by `catalog_name:entry_name`.
685    #[serde(default)]
686    unused_catalog_entries: Vec<String>,
687    /// Empty pnpm catalog groups, keyed by `catalog_name`.
688    #[serde(default)]
689    empty_catalog_groups: Vec<String>,
690    /// Unresolved catalog references, keyed by `path:line:catalog_name:entry_name`.
691    #[serde(default)]
692    unresolved_catalog_references: Vec<String>,
693    /// Unused package-manager dependency overrides, keyed by `source:raw_key`.
694    #[serde(default)]
695    unused_dependency_overrides: Vec<String>,
696    /// Misconfigured package-manager dependency overrides, keyed by `source:raw_key`.
697    #[serde(default)]
698    misconfigured_dependency_overrides: Vec<String>,
699    /// Invalid `"use client"` exports, keyed by `path:export_name`.
700    #[serde(default)]
701    invalid_client_exports: Vec<String>,
702    /// Mixed client/server barrels, keyed by `path:client_origin:server_origin`.
703    #[serde(default)]
704    mixed_client_server_barrels: Vec<String>,
705    /// Misplaced `"use client"` / `"use server"` directives, keyed by
706    /// `path:line:directive`.
707    #[serde(default)]
708    misplaced_directives: Vec<String>,
709    /// Next.js route collisions, keyed by `path:url`.
710    #[serde(default)]
711    route_collisions: Vec<String>,
712    /// Next.js dynamic-segment name conflicts, keyed by `path:position`.
713    #[serde(default)]
714    dynamic_segment_name_conflicts: Vec<String>,
715    /// Reviewed prop chains, keyed by source component and prop with occurrence matching.
716    #[serde(default)]
717    prop_drilling_chains: Vec<String>,
718}
719
720impl BaselineData {
721    /// The keys a dead-code baseline cannot load without. They carry no serde
722    /// default, so a file that deserializes into this struct carries all of
723    /// them, and a file carrying none of them is not a dead-code baseline
724    /// however it is spelled. Deliberately a subset of what the format writes:
725    /// the rest are optional and say nothing about which command wrote the file.
726    pub const REQUIRED_KEYS: &'static [&'static str] = &[
727        "unused_files",
728        "unused_exports",
729        "unused_types",
730        "unused_dependencies",
731        "unused_dev_dependencies",
732    ];
733
734    /// Build baseline keys from analysis results under the syntactic analysis
735    /// identity (the default for runs without semantic analysis).
736    pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
737        Self::from_results_with_identity(
738            results,
739            root,
740            fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
741        )
742    }
743
744    /// Build baseline keys from analysis results, stamping the given analysis
745    /// identity so later loads can reject baselines produced under an
746    /// incompatible analysis mode.
747    ///
748    /// Every entry is a canonical key, written once for each occurrence and
749    /// sorted, so the file does not change when findings move.
750    pub fn from_results_with_identity(
751        results: &crate::results::AnalysisResults,
752        root: &Path,
753        analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
754    ) -> Self {
755        let paths = IdentityPaths::new(root);
756        macro_rules! build {
757            ($($field:ident),* $(,)?) => {
758                Self {
759                    kind: Some(BaselineKind::DeadCode),
760                    analysis_identity,
761                    identity: Some(BASELINE_KEY_SCHEME.to_owned()),
762                    scope_reasons: Vec::new(),
763                    $($field: canonical_keys(&results.$field, &paths),)*
764                }
765            };
766        }
767        with_baseline_fields!(build)
768    }
769
770    /// Record the channels that narrowed the saving run.
771    #[must_use]
772    pub fn with_scope_reasons(mut self, reasons: fallow_output::BaselineScopeReasons) -> Self {
773        self.scope_reasons = reasons
774            .iter()
775            .map(|reason| reason.as_str().to_owned())
776            .collect();
777        self
778    }
779
780    /// Build a baseline in the legacy key forms that fallow wrote before
781    /// canonical keys. Tests use it to prove that legacy files still load.
782    #[cfg(test)]
783    pub(crate) fn legacy_from_results(
784        results: &crate::results::AnalysisResults,
785        root: &Path,
786    ) -> Self {
787        let analysis_identity = fallow_types::semantic::SemanticAnalysisIdentity::syntactic();
788        let file_exports = baseline_file_export_keys(results, root);
789        let member_imports = baseline_member_import_keys(results, root);
790        let dependencies = baseline_dependency_keys(results, root);
791        let graph = baseline_graph_keys(results, root);
792        let catalog = baseline_catalog_keys(results, root);
793
794        Self {
795            kind: Some(BaselineKind::DeadCode),
796            analysis_identity,
797            identity: None,
798            scope_reasons: Vec::new(),
799            unused_files: file_exports.unused_files,
800            unused_exports: file_exports.unused_exports,
801            unused_types: file_exports.unused_types,
802            private_type_leaks: file_exports.private_type_leaks,
803            deprecated_exports_in_use: file_exports.deprecated_exports_in_use,
804            unused_dependencies: dependencies.unused,
805            unused_dev_dependencies: dependencies.unused_dev,
806            circular_dependencies: graph.circular_dependencies,
807            re_export_cycles: graph.re_export_cycles,
808            package_cycles: graph.package_cycles,
809            unused_optional_dependencies: dependencies.unused_optional,
810            unused_enum_members: member_imports.unused_enum_members,
811            unused_class_members: member_imports.unused_class_members,
812            unused_store_members: member_imports.unused_store_members,
813            unprovided_injects: member_imports.unprovided_injects,
814            unrendered_components: member_imports.unrendered_components,
815            unused_component_props: member_imports.unused_component_props,
816            absent_component_props: member_imports.absent_component_props,
817            unused_component_emits: member_imports.unused_component_emits,
818            unused_component_inputs: member_imports.unused_component_inputs,
819            unused_component_outputs: member_imports.unused_component_outputs,
820            unused_svelte_events: member_imports.unused_svelte_events,
821            unused_effect_layers: member_imports.unused_effect_layers,
822            unused_tagged_errors: member_imports.unused_tagged_errors,
823            unproduced_catch_tags: member_imports.unproduced_catch_tags,
824            unused_server_actions: member_imports.unused_server_actions,
825            unused_load_data_keys: member_imports.unused_load_data_keys,
826            unresolved_imports: member_imports.unresolved_imports,
827            unlisted_dependencies: dependencies.unlisted,
828            duplicate_exports: member_imports.duplicate_exports,
829            type_only_dependencies: dependencies.type_only,
830            test_only_dependencies: dependencies.test_only,
831            dev_dependencies_in_production: dependencies.dev_in_prod,
832            boundary_violations: graph.boundary_violations,
833            boundary_coverage_violations: graph.boundary_coverage_violations,
834            boundary_call_violations: graph.boundary_call_violations,
835            policy_violations: graph.policy_violations,
836            stale_suppressions: member_imports.stale_suppressions,
837            unused_catalog_entries: catalog.unused_catalog_entries,
838            empty_catalog_groups: catalog.empty_catalog_groups,
839            unresolved_catalog_references: catalog.unresolved_catalog_references,
840            unused_dependency_overrides: catalog.unused_dependency_overrides,
841            misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
842            invalid_client_exports: file_exports.invalid_client_exports,
843            mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
844            misplaced_directives: file_exports.misplaced_directives,
845            route_collisions: file_exports.route_collisions,
846            dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
847            prop_drilling_chains: Vec::new(),
848        }
849    }
850
851    /// The analysis identity this baseline was captured under.
852    #[must_use]
853    pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
854        &self.analysis_identity
855    }
856
857    /// Total number of entries across all categories.
858    pub fn total_entries(&self) -> usize {
859        self.unused_files.len()
860            + self.unused_exports.len()
861            + self.unused_types.len()
862            + self.private_type_leaks.len()
863            + self.deprecated_exports_in_use.len()
864            + self.unused_dependencies.len()
865            + self.unused_dev_dependencies.len()
866            + self.circular_dependencies.len()
867            + self.re_export_cycles.len()
868            + self.package_cycles.len()
869            + self.unused_optional_dependencies.len()
870            + self.unused_enum_members.len()
871            + self.unused_class_members.len()
872            + self.unused_store_members.len()
873            + self.unprovided_injects.len()
874            + self.unrendered_components.len()
875            + self.unused_component_props.len()
876            + self.absent_component_props.len()
877            + self.unused_component_emits.len()
878            + self.unused_component_inputs.len()
879            + self.unused_component_outputs.len()
880            + self.unused_svelte_events.len()
881            + self.unused_effect_layers.len()
882            + self.unused_tagged_errors.len()
883            + self.unproduced_catch_tags.len()
884            + self.unused_server_actions.len()
885            + self.unused_load_data_keys.len()
886            + self.unresolved_imports.len()
887            + self.unlisted_dependencies.len()
888            + self.duplicate_exports.len()
889            + self.type_only_dependencies.len()
890            + self.test_only_dependencies.len()
891            + self.dev_dependencies_in_production.len()
892            + self.boundary_violations.len()
893            + self.boundary_coverage_violations.len()
894            + self.boundary_call_violations.len()
895            + self.policy_violations.len()
896            + self.stale_suppressions.len()
897            + self.unused_catalog_entries.len()
898            + self.empty_catalog_groups.len()
899            + self.unresolved_catalog_references.len()
900            + self.unused_dependency_overrides.len()
901            + self.misconfigured_dependency_overrides.len()
902            + self.invalid_client_exports.len()
903            + self.mixed_client_server_barrels.len()
904            + self.misplaced_directives.len()
905            + self.route_collisions.len()
906            + self.dynamic_segment_name_conflicts.len()
907            + self.prop_drilling_chains.len()
908    }
909}
910
911#[cfg(test)]
912struct BaselineFileExportKeys {
913    unused_files: Vec<String>,
914    unused_exports: Vec<String>,
915    unused_types: Vec<String>,
916    private_type_leaks: Vec<String>,
917    deprecated_exports_in_use: Vec<String>,
918    invalid_client_exports: Vec<String>,
919    mixed_client_server_barrels: Vec<String>,
920    misplaced_directives: Vec<String>,
921    route_collisions: Vec<String>,
922    dynamic_segment_name_conflicts: Vec<String>,
923}
924
925#[cfg(test)]
926fn baseline_file_export_keys(
927    results: &crate::results::AnalysisResults,
928    root: &Path,
929) -> BaselineFileExportKeys {
930    BaselineFileExportKeys {
931        unused_files: results
932            .unused_files
933            .iter()
934            .map(|f| relative_path(&f.file.path, root))
935            .collect(),
936        unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
937        unused_types: unused_type_baseline_keys(&results.unused_types, root),
938        private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
939        deprecated_exports_in_use: results
940            .deprecated_exports_in_use
941            .iter()
942            .map(|e| deprecated_export_key(&e.export, root))
943            .collect(),
944        invalid_client_exports: invalid_client_export_baseline_keys(
945            &results.invalid_client_exports,
946            root,
947        ),
948        mixed_client_server_barrels: barrel_baseline_keys(
949            &results.mixed_client_server_barrels,
950            root,
951        ),
952        misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
953        route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
954        dynamic_segment_name_conflicts: results
955            .dynamic_segment_name_conflicts
956            .iter()
957            .map(|c| {
958                format!(
959                    "{}:{}",
960                    relative_path(&c.conflict.path, root),
961                    c.conflict.position
962                )
963            })
964            .collect(),
965    }
966}
967
968#[cfg(test)]
969fn unused_export_baseline_keys(
970    items: &[crate::results::UnusedExportFinding],
971    root: &Path,
972) -> Vec<String> {
973    items
974        .iter()
975        .map(|e| {
976            format!(
977                "{}:{}",
978                relative_path(&e.export.path, root),
979                e.export.export_name
980            )
981        })
982        .collect()
983}
984
985#[cfg(test)]
986fn unused_type_baseline_keys(
987    items: &[crate::results::UnusedTypeFinding],
988    root: &Path,
989) -> Vec<String> {
990    items
991        .iter()
992        .map(|e| {
993            format!(
994                "{}:{}",
995                relative_path(&e.export.path, root),
996                e.export.export_name
997            )
998        })
999        .collect()
1000}
1001
1002#[cfg(test)]
1003fn invalid_client_export_baseline_keys(
1004    items: &[crate::results::InvalidClientExportFinding],
1005    root: &Path,
1006) -> Vec<String> {
1007    items
1008        .iter()
1009        .map(|e| {
1010            format!(
1011                "{}:{}",
1012                relative_path(&e.export.path, root),
1013                e.export.export_name
1014            )
1015        })
1016        .collect()
1017}
1018
1019#[cfg(test)]
1020fn private_type_leak_baseline_keys(
1021    items: &[crate::results::PrivateTypeLeakFinding],
1022    root: &Path,
1023) -> Vec<String> {
1024    items
1025        .iter()
1026        .map(|e| {
1027            format!(
1028                "{}:{}->{}",
1029                relative_path(&e.leak.path, root),
1030                e.leak.export_name,
1031                e.leak.type_name
1032            )
1033        })
1034        .collect()
1035}
1036
1037#[cfg(test)]
1038fn barrel_baseline_keys(
1039    items: &[crate::results::MixedClientServerBarrelFinding],
1040    root: &Path,
1041) -> Vec<String> {
1042    items
1043        .iter()
1044        .map(|b| {
1045            format!(
1046                "{}:{}:{}",
1047                relative_path(&b.barrel.path, root),
1048                b.barrel.client_origin,
1049                b.barrel.server_origin
1050            )
1051        })
1052        .collect()
1053}
1054
1055#[cfg(test)]
1056fn directive_baseline_keys(
1057    items: &[crate::results::MisplacedDirectiveFinding],
1058    root: &Path,
1059) -> Vec<String> {
1060    items
1061        .iter()
1062        .map(|d| {
1063            format!(
1064                "{}:{}:{}",
1065                relative_path(&d.directive_site.path, root),
1066                d.directive_site.line,
1067                d.directive_site.directive
1068            )
1069        })
1070        .collect()
1071}
1072
1073#[cfg(test)]
1074fn route_collision_baseline_keys(
1075    items: &[crate::results::RouteCollisionFinding],
1076    root: &Path,
1077) -> Vec<String> {
1078    items
1079        .iter()
1080        .map(|c| {
1081            format!(
1082                "{}:{}",
1083                relative_path(&c.collision.path, root),
1084                c.collision.url
1085            )
1086        })
1087        .collect()
1088}
1089
1090#[cfg(test)]
1091struct BaselineMemberImportKeys {
1092    unused_enum_members: Vec<String>,
1093    unused_class_members: Vec<String>,
1094    unused_store_members: Vec<String>,
1095    unprovided_injects: Vec<String>,
1096    unrendered_components: Vec<String>,
1097    unused_component_props: Vec<String>,
1098    absent_component_props: Vec<String>,
1099    unused_component_emits: Vec<String>,
1100    unused_component_inputs: Vec<String>,
1101    unused_component_outputs: Vec<String>,
1102    unused_svelte_events: Vec<String>,
1103    unused_effect_layers: Vec<String>,
1104    unused_tagged_errors: Vec<String>,
1105    unproduced_catch_tags: Vec<String>,
1106    unused_server_actions: Vec<String>,
1107    unused_load_data_keys: Vec<String>,
1108    unresolved_imports: Vec<String>,
1109    duplicate_exports: Vec<String>,
1110    stale_suppressions: Vec<String>,
1111}
1112
1113#[cfg(test)]
1114fn baseline_member_import_keys(
1115    results: &crate::results::AnalysisResults,
1116    root: &Path,
1117) -> BaselineMemberImportKeys {
1118    BaselineMemberImportKeys {
1119        unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
1120        unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
1121        unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
1122        unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
1123        unrendered_components: component_baseline_keys(&results.unrendered_components, root),
1124        unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
1125        absent_component_props: absent_component_prop_baseline_keys(
1126            &results.absent_component_props,
1127            root,
1128        ),
1129        unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
1130        unused_component_inputs: component_input_baseline_keys(
1131            &results.unused_component_inputs,
1132            root,
1133        ),
1134        unused_component_outputs: component_output_baseline_keys(
1135            &results.unused_component_outputs,
1136            root,
1137        ),
1138        unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
1139        unused_effect_layers: effect_layer_baseline_keys(&results.unused_effect_layers, root),
1140        unused_tagged_errors: tagged_error_baseline_keys(&results.unused_tagged_errors, root),
1141        unproduced_catch_tags: catch_tag_baseline_keys(&results.unproduced_catch_tags, root),
1142        unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
1143        unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
1144        unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
1145        duplicate_exports: results
1146            .duplicate_exports
1147            .iter()
1148            .map(|d| duplicate_export_key(&d.export, root))
1149            .collect(),
1150        stale_suppressions: results
1151            .stale_suppressions
1152            .iter()
1153            .map(|s| stale_suppression_baseline_key(s, root))
1154            .collect(),
1155    }
1156}
1157
1158fn stale_suppression_baseline_key(
1159    suppression: &crate::results::StaleSuppression,
1160    root: &Path,
1161) -> String {
1162    let rule_id = if suppression.missing_reason {
1163        "missing-suppression-reason"
1164    } else {
1165        "stale-suppression"
1166    };
1167    format!(
1168        "{rule_id}:{}:{}",
1169        relative_path(&suppression.path, root),
1170        suppression.line
1171    )
1172}
1173
1174#[cfg(test)]
1175fn enum_member_baseline_keys(
1176    items: &[crate::results::UnusedEnumMemberFinding],
1177    root: &Path,
1178) -> Vec<String> {
1179    items
1180        .iter()
1181        .map(|m| unused_member_baseline_key(&m.member, root))
1182        .collect()
1183}
1184
1185#[cfg(test)]
1186fn class_member_baseline_keys(
1187    items: &[crate::results::UnusedClassMemberFinding],
1188    root: &Path,
1189) -> Vec<String> {
1190    items
1191        .iter()
1192        .map(|m| unused_member_baseline_key(&m.member, root))
1193        .collect()
1194}
1195
1196#[cfg(test)]
1197fn store_member_baseline_keys(
1198    items: &[crate::results::UnusedStoreMemberFinding],
1199    root: &Path,
1200) -> Vec<String> {
1201    items
1202        .iter()
1203        .map(|m| unused_member_baseline_key(&m.member, root))
1204        .collect()
1205}
1206
1207#[cfg(test)]
1208fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
1209    format!(
1210        "{}:{}.{}",
1211        relative_path(&member.path, root),
1212        member.parent_name,
1213        member.member_name
1214    )
1215}
1216
1217fn inject_baseline_keys(
1218    items: &[crate::results::UnprovidedInjectFinding],
1219    root: &Path,
1220) -> Vec<String> {
1221    items
1222        .iter()
1223        .map(|f| {
1224            format!(
1225                "{}:{}",
1226                relative_path(&f.inject.path, root),
1227                f.inject.key_name
1228            )
1229        })
1230        .collect()
1231}
1232
1233fn component_baseline_keys(
1234    items: &[crate::results::UnrenderedComponentFinding],
1235    root: &Path,
1236) -> Vec<String> {
1237    items
1238        .iter()
1239        .map(|c| {
1240            format!(
1241                "{}:{}",
1242                relative_path(&c.component.path, root),
1243                c.component.component_name
1244            )
1245        })
1246        .collect()
1247}
1248
1249fn absent_component_prop_baseline_keys(
1250    items: &[crate::results::AbsentComponentPropFinding],
1251    root: &Path,
1252) -> Vec<String> {
1253    items
1254        .iter()
1255        .map(|finding| {
1256            format!(
1257                "{}:{}:{}",
1258                relative_path(&finding.prop.path, root),
1259                finding.prop.component_name,
1260                finding.prop.prop_name
1261            )
1262        })
1263        .collect()
1264}
1265
1266fn component_prop_baseline_keys(
1267    items: &[crate::results::UnusedComponentPropFinding],
1268    root: &Path,
1269) -> Vec<String> {
1270    items
1271        .iter()
1272        .map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
1273        .collect()
1274}
1275
1276fn component_emit_baseline_keys(
1277    items: &[crate::results::UnusedComponentEmitFinding],
1278    root: &Path,
1279) -> Vec<String> {
1280    items
1281        .iter()
1282        .map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
1283        .collect()
1284}
1285
1286fn component_input_baseline_keys(
1287    items: &[crate::results::UnusedComponentInputFinding],
1288    root: &Path,
1289) -> Vec<String> {
1290    items
1291        .iter()
1292        .map(|i| {
1293            format!(
1294                "{}:{}",
1295                relative_path(&i.input.path, root),
1296                i.input.input_name
1297            )
1298        })
1299        .collect()
1300}
1301
1302fn component_output_baseline_keys(
1303    items: &[crate::results::UnusedComponentOutputFinding],
1304    root: &Path,
1305) -> Vec<String> {
1306    items
1307        .iter()
1308        .map(|o| {
1309            format!(
1310                "{}:{}",
1311                relative_path(&o.output.path, root),
1312                o.output.output_name
1313            )
1314        })
1315        .collect()
1316}
1317
1318fn effect_layer_baseline_keys(
1319    items: &[crate::results::UnusedEffectLayerFinding],
1320    root: &Path,
1321) -> Vec<String> {
1322    items
1323        .iter()
1324        .map(|f| format!("{}:{}", relative_path(&f.layer.path, root), f.layer.name))
1325        .collect()
1326}
1327
1328fn tagged_error_baseline_keys(
1329    items: &[crate::results::UnusedTaggedErrorFinding],
1330    root: &Path,
1331) -> Vec<String> {
1332    items
1333        .iter()
1334        .map(|f| format!("{}:{}", relative_path(&f.error.path, root), f.error.name))
1335        .collect()
1336}
1337
1338fn catch_tag_baseline_keys(
1339    items: &[crate::results::UnproducedCatchTagFinding],
1340    root: &Path,
1341) -> Vec<String> {
1342    items
1343        .iter()
1344        .map(|f| {
1345            format!(
1346                "{}:{}",
1347                relative_path(&f.catch_tag.path, root),
1348                f.catch_tag.tag
1349            )
1350        })
1351        .collect()
1352}
1353
1354fn svelte_event_baseline_keys(
1355    items: &[crate::results::UnusedSvelteEventFinding],
1356    root: &Path,
1357) -> Vec<String> {
1358    items
1359        .iter()
1360        .map(|e| {
1361            format!(
1362                "{}:{}",
1363                relative_path(&e.event.path, root),
1364                e.event.event_name
1365            )
1366        })
1367        .collect()
1368}
1369
1370#[cfg(test)]
1371fn server_action_baseline_keys(
1372    items: &[crate::results::UnusedServerActionFinding],
1373    root: &Path,
1374) -> Vec<String> {
1375    items
1376        .iter()
1377        .map(|a| {
1378            format!(
1379                "{}:{}",
1380                relative_path(&a.action.path, root),
1381                a.action.action_name
1382            )
1383        })
1384        .collect()
1385}
1386
1387#[cfg(test)]
1388fn load_data_key_baseline_keys(
1389    items: &[crate::results::UnusedLoadDataKeyFinding],
1390    root: &Path,
1391) -> Vec<String> {
1392    items
1393        .iter()
1394        .map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
1395        .collect()
1396}
1397
1398#[cfg(test)]
1399fn unresolved_import_baseline_keys(
1400    items: &[crate::results::UnresolvedImportFinding],
1401    root: &Path,
1402) -> Vec<String> {
1403    items
1404        .iter()
1405        .map(|i| {
1406            format!(
1407                "{}:{}",
1408                relative_path(&i.import.path, root),
1409                i.import.specifier
1410            )
1411        })
1412        .collect()
1413}
1414
1415#[cfg(test)]
1416struct BaselineDependencyKeys {
1417    unused: Vec<String>,
1418    unused_dev: Vec<String>,
1419    unused_optional: Vec<String>,
1420    unlisted: Vec<String>,
1421    type_only: Vec<String>,
1422    test_only: Vec<String>,
1423    dev_in_prod: Vec<String>,
1424}
1425
1426#[cfg(test)]
1427fn baseline_dependency_keys(
1428    results: &crate::results::AnalysisResults,
1429    root: &Path,
1430) -> BaselineDependencyKeys {
1431    BaselineDependencyKeys {
1432        unused: results
1433            .unused_dependencies
1434            .iter()
1435            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1436            .collect(),
1437        unused_dev: results
1438            .unused_dev_dependencies
1439            .iter()
1440            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1441            .collect(),
1442        unused_optional: results
1443            .unused_optional_dependencies
1444            .iter()
1445            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1446            .collect(),
1447        unlisted: results
1448            .unlisted_dependencies
1449            .iter()
1450            .map(|d| d.dep.package_name.clone())
1451            .collect(),
1452        type_only: results
1453            .type_only_dependencies
1454            .iter()
1455            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1456            .collect(),
1457        test_only: results
1458            .test_only_dependencies
1459            .iter()
1460            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1461            .collect(),
1462        dev_in_prod: results
1463            .dev_dependencies_in_production
1464            .iter()
1465            .map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
1466            .collect(),
1467    }
1468}
1469
1470#[cfg(test)]
1471struct BaselineGraphKeys {
1472    circular_dependencies: Vec<String>,
1473    re_export_cycles: Vec<String>,
1474    package_cycles: Vec<String>,
1475    boundary_violations: Vec<String>,
1476    boundary_coverage_violations: Vec<String>,
1477    boundary_call_violations: Vec<String>,
1478    policy_violations: Vec<String>,
1479}
1480
1481#[cfg(test)]
1482fn baseline_graph_keys(
1483    results: &crate::results::AnalysisResults,
1484    root: &Path,
1485) -> BaselineGraphKeys {
1486    BaselineGraphKeys {
1487        circular_dependencies: results
1488            .circular_dependencies
1489            .iter()
1490            .map(|c| circular_dep_key(&c.cycle, root))
1491            .collect(),
1492        re_export_cycles: results
1493            .re_export_cycles
1494            .iter()
1495            .map(|c| re_export_cycle_key(&c.cycle, root))
1496            .collect(),
1497        package_cycles: results
1498            .package_cycles
1499            .iter()
1500            .map(|c| package_cycle_key(&c.cycle))
1501            .collect(),
1502        boundary_violations: results
1503            .boundary_violations
1504            .iter()
1505            .map(|v| boundary_violation_key(&v.violation, root))
1506            .collect(),
1507        boundary_coverage_violations: results
1508            .boundary_coverage_violations
1509            .iter()
1510            .map(|v| relative_path(&v.violation.path, root))
1511            .collect(),
1512        boundary_call_violations: results
1513            .boundary_call_violations
1514            .iter()
1515            .map(|v| boundary_call_violation_key(&v.violation, root))
1516            .collect(),
1517        policy_violations: results
1518            .policy_violations
1519            .iter()
1520            .map(|v| policy_violation_key(&v.violation, root))
1521            .collect(),
1522    }
1523}
1524
1525#[cfg(test)]
1526struct BaselineCatalogKeys {
1527    unused_catalog_entries: Vec<String>,
1528    empty_catalog_groups: Vec<String>,
1529    unresolved_catalog_references: Vec<String>,
1530    unused_dependency_overrides: Vec<String>,
1531    misconfigured_dependency_overrides: Vec<String>,
1532}
1533
1534#[cfg(test)]
1535fn baseline_catalog_keys(
1536    results: &crate::results::AnalysisResults,
1537    root: &Path,
1538) -> BaselineCatalogKeys {
1539    BaselineCatalogKeys {
1540        unused_catalog_entries: results
1541            .unused_catalog_entries
1542            .iter()
1543            .map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
1544            .collect(),
1545        empty_catalog_groups: results
1546            .empty_catalog_groups
1547            .iter()
1548            .map(|g| g.group.catalog_name.clone())
1549            .collect(),
1550        unresolved_catalog_references: results
1551            .unresolved_catalog_references
1552            .iter()
1553            .map(|r| {
1554                format!(
1555                    "{}:{}:{}:{}",
1556                    relative_path(&r.reference.path, root),
1557                    r.reference.line,
1558                    r.reference.catalog_name,
1559                    r.reference.entry_name,
1560                )
1561            })
1562            .collect(),
1563        unused_dependency_overrides: results
1564            .unused_dependency_overrides
1565            .iter()
1566            .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1567            .collect(),
1568        misconfigured_dependency_overrides: results
1569            .misconfigured_dependency_overrides
1570            .iter()
1571            .map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
1572            .collect(),
1573    }
1574}
1575
1576/// Generate a stable key for a boundary violation: `from_path->to_path`.
1577fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
1578    format!(
1579        "{}->{}",
1580        relative_path(&v.from_path, root),
1581        relative_path(&v.to_path, root),
1582    )
1583}
1584
1585/// Generate a stable key for a boundary call violation: `path:callee`.
1586fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
1587    format!("{}:{}", relative_path(&v.path, root), v.callee)
1588}
1589
1590/// Generate a stable key for a rule-pack policy violation:
1591/// `path:pack/rule_id:matched`. Line numbers are deliberately excluded so a
1592/// baselined finding survives unrelated edits above it.
1593#[cfg(test)]
1594fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
1595    format!(
1596        "{}:{}/{}:{}",
1597        relative_path(&v.path, root),
1598        v.pack,
1599        v.rule_id,
1600        v.matched
1601    )
1602}
1603
1604/// Generate a stable key for a duplicate export: `name|sorted_paths`.
1605fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
1606    let mut locs: Vec<String> = dup
1607        .locations
1608        .iter()
1609        .map(|l| relative_path(&l.path, root))
1610        .collect();
1611    locs.sort();
1612    format!("{}|{}", dup.export_name, locs.join("|"))
1613}
1614
1615/// Generate a stable key for a circular dependency based on sorted file paths.
1616fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
1617    let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
1618    paths.sort();
1619    paths.join("->")
1620}
1621
1622/// Generate a stable key for a re-export cycle based on its discriminator
1623/// kind plus sorted member paths. The `kind` prefix is mandatory: without
1624/// it a self-loop on `src/foo.ts` would keyspace-collide with any future
1625/// single-file multi-node shape, and the `--baseline new` filter would
1626/// silently drop the new one as already-seen (panel catch #7).
1627fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
1628    let kind = match cycle.kind {
1629        crate::results::ReExportCycleKind::MultiNode => "multi-node",
1630        crate::results::ReExportCycleKind::SelfLoop => "self-loop",
1631    };
1632    let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
1633    paths.sort();
1634    format!("{kind}:{}", paths.join("<->"))
1635}
1636
1637/// Generate a stable key for a package cycle. The package names are already
1638/// in canonical cycle order, and they do not depend on the example import, so
1639/// the key survives a change of example import.
1640fn package_cycle_key(cycle: &crate::results::PackageCycle) -> String {
1641    cycle.packages.join("->")
1642}
1643
1644fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
1645    format!(
1646        "{}:{}->{}",
1647        relative_path(&leak.path, root),
1648        leak.export_name,
1649        leak.type_name
1650    )
1651}
1652
1653fn deprecated_export_key(
1654    export: &fallow_types::results::DeprecatedExportInUse,
1655    root: &Path,
1656) -> String {
1657    format!(
1658        "{}:{}",
1659        relative_path(&export.path, root),
1660        export.export_name
1661    )
1662}
1663
1664fn filter_private_type_leaks(
1665    leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
1666    baseline_keys: &[String],
1667    root: &Path,
1668) {
1669    let baseline_private_type_leaks: FxHashSet<&str> =
1670        baseline_keys.iter().map(String::as_str).collect();
1671    leaks.retain(|entry| {
1672        let key = private_type_leak_key(&entry.leak, root);
1673        !baseline_private_type_leaks.contains(key.as_str())
1674    });
1675}
1676
1677struct BaselineFilterContext<'a> {
1678    baseline: &'a BaselineData,
1679    root: &'a Path,
1680}
1681
1682impl BaselineFilterContext<'_> {
1683    fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
1684        let baseline_circular: FxHashSet<&str> = self
1685            .baseline
1686            .circular_dependencies
1687            .iter()
1688            .map(String::as_str)
1689            .collect();
1690        results.circular_dependencies.retain(|cycle| {
1691            let key = circular_dep_key(&cycle.cycle, self.root);
1692            !baseline_circular.contains(key.as_str())
1693        });
1694
1695        let baseline_re_export_cycles: FxHashSet<&str> = self
1696            .baseline
1697            .re_export_cycles
1698            .iter()
1699            .map(String::as_str)
1700            .collect();
1701        results.re_export_cycles.retain(|cycle| {
1702            let key = re_export_cycle_key(&cycle.cycle, self.root);
1703            !baseline_re_export_cycles.contains(key.as_str())
1704        });
1705
1706        let baseline_package_cycles: FxHashSet<&str> = self
1707            .baseline
1708            .package_cycles
1709            .iter()
1710            .map(String::as_str)
1711            .collect();
1712        results.package_cycles.retain(|cycle| {
1713            !baseline_package_cycles.contains(package_cycle_key(&cycle.cycle).as_str())
1714        });
1715
1716        self.filter_unused_members(results);
1717        self.filter_unresolved_and_exports(results);
1718    }
1719
1720    fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
1721        self.filter_enum_class_store_members(results);
1722        self.filter_component_surface_members(results);
1723        self.filter_route_action_members(results);
1724    }
1725
1726    fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
1727        let baseline_enum_members: FxHashSet<&str> = self
1728            .baseline
1729            .unused_enum_members
1730            .iter()
1731            .map(String::as_str)
1732            .collect();
1733        results.unused_enum_members.retain(|member| {
1734            let key = format!(
1735                "{}:{}.{}",
1736                relative_path(&member.member.path, self.root),
1737                member.member.parent_name,
1738                member.member.member_name
1739            );
1740            !baseline_enum_members.contains(key.as_str())
1741        });
1742
1743        let baseline_class_members: FxHashSet<&str> = self
1744            .baseline
1745            .unused_class_members
1746            .iter()
1747            .map(String::as_str)
1748            .collect();
1749        results.unused_class_members.retain(|member| {
1750            let key = format!(
1751                "{}:{}.{}",
1752                relative_path(&member.member.path, self.root),
1753                member.member.parent_name,
1754                member.member.member_name
1755            );
1756            !baseline_class_members.contains(key.as_str())
1757        });
1758
1759        let baseline_store_members: FxHashSet<&str> = self
1760            .baseline
1761            .unused_store_members
1762            .iter()
1763            .map(String::as_str)
1764            .collect();
1765        results.unused_store_members.retain(|member| {
1766            let key = format!(
1767                "{}:{}.{}",
1768                relative_path(&member.member.path, self.root),
1769                member.member.parent_name,
1770                member.member.member_name
1771            );
1772            !baseline_store_members.contains(key.as_str())
1773        });
1774    }
1775
1776    fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
1777        retain_new_by_keys(
1778            &mut results.unprovided_injects,
1779            &self.baseline.unprovided_injects,
1780            self.root,
1781            inject_baseline_keys,
1782        );
1783        retain_new_by_keys(
1784            &mut results.unrendered_components,
1785            &self.baseline.unrendered_components,
1786            self.root,
1787            component_baseline_keys,
1788        );
1789        retain_new_by_keys(
1790            &mut results.unused_component_props,
1791            &self.baseline.unused_component_props,
1792            self.root,
1793            component_prop_baseline_keys,
1794        );
1795        retain_new_by_keys(
1796            &mut results.absent_component_props,
1797            &self.baseline.absent_component_props,
1798            self.root,
1799            absent_component_prop_baseline_keys,
1800        );
1801        retain_new_by_keys(
1802            &mut results.unused_component_emits,
1803            &self.baseline.unused_component_emits,
1804            self.root,
1805            component_emit_baseline_keys,
1806        );
1807        retain_new_by_keys(
1808            &mut results.unused_component_inputs,
1809            &self.baseline.unused_component_inputs,
1810            self.root,
1811            component_input_baseline_keys,
1812        );
1813        retain_new_by_keys(
1814            &mut results.unused_component_outputs,
1815            &self.baseline.unused_component_outputs,
1816            self.root,
1817            component_output_baseline_keys,
1818        );
1819        retain_new_by_keys(
1820            &mut results.unused_svelte_events,
1821            &self.baseline.unused_svelte_events,
1822            self.root,
1823            svelte_event_baseline_keys,
1824        );
1825        retain_new_by_keys(
1826            &mut results.unused_effect_layers,
1827            &self.baseline.unused_effect_layers,
1828            self.root,
1829            effect_layer_baseline_keys,
1830        );
1831        retain_new_by_keys(
1832            &mut results.unused_tagged_errors,
1833            &self.baseline.unused_tagged_errors,
1834            self.root,
1835            tagged_error_baseline_keys,
1836        );
1837        retain_new_by_keys(
1838            &mut results.unproduced_catch_tags,
1839            &self.baseline.unproduced_catch_tags,
1840            self.root,
1841            catch_tag_baseline_keys,
1842        );
1843    }
1844
1845    fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
1846        let baseline_unused_server_actions: FxHashSet<&str> = self
1847            .baseline
1848            .unused_server_actions
1849            .iter()
1850            .map(String::as_str)
1851            .collect();
1852        results.unused_server_actions.retain(|finding| {
1853            let key = format!(
1854                "{}:{}",
1855                relative_path(&finding.action.path, self.root),
1856                finding.action.action_name
1857            );
1858            !baseline_unused_server_actions.contains(key.as_str())
1859        });
1860
1861        let baseline_unused_load_data_keys: FxHashSet<&str> = self
1862            .baseline
1863            .unused_load_data_keys
1864            .iter()
1865            .map(String::as_str)
1866            .collect();
1867        results.unused_load_data_keys.retain(|finding| {
1868            let key = format!(
1869                "{}:{}",
1870                relative_path(&finding.key.path, self.root),
1871                finding.key.key_name
1872            );
1873            !baseline_unused_load_data_keys.contains(key.as_str())
1874        });
1875    }
1876
1877    fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
1878        let baseline_unresolved: FxHashSet<&str> = self
1879            .baseline
1880            .unresolved_imports
1881            .iter()
1882            .map(String::as_str)
1883            .collect();
1884        results.unresolved_imports.retain(|import| {
1885            let key = format!(
1886                "{}:{}",
1887                relative_path(&import.import.path, self.root),
1888                import.import.specifier
1889            );
1890            !baseline_unresolved.contains(key.as_str())
1891        });
1892
1893        let baseline_unlisted: FxHashSet<&str> = self
1894            .baseline
1895            .unlisted_dependencies
1896            .iter()
1897            .map(String::as_str)
1898            .collect();
1899        results
1900            .unlisted_dependencies
1901            .retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
1902
1903        let baseline_dup_exports: FxHashSet<&str> = self
1904            .baseline
1905            .duplicate_exports
1906            .iter()
1907            .map(String::as_str)
1908            .collect();
1909        results.duplicate_exports.retain(|duplicate| {
1910            let key = duplicate_export_key(&duplicate.export, self.root);
1911            !baseline_dup_exports.contains(key.as_str())
1912        });
1913    }
1914
1915    fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
1916        let baseline_optional_deps: FxHashSet<&str> = self
1917            .baseline
1918            .unused_optional_dependencies
1919            .iter()
1920            .map(String::as_str)
1921            .collect();
1922        results.unused_optional_dependencies.retain(|dep| {
1923            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1924            !baseline_contains_dependency(
1925                &baseline_optional_deps,
1926                &dep.dep.package_name,
1927                key.as_str(),
1928            )
1929        });
1930
1931        self.filter_type_and_test_only_dependencies(results);
1932    }
1933
1934    fn filter_type_and_test_only_dependencies(
1935        &self,
1936        results: &mut crate::results::AnalysisResults,
1937    ) {
1938        let baseline_type_only: FxHashSet<&str> = self
1939            .baseline
1940            .type_only_dependencies
1941            .iter()
1942            .map(String::as_str)
1943            .collect();
1944        results.type_only_dependencies.retain(|dep| {
1945            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1946            !baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
1947        });
1948
1949        let baseline_test_only: FxHashSet<&str> = self
1950            .baseline
1951            .test_only_dependencies
1952            .iter()
1953            .map(String::as_str)
1954            .collect();
1955        results.test_only_dependencies.retain(|dep| {
1956            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1957            !baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
1958        });
1959
1960        let baseline_dev_in_prod: FxHashSet<&str> = self
1961            .baseline
1962            .dev_dependencies_in_production
1963            .iter()
1964            .map(String::as_str)
1965            .collect();
1966        results.dev_dependencies_in_production.retain(|dep| {
1967            let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
1968            !baseline_contains_dependency(
1969                &baseline_dev_in_prod,
1970                &dep.dep.package_name,
1971                key.as_str(),
1972            )
1973        });
1974    }
1975
1976    fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
1977        let baseline_boundary: FxHashSet<&str> = self
1978            .baseline
1979            .boundary_violations
1980            .iter()
1981            .map(String::as_str)
1982            .collect();
1983        results.boundary_violations.retain(|violation| {
1984            let key = boundary_violation_key(&violation.violation, self.root);
1985            !baseline_boundary.contains(key.as_str())
1986        });
1987
1988        self.filter_boundary_details(results);
1989        self.filter_stale_suppressions(results);
1990        self.filter_invalid_client_exports(results);
1991        self.filter_mixed_client_server_barrels(results);
1992        self.filter_misplaced_directives(results);
1993        self.filter_route_collisions(results);
1994        self.filter_dynamic_segment_name_conflicts(results);
1995    }
1996
1997    fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
1998        let baseline_invalid: FxHashSet<&str> = self
1999            .baseline
2000            .invalid_client_exports
2001            .iter()
2002            .map(String::as_str)
2003            .collect();
2004        results.invalid_client_exports.retain(|finding| {
2005            let key = format!(
2006                "{}:{}",
2007                relative_path(&finding.export.path, self.root),
2008                finding.export.export_name
2009            );
2010            !baseline_invalid.contains(key.as_str())
2011        });
2012    }
2013
2014    fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
2015        let baseline_barrels: FxHashSet<&str> = self
2016            .baseline
2017            .mixed_client_server_barrels
2018            .iter()
2019            .map(String::as_str)
2020            .collect();
2021        results.mixed_client_server_barrels.retain(|finding| {
2022            let key = format!(
2023                "{}:{}:{}",
2024                relative_path(&finding.barrel.path, self.root),
2025                finding.barrel.client_origin,
2026                finding.barrel.server_origin
2027            );
2028            !baseline_barrels.contains(key.as_str())
2029        });
2030    }
2031
2032    fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
2033        let baseline_directives: FxHashSet<&str> = self
2034            .baseline
2035            .misplaced_directives
2036            .iter()
2037            .map(String::as_str)
2038            .collect();
2039        results.misplaced_directives.retain(|finding| {
2040            let key = format!(
2041                "{}:{}:{}",
2042                relative_path(&finding.directive_site.path, self.root),
2043                finding.directive_site.line,
2044                finding.directive_site.directive
2045            );
2046            !baseline_directives.contains(key.as_str())
2047        });
2048    }
2049
2050    fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
2051        let baseline_collisions: FxHashSet<&str> = self
2052            .baseline
2053            .route_collisions
2054            .iter()
2055            .map(String::as_str)
2056            .collect();
2057        results.route_collisions.retain(|finding| {
2058            let key = format!(
2059                "{}:{}",
2060                relative_path(&finding.collision.path, self.root),
2061                finding.collision.url
2062            );
2063            !baseline_collisions.contains(key.as_str())
2064        });
2065    }
2066
2067    fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
2068        let baseline_conflicts: FxHashSet<&str> = self
2069            .baseline
2070            .dynamic_segment_name_conflicts
2071            .iter()
2072            .map(String::as_str)
2073            .collect();
2074        results.dynamic_segment_name_conflicts.retain(|finding| {
2075            let key = format!(
2076                "{}:{}",
2077                relative_path(&finding.conflict.path, self.root),
2078                finding.conflict.position
2079            );
2080            !baseline_conflicts.contains(key.as_str())
2081        });
2082    }
2083
2084    fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
2085        let baseline_boundary_coverage: FxHashSet<&str> = self
2086            .baseline
2087            .boundary_coverage_violations
2088            .iter()
2089            .map(String::as_str)
2090            .collect();
2091        results.boundary_coverage_violations.retain(|violation| {
2092            let key = relative_path(&violation.violation.path, self.root);
2093            !baseline_boundary_coverage.contains(key.as_str())
2094        });
2095
2096        let baseline_boundary_calls: FxHashSet<&str> = self
2097            .baseline
2098            .boundary_call_violations
2099            .iter()
2100            .map(String::as_str)
2101            .collect();
2102        results.boundary_call_violations.retain(|violation| {
2103            let key = boundary_call_violation_key(&violation.violation, self.root);
2104            !baseline_boundary_calls.contains(key.as_str())
2105        });
2106    }
2107
2108    fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
2109        let baseline_stale: FxHashSet<&str> = self
2110            .baseline
2111            .stale_suppressions
2112            .iter()
2113            .map(String::as_str)
2114            .collect();
2115        results.stale_suppressions.retain(|suppression| {
2116            let key = stale_suppression_baseline_key(suppression, self.root);
2117            let legacy_key = format!(
2118                "{}:{}",
2119                relative_path(&suppression.path, self.root),
2120                suppression.line
2121            );
2122            !baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
2123        });
2124    }
2125
2126    fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
2127        let baseline_catalog: FxHashSet<&str> = self
2128            .baseline
2129            .unused_catalog_entries
2130            .iter()
2131            .map(String::as_str)
2132            .collect();
2133        results.unused_catalog_entries.retain(|entry| {
2134            let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
2135            !baseline_catalog.contains(key.as_str())
2136        });
2137
2138        let baseline_empty_catalog_groups: FxHashSet<&str> = self
2139            .baseline
2140            .empty_catalog_groups
2141            .iter()
2142            .map(String::as_str)
2143            .collect();
2144        results.empty_catalog_groups.retain(|group| {
2145            !baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
2146        });
2147
2148        self.filter_pnpm_references_and_overrides(results);
2149    }
2150
2151    fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
2152        let baseline_unresolved: FxHashSet<&str> = self
2153            .baseline
2154            .unresolved_catalog_references
2155            .iter()
2156            .map(String::as_str)
2157            .collect();
2158        results.unresolved_catalog_references.retain(|reference| {
2159            let key = format!(
2160                "{}:{}:{}:{}",
2161                relative_path(&reference.reference.path, self.root),
2162                reference.reference.line,
2163                reference.reference.catalog_name,
2164                reference.reference.entry_name,
2165            );
2166            !baseline_unresolved.contains(key.as_str())
2167        });
2168
2169        self.filter_pnpm_overrides(results);
2170    }
2171
2172    fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
2173        let baseline_unused_overrides: FxHashSet<&str> = self
2174            .baseline
2175            .unused_dependency_overrides
2176            .iter()
2177            .map(String::as_str)
2178            .collect();
2179        results
2180            .unused_dependency_overrides
2181            .retain(|override_entry| {
2182                let key = format!(
2183                    "{}:{}",
2184                    override_entry.entry.source, override_entry.entry.raw_key
2185                );
2186                !baseline_unused_overrides.contains(key.as_str())
2187            });
2188
2189        let baseline_misconfigured_overrides: FxHashSet<&str> = self
2190            .baseline
2191            .misconfigured_dependency_overrides
2192            .iter()
2193            .map(String::as_str)
2194            .collect();
2195        results
2196            .misconfigured_dependency_overrides
2197            .retain(|override_entry| {
2198                let key = format!(
2199                    "{}:{}",
2200                    override_entry.entry.source, override_entry.entry.raw_key
2201                );
2202                !baseline_misconfigured_overrides.contains(key.as_str())
2203            });
2204    }
2205}
2206
2207/// Filter results to only include issues not present in the baseline.
2208///
2209/// A baseline with an `identity` matches canonical keys by count. A legacy
2210/// baseline matches each entry in its old form, as older fallow versions did.
2211pub fn filter_new_issues(
2212    results: crate::results::AnalysisResults,
2213    baseline: &BaselineData,
2214    root: &Path,
2215) -> crate::results::AnalysisResults {
2216    if baseline.identity.is_some() {
2217        filter_new_issues_by_canonical_keys(results, baseline, root)
2218    } else {
2219        filter_new_issues_by_legacy_keys(results, baseline, root)
2220    }
2221}
2222
2223fn filter_new_issues_by_canonical_keys(
2224    mut results: crate::results::AnalysisResults,
2225    baseline: &BaselineData,
2226    root: &Path,
2227) -> crate::results::AnalysisResults {
2228    let paths = IdentityPaths::new(root);
2229    macro_rules! retain {
2230        ($($field:ident),* $(,)?) => {
2231            $(retain_unsaved(&mut results.$field, &baseline.$field, &paths);)*
2232        };
2233    }
2234    with_baseline_fields!(retain);
2235    results
2236}
2237
2238fn filter_new_issues_by_legacy_keys(
2239    mut results: crate::results::AnalysisResults,
2240    baseline: &BaselineData,
2241    root: &Path,
2242) -> crate::results::AnalysisResults {
2243    let baseline_files: FxHashSet<&str> =
2244        baseline.unused_files.iter().map(String::as_str).collect();
2245    let baseline_exports: FxHashSet<&str> =
2246        baseline.unused_exports.iter().map(String::as_str).collect();
2247    let baseline_types: FxHashSet<&str> =
2248        baseline.unused_types.iter().map(String::as_str).collect();
2249    let baseline_deps: FxHashSet<&str> = baseline
2250        .unused_dependencies
2251        .iter()
2252        .map(String::as_str)
2253        .collect();
2254    let baseline_dev_deps: FxHashSet<&str> = baseline
2255        .unused_dev_dependencies
2256        .iter()
2257        .map(String::as_str)
2258        .collect();
2259
2260    results
2261        .unused_files
2262        .retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
2263    results.unused_exports.retain(|e| {
2264        let key = format!(
2265            "{}:{}",
2266            relative_path(&e.export.path, root),
2267            e.export.export_name
2268        );
2269        !baseline_exports.contains(key.as_str())
2270    });
2271    results.unused_types.retain(|e| {
2272        let key = format!(
2273            "{}:{}",
2274            relative_path(&e.export.path, root),
2275            e.export.export_name
2276        );
2277        !baseline_types.contains(key.as_str())
2278    });
2279    filter_private_type_leaks(
2280        &mut results.private_type_leaks,
2281        &baseline.private_type_leaks,
2282        root,
2283    );
2284    let baseline_deprecated: FxHashSet<&str> = baseline
2285        .deprecated_exports_in_use
2286        .iter()
2287        .map(String::as_str)
2288        .collect();
2289    results
2290        .deprecated_exports_in_use
2291        .retain(|e| !baseline_deprecated.contains(deprecated_export_key(&e.export, root).as_str()));
2292    results.unused_dependencies.retain(|d| {
2293        let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
2294        !baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
2295    });
2296    results.unused_dev_dependencies.retain(|d| {
2297        let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
2298        !baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
2299    });
2300
2301    let filter = BaselineFilterContext { baseline, root };
2302    filter.filter_cycles_and_members(&mut results);
2303    filter.filter_dependency_variants(&mut results);
2304    filter.filter_boundaries_and_suppressions(&mut results);
2305    filter.filter_pnpm_entries(&mut results);
2306
2307    results
2308}
2309
2310/// What a loaded dead-code baseline did to a result.
2311#[derive(Debug, Clone, PartialEq, Eq)]
2312pub enum DeadCodeBaselineOutcome {
2313    /// The file is a dead-code baseline. The findings it holds are removed.
2314    Applied {
2315        /// This run's view of the baseline.
2316        staleness: BaselineStaleness,
2317        /// True when the file has no `identity`, so its entries use the legacy
2318        /// key forms. `--save-baseline` rewrites it with canonical keys.
2319        legacy_keys: bool,
2320    },
2321    /// The file is not a dead-code baseline: another command saved it
2322    /// (`saved_by` names that command), or nothing in it names a writer. It
2323    /// suppresses nothing, so every finding stays.
2324    NotDeadCode {
2325        /// This run's view of the file: zero entries, zero matches.
2326        staleness: BaselineStaleness,
2327        /// The known command that saved the file, when the file names one.
2328        saved_by: Option<BaselineKind>,
2329    },
2330}
2331
2332/// Why a dead-code baseline could not be applied.
2333#[derive(Debug, Clone, PartialEq, Eq)]
2334pub enum DeadCodeBaselineError {
2335    /// The file is not valid JSON, or a dead-code baseline with a missing or
2336    /// wrong field.
2337    Parse(String),
2338    /// The baseline was saved with another analysis identity, for example a
2339    /// type-aware run. The fields that differ are listed.
2340    IncompatibleIdentity(Vec<&'static str>),
2341}
2342
2343/// Apply a dead-code baseline to a result.
2344///
2345/// The CLI (`--baseline`) and the programmatic API read a baseline through this
2346/// one function, so a baseline hides the same findings on every surface.
2347/// `content` is the text of the baseline file. `change_scoped` is true when the
2348/// run analyzed only part of the project; see [`BaselineStaleness`].
2349///
2350/// The file kind is read before the strict parse. Several fields of the
2351/// dead-code format have no default, so another command's baseline would fail
2352/// the parse before anything could name its writer.
2353///
2354/// # Errors
2355///
2356/// Returns [`DeadCodeBaselineError`] when the file is not valid JSON, is a
2357/// broken dead-code baseline, or was saved with an incompatible analysis
2358/// identity.
2359pub fn apply_dead_code_baseline(
2360    results: &mut crate::results::AnalysisResults,
2361    content: &str,
2362    root: &Path,
2363    identity: &fallow_types::semantic::SemanticAnalysisIdentity,
2364    change_scoped: bool,
2365) -> Result<DeadCodeBaselineOutcome, DeadCodeBaselineError> {
2366    let parsed = serde_json::from_str::<serde_json::Value>(content)
2367        .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
2368    let saved_by = match classify_baseline_value(&parsed, BaselineKind::DeadCode) {
2369        BaselineFileKind::Own | BaselineFileKind::NotAnObject => None,
2370        foreign @ BaselineFileKind::Foreign(_) => Some(foreign.saved_by()),
2371        BaselineFileKind::Unrecognised => Some(None),
2372    };
2373    if let Some(saved_by) = saved_by {
2374        return Ok(DeadCodeBaselineOutcome::NotDeadCode {
2375            staleness: BaselineStaleness {
2376                entries: 0,
2377                matched: 0,
2378                current_findings: results.total_issues(),
2379                change_scoped,
2380            },
2381            saved_by,
2382        });
2383    }
2384    let baseline = serde_json::from_value::<BaselineData>(parsed)
2385        .map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
2386    if let Some(scheme) = baseline.identity.as_deref()
2387        && scheme != BASELINE_KEY_SCHEME
2388    {
2389        return Err(DeadCodeBaselineError::Parse(format!(
2390            "unknown baseline identity `{scheme}`: this fallow version reads `{BASELINE_KEY_SCHEME}` and legacy baselines. Regenerate the file with --save-baseline"
2391        )));
2392    }
2393    let incompatible = baseline.analysis_identity().incompatible_fields(identity);
2394    if !incompatible.is_empty() {
2395        return Err(DeadCodeBaselineError::IncompatibleIdentity(incompatible));
2396    }
2397    let before = baseline_finding_count(results);
2398    let hidden_matched = cascade_hidden_matches(results, &baseline, root);
2399    *results = filter_new_issues(std::mem::take(results), &baseline, root);
2400    Ok(DeadCodeBaselineOutcome::Applied {
2401        staleness: BaselineStaleness {
2402            entries: baseline.total_entries(),
2403            matched: before.saturating_sub(baseline_finding_count(results)) + hidden_matched,
2404            current_findings: before,
2405            change_scoped,
2406        },
2407        legacy_keys: baseline.identity.is_none(),
2408    })
2409}
2410
2411fn baseline_finding_count(results: &crate::results::AnalysisResults) -> usize {
2412    results.total_issues() - results.enforced_prop_drilling_count()
2413        + results.prop_drilling_chains.len()
2414}
2415
2416/// Baseline entries that match a finding of the unused-file cascade filter.
2417///
2418/// Such a finding still exists: the report hides it because its file is an
2419/// unused file. Its baseline entry therefore still describes the project and
2420/// must not count as stale. The hidden findings sit in unused files and the
2421/// reported ones do not, so no entry can match both.
2422fn cascade_hidden_matches(
2423    results: &crate::results::AnalysisResults,
2424    baseline: &BaselineData,
2425    root: &Path,
2426) -> usize {
2427    if results.cascade.hidden.is_empty() {
2428        return 0;
2429    }
2430    let hidden = results.cascade.hidden.to_results();
2431    let before = hidden.total_issues();
2432    before.saturating_sub(filter_new_issues(hidden, baseline, root).total_issues())
2433}
2434
2435/// Baseline data for duplication comparison.
2436///
2437/// New baselines key every clone group by `<fingerprint>:<instance count>` in
2438/// `normalized_clone_fingerprints`. The fingerprint hashes normalized clone
2439/// content, so an unrelated line shift or formatting-only edit keeps it matched,
2440/// while a token edit or extra copy reports a new finding.
2441///
2442/// `clone_groups` keeps the oldest location keys, while `clone_fingerprints`
2443/// keeps the raw-fragment keys expected by older binaries. New readers prefer
2444/// the normalized field. This makes baselines readable in both directions.
2445#[derive(Default, serde::Serialize, serde::Deserialize)]
2446pub struct DuplicationBaselineData {
2447    /// The command that saved this file. See [`BaselineData::kind`] for why it
2448    /// is written but never read back through this struct.
2449    #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2450    kind: Option<BaselineKind>,
2451    /// Legacy clone group keys: sorted list of `file:start-end` per group.
2452    #[serde(default)]
2453    pub clone_groups: Vec<String>,
2454    /// Legacy raw-fragment keys expected by older binaries.
2455    #[serde(default)]
2456    pub clone_fingerprints: Vec<String>,
2457    /// Normalized content keys used by current binaries.
2458    #[serde(default)]
2459    pub normalized_clone_fingerprints: Vec<String>,
2460}
2461
2462impl DuplicationBaselineData {
2463    /// The keys this format writes, for [`declares_baseline_format`].
2464    pub const DECLARED_KEYS: &'static [&'static str] = &[
2465        "clone_groups",
2466        "clone_fingerprints",
2467        "normalized_clone_fingerprints",
2468    ];
2469
2470    /// Build a duplication baseline from the current report.
2471    pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
2472        let fingerprints =
2473            crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2474        Self {
2475            kind: Some(BaselineKind::Dupes),
2476            clone_groups: report
2477                .clone_groups
2478                .iter()
2479                .map(|g| clone_group_key(g, root))
2480                .collect(),
2481            clone_fingerprints: report
2482                .clone_groups
2483                .iter()
2484                .map(legacy_clone_group_fingerprint_key)
2485                .collect(),
2486            normalized_clone_fingerprints: report
2487                .clone_groups
2488                .iter()
2489                .map(|group| clone_group_fingerprint_key(group, &fingerprints))
2490                .collect(),
2491        }
2492    }
2493
2494    /// True when the baseline holds a key that older versions gave to every
2495    /// clone group whose code did not parse on its own. A re-save replaces it.
2496    #[must_use]
2497    pub fn has_unparsed_collision_keys(&self) -> bool {
2498        self.normalized_clone_fingerprints
2499            .iter()
2500            .any(|key| crate::duplicates::is_unparsed_collision_key(key))
2501    }
2502
2503    /// Number of baseline entries actually used for comparison.
2504    #[must_use]
2505    pub fn entry_count(&self) -> usize {
2506        if !self.normalized_clone_fingerprints.is_empty() {
2507            self.normalized_clone_fingerprints.len()
2508        } else if !self.clone_fingerprints.is_empty() {
2509            self.clone_fingerprints.len()
2510        } else {
2511            self.clone_groups.len()
2512        }
2513    }
2514}
2515
2516/// Generate a stable key for a clone group based on its instance locations.
2517fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
2518    let mut parts: Vec<String> = group
2519        .instances
2520        .iter()
2521        .map(|i| {
2522            format!(
2523                "{}:{}-{}",
2524                relative_path(&i.file, root),
2525                i.start_line,
2526                i.end_line
2527            )
2528        })
2529        .collect();
2530    parts.sort();
2531    parts.join("|")
2532}
2533
2534/// Generate the normalized, location-independent key written by new baselines.
2535fn clone_group_fingerprint_key(
2536    group: &crate::duplicates::CloneGroup,
2537    fingerprints: &crate::duplicates::CloneFingerprintSet,
2538) -> String {
2539    fingerprints.ignored_clone_key_for_group(group)
2540}
2541
2542/// Recreate the raw-fragment key written by baseline versions before normalized
2543/// clone identity. Kept only as a read fallback during migration.
2544fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
2545    let representative = group
2546        .instances
2547        .iter()
2548        .min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
2549        .map_or("", |i| i.fragment.as_str());
2550    let hash = if representative.as_bytes().contains(&b'\r') {
2551        xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
2552    } else {
2553        xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
2554    };
2555    format!(
2556        "{}{:08x}:{}",
2557        crate::duplicates::FINGERPRINT_PREFIX,
2558        hash as u32,
2559        group.instances.len()
2560    )
2561}
2562
2563fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
2564    match remaining.get_mut(key) {
2565        Some(count) if *count > 0 => {
2566            *count -= 1;
2567            true
2568        }
2569        _ => false,
2570    }
2571}
2572
2573/// Filter a duplication report to only include clone groups not present in the baseline.
2574///
2575/// Baselines carrying `normalized_clone_fingerprints` compare on normalized
2576/// content plus instance count. Older raw fingerprints and location keys remain
2577/// supported as fallbacks.
2578pub fn filter_new_clone_groups(
2579    mut report: DuplicationReport,
2580    baseline: &DuplicationBaselineData,
2581    root: &Path,
2582) -> DuplicationReport {
2583    if !baseline.normalized_clone_fingerprints.is_empty() {
2584        let fingerprints =
2585            crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
2586        let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2587        for key in &baseline.normalized_clone_fingerprints {
2588            *remaining.entry(key.as_str()).or_insert(0) += 1;
2589        }
2590        report.clone_groups.retain(|group| {
2591            let key = clone_group_fingerprint_key(group, &fingerprints);
2592            !consume_baseline_key(&mut remaining, &key)
2593        });
2594    } else if baseline.clone_fingerprints.is_empty() {
2595        let baseline_keys: FxHashSet<&str> =
2596            baseline.clone_groups.iter().map(String::as_str).collect();
2597        report.clone_groups.retain(|g| {
2598            let key = clone_group_key(g, root);
2599            !baseline_keys.contains(key.as_str())
2600        });
2601    } else {
2602        let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
2603        for key in &baseline.clone_fingerprints {
2604            *remaining.entry(key.as_str()).or_insert(0) += 1;
2605        }
2606        report.clone_groups.retain(|group| {
2607            let key = legacy_clone_group_fingerprint_key(group);
2608            !consume_baseline_key(&mut remaining, &key)
2609        });
2610    }
2611
2612    crate::duplicates::refresh_scoped_report(&mut report, root);
2613
2614    report
2615}
2616
2617/// Recompute duplication statistics after filtering (baseline or `--changed-since`).
2618///
2619/// Uses per-file line deduplication (matching `compute_stats` in `detect.rs`)
2620/// so overlapping clone instances don't inflate the duplicated line count.
2621pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
2622    crate::duplicates::recompute_stats(report)
2623}
2624
2625/// Baseline data for health (complexity) comparison.
2626///
2627/// New baselines store count-per-category-per-file data in `finding_counts` so
2628/// line shifts do not leak pre-existing findings. Legacy baselines with
2629/// `findings: ["path:name:line"]` still load so users can refresh them in
2630/// place with `--save-baseline`.
2631///
2632/// `identity_finding_counts` carries the same counts bucketed per function
2633/// identity instead of per file, for the stricter [`HealthBaselineMode::Identity`]
2634/// comparison. It is written only when the baseline is saved in identity mode,
2635/// so default baselines keep their count-only shape. Identity baselines still
2636/// carry `finding_counts`, so they also work in count mode and with older
2637/// binaries.
2638#[derive(Default, serde::Serialize, serde::Deserialize)]
2639pub struct HealthBaselineData {
2640    /// The command that saved this file. See [`BaselineData::kind`] for why it
2641    /// is written but never read back through this struct.
2642    #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
2643    pub(crate) kind: Option<BaselineKind>,
2644    /// Legacy health baseline keys: `relative_path:function_name:line`.
2645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2646    pub(crate) findings: Vec<String>,
2647    /// Count-per-category-per-file baseline buckets.
2648    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2649    pub(crate) finding_counts: HealthFindingCountMap,
2650    /// Count-per-category buckets keyed by `relative_path\0function_name`.
2651    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2652    pub(crate) identity_finding_counts: HealthFindingCountMap,
2653    /// Stable runtime-coverage finding IDs from the sidecar.
2654    #[serde(default)]
2655    pub(crate) runtime_coverage_findings: Vec<String>,
2656    /// Line-move-tolerant runtime-coverage suppression keys of the form
2657    /// `path\0name\0source_hash`. Unlike `runtime_coverage_findings` (whose
2658    /// keys hash the start line and so churn when a function moves), the
2659    /// `source_hash` component is the content digest of the function body, so a
2660    /// moved-but-unedited function keeps the same key and stays suppressed.
2661    /// Only findings whose `source_hash` is present contribute an entry.
2662    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2663    pub(crate) runtime_coverage_source_hashes: Vec<String>,
2664    /// Refactoring target keys: `relative_path:category`.
2665    #[serde(default)]
2666    pub(crate) target_keys: Vec<String>,
2667}
2668
2669/// Serialized per-bucket finding tally inside a health baseline file.
2670#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2671pub struct HealthBaselineCount {
2672    count: usize,
2673}
2674
2675type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
2676
2677/// How a saved health baseline is matched against current findings.
2678#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2679pub enum HealthBaselineMode {
2680    /// Match per file and finding category. Resilient to renames and line
2681    /// shifts, but a replacement hotspot consumes the allowance of the hotspot
2682    /// it replaced.
2683    ///
2684    /// Count buckets do not survive file moves: the bucket key is the path and
2685    /// the payload is category tallies alone, so a bucket whose file moved has
2686    /// no surviving identity component to re-match on. Guessing a new path
2687    /// from count shapes could silently transfer allowance between unrelated
2688    /// files, so no move tolerance is attempted in this mode.
2689    #[default]
2690    Count,
2691    /// Match per function identity (path plus function name) and finding
2692    /// category. A hotspot that replaces another hotspot in the same file is
2693    /// reported, while line shifts and in-place edits stay suppressed.
2694    ///
2695    /// Identity buckets tolerate file moves conservatively: a bucket whose
2696    /// path no longer exists on disk follows its function name to a new path
2697    /// when exactly one unclaimed current bucket carries that name, resolved
2698    /// by `moved_identity_bucket_remaps`.
2699    Identity,
2700}
2701
2702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2703enum HealthFindingDimension {
2704    Complexity,
2705    Crap,
2706}
2707
2708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2709struct HealthFindingCategory {
2710    dimension: HealthFindingDimension,
2711    severity: fallow_output::FindingSeverity,
2712}
2713
2714impl HealthFindingCategory {
2715    const fn key(self) -> &'static str {
2716        match (self.dimension, self.severity) {
2717            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
2718                "complexity_moderate"
2719            }
2720            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
2721                "complexity_high"
2722            }
2723            (HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
2724                "complexity_critical"
2725            }
2726            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
2727                "crap_moderate"
2728            }
2729            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
2730            (HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
2731                "crap_critical"
2732            }
2733        }
2734    }
2735}
2736
2737const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
2738    HealthFindingDimension::Complexity,
2739    HealthFindingDimension::Crap,
2740];
2741
2742impl HealthBaselineData {
2743    /// The keys this format writes, for [`declares_baseline_format`]. A health
2744    /// baseline omits the buckets it has nothing for, so a file carrying any
2745    /// one of these is health's own.
2746    pub const DECLARED_KEYS: &'static [&'static str] = &[
2747        "findings",
2748        "finding_counts",
2749        "identity_finding_counts",
2750        "runtime_coverage_findings",
2751        "runtime_coverage_source_hashes",
2752        "target_keys",
2753    ];
2754
2755    /// Build a health baseline from findings and targets.
2756    pub(crate) fn from_findings(
2757        findings: &[fallow_output::ComplexityViolation],
2758        runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
2759        targets: &[fallow_output::RefactoringTarget],
2760        root: &Path,
2761    ) -> Self {
2762        Self {
2763            kind: Some(BaselineKind::Health),
2764            findings: Vec::new(),
2765            finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
2766            identity_finding_counts: HealthFindingCountMap::new(),
2767            runtime_coverage_findings: runtime_coverage_findings
2768                .iter()
2769                .map(|f| runtime_coverage_finding_key(f, root))
2770                .collect(),
2771            runtime_coverage_source_hashes: runtime_coverage_findings
2772                .iter()
2773                .filter_map(|f| runtime_coverage_source_hash_key(f, root))
2774                .collect(),
2775            target_keys: targets
2776                .iter()
2777                .map(|t| target_baseline_key(t, root))
2778                .collect(),
2779        }
2780    }
2781
2782    pub(crate) fn finding_entry_count(&self) -> usize {
2783        if self.finding_counts.is_empty() {
2784            self.findings.len()
2785        } else {
2786            self.finding_counts
2787                .values()
2788                .flat_map(BTreeMap::values)
2789                .map(|entry| entry.count)
2790                .sum()
2791        }
2792    }
2793
2794    /// Add per-function identity buckets to a saved baseline.
2795    ///
2796    /// Only [`HealthBaselineMode::Identity`] saves record these, so a default
2797    /// baseline keeps carrying counts alone and stays free of function names.
2798    #[must_use]
2799    pub(crate) fn with_identity(
2800        mut self,
2801        findings: &[fallow_output::ComplexityViolation],
2802        root: &Path,
2803    ) -> Self {
2804        self.identity_finding_counts =
2805            health_finding_counts(findings, root, HealthBaselineMode::Identity);
2806        self
2807    }
2808
2809    /// `true` when identity matching would silently degrade because the saved
2810    /// baseline predates `identity_finding_counts` yet does carry findings.
2811    pub(crate) fn lacks_identity_data(&self) -> bool {
2812        self.identity_finding_counts.is_empty()
2813            && (!self.finding_counts.is_empty() || !self.findings.is_empty())
2814    }
2815
2816    fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
2817        match mode {
2818            HealthBaselineMode::Count => &self.finding_counts,
2819            HealthBaselineMode::Identity => &self.identity_finding_counts,
2820        }
2821    }
2822
2823    pub(crate) fn overlap_entries(
2824        &self,
2825        findings: &[fallow_output::ComplexityViolation],
2826        root: &Path,
2827        mode: HealthBaselineMode,
2828    ) -> HealthBaselineOverlap {
2829        let baseline_counts = self.counts_for(mode);
2830        if baseline_counts.is_empty() {
2831            let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
2832            HealthBaselineOverlap {
2833                matched_entries: findings
2834                    .iter()
2835                    .filter(|finding| {
2836                        baseline_keys.contains(health_finding_key(finding, root).as_str())
2837                    })
2838                    .count(),
2839                moved_entries: 0,
2840            }
2841        } else {
2842            let current_counts = health_finding_counts(findings, root, mode);
2843            let direct = health_overlap_entry_count(&current_counts, baseline_counts);
2844            if mode != HealthBaselineMode::Identity {
2845                return HealthBaselineOverlap {
2846                    matched_entries: direct,
2847                    moved_entries: 0,
2848                };
2849            }
2850            // `moved_entries` counts only followed file moves, so the overlap
2851            // is computed before and after the placeholder transfer.
2852            let moved = identity_counts_with_move_tolerance(baseline_counts, &current_counts, root);
2853            let moved_matched = moved.as_ref().map_or(direct, |counts| {
2854                health_overlap_entry_count(&current_counts, counts)
2855            });
2856            let matched = identity_counts_with_placeholder_allowance(
2857                moved.as_ref().unwrap_or(baseline_counts),
2858                &current_counts,
2859            )
2860            .map_or(moved_matched, |counts| {
2861                health_overlap_entry_count(&current_counts, &counts)
2862            });
2863            HealthBaselineOverlap {
2864                matched_entries: matched,
2865                moved_entries: moved_matched.saturating_sub(direct),
2866            }
2867        }
2868    }
2869}
2870
2871/// Baseline entry overlap for one run, split so followed file moves stay
2872/// observable in output rather than silently absorbed into the match count.
2873pub(crate) struct HealthBaselineOverlap {
2874    /// Entries that matched a current finding, including via followed moves.
2875    pub(crate) matched_entries: usize,
2876    /// Entries that matched only because a retired identity bucket was
2877    /// re-keyed to a moved file.
2878    pub(crate) moved_entries: usize,
2879}
2880
2881/// Generate a stable key for a refactoring target: `relative_path:category`.
2882fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
2883    format!(
2884        "{}:{}",
2885        relative_path(&target.path, root),
2886        target.category.label()
2887    )
2888}
2889
2890/// Generate a stable key for a health finding.
2891fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
2892    format!(
2893        "{}:{}:{}",
2894        relative_path(&finding.path, root),
2895        finding.name,
2896        finding.line
2897    )
2898}
2899
2900/// Bucket a finding belongs to for the given comparison mode.
2901///
2902/// The NUL separator keeps the identity bucket unambiguous for paths and
2903/// function names that contain `:`.
2904fn health_bucket_key(
2905    finding: &fallow_output::ComplexityViolation,
2906    root: &Path,
2907    mode: HealthBaselineMode,
2908) -> String {
2909    let path = relative_path(&finding.path, root);
2910    match mode {
2911        HealthBaselineMode::Count => path,
2912        HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
2913    }
2914}
2915
2916fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
2917    key.split_once('\0')
2918}
2919
2920/// Conservative file-move tolerance for identity baseline buckets.
2921///
2922/// A baseline bucket follows its function to a new path only when every one of
2923/// these holds:
2924///
2925/// - the bucket matched no current bucket at its saved path,
2926/// - the saved path no longer exists on disk under the project root, so the
2927///   file was moved or deleted rather than merely fixed,
2928/// - exactly one current bucket carries the same function name at a path the
2929///   baseline does not already cover,
2930/// - no other retired baseline bucket claims that same candidate.
2931///
2932/// The function name is the only identity component that survives a move, so
2933/// anything more permissive would risk transferring allowance between
2934/// unrelated functions. Anonymous placeholders never match. The result is
2935/// deterministic: both maps iterate in `BTreeMap` order and the
2936/// exactly-one rules make the outcome independent of iteration order.
2937fn moved_identity_bucket_remaps(
2938    baseline_counts: &HealthFindingCountMap,
2939    current_counts: &HealthFindingCountMap,
2940    root: &Path,
2941) -> Vec<(String, String)> {
2942    let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
2943    for key in current_counts.keys() {
2944        if baseline_counts.contains_key(key) {
2945            continue;
2946        }
2947        if let Some((_, name)) = identity_bucket_parts(key)
2948            && !is_anonymous_function_name(name)
2949        {
2950            candidates_by_name.entry(name).or_default().push(key);
2951        }
2952    }
2953
2954    let mut proposals: Vec<(&str, &str)> = Vec::new();
2955    let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
2956    for key in baseline_counts.keys() {
2957        if current_counts.contains_key(key.as_str()) {
2958            continue;
2959        }
2960        let Some((path, name)) = identity_bucket_parts(key) else {
2961            continue;
2962        };
2963        if is_anonymous_function_name(name) || root.join(path).exists() {
2964            continue;
2965        }
2966        if let Some(candidates) = candidates_by_name.get(name)
2967            && let [only_candidate] = candidates.as_slice()
2968        {
2969            proposals.push((key.as_str(), only_candidate));
2970            *claims.entry(only_candidate).or_default() += 1;
2971        }
2972    }
2973
2974    proposals
2975        .into_iter()
2976        .filter(|(_, candidate)| claims.get(candidate) == Some(&1))
2977        .map(|(old, new)| (old.to_string(), new.to_string()))
2978        .collect()
2979}
2980
2981/// A current identity bucket with a function name: the key and its counts.
2982type NamedBucket<'a> = (&'a String, &'a BTreeMap<String, HealthBaselineCount>);
2983
2984/// Baseline identity counts where a placeholder bucket (`<anonymous>`,
2985/// `<arrow>`) gives its unused allowance to new function names in the same
2986/// file.
2987///
2988/// A function without a declared name can get a context name in a later
2989/// version (`Command.make("setup")`, an `Effect.fn` span name). Its findings
2990/// then move from the placeholder bucket to a named bucket that has no
2991/// allowance for them. This transfer keeps such a baseline valid. The
2992/// allowance moves per category, only to named buckets in the same file whose
2993/// current count is above their own allowance, and only up to the count that
2994/// the placeholder bucket no longer uses. After a re-save, a placeholder bucket has unused allowance only when
2995/// an unnamed finding was fixed. Count mode has the same tolerance per file.
2996///
2997/// Returns `None` when no allowance moves, so callers can keep borrowing the
2998/// original map.
2999fn identity_counts_with_placeholder_allowance(
3000    baseline_counts: &HealthFindingCountMap,
3001    current_counts: &HealthFindingCountMap,
3002) -> Option<HealthFindingCountMap> {
3003    let mut named_by_path: FxHashMap<&str, Vec<NamedBucket<'_>>> = FxHashMap::default();
3004    for (key, counts) in current_counts {
3005        if let Some((path, name)) = identity_bucket_parts(key)
3006            && !is_anonymous_function_name(name)
3007        {
3008            named_by_path.entry(path).or_default().push((key, counts));
3009        }
3010    }
3011    let mut transferred: HealthFindingCountMap = BTreeMap::new();
3012    for (key, placeholder_counts) in baseline_counts {
3013        let Some((path, name)) = identity_bucket_parts(key) else {
3014            continue;
3015        };
3016        if !is_anonymous_function_name(name) {
3017            continue;
3018        }
3019        let Some(recipients) = named_by_path.get(path) else {
3020            continue;
3021        };
3022        for (category, placeholder_count) in placeholder_counts {
3023            let mut unused = placeholder_count
3024                .count
3025                .saturating_sub(bucket_category_count(current_counts, key, category));
3026            for (recipient_key, recipient_counts) in recipients {
3027                if unused == 0 {
3028                    break;
3029                }
3030                let current = recipient_counts
3031                    .get(category)
3032                    .map_or(0, |entry| entry.count);
3033                let own_allowance = bucket_category_count(baseline_counts, recipient_key, category);
3034                let given = transferred
3035                    .entry((*recipient_key).clone())
3036                    .or_default()
3037                    .entry(category.clone())
3038                    .or_insert(HealthBaselineCount { count: 0 });
3039                let take = current
3040                    .saturating_sub(own_allowance + given.count)
3041                    .min(unused);
3042                given.count += take;
3043                unused -= take;
3044            }
3045        }
3046    }
3047    transferred.retain(|_, counts| {
3048        counts.retain(|_, entry| entry.count > 0);
3049        !counts.is_empty()
3050    });
3051    if transferred.is_empty() {
3052        return None;
3053    }
3054    let mut effective = baseline_counts.clone();
3055    for (key, counts) in transferred {
3056        let bucket = effective.entry(key).or_default();
3057        for (category, given) in counts {
3058            bucket
3059                .entry(category)
3060                .and_modify(|entry| entry.count += given.count)
3061                .or_insert(given);
3062        }
3063    }
3064    Some(effective)
3065}
3066
3067fn bucket_category_count(counts: &HealthFindingCountMap, key: &str, category: &str) -> usize {
3068    counts
3069        .get(key)
3070        .and_then(|bucket| bucket.get(category))
3071        .map_or(0, |entry| entry.count)
3072}
3073
3074/// Baseline identity counts after file-move tolerance and the placeholder
3075/// allowance transfer. Returns `None` when neither changes the baseline.
3076fn effective_identity_counts(
3077    baseline_counts: &HealthFindingCountMap,
3078    current_counts: &HealthFindingCountMap,
3079    root: &Path,
3080) -> Option<HealthFindingCountMap> {
3081    let moved = identity_counts_with_move_tolerance(baseline_counts, current_counts, root);
3082    identity_counts_with_placeholder_allowance(
3083        moved.as_ref().unwrap_or(baseline_counts),
3084        current_counts,
3085    )
3086    .or(moved)
3087}
3088
3089/// Baseline identity counts with retired buckets re-keyed to moved files.
3090///
3091/// Returns `None` when no bucket qualifies, so callers can keep borrowing the
3092/// original map.
3093fn identity_counts_with_move_tolerance(
3094    baseline_counts: &HealthFindingCountMap,
3095    current_counts: &HealthFindingCountMap,
3096    root: &Path,
3097) -> Option<HealthFindingCountMap> {
3098    let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
3099    if remaps.is_empty() {
3100        return None;
3101    }
3102    let mut remapped = baseline_counts.clone();
3103    for (old_key, new_key) in remaps {
3104        if let Some(entry) = remapped.remove(&old_key) {
3105            remapped.insert(new_key, entry);
3106        }
3107    }
3108    Some(remapped)
3109}
3110
3111fn health_finding_counts(
3112    findings: &[fallow_output::ComplexityViolation],
3113    root: &Path,
3114    mode: HealthBaselineMode,
3115) -> HealthFindingCountMap {
3116    let mut counts = BTreeMap::new();
3117    for finding in findings {
3118        let bucket = health_bucket_key(finding, root, mode);
3119        let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
3120        for category in health_finding_categories(finding).into_iter().flatten() {
3121            file_counts
3122                .entry(category.key().to_string())
3123                .and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
3124                .or_insert(HealthBaselineCount { count: 1 });
3125        }
3126    }
3127    counts
3128}
3129
3130fn health_finding_categories(
3131    finding: &fallow_output::ComplexityViolation,
3132) -> [Option<HealthFindingCategory>; 2] {
3133    let complexity_category = HealthFindingCategory {
3134        dimension: HealthFindingDimension::Complexity,
3135        severity: finding.severity,
3136    };
3137    let crap_category = HealthFindingCategory {
3138        dimension: HealthFindingDimension::Crap,
3139        severity: finding.severity,
3140    };
3141    let has_complexity =
3142        finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
3143    let has_crap = finding.exceeded.includes_crap();
3144    [
3145        has_complexity.then_some(complexity_category),
3146        has_crap.then_some(crap_category),
3147    ]
3148}
3149
3150fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
3151    match severity {
3152        fallow_output::FindingSeverity::Moderate => 0,
3153        fallow_output::FindingSeverity::High => 1,
3154        fallow_output::FindingSeverity::Critical => 2,
3155    }
3156}
3157
3158fn severity_counts_for_dimension(
3159    file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
3160    dimension: HealthFindingDimension,
3161) -> [usize; 3] {
3162    let mut counts = [0; 3];
3163    for severity in [
3164        fallow_output::FindingSeverity::Moderate,
3165        fallow_output::FindingSeverity::High,
3166        fallow_output::FindingSeverity::Critical,
3167    ] {
3168        let category = HealthFindingCategory {
3169            dimension,
3170            severity,
3171        };
3172        counts[severity_index(severity)] = file_counts
3173            .and_then(|entries| entries.get(category.key()))
3174            .map_or(0, |entry| entry.count);
3175    }
3176    counts
3177}
3178
3179fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
3180    let mut available = baseline;
3181    let mut overflow = [false; 3];
3182
3183    for severity_idx in 0..3 {
3184        let compatible = available[severity_idx..].iter().sum::<usize>();
3185        overflow[severity_idx] = compatible < current[severity_idx];
3186
3187        let mut matched = current[severity_idx].min(compatible);
3188        for slot in available.iter_mut().skip(severity_idx) {
3189            let taken = matched.min(*slot);
3190            *slot -= taken;
3191            matched -= taken;
3192            if matched == 0 {
3193                break;
3194            }
3195        }
3196    }
3197
3198    overflow
3199}
3200
3201fn health_overflow_categories(
3202    current_counts: &HealthFindingCountMap,
3203    baseline_counts: &HealthFindingCountMap,
3204) -> FxHashMap<String, FxHashSet<&'static str>> {
3205    let mut overflow_by_path = FxHashMap::default();
3206
3207    for (path, current_file_counts) in current_counts {
3208        let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
3209        let baseline_file_counts = baseline_counts.get(path);
3210
3211        for dimension in HEALTH_FINDING_DIMENSIONS {
3212            let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
3213            let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
3214            let overflow = overflowing_severities(current, baseline);
3215
3216            for severity in [
3217                fallow_output::FindingSeverity::Moderate,
3218                fallow_output::FindingSeverity::High,
3219                fallow_output::FindingSeverity::Critical,
3220            ] {
3221                if overflow[severity_index(severity)] {
3222                    overflow_categories.insert(
3223                        HealthFindingCategory {
3224                            dimension,
3225                            severity,
3226                        }
3227                        .key(),
3228                    );
3229                }
3230            }
3231        }
3232
3233        if !overflow_categories.is_empty() {
3234            overflow_by_path.insert(path.clone(), overflow_categories);
3235        }
3236    }
3237
3238    overflow_by_path
3239}
3240
3241fn health_overlap_entry_count(
3242    current_counts: &HealthFindingCountMap,
3243    baseline_counts: &HealthFindingCountMap,
3244) -> usize {
3245    let mut overlap = 0;
3246
3247    for (path, baseline_file_counts) in baseline_counts {
3248        let current_file_counts = current_counts.get(path);
3249
3250        for dimension in HEALTH_FINDING_DIMENSIONS {
3251            let current_total: usize =
3252                severity_counts_for_dimension(current_file_counts, dimension)
3253                    .into_iter()
3254                    .sum();
3255            let baseline_total: usize =
3256                severity_counts_for_dimension(Some(baseline_file_counts), dimension)
3257                    .into_iter()
3258                    .sum();
3259            overlap += current_total.min(baseline_total);
3260        }
3261    }
3262
3263    overlap
3264}
3265
3266fn runtime_coverage_finding_key(
3267    finding: &fallow_output::RuntimeCoverageFinding,
3268    _root: &Path,
3269) -> String {
3270    finding
3271        .stable_id
3272        .clone()
3273        .unwrap_or_else(|| finding.id.clone())
3274}
3275
3276/// Line-move-tolerant writer key: `path\0name\0source_hash`.
3277///
3278/// Returns `None` when the finding carries no `source_hash` (e.g. a 0.5-shape
3279/// sidecar or an un-migrated producer); such findings fall back to the
3280/// line-sensitive `runtime_coverage_finding_key` for suppression. The NUL
3281/// separator avoids collisions with paths/names that contain `:`.
3282fn runtime_coverage_source_hash_key(
3283    finding: &fallow_output::RuntimeCoverageFinding,
3284    root: &Path,
3285) -> Option<String> {
3286    finding.source_hash.as_deref().map(|hash| {
3287        format!(
3288            "{}\0{}\0{}",
3289            relative_path(&finding.path, root),
3290            finding.function,
3291            hash
3292        )
3293    })
3294}
3295
3296/// Filter health findings to only include those not present in the baseline.
3297pub(crate) fn filter_new_health_findings(
3298    mut findings: Vec<fallow_output::ComplexityViolation>,
3299    baseline: &HealthBaselineData,
3300    root: &Path,
3301    mode: HealthBaselineMode,
3302) -> Vec<fallow_output::ComplexityViolation> {
3303    let baseline_counts = baseline.counts_for(mode);
3304    if !baseline_counts.is_empty() {
3305        let current_counts = health_finding_counts(&findings, root, mode);
3306        let remapped = (mode == HealthBaselineMode::Identity)
3307            .then(|| effective_identity_counts(baseline_counts, &current_counts, root))
3308            .flatten();
3309        let overflow_categories = health_overflow_categories(
3310            &current_counts,
3311            remapped.as_ref().unwrap_or(baseline_counts),
3312        );
3313        findings.retain(|finding| {
3314            let bucket = health_bucket_key(finding, root, mode);
3315            overflow_categories.get(&bucket).is_some_and(|categories| {
3316                health_finding_categories(finding)
3317                    .into_iter()
3318                    .flatten()
3319                    .any(|category| categories.contains(category.key()))
3320            })
3321        });
3322        return findings;
3323    }
3324
3325    let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
3326    findings.retain(|f| {
3327        let key = health_finding_key(f, root);
3328        !baseline_keys.contains(key.as_str())
3329    });
3330    findings
3331}
3332
3333pub(crate) fn filter_new_runtime_coverage_findings(
3334    mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
3335    baseline: &HealthBaselineData,
3336    root: &Path,
3337) -> Vec<fallow_output::RuntimeCoverageFinding> {
3338    let baseline_keys: FxHashSet<&str> = baseline
3339        .runtime_coverage_findings
3340        .iter()
3341        .map(String::as_str)
3342        .collect();
3343    let baseline_source_hash_keys: FxHashSet<&str> = baseline
3344        .runtime_coverage_source_hashes
3345        .iter()
3346        .map(String::as_str)
3347        .collect();
3348    findings.retain(|finding| {
3349        let suppressed_by_stable_id = finding
3350            .stable_id
3351            .as_deref()
3352            .is_some_and(|id| baseline_keys.contains(id));
3353        let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
3354        let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
3355            .is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
3356        !(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
3357    });
3358    findings
3359}
3360
3361/// Filter refactoring targets to only include those not present in the baseline.
3362pub(crate) fn filter_new_health_targets(
3363    mut targets: Vec<fallow_output::RefactoringTarget>,
3364    baseline: &HealthBaselineData,
3365    root: &Path,
3366) -> Vec<fallow_output::RefactoringTarget> {
3367    let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
3368    targets.retain(|t| {
3369        let key = target_baseline_key(t, root);
3370        !baseline_keys.contains(key.as_str())
3371    });
3372    targets
3373}
3374
3375/// Per-category delta between current results and a baseline.
3376#[derive(Debug, Clone, serde::Serialize)]
3377pub struct CategoryDelta {
3378    /// Finding count in the current run.
3379    pub current: usize,
3380    /// Finding count recorded in the baseline.
3381    pub baseline: usize,
3382    /// `current - baseline`; positive means new findings appeared.
3383    pub delta: i64,
3384}
3385
3386/// Deltas between current analysis results and a saved baseline.
3387///
3388/// Used in combined mode to show +/- counts in the failure summary and
3389/// to emit `baseline_deltas` in JSON output.
3390#[derive(Debug, Clone)]
3391pub struct BaselineDeltas {
3392    /// Net change in total issue count (positive = more issues).
3393    pub total_delta: i64,
3394    /// Per-category deltas keyed by category name.
3395    pub per_category: Vec<(String, CategoryDelta)>,
3396}
3397
3398#[cfg(test)]
3399mod tests {
3400    #[test]
3401    fn prop_baselines_match_repeated_occurrences_and_advisory_staleness() {
3402        use fallow_types::output_dead_code::{EffectiveSeverity, PropDrillingChainFinding};
3403        use fallow_types::results::{PropDrillHop, PropDrillingChain};
3404        let root = Path::new("/project");
3405        let identity = fallow_types::semantic::SemanticAnalysisIdentity::syntactic();
3406        let mut chain = PropDrillingChainFinding::with_actions(PropDrillingChain {
3407            prop: "user".to_owned(),
3408            depth: 3,
3409            hops: vec![PropDrillHop {
3410                file: root.join("src/Page.tsx"),
3411                line: 4,
3412                component: "Page".to_owned(),
3413            }],
3414        });
3415        chain.effective_severity = Some(EffectiveSeverity::Error);
3416        let original = AnalysisResults {
3417            prop_drilling_chains: vec![chain.clone()],
3418            ..Default::default()
3419        };
3420        let baseline = BaselineData::from_results(&original, root);
3421        let saved = serde_json::to_string(&baseline).unwrap();
3422        let mut repeated = original.clone();
3423        repeated.prop_drilling_chains.push(chain.clone());
3424        let outcome =
3425            apply_dead_code_baseline(&mut repeated, &saved, root, &identity, false).unwrap();
3426        let DeadCodeBaselineOutcome::Applied { staleness, .. } = outcome else {
3427            panic!("baseline must apply");
3428        };
3429        assert_eq!(
3430            (
3431                staleness.entries,
3432                staleness.matched,
3433                staleness.current_findings
3434            ),
3435            (1, 1, 2)
3436        );
3437        assert_eq!(repeated.enforced_prop_drilling_count(), 1);
3438        let doubled = AnalysisResults {
3439            prop_drilling_chains: vec![chain.clone(), chain.clone()],
3440            ..Default::default()
3441        };
3442        let doubled_baseline =
3443            serde_json::to_string(&BaselineData::from_results(&doubled, root)).unwrap();
3444        let base_value = serde_json::from_str(&saved).unwrap();
3445        let doubled_value = serde_json::from_str(&doubled_baseline).unwrap();
3446        let growth = crate::baseline_growth::baseline_growth(
3447            BaselineKind::DeadCode,
3448            &base_value,
3449            &doubled_value,
3450        );
3451        assert_eq!(growth.added_entries(), 1);
3452        assert_eq!(growth.categories[0].category, "prop_drilling_chains");
3453        assert!(
3454            crate::baseline_growth::baseline_growth(
3455                BaselineKind::DeadCode,
3456                &doubled_value,
3457                &base_value
3458            )
3459            .is_empty()
3460        );
3461        let pruned =
3462            prune_dead_code_baseline(&doubled_baseline, &original, root, &identity).unwrap();
3463        assert_eq!((pruned.entries_before, pruned.entries_after), (2, 1));
3464        let mut old: serde_json::Value = serde_json::from_str(&saved).unwrap();
3465        old.as_object_mut().unwrap().remove("prop_drilling_chains");
3466        let mut unreviewed = original;
3467        apply_dead_code_baseline(&mut unreviewed, &old.to_string(), root, &identity, false)
3468            .unwrap();
3469        assert_eq!(unreviewed.enforced_prop_drilling_count(), 1);
3470
3471        chain.effective_severity = Some(EffectiveSeverity::Warn);
3472        let mut advisory = AnalysisResults {
3473            prop_drilling_chains: vec![chain],
3474            ..Default::default()
3475        };
3476        let saved = serde_json::to_string(&BaselineData::from_results(&advisory, root)).unwrap();
3477        assert_eq!(advisory.total_issues(), 0);
3478        let outcome =
3479            apply_dead_code_baseline(&mut advisory, &saved, root, &identity, false).unwrap();
3480        let DeadCodeBaselineOutcome::Applied { staleness, .. } = outcome else {
3481            panic!("baseline must apply");
3482        };
3483        assert_eq!(
3484            (
3485                staleness.entries,
3486                staleness.matched,
3487                staleness.current_findings
3488            ),
3489            (1, 1, 1)
3490        );
3491        assert_eq!(advisory.prop_drilling_chains.len(), 0);
3492    }
3493
3494    use super::*;
3495
3496    use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3497    use crate::results::{
3498        AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
3499        UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
3500        UnusedFile,
3501    };
3502    use fallow_types::output_dead_code::{
3503        UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
3504    };
3505    use std::path::PathBuf;
3506
3507    #[test]
3508    fn stale_share_threshold_matches_the_documented_quarter() {
3509        for (baseline_entries, stale_entries, expected) in [
3510            (100, 24, false),
3511            (100, 25, true),
3512            (4, 2, true),
3513            (29, 2, false),
3514            (5, 1, false),
3515            (0, 0, false),
3516        ] {
3517            assert_eq!(
3518                stale_share_warrants_warning(baseline_entries, stale_entries),
3519                expected,
3520                "{stale_entries} of {baseline_entries} entries"
3521            );
3522        }
3523    }
3524
3525    const fn staleness(
3526        entries: usize,
3527        matched: usize,
3528        current_findings: usize,
3529    ) -> BaselineStaleness {
3530        BaselineStaleness {
3531            entries,
3532            matched,
3533            current_findings,
3534            change_scoped: false,
3535        }
3536    }
3537
3538    #[test]
3539    fn warning_is_silent_when_the_run_found_nothing() {
3540        let staleness = staleness(4, 0, 0);
3541        assert_eq!(staleness.stale_entries(), 4);
3542        assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3543    }
3544
3545    #[test]
3546    fn zero_overlap_warns_when_the_run_has_findings() {
3547        assert_eq!(
3548            staleness(4, 0, 4).warning(),
3549            BaselineStalenessWarning::ZeroOverlap
3550        );
3551    }
3552
3553    #[test]
3554    fn partial_warning_needs_the_documented_quarter() {
3555        assert_eq!(
3556            staleness(100, 76, 100).warning(),
3557            BaselineStalenessWarning::None
3558        );
3559        assert_eq!(
3560            staleness(100, 75, 100).warning(),
3561            BaselineStalenessWarning::Partial
3562        );
3563    }
3564
3565    #[test]
3566    fn empty_baseline_never_warns() {
3567        assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
3568    }
3569
3570    #[test]
3571    fn change_scoped_run_never_warns_and_never_trips_the_gate() {
3572        let scoped = BaselineStaleness {
3573            change_scoped: true,
3574            ..staleness(8, 2, 8)
3575        };
3576        assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
3577        assert!(!scoped.trips_gate());
3578    }
3579
3580    /// The whole point of the opt-in gate: it fires where the advisory
3581    /// warning deliberately stays quiet, because the repository asked for
3582    /// strictness rather than calibration.
3583    #[test]
3584    fn gate_trips_on_one_stale_entry_the_warning_ignores() {
3585        let staleness = staleness(20, 19, 19);
3586        assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3587        assert!(staleness.trips_gate());
3588    }
3589
3590    #[test]
3591    fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
3592        let staleness = staleness(4, 0, 0);
3593        assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
3594        assert!(staleness.trips_gate());
3595    }
3596
3597    #[test]
3598    fn gate_is_inert_on_an_empty_baseline() {
3599        assert!(!staleness(0, 0, 0).trips_gate());
3600    }
3601
3602    #[test]
3603    fn gate_is_inert_when_every_entry_matched() {
3604        assert!(!staleness(4, 4, 4).trips_gate());
3605    }
3606
3607    pub(super) fn make_results() -> AnalysisResults {
3608        AnalysisResults {
3609            unused_files: vec![
3610                UnusedFileFinding::with_actions(UnusedFile {
3611                    path: PathBuf::from("src/old.ts"),
3612                }),
3613                UnusedFileFinding::with_actions(UnusedFile {
3614                    path: PathBuf::from("src/dead.ts"),
3615                }),
3616            ],
3617            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
3618                path: PathBuf::from("src/utils.ts"),
3619                export_name: "helperA".to_string(),
3620                is_type_only: false,
3621                line: 5,
3622                col: 0,
3623                span_start: 40,
3624                is_re_export: false,
3625                deprecated: false,
3626                deprecated_reason: None,
3627            })],
3628            unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
3629                path: PathBuf::from("src/types.ts"),
3630                export_name: "OldType".to_string(),
3631                is_type_only: true,
3632                line: 10,
3633                col: 0,
3634                span_start: 100,
3635                is_re_export: false,
3636                deprecated: false,
3637                deprecated_reason: None,
3638            })],
3639            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3640                package_name: "lodash".to_string(),
3641                location: DependencyLocation::Dependencies,
3642                path: PathBuf::from("package.json"),
3643                line: 5,
3644                used_in_workspaces: Vec::new(),
3645                declared_and_imported_in: Vec::new(),
3646            })],
3647            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
3648                UnusedDependency {
3649                    package_name: "jest".to_string(),
3650                    location: DependencyLocation::DevDependencies,
3651                    path: PathBuf::from("package.json"),
3652                    line: 5,
3653                    used_in_workspaces: Vec::new(),
3654                    declared_and_imported_in: Vec::new(),
3655                },
3656            )],
3657            ..Default::default()
3658        }
3659    }
3660
3661    #[test]
3662    fn baseline_from_results_captures_all_fields() {
3663        let results = make_results();
3664        let baseline = BaselineData::from_results(&results, Path::new(""));
3665        assert_eq!(
3666            baseline.unused_files,
3667            vec!["unused-file:src/dead.ts", "unused-file:src/old.ts"]
3668        );
3669        assert_eq!(
3670            baseline.unused_exports,
3671            vec!["unused-export:src/utils.ts:helperA"]
3672        );
3673        assert_eq!(
3674            baseline.unused_types,
3675            vec!["unused-type:src/types.ts:OldType"]
3676        );
3677        assert_eq!(
3678            baseline.unused_dependencies,
3679            vec!["unused-dependency:package.json:lodash"]
3680        );
3681        assert_eq!(
3682            baseline.unused_dev_dependencies,
3683            vec!["unused-dev-dependency:package.json:jest"]
3684        );
3685    }
3686
3687    #[test]
3688    fn dependency_baseline_keys_include_package_json_path() {
3689        let root = Path::new("/repo");
3690        let results = AnalysisResults {
3691            unused_dependencies: vec![
3692                UnusedDependencyFinding::with_actions(UnusedDependency {
3693                    package_name: "lodash-es".to_string(),
3694                    location: DependencyLocation::Dependencies,
3695                    path: PathBuf::from("/repo/packages/app-a/package.json"),
3696                    line: 5,
3697                    used_in_workspaces: Vec::new(),
3698                    declared_and_imported_in: Vec::new(),
3699                }),
3700                UnusedDependencyFinding::with_actions(UnusedDependency {
3701                    package_name: "lodash-es".to_string(),
3702                    location: DependencyLocation::Dependencies,
3703                    path: PathBuf::from("/repo/packages/app-b/package.json"),
3704                    line: 5,
3705                    used_in_workspaces: Vec::new(),
3706                    declared_and_imported_in: Vec::new(),
3707                }),
3708            ],
3709            ..Default::default()
3710        };
3711
3712        let baseline = BaselineData::from_results(&results, root);
3713
3714        assert_eq!(
3715            baseline.unused_dependencies,
3716            vec![
3717                "unused-dependency:packages/app-a/package.json:lodash-es",
3718                "unused-dependency:packages/app-b/package.json:lodash-es"
3719            ]
3720        );
3721    }
3722
3723    #[test]
3724    fn dependency_baseline_filter_matches_path_before_package_name() {
3725        let root = Path::new("/repo");
3726        let results = AnalysisResults {
3727            unused_dependencies: vec![
3728                UnusedDependencyFinding::with_actions(UnusedDependency {
3729                    package_name: "lodash-es".to_string(),
3730                    location: DependencyLocation::Dependencies,
3731                    path: PathBuf::from("/repo/packages/app-a/package.json"),
3732                    line: 5,
3733                    used_in_workspaces: Vec::new(),
3734                    declared_and_imported_in: Vec::new(),
3735                }),
3736                UnusedDependencyFinding::with_actions(UnusedDependency {
3737                    package_name: "lodash-es".to_string(),
3738                    location: DependencyLocation::Dependencies,
3739                    path: PathBuf::from("/repo/packages/app-b/package.json"),
3740                    line: 5,
3741                    used_in_workspaces: Vec::new(),
3742                    declared_and_imported_in: Vec::new(),
3743                }),
3744            ],
3745            ..Default::default()
3746        };
3747        let baseline = BaselineData {
3748            unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
3749            ..BaselineData::legacy_from_results(&AnalysisResults::default(), root)
3750        };
3751
3752        let filtered = filter_new_issues(results, &baseline, root);
3753
3754        assert_eq!(filtered.unused_dependencies.len(), 1);
3755        assert_eq!(
3756            filtered.unused_dependencies[0].dep.path,
3757            PathBuf::from("/repo/packages/app-b/package.json")
3758        );
3759    }
3760
3761    #[test]
3762    fn dependency_baseline_filter_supports_legacy_package_only_keys() {
3763        let root = Path::new("/repo");
3764        let results = AnalysisResults {
3765            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
3766                package_name: "lodash-es".to_string(),
3767                location: DependencyLocation::Dependencies,
3768                path: PathBuf::from("/repo/packages/app/package.json"),
3769                line: 5,
3770                used_in_workspaces: Vec::new(),
3771                declared_and_imported_in: Vec::new(),
3772            })],
3773            ..Default::default()
3774        };
3775        let baseline = BaselineData {
3776            unused_dependencies: vec!["lodash-es".to_string()],
3777            ..BaselineData::legacy_from_results(&AnalysisResults::default(), root)
3778        };
3779
3780        let filtered = filter_new_issues(results, &baseline, root);
3781
3782        assert!(filtered.unused_dependencies.is_empty());
3783    }
3784
3785    #[test]
3786    fn baseline_serialization_roundtrip() {
3787        let results = make_results();
3788        let baseline = BaselineData::from_results(&results, Path::new(""));
3789        let json = serde_json::to_string(&baseline).unwrap();
3790        let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
3791        assert_eq!(deserialized.unused_files, baseline.unused_files);
3792        assert_eq!(deserialized.unused_exports, baseline.unused_exports);
3793        assert_eq!(deserialized.unused_types, baseline.unused_types);
3794        assert_eq!(
3795            deserialized.unused_dependencies,
3796            baseline.unused_dependencies
3797        );
3798        assert_eq!(
3799            deserialized.unused_dev_dependencies,
3800            baseline.unused_dev_dependencies
3801        );
3802    }
3803
3804    #[test]
3805    fn filter_removes_baseline_issues() {
3806        let results = make_results();
3807        let baseline = BaselineData::from_results(&results, Path::new(""));
3808        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3809        assert!(
3810            filtered.unused_files.is_empty(),
3811            "all files were in baseline"
3812        );
3813        assert!(
3814            filtered.unused_exports.is_empty(),
3815            "all exports were in baseline"
3816        );
3817        assert!(
3818            filtered.unused_types.is_empty(),
3819            "all types were in baseline"
3820        );
3821        assert!(
3822            filtered.unused_dependencies.is_empty(),
3823            "all deps were in baseline"
3824        );
3825        assert!(
3826            filtered.unused_dev_dependencies.is_empty(),
3827            "all dev deps were in baseline"
3828        );
3829    }
3830
3831    #[test]
3832    fn filter_keeps_new_issues_not_in_baseline() {
3833        let baseline = BaselineData {
3834            kind: None,
3835            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3836            scope_reasons: Vec::new(),
3837            identity: None,
3838            unused_files: vec!["src/old.ts".to_string()],
3839            unused_exports: vec![],
3840            unused_types: vec![],
3841            private_type_leaks: vec![],
3842            deprecated_exports_in_use: vec![],
3843            unused_dependencies: vec![],
3844            unused_dev_dependencies: vec![],
3845            circular_dependencies: vec![],
3846            re_export_cycles: vec![],
3847            package_cycles: vec![],
3848            unused_optional_dependencies: vec![],
3849            unused_enum_members: vec![],
3850            unused_class_members: vec![],
3851            unused_store_members: vec![],
3852            unprovided_injects: vec![],
3853            unrendered_components: vec![],
3854            unused_component_props: vec![],
3855            absent_component_props: vec![],
3856            unused_component_emits: vec![],
3857            unused_component_inputs: vec![],
3858            unused_component_outputs: vec![],
3859            unused_svelte_events: vec![],
3860            unused_effect_layers: vec![],
3861            unused_tagged_errors: vec![],
3862            unproduced_catch_tags: vec![],
3863            unused_server_actions: vec![],
3864            unused_load_data_keys: vec![],
3865            unresolved_imports: vec![],
3866            unlisted_dependencies: vec![],
3867            duplicate_exports: vec![],
3868            type_only_dependencies: vec![],
3869            test_only_dependencies: vec![],
3870            dev_dependencies_in_production: vec![],
3871            boundary_violations: vec![],
3872            boundary_coverage_violations: vec![],
3873            boundary_call_violations: vec![],
3874            policy_violations: vec![],
3875            stale_suppressions: vec![],
3876            unused_catalog_entries: vec![],
3877            empty_catalog_groups: vec![],
3878            unresolved_catalog_references: vec![],
3879            unused_dependency_overrides: vec![],
3880            misconfigured_dependency_overrides: vec![],
3881            invalid_client_exports: vec![],
3882            mixed_client_server_barrels: vec![],
3883            misplaced_directives: vec![],
3884            route_collisions: vec![],
3885            dynamic_segment_name_conflicts: vec![],
3886            prop_drilling_chains: vec![],
3887        };
3888        let results = AnalysisResults {
3889            unused_files: vec![
3890                UnusedFileFinding::with_actions(UnusedFile {
3891                    path: PathBuf::from("src/old.ts"),
3892                }),
3893                UnusedFileFinding::with_actions(UnusedFile {
3894                    path: PathBuf::from("src/new-dead.ts"),
3895                }),
3896            ],
3897            ..Default::default()
3898        };
3899        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3900        assert_eq!(filtered.unused_files.len(), 1);
3901        assert_eq!(
3902            filtered.unused_files[0].file.path,
3903            PathBuf::from("src/new-dead.ts")
3904        );
3905    }
3906
3907    #[test]
3908    fn filter_with_empty_baseline_keeps_all() {
3909        let baseline = BaselineData {
3910            kind: None,
3911            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3912            scope_reasons: Vec::new(),
3913            identity: None,
3914            unused_files: vec![],
3915            unused_exports: vec![],
3916            unused_types: vec![],
3917            private_type_leaks: vec![],
3918            deprecated_exports_in_use: vec![],
3919            unused_dependencies: vec![],
3920            unused_dev_dependencies: vec![],
3921            circular_dependencies: vec![],
3922            re_export_cycles: vec![],
3923            package_cycles: vec![],
3924            unused_optional_dependencies: vec![],
3925            unused_enum_members: vec![],
3926            unused_class_members: vec![],
3927            unused_store_members: vec![],
3928            unprovided_injects: vec![],
3929            unrendered_components: vec![],
3930            unused_component_props: vec![],
3931            absent_component_props: vec![],
3932            unused_component_emits: vec![],
3933            unused_component_inputs: vec![],
3934            unused_component_outputs: vec![],
3935            unused_svelte_events: vec![],
3936            unused_effect_layers: vec![],
3937            unused_tagged_errors: vec![],
3938            unproduced_catch_tags: vec![],
3939            unused_server_actions: vec![],
3940            unused_load_data_keys: vec![],
3941            unresolved_imports: vec![],
3942            unlisted_dependencies: vec![],
3943            duplicate_exports: vec![],
3944            type_only_dependencies: vec![],
3945            test_only_dependencies: vec![],
3946            dev_dependencies_in_production: vec![],
3947            boundary_violations: vec![],
3948            boundary_coverage_violations: vec![],
3949            boundary_call_violations: vec![],
3950            policy_violations: vec![],
3951            stale_suppressions: vec![],
3952            unused_catalog_entries: vec![],
3953            empty_catalog_groups: vec![],
3954            unresolved_catalog_references: vec![],
3955            unused_dependency_overrides: vec![],
3956            misconfigured_dependency_overrides: vec![],
3957            invalid_client_exports: vec![],
3958            mixed_client_server_barrels: vec![],
3959            misplaced_directives: vec![],
3960            route_collisions: vec![],
3961            dynamic_segment_name_conflicts: vec![],
3962            prop_drilling_chains: vec![],
3963        };
3964        let results = make_results();
3965        let filtered = filter_new_issues(results, &baseline, Path::new(""));
3966        assert_eq!(filtered.unused_files.len(), 2);
3967        assert_eq!(filtered.unused_exports.len(), 1);
3968    }
3969
3970    #[test]
3971    fn filter_new_exports_by_file_and_name() {
3972        let baseline = BaselineData {
3973            kind: None,
3974            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
3975            scope_reasons: Vec::new(),
3976            identity: None,
3977            unused_files: vec![],
3978            unused_exports: vec!["src/utils.ts:helperA".to_string()],
3979            unused_types: vec![],
3980            private_type_leaks: vec![],
3981            deprecated_exports_in_use: vec![],
3982            unused_dependencies: vec![],
3983            unused_dev_dependencies: vec![],
3984            circular_dependencies: vec![],
3985            re_export_cycles: vec![],
3986            package_cycles: vec![],
3987            unused_optional_dependencies: vec![],
3988            unused_enum_members: vec![],
3989            unused_class_members: vec![],
3990            unused_store_members: vec![],
3991            unprovided_injects: vec![],
3992            unrendered_components: vec![],
3993            unused_component_props: vec![],
3994            absent_component_props: vec![],
3995            unused_component_emits: vec![],
3996            unused_component_inputs: vec![],
3997            unused_component_outputs: vec![],
3998            unused_svelte_events: vec![],
3999            unused_effect_layers: vec![],
4000            unused_tagged_errors: vec![],
4001            unproduced_catch_tags: vec![],
4002            unused_server_actions: vec![],
4003            unused_load_data_keys: vec![],
4004            unresolved_imports: vec![],
4005            unlisted_dependencies: vec![],
4006            duplicate_exports: vec![],
4007            type_only_dependencies: vec![],
4008            test_only_dependencies: vec![],
4009            dev_dependencies_in_production: vec![],
4010            boundary_violations: vec![],
4011            boundary_coverage_violations: vec![],
4012            boundary_call_violations: vec![],
4013            policy_violations: vec![],
4014            stale_suppressions: vec![],
4015            unused_catalog_entries: vec![],
4016            empty_catalog_groups: vec![],
4017            unresolved_catalog_references: vec![],
4018            unused_dependency_overrides: vec![],
4019            misconfigured_dependency_overrides: vec![],
4020            invalid_client_exports: vec![],
4021            mixed_client_server_barrels: vec![],
4022            misplaced_directives: vec![],
4023            route_collisions: vec![],
4024            dynamic_segment_name_conflicts: vec![],
4025            prop_drilling_chains: vec![],
4026        };
4027        let results = AnalysisResults {
4028            unused_exports: vec![
4029                UnusedExportFinding::with_actions(UnusedExport {
4030                    path: PathBuf::from("src/utils.ts"),
4031                    export_name: "helperA".to_string(),
4032                    is_type_only: false,
4033                    line: 5,
4034                    col: 0,
4035                    span_start: 40,
4036                    is_re_export: false,
4037                    deprecated: false,
4038                    deprecated_reason: None,
4039                }),
4040                UnusedExportFinding::with_actions(UnusedExport {
4041                    path: PathBuf::from("src/utils.ts"),
4042                    export_name: "helperB".to_string(),
4043                    is_type_only: false,
4044                    line: 10,
4045                    col: 0,
4046                    span_start: 80,
4047                    is_re_export: false,
4048                    deprecated: false,
4049                    deprecated_reason: None,
4050                }),
4051            ],
4052            ..Default::default()
4053        };
4054        let filtered = filter_new_issues(results, &baseline, Path::new(""));
4055        assert_eq!(filtered.unused_exports.len(), 1);
4056        assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
4057    }
4058
4059    pub(super) fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
4060        let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
4061        files.sort_unstable();
4062        let fragment = format!("const source = '{}';", files.join(","));
4063        make_clone_group_with_fragment(&fragment, instances)
4064    }
4065
4066    fn make_clone_group_with_fragment(
4067        fragment: &str,
4068        instances: Vec<(&str, usize, usize)>,
4069    ) -> CloneGroup {
4070        CloneGroup {
4071            instances: instances
4072                .into_iter()
4073                .map(|(file, start, end)| CloneInstance {
4074                    is_symlink: false,
4075                    file: PathBuf::from(file),
4076                    start_line: start,
4077                    end_line: end,
4078                    start_col: 0,
4079                    end_col: 0,
4080                    fragment: fragment.to_string(),
4081                })
4082                .collect(),
4083            token_count: 50,
4084            line_count: 10,
4085            similarity: None,
4086        }
4087    }
4088
4089    pub(super) fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
4090        DuplicationReport {
4091            clone_groups: groups,
4092            clone_families: vec![],
4093            mirrored_directories: vec![],
4094            stats: DuplicationStats {
4095                total_files: 10,
4096                files_with_clones: 2,
4097                total_lines: 1000,
4098                duplicated_lines: 100,
4099                total_tokens: 5000,
4100                duplicated_tokens: 500,
4101                clone_groups: 1,
4102                clone_families: 0,
4103                clone_instances: 2,
4104                duplication_percentage: 10.0,
4105                clone_groups_below_min_occurrences: 0,
4106                clone_groups_ignored: 0,
4107                near_candidates_skipped: 0,
4108            },
4109        }
4110    }
4111
4112    fn normalized_clone_group_key(group: &CloneGroup) -> String {
4113        let fingerprints =
4114            crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
4115        clone_group_fingerprint_key(group, &fingerprints)
4116    }
4117
4118    #[test]
4119    fn clone_group_key_is_deterministic() {
4120        let root = Path::new("/project");
4121        let group = make_clone_group(vec![
4122            ("/project/src/a.ts", 1, 10),
4123            ("/project/src/b.ts", 5, 15),
4124        ]);
4125        let key1 = clone_group_key(&group, root);
4126        let key2 = clone_group_key(&group, root);
4127        assert_eq!(key1, key2);
4128    }
4129
4130    #[test]
4131    fn clone_group_key_is_sorted() {
4132        let root = Path::new("/project");
4133        let group_ab = make_clone_group(vec![
4134            ("/project/src/a.ts", 1, 10),
4135            ("/project/src/b.ts", 5, 15),
4136        ]);
4137        let group_ba = make_clone_group(vec![
4138            ("/project/src/b.ts", 5, 15),
4139            ("/project/src/a.ts", 1, 10),
4140        ]);
4141        assert_eq!(
4142            clone_group_key(&group_ab, root),
4143            clone_group_key(&group_ba, root),
4144            "key should be stable regardless of instance order"
4145        );
4146    }
4147
4148    #[test]
4149    fn duplication_baseline_roundtrip() {
4150        let root = Path::new("/project");
4151        let group = make_clone_group(vec![
4152            ("/project/src/a.ts", 1, 10),
4153            ("/project/src/b.ts", 5, 15),
4154        ]);
4155        let report = make_duplication_report(vec![group]);
4156        let baseline = DuplicationBaselineData::from_report(&report, root);
4157        let json = serde_json::to_string(&baseline).unwrap();
4158        let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
4159        assert_eq!(deserialized.clone_groups, baseline.clone_groups);
4160        assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
4161        assert_eq!(
4162            deserialized.normalized_clone_fingerprints,
4163            baseline.normalized_clone_fingerprints
4164        );
4165        assert_eq!(
4166            baseline.normalized_clone_fingerprints.len(),
4167            1,
4168            "a saved baseline carries a normalized key per clone group"
4169        );
4170    }
4171
4172    /// The key list is what separates a foreign file from a legitimately empty
4173    /// baseline, so a field added to the format without a key here would make
4174    /// its own saved baseline read as foreign.
4175    ///
4176    /// `kind` is compared out rather than added: it is the one key every format
4177    /// writes, so listing it would make each format declare all three and turn
4178    /// the key fallback into a coin flip for files that carry no `kind`.
4179    #[test]
4180    fn the_declared_keys_are_every_key_each_format_writes() {
4181        let duplication = DuplicationBaselineData {
4182            kind: Some(BaselineKind::Dupes),
4183            clone_groups: vec!["src/a.ts:1-10".to_owned()],
4184            clone_fingerprints: vec!["abc".to_owned()],
4185            normalized_clone_fingerprints: vec!["def".to_owned()],
4186        };
4187        assert_eq!(
4188            serialized_keys_without_kind(&duplication),
4189            DuplicationBaselineData::DECLARED_KEYS
4190        );
4191
4192        let counts: HealthFindingCountMap = std::iter::once((
4193            "src/a.ts".to_owned(),
4194            std::iter::once(("complexity".to_owned(), HealthBaselineCount { count: 1 })).collect(),
4195        ))
4196        .collect();
4197        let health = HealthBaselineData {
4198            kind: Some(BaselineKind::Health),
4199            findings: vec!["src/a.ts:run:1".to_owned()],
4200            finding_counts: counts.clone(),
4201            identity_finding_counts: counts,
4202            runtime_coverage_findings: vec!["src/a.ts:run".to_owned()],
4203            runtime_coverage_source_hashes: vec!["src/a.ts\0run\0hash".to_owned()],
4204            target_keys: vec!["src/a.ts:complexity".to_owned()],
4205        };
4206        assert_eq!(
4207            serialized_keys_without_kind(&health),
4208            HealthBaselineData::DECLARED_KEYS
4209        );
4210
4211        for list in [
4212            DuplicationBaselineData::DECLARED_KEYS,
4213            HealthBaselineData::DECLARED_KEYS,
4214            BaselineData::REQUIRED_KEYS,
4215        ] {
4216            assert!(
4217                !list.contains(&"kind"),
4218                "no format may declare the key every format writes: {list:?}"
4219            );
4220        }
4221    }
4222
4223    /// The five keys the dead-code format cannot load without, which is what
4224    /// makes "carries none of them" a safe reading of "not a dead-code
4225    /// baseline". Derived from the format rather than restated, so a field that
4226    /// loses its serde default has to be listed.
4227    #[test]
4228    fn the_required_dead_code_keys_are_the_ones_without_a_serde_default() {
4229        let json = serde_json::to_string(&BaselineData::from_results(
4230            &crate::results::AnalysisResults::default(),
4231            Path::new("/project"),
4232        ))
4233        .expect("baseline serializes");
4234        let serde_json::Value::Object(object) =
4235            serde_json::from_str::<serde_json::Value>(&json).expect("object")
4236        else {
4237            panic!("a baseline serializes as an object");
4238        };
4239
4240        for key in object.keys() {
4241            let mut without = object.clone();
4242            without.remove(key);
4243            let loads =
4244                serde_json::from_value::<BaselineData>(serde_json::Value::Object(without)).is_ok();
4245            assert_eq!(
4246                !loads,
4247                BaselineData::REQUIRED_KEYS.contains(&key.as_str()),
4248                "REQUIRED_KEYS must list exactly the keys a load cannot do without, and {key} \
4249                 disagrees"
4250            );
4251        }
4252    }
4253
4254    #[test]
4255    fn every_saved_baseline_names_the_command_that_wrote_it() {
4256        let dead_code = serde_json::to_string(&BaselineData::from_results(
4257            &crate::results::AnalysisResults::default(),
4258            Path::new("/project"),
4259        ))
4260        .expect("baseline serializes");
4261        let dupes = serde_json::to_string(&DuplicationBaselineData::from_report(
4262            &make_duplication_report(Vec::new()),
4263            Path::new("/project"),
4264        ))
4265        .expect("baseline serializes");
4266        let health = serde_json::to_string(&HealthBaselineData::from_findings(
4267            &[],
4268            &[],
4269            &[],
4270            Path::new("/project"),
4271        ))
4272        .expect("baseline serializes");
4273
4274        for (json, kind) in [
4275            (&dead_code, BaselineKind::DeadCode),
4276            (&dupes, BaselineKind::Dupes),
4277            (&health, BaselineKind::Health),
4278        ] {
4279            assert_eq!(
4280                serde_json::from_str::<serde_json::Value>(json).expect("object")["kind"],
4281                serde_json::json!(kind.as_str()),
4282                "a saved baseline states which command wrote it: {json}"
4283            );
4284            assert_eq!(classify_baseline_file(json, kind), BaselineFileKind::Own);
4285            for other in [
4286                BaselineKind::DeadCode,
4287                BaselineKind::Dupes,
4288                BaselineKind::Health,
4289            ] {
4290                if other == kind {
4291                    continue;
4292                }
4293                assert_eq!(
4294                    classify_baseline_file(json, other),
4295                    BaselineFileKind::Foreign(kind.as_str().to_owned()),
4296                    "and every other command reads it as that command's: {json}"
4297                );
4298            }
4299        }
4300    }
4301
4302    /// A baseline saved by the previous release carries no `kind`, so the keys
4303    /// decide, which is exactly today's behaviour and must stay it.
4304    #[test]
4305    fn a_baseline_without_a_kind_is_classified_by_its_keys() {
4306        let dupes =
4307            r#"{"clone_groups":[],"clone_fingerprints":[],"normalized_clone_fingerprints":[]}"#;
4308        let health = r#"{"runtime_coverage_findings":[],"target_keys":[]}"#;
4309        let dead_code = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],"unused_dependencies":[],"unused_dev_dependencies":[]}"#;
4310
4311        for (json, own) in [
4312            (dupes, BaselineKind::Dupes),
4313            (health, BaselineKind::Health),
4314            (dead_code, BaselineKind::DeadCode),
4315        ] {
4316            assert_eq!(classify_baseline_file(json, own), BaselineFileKind::Own);
4317            for other in [
4318                BaselineKind::DeadCode,
4319                BaselineKind::Dupes,
4320                BaselineKind::Health,
4321            ] {
4322                if other == own {
4323                    continue;
4324                }
4325                assert_eq!(
4326                    classify_baseline_file(json, other),
4327                    BaselineFileKind::Unrecognised,
4328                    "a file with no kind and none of this format's keys is unrecognised, not \
4329                     attributed to a command it never named: {json}"
4330                );
4331            }
4332        }
4333    }
4334
4335    /// Only the file's own statement is trusted. A `kind` a newer fallow writes
4336    /// must read as another command's file rather than as a parse error, and a
4337    /// `kind` that is not a string is no statement at all.
4338    #[test]
4339    fn an_unreadable_kind_never_becomes_a_parse_error() {
4340        assert_eq!(
4341            classify_baseline_file(r#"{"kind":"security"}"#, BaselineKind::Dupes),
4342            BaselineFileKind::Foreign("security".to_owned())
4343        );
4344        assert_eq!(
4345            classify_baseline_file(r#"{"kind":7,"clone_groups":[]}"#, BaselineKind::Dupes),
4346            BaselineFileKind::Own
4347        );
4348        assert_eq!(
4349            classify_baseline_file("[]", BaselineKind::Dupes),
4350            BaselineFileKind::NotAnObject
4351        );
4352        assert_eq!(
4353            classify_baseline_file("not json", BaselineKind::DeadCode),
4354            BaselineFileKind::NotAnObject
4355        );
4356        assert!(
4357            serde_json::from_str::<DuplicationBaselineData>(r#"{"kind":"security"}"#).is_ok(),
4358            "a kind only a newer fallow writes must not break a load"
4359        );
4360    }
4361
4362    #[test]
4363    fn a_save_is_refused_only_over_another_commands_baseline() {
4364        let dir = tempfile::tempdir().expect("tempdir");
4365        let path = dir.path().join("baseline.json");
4366
4367        assert!(
4368            refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
4369            "there is nothing to destroy yet"
4370        );
4371
4372        std::fs::write(
4373            &path,
4374            serde_json::to_string(&DuplicationBaselineData::from_report(
4375                &make_duplication_report(Vec::new()),
4376                Path::new("/project"),
4377            ))
4378            .expect("baseline serializes"),
4379        )
4380        .expect("write");
4381        assert!(
4382            refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
4383            "re-saving over its own file is the documented workflow"
4384        );
4385        let message = refuse_baseline_kind_overwrite(&path, BaselineKind::Health)
4386            .expect("a health save over a duplication baseline is refused");
4387        assert!(message.contains("`fallow dupes`"), "{message}");
4388        assert!(message.contains("`fallow health`"), "{message}");
4389        assert!(message.contains(&path.display().to_string()), "{message}");
4390
4391        std::fs::write(&path, "{}").expect("write");
4392        assert!(
4393            refuse_baseline_kind_overwrite(&path, BaselineKind::Health).is_none(),
4394            "a file with nothing to identify it carries no claim to protect"
4395        );
4396    }
4397
4398    fn serialized_keys_without_kind<T: serde::Serialize>(value: &T) -> Vec<String> {
4399        let serde_json::Value::Object(object) =
4400            serde_json::to_value(value).expect("baseline serializes")
4401        else {
4402            panic!("a baseline serializes as an object");
4403        };
4404        object
4405            .keys()
4406            .filter(|key| key.as_str() != "kind")
4407            .cloned()
4408            .collect()
4409    }
4410
4411    #[test]
4412    fn a_baseline_saved_from_a_clean_project_still_declares_its_format() {
4413        let empty = serde_json::to_string(&DuplicationBaselineData::default())
4414            .expect("baseline serializes");
4415
4416        assert!(
4417            declares_baseline_format(&empty, DuplicationBaselineData::DECLARED_KEYS),
4418            "an empty duplication baseline is still a duplication baseline: {empty}"
4419        );
4420        assert!(
4421            !declares_baseline_format(&empty, HealthBaselineData::DECLARED_KEYS),
4422            "and it is not a health one: {empty}"
4423        );
4424        assert!(!declares_baseline_format(
4425            "{}",
4426            HealthBaselineData::DECLARED_KEYS
4427        ));
4428        assert!(!declares_baseline_format(
4429            "[]",
4430            HealthBaselineData::DECLARED_KEYS
4431        ));
4432        assert!(!declares_baseline_format(
4433            "not json",
4434            HealthBaselineData::DECLARED_KEYS
4435        ));
4436    }
4437
4438    #[test]
4439    fn filter_new_clone_groups_matches_shifted_clone() {
4440        let root = Path::new("/project");
4441        let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4442            "const total = a + b;",
4443            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4444        )]);
4445        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4446
4447        let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
4448            "const total = a + b;",
4449            vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
4450        )]);
4451        let filtered = filter_new_clone_groups(shifted, &baseline, root);
4452        assert!(
4453            filtered.clone_groups.is_empty(),
4454            "an unrelated line shift must not resurface a baselined clone"
4455        );
4456    }
4457
4458    /// A baseline filter keeps the family order of every other path: the file
4459    /// set. The larger family has the later file set, so an order by
4460    /// duplicated lines puts it first.
4461    #[test]
4462    fn filter_new_clone_groups_orders_families_by_file_set() {
4463        let root = Path::new("/project");
4464        let baseline = DuplicationBaselineData::from_report(&make_duplication_report(vec![]), root);
4465        let mut large = make_clone_group_with_fragment(
4466            "const large = 1;",
4467            vec![("/project/src/y.ts", 1, 46), ("/project/src/z.ts", 1, 46)],
4468        );
4469        large.line_count = 46;
4470        let small = make_clone_group_with_fragment(
4471            "const small = 1;",
4472            vec![("/project/src/a.ts", 1, 7), ("/project/src/b.ts", 1, 7)],
4473        );
4474
4475        let filtered =
4476            filter_new_clone_groups(make_duplication_report(vec![large, small]), &baseline, root);
4477
4478        let order: Vec<&Path> = filtered
4479            .clone_families
4480            .iter()
4481            .map(|family| family.files[0].as_path())
4482            .collect();
4483        assert_eq!(
4484            order,
4485            [
4486                Path::new("/project/src/a.ts"),
4487                Path::new("/project/src/y.ts")
4488            ]
4489        );
4490    }
4491
4492    #[test]
4493    fn filter_new_clone_groups_reports_extra_copy() {
4494        let root = Path::new("/project");
4495        let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4496            "const total = a + b;",
4497            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4498        )]);
4499        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4500
4501        let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
4502            "const total = a + b;",
4503            vec![
4504                ("/project/src/a.ts", 10, 20),
4505                ("/project/src/b.ts", 30, 40),
4506                ("/project/src/c.ts", 5, 15),
4507            ],
4508        )]);
4509        let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
4510        assert_eq!(
4511            filtered.clone_groups.len(),
4512            1,
4513            "a fresh copy in a third file is a new finding"
4514        );
4515    }
4516
4517    #[test]
4518    fn filter_new_clone_groups_reads_legacy_baseline() {
4519        let root = Path::new("/project");
4520        let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
4521        let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
4522        assert_eq!(baseline.entry_count(), 1);
4523
4524        let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
4525            "const total = a + b;",
4526            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4527        )]);
4528        assert!(
4529            filter_new_clone_groups(unchanged, &baseline, root)
4530                .clone_groups
4531                .is_empty(),
4532            "a legacy baseline still matches on locations"
4533        );
4534
4535        let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
4536            "const total = a + b;",
4537            vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
4538        )]);
4539        assert_eq!(
4540            filter_new_clone_groups(shifted, &baseline, root)
4541                .clone_groups
4542                .len(),
4543            1,
4544            "legacy behavior is unchanged: a shift stops matching"
4545        );
4546    }
4547
4548    #[test]
4549    fn clone_group_fingerprint_key_survives_file_rename() {
4550        let before = make_clone_group_with_fragment(
4551            "const total = a + b;",
4552            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4553        );
4554        let after = make_clone_group_with_fragment(
4555            "const total = a + b;",
4556            vec![
4557                ("/project/src/renamed.ts", 10, 20),
4558                ("/project/src/b.ts", 30, 40),
4559            ],
4560        );
4561        assert_eq!(
4562            normalized_clone_group_key(&before),
4563            normalized_clone_group_key(&after),
4564            "renaming a file must not resurface a baselined clone"
4565        );
4566    }
4567
4568    #[test]
4569    fn clone_group_fingerprint_key_does_not_follow_representative_order() {
4570        let mut before = make_clone_group_with_fragment(
4571            "const total = a + b;",
4572            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4573        );
4574        before.instances[1].fragment = "const total = a  +  b;".to_string();
4575
4576        let mut after = before.clone();
4577        after.instances[0].file = PathBuf::from("/project/src/z.ts");
4578
4579        assert_eq!(
4580            normalized_clone_group_key(&before),
4581            normalized_clone_group_key(&after),
4582            "normalized group identity is independent of representative order"
4583        );
4584    }
4585
4586    #[test]
4587    fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
4588        let root = Path::new("/project");
4589        let group = make_clone_group_with_fragment(
4590            "const total = a + b;",
4591            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4592        );
4593        let legacy_key = legacy_clone_group_fingerprint_key(&group);
4594        let baseline = DuplicationBaselineData {
4595            kind: None,
4596            clone_groups: Vec::new(),
4597            clone_fingerprints: vec![legacy_key.clone()],
4598            normalized_clone_fingerprints: Vec::new(),
4599        };
4600
4601        let filtered = filter_new_clone_groups(
4602            make_duplication_report(vec![group.clone()]),
4603            &baseline,
4604            root,
4605        );
4606        assert!(filtered.clone_groups.is_empty());
4607
4608        let current =
4609            DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
4610        assert_eq!(current.clone_fingerprints, vec![legacy_key]);
4611        assert_ne!(
4612            current.normalized_clone_fingerprints,
4613            current.clone_fingerprints
4614        );
4615    }
4616
4617    #[test]
4618    fn normalized_baseline_survives_formatting_only_edits() {
4619        let root = Path::new("/project");
4620        let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
4621            "const total = left + right;",
4622            vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
4623        )]);
4624        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4625
4626        let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
4627            "/* reviewed */\r\nconst  total=left + right;",
4628            vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
4629        )]);
4630        assert!(
4631            filter_new_clone_groups(formatted, &baseline, root)
4632                .clone_groups
4633                .is_empty()
4634        );
4635    }
4636
4637    #[test]
4638    fn filter_new_clone_groups_removes_baseline() {
4639        let root = Path::new("/project");
4640        let group = make_clone_group(vec![
4641            ("/project/src/a.ts", 1, 10),
4642            ("/project/src/b.ts", 5, 15),
4643        ]);
4644        let report = make_duplication_report(vec![group]);
4645        let baseline = DuplicationBaselineData::from_report(&report, root);
4646        let filtered = filter_new_clone_groups(report, &baseline, root);
4647        assert!(
4648            filtered.clone_groups.is_empty(),
4649            "baseline group should be filtered out"
4650        );
4651    }
4652
4653    #[test]
4654    fn filter_new_clone_groups_keeps_new_groups() {
4655        let root = Path::new("/project");
4656        let baseline_group = make_clone_group(vec![
4657            ("/project/src/a.ts", 1, 10),
4658            ("/project/src/b.ts", 5, 15),
4659        ]);
4660        let new_group = make_clone_group(vec![
4661            ("/project/src/c.ts", 20, 30),
4662            ("/project/src/d.ts", 25, 35),
4663        ]);
4664        let baseline_report = make_duplication_report(vec![baseline_group]);
4665        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
4666
4667        let report = make_duplication_report(vec![
4668            make_clone_group(vec![
4669                ("/project/src/a.ts", 1, 10),
4670                ("/project/src/b.ts", 5, 15),
4671            ]),
4672            new_group,
4673        ]);
4674        let filtered = filter_new_clone_groups(report, &baseline, root);
4675        assert_eq!(
4676            filtered.clone_groups.len(),
4677            1,
4678            "only the new group should remain"
4679        );
4680    }
4681
4682    #[test]
4683    fn recompute_stats_after_filtering() {
4684        let root = Path::new("/project");
4685        let group = make_clone_group(vec![
4686            ("/project/src/a.ts", 1, 10),
4687            ("/project/src/b.ts", 5, 15),
4688        ]);
4689        let report = make_duplication_report(vec![group]);
4690        let baseline = DuplicationBaselineData::from_report(&report, root);
4691        let filtered = filter_new_clone_groups(report, &baseline, root);
4692        assert_eq!(filtered.stats.clone_groups, 0);
4693        assert_eq!(filtered.stats.clone_instances, 0);
4694        assert_eq!(filtered.stats.duplicated_lines, 0);
4695    }
4696
4697    #[test]
4698    fn recompute_stats_zero_total_lines() {
4699        let report = DuplicationReport {
4700            clone_groups: vec![],
4701            clone_families: vec![],
4702            mirrored_directories: vec![],
4703            stats: DuplicationStats {
4704                total_files: 0,
4705                files_with_clones: 0,
4706                total_lines: 0,
4707                duplicated_lines: 0,
4708                total_tokens: 0,
4709                duplicated_tokens: 0,
4710                clone_groups: 0,
4711                clone_families: 0,
4712                clone_instances: 0,
4713                duplication_percentage: 0.0,
4714                clone_groups_below_min_occurrences: 0,
4715                clone_groups_ignored: 0,
4716                near_candidates_skipped: 0,
4717            },
4718        };
4719        let stats = super::recompute_stats(&report);
4720        assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
4721    }
4722
4723    /// Count-mode wrapper shadowing the mode-aware function, so the existing
4724    /// count-mode expectations stay readable. Identity-mode tests call
4725    /// `super::filter_new_health_findings` directly.
4726    fn filter_new_health_findings(
4727        findings: Vec<fallow_output::ComplexityViolation>,
4728        baseline: &HealthBaselineData,
4729        root: &Path,
4730    ) -> Vec<fallow_output::ComplexityViolation> {
4731        super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
4732    }
4733
4734    fn make_health_finding(
4735        root: &Path,
4736        name: &str,
4737        line: u32,
4738    ) -> fallow_output::ComplexityViolation {
4739        make_health_finding_with(
4740            root,
4741            name,
4742            line,
4743            fallow_output::ExceededThreshold::Both,
4744            fallow_output::FindingSeverity::High,
4745        )
4746    }
4747
4748    pub(super) fn make_health_finding_with(
4749        root: &Path,
4750        name: &str,
4751        line: u32,
4752        exceeded: fallow_output::ExceededThreshold,
4753        severity: fallow_output::FindingSeverity,
4754    ) -> fallow_output::ComplexityViolation {
4755        fallow_output::ComplexityViolation {
4756            path: root.join("src/utils.ts"),
4757            name: name.to_string(),
4758            line,
4759            col: 0,
4760            cyclomatic: 25,
4761            cognitive: 30,
4762            line_count: 80,
4763            param_count: 0,
4764            react_hook_count: 0,
4765            react_jsx_max_depth: 0,
4766            react_prop_count: 0,
4767            react_hook_profile: None,
4768            exceeded,
4769            severity,
4770            effective_severity: None,
4771            crap: None,
4772            mutation: None,
4773            coverage_pct: None,
4774            coverage_tier: None,
4775            coverage_source: None,
4776            inherited_from: None,
4777            component_rollup: None,
4778            contributions: Vec::new(),
4779            effective_thresholds: None,
4780            threshold_source: None,
4781        }
4782    }
4783
4784    #[test]
4785    fn health_baseline_roundtrip() {
4786        let root = PathBuf::from("/project");
4787        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
4788        let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
4789        let json = serde_json::to_string(&baseline).unwrap();
4790        let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
4791        assert_eq!(deserialized.findings, baseline.findings);
4792        assert_eq!(baseline.findings, Vec::<String>::new());
4793        assert_eq!(
4794            deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
4795            1
4796        );
4797        assert!(!json.contains("parseExpression"));
4798    }
4799
4800    #[test]
4801    fn health_baseline_filters_known_findings() {
4802        let root = PathBuf::from("/project");
4803        let mut findings = vec![
4804            make_health_finding(&root, "parseExpression", 42),
4805            make_health_finding(&root, "newFunction", 100),
4806        ];
4807        findings[1].path = root.join("src/other.ts");
4808        let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
4809        let filtered = filter_new_health_findings(findings, &baseline, &root);
4810        assert_eq!(filtered.len(), 1);
4811        assert_eq!(filtered[0].name, "newFunction");
4812    }
4813
4814    #[test]
4815    fn health_baseline_filters_shifted_lines_with_same_category_count() {
4816        let root = PathBuf::from("/project");
4817        let baseline = HealthBaselineData::from_findings(
4818            &[make_health_finding(&root, "parseExpression", 42)],
4819            &[],
4820            &[],
4821            &root,
4822        );
4823        let filtered = filter_new_health_findings(
4824            vec![make_health_finding(&root, "parseExpression", 43)],
4825            &baseline,
4826            &root,
4827        );
4828        assert!(filtered.is_empty());
4829    }
4830
4831    #[test]
4832    fn health_baseline_reports_full_category_when_count_increases() {
4833        let root = PathBuf::from("/project");
4834        let baseline = HealthBaselineData::from_findings(
4835            &[make_health_finding(&root, "parseExpression", 42)],
4836            &[],
4837            &[],
4838            &root,
4839        );
4840        let filtered = filter_new_health_findings(
4841            vec![
4842                make_health_finding(&root, "parseExpression", 43),
4843                make_health_finding(&root, "newFunction", 100),
4844            ],
4845            &baseline,
4846            &root,
4847        );
4848        assert_eq!(filtered.len(), 2);
4849    }
4850
4851    #[test]
4852    fn health_baseline_legacy_findings_still_load() {
4853        let root = PathBuf::from("/project");
4854        let baseline = HealthBaselineData {
4855            kind: None,
4856            findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
4857            finding_counts: BTreeMap::new(),
4858            identity_finding_counts: BTreeMap::new(),
4859            target_keys: vec![],
4860            runtime_coverage_findings: vec![],
4861            runtime_coverage_source_hashes: vec![],
4862        };
4863        let filtered = filter_new_health_findings(
4864            vec![make_health_finding(&root, "parseExpression", 42)],
4865            &baseline,
4866            &root,
4867        );
4868        assert!(filtered.is_empty());
4869    }
4870
4871    #[test]
4872    fn health_baseline_keeps_crap_categories_separate_from_complexity() {
4873        let root = PathBuf::from("/project");
4874        let baseline = HealthBaselineData::from_findings(
4875            &[make_health_finding_with(
4876                &root,
4877                "parseExpression",
4878                42,
4879                fallow_output::ExceededThreshold::Crap,
4880                fallow_output::FindingSeverity::High,
4881            )],
4882            &[],
4883            &[],
4884            &root,
4885        );
4886        let filtered = filter_new_health_findings(
4887            vec![
4888                make_health_finding_with(
4889                    &root,
4890                    "parseExpression",
4891                    43,
4892                    fallow_output::ExceededThreshold::Crap,
4893                    fallow_output::FindingSeverity::High,
4894                ),
4895                make_health_finding(&root, "newComplexityOnlyFunction", 100),
4896            ],
4897            &baseline,
4898            &root,
4899        );
4900        assert_eq!(filtered.len(), 1);
4901        assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
4902    }
4903
4904    #[test]
4905    fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
4906        let root = PathBuf::from("/project");
4907        let baseline = HealthBaselineData::from_findings(
4908            &[make_health_finding_with(
4909                &root,
4910                "parseExpression",
4911                42,
4912                fallow_output::ExceededThreshold::Both,
4913                fallow_output::FindingSeverity::Critical,
4914            )],
4915            &[],
4916            &[],
4917            &root,
4918        );
4919        let filtered = filter_new_health_findings(
4920            vec![make_health_finding_with(
4921                &root,
4922                "parseExpression",
4923                42,
4924                fallow_output::ExceededThreshold::Both,
4925                fallow_output::FindingSeverity::High,
4926            )],
4927            &baseline,
4928            &root,
4929        );
4930        assert!(filtered.is_empty());
4931    }
4932
4933    #[test]
4934    fn health_baseline_still_reports_worse_current_severity_as_new() {
4935        let root = PathBuf::from("/project");
4936        let baseline = HealthBaselineData::from_findings(
4937            &[make_health_finding_with(
4938                &root,
4939                "parseExpression",
4940                42,
4941                fallow_output::ExceededThreshold::Both,
4942                fallow_output::FindingSeverity::High,
4943            )],
4944            &[],
4945            &[],
4946            &root,
4947        );
4948        let filtered = filter_new_health_findings(
4949            vec![make_health_finding_with(
4950                &root,
4951                "parseExpression",
4952                42,
4953                fallow_output::ExceededThreshold::Both,
4954                fallow_output::FindingSeverity::Critical,
4955            )],
4956            &baseline,
4957            &root,
4958        );
4959        assert_eq!(filtered.len(), 1);
4960        assert_eq!(filtered[0].name, "parseExpression");
4961        assert!(matches!(
4962            filtered[0].severity,
4963            fallow_output::FindingSeverity::Critical
4964        ));
4965    }
4966
4967    #[test]
4968    fn health_baseline_overlap_counts_partial_category_overflow() {
4969        let root = PathBuf::from("/project");
4970        let baseline = HealthBaselineData::from_findings(
4971            &[make_health_finding(&root, "parseExpression", 42)],
4972            &[],
4973            &[],
4974            &root,
4975        );
4976        let overlap = baseline.overlap_entries(
4977            &[
4978                make_health_finding(&root, "parseExpression", 42),
4979                make_health_finding(&root, "newFunction", 100),
4980            ],
4981            &root,
4982            HealthBaselineMode::Count,
4983        );
4984        assert_eq!(overlap.matched_entries, 1);
4985        assert_eq!(overlap.moved_entries, 0);
4986    }
4987
4988    /// Baseline saved the way `--baseline-mode identity` saves it.
4989    fn identity_baseline(
4990        findings: &[fallow_output::ComplexityViolation],
4991        root: &Path,
4992    ) -> HealthBaselineData {
4993        HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
4994    }
4995
4996    #[test]
4997    fn health_identity_baseline_reports_replacement_hotspot() {
4998        let root = PathBuf::from("/project");
4999        let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
5000        let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
5001
5002        assert!(
5003            filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
5004            "count mode keeps the per-file allowance and suppresses the replacement"
5005        );
5006
5007        let filtered = super::filter_new_health_findings(
5008            replacement,
5009            &baseline,
5010            &root,
5011            HealthBaselineMode::Identity,
5012        );
5013        assert_eq!(filtered.len(), 1);
5014        assert_eq!(filtered[0].name, "replacementHotspot");
5015    }
5016
5017    #[test]
5018    fn health_identity_baseline_survives_line_moves() {
5019        let root = PathBuf::from("/project");
5020        let baseline =
5021            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
5022        let filtered = super::filter_new_health_findings(
5023            vec![make_health_finding(&root, "parseExpression", 512)],
5024            &baseline,
5025            &root,
5026            HealthBaselineMode::Identity,
5027        );
5028        assert!(filtered.is_empty());
5029    }
5030
5031    #[test]
5032    fn health_identity_baseline_suppresses_severity_improvement() {
5033        let root = PathBuf::from("/project");
5034        let baseline = identity_baseline(
5035            &[make_health_finding_with(
5036                &root,
5037                "parseExpression",
5038                42,
5039                fallow_output::ExceededThreshold::Both,
5040                fallow_output::FindingSeverity::Critical,
5041            )],
5042            &root,
5043        );
5044        let filtered = super::filter_new_health_findings(
5045            vec![make_health_finding_with(
5046                &root,
5047                "parseExpression",
5048                42,
5049                fallow_output::ExceededThreshold::Both,
5050                fallow_output::FindingSeverity::Moderate,
5051            )],
5052            &baseline,
5053            &root,
5054            HealthBaselineMode::Identity,
5055        );
5056        assert!(filtered.is_empty());
5057    }
5058
5059    #[test]
5060    fn health_identity_baseline_reports_added_finding_for_known_function() {
5061        let root = PathBuf::from("/project");
5062        let baseline =
5063            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
5064        let filtered = super::filter_new_health_findings(
5065            vec![
5066                make_health_finding(&root, "parseExpression", 42),
5067                make_health_finding(&root, "parseStatement", 90),
5068            ],
5069            &baseline,
5070            &root,
5071            HealthBaselineMode::Identity,
5072        );
5073        assert_eq!(filtered.len(), 1);
5074        assert_eq!(filtered[0].name, "parseStatement");
5075    }
5076
5077    #[test]
5078    fn health_identity_buckets_are_written_only_in_identity_mode() {
5079        let root = PathBuf::from("/project");
5080        let findings = [make_health_finding(&root, "parseExpression", 42)];
5081        let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
5082        let json = serde_json::to_string(&count_only).unwrap();
5083        assert!(!json.contains("identity_finding_counts"));
5084        assert!(count_only.lacks_identity_data());
5085
5086        let identity = identity_baseline(&findings, &root);
5087        assert!(!identity.lacks_identity_data());
5088        assert_eq!(
5089            identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
5090                .count,
5091            1
5092        );
5093        assert!(
5094            !identity.finding_counts.is_empty(),
5095            "an identity baseline stays readable in count mode"
5096        );
5097    }
5098
5099    fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
5100        let mut finding = make_health_finding(root, name, 42);
5101        finding.path = root.join(path);
5102        finding
5103    }
5104
5105    #[test]
5106    fn health_identity_baseline_follows_file_move() {
5107        let root = PathBuf::from("/project");
5108        let baseline =
5109            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
5110        let moved = vec![moved_finding(
5111            &root,
5112            "src/parser/utils.ts",
5113            "parseExpression",
5114        )];
5115
5116        let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
5117        assert_eq!(
5118            overlap.matched_entries, 1,
5119            "a followed move counts as matched, not stale"
5120        );
5121        assert_eq!(
5122            overlap.moved_entries, 1,
5123            "the followed move stays observable as a moved entry"
5124        );
5125        let filtered = super::filter_new_health_findings(
5126            moved,
5127            &baseline,
5128            &root,
5129            HealthBaselineMode::Identity,
5130        );
5131        assert!(filtered.is_empty());
5132    }
5133
5134    #[test]
5135    fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
5136        let root = PathBuf::from("/project");
5137        let baseline =
5138            identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
5139        let filtered = super::filter_new_health_findings(
5140            vec![
5141                moved_finding(&root, "src/a.ts", "parseExpression"),
5142                moved_finding(&root, "src/b.ts", "parseExpression"),
5143            ],
5144            &baseline,
5145            &root,
5146            HealthBaselineMode::Identity,
5147        );
5148        assert_eq!(filtered.len(), 2);
5149    }
5150
5151    #[test]
5152    fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
5153        let root = PathBuf::from("/project");
5154        let baseline = identity_baseline(
5155            &[
5156                moved_finding(&root, "src/a.ts", "parseExpression"),
5157                moved_finding(&root, "src/b.ts", "parseExpression"),
5158            ],
5159            &root,
5160        );
5161        let filtered = super::filter_new_health_findings(
5162            vec![moved_finding(&root, "src/c.ts", "parseExpression")],
5163            &baseline,
5164            &root,
5165            HealthBaselineMode::Identity,
5166        );
5167        assert_eq!(filtered.len(), 1);
5168    }
5169
5170    #[test]
5171    fn health_identity_move_is_not_followed_when_old_path_still_exists() {
5172        // The manifest dir makes the saved path a file that really exists, so
5173        // the function was fixed or deleted in place rather than moved.
5174        let root = std::path::PathBuf::from(
5175            std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
5176        );
5177        let baseline = identity_baseline(
5178            &[moved_finding(
5179                &root,
5180                "src/baseline/mod.rs",
5181                "parseExpression",
5182            )],
5183            &root,
5184        );
5185        let filtered = super::filter_new_health_findings(
5186            vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
5187            &baseline,
5188            &root,
5189            HealthBaselineMode::Identity,
5190        );
5191        assert_eq!(filtered.len(), 1);
5192    }
5193
5194    #[test]
5195    fn health_identity_move_is_not_followed_for_anonymous_functions() {
5196        let root = PathBuf::from("/project");
5197        let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
5198        let filtered = super::filter_new_health_findings(
5199            vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
5200            &baseline,
5201            &root,
5202            HealthBaselineMode::Identity,
5203        );
5204        assert_eq!(filtered.len(), 1);
5205    }
5206
5207    #[test]
5208    fn health_identity_move_is_not_followed_for_arrow_placeholders() {
5209        let root = PathBuf::from("/project");
5210        let baseline = identity_baseline(&[make_health_finding(&root, "<arrow>", 42)], &root);
5211        let filtered = super::filter_new_health_findings(
5212            vec![moved_finding(&root, "src/moved.ts", "<arrow>")],
5213            &baseline,
5214            &root,
5215            HealthBaselineMode::Identity,
5216        );
5217        assert_eq!(filtered.len(), 1);
5218    }
5219
5220    #[test]
5221    fn health_identity_placeholder_allowance_covers_new_context_names() {
5222        let root = PathBuf::from("/project");
5223        let baseline = identity_baseline(
5224            &[
5225                make_health_finding(&root, "<anonymous>", 10),
5226                make_health_finding(&root, "<arrow>", 20),
5227            ],
5228            &root,
5229        );
5230        let current = vec![
5231            make_health_finding(&root, "fetchIssue", 10),
5232            make_health_finding(&root, "Command.make(\"setup\")", 20),
5233        ];
5234        let filtered = super::filter_new_health_findings(
5235            current.clone(),
5236            &baseline,
5237            &root,
5238            HealthBaselineMode::Identity,
5239        );
5240        assert!(filtered.is_empty(), "{filtered:?}");
5241        let overlap = baseline.overlap_entries(&current, &root, HealthBaselineMode::Identity);
5242        assert_eq!(overlap.matched_entries, 2);
5243        assert_eq!(overlap.moved_entries, 0);
5244    }
5245
5246    #[test]
5247    fn health_identity_placeholder_allowance_gives_only_unused_count() {
5248        let root = PathBuf::from("/project");
5249        let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 10)], &root);
5250        let filtered = super::filter_new_health_findings(
5251            vec![
5252                make_health_finding(&root, "<anonymous>", 10),
5253                make_health_finding(&root, "newHotspot", 30),
5254            ],
5255            &baseline,
5256            &root,
5257            HealthBaselineMode::Identity,
5258        );
5259        assert_eq!(filtered.len(), 1);
5260        assert_eq!(filtered[0].name, "newHotspot");
5261    }
5262
5263    #[test]
5264    fn health_identity_placeholder_allowance_stays_in_its_file() {
5265        let root = PathBuf::from("/project");
5266        let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 10)], &root);
5267        let filtered = super::filter_new_health_findings(
5268            vec![moved_finding(&root, "src/other.ts", "fetchIssue")],
5269            &baseline,
5270            &root,
5271            HealthBaselineMode::Identity,
5272        );
5273        assert_eq!(filtered.len(), 1);
5274    }
5275
5276    #[test]
5277    fn health_identity_placeholder_allowance_tops_up_a_known_name() {
5278        let root = PathBuf::from("/project");
5279        // The wrapper `load` was a finding, and the generator that it returns
5280        // was `<anonymous>`. Both now carry `load`.
5281        let baseline = identity_baseline(
5282            &[
5283                make_health_finding(&root, "<anonymous>", 10),
5284                make_health_finding(&root, "load", 30),
5285            ],
5286            &root,
5287        );
5288        let current = vec![
5289            make_health_finding(&root, "load", 10),
5290            make_health_finding(&root, "load", 30),
5291        ];
5292        let filtered = super::filter_new_health_findings(
5293            current.clone(),
5294            &baseline,
5295            &root,
5296            HealthBaselineMode::Identity,
5297        );
5298        assert!(filtered.is_empty(), "{filtered:?}");
5299        let overlap = baseline.overlap_entries(&current, &root, HealthBaselineMode::Identity);
5300        assert_eq!(overlap.matched_entries, 2);
5301    }
5302
5303    #[test]
5304    fn health_identity_placeholder_allowance_is_not_shared_twice() {
5305        let root = PathBuf::from("/project");
5306        let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 10)], &root);
5307        let filtered = super::filter_new_health_findings(
5308            vec![
5309                make_health_finding(&root, "alpha", 10),
5310                make_health_finding(&root, "beta", 30),
5311            ],
5312            &baseline,
5313            &root,
5314            HealthBaselineMode::Identity,
5315        );
5316        assert_eq!(filtered.len(), 1, "one placeholder covers one finding");
5317    }
5318
5319    #[test]
5320    fn health_count_baseline_does_not_follow_file_moves() {
5321        let root = PathBuf::from("/project");
5322        let baseline = HealthBaselineData::from_findings(
5323            &[make_health_finding(&root, "parseExpression", 42)],
5324            &[],
5325            &[],
5326            &root,
5327        );
5328        let filtered = filter_new_health_findings(
5329            vec![moved_finding(
5330                &root,
5331                "src/parser/utils.ts",
5332                "parseExpression",
5333            )],
5334            &baseline,
5335            &root,
5336        );
5337        assert_eq!(filtered.len(), 1);
5338    }
5339
5340    #[test]
5341    fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
5342        let legacy = HealthBaselineData {
5343            findings: vec!["src/utils.ts:parseExpression:42".to_string()],
5344            ..HealthBaselineData::default()
5345        };
5346        assert!(legacy.lacks_identity_data());
5347        assert!(!HealthBaselineData::default().lacks_identity_data());
5348    }
5349
5350    #[test]
5351    fn health_baseline_empty_keeps_all() {
5352        let root = PathBuf::from("/project");
5353        let findings = vec![make_health_finding(&root, "parseExpression", 42)];
5354        let baseline = HealthBaselineData {
5355            kind: None,
5356            findings: vec![],
5357            finding_counts: BTreeMap::new(),
5358            identity_finding_counts: BTreeMap::new(),
5359            target_keys: vec![],
5360            runtime_coverage_findings: vec![],
5361            runtime_coverage_source_hashes: vec![],
5362        };
5363        let filtered = filter_new_health_findings(findings, &baseline, &root);
5364        assert_eq!(filtered.len(), 1);
5365    }
5366
5367    #[test]
5368    fn circular_dep_key_is_order_independent() {
5369        use crate::results::CircularDependency;
5370
5371        let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
5372            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
5373            length: 2,
5374            line: 1,
5375            col: 0,
5376            edges: Vec::new(),
5377            is_cross_package: false,
5378        });
5379        let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
5380            files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
5381            length: 2,
5382            line: 1,
5383            col: 0,
5384            edges: Vec::new(),
5385            is_cross_package: false,
5386        });
5387        assert_eq!(
5388            super::circular_dep_key(&dep_ab.cycle, Path::new("")),
5389            super::circular_dep_key(&dep_ba.cycle, Path::new("")),
5390            "same files in different order should produce identical keys"
5391        );
5392    }
5393
5394    #[test]
5395    fn circular_dep_key_different_files_different_keys() {
5396        use crate::results::CircularDependency;
5397
5398        let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
5399            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
5400            length: 2,
5401            line: 1,
5402            col: 0,
5403            edges: Vec::new(),
5404            is_cross_package: false,
5405        });
5406        let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
5407            files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
5408            length: 2,
5409            line: 1,
5410            col: 0,
5411            edges: Vec::new(),
5412            is_cross_package: false,
5413        });
5414        assert_ne!(
5415            super::circular_dep_key(&dep1.cycle, Path::new("")),
5416            super::circular_dep_key(&dep2.cycle, Path::new("")),
5417        );
5418    }
5419
5420    #[test]
5421    fn circular_dep_key_three_files_order_independent() {
5422        use crate::results::CircularDependency;
5423
5424        let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
5425            files: vec![
5426                PathBuf::from("src/a.ts"),
5427                PathBuf::from("src/b.ts"),
5428                PathBuf::from("src/c.ts"),
5429            ],
5430            length: 3,
5431            line: 1,
5432            col: 0,
5433            edges: Vec::new(),
5434            is_cross_package: false,
5435        });
5436        let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
5437            files: vec![
5438                PathBuf::from("src/c.ts"),
5439                PathBuf::from("src/a.ts"),
5440                PathBuf::from("src/b.ts"),
5441            ],
5442            length: 3,
5443            line: 1,
5444            col: 0,
5445            edges: Vec::new(),
5446            is_cross_package: false,
5447        });
5448        assert_eq!(
5449            super::circular_dep_key(&dep_abc.cycle, Path::new("")),
5450            super::circular_dep_key(&dep_cab.cycle, Path::new("")),
5451        );
5452    }
5453
5454    #[expect(
5455        clippy::too_many_lines,
5456        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5457    )]
5458    fn make_full_results() -> AnalysisResults {
5459        use crate::results::*;
5460        use crate::source::MemberKind;
5461
5462        let mut r = make_results();
5463        r.circular_dependencies
5464            .push(CircularDependencyFinding::with_actions(
5465                CircularDependency {
5466                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
5467                    length: 2,
5468                    line: 1,
5469                    col: 0,
5470                    edges: Vec::new(),
5471                    is_cross_package: false,
5472                },
5473            ));
5474        r.unused_optional_dependencies
5475            .push(UnusedOptionalDependencyFinding::with_actions(
5476                UnusedDependency {
5477                    package_name: "fsevents".to_string(),
5478                    location: DependencyLocation::OptionalDependencies,
5479                    path: PathBuf::from("package.json"),
5480                    line: 15,
5481                    used_in_workspaces: Vec::new(),
5482                    declared_and_imported_in: Vec::new(),
5483                },
5484            ));
5485        r.unused_enum_members
5486            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
5487                path: PathBuf::from("src/enums.ts"),
5488                parent_name: "Status".to_string(),
5489                member_name: "Deprecated".to_string(),
5490                kind: MemberKind::EnumMember,
5491                line: 8,
5492                col: 0,
5493            }));
5494        r.unused_class_members
5495            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
5496                path: PathBuf::from("src/service.ts"),
5497                parent_name: "UserService".to_string(),
5498                member_name: "legacy".to_string(),
5499                kind: MemberKind::ClassMethod,
5500                line: 42,
5501                col: 0,
5502            }));
5503        r.unused_store_members
5504            .push(UnusedStoreMemberFinding::with_actions(UnusedMember {
5505                path: PathBuf::from("src/store.ts"),
5506                parent_name: "useStore".to_string(),
5507                member_name: "legacyAction".to_string(),
5508                kind: MemberKind::StoreMember,
5509                line: 17,
5510                col: 0,
5511            }));
5512        r.unresolved_imports.push(
5513            fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
5514                crate::results::UnresolvedImport {
5515                    path: PathBuf::from("src/app.ts"),
5516                    specifier: "./missing".to_string(),
5517                    line: 3,
5518                    col: 0,
5519                    specifier_col: 0,
5520                },
5521            ),
5522        );
5523        r.unlisted_dependencies
5524            .push(crate::results::UnlistedDependencyFinding::with_actions(
5525                UnlistedDependency {
5526                    package_name: "chalk".to_string(),
5527                    imported_from: vec![],
5528                },
5529            ));
5530        r.duplicate_exports
5531            .push(crate::results::DuplicateExportFinding::with_actions(
5532                crate::results::DuplicateExport {
5533                    export_name: "Config".to_string(),
5534                    locations: vec![
5535                        crate::results::DuplicateLocation {
5536                            path: PathBuf::from("src/a.ts"),
5537                            line: 1,
5538                            col: 0,
5539                        },
5540                        crate::results::DuplicateLocation {
5541                            path: PathBuf::from("src/b.ts"),
5542                            line: 5,
5543                            col: 0,
5544                        },
5545                    ],
5546                },
5547            ));
5548        r.type_only_dependencies
5549            .push(crate::results::TypeOnlyDependencyFinding::with_actions(
5550                TypeOnlyDependency {
5551                    package_name: "zod".to_string(),
5552                    path: PathBuf::from("package.json"),
5553                    line: 8,
5554                },
5555            ));
5556        r.test_only_dependencies
5557            .push(crate::results::TestOnlyDependencyFinding::with_actions(
5558                TestOnlyDependency {
5559                    package_name: "vitest".to_string(),
5560                    path: PathBuf::from("package.json"),
5561                    line: 10,
5562                },
5563            ));
5564        r.boundary_violations.push(
5565            fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
5566                crate::results::BoundaryViolation {
5567                    from_path: PathBuf::from("src/ui/btn.ts"),
5568                    to_path: PathBuf::from("src/db/query.ts"),
5569                    from_zone: "ui".to_string(),
5570                    to_zone: "db".to_string(),
5571                    import_specifier: "../db/query".to_string(),
5572                    line: 1,
5573                    col: 0,
5574                    via_path: None,
5575                },
5576            ),
5577        );
5578        r
5579    }
5580
5581    #[test]
5582    fn baseline_from_results_captures_all_extended_fields() {
5583        let results = make_full_results();
5584        let baseline = BaselineData::from_results(&results, Path::new(""));
5585        assert_eq!(baseline.circular_dependencies.len(), 1);
5586        assert_eq!(
5587            baseline.unused_optional_dependencies,
5588            vec!["unused-optional-dependency:package.json:fsevents"]
5589        );
5590        assert_eq!(baseline.unused_enum_members.len(), 1);
5591        assert!(baseline.unused_enum_members[0].ends_with(":Status:Deprecated"));
5592        assert_eq!(baseline.unused_class_members.len(), 1);
5593        assert!(baseline.unused_class_members[0].ends_with(":UserService:legacy"));
5594        assert_eq!(baseline.unused_store_members.len(), 1);
5595        assert!(baseline.unused_store_members[0].ends_with(":useStore:legacyAction"));
5596        assert_eq!(baseline.unresolved_imports.len(), 1);
5597        assert!(baseline.unresolved_imports[0].ends_with(":./missing"));
5598        assert_eq!(
5599            baseline.unlisted_dependencies,
5600            vec!["unlisted-dependency:chalk"]
5601        );
5602        assert_eq!(baseline.duplicate_exports.len(), 1);
5603        assert!(baseline.duplicate_exports[0].starts_with("duplicate-export:Config:"));
5604        assert_eq!(
5605            baseline.type_only_dependencies,
5606            vec!["type-only-dependency:package.json:zod"]
5607        );
5608        assert_eq!(
5609            baseline.test_only_dependencies,
5610            vec!["test-only-dependency:package.json:vitest"]
5611        );
5612        assert_eq!(baseline.boundary_violations.len(), 1);
5613        assert!(baseline.boundary_violations[0].starts_with("boundary-violation:"));
5614    }
5615
5616    #[test]
5617    fn filter_removes_all_extended_baseline_issues() {
5618        let results = make_full_results();
5619        let baseline = BaselineData::from_results(&results, Path::new(""));
5620        let filtered = filter_new_issues(results, &baseline, Path::new(""));
5621        assert!(filtered.circular_dependencies.is_empty());
5622        assert!(filtered.unused_optional_dependencies.is_empty());
5623        assert!(filtered.unused_enum_members.is_empty());
5624        assert!(filtered.unused_class_members.is_empty());
5625        assert!(filtered.unused_store_members.is_empty());
5626        assert!(filtered.unresolved_imports.is_empty());
5627        assert!(filtered.unlisted_dependencies.is_empty());
5628        assert!(filtered.duplicate_exports.is_empty());
5629        assert!(filtered.type_only_dependencies.is_empty());
5630        assert!(filtered.test_only_dependencies.is_empty());
5631        assert!(filtered.boundary_violations.is_empty());
5632    }
5633
5634    #[test]
5635    fn filter_keeps_new_circular_deps() {
5636        use crate::results::CircularDependency;
5637        let baseline = BaselineData {
5638            circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
5639            ..BaselineData::legacy_from_results(&AnalysisResults::default(), Path::new(""))
5640        };
5641        let mut results = AnalysisResults::default();
5642        results
5643            .circular_dependencies
5644            .push(CircularDependencyFinding::with_actions(
5645                CircularDependency {
5646                    files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
5647                    length: 2,
5648                    line: 1,
5649                    col: 0,
5650                    edges: Vec::new(),
5651                    is_cross_package: false,
5652                },
5653            ));
5654        results
5655            .circular_dependencies
5656            .push(CircularDependencyFinding::with_actions(
5657                CircularDependency {
5658                    files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
5659                    length: 2,
5660                    line: 5,
5661                    col: 0,
5662                    edges: Vec::new(),
5663                    is_cross_package: false,
5664                },
5665            ));
5666        let filtered = filter_new_issues(results, &baseline, Path::new(""));
5667        assert_eq!(filtered.circular_dependencies.len(), 1);
5668    }
5669
5670    #[test]
5671    fn package_cycles_round_trip_through_the_baseline() {
5672        use crate::results::{PackageCycle, PackageCycleEdge, PackageCycleFinding};
5673        let cycle = |packages: &[&str], example: &str| {
5674            PackageCycleFinding::with_actions(PackageCycle {
5675                packages: packages.iter().map(ToString::to_string).collect(),
5676                package_roots: packages
5677                    .iter()
5678                    .map(|name| {
5679                        PathBuf::from(format!("packages/{}", name.trim_start_matches("@x/")))
5680                    })
5681                    .collect(),
5682                length: packages.len(),
5683                edges: vec![PackageCycleEdge {
5684                    from_package: packages[0].to_string(),
5685                    to_package: packages[1].to_string(),
5686                    path: PathBuf::from(example),
5687                    target_path: PathBuf::from("packages/b/src/y.ts"),
5688                    line: 1,
5689                    col: 0,
5690                    type_only: false,
5691                }],
5692                group_truncated: false,
5693            })
5694        };
5695        let mut saved = AnalysisResults::default();
5696        saved
5697            .package_cycles
5698            .push(cycle(&["@x/a", "@x/b"], "packages/a/src/x.ts"));
5699        let baseline = BaselineData::from_results(&saved, Path::new(""));
5700        assert_eq!(
5701            baseline.package_cycles,
5702            vec!["package-cycle:packages/a|packages/b"]
5703        );
5704
5705        // A new example import for the same cycle is still a known cycle.
5706        let mut results = AnalysisResults::default();
5707        results
5708            .package_cycles
5709            .push(cycle(&["@x/a", "@x/b"], "packages/a/src/other.ts"));
5710        results
5711            .package_cycles
5712            .push(cycle(&["@x/a", "@x/c"], "packages/a/src/x.ts"));
5713        let filtered = filter_new_issues(results, &baseline, Path::new(""));
5714        assert_eq!(filtered.package_cycles.len(), 1);
5715        assert_eq!(filtered.package_cycles[0].cycle.packages, ["@x/a", "@x/c"]);
5716
5717        // Packages that share a name carry their root in the label, so two
5718        // such cycles have two keys.
5719        let mut saved = AnalysisResults::default();
5720        saved.package_cycles.push(cycle(
5721            &["@x/a", "example (examples/one)"],
5722            "packages/a/src/x.ts",
5723        ));
5724        let baseline = BaselineData::from_results(&saved, Path::new(""));
5725        let mut results = AnalysisResults::default();
5726        results.package_cycles.push(cycle(
5727            &["@x/a", "example (examples/two)"],
5728            "packages/a/src/x.ts",
5729        ));
5730        let filtered = filter_new_issues(results, &baseline, Path::new(""));
5731        assert_eq!(filtered.package_cycles.len(), 1);
5732
5733        // A legacy baseline keeps its package-name key form and still matches.
5734        let mut saved = AnalysisResults::default();
5735        saved
5736            .package_cycles
5737            .push(cycle(&["@x/a", "@x/b"], "packages/a/src/x.ts"));
5738        let legacy = BaselineData::legacy_from_results(&saved, Path::new(""));
5739        assert_eq!(legacy.package_cycles, vec!["@x/a->@x/b"]);
5740        let filtered = filter_new_issues(saved, &legacy, Path::new(""));
5741        assert!(filtered.package_cycles.is_empty());
5742    }
5743
5744    #[test]
5745    fn filter_keeps_new_boundary_violations() {
5746        use crate::results::BoundaryViolation;
5747        let baseline = BaselineData {
5748            boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
5749            boundary_coverage_violations: vec![],
5750            boundary_call_violations: vec![],
5751            policy_violations: vec![],
5752            ..BaselineData::legacy_from_results(&AnalysisResults::default(), Path::new(""))
5753        };
5754        let mut results = AnalysisResults::default();
5755        results
5756            .boundary_violations
5757            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
5758                from_path: PathBuf::from("src/a.ts"),
5759                to_path: PathBuf::from("src/b.ts"),
5760                from_zone: "a".to_string(),
5761                to_zone: "b".to_string(),
5762                import_specifier: "../b".to_string(),
5763                line: 1,
5764                col: 0,
5765                via_path: None,
5766            }));
5767        results
5768            .boundary_violations
5769            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
5770                from_path: PathBuf::from("src/new.ts"),
5771                to_path: PathBuf::from("src/secret.ts"),
5772                from_zone: "new".to_string(),
5773                to_zone: "secret".to_string(),
5774                import_specifier: "../secret".to_string(),
5775                line: 1,
5776                col: 0,
5777                via_path: None,
5778            }));
5779        let filtered = filter_new_issues(results, &baseline, Path::new(""));
5780        assert_eq!(filtered.boundary_violations.len(), 1);
5781    }
5782
5783    #[test]
5784    fn health_targets_baseline_filters_known() {
5785        let root = PathBuf::from("/project");
5786        let targets = vec![
5787            fallow_output::RefactoringTarget {
5788                path: root.join("src/complex.ts"),
5789                priority: 80.0,
5790                efficiency: 40.0,
5791                recommendation: "Split file".to_string(),
5792                category: fallow_output::RecommendationCategory::SplitHighImpact,
5793                effort: fallow_output::EffortEstimate::Medium,
5794                confidence: fallow_output::Confidence::Medium,
5795                factors: vec![],
5796                evidence: None,
5797            },
5798            fallow_output::RefactoringTarget {
5799                path: root.join("src/new-issue.ts"),
5800                priority: 60.0,
5801                efficiency: 30.0,
5802                recommendation: "Extract function".to_string(),
5803                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
5804                effort: fallow_output::EffortEstimate::Low,
5805                confidence: fallow_output::Confidence::High,
5806                factors: vec![],
5807                evidence: None,
5808            },
5809        ];
5810        let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
5811        let filtered = filter_new_health_targets(targets, &baseline, &root);
5812        assert_eq!(filtered.len(), 1);
5813        assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
5814    }
5815
5816    #[test]
5817    fn duplicate_export_key_is_sorted() {
5818        use crate::results::{DuplicateExport, DuplicateLocation};
5819        let dup_ab = DuplicateExport {
5820            export_name: "foo".to_string(),
5821            locations: vec![
5822                DuplicateLocation {
5823                    path: PathBuf::from("src/a.ts"),
5824                    line: 1,
5825                    col: 0,
5826                },
5827                DuplicateLocation {
5828                    path: PathBuf::from("src/b.ts"),
5829                    line: 5,
5830                    col: 0,
5831                },
5832            ],
5833        };
5834        let dup_ba = DuplicateExport {
5835            export_name: "foo".to_string(),
5836            locations: vec![
5837                DuplicateLocation {
5838                    path: PathBuf::from("src/b.ts"),
5839                    line: 5,
5840                    col: 0,
5841                },
5842                DuplicateLocation {
5843                    path: PathBuf::from("src/a.ts"),
5844                    line: 1,
5845                    col: 0,
5846                },
5847            ],
5848        };
5849        assert_eq!(
5850            super::duplicate_export_key(&dup_ab, Path::new("")),
5851            super::duplicate_export_key(&dup_ba, Path::new("")),
5852        );
5853    }
5854
5855    #[test]
5856    fn boundary_violation_key_format() {
5857        use crate::results::BoundaryViolation;
5858        let v = BoundaryViolation {
5859            from_path: PathBuf::from("src/ui/btn.ts"),
5860            to_path: PathBuf::from("src/db/query.ts"),
5861            from_zone: "ui".to_string(),
5862            to_zone: "db".to_string(),
5863            import_specifier: "../db/query".to_string(),
5864            line: 1,
5865            col: 0,
5866            via_path: None,
5867        };
5868        let key = super::boundary_violation_key(&v, Path::new(""));
5869        assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
5870    }
5871
5872    /// Build results with absolute paths rooted at the given prefix.
5873    fn make_absolute_results(root: &str) -> AnalysisResults {
5874        use crate::results::*;
5875        use crate::source::MemberKind;
5876
5877        let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
5878
5879        AnalysisResults {
5880            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
5881                path: p("src/old.ts"),
5882            })],
5883            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
5884                path: p("src/utils.ts"),
5885                export_name: "helper".to_string(),
5886                is_type_only: false,
5887                line: 5,
5888                col: 0,
5889                span_start: 40,
5890                is_re_export: false,
5891                deprecated: false,
5892                deprecated_reason: None,
5893            })],
5894            unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
5895                package_name: "lodash-es".to_string(),
5896                location: DependencyLocation::Dependencies,
5897                path: p("packages/app/package.json"),
5898                line: 5,
5899                used_in_workspaces: Vec::new(),
5900                declared_and_imported_in: Vec::new(),
5901            })],
5902            circular_dependencies: vec![CircularDependencyFinding::with_actions(
5903                CircularDependency {
5904                    files: vec![p("src/a.ts"), p("src/b.ts")],
5905                    length: 2,
5906                    line: 1,
5907                    col: 0,
5908                    edges: Vec::new(),
5909                    is_cross_package: false,
5910                },
5911            )],
5912            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
5913                path: p("src/enums.ts"),
5914                parent_name: "Status".to_string(),
5915                member_name: "Deprecated".to_string(),
5916                kind: MemberKind::EnumMember,
5917                line: 8,
5918                col: 0,
5919            })],
5920            unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
5921                path: p("src/service.ts"),
5922                parent_name: "UserService".to_string(),
5923                member_name: "legacy".to_string(),
5924                kind: MemberKind::ClassMethod,
5925                line: 42,
5926                col: 0,
5927            })],
5928            unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
5929                path: p("src/store.ts"),
5930                parent_name: "useStore".to_string(),
5931                member_name: "legacyAction".to_string(),
5932                kind: MemberKind::StoreMember,
5933                line: 17,
5934                col: 0,
5935            })],
5936            unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
5937                path: p("src/app.ts"),
5938                specifier: "./missing".to_string(),
5939                line: 3,
5940                col: 0,
5941                specifier_col: 0,
5942            })],
5943            duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
5944                export_name: "Config".to_string(),
5945                locations: vec![
5946                    DuplicateLocation {
5947                        path: p("src/a.ts"),
5948                        line: 1,
5949                        col: 0,
5950                    },
5951                    DuplicateLocation {
5952                        path: p("src/b.ts"),
5953                        line: 5,
5954                        col: 0,
5955                    },
5956                ],
5957            })],
5958            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
5959                from_path: p("src/ui/btn.ts"),
5960                to_path: p("src/db/query.ts"),
5961                from_zone: "ui".to_string(),
5962                to_zone: "db".to_string(),
5963                import_specifier: "../db/query".to_string(),
5964                line: 1,
5965                col: 0,
5966                via_path: None,
5967            })],
5968            ..Default::default()
5969        }
5970    }
5971
5972    /// Regression test: baseline saved on one machine (different absolute root)
5973    /// must match issues found on another machine across all path-based types.
5974    #[test]
5975    fn baseline_keys_are_relative_to_root() {
5976        let local_root = Path::new("/Users/dev/project");
5977        let results = make_absolute_results("/Users/dev/project");
5978        let baseline = BaselineData::from_results(&results, local_root);
5979
5980        assert_eq!(baseline.unused_files, vec!["unused-file:src/old.ts"]);
5981        assert_eq!(
5982            baseline.unused_exports,
5983            vec!["unused-export:src/utils.ts:helper"]
5984        );
5985        assert_eq!(
5986            baseline.unused_dependencies,
5987            vec!["unused-dependency:packages/app/package.json:lodash-es"]
5988        );
5989        assert_eq!(
5990            baseline.boundary_violations,
5991            vec!["boundary-violation:src/ui/btn.ts:src/db/query.ts"]
5992        );
5993        assert_eq!(
5994            baseline.circular_dependencies,
5995            vec!["circular-dependency:src/a.ts|src/b.ts"]
5996        );
5997        assert_eq!(
5998            baseline.unused_enum_members,
5999            vec!["unused-enum-member:src/enums.ts:Status:Deprecated"]
6000        );
6001        assert_eq!(
6002            baseline.unused_class_members,
6003            vec!["unused-class-member:src/service.ts:UserService:legacy"]
6004        );
6005        assert_eq!(
6006            baseline.unused_store_members,
6007            vec!["unused-store-member:src/store.ts:useStore:legacyAction"]
6008        );
6009        assert_eq!(
6010            baseline.unresolved_imports,
6011            vec!["unresolved-import:src/app.ts:./missing"]
6012        );
6013        assert_eq!(
6014            baseline.duplicate_exports,
6015            vec!["duplicate-export:Config:src/a.ts|src/b.ts"]
6016        );
6017
6018        let ci_root = Path::new("/home/runner/work/project/project");
6019        let ci_results = make_absolute_results("/home/runner/work/project/project");
6020
6021        let filtered = filter_new_issues(ci_results, &baseline, ci_root);
6022        assert!(filtered.unused_files.is_empty(), "unused files");
6023        assert!(filtered.unused_exports.is_empty(), "unused exports");
6024        assert!(filtered.unused_dependencies.is_empty(), "unused deps");
6025        assert!(
6026            filtered.boundary_violations.is_empty(),
6027            "boundary violations"
6028        );
6029        assert!(filtered.circular_dependencies.is_empty(), "circular deps");
6030        assert!(filtered.unused_enum_members.is_empty(), "enum members");
6031        assert!(filtered.unused_class_members.is_empty(), "class members");
6032        assert!(filtered.unused_store_members.is_empty(), "store members");
6033        assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
6034        assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
6035    }
6036
6037    #[test]
6038    fn stale_suppression_baseline_keys_include_missing_reason_state() {
6039        let root = Path::new("/project");
6040        let stale = crate::results::StaleSuppression {
6041            finding_id: None,
6042            path: root.join("src/file.ts"),
6043            line: 1,
6044            col: 0,
6045            origin: crate::results::SuppressionOrigin::Comment {
6046                issue_kind: Some("unused-export".to_string()),
6047                reason: None,
6048                is_file_level: false,
6049                kind_known: true,
6050            },
6051            missing_reason: false,
6052            actions: crate::results::StaleSuppression::actions_for(false),
6053            effective_severity: None,
6054        };
6055        let missing = crate::results::StaleSuppression {
6056            missing_reason: true,
6057            actions: crate::results::StaleSuppression::actions_for(true),
6058            ..stale.clone()
6059        };
6060        let results = AnalysisResults {
6061            stale_suppressions: vec![stale, missing],
6062            ..Default::default()
6063        };
6064        let baseline = BaselineData::from_results(&results, root);
6065
6066        assert_eq!(
6067            baseline.stale_suppressions,
6068            vec![
6069                "missing-suppression-reason:src/file.ts:comment:unused-export:line",
6070                "stale-suppression:src/file.ts:comment:unused-export:line",
6071            ]
6072        );
6073
6074        let mut legacy_baseline =
6075            BaselineData::legacy_from_results(&AnalysisResults::default(), root);
6076        legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
6077        let filtered = filter_new_issues(results, &legacy_baseline, root);
6078        assert!(filtered.stale_suppressions.is_empty());
6079    }
6080
6081    fn runtime_finding(
6082        id: &str,
6083        stable_id: Option<&str>,
6084        line: u32,
6085        source_hash: Option<&str>,
6086    ) -> fallow_output::RuntimeCoverageFinding {
6087        fallow_output::RuntimeCoverageFinding {
6088            id: id.to_owned(),
6089            stable_id: stable_id.map(str::to_owned),
6090            source_hash: source_hash.map(str::to_owned),
6091            path: PathBuf::from("src/a.ts"),
6092            function: "alpha".to_owned(),
6093            line,
6094            verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
6095            invocations: Some(0),
6096            confidence: fallow_output::RuntimeCoverageConfidence::Medium,
6097            evidence: fallow_output::RuntimeCoverageEvidence {
6098                static_status: "used".to_owned(),
6099                test_coverage: "not_covered".to_owned(),
6100                test_only_reference: None,
6101                v8_tracking: "tracked".to_owned(),
6102                untracked_reason: None,
6103                observation_days: 1,
6104                deployments_observed: 1,
6105            },
6106            actions: vec![],
6107            discriminators: None,
6108        }
6109    }
6110
6111    #[test]
6112    fn legacy_prod_baseline_still_suppresses_finding() {
6113        let baseline = HealthBaselineData {
6114            runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
6115            ..HealthBaselineData::default()
6116        };
6117        let findings = vec![runtime_finding(
6118            "fallow:prod:deadbeef",
6119            Some("fallow:fn:00000001"),
6120            14,
6121            None,
6122        )];
6123        let filtered =
6124            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
6125        assert!(filtered.is_empty(), "legacy prod id must still suppress");
6126    }
6127
6128    #[test]
6129    fn source_hash_baseline_survives_line_move() {
6130        let root = Path::new("/repo");
6131        let baselined = runtime_finding(
6132            "fallow:prod:deadbeef",
6133            Some("fallow:fn:00000001"),
6134            14,
6135            Some("0123456789abcdef"),
6136        );
6137        let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
6138        assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
6139
6140        let findings = vec![runtime_finding(
6141            "fallow:prod:99999999",
6142            Some("fallow:fn:cafe0002"),
6143            40,
6144            Some("0123456789abcdef"),
6145        )];
6146        let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
6147        assert!(
6148            filtered.is_empty(),
6149            "source_hash baseline must survive a line move despite a changed stable_id and id"
6150        );
6151    }
6152
6153    #[test]
6154    fn unbaselined_finding_is_reported() {
6155        let baseline = HealthBaselineData {
6156            runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
6157            ..HealthBaselineData::default()
6158        };
6159        let findings = vec![runtime_finding(
6160            "fallow:prod:abc1234d",
6161            Some("fallow:fn:beefcafe"),
6162            7,
6163            None,
6164        )];
6165        let filtered =
6166            filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
6167        assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
6168    }
6169
6170    // Line-free, count-matched dead-code baselines.
6171
6172    fn line_free_member(
6173        root: &Path,
6174        member: &str,
6175        line: u32,
6176    ) -> crate::results::UnusedClassMemberFinding {
6177        crate::results::UnusedClassMemberFinding::with_actions(crate::results::UnusedMember {
6178            path: root.join("src/service.ts"),
6179            parent_name: "Service".to_owned(),
6180            member_name: member.to_owned(),
6181            kind: fallow_types::extract::MemberKind::ClassMethod,
6182            line,
6183            col: 2,
6184        })
6185    }
6186
6187    fn line_free_suppression(
6188        root: &Path,
6189        line: u32,
6190        reason: Option<&str>,
6191    ) -> crate::results::StaleSuppression {
6192        crate::results::StaleSuppression {
6193            path: root.join("src/flags.ts"),
6194            line,
6195            col: 0,
6196            origin: crate::results::SuppressionOrigin::Comment {
6197                issue_kind: Some("unused-export".to_owned()),
6198                reason: reason.map(str::to_owned),
6199                is_file_level: false,
6200                kind_known: true,
6201            },
6202            missing_reason: false,
6203            finding_id: None,
6204            actions: Vec::new(),
6205            effective_severity: None,
6206        }
6207    }
6208
6209    fn line_free_directive(root: &Path, line: u32) -> crate::results::MisplacedDirectiveFinding {
6210        crate::results::MisplacedDirectiveFinding::with_actions(
6211            crate::results::MisplacedDirective {
6212                path: root.join("src/action.ts"),
6213                directive: "use server".to_owned(),
6214                line,
6215                col: 0,
6216            },
6217        )
6218    }
6219
6220    fn line_free_results(root: &Path, shift: u32, reason: Option<&str>) -> AnalysisResults {
6221        AnalysisResults {
6222            unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
6223                path: root.join("src/utils.ts"),
6224                export_name: "helper".to_owned(),
6225                is_type_only: false,
6226                line: 10 + shift,
6227                col: 0,
6228                span_start: 100 + shift * 10,
6229                is_re_export: false,
6230                deprecated: false,
6231                deprecated_reason: None,
6232            })],
6233            stale_suppressions: vec![line_free_suppression(root, 3 + shift, reason)],
6234            misplaced_directives: vec![line_free_directive(root, 5 + shift)],
6235            ..AnalysisResults::default()
6236        }
6237    }
6238
6239    fn reload(baseline: &BaselineData) -> BaselineData {
6240        serde_json::from_str(&serde_json::to_string(baseline).expect("serialize baseline"))
6241            .expect("parse baseline")
6242    }
6243
6244    #[test]
6245    fn saved_baseline_hides_findings_after_a_line_shift() {
6246        let root = PathBuf::from("/project");
6247        let baseline = reload(&BaselineData::from_results(
6248            &line_free_results(&root, 0, None),
6249            &root,
6250        ));
6251
6252        let filtered = filter_new_issues(line_free_results(&root, 20, None), &baseline, &root);
6253
6254        assert_eq!(
6255            filtered.total_issues(),
6256            0,
6257            "a line shift must not make findings new"
6258        );
6259    }
6260
6261    #[test]
6262    fn saved_baseline_ignores_the_suppression_reason_text() {
6263        let root = PathBuf::from("/project");
6264        let baseline = reload(&BaselineData::from_results(
6265            &line_free_results(&root, 0, Some("old reason")),
6266            &root,
6267        ));
6268
6269        let filtered = filter_new_issues(
6270            line_free_results(&root, 7, Some("a new reason")),
6271            &baseline,
6272            &root,
6273        );
6274
6275        assert!(filtered.stale_suppressions.is_empty());
6276    }
6277
6278    #[test]
6279    fn saved_baseline_hides_at_most_the_saved_number_of_occurrences() {
6280        let root = PathBuf::from("/project");
6281        let saved = AnalysisResults {
6282            unused_class_members: vec![line_free_member(&root, "value", 4)],
6283            ..AnalysisResults::default()
6284        };
6285        let baseline = reload(&BaselineData::from_results(&saved, &root));
6286        let current = AnalysisResults {
6287            unused_class_members: vec![
6288                line_free_member(&root, "value", 14),
6289                line_free_member(&root, "value", 18),
6290            ],
6291            ..AnalysisResults::default()
6292        };
6293
6294        let filtered = filter_new_issues(current, &baseline, &root);
6295
6296        assert_eq!(
6297            filtered.unused_class_members.len(),
6298            1,
6299            "one saved occurrence hides one current finding, not two"
6300        );
6301    }
6302
6303    #[test]
6304    fn saved_baseline_declares_the_dc1_identity_and_canonical_keys() {
6305        let root = PathBuf::from("/project");
6306        let mut results = line_free_results(&root, 0, None);
6307        results.unused_class_members = vec![
6308            line_free_member(&root, "value", 4),
6309            line_free_member(&root, "value", 8),
6310        ];
6311
6312        let json: serde_json::Value =
6313            serde_json::to_value(BaselineData::from_results(&results, &root)).unwrap();
6314
6315        assert_eq!(json["identity"], "dc1");
6316        assert_eq!(
6317            json["unused_exports"],
6318            serde_json::json!(["unused-export:src/utils.ts:helper"])
6319        );
6320        assert_eq!(
6321            json["unused_class_members"],
6322            serde_json::json!([
6323                "unused-class-member:src/service.ts:Service:value",
6324                "unused-class-member:src/service.ts:Service:value"
6325            ])
6326        );
6327        assert_eq!(
6328            json["stale_suppressions"],
6329            serde_json::json!(["stale-suppression:src/flags.ts:comment:unused-export:line"])
6330        );
6331        assert_eq!(
6332            json["misplaced_directives"],
6333            serde_json::json!(["misplaced-directive:src/action.ts:use server"])
6334        );
6335    }
6336
6337    #[test]
6338    fn legacy_baseline_still_matches_its_exact_keys() {
6339        let root = PathBuf::from("/project");
6340        let legacy: BaselineData = serde_json::from_value(serde_json::json!({
6341            "unused_files": [],
6342            "unused_exports": ["src/utils.ts:helper"],
6343            "unused_types": [],
6344            "unused_dependencies": [],
6345            "unused_dev_dependencies": [],
6346            "stale_suppressions": ["stale-suppression:src/flags.ts:3"],
6347            "misplaced_directives": ["src/action.ts:5:use server"]
6348        }))
6349        .unwrap();
6350
6351        let unshifted = filter_new_issues(line_free_results(&root, 0, None), &legacy, &root);
6352        let shifted = filter_new_issues(line_free_results(&root, 1, None), &legacy, &root);
6353
6354        assert_eq!(
6355            unshifted.total_issues(),
6356            0,
6357            "a legacy key matches its old form"
6358        );
6359        assert!(shifted.unused_exports.is_empty());
6360        assert_eq!(
6361            shifted.stale_suppressions.len() + shifted.misplaced_directives.len(),
6362            2,
6363            "a legacy key that holds a line matches only that line"
6364        );
6365    }
6366
6367    #[test]
6368    fn a_baseline_with_an_unknown_identity_is_refused() {
6369        let root = PathBuf::from("/project");
6370        let mut results = line_free_results(&root, 0, None);
6371        let content = serde_json::json!({
6372            "identity": "dc9",
6373            "unused_files": [],
6374            "unused_exports": [],
6375            "unused_types": [],
6376            "unused_dependencies": [],
6377            "unused_dev_dependencies": []
6378        })
6379        .to_string();
6380
6381        let outcome = apply_dead_code_baseline(
6382            &mut results,
6383            &content,
6384            &root,
6385            &fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
6386            false,
6387        );
6388
6389        assert!(
6390            matches!(outcome, Err(DeadCodeBaselineError::Parse(ref message)) if message.contains("dc9")),
6391            "{outcome:?}"
6392        );
6393    }
6394
6395    #[test]
6396    fn saved_scope_reasons_reads_only_a_string_array() {
6397        assert_eq!(
6398            saved_scope_reasons(r#"{"scope_reasons":["package-baselines","production"]}"#),
6399            ["package-baselines", "production"]
6400        );
6401        assert!(saved_scope_reasons(r#"{"unused_files":[]}"#).is_empty());
6402        assert!(saved_scope_reasons(r#"{"scope_reasons":"package-baselines"}"#).is_empty());
6403        assert_eq!(
6404            saved_scope_reasons(r#"{"scope_reasons":["diff",7,null]}"#),
6405            ["diff"]
6406        );
6407        assert!(saved_scope_reasons("not json").is_empty());
6408    }
6409
6410    #[test]
6411    fn a_whole_project_save_records_no_scope() {
6412        let root = Path::new("/project");
6413        let saved = serde_json::to_value(BaselineData::from_results(
6414            &AnalysisResults::default(),
6415            root,
6416        ))
6417        .expect("baseline JSON");
6418        assert!(saved.get("scope_reasons").is_none(), "{saved}");
6419        let scoped = serde_json::to_value(
6420            BaselineData::from_results(&AnalysisResults::default(), root).with_scope_reasons(
6421                fallow_output::BaselineScopeReasons::empty()
6422                    .with(fallow_output::ScopeReason::PackageBaselines),
6423            ),
6424        )
6425        .expect("baseline JSON");
6426        assert_eq!(
6427            scoped["scope_reasons"],
6428            serde_json::json!(["package-baselines"])
6429        );
6430    }
6431}