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}