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