Skip to main content

fallow_output/
audit_diff_risk.rs

1//! Audit diff-risk output contracts.
2//!
3//! A diff-risk finding is a fact about the change itself: the diff weakened a
4//! test, added a suppression, added a test-only branch, edited one copy of a
5//! clone group, or added a dependency. `fallow audit` computes these from the
6//! base and head text of the changed files, so every finding is introduced by
7//! the change. The actions offer only suppression, never a fix.
8
9use serde::{Deserialize, Serialize};
10
11/// The rule that owns a diff-risk finding.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[serde(rename_all = "kebab-case")]
15pub enum DiffRiskRule {
16    /// The diff weakened tests. The `kind` field tells how.
17    WeakenedTests,
18    /// The diff added a lint, type-check, coverage or Fallow suppression.
19    AddedSuppression,
20    /// The diff added a test-environment branch to a non-test source file.
21    TestOnlyBranch,
22    /// The diff edited one copy of a base clone group, and no sibling changed.
23    DivergedCloneSibling,
24    /// The diff added a dependency to a `package.json`.
25    AddedDependency,
26}
27
28impl DiffRiskRule {
29    /// Every rule, in output order.
30    pub const ALL: [Self; 5] = [
31        Self::WeakenedTests,
32        Self::AddedSuppression,
33        Self::TestOnlyBranch,
34        Self::DivergedCloneSibling,
35        Self::AddedDependency,
36    ];
37
38    /// The stable rule code. It is also the `rules` config key and the
39    /// suppression token.
40    #[must_use]
41    pub const fn code(self) -> &'static str {
42        match self {
43            Self::WeakenedTests => "weakened-tests",
44            Self::AddedSuppression => "added-suppression",
45            Self::TestOnlyBranch => "test-only-branch",
46            Self::DivergedCloneSibling => "diverged-clone-sibling",
47            Self::AddedDependency => "added-dependency",
48        }
49    }
50
51    /// The SARIF rule id, `fallow/<code>`.
52    #[must_use]
53    pub fn sarif_rule_id(self) -> String {
54        format!("fallow/{}", self.code())
55    }
56
57    /// A short description for SARIF rule metadata and human output.
58    #[must_use]
59    pub const fn description(self) -> &'static str {
60        match self {
61            Self::WeakenedTests => "The change weakens tests or a coverage threshold",
62            Self::AddedSuppression => "The change adds a suppression comment",
63            Self::TestOnlyBranch => "The change adds a test-environment branch to source code",
64            Self::DivergedCloneSibling => {
65                "The change edits one copy of duplicated code but not its siblings"
66            }
67            Self::AddedDependency => "The change adds a dependency",
68        }
69    }
70}
71
72/// How a `weakened-tests` finding weakened the tests.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[serde(rename_all = "kebab-case")]
76pub enum WeakenedTestsKind {
77    /// A new skip or focus marker (`it.skip`, `xit`, `it.only`).
78    SkipOnly,
79    /// Fewer test cases across the changed test files.
80    RemovedTests,
81    /// Fewer assertions in a test file, with no test case removed.
82    AssertionDrop,
83    /// A coverage threshold that the change made less strict.
84    CoverageThreshold,
85}
86
87impl WeakenedTestsKind {
88    /// The stable wire value, the same as the serialized form.
89    #[must_use]
90    pub const fn code(self) -> &'static str {
91        match self {
92            Self::SkipOnly => "skip-only",
93            Self::RemovedTests => "removed-tests",
94            Self::AssertionDrop => "assertion-drop",
95            Self::CoverageThreshold => "coverage-threshold",
96        }
97    }
98}
99
100/// The severity of a diff-risk finding, from its rule.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[serde(rename_all = "kebab-case")]
104pub enum DiffRiskSeverity {
105    /// The rule is `error`: the finding fails the audit verdict.
106    Error,
107    /// The rule is `warn`: the finding gives a `warn` verdict.
108    Warn,
109}
110
111impl DiffRiskSeverity {
112    /// The stable wire value, the same as the serialized form.
113    #[must_use]
114    pub const fn code(self) -> &'static str {
115        match self {
116            Self::Error => "error",
117            Self::Warn => "warn",
118        }
119    }
120}
121
122/// A location that relates to a diff-risk finding, such as an unchanged clone
123/// sibling.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126pub struct DiffRiskLocation {
127    /// Root-relative path, with forward slashes.
128    pub path: String,
129    /// 1-based line.
130    pub line: u32,
131}
132
133/// The type of a diff-risk action. Every type is a suppression.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136#[serde(rename_all = "kebab-case")]
137pub enum DiffRiskActionType {
138    /// Put the `comment` on the line above the finding.
139    SuppressLine,
140    /// Put the `comment` at the top of the file.
141    SuppressFile,
142    /// The file cannot hold a comment. Set the rule to `off` for this path in
143    /// an `overrides` entry of the Fallow config.
144    SuppressInConfig,
145}
146
147/// One action on a diff-risk finding.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub struct DiffRiskAction {
151    /// Action discriminator.
152    #[serde(rename = "type")]
153    pub kind: DiffRiskActionType,
154    /// Always `false`: Fallow does not apply a suppression for you.
155    pub auto_fixable: bool,
156    /// Human-readable description of the action.
157    pub description: String,
158    /// The suppression comment to insert. Absent on `suppress-in-config`.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub comment: Option<String>,
161}
162
163/// One diff-risk finding.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub struct DiffRiskFinding {
167    /// The rule that owns the finding.
168    pub rule: DiffRiskRule,
169    /// How the tests were weakened. Present only on `weakened-tests`.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub kind: Option<WeakenedTestsKind>,
172    /// Root-relative path of the changed file, with forward slashes.
173    pub path: String,
174    /// 1-based head line. Absent when the finding is about the whole file,
175    /// for example removed tests or a deleted file.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub line: Option<u32>,
178    /// One-sentence statement of the fact.
179    pub message: String,
180    /// The token, the count change, or the value change that supports the
181    /// finding.
182    pub evidence: String,
183    /// The severity from the rule config.
184    pub severity: DiffRiskSeverity,
185    /// Always `true`: the change introduced the finding.
186    pub introduced: bool,
187    /// Related locations, such as the unchanged siblings of a clone group.
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub related: Vec<DiffRiskLocation>,
190    /// Suppression actions.
191    pub actions: Vec<DiffRiskAction>,
192}
193
194/// The diff-risk section of `fallow audit` JSON.
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct DiffRiskReport {
198    /// Findings, ordered by path, line, rule and kind.
199    pub findings: Vec<DiffRiskFinding>,
200}
201
202impl DiffRiskReport {
203    /// Whether the report has no findings.
204    #[must_use]
205    pub fn is_empty(&self) -> bool {
206        self.findings.is_empty()
207    }
208
209    /// Whether a finding has `error` severity.
210    #[must_use]
211    pub fn has_errors(&self) -> bool {
212        self.findings
213            .iter()
214            .any(|finding| finding.severity == DiffRiskSeverity::Error)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn codes_match_the_serialized_values() {
224        for kind in [
225            WeakenedTestsKind::SkipOnly,
226            WeakenedTestsKind::RemovedTests,
227            WeakenedTestsKind::AssertionDrop,
228            WeakenedTestsKind::CoverageThreshold,
229        ] {
230            assert_eq!(serde_json::json!(kind), kind.code());
231        }
232        for severity in [DiffRiskSeverity::Error, DiffRiskSeverity::Warn] {
233            assert_eq!(serde_json::json!(severity), severity.code());
234        }
235    }
236
237    #[test]
238    fn finding_serializes_with_kebab_case_and_skips_empty_fields() {
239        let finding = DiffRiskFinding {
240            rule: DiffRiskRule::WeakenedTests,
241            kind: Some(WeakenedTestsKind::SkipOnly),
242            path: "src/a.test.ts".to_owned(),
243            line: Some(3),
244            message: "m".to_owned(),
245            evidence: "it.skip".to_owned(),
246            severity: DiffRiskSeverity::Warn,
247            introduced: true,
248            related: Vec::new(),
249            actions: vec![DiffRiskAction {
250                kind: DiffRiskActionType::SuppressLine,
251                auto_fixable: false,
252                description: "d".to_owned(),
253                comment: Some("// fallow-ignore-next-line weakened-tests".to_owned()),
254            }],
255        };
256        let value = serde_json::to_value(&finding).expect("serialize");
257        assert_eq!(value["rule"], "weakened-tests");
258        assert_eq!(value["kind"], "skip-only");
259        assert_eq!(value["severity"], "warn");
260        assert_eq!(value["actions"][0]["type"], "suppress-line");
261        assert!(value.get("related").is_none());
262    }
263
264    #[test]
265    fn rule_codes_match_sarif_ids() {
266        for rule in DiffRiskRule::ALL {
267            assert_eq!(rule.sarif_rule_id(), format!("fallow/{}", rule.code()));
268            let value = serde_json::to_value(rule).expect("serialize");
269            assert_eq!(value, rule.code());
270        }
271    }
272}