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