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    /// Whether the LLM explicitly claims the code cannot compile. Tool
129    /// findings and older cached responses leave this false.
130    pub asserts_compile_failure: bool,
131    /// Stable acknowledgement key for an LLM finding, when source context was
132    /// available. Deterministic findings do not use acknowledgements.
133    pub fingerprint: Option<String>,
134}
135
136impl Finding {
137    /// Construct a rule-based finding, centralizing metadata that belongs only
138    /// to semantic review.
139    pub fn deterministic(
140        kind: String,
141        severity: Severity,
142        file_path: String,
143        line: u32,
144        column: Option<u32>,
145        message: String,
146        suggestion: Option<String>,
147    ) -> Self {
148        Self {
149            kind,
150            severity,
151            file_path,
152            line,
153            column,
154            message,
155            suggestion,
156            asserts_compile_failure: false,
157            fingerprint: None,
158        }
159    }
160}
161
162/// The five-level scale the LLM is asked to use, and its mapping onto
163/// [`Severity`].
164///
165/// The model does not emit `Severity` directly: a reviewer reasons in
166/// critical/high/medium/low/info, and collapsing that to three levels is
167/// drep's decision, not the model's. Keeping the wire vocabulary as its own
168/// type means the prompt renders the alternation from `ALL` and the parser
169/// accepts exactly the same list, so the two cannot drift. They previously
170/// could, and the consequence was not cosmetic: a level named in the prompt
171/// but missing from the parser makes every record carrying it `Malformed`,
172/// which marks the file unanalyzed and turns the gate's exit code to 2.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum LlmSeverity {
175    Critical,
176    High,
177    Medium,
178    Low,
179    Info,
180}
181
182impl LlmSeverity {
183    /// Every level, most severe first - the order the prompt lists them in.
184    pub const ALL: [LlmSeverity; 5] = [
185        LlmSeverity::Critical,
186        LlmSeverity::High,
187        LlmSeverity::Medium,
188        LlmSeverity::Low,
189        LlmSeverity::Info,
190    ];
191
192    /// Every wire name, most severe first. Derived from `ALL` — see
193    /// [`Severity::NAMES`].
194    pub const NAMES: [&'static str; 5] = [
195        Self::ALL[0].as_str(),
196        Self::ALL[1].as_str(),
197        Self::ALL[2].as_str(),
198        Self::ALL[3].as_str(),
199        Self::ALL[4].as_str(),
200    ];
201
202    /// The wire name, as the prompt asks for it and the response carries it.
203    pub const fn as_str(self) -> &'static str {
204        match self {
205            LlmSeverity::Critical => "critical",
206            LlmSeverity::High => "high",
207            LlmSeverity::Medium => "medium",
208            LlmSeverity::Low => "low",
209            LlmSeverity::Info => "info",
210        }
211    }
212
213    /// Collapse onto drep's three-level vocabulary.
214    pub const fn to_severity(self) -> Severity {
215        match self {
216            LlmSeverity::Critical | LlmSeverity::High => Severity::Error,
217            LlmSeverity::Medium => Severity::Warning,
218            LlmSeverity::Low | LlmSeverity::Info => Severity::Info,
219        }
220    }
221
222    /// The `critical|high|medium|low|info` alternation, for the prompt.
223    ///
224    /// Rendered from `NAMES`, which is itself derived from `ALL`, so a level
225    /// added to the enum reaches the prompt without anyone remembering to
226    /// update it.
227    pub fn alternation() -> String {
228        Self::NAMES.join("|")
229    }
230}
231
232impl FromStr for LlmSeverity {
233    type Err = UnknownSeverity;
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        LlmSeverity::ALL
237            .into_iter()
238            .find(|level| level.as_str() == s)
239            .ok_or_else(|| UnknownSeverity {
240                value: s.to_owned(),
241                expected: &LlmSeverity::NAMES,
242            })
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn severity_orders_lowest_first() {
252        assert!(Severity::Info < Severity::Warning);
253        assert!(Severity::Warning < Severity::Error);
254    }
255
256    #[test]
257    fn all_is_in_rank_order_and_complete() {
258        // Guards the invariant that `ALL` and the derived `Ord` agree; a
259        // variant added out of order would make `ALL` a second, wrong ranking.
260        assert!(Severity::ALL.is_sorted());
261    }
262
263    #[test]
264    fn gating_at_error_admits_only_error() {
265        let threshold = Severity::Error;
266        assert!(Severity::Error >= threshold);
267        assert!(Severity::Warning < threshold);
268        assert!(Severity::Info < threshold);
269    }
270
271    #[test]
272    fn wire_names_round_trip() {
273        for sev in Severity::ALL {
274            assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
275            assert_eq!(sev.to_string(), sev.as_str());
276        }
277    }
278
279    #[test]
280    fn unknown_severity_is_an_error_not_a_default() {
281        let err = "critical".parse::<Severity>().unwrap_err();
282        assert_eq!(err.value, "critical");
283        assert!(err.to_string().contains("critical"));
284        // The message must list the vocabulary, so a producer mismatch is
285        // diagnosable from the error alone.
286        assert!(err.to_string().contains("info, warning, error"));
287    }
288
289    #[test]
290    fn parsing_is_case_sensitive() {
291        // Producers emit lowercase. Accepting "ERROR" would mean quietly
292        // normalising, and normalising is how a second vocabulary starts.
293        assert!("ERROR".parse::<Severity>().is_err());
294    }
295
296    #[test]
297    fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
298        // The two scales share one error type. Reporting drep's three levels
299        // for a rejected LLM level sends the reader looking for a value the
300        // parser never accepts.
301        let err = "blocker".parse::<LlmSeverity>().unwrap_err();
302        let msg = err.to_string();
303        assert!(
304            msg.contains("critical, high, medium, low, info"),
305            "got {msg}"
306        );
307        assert!(
308            !msg.contains("warning"),
309            "must not quote drep's scale: {msg}"
310        );
311
312        let err = "blocker".parse::<Severity>().unwrap_err();
313        let msg = err.to_string();
314        assert!(msg.contains("info, warning, error"), "got {msg}");
315        assert!(
316            !msg.contains("critical"),
317            "must not quote the LLM scale: {msg}"
318        );
319    }
320
321    #[test]
322    fn llm_severity_wire_names_round_trip() {
323        for level in LlmSeverity::ALL {
324            assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
325        }
326        assert!("blocker".parse::<LlmSeverity>().is_err());
327    }
328
329    #[test]
330    fn llm_severity_collapses_onto_the_three_level_vocabulary() {
331        // All five in one assertion: a single hardcoded mapping cannot pass.
332        let mapped: Vec<Severity> = LlmSeverity::ALL
333            .into_iter()
334            .map(LlmSeverity::to_severity)
335            .collect();
336        assert_eq!(
337            mapped,
338            vec![
339                Severity::Error,
340                Severity::Error,
341                Severity::Warning,
342                Severity::Info,
343                Severity::Info
344            ]
345        );
346    }
347
348    #[test]
349    fn alternation_is_derived_from_all_not_written_out() {
350        assert_eq!(LlmSeverity::alternation(), "critical|high|medium|low|info");
351        // Every level must appear, so adding one cannot silently miss the prompt.
352        for level in LlmSeverity::ALL {
353            assert!(LlmSeverity::alternation().contains(level.as_str()));
354        }
355    }
356}