Skip to main content

drep/analysis/
findings.rs

1//! The finding vocabulary.
2//!
3//! Phase 4 adds `Finding` itself. `Severity` lands here in Phase 0 because
4//! `drep check --fail-on` is part of the CLI contract and needs it to parse.
5//!
6//! Deliberately free of `clap`: this module becomes the core analysis library,
7//! and the tool parsers (Phase 1) and LLM response parsing (Phases 3-4) need
8//! `Severity` without dragging an argument parser in behind it. The CLI adapts
9//! to `FromStr` at its own boundary.
10
11use std::str::FromStr;
12
13/// Finding severity, lowest first.
14///
15/// The single vocabulary for a finding's severity. Producers map their own
16/// scales onto it; consumers that gate on severity compare `Severity` values
17/// directly rather than inventing a ranking.
18///
19/// Ordering is derived from declaration order, so it cannot drift from a
20/// separate rank table and there is no "unknown severity" case to default.
21/// In the Python implementation this was a `SEVERITY_RANK` dict that callers
22/// had to remember to index rather than `.get(..., 0)` - a lookup with a
23/// default silently passes a gate on a severity nobody ranked. Here the type
24/// system removes the question.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum Severity {
27    Info,
28    Warning,
29    Error,
30}
31
32/// Does any finding sit at or above `threshold`?
33///
34/// The one definition of "this finding blocks". `check` and `lint-docs` both
35/// gate on it and the `lint-docs` footer reports on it, and the comparison was
36/// written out at all three sites - the same drift `SEVERITY_RANK` living here
37/// exists to prevent, one level up.
38pub fn any_at_or_above(findings: &[Finding], threshold: Severity) -> bool {
39    findings.iter().any(|finding| finding.severity >= threshold)
40}
41
42/// Raised when a producer emits a severity outside the vocabulary.
43///
44/// An unrecognised severity is a bug to surface, not a value to coerce to the
45/// lowest rank - coercion is how a finding silently stops blocking.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47#[error("unknown severity `{value}` (expected one of: {})", expected.join(", "))]
48pub struct UnknownSeverity {
49    /// The value that failed to parse.
50    pub value: String,
51    /// The vocabulary that was expected.
52    ///
53    /// Carried rather than hardcoded because two different scales parse into
54    /// this error: drep's three-level [`Severity`] and the model-facing
55    /// five-level [`LlmSeverity`]. A fixed list meant a rejected `"blocker"`
56    /// from an LLM response reported "expected one of: info, warning, error" -
57    /// a vocabulary the parser does not accept and the model was never asked
58    /// for, which sends whoever reads it looking in the wrong place.
59    pub expected: &'static [&'static str],
60}
61
62impl Severity {
63    /// Every severity, lowest first. The one place the vocabulary is listed.
64    pub const ALL: [Severity; 3] = [Severity::Info, Severity::Warning, Severity::Error];
65
66    /// Every wire name, in rank order — the list an error message quotes.
67    ///
68    /// Derived from `ALL` in a const, not written out. A second literal list
69    /// would need a test to stop it drifting, and a derived list plus a
70    /// consistency test is a weaker construction than derivation.
71    pub const NAMES: [&'static str; 3] = [
72        Self::ALL[0].as_str(),
73        Self::ALL[1].as_str(),
74        Self::ALL[2].as_str(),
75    ];
76
77    /// The wire name, as tool parsers and the LLM emit it.
78    ///
79    /// `FromStr` is defined in terms of this, so the two directions cannot
80    /// disagree.
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Severity::Info => "info",
84            Severity::Warning => "warning",
85            Severity::Error => "error",
86        }
87    }
88}
89
90impl FromStr for Severity {
91    type Err = UnknownSeverity;
92
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        Severity::ALL
95            .into_iter()
96            .find(|sev| sev.as_str() == s)
97            .ok_or_else(|| UnknownSeverity {
98                value: s.to_owned(),
99                expected: &Severity::NAMES,
100            })
101    }
102}
103
104impl std::fmt::Display for Severity {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110/// One finding produced by an analyzer.
111///
112/// Field naming follows the Python `drep.models.findings.Finding` so the two
113/// stay translatable; `kind` here is `type` there, since `type` is a reserved
114/// word in Rust and would force `r#type` at every call site.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Finding {
117    /// Rule code (e.g. `"F401"`) for a structured finding, or the tool name
118    /// (`"ruff"`, `"gofmt"`) when the tool emits no per-rule identifier.
119    pub kind: String,
120    pub severity: Severity,
121    pub file_path: String,
122    pub line: u32,
123    pub column: Option<u32>,
124    pub message: String,
125    /// Optional one-line suggested fix from the tool. None when the tool
126    /// does not emit one (e.g. eslint messages carry only a rule id).
127    pub suggestion: Option<String>,
128}
129
130/// The five-level scale the LLM is asked to use, and its mapping onto
131/// [`Severity`].
132///
133/// The model does not emit `Severity` directly: a reviewer reasons in
134/// critical/high/medium/low/info, and collapsing that to three levels is
135/// drep's decision, not the model's. Keeping the wire vocabulary as its own
136/// type means the prompt renders the alternation from `ALL` and the parser
137/// accepts exactly the same list, so the two cannot drift. They previously
138/// could, and the consequence was not cosmetic: a level named in the prompt
139/// but missing from the parser makes every record carrying it `Malformed`,
140/// which marks the file unanalyzed and turns the gate's exit code to 2.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum LlmSeverity {
143    Critical,
144    High,
145    Medium,
146    Low,
147    Info,
148}
149
150impl LlmSeverity {
151    /// Every level, most severe first - the order the prompt lists them in.
152    pub const ALL: [LlmSeverity; 5] = [
153        LlmSeverity::Critical,
154        LlmSeverity::High,
155        LlmSeverity::Medium,
156        LlmSeverity::Low,
157        LlmSeverity::Info,
158    ];
159
160    /// Every wire name, most severe first. Derived from `ALL` — see
161    /// [`Severity::NAMES`].
162    pub const NAMES: [&'static str; 5] = [
163        Self::ALL[0].as_str(),
164        Self::ALL[1].as_str(),
165        Self::ALL[2].as_str(),
166        Self::ALL[3].as_str(),
167        Self::ALL[4].as_str(),
168    ];
169
170    /// The wire name, as the prompt asks for it and the response carries it.
171    pub const fn as_str(self) -> &'static str {
172        match self {
173            LlmSeverity::Critical => "critical",
174            LlmSeverity::High => "high",
175            LlmSeverity::Medium => "medium",
176            LlmSeverity::Low => "low",
177            LlmSeverity::Info => "info",
178        }
179    }
180
181    /// Collapse onto drep's three-level vocabulary.
182    pub const fn to_severity(self) -> Severity {
183        match self {
184            LlmSeverity::Critical | LlmSeverity::High => Severity::Error,
185            LlmSeverity::Medium => Severity::Warning,
186            LlmSeverity::Low | LlmSeverity::Info => Severity::Info,
187        }
188    }
189
190    /// The `critical|high|medium|low|info` alternation, for the prompt.
191    ///
192    /// Rendered from `NAMES`, which is itself derived from `ALL`, so a level
193    /// added to the enum reaches the prompt without anyone remembering to
194    /// update it.
195    pub fn alternation() -> String {
196        Self::NAMES.join("|")
197    }
198}
199
200impl FromStr for LlmSeverity {
201    type Err = UnknownSeverity;
202
203    fn from_str(s: &str) -> Result<Self, Self::Err> {
204        LlmSeverity::ALL
205            .into_iter()
206            .find(|level| level.as_str() == s)
207            .ok_or_else(|| UnknownSeverity {
208                value: s.to_owned(),
209                expected: &LlmSeverity::NAMES,
210            })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn severity_orders_lowest_first() {
220        assert!(Severity::Info < Severity::Warning);
221        assert!(Severity::Warning < Severity::Error);
222    }
223
224    #[test]
225    fn all_is_in_rank_order_and_complete() {
226        // Guards the invariant that `ALL` and the derived `Ord` agree; a
227        // variant added out of order would make `ALL` a second, wrong ranking.
228        assert!(Severity::ALL.is_sorted());
229    }
230
231    #[test]
232    fn gating_at_error_admits_only_error() {
233        let threshold = Severity::Error;
234        assert!(Severity::Error >= threshold);
235        assert!(Severity::Warning < threshold);
236        assert!(Severity::Info < threshold);
237    }
238
239    #[test]
240    fn wire_names_round_trip() {
241        for sev in Severity::ALL {
242            assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
243            assert_eq!(sev.to_string(), sev.as_str());
244        }
245    }
246
247    #[test]
248    fn unknown_severity_is_an_error_not_a_default() {
249        let err = "critical".parse::<Severity>().unwrap_err();
250        assert_eq!(err.value, "critical");
251        assert!(err.to_string().contains("critical"));
252        // The message must list the vocabulary, so a producer mismatch is
253        // diagnosable from the error alone.
254        assert!(err.to_string().contains("info, warning, error"));
255    }
256
257    #[test]
258    fn parsing_is_case_sensitive() {
259        // Producers emit lowercase. Accepting "ERROR" would mean quietly
260        // normalising, and normalising is how a second vocabulary starts.
261        assert!("ERROR".parse::<Severity>().is_err());
262    }
263
264    #[test]
265    fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
266        // The two scales share one error type. Reporting drep's three levels
267        // for a rejected LLM level sends the reader looking for a value the
268        // parser never accepts.
269        let err = "blocker".parse::<LlmSeverity>().unwrap_err();
270        let msg = err.to_string();
271        assert!(
272            msg.contains("critical, high, medium, low, info"),
273            "got {msg}"
274        );
275        assert!(
276            !msg.contains("warning"),
277            "must not quote drep's scale: {msg}"
278        );
279
280        let err = "blocker".parse::<Severity>().unwrap_err();
281        let msg = err.to_string();
282        assert!(msg.contains("info, warning, error"), "got {msg}");
283        assert!(
284            !msg.contains("critical"),
285            "must not quote the LLM scale: {msg}"
286        );
287    }
288
289    #[test]
290    fn llm_severity_wire_names_round_trip() {
291        for level in LlmSeverity::ALL {
292            assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
293        }
294        assert!("blocker".parse::<LlmSeverity>().is_err());
295    }
296
297    #[test]
298    fn llm_severity_collapses_onto_the_three_level_vocabulary() {
299        // All five in one assertion: a single hardcoded mapping cannot pass.
300        let mapped: Vec<Severity> = LlmSeverity::ALL
301            .into_iter()
302            .map(LlmSeverity::to_severity)
303            .collect();
304        assert_eq!(
305            mapped,
306            vec![
307                Severity::Error,
308                Severity::Error,
309                Severity::Warning,
310                Severity::Info,
311                Severity::Info
312            ]
313        );
314    }
315
316    #[test]
317    fn alternation_is_derived_from_all_not_written_out() {
318        assert_eq!(LlmSeverity::alternation(), "critical|high|medium|low|info");
319        // Every level must appear, so adding one cannot silently miss the prompt.
320        for level in LlmSeverity::ALL {
321            assert!(LlmSeverity::alternation().contains(level.as_str()));
322        }
323    }
324}