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}