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