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 LLM severity vocabulary and its mapping onto [`Severity`].
163///
164/// New reviews request only critical/high/medium findings. The parser retains
165/// low/info for compatibility with cached and unconstrained provider responses;
166/// a recognized legacy level must not make the entire file `Malformed`.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum LlmSeverity {
169    Critical,
170    High,
171    Medium,
172    Low,
173    Info,
174}
175
176impl LlmSeverity {
177    /// Every level, most severe first - the order the prompt lists them in.
178    pub const ALL: [LlmSeverity; 5] = [
179        LlmSeverity::Critical,
180        LlmSeverity::High,
181        LlmSeverity::Medium,
182        LlmSeverity::Low,
183        LlmSeverity::Info,
184    ];
185
186    /// The material levels a new review is allowed to emit.
187    pub const REVIEW: [LlmSeverity; 3] = [
188        LlmSeverity::Critical,
189        LlmSeverity::High,
190        LlmSeverity::Medium,
191    ];
192
193    /// Every wire name, most severe first. Derived from `ALL` — see
194    /// [`Severity::NAMES`].
195    pub const NAMES: [&'static str; 5] = [
196        Self::ALL[0].as_str(),
197        Self::ALL[1].as_str(),
198        Self::ALL[2].as_str(),
199        Self::ALL[3].as_str(),
200        Self::ALL[4].as_str(),
201    ];
202
203    /// Wire names exposed by the prompt and strict output schema.
204    pub const REVIEW_NAMES: [&'static str; 3] = [
205        Self::REVIEW[0].as_str(),
206        Self::REVIEW[1].as_str(),
207        Self::REVIEW[2].as_str(),
208    ];
209
210    /// The wire name, as the prompt asks for it and the response carries it.
211    pub const fn as_str(self) -> &'static str {
212        match self {
213            LlmSeverity::Critical => "critical",
214            LlmSeverity::High => "high",
215            LlmSeverity::Medium => "medium",
216            LlmSeverity::Low => "low",
217            LlmSeverity::Info => "info",
218        }
219    }
220
221    /// Collapse onto drep's three-level vocabulary.
222    pub const fn to_severity(self) -> Severity {
223        match self {
224            LlmSeverity::Critical | LlmSeverity::High => Severity::Error,
225            LlmSeverity::Medium => Severity::Warning,
226            LlmSeverity::Low | LlmSeverity::Info => Severity::Info,
227        }
228    }
229
230    /// The `critical|high|medium` alternation exposed by the prompt.
231    pub fn review_alternation() -> String {
232        Self::REVIEW_NAMES.join("|")
233    }
234}
235
236impl FromStr for LlmSeverity {
237    type Err = UnknownSeverity;
238
239    fn from_str(s: &str) -> Result<Self, Self::Err> {
240        LlmSeverity::ALL
241            .into_iter()
242            .find(|level| level.as_str() == s)
243            .ok_or_else(|| UnknownSeverity {
244                value: s.to_owned(),
245                expected: &LlmSeverity::NAMES,
246            })
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn severity_orders_lowest_first() {
256        assert!(Severity::Info < Severity::Warning);
257        assert!(Severity::Warning < Severity::Error);
258    }
259
260    #[test]
261    fn all_is_in_rank_order_and_complete() {
262        // Guards the invariant that `ALL` and the derived `Ord` agree; a
263        // variant added out of order would make `ALL` a second, wrong ranking.
264        assert!(Severity::ALL.is_sorted());
265    }
266
267    #[test]
268    fn gating_at_error_admits_only_error() {
269        let threshold = Severity::Error;
270        assert!(Severity::Error >= threshold);
271        assert!(Severity::Warning < threshold);
272        assert!(Severity::Info < threshold);
273    }
274
275    #[test]
276    fn wire_names_round_trip() {
277        for sev in Severity::ALL {
278            assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
279            assert_eq!(sev.to_string(), sev.as_str());
280        }
281    }
282
283    #[test]
284    fn unknown_severity_is_an_error_not_a_default() {
285        let err = "critical".parse::<Severity>().unwrap_err();
286        assert_eq!(err.value, "critical");
287        assert!(err.to_string().contains("critical"));
288        // The message must list the vocabulary, so a producer mismatch is
289        // diagnosable from the error alone.
290        assert!(err.to_string().contains("info, warning, error"));
291    }
292
293    #[test]
294    fn parsing_is_case_sensitive() {
295        // Producers emit lowercase. Accepting "ERROR" would mean quietly
296        // normalising, and normalising is how a second vocabulary starts.
297        assert!("ERROR".parse::<Severity>().is_err());
298    }
299
300    #[test]
301    fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
302        // The two scales share one error type. Reporting drep's three levels
303        // for a rejected LLM level sends the reader looking for a value the
304        // parser never accepts.
305        let err = "blocker".parse::<LlmSeverity>().unwrap_err();
306        let msg = err.to_string();
307        assert!(
308            msg.contains("critical, high, medium, low, info"),
309            "got {msg}"
310        );
311        assert!(
312            !msg.contains("warning"),
313            "must not quote drep's scale: {msg}"
314        );
315
316        let err = "blocker".parse::<Severity>().unwrap_err();
317        let msg = err.to_string();
318        assert!(msg.contains("info, warning, error"), "got {msg}");
319        assert!(
320            !msg.contains("critical"),
321            "must not quote the LLM scale: {msg}"
322        );
323    }
324
325    #[test]
326    fn llm_severity_wire_names_round_trip() {
327        for level in LlmSeverity::ALL {
328            assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
329        }
330        assert!("blocker".parse::<LlmSeverity>().is_err());
331    }
332
333    #[test]
334    fn llm_severity_collapses_onto_the_three_level_vocabulary() {
335        // All five in one assertion: a single hardcoded mapping cannot pass.
336        let mapped: Vec<Severity> = LlmSeverity::ALL
337            .into_iter()
338            .map(LlmSeverity::to_severity)
339            .collect();
340        assert_eq!(
341            mapped,
342            vec![
343                Severity::Error,
344                Severity::Error,
345                Severity::Warning,
346                Severity::Info,
347                Severity::Info
348            ]
349        );
350    }
351
352    #[test]
353    fn review_vocabulary_excludes_advisory_levels() {
354        assert_eq!(LlmSeverity::review_alternation(), "critical|high|medium");
355        assert_eq!(LlmSeverity::REVIEW_NAMES, ["critical", "high", "medium"]);
356    }
357}