Skip to main content

fallow_output/
finding_id_query.rs

1//! The machine-readable answer to `fallow dead-code --finding-id`.
2//!
3//! A consumer that stores a verdict per finding id asks whether a finding
4//! still exists. The report alone cannot answer that: an id that is not in the
5//! report can be absent, or it can be hidden by a scope, a baseline or a
6//! filter of this run. `finding_id_query` makes the difference explicit.
7//! Read `missing` as "resolved" only when `conclusive` is true.
8
9use serde::Serialize;
10
11use crate::ScopeReason;
12
13/// One reason why a missing id does not prove that the finding is gone.
14///
15/// Serialized as kebab-case inside `inconclusive_reasons`. The set is OPEN: a
16/// name this build does not emit means "some reason", not an error, and the
17/// query stays inconclusive.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "kebab-case")]
21pub enum FindingIdQueryReason {
22    /// A diff index narrowed the report (`--diff-file`, `--diff-stdin`,
23    /// `FALLOW_DIFF_FILE` or a CI format).
24    Diff,
25    /// A global changed-since ref.
26    ChangedSince,
27    /// The per-package refs of `workspaces.changedSince` in the config.
28    PackageBaselines,
29    /// A resolved changed-file set narrowed the report.
30    ChangedFiles,
31    /// `--workspace`.
32    Workspace,
33    /// `--changed-workspaces`.
34    ChangedWorkspaces,
35    /// The positional `[PATH]` scope.
36    Scope,
37    /// One or more `--file`.
38    File,
39    /// An issue-type filter such as `--unused-exports`.
40    IssueTypeFilter,
41    /// Production mode, from the flag or from the project config. It removes
42    /// test, story and dev files before the analysis, so a finding in such a
43    /// file is never seen. A project that sets production mode in its config
44    /// therefore never gets a conclusive answer.
45    Production,
46    /// `--include-entry-exports` or the `includeEntryExports` config key. It
47    /// changes which exports `unused-exports` reports.
48    IncludeEntryExports,
49    /// `--baseline`: the run hides the findings the baseline lists.
50    Baseline,
51    /// The rule of a missing id is `off` in `rules` or in one of the
52    /// `overrides[].rules`. The analysis does not look for such a finding,
53    /// so its absence proves nothing.
54    RuleOff,
55    /// The analysis found a requested id, and a filter of this run removed it
56    /// from the report. The ids are in `filtered`.
57    Filtered,
58}
59
60impl FindingIdQueryReason {
61    /// The kebab-case name this reason serializes as, for prose outside the
62    /// JSON envelope.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Diff => "diff",
67            Self::ChangedSince => "changed-since",
68            Self::PackageBaselines => "package-baselines",
69            Self::ChangedFiles => "changed-files",
70            Self::Workspace => "workspace",
71            Self::ChangedWorkspaces => "changed-workspaces",
72            Self::Scope => "scope",
73            Self::File => "file",
74            Self::IssueTypeFilter => "issue-type-filter",
75            Self::Production => "production",
76            Self::IncludeEntryExports => "include-entry-exports",
77            Self::Baseline => "baseline",
78            Self::RuleOff => "rule-off",
79            Self::Filtered => "filtered",
80        }
81    }
82}
83
84impl From<ScopeReason> for FindingIdQueryReason {
85    fn from(reason: ScopeReason) -> Self {
86        match reason {
87            ScopeReason::Diff => Self::Diff,
88            ScopeReason::ChangedSince => Self::ChangedSince,
89            ScopeReason::PackageBaselines => Self::PackageBaselines,
90            ScopeReason::ChangedFiles => Self::ChangedFiles,
91            ScopeReason::Workspace => Self::Workspace,
92            ScopeReason::ChangedWorkspaces => Self::ChangedWorkspaces,
93            ScopeReason::Scope => Self::Scope,
94            ScopeReason::File => Self::File,
95            ScopeReason::IssueTypeFilter => Self::IssueTypeFilter,
96            ScopeReason::Production => Self::Production,
97            ScopeReason::IncludeEntryExports => Self::IncludeEntryExports,
98        }
99    }
100}
101
102/// The result of a `--finding-id` query, present only when the run received
103/// one or more `--finding-id` values.
104///
105/// A requested id that is missing from a conclusive run means "fixed,
106/// suppressed, or ignored by config", never "unknown": an inline suppression
107/// comment or an `ignoreFindings` entry is a choice a person made to hide the
108/// finding, so it counts as absent. A missing id in a run that is not
109/// conclusive is unknown, never resolved.
110///
111/// Every list keeps the order of `requested`. `found` and `missing` partition
112/// `requested`. `filtered` is a subset of `missing`.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115pub struct FindingIdQuery {
116    /// The requested ids, without duplicates, in the order of the arguments.
117    pub requested: Vec<String>,
118    /// The requested ids that this report contains.
119    pub found: Vec<String>,
120    /// The requested ids that this report does not contain. When `conclusive`
121    /// is true, a missing id is fixed, suppressed, or ignored by config.
122    /// Otherwise its state is unknown.
123    pub missing: Vec<String>,
124    /// The missing ids that the analysis still found before a filter of this
125    /// run (scope, baseline, issue-type filter) removed them. Such a finding
126    /// still exists.
127    pub filtered: Vec<String>,
128    /// True when no option of this run can hide a finding without a fix, and
129    /// no requested id was filtered. Only then does a missing id mean that
130    /// the analysis no longer reports the finding.
131    pub conclusive: bool,
132    /// Why the query is not conclusive, sorted. Empty exactly when
133    /// `conclusive` is true.
134    pub inconclusive_reasons: Vec<FindingIdQueryReason>,
135    /// A stable hash (`af1:<16 hex digits>`) of every input other than the
136    /// source code that decides which findings the run reports:
137    /// - the fallow version;
138    /// - the merged config after `extends` (without keys that only shape other
139    ///   commands), the loaded external plugins and rule packs;
140    /// - production mode, `includeEntryExports`, the effective rules, the
141    ///   type-aware mode, requirement and project list, the file size limit;
142    /// - the root-relative path and content of each repository `.gitignore`,
143    ///   `.ignore` and `.git/info/exclude`, each `package.json`, each
144    ///   `tsconfig*.json` and `jsconfig*.json` with the files its `extends`
145    ///   names, and each file that matches a built-in or external plugin
146    ///   config pattern (for example `vite.config.ts`).
147    ///
148    /// File content is normalized (CRLF to LF, trailing newlines removed).
149    /// Known exclusions: the global git excludes file and other machine
150    /// environment outside the `FALLOW_*` variables. Store the fingerprint
151    /// with a verdict. A later query with another fingerprint is unknown, even
152    /// when `conclusive` is true. An edit to a source file keeps it; an edit
153    /// to a manifest or project config changes it, also when the edit fixes a
154    /// dependency finding.
155    pub analysis_fingerprint: String,
156}
157
158impl FindingIdQuery {
159    /// Build the query result.
160    ///
161    /// `requested` must be free of duplicates. `found` and `filtered` are
162    /// membership tests over `requested`. `run_reasons` are the options of
163    /// the run that can hide a finding; `Filtered` is added when an id was
164    /// filtered.
165    #[must_use]
166    pub fn new(
167        requested: Vec<String>,
168        is_found: impl Fn(&str) -> bool,
169        is_filtered: impl Fn(&str) -> bool,
170        run_reasons: impl IntoIterator<Item = FindingIdQueryReason>,
171        analysis_fingerprint: String,
172    ) -> Self {
173        let (found, missing): (Vec<String>, Vec<String>) =
174            requested.iter().cloned().partition(|id| is_found(id));
175        let filtered: Vec<String> = missing
176            .iter()
177            .filter(|id| is_filtered(id))
178            .cloned()
179            .collect();
180        let mut reasons: Vec<FindingIdQueryReason> = run_reasons.into_iter().collect();
181        if !filtered.is_empty() {
182            reasons.push(FindingIdQueryReason::Filtered);
183        }
184        reasons.sort_unstable();
185        reasons.dedup();
186        Self {
187            requested,
188            found,
189            missing,
190            filtered,
191            conclusive: reasons.is_empty(),
192            inconclusive_reasons: reasons,
193            analysis_fingerprint,
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn ids(values: &[&str]) -> Vec<String> {
203        values.iter().map(|value| (*value).to_owned()).collect()
204    }
205
206    #[test]
207    fn a_missing_id_without_reasons_is_conclusive() {
208        let query = FindingIdQuery::new(
209            ids(&["a", "b"]),
210            |id| id == "a",
211            |_| false,
212            [],
213            String::new(),
214        );
215
216        assert_eq!(query.found, ids(&["a"]));
217        assert_eq!(query.missing, ids(&["b"]));
218        assert!(query.filtered.is_empty());
219        assert!(query.conclusive);
220        assert!(query.inconclusive_reasons.is_empty());
221    }
222
223    #[test]
224    fn a_filtered_id_makes_the_query_inconclusive() {
225        let query = FindingIdQuery::new(
226            ids(&["a", "b"]),
227            |_| false,
228            |id| id == "b",
229            [],
230            String::new(),
231        );
232
233        assert_eq!(query.filtered, ids(&["b"]));
234        assert!(!query.conclusive);
235        assert_eq!(
236            query.inconclusive_reasons,
237            vec![FindingIdQueryReason::Filtered]
238        );
239    }
240
241    #[test]
242    fn run_reasons_are_sorted_and_unique() {
243        let query = FindingIdQuery::new(
244            ids(&["a"]),
245            |_| true,
246            |_| false,
247            [
248                FindingIdQueryReason::Baseline,
249                FindingIdQueryReason::Scope,
250                FindingIdQueryReason::Baseline,
251            ],
252            String::new(),
253        );
254
255        assert!(!query.conclusive);
256        assert_eq!(
257            query.inconclusive_reasons,
258            vec![FindingIdQueryReason::Scope, FindingIdQueryReason::Baseline]
259        );
260    }
261}