Skip to main content

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