Skip to main content

fallow_engine/
dead_code.rs

1//! Dead-code result helpers exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_config::{ResolvedConfig, RulesConfig, Severity};
8use fallow_types::discover::StableFileKey;
9
10pub use crate::results::{
11    AnalysisResults, DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
12    DeadCodeAnalysisWithHashes, derive_security_severity, enable_security_rules,
13    resolve_security_finding_severity, security_catalogue_title, security_rule_id,
14    security_rules_can_error,
15};
16
17pub use crate::effective_severity::{
18    RuleSeverity, SeveritySource, apply_effective_severities, findings_without_severity,
19    promote_effective_warns,
20};
21
22#[path = "dead_code_config_patterns.rs"]
23mod config_patterns;
24pub use config_patterns::config_pattern_diagnostics;
25
26use crate::{
27    EngineResult, change_scope::ChangeScope,
28    session::analyze_dead_code_with_parse_result_from_config, source::ModuleInfo,
29};
30
31/// Run dead-code analysis from pre-parsed modules.
32///
33/// # Errors
34///
35/// Returns an error if discovery, graph construction, or analysis fails.
36pub(crate) fn analyze_with_parse_result(
37    config: &ResolvedConfig,
38    modules: &[ModuleInfo],
39) -> EngineResult<DeadCodeAnalysisArtifacts> {
40    analyze_dead_code_with_parse_result_from_config(config, modules)
41}
42
43/// Write a stable `finding_id` onto every dead-code finding in `results`.
44///
45/// Every producer calls this on the full result set, before the workspace,
46/// scope, changed-file, ignore, baseline and rule filters. A filter then never
47/// changes the id of a finding that stays in the report.
48pub fn stamp_finding_ids(results: &mut AnalysisResults, root: &Path) {
49    fallow_types::identity::stamp_dead_code_finding_ids(results, root);
50}
51
52/// Give a `finding_id` to each dead-code finding that has none, and keep the
53/// existing ids.
54///
55/// Type-aware refinement adds findings after the scope filters ran. A full
56/// restamp there would compute tiebreak suffixes over the filtered set.
57pub fn stamp_missing_finding_ids(results: &mut AnalysisResults, root: &Path) {
58    fallow_types::identity::stamp_missing_dead_code_finding_ids(results, root);
59}
60
61/// A validated `--finding-id` request: the ids a run reports, in request order.
62///
63/// The filter runs after every other filter and after the baseline, so it
64/// narrows what the run would otherwise report. The ids themselves are
65/// stamped on the full result set before any filter, so a filter never
66/// changes them.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FindingIdFilter {
69    requested: Vec<String>,
70    set: FxHashSet<String>,
71}
72
73impl FindingIdFilter {
74    /// Validate the requested ids and drop duplicates. Returns `Ok(None)`
75    /// when `values` is empty.
76    ///
77    /// # Errors
78    ///
79    /// Returns a message that names the first value that is not a current
80    /// dead-code finding id (`dc1:<rule>:<16 hex digits>` with an optional
81    /// `~<k>` suffix). A typo must never read as "the finding is gone".
82    pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Option<Self>, String> {
83        if values.is_empty() {
84            return Ok(None);
85        }
86        let mut requested = Vec::with_capacity(values.len());
87        let mut set = FxHashSet::default();
88        for value in values {
89            let id = value.as_ref().trim();
90            if !fallow_types::identity::is_dead_code_finding_id(id) {
91                return Err(format!(
92                    "invalid finding id '{id}': expected {}:<rule>:<16 hex digits>, \
93                     optionally with a ~<k> suffix, as printed in the finding_id field",
94                    fallow_types::identity::DEAD_CODE_ID_SCHEME
95                ));
96            }
97            if set.insert(id.to_owned()) {
98                requested.push(id.to_owned());
99            }
100        }
101        Ok(Some(Self { requested, set }))
102    }
103
104    /// The requested ids that a finding in `results` carries. Reads only.
105    #[must_use]
106    pub fn present(&self, results: &mut AnalysisResults) -> FxHashSet<String> {
107        fallow_types::identity::present_dead_code_finding_ids(results, &self.set)
108    }
109
110    /// Keep only the requested findings and build the query answer.
111    ///
112    /// `filtered` holds the requested ids that the analysis found and a filter
113    /// of this run removed. `run_reasons` are the options of this run that
114    /// can hide a finding without a fix. `rule-off` is added when the rule of
115    /// a missing id is `off` in `config`.
116    pub fn apply(
117        &self,
118        results: &mut AnalysisResults,
119        config: &ResolvedConfig,
120        filtered: &FxHashSet<String>,
121        run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
122    ) -> fallow_output::FindingIdQuery {
123        let found = fallow_types::identity::retain_dead_code_findings_by_id(results, &self.set);
124        let rule_off = self
125            .requested
126            .iter()
127            .filter(|id| !found.contains(*id))
128            .any(|id| finding_id_rule_is_off(id, config));
129        fallow_output::FindingIdQuery::new(
130            self.requested.clone(),
131            |id| found.contains(id),
132            |id| filtered.contains(id),
133            run_reasons
134                .into_iter()
135                .chain(rule_off.then_some(fallow_output::FindingIdQueryReason::RuleOff)),
136            analysis_fingerprint(config),
137        )
138    }
139}
140
141/// Evidence for a finding-id answer, collected around the filter stages of
142/// one run.
143///
144/// A requested id that is present before a filter stage and absent after it
145/// was hidden by this run, not fixed. Those ids end in `filtered`. A stage
146/// that is analysis (type-aware refinement) stays outside every stage, so a
147/// finding it removes counts as gone.
148#[derive(Debug, Clone)]
149pub struct FindingIdTrace {
150    filter: FindingIdFilter,
151    before_stage: FxHashSet<String>,
152    filtered: FxHashSet<String>,
153}
154
155impl FindingIdTrace {
156    /// Start the first filter stage on the full result set.
157    #[must_use]
158    pub fn start(filter: FindingIdFilter, results: &mut AnalysisResults) -> Self {
159        let before_stage = filter.present(results);
160        Self {
161            filter,
162            before_stage,
163            filtered: FxHashSet::default(),
164        }
165    }
166
167    /// Start a filter stage after work that is not a filter.
168    pub fn start_stage(&mut self, results: &mut AnalysisResults) {
169        self.before_stage = self.filter.present(results);
170    }
171
172    /// End a filter stage: the requested ids it removed count as filtered.
173    pub fn end_stage(&mut self, results: &mut AnalysisResults) {
174        let after = self.filter.present(results);
175        self.filtered
176            .extend(self.before_stage.drain().filter(|id| !after.contains(id)));
177    }
178
179    /// Apply the filter and build the answer. See [`FindingIdFilter::apply`].
180    pub fn finish(
181        self,
182        results: &mut AnalysisResults,
183        config: &ResolvedConfig,
184        run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
185    ) -> fallow_output::FindingIdQuery {
186        self.filter
187            .apply(results, config, &self.filtered, run_reasons)
188    }
189}
190
191/// The version prefix of an analysis fingerprint. A change to the hash inputs
192/// moves it, so an old fingerprint never equals a new one.
193const ANALYSIS_FINGERPRINT_SCHEME: &str = "af1";
194
195/// Ignore files that discovery reads in each directory it walks.
196const IGNORE_FILE_NAMES: &[&str] = &[".gitignore", ".ignore"];
197
198/// Non-source files that import resolution and entry-point discovery read,
199/// in every directory: manifests and TypeScript or JavaScript project files.
200/// The built-in and external plugin config patterns are added to these.
201const RESOLUTION_FILE_GLOBS: &[&str] =
202    &["**/package.json", "**/tsconfig*.json", "**/jsconfig*.json"];
203
204/// The maximum depth of a followed tsconfig `extends` chain.
205const MAX_EXTENDS_DEPTH: usize = 8;
206
207/// A stable hash of every input, other than the source files, that decides
208/// which dead-code findings a run of `config` reports.
209///
210/// The inputs:
211/// - the fallow version;
212/// - the detection config digest (merged user config after `extends`,
213///   external plugins, rule packs);
214/// - the settings that a surface changes after resolution: production mode,
215///   `includeEntryExports`, `showCascade`, the effective rules, the type-aware mode,
216///   requirement and project list, the file size limit;
217/// - the root-relative path and content of the repository ignore files, the
218///   `package.json` files, the `tsconfig*.json` and `jsconfig*.json` files and
219///   the `extends` files they name, and every file that matches a built-in or
220///   external plugin config pattern.
221///
222/// File content is normalized (CRLF to LF, trailing newlines removed) and the
223/// entries are sorted, so two checkouts of one commit give the same value on
224/// every platform. Known exclusions: the global git excludes file and other
225/// machine environment outside the `FALLOW_*` variables.
226#[must_use]
227pub fn analysis_fingerprint(config: &ResolvedConfig) -> String {
228    analysis_fingerprint_for_version(config, env!("CARGO_PKG_VERSION"))
229}
230
231/// [`analysis_fingerprint`] for an explicit fallow version.
232#[must_use]
233pub fn analysis_fingerprint_for_version(config: &ResolvedConfig, version: &str) -> String {
234    let rules = serde_json::to_string(&config.rules).unwrap_or_default();
235    let projects: Vec<String> = config
236        .type_aware
237        .projects
238        .iter()
239        .map(|project| root_relative_text(&config.root, project))
240        .collect();
241    let type_aware = format!(
242        "{}:{}:{}",
243        config.type_aware.enabled,
244        serde_json::to_string(&config.type_aware.require).unwrap_or_default(),
245        projects.join("|")
246    );
247    let max_file_size = config
248        .max_file_size_bytes
249        .map_or_else(|| "none".to_owned(), |bytes| bytes.to_string());
250    let input_files = input_files_digest(config);
251    let hash = fallow_types::identity::fnv1a64_parts(&[
252        ANALYSIS_FINGERPRINT_SCHEME,
253        version,
254        &config.detection_config_digest,
255        if config.production {
256            "production"
257        } else {
258            "all"
259        },
260        if config.include_entry_exports {
261            "entry-exports"
262        } else {
263            "no-entry-exports"
264        },
265        if config.show_cascade {
266            "cascade-shown"
267        } else {
268            "cascade-hidden"
269        },
270        &rules,
271        &type_aware,
272        &max_file_size,
273        &input_files,
274    ]);
275    format!("{ANALYSIS_FINGERPRINT_SCHEME}:{hash}")
276}
277
278/// `path` relative to `root` with forward slashes when it is inside the
279/// root, else the text as given.
280fn root_relative_text(root: &Path, path: &str) -> String {
281    Path::new(path).strip_prefix(root).map_or_else(
282        |_| path.replace('\\', "/"),
283        |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
284    )
285}
286
287/// Normalize file text before it is hashed: CRLF becomes LF and trailing
288/// newlines are removed, so a checkout with `core.autocrlf` hashes the same
289/// as one without it.
290fn normalized_text(content: &[u8]) -> String {
291    String::from_utf8_lossy(content)
292        .replace("\r\n", "\n")
293        .trim_end_matches('\n')
294        .to_owned()
295}
296
297/// The walker for the fingerprint inputs.
298///
299/// It honors the repository `.gitignore`, `.ignore` and `.git/info/exclude`
300/// files, but never the global git excludes file of the machine: that file
301/// would prune directories on one machine and not on another. It skips hidden
302/// directories (the fallow cache lives there) and `node_modules`.
303fn fingerprint_walk_builder(root: &Path) -> ignore::WalkBuilder {
304    let mut builder = ignore::WalkBuilder::new(root);
305    builder
306        .hidden(false)
307        .git_ignore(true)
308        .git_global(false)
309        .git_exclude(true)
310        .filter_entry(|entry| {
311            let is_dir = entry.file_type().is_some_and(|kind| kind.is_dir());
312            if !is_dir || entry.depth() == 0 {
313                return true;
314            }
315            let name = entry.file_name().to_string_lossy();
316            !name.starts_with('.') && name != "node_modules"
317        });
318    builder
319}
320
321/// The globs of the non-source files the analysis reads to resolve imports
322/// and entry points, each also tried under `**/`.
323fn resolution_file_globs(config: &ResolvedConfig) -> globset::GlobSet {
324    let mut builder = globset::GlobSetBuilder::new();
325    let external = config
326        .external_plugins
327        .iter()
328        .flat_map(|plugin| plugin.config_patterns.iter().map(String::as_str));
329    let patterns = crate::core_backend::builtin_config_patterns()
330        .into_iter()
331        .chain(external)
332        .chain(RESOLUTION_FILE_GLOBS.iter().copied());
333    for pattern in patterns {
334        let anywhere = if pattern.starts_with("**/") {
335            pattern.to_owned()
336        } else {
337            format!("**/{pattern}")
338        };
339        for candidate in [pattern.to_owned(), anywhere] {
340            if let Ok(glob) = globset::Glob::new(&candidate) {
341                builder.add(glob);
342            }
343        }
344    }
345    builder
346        .build()
347        .unwrap_or_else(|_| globset::GlobSet::empty())
348}
349
350fn is_project_config_name(name: &str) -> bool {
351    (name.starts_with("tsconfig") || name.starts_with("jsconfig"))
352        && std::path::Path::new(name)
353            .extension()
354            .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
355}
356
357/// A hash over the root-relative path and normalized content of each
358/// fingerprint input file. See [`analysis_fingerprint`].
359fn input_files_digest(config: &ResolvedConfig) -> String {
360    let root = config.root.as_path();
361    let globs = resolution_file_globs(config);
362    let mut files: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
363    if let Ok(content) = std::fs::read(root.join(".git/info/exclude")) {
364        files.insert(".git/info/exclude".to_owned(), normalized_text(&content));
365    }
366    let mut project_configs: Vec<PathBuf> = Vec::new();
367    for entry in fingerprint_walk_builder(root).build().flatten() {
368        if entry.file_type().is_none_or(|kind| kind.is_dir()) {
369            continue;
370        }
371        let Ok(relative) = entry.path().strip_prefix(root) else {
372            continue;
373        };
374        let name = entry.file_name().to_string_lossy();
375        let is_ignore_file = IGNORE_FILE_NAMES.contains(&name.as_ref());
376        if !is_ignore_file && !globs.is_match(relative) {
377            continue;
378        }
379        if config.ignore_patterns.is_match(relative) {
380            continue;
381        }
382        if let Ok(content) = std::fs::read(entry.path()) {
383            let key = StableFileKey::from_relative(relative).as_str().to_owned();
384            files.insert(key, normalized_text(&content));
385            if is_project_config_name(&name) {
386                project_configs.push(entry.path().to_path_buf());
387            }
388        }
389    }
390    for project_config in project_configs {
391        add_extends_chain(root, &project_config, &mut files);
392    }
393    let parts: Vec<&str> = files
394        .iter()
395        .flat_map(|(path, content)| [path.as_str(), content.as_str()])
396        .collect();
397    fallow_types::identity::fnv1a64_parts(&parts)
398}
399
400/// Follow the `extends` chain of one tsconfig or jsconfig file and add each
401/// file it names, also a file the walk did not see: one in a hidden
402/// directory, outside the root, or in `node_modules`.
403fn add_extends_chain(
404    root: &Path,
405    project_config: &Path,
406    files: &mut std::collections::BTreeMap<String, String>,
407) {
408    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
409    let mut frontier: Vec<(PathBuf, usize)> = vec![(project_config.to_path_buf(), 0)];
410    while let Some((current, depth)) = frontier.pop() {
411        if depth >= MAX_EXTENDS_DEPTH || !seen.insert(current.clone()) {
412            continue;
413        }
414        for target in read_extends(&current).unwrap_or_default() {
415            let Some(next) = resolve_extends_target(root, &current, &target) else {
416                continue;
417            };
418            if let Ok(content) = std::fs::read(&next) {
419                files.insert(extends_key(root, &next), normalized_text(&content));
420                frontier.push((next, depth + 1));
421            }
422        }
423    }
424}
425
426/// The `extends` targets of a tsconfig or jsconfig file: one string or an
427/// array of strings.
428fn read_extends(path: &Path) -> Option<Vec<String>> {
429    let content = std::fs::read_to_string(path).ok()?;
430    let value: serde_json::Value = fallow_config::jsonc::parse_to_value(&content).ok()?;
431    match value.get("extends")? {
432        serde_json::Value::String(target) => Some(vec![target.clone()]),
433        serde_json::Value::Array(items) => Some(
434            items
435                .iter()
436                .filter_map(|item| item.as_str().map(str::to_owned))
437                .collect(),
438        ),
439        _ => None,
440    }
441}
442
443/// The file an `extends` target names: a relative path from the extending
444/// file, or a package path under the root `node_modules`.
445fn resolve_extends_target(root: &Path, from: &Path, target: &str) -> Option<PathBuf> {
446    let base = if target.starts_with('.') || Path::new(target).is_absolute() {
447        from.parent()?.join(target)
448    } else {
449        root.join("node_modules").join(target)
450    };
451    let candidates = [
452        base.clone(),
453        base.with_extension("json"),
454        base.join("tsconfig.json"),
455    ];
456    candidates.into_iter().find(|candidate| candidate.is_file())
457}
458
459/// The hash key of an `extends` file: root-relative when inside the root,
460/// else `extends:` plus the file name, which carries no machine path.
461fn extends_key(root: &Path, path: &Path) -> String {
462    path.strip_prefix(root).map_or_else(
463        |_| {
464            format!(
465                "extends:{}",
466                path.file_name()
467                    .map(|name| name.to_string_lossy().into_owned())
468                    .unwrap_or_default()
469            )
470        },
471        |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
472    )
473}
474
475/// Whether the rule of `id` is `off` in the top-level rules or in any
476/// override. An override is file-scoped and the id carries no path, so any
477/// override that turns the rule off counts.
478fn finding_id_rule_is_off(id: &str, config: &ResolvedConfig) -> bool {
479    let Some(kind) = id
480        .split(':')
481        .nth(1)
482        .and_then(fallow_types::suppress::IssueKind::parse)
483    else {
484        return false;
485    };
486    let off = |rules: &RulesConfig| rules.severity_for_kind(kind) == Severity::Off;
487    off(&config.rules)
488        || config.overrides.iter().any(|entry| {
489            let mut rules = config.rules.clone();
490            rules.apply_partial(&entry.rules);
491            off(&rules)
492        })
493}
494
495/// Scope dead-code results to the union of the given workspace roots.
496///
497/// The full cross-workspace graph is still built before this helper runs, so
498/// cross-package imports are resolved. Only reported findings are narrowed.
499pub fn filter_to_workspaces(results: &mut AnalysisResults, ws_roots: &[PathBuf]) {
500    let any_under = |path: &Path| ws_roots.iter().any(|root| path.starts_with(root));
501    let pkg_jsons = ws_roots
502        .iter()
503        .map(|root| root.join("package.json"))
504        .collect::<Vec<_>>();
505    let in_pkg_jsons = |path: &Path| pkg_jsons.iter().any(|pkg| path == pkg);
506
507    filter_workspace_source_findings(results, &any_under);
508    filter_workspace_dependency_findings(results, &any_under, &in_pkg_jsons);
509    filter_workspace_graph_findings(results, &any_under);
510    filter_workspace_policy_findings(results, &any_under);
511}
512
513/// The scope of one dead-code run, as the surface resolved it.
514///
515/// Every field is optional. A field that is `None` does not narrow the run.
516#[derive(Debug, Clone, Copy)]
517pub struct DeadCodeScope<'a> {
518    /// `--workspace`, `--changed-workspaces` and a positional path: the union
519    /// of these roots.
520    pub workspace_roots: Option<&'a [PathBuf]>,
521    /// The resolved change scope: a global changed-file set or the
522    /// configured package baselines.
523    pub changes: Option<&'a ChangeScope>,
524    /// A unified diff, with the root that finding paths resolve against.
525    pub diff: Option<(&'a fallow_output::DiffIndex, &'a Path)>,
526    /// `--file`: the only files to report. Dependency findings are dropped,
527    /// because a file list does not own a manifest.
528    pub files: Option<&'a FxHashSet<PathBuf>>,
529}
530
531/// Narrow dead-code results to the scope of the run.
532///
533/// The CLI, the programmatic API and the MCP typed path call this one function,
534/// so a scope narrows the same way on every surface. The filters run in this
535/// order: workspace roots, changed files, the diff, the file list. Then the
536/// configured `ignoreFindings` patterns run again, because the scope filters
537/// remove owners from a finding with several owners (`duplicate_exports`). A
538/// finding that only ignored owners hold after the scope is hidden, as the
539/// "hidden only when every owner matches" rule says.
540pub fn apply_scope(
541    results: &mut AnalysisResults,
542    scope: &DeadCodeScope<'_>,
543    config: &ResolvedConfig,
544) {
545    // Chain scopes depend on source-owned enforcement, which must resolve before filtering.
546    crate::effective_severity::apply_prop_drilling_severities(results, config);
547    if let Some(roots) = scope.workspace_roots {
548        filter_to_workspaces(results, roots);
549    }
550    if let Some(changes) = scope.changes {
551        changes.retain_dead_code(results);
552    }
553    if let Some((diff, root)) = scope.diff {
554        crate::diff_scope::filter_dead_code_by_diff(results, diff, root);
555    }
556    if let Some(files) = scope.files {
557        filter_by_changed_files(results, files);
558        clear_dependency_findings(results);
559    }
560    filter_configured_ignored_findings(results, config);
561}
562
563fn clear_dependency_findings(results: &mut AnalysisResults) {
564    results.unused_dependencies.clear();
565    results.unused_dev_dependencies.clear();
566    results.unused_optional_dependencies.clear();
567    results.type_only_dependencies.clear();
568    results.test_only_dependencies.clear();
569    results.dev_dependencies_in_production.clear();
570}
571
572/// Scope dead-code results to findings affected by changed files.
573#[expect(
574    clippy::implicit_hasher,
575    reason = "fallow standardizes on FxHashSet across the workspace"
576)]
577pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
578    crate::changed_files::filter_results_by_changed_files(results, changed_files);
579}
580
581/// Apply configured source-owned finding exclusions to an analysis result.
582///
583/// Analysis stages that append findings after the engine pipeline, such as
584/// type-aware reconciliation, must call this before exposing their final
585/// result.
586pub fn filter_configured_ignored_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
587    if config.ignore_findings.is_empty() {
588        return;
589    }
590
591    results.remove_ignored_dead_code_findings(|path| {
592        config.ignore_findings.is_ignored_path(&config.root, path)
593    });
594}
595
596fn filter_workspace_source_findings(
597    results: &mut AnalysisResults,
598    any_under: &dyn Fn(&Path) -> bool,
599) {
600    results
601        .unused_files
602        .retain(|finding| any_under(&finding.file.path));
603    results
604        .unused_exports
605        .retain(|finding| any_under(&finding.export.path));
606    results
607        .unused_types
608        .retain(|finding| any_under(&finding.export.path));
609    results
610        .private_type_leaks
611        .retain(|finding| any_under(&finding.leak.path));
612    results
613        .deprecated_exports_in_use
614        .retain(|finding| any_under(&finding.export.path));
615    results
616        .unused_enum_members
617        .retain(|finding| any_under(&finding.member.path));
618    results
619        .unused_class_members
620        .retain(|finding| any_under(&finding.member.path));
621    results
622        .unused_store_members
623        .retain(|finding| any_under(&finding.member.path));
624    results
625        .unprovided_injects
626        .retain(|finding| any_under(&finding.inject.path));
627    results
628        .unrendered_components
629        .retain(|finding| any_under(&finding.component.path));
630    results
631        .unused_component_props
632        .retain(|finding| any_under(&finding.prop.path));
633    results
634        .absent_component_props
635        .retain(|finding| any_under(&finding.prop.path));
636    results
637        .unused_component_emits
638        .retain(|finding| any_under(&finding.emit.path));
639    results
640        .unused_component_inputs
641        .retain(|finding| any_under(&finding.input.path));
642    results
643        .unused_component_outputs
644        .retain(|finding| any_under(&finding.output.path));
645    results
646        .unused_svelte_events
647        .retain(|finding| any_under(&finding.event.path));
648    results
649        .unused_effect_layers
650        .retain(|finding| any_under(&finding.layer.path));
651    results
652        .unused_tagged_errors
653        .retain(|finding| any_under(&finding.error.path));
654    results
655        .unproduced_catch_tags
656        .retain(|finding| any_under(&finding.catch_tag.path));
657    results
658        .unused_server_actions
659        .retain(|finding| any_under(&finding.action.path));
660    results
661        .unused_load_data_keys
662        .retain(|finding| any_under(&finding.key.path));
663    results
664        .unresolved_imports
665        .retain(|finding| any_under(&finding.import.path));
666}
667
668fn filter_workspace_dependency_findings(
669    results: &mut AnalysisResults,
670    any_under: &dyn Fn(&Path) -> bool,
671    in_pkg_jsons: &dyn Fn(&Path) -> bool,
672) {
673    results
674        .unused_dependencies
675        .retain(|finding| in_pkg_jsons(&finding.dep.path));
676    results
677        .unused_dev_dependencies
678        .retain(|finding| in_pkg_jsons(&finding.dep.path));
679    results
680        .unused_optional_dependencies
681        .retain(|finding| in_pkg_jsons(&finding.dep.path));
682    results
683        .type_only_dependencies
684        .retain(|finding| in_pkg_jsons(&finding.dep.path));
685    results
686        .test_only_dependencies
687        .retain(|finding| in_pkg_jsons(&finding.dep.path));
688    results
689        .dev_dependencies_in_production
690        .retain(|finding| in_pkg_jsons(&finding.dep.path));
691
692    results.unlisted_dependencies.retain(|finding| {
693        finding
694            .dep
695            .imported_from
696            .iter()
697            .any(|source| any_under(&source.path))
698    });
699    results.unused_dependency_overrides.clear();
700    results.misconfigured_dependency_overrides.clear();
701}
702
703fn filter_workspace_graph_findings(
704    results: &mut AnalysisResults,
705    any_under: &dyn Fn(&Path) -> bool,
706) {
707    for duplicate in &mut results.duplicate_exports {
708        duplicate
709            .export
710            .locations
711            .retain(|location| any_under(&location.path));
712    }
713    results
714        .duplicate_exports
715        .retain(|duplicate| duplicate.export.locations.len() >= 2);
716
717    results
718        .circular_dependencies
719        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
720
721    results
722        .re_export_cycles
723        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
724
725    results
726        .package_cycles
727        .retain(|cycle| cycle.cycle.edges.iter().any(|edge| any_under(&edge.path)));
728}
729
730fn filter_workspace_policy_findings(
731    results: &mut AnalysisResults,
732    any_under: &dyn Fn(&Path) -> bool,
733) {
734    results
735        .boundary_violations
736        .retain(|finding| any_under(&finding.violation.from_path));
737    results
738        .boundary_coverage_violations
739        .retain(|finding| any_under(&finding.violation.path));
740    results
741        .boundary_call_violations
742        .retain(|finding| any_under(&finding.violation.path));
743    results
744        .policy_violations
745        .retain(|finding| any_under(&finding.violation.path));
746
747    results
748        .stale_suppressions
749        .retain(|finding| any_under(&finding.path));
750
751    results
752        .security_findings
753        .retain(|finding| any_under(&finding.path));
754    results
755        .security_unresolved_callee_diagnostics
756        .retain(|finding| any_under(&finding.path));
757
758    results.unused_catalog_entries.clear();
759    results.empty_catalog_groups.clear();
760    results
761        .unresolved_catalog_references
762        .retain(|finding| any_under(&finding.reference.path));
763
764    results
765        .invalid_client_exports
766        .retain(|finding| any_under(&finding.export.path));
767
768    results
769        .mixed_client_server_barrels
770        .retain(|finding| any_under(&finding.barrel.path));
771
772    results
773        .misplaced_directives
774        .retain(|finding| any_under(&finding.directive_site.path));
775
776    results
777        .route_collisions
778        .retain(|finding| any_under(&finding.collision.path));
779
780    results
781        .dynamic_segment_name_conflicts
782        .retain(|finding| any_under(&finding.conflict.path));
783}
784
785/// Remove findings whose effective severity is `Off` from an analysis result.
786///
787/// Every surface that reports findings runs this pass: the `check` command
788/// (which also serves `dead-code` and the CLI audit), the editor analysis path
789/// behind inline diagnostics and the sidebar, and the programmatic runtime
790/// behind the MCP tools, the decision surface and the Node bindings. Each of
791/// them runs it at the same two points, once over the freshly analyzed set and
792/// once after type-aware reconciliation, because reconciliation can append
793/// findings. The pass removes findings and writes the gate severity of each
794/// finding that stays, so the second run is idempotent when nothing was
795/// appended.
796///
797/// When overrides are configured, per-file rule resolution is used for
798/// file-scoped issue types. Circular dependencies resolve against every file in
799/// the cycle. Non-file-scoped issues (unused deps, unlisted deps, duplicate
800/// exports) use the base rules only.
801pub fn apply_rule_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
802    let rules = &config.rules;
803    let has_overrides = !config.overrides.is_empty();
804
805    if has_overrides {
806        apply_file_override_rules(results, config);
807        apply_boundary_override_rules(results, config);
808    } else {
809        apply_base_file_rules(results, rules);
810    }
811
812    apply_base_collection_rules(results, rules);
813    hide_cascade_findings(results, config);
814    apply_effective_severities(results, config);
815}
816
817/// Hide the export and member findings of unused files.
818///
819/// Deleting an unused file removes every unused export, unused type, unused
820/// class member and unused enum member in it, so these findings add no
821/// information to the report. The pass runs inside the rule pass, so every
822/// surface that reports findings hides the same set.
823///
824/// A finding is hidden only when the report lists its file in
825/// `unused_files`, or when an inline `fallow-ignore-file unused-file` comment
826/// suppressed that file. A scope, an issue-type selection without unused
827/// files, an `ignoreFindings` pattern or an `off` rule that removes the file
828/// from the report therefore hides nothing in that file. The issue-type
829/// filters clear the suppressed files too, so they must run before this pass.
830/// The baseline runs after this pass, so a baseline entry for the unused file
831/// does not make its findings visible again. `showCascade` keeps every
832/// finding.
833pub fn hide_cascade_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
834    if config.show_cascade {
835        return;
836    }
837    let reported = results.unused_files.iter().map(|f| &f.file.path);
838    let suppressed = results
839        .cascade
840        .suppressed_unused_files
841        .iter()
842        .filter(|path| config.resolve_rules_for_path(path).unused_files != Severity::Off);
843    let covered: FxHashSet<PathBuf> = reported.chain(suppressed).cloned().collect();
844    if covered.is_empty() {
845        return;
846    }
847    results.hide_cascade_findings(&|path| covered.contains(path));
848}
849
850fn apply_base_collection_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
851    if rules.unused_dependencies == Severity::Off {
852        results.unused_dependencies.clear();
853    }
854    if rules.unused_dev_dependencies == Severity::Off {
855        results.unused_dev_dependencies.clear();
856    }
857    if rules.unused_optional_dependencies == Severity::Off {
858        results.unused_optional_dependencies.clear();
859    }
860    if rules.unlisted_dependencies == Severity::Off {
861        results.unlisted_dependencies.clear();
862    }
863    if rules.duplicate_exports == Severity::Off {
864        results.duplicate_exports.clear();
865    }
866    if rules.type_only_dependencies == Severity::Off {
867        results.type_only_dependencies.clear();
868    }
869    if rules.test_only_dependencies == Severity::Off {
870        results.test_only_dependencies.clear();
871    }
872    if rules.dev_dependencies_in_production == Severity::Off {
873        results.dev_dependencies_in_production.clear();
874    }
875    if rules.circular_dependencies == Severity::Off {
876        results.circular_dependencies.clear();
877    }
878    if rules.re_export_cycle == Severity::Off {
879        results.re_export_cycles.clear();
880    }
881    if rules.package_cycle == Severity::Off {
882        results.package_cycles.clear();
883    }
884    if rules.boundary_violation == Severity::Off {
885        results.boundary_violations.clear();
886        results.boundary_coverage_violations.clear();
887        results.boundary_call_violations.clear();
888    }
889    if rules.policy_violation == Severity::Off {
890        results.policy_violations.clear();
891    }
892    if rules.unused_catalog_entries == Severity::Off {
893        results.unused_catalog_entries.clear();
894    }
895    if rules.empty_catalog_groups == Severity::Off {
896        results.empty_catalog_groups.clear();
897    }
898    if rules.unresolved_catalog_references == Severity::Off {
899        results.unresolved_catalog_references.clear();
900    }
901    if rules.unused_dependency_overrides == Severity::Off {
902        results.unused_dependency_overrides.clear();
903    }
904    if rules.misconfigured_dependency_overrides == Severity::Off {
905        results.misconfigured_dependency_overrides.clear();
906    }
907}
908
909fn apply_file_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
910    apply_dead_code_override_rules(results, config);
911    apply_catalog_override_rules(results, config);
912    apply_framework_override_rules(results, config);
913    apply_circular_override_rules(results, config);
914}
915
916fn apply_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
917    apply_core_dead_code_override_rules(results, config);
918    apply_component_dead_code_override_rules(results, config);
919}
920
921/// Retain core (non-component) dead-code findings whose per-file rule is not Off.
922fn apply_core_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
923    results
924        .unused_files
925        .retain(|f| config.resolve_rules_for_path(&f.file.path).unused_files != Severity::Off);
926    results
927        .unused_exports
928        .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_exports != Severity::Off);
929    results
930        .unused_types
931        .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_types != Severity::Off);
932    results.private_type_leaks.retain(|e| {
933        config
934            .resolve_rules_for_path(&e.leak.path)
935            .private_type_leaks
936            != Severity::Off
937    });
938    results.deprecated_exports_in_use.retain(|e| {
939        config
940            .resolve_rules_for_path(&e.export.path)
941            .deprecated_exports_in_use
942            != Severity::Off
943    });
944    results.unused_enum_members.retain(|m| {
945        config
946            .resolve_rules_for_path(&m.member.path)
947            .unused_enum_members
948            != Severity::Off
949    });
950    results.unused_class_members.retain(|m| {
951        config
952            .resolve_rules_for_path(&m.member.path)
953            .unused_class_members
954            != Severity::Off
955    });
956    results.unused_store_members.retain(|m| {
957        config
958            .resolve_rules_for_path(&m.member.path)
959            .unused_store_members
960            != Severity::Off
961    });
962    results.unprovided_injects.retain(|f| {
963        config
964            .resolve_rules_for_path(&f.inject.path)
965            .unprovided_injects
966            != Severity::Off
967    });
968    results.unresolved_imports.retain(|i| {
969        config
970            .resolve_rules_for_path(&i.import.path)
971            .unresolved_imports
972            != Severity::Off
973    });
974}
975
976/// Retain component-shaped dead-code findings whose per-file rule is not Off.
977fn apply_component_dead_code_override_rules(
978    results: &mut AnalysisResults,
979    config: &ResolvedConfig,
980) {
981    results.unrendered_components.retain(|c| {
982        config
983            .resolve_rules_for_path(&c.component.path)
984            .unrendered_components
985            != Severity::Off
986    });
987    results.unused_component_props.retain(|p| {
988        config
989            .resolve_rules_for_path(&p.prop.path)
990            .unused_component_props
991            != Severity::Off
992    });
993    results.absent_component_props.retain(|p| {
994        config
995            .resolve_rules_for_path(&p.prop.path)
996            .absent_component_props
997            != Severity::Off
998    });
999    results.unused_component_emits.retain(|e| {
1000        config
1001            .resolve_rules_for_path(&e.emit.path)
1002            .unused_component_emits
1003            != Severity::Off
1004    });
1005    results.unused_component_inputs.retain(|i| {
1006        config
1007            .resolve_rules_for_path(&i.input.path)
1008            .unused_component_inputs
1009            != Severity::Off
1010    });
1011    results.unused_component_outputs.retain(|o| {
1012        config
1013            .resolve_rules_for_path(&o.output.path)
1014            .unused_component_outputs
1015            != Severity::Off
1016    });
1017    results.unused_svelte_events.retain(|e| {
1018        config
1019            .resolve_rules_for_path(&e.event.path)
1020            .unused_svelte_events
1021            != Severity::Off
1022    });
1023    results.unused_effect_layers.retain(|e| {
1024        config
1025            .resolve_rules_for_path(&e.layer.path)
1026            .unused_effect_layers
1027            != Severity::Off
1028    });
1029    results.unused_tagged_errors.retain(|e| {
1030        config
1031            .resolve_rules_for_path(&e.error.path)
1032            .unused_tagged_errors
1033            != Severity::Off
1034    });
1035    results.unproduced_catch_tags.retain(|e| {
1036        config
1037            .resolve_rules_for_path(&e.catch_tag.path)
1038            .unproduced_catch_tags
1039            != Severity::Off
1040    });
1041    results.unused_server_actions.retain(|a| {
1042        config
1043            .resolve_rules_for_path(&a.action.path)
1044            .unused_server_actions
1045            != Severity::Off
1046    });
1047    results.unused_load_data_keys.retain(|k| {
1048        config
1049            .resolve_rules_for_path(&k.key.path)
1050            .unused_load_data_keys
1051            != Severity::Off
1052    });
1053}
1054
1055fn apply_catalog_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1056    results.stale_suppressions.retain(|s| {
1057        let rules = config.resolve_rules_for_path(&s.path);
1058        if s.missing_reason {
1059            rules.require_suppression_reason != Severity::Off
1060        } else {
1061            rules.stale_suppressions != Severity::Off
1062        }
1063    });
1064    results.unresolved_catalog_references.retain(|r| {
1065        config
1066            .resolve_rules_for_path(&r.reference.path)
1067            .unresolved_catalog_references
1068            != Severity::Off
1069    });
1070    results.empty_catalog_groups.retain(|g| {
1071        config
1072            .resolve_rules_for_path(&g.group.path)
1073            .empty_catalog_groups
1074            != Severity::Off
1075    });
1076    results.unused_dependency_overrides.retain(|o| {
1077        config
1078            .resolve_rules_for_path(&o.entry.path)
1079            .unused_dependency_overrides
1080            != Severity::Off
1081    });
1082    results.misconfigured_dependency_overrides.retain(|o| {
1083        config
1084            .resolve_rules_for_path(&o.entry.path)
1085            .misconfigured_dependency_overrides
1086            != Severity::Off
1087    });
1088}
1089
1090fn apply_framework_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1091    results.invalid_client_exports.retain(|e| {
1092        config
1093            .resolve_rules_for_path(&e.export.path)
1094            .invalid_client_export
1095            != Severity::Off
1096    });
1097    results.mixed_client_server_barrels.retain(|b| {
1098        config
1099            .resolve_rules_for_path(&b.barrel.path)
1100            .mixed_client_server_barrel
1101            != Severity::Off
1102    });
1103    results.misplaced_directives.retain(|d| {
1104        config
1105            .resolve_rules_for_path(&d.directive_site.path)
1106            .misplaced_directive
1107            != Severity::Off
1108    });
1109    results.route_collisions.retain(|c| {
1110        config
1111            .resolve_rules_for_path(&c.collision.path)
1112            .route_collision
1113            != Severity::Off
1114    });
1115    results.dynamic_segment_name_conflicts.retain(|c| {
1116        config
1117            .resolve_rules_for_path(&c.conflict.path)
1118            .dynamic_segment_name_conflict
1119            != Severity::Off
1120    });
1121}
1122
1123fn apply_circular_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1124    results.circular_dependencies.retain(|c| {
1125        c.cycle
1126            .files
1127            .iter()
1128            .any(|path| config.resolve_rules_for_path(path).circular_dependencies != Severity::Off)
1129    });
1130}
1131
1132fn apply_base_file_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
1133    clear_base_core_dead_code(results, rules);
1134    clear_base_component_dead_code(results, rules);
1135    clear_base_suppression_and_framework(results, rules);
1136}
1137
1138/// Clear core (non-component) dead-code findings whose base rule is Off.
1139fn clear_base_core_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1140    if rules.unused_files == Severity::Off {
1141        results.unused_files.clear();
1142    }
1143    if rules.unused_exports == Severity::Off {
1144        results.unused_exports.clear();
1145    }
1146    if rules.unused_types == Severity::Off {
1147        results.unused_types.clear();
1148    }
1149    if rules.private_type_leaks == Severity::Off {
1150        results.private_type_leaks.clear();
1151    }
1152    if rules.deprecated_exports_in_use == Severity::Off {
1153        results.deprecated_exports_in_use.clear();
1154    }
1155    if rules.unused_enum_members == Severity::Off {
1156        results.unused_enum_members.clear();
1157    }
1158    if rules.unused_class_members == Severity::Off {
1159        results.unused_class_members.clear();
1160    }
1161    if rules.unused_store_members == Severity::Off {
1162        results.unused_store_members.clear();
1163    }
1164    if rules.unprovided_injects == Severity::Off {
1165        results.unprovided_injects.clear();
1166    }
1167    if rules.unresolved_imports == Severity::Off {
1168        results.unresolved_imports.clear();
1169    }
1170}
1171
1172/// Clear component-shaped dead-code findings whose base rule is Off.
1173fn clear_base_component_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1174    if rules.unrendered_components == Severity::Off {
1175        results.unrendered_components.clear();
1176    }
1177    if rules.unused_component_props == Severity::Off {
1178        results.unused_component_props.clear();
1179    }
1180    if rules.absent_component_props == Severity::Off {
1181        results.absent_component_props.clear();
1182    }
1183    if rules.unused_component_emits == Severity::Off {
1184        results.unused_component_emits.clear();
1185    }
1186    if rules.unused_component_inputs == Severity::Off {
1187        results.unused_component_inputs.clear();
1188    }
1189    if rules.unused_component_outputs == Severity::Off {
1190        results.unused_component_outputs.clear();
1191    }
1192    if rules.unused_svelte_events == Severity::Off {
1193        results.unused_svelte_events.clear();
1194    }
1195    if rules.unused_effect_layers == Severity::Off {
1196        results.unused_effect_layers.clear();
1197    }
1198    if rules.unused_tagged_errors == Severity::Off {
1199        results.unused_tagged_errors.clear();
1200    }
1201    if rules.unproduced_catch_tags == Severity::Off {
1202        results.unproduced_catch_tags.clear();
1203    }
1204    if rules.unused_server_actions == Severity::Off {
1205        results.unused_server_actions.clear();
1206    }
1207    if rules.unused_load_data_keys == Severity::Off {
1208        results.unused_load_data_keys.clear();
1209    }
1210}
1211
1212/// Apply base stale-suppression retention and clear framework findings whose
1213/// base rule is Off.
1214fn clear_base_suppression_and_framework(results: &mut AnalysisResults, rules: &RulesConfig) {
1215    results.stale_suppressions.retain(|s| {
1216        if s.missing_reason {
1217            rules.require_suppression_reason != Severity::Off
1218        } else {
1219            rules.stale_suppressions != Severity::Off
1220        }
1221    });
1222    if rules.invalid_client_export == Severity::Off {
1223        results.invalid_client_exports.clear();
1224    }
1225    if rules.mixed_client_server_barrel == Severity::Off {
1226        results.mixed_client_server_barrels.clear();
1227    }
1228    if rules.misplaced_directive == Severity::Off {
1229        results.misplaced_directives.clear();
1230    }
1231    if rules.route_collision == Severity::Off {
1232        results.route_collisions.clear();
1233    }
1234    if rules.dynamic_segment_name_conflict == Severity::Off {
1235        results.dynamic_segment_name_conflicts.clear();
1236    }
1237}
1238
1239fn apply_boundary_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1240    results.boundary_violations.retain(|v| {
1241        config
1242            .resolve_rules_for_path(&v.violation.from_path)
1243            .boundary_violation
1244            != Severity::Off
1245    });
1246    results.boundary_coverage_violations.retain(|v| {
1247        config
1248            .resolve_rules_for_path(&v.violation.path)
1249            .boundary_violation
1250            != Severity::Off
1251    });
1252    results.boundary_call_violations.retain(|v| {
1253        config
1254            .resolve_rules_for_path(&v.violation.path)
1255            .boundary_violation
1256            != Severity::Off
1257    });
1258    results.policy_violations.retain(|v| {
1259        config
1260            .resolve_rules_for_path(&v.violation.path)
1261            .policy_violation
1262            != Severity::Off
1263    });
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use std::path::PathBuf;
1269
1270    use super::*;
1271    use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1272    use fallow_types::output_dead_code::{
1273        BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
1274        PrivateTypeLeakFinding, UnlistedDependencyFinding, UnusedClassMemberFinding,
1275        UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1276    };
1277    use fallow_types::results::{
1278        BoundaryViolation, CircularDependency, DuplicateExport, DuplicateLocation, ImportSite,
1279        PrivateTypeLeak, UnlistedDependency, UnusedExport, UnusedFile, UnusedMember,
1280    };
1281
1282    #[test]
1283    fn finding_id_filter_keeps_request_order_and_drops_duplicates() {
1284        let a = "dc1:unused-export:0123456789abcdef";
1285        let b = "dc1:unused-file:0123456789abcdef~1";
1286
1287        let filter = FindingIdFilter::parse(&[b, a, b])
1288            .expect("valid ids")
1289            .expect("a filter");
1290
1291        assert_eq!(filter.requested, vec![b.to_owned(), a.to_owned()]);
1292        assert_eq!(FindingIdFilter::parse::<&str>(&[]), Ok(None));
1293    }
1294
1295    #[test]
1296    fn normalized_text_ignores_line_endings_and_trailing_newlines() {
1297        assert_eq!(normalized_text(b"dist\r\nbuild\r\n"), "dist\nbuild");
1298        assert_eq!(normalized_text(b"dist\nbuild\n\n"), "dist\nbuild");
1299        assert_eq!(normalized_text(b"dist\nbuild"), "dist\nbuild");
1300        assert_ne!(
1301            normalized_text(b"dist\nbuild"),
1302            normalized_text(b"dist\nbuilt")
1303        );
1304    }
1305
1306    #[test]
1307    fn a_crlf_checkout_has_the_same_fingerprint_as_an_lf_checkout() {
1308        let write_project = |line_end: &str| {
1309            let dir = tempfile::tempdir().expect("project");
1310            let root = dir.path();
1311            std::fs::create_dir_all(root.join("src")).expect("src");
1312            std::fs::write(
1313                root.join(".gitignore"),
1314                format!("dist{line_end}coverage{line_end}"),
1315            )
1316            .expect("gitignore");
1317            std::fs::write(
1318                root.join("package.json"),
1319                format!("{{{line_end}  \"name\": \"crlf\"{line_end}}}{line_end}"),
1320            )
1321            .expect("package.json");
1322            dir
1323        };
1324        let config_at = |root: &std::path::Path| {
1325            fallow_config::FallowConfig::default().resolve(
1326                root.to_path_buf(),
1327                fallow_config::OutputFormat::Json,
1328                1,
1329                true,
1330                true,
1331                None,
1332            )
1333        };
1334        let lf = write_project("\n");
1335        let crlf = write_project("\r\n");
1336
1337        assert_eq!(
1338            analysis_fingerprint_for_version(&config_at(lf.path()), "1.0.0"),
1339            analysis_fingerprint_for_version(&config_at(crlf.path()), "1.0.0")
1340        );
1341    }
1342
1343    #[test]
1344    fn a_tsconfig_extends_target_outside_the_walk_is_an_input() {
1345        let dir = tempfile::tempdir().expect("project");
1346        let root = dir.path();
1347        std::fs::create_dir_all(root.join(".config")).expect("hidden dir");
1348        std::fs::write(
1349            root.join("tsconfig.json"),
1350            r#"{ "extends": "./.config/tsconfig.base.json" }"#,
1351        )
1352        .expect("tsconfig");
1353        let base = root.join(".config/tsconfig.base.json");
1354        std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "." } }"#).expect("base");
1355        let config = fallow_config::FallowConfig::default().resolve(
1356            root.to_path_buf(),
1357            fallow_config::OutputFormat::Json,
1358            1,
1359            true,
1360            true,
1361            None,
1362        );
1363        let before = analysis_fingerprint_for_version(&config, "1.0.0");
1364
1365        std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "src" } }"#).expect("edit");
1366
1367        assert_ne!(analysis_fingerprint_for_version(&config, "1.0.0"), before);
1368    }
1369
1370    #[test]
1371    fn analysis_fingerprint_depends_on_the_version_and_not_on_the_root() {
1372        let config_at = |root: &std::path::Path| {
1373            fallow_config::FallowConfig::default().resolve(
1374                root.to_path_buf(),
1375                fallow_config::OutputFormat::Json,
1376                1,
1377                true,
1378                true,
1379                None,
1380            )
1381        };
1382        let a = tempfile::tempdir().expect("project a");
1383        let b = tempfile::tempdir().expect("project b");
1384        let config_a = config_at(a.path());
1385        let config_b = config_at(b.path());
1386
1387        let base = analysis_fingerprint_for_version(&config_a, "1.0.0");
1388        assert!(base.starts_with("af1:"), "{base}");
1389        assert_eq!(base, analysis_fingerprint_for_version(&config_a, "1.0.0"));
1390        assert_eq!(base, analysis_fingerprint_for_version(&config_b, "1.0.0"));
1391        assert_ne!(base, analysis_fingerprint_for_version(&config_a, "1.0.1"));
1392    }
1393
1394    #[test]
1395    fn finding_id_filter_refuses_a_malformed_id() {
1396        let error = FindingIdFilter::parse(&["dc1:unused-export:helper"])
1397            .expect_err("malformed id refused");
1398
1399        assert!(error.contains("dc1:unused-export:helper"), "{error}");
1400    }
1401
1402    #[test]
1403    fn finding_id_rule_is_off_reads_rules_and_overrides() {
1404        let id = "dc1:unused-export:0123456789abcdef";
1405        let mut config = fallow_config::FallowConfig::default().resolve(
1406            PathBuf::from("/repo"),
1407            fallow_config::OutputFormat::Json,
1408            1,
1409            true,
1410            true,
1411            None,
1412        );
1413        assert!(!finding_id_rule_is_off(id, &config));
1414
1415        config.rules.unused_exports = Severity::Off;
1416        assert!(finding_id_rule_is_off(id, &config));
1417        assert!(!finding_id_rule_is_off(
1418            "dc1:unused-file:0123456789abcdef",
1419            &config
1420        ));
1421
1422        let with_override = serde_json::from_str::<fallow_config::FallowConfig>(
1423            r#"{"overrides":[{"files":["src/a.ts"],"rules":{"unused-exports":"off"}}]}"#,
1424        )
1425        .expect("config parses")
1426        .resolve(
1427            PathBuf::from("/repo"),
1428            fallow_config::OutputFormat::Json,
1429            1,
1430            true,
1431            true,
1432            None,
1433        );
1434        assert!(finding_id_rule_is_off(id, &with_override));
1435    }
1436
1437    #[test]
1438    fn workspace_filter_keeps_findings_under_workspace_root() {
1439        let root = PathBuf::from("/repo/packages/app");
1440        let mut results = AnalysisResults::default();
1441        results
1442            .unused_files
1443            .push(UnusedFileFinding::with_actions(UnusedFile {
1444                path: root.join("src/unused.ts"),
1445            }));
1446        results
1447            .unused_files
1448            .push(UnusedFileFinding::with_actions(UnusedFile {
1449                path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
1450            }));
1451
1452        filter_to_workspaces(&mut results, std::slice::from_ref(&root));
1453
1454        assert_eq!(results.unused_files.len(), 1);
1455        assert_eq!(
1456            results.unused_files[0].file.path,
1457            root.join("src/unused.ts")
1458        );
1459    }
1460
1461    #[test]
1462    fn configured_filter_removes_findings_added_after_engine_analysis() {
1463        let project = tempfile::tempdir().expect("project");
1464        let config = serde_json::from_str::<fallow_config::FallowConfig>(
1465            r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1466        )
1467        .expect("config parses")
1468        .resolve(
1469            project.path().to_path_buf(),
1470            fallow_config::OutputFormat::Human,
1471            1,
1472            true,
1473            true,
1474            None,
1475        );
1476        let mut results = AnalysisResults::default();
1477        results
1478            .private_type_leaks
1479            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1480                path: project.path().join("src/hidden.ts"),
1481                export_name: "publicApi".to_string(),
1482                type_name: "PrivateShape".to_string(),
1483                line: 1,
1484                col: 0,
1485                span_start: 0,
1486                semantic: None,
1487            }));
1488        results
1489            .boundary_violations
1490            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1491                from_path: project.path().join("src/hidden.ts"),
1492                to_path: project.path().join("src/data.ts"),
1493                from_zone: "ui".to_string(),
1494                to_zone: "data".to_string(),
1495                import_specifier: "./data".to_string(),
1496                line: 1,
1497                col: 0,
1498                via_path: None,
1499            }));
1500
1501        filter_configured_ignored_findings(&mut results, &config);
1502
1503        assert!(results.private_type_leaks.is_empty());
1504        assert_eq!(results.boundary_violations.len(), 1);
1505    }
1506
1507    fn unmatched_patterns(diagnostics: &[WorkspaceDiagnostic]) -> Vec<(&'static str, String)> {
1508        diagnostics
1509            .iter()
1510            .filter_map(|diagnostic| match &diagnostic.kind {
1511                WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => {
1512                    Some(("ignoreFindings", pattern.clone()))
1513                }
1514                WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => {
1515                    Some(("ignoreDependencies", pattern.clone()))
1516                }
1517                _ => None,
1518            })
1519            .collect()
1520    }
1521
1522    fn write_manifest(root: &Path, dependencies: &str) {
1523        std::fs::write(
1524            root.join("package.json"),
1525            format!(
1526                r#"{{"name":"app","private":true,"main":"src/index.ts","dependencies":{dependencies}}}"#
1527            ),
1528        )
1529        .expect("write package.json");
1530    }
1531
1532    #[test]
1533    fn config_pattern_diagnostics_describe_the_latest_pass_only() {
1534        let project = tempfile::tempdir().expect("project");
1535        let root = project.path();
1536        std::fs::create_dir_all(root.join("src")).expect("create src");
1537        std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("write entry");
1538        std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n")
1539            .expect("write orphan");
1540        write_manifest(root, r#"{"@acme/lib":"1.0.0"}"#);
1541        let config = serde_json::from_str::<fallow_config::FallowConfig>(
1542            r#"{"ignoreDependencies":["@acme/*","@typo/*"],"ignoreFindings":["src/legcy/**"]}"#,
1543        )
1544        .expect("config parses")
1545        .resolve(
1546            root.to_path_buf(),
1547            fallow_config::OutputFormat::Human,
1548            1,
1549            true,
1550            true,
1551            None,
1552        );
1553
1554        crate::session::AnalysisSession::from_resolved_config(config.clone())
1555            .expect("session")
1556            .analyze_dead_code()
1557            .expect("first pass");
1558        assert_eq!(
1559            unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1560            vec![
1561                ("ignoreFindings", "src/legcy/**".to_owned()),
1562                ("ignoreDependencies", "@typo/*".to_owned()),
1563            ]
1564        );
1565        assert_eq!(
1566            unmatched_patterns(&config_pattern_diagnostics(&config, false)),
1567            vec![("ignoreFindings", "src/legcy/**".to_owned())],
1568            "a run that reports no dependency findings omits the dependency globs"
1569        );
1570
1571        // A long-lived process keeps the config. The second pass must not
1572        // inherit the `@acme/*` hit of the first pass.
1573        write_manifest(root, r#"{"react":"1.0.0"}"#);
1574        crate::session::AnalysisSession::from_resolved_config(config.clone())
1575            .expect("session")
1576            .analyze_dead_code()
1577            .expect("second pass");
1578        assert_eq!(
1579            unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1580            vec![
1581                ("ignoreFindings", "src/legcy/**".to_owned()),
1582                ("ignoreDependencies", "@acme/*".to_owned()),
1583                ("ignoreDependencies", "@typo/*".to_owned()),
1584            ]
1585        );
1586    }
1587
1588    fn config_with_override(
1589        pattern: &str,
1590        configure: impl FnOnce(&mut fallow_config::PartialRulesConfig),
1591    ) -> ResolvedConfig {
1592        let mut partial = fallow_config::PartialRulesConfig::default();
1593        configure(&mut partial);
1594        fallow_config::FallowConfig {
1595            rules: RulesConfig {
1596                private_type_leaks: Severity::Warn,
1597                ..RulesConfig::default()
1598            },
1599            overrides: vec![fallow_config::ConfigOverride {
1600                files: vec![pattern.to_string()],
1601                rules: partial,
1602            }],
1603            ..fallow_config::FallowConfig::default()
1604        }
1605        .resolve(
1606            PathBuf::from("/project"),
1607            fallow_config::OutputFormat::Human,
1608            1,
1609            true,
1610            true,
1611            None,
1612        )
1613    }
1614
1615    fn unused_export(path: &str) -> UnusedExportFinding {
1616        UnusedExportFinding::with_actions(UnusedExport {
1617            path: PathBuf::from(path),
1618            export_name: "Unused".to_string(),
1619            is_type_only: false,
1620            line: 1,
1621            col: 0,
1622            span_start: 0,
1623            is_re_export: false,
1624            deprecated: false,
1625            deprecated_reason: None,
1626        })
1627    }
1628
1629    fn private_type_leak(path: &str) -> PrivateTypeLeakFinding {
1630        PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1631            path: PathBuf::from(path),
1632            export_name: "Unused".to_string(),
1633            type_name: "Props".to_string(),
1634            line: 1,
1635            col: 0,
1636            span_start: 0,
1637            semantic: None,
1638        })
1639    }
1640
1641    fn overridden_fixture() -> AnalysisResults {
1642        let mut results = AnalysisResults::default();
1643        results
1644            .unused_exports
1645            .push(unused_export("/project/src/ui/kit.ts"));
1646        results
1647            .unused_exports
1648            .push(unused_export("/project/src/lib/util.ts"));
1649        results
1650            .private_type_leaks
1651            .push(private_type_leak("/project/src/ui/kit.ts"));
1652        results
1653            .private_type_leaks
1654            .push(private_type_leak("/project/src/lib/util.ts"));
1655        results
1656    }
1657
1658    #[test]
1659    fn rule_severities_drop_findings_only_on_overridden_paths() {
1660        let config = config_with_override("src/ui/**", |rules| {
1661            rules.unused_exports = Some(Severity::Off);
1662            rules.private_type_leaks = Some(Severity::Off);
1663        });
1664        let mut results = overridden_fixture();
1665
1666        apply_rule_severities(&mut results, &config);
1667
1668        assert_eq!(
1669            results
1670                .unused_exports
1671                .iter()
1672                .map(|finding| finding.export.path.clone())
1673                .collect::<Vec<_>>(),
1674            vec![PathBuf::from("/project/src/lib/util.ts")]
1675        );
1676        assert_eq!(
1677            results
1678                .private_type_leaks
1679                .iter()
1680                .map(|finding| finding.leak.path.clone())
1681                .collect::<Vec<_>>(),
1682            vec![PathBuf::from("/project/src/lib/util.ts")]
1683        );
1684    }
1685
1686    #[test]
1687    fn rule_severities_are_idempotent() {
1688        // The editor path resolves severities once after analysis and again
1689        // after type-aware reconciliation, so a second pass must not change
1690        // the result set.
1691        let config = config_with_override("src/ui/**", |rules| {
1692            rules.unused_exports = Some(Severity::Off);
1693            rules.private_type_leaks = Some(Severity::Off);
1694        });
1695
1696        let mut once = overridden_fixture();
1697        apply_rule_severities(&mut once, &config);
1698        let mut twice = overridden_fixture();
1699        apply_rule_severities(&mut twice, &config);
1700        apply_rule_severities(&mut twice, &config);
1701
1702        assert_eq!(
1703            once.unused_exports
1704                .iter()
1705                .map(|finding| finding.export.path.clone())
1706                .collect::<Vec<_>>(),
1707            twice
1708                .unused_exports
1709                .iter()
1710                .map(|finding| finding.export.path.clone())
1711                .collect::<Vec<_>>()
1712        );
1713        assert_eq!(
1714            once.private_type_leaks
1715                .iter()
1716                .map(|finding| finding.leak.path.clone())
1717                .collect::<Vec<_>>(),
1718            twice
1719                .private_type_leaks
1720                .iter()
1721                .map(|finding| finding.leak.path.clone())
1722                .collect::<Vec<_>>()
1723        );
1724    }
1725
1726    #[test]
1727    fn rule_severities_keep_a_cycle_when_any_member_file_stays_enabled() {
1728        let config = config_with_override("src/ui/**", |rules| {
1729            rules.circular_dependencies = Some(Severity::Off);
1730        });
1731        let mut results = AnalysisResults::default();
1732        results
1733            .circular_dependencies
1734            .push(CircularDependencyFinding::with_actions(
1735                CircularDependency {
1736                    files: vec![
1737                        PathBuf::from("/project/src/ui/a.ts"),
1738                        PathBuf::from("/project/src/lib/b.ts"),
1739                    ],
1740                    length: 2,
1741                    line: 1,
1742                    col: 0,
1743                    edges: Vec::new(),
1744                    is_cross_package: false,
1745                },
1746            ));
1747        results
1748            .circular_dependencies
1749            .push(CircularDependencyFinding::with_actions(
1750                CircularDependency {
1751                    files: vec![
1752                        PathBuf::from("/project/src/ui/c.ts"),
1753                        PathBuf::from("/project/src/ui/d.ts"),
1754                    ],
1755                    length: 2,
1756                    line: 1,
1757                    col: 0,
1758                    edges: Vec::new(),
1759                    is_cross_package: false,
1760                },
1761            ));
1762
1763        apply_rule_severities(&mut results, &config);
1764
1765        assert_eq!(results.circular_dependencies.len(), 1);
1766        assert_eq!(
1767            results.circular_dependencies[0].cycle.files[0],
1768            PathBuf::from("/project/src/ui/a.ts")
1769        );
1770    }
1771
1772    #[test]
1773    fn rule_severities_clear_base_rules_without_overrides() {
1774        let config = fallow_config::FallowConfig {
1775            rules: RulesConfig {
1776                unused_exports: Severity::Off,
1777                private_type_leaks: Severity::Warn,
1778                ..RulesConfig::default()
1779            },
1780            ..fallow_config::FallowConfig::default()
1781        }
1782        .resolve(
1783            PathBuf::from("/project"),
1784            fallow_config::OutputFormat::Human,
1785            1,
1786            true,
1787            true,
1788            None,
1789        );
1790        let mut results = overridden_fixture();
1791
1792        apply_rule_severities(&mut results, &config);
1793
1794        assert!(results.unused_exports.is_empty());
1795        assert_eq!(results.private_type_leaks.len(), 2);
1796    }
1797
1798    const DEAD_FILE: &str = "/project/src/dead.ts";
1799    const LIVE_FILE: &str = "/project/src/live.ts";
1800
1801    fn resolve(config: fallow_config::FallowConfig) -> ResolvedConfig {
1802        config.resolve(
1803            PathBuf::from("/project"),
1804            fallow_config::OutputFormat::Human,
1805            1,
1806            true,
1807            true,
1808            None,
1809        )
1810    }
1811
1812    fn member(path: &str, kind: fallow_types::extract::MemberKind) -> UnusedMember {
1813        UnusedMember {
1814            path: PathBuf::from(path),
1815            parent_name: "Parent".to_string(),
1816            member_name: "member".to_string(),
1817            kind,
1818            line: 2,
1819            col: 0,
1820        }
1821    }
1822
1823    fn unused_type(path: &str) -> UnusedTypeFinding {
1824        let mut export = unused_export(path).export;
1825        export.is_type_only = true;
1826        UnusedTypeFinding::with_actions(export)
1827    }
1828
1829    /// One finding of each cascade kind in `DEAD_FILE` and in `LIVE_FILE`,
1830    /// plus findings of other kinds that touch `DEAD_FILE`.
1831    fn cascade_fixture() -> AnalysisResults {
1832        let mut results = AnalysisResults::default();
1833        results
1834            .unused_files
1835            .push(UnusedFileFinding::with_actions(UnusedFile {
1836                path: PathBuf::from(DEAD_FILE),
1837            }));
1838        for path in [DEAD_FILE, LIVE_FILE] {
1839            results.unused_exports.push(unused_export(path));
1840            results.unused_types.push(unused_type(path));
1841            results
1842                .unused_enum_members
1843                .push(UnusedEnumMemberFinding::with_actions(member(
1844                    path,
1845                    fallow_types::extract::MemberKind::EnumMember,
1846                )));
1847            results
1848                .unused_class_members
1849                .push(UnusedClassMemberFinding::with_actions(member(
1850                    path,
1851                    fallow_types::extract::MemberKind::ClassMethod,
1852                )));
1853        }
1854        results
1855            .duplicate_exports
1856            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1857                export_name: "Shared".to_string(),
1858                locations: [DEAD_FILE, LIVE_FILE]
1859                    .iter()
1860                    .map(|path| DuplicateLocation {
1861                        path: PathBuf::from(path),
1862                        line: 1,
1863                        col: 0,
1864                    })
1865                    .collect(),
1866            }));
1867        results
1868            .circular_dependencies
1869            .push(CircularDependencyFinding::with_actions(
1870                CircularDependency {
1871                    files: vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)],
1872                    length: 2,
1873                    line: 1,
1874                    col: 0,
1875                    edges: Vec::new(),
1876                    is_cross_package: false,
1877                },
1878            ));
1879        results
1880            .unlisted_dependencies
1881            .push(UnlistedDependencyFinding::with_actions(
1882                UnlistedDependency {
1883                    package_name: "left-pad".to_string(),
1884                    imported_from: vec![ImportSite {
1885                        path: PathBuf::from(DEAD_FILE),
1886                        line: 1,
1887                        col: 0,
1888                    }],
1889                },
1890            ));
1891        results
1892            .boundary_violations
1893            .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1894                from_path: PathBuf::from(DEAD_FILE),
1895                to_path: PathBuf::from(LIVE_FILE),
1896                from_zone: "ui".to_string(),
1897                to_zone: "data".to_string(),
1898                import_specifier: "./live".to_string(),
1899                line: 1,
1900                col: 0,
1901                via_path: None,
1902            }));
1903        results
1904    }
1905
1906    fn cascade_paths(results: &AnalysisResults) -> Vec<Vec<PathBuf>> {
1907        vec![
1908            results
1909                .unused_exports
1910                .iter()
1911                .map(|f| f.export.path.clone())
1912                .collect(),
1913            results
1914                .unused_types
1915                .iter()
1916                .map(|f| f.export.path.clone())
1917                .collect(),
1918            results
1919                .unused_enum_members
1920                .iter()
1921                .map(|f| f.member.path.clone())
1922                .collect(),
1923            results
1924                .unused_class_members
1925                .iter()
1926                .map(|f| f.member.path.clone())
1927                .collect(),
1928        ]
1929    }
1930
1931    #[test]
1932    fn rule_pass_hides_the_cascade_kinds_of_unused_files() {
1933        let config = resolve(fallow_config::FallowConfig::default());
1934        let mut results = cascade_fixture();
1935
1936        apply_rule_severities(&mut results, &config);
1937
1938        let live = vec![PathBuf::from(LIVE_FILE)];
1939        assert_eq!(cascade_paths(&results), vec![live; 4]);
1940        assert_eq!(results.cascade_hidden, 4);
1941        assert_eq!(results.cascade.hidden.len(), 4);
1942        assert_eq!(results.unused_files.len(), 1, "the unused file stays");
1943    }
1944
1945    #[test]
1946    fn rule_pass_keeps_other_kinds_in_unused_files() {
1947        let config = resolve(fallow_config::FallowConfig::default());
1948        let mut results = cascade_fixture();
1949
1950        apply_rule_severities(&mut results, &config);
1951
1952        assert_eq!(results.duplicate_exports.len(), 1);
1953        assert_eq!(results.circular_dependencies.len(), 1);
1954        assert_eq!(results.unlisted_dependencies.len(), 1);
1955        assert_eq!(results.boundary_violations.len(), 1);
1956    }
1957
1958    #[test]
1959    fn show_cascade_keeps_every_finding() {
1960        let config = resolve(fallow_config::FallowConfig {
1961            show_cascade: true,
1962            ..fallow_config::FallowConfig::default()
1963        });
1964        let mut results = cascade_fixture();
1965
1966        apply_rule_severities(&mut results, &config);
1967
1968        let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
1969        assert_eq!(cascade_paths(&results), vec![both; 4]);
1970        assert_eq!(results.cascade_hidden, 0);
1971    }
1972
1973    #[test]
1974    fn unused_files_rule_off_for_the_file_keeps_its_findings() {
1975        let config = config_with_override("src/dead.ts", |rules| {
1976            rules.unused_files = Some(Severity::Off);
1977        });
1978        let mut results = cascade_fixture();
1979
1980        apply_rule_severities(&mut results, &config);
1981
1982        assert!(results.unused_files.is_empty());
1983        let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
1984        assert_eq!(cascade_paths(&results), vec![both; 4]);
1985        assert_eq!(results.cascade_hidden, 0);
1986    }
1987
1988    #[test]
1989    fn a_suppressed_unused_file_still_hides_its_findings() {
1990        // An inline `fallow-ignore-file unused-file` comment removes the file
1991        // from `unused_files`, and the detector records it as suppressed.
1992        let config = resolve(fallow_config::FallowConfig::default());
1993        let mut results = cascade_fixture();
1994        results.unused_files.clear();
1995        results.cascade.suppressed_unused_files = vec![PathBuf::from(DEAD_FILE)];
1996
1997        apply_rule_severities(&mut results, &config);
1998
1999        let live = vec![PathBuf::from(LIVE_FILE)];
2000        assert_eq!(cascade_paths(&results), vec![live; 4]);
2001        assert_eq!(results.cascade_hidden, 4);
2002    }
2003
2004    #[test]
2005    fn a_file_missing_from_the_report_hides_nothing() {
2006        // An issue-type filter without `unused-files`, a scope or a rule can
2007        // remove the unused file from the report. Its findings must then stay,
2008        // or the report loses them without a pointer to the file.
2009        let config = resolve(fallow_config::FallowConfig::default());
2010        let mut results = cascade_fixture();
2011        results.unused_files.clear();
2012
2013        apply_rule_severities(&mut results, &config);
2014
2015        let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2016        assert_eq!(cascade_paths(&results), vec![both; 4]);
2017        assert_eq!(results.cascade_hidden, 0);
2018    }
2019
2020    #[test]
2021    fn cascade_hiding_is_idempotent() {
2022        let config = resolve(fallow_config::FallowConfig::default());
2023        let mut results = cascade_fixture();
2024
2025        apply_rule_severities(&mut results, &config);
2026        apply_rule_severities(&mut results, &config);
2027
2028        assert_eq!(results.cascade_hidden, 4);
2029        assert_eq!(results.cascade.hidden.len(), 4);
2030    }
2031
2032    #[test]
2033    fn cascade_hidden_serializes_only_when_not_zero() {
2034        let config = resolve(fallow_config::FallowConfig::default());
2035        let mut results = cascade_fixture();
2036        let before = serde_json::to_value(&results).expect("serializes");
2037        assert!(before.get("cascade_hidden").is_none());
2038
2039        apply_rule_severities(&mut results, &config);
2040
2041        let after = serde_json::to_value(&results).expect("serializes");
2042        assert_eq!(after["cascade_hidden"], 4);
2043        assert!(after.get("cascade").is_none());
2044    }
2045}