Skip to main content

drep/analysis/
findings.rs

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