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