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