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