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 wire_names_round_trip() {
260 for sev in Severity::ALL {
261 assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
262 assert_eq!(sev.to_string(), sev.as_str());
263 }
264 }
265
266 #[test]
267 fn unknown_severity_is_an_error_not_a_default() {
268 let err = "critical".parse::<Severity>().unwrap_err();
269 assert_eq!(err.value, "critical");
270 assert!(err.to_string().contains("critical"));
271 // The message must list the vocabulary, so a producer mismatch is
272 // diagnosable from the error alone.
273 assert!(err.to_string().contains("info, warning, error"));
274 }
275
276 #[test]
277 fn parsing_is_case_sensitive() {
278 // Producers emit lowercase. Accepting "ERROR" would mean quietly
279 // normalising, and normalising is how a second vocabulary starts.
280 assert!("ERROR".parse::<Severity>().is_err());
281 }
282
283 #[test]
284 fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
285 // The two scales share one error type. Reporting drep's three levels
286 // for a rejected LLM level sends the reader looking for a value the
287 // parser never accepts.
288 let err = "blocker".parse::<LlmSeverity>().unwrap_err();
289 let msg = err.to_string();
290 assert!(
291 msg.contains("critical, high, medium, low, info"),
292 "got {msg}"
293 );
294 assert!(
295 !msg.contains("warning"),
296 "must not quote drep's scale: {msg}"
297 );
298
299 let err = "blocker".parse::<Severity>().unwrap_err();
300 let msg = err.to_string();
301 assert!(msg.contains("info, warning, error"), "got {msg}");
302 assert!(
303 !msg.contains("critical"),
304 "must not quote the LLM scale: {msg}"
305 );
306 }
307
308 #[test]
309 fn llm_severity_wire_names_round_trip() {
310 for level in LlmSeverity::ALL {
311 assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
312 }
313 assert!("blocker".parse::<LlmSeverity>().is_err());
314 }
315
316 #[test]
317 fn llm_severity_collapses_onto_the_three_level_vocabulary() {
318 // All five in one assertion: a single hardcoded mapping cannot pass.
319 let mapped: Vec<Severity> = LlmSeverity::ALL
320 .into_iter()
321 .map(LlmSeverity::to_severity)
322 .collect();
323 assert_eq!(
324 mapped,
325 vec![
326 Severity::Error,
327 Severity::Error,
328 Severity::Warning,
329 Severity::Info,
330 Severity::Info
331 ]
332 );
333 }
334
335 #[test]
336 fn review_vocabulary_excludes_advisory_levels() {
337 assert_eq!(LlmSeverity::review_alternation(), "critical|high|medium");
338 assert_eq!(LlmSeverity::REVIEW_NAMES, ["critical", "high", "medium"]);
339 }
340}