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}