drep/analysis/result.rs
1//! What one analysis pass produced.
2//!
3//! A single pass over a file can produce findings AND fail to fully analyze
4//! the file (a truncated response gives a partial list, an unknown severity
5//! in one record fails the file, a transport error produces zero findings but
6//! still surfaces as a failure). Reporting the findings while forgetting the
7//! failure is the exact bug this type exists to prevent — the gate would
8//! green-light a commit whenever the LLM endpoint was unreachable, which is
9//! worse than having no gate at all.
10//!
11//! `failed_files` is a [`BTreeMap`] rather than a `Vec` because two passes
12//! over the same file set must UNION, never sum. Summing counts one
13//! unreachable endpoint twice, drifting the failure count up without any
14//! matching file to investigate. The map's value carries the reason so the
15//! caller can render something a user can act on, not just a path.
16//!
17//! `dropped_out_of_range` counts rather than silently drops out-of-range
18//! findings so that a model which consistently reports wrong lines is
19//! observable to the caller — not invisible.
20
21use std::collections::BTreeMap;
22use std::fmt;
23use std::path::PathBuf;
24
25use crate::analysis::findings::Finding;
26use crate::llm::error::BackendErrorKind;
27
28/// Why one file went unanalyzed.
29///
30/// A bare set of paths cannot tell a dead endpoint from a rate limit from a
31/// truncated response, and the caller needs that to print something a user can
32/// act on. `LlmError` already carries the detail; it used to be discarded at
33/// the analyzer boundary.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum FailureReason {
36 /// The endpoint was unreachable, or returned a retryable status too many
37 /// times. `status` is the HTTP code when there was one.
38 Transport {
39 status: Option<u16>,
40 message: String,
41 },
42 /// A non-HTTP backend failed with a structured routing class.
43 Backend {
44 kind: BackendErrorKind,
45 message: String,
46 },
47 /// A response arrived and no JSON could be extracted from it.
48 Unparseable(String),
49 /// Cache-only review found no response for this exact prompt and provider.
50 CacheMiss,
51 /// A fresh semantic review was required after the configured remediation
52 /// budget had already been consumed. This is fail-closed: cached reviews
53 /// remain usable, but uncached code is never waved through unseen.
54 ReviewLimit { completed: u32, limit: u32 },
55 /// The model stopped before producing JSON, and the server said why.
56 ///
57 /// Distinct from [`Self::Unparseable`] because the cause is known and
58 /// deterministic - an output-token cap or a content filter - so the answer
59 /// is not "ask again" but "this request cannot be served as sent". `finish`
60 /// is the server's own word for it, kept as a machine tag beside the human
61 /// message exactly as [`Self::Transport`] keeps its status.
62 ModelStopped { finish: String, message: String },
63 /// The response parsed only after closing unbalanced delimiters, so it is
64 /// a prefix of what the model meant to say.
65 Truncated,
66 /// A record in the response could not be understood - unknown severity,
67 /// missing field, unusable line number.
68 MalformedFinding(String),
69 /// A deterministic tool that should have run could not.
70 ToolUnavailable { tool: String, detail: String },
71 /// A linter did not run because it would have read working-tree content the commit does not hold. `paths` are the differing files, as the user names them.
72 ///
73 /// Distinct from [`Self::ToolUnavailable`]: the tool is installed and
74 /// configured, but in staged and branch modes it reads the working tree,
75 /// and linting a staged file's uncommitted edit or a checkout that is not
76 /// the pushed tip would judge content the commit does not contain - a
77 /// lint-failing commit would pass. Refusing the run keeps
78 /// "unanalyzed is not clean" true for the deterministic layer too.
79 UncommittedChanges { tool: String, paths: Vec<PathBuf> },
80 /// Machine site policy refuses to have this repository's source reviewed by
81 /// a model, because a marker file is present at its root.
82 ///
83 /// A `FailureReason` rather than a run-level field because this is the type
84 /// the whole exit-2 contract already rests on: putting the refusal in
85 /// `failed_files` makes `gate`, the text failure block, the JSON `unanalyzed`
86 /// array and the clean-cycle reset guard treat it correctly with no second
87 /// mechanism to keep in agreement. A consumer asking "did semantic review
88 /// happen" is then told no, rather than handed an empty `unanalyzed` beside
89 /// exit 2.
90 ///
91 /// Both paths are carried because a developer meeting this for the first time
92 /// reads it as a broken install unless it names the file that caused it and
93 /// the policy that asked for it.
94 SitePolicyRefused { marker: PathBuf, policy: PathBuf },
95 /// The file on disk exceeded the read guard, so drep never read it.
96 ///
97 /// Distinct from [`Self::PayloadTooLarge`] because the two measure
98 /// different things: this is the file's own size, checked before any I/O,
99 /// and that one is the size of the text the model would have been sent.
100 /// They were one variant sharing one limit, which meant `bytes` held the
101 /// file size on one code path and the rendered-payload size on another -
102 /// so "file is too large (330102 bytes)" could name a file that `ls`
103 /// reports as 261900 bytes.
104 FileTooLarge { bytes: u64, limit: u64 },
105 /// The rendered LLM payload exceeded the ceiling. See [`Self::FileTooLarge`].
106 PayloadTooLarge { bytes: u64, limit: u64 },
107 /// The file could not be read from disk.
108 Unreadable(String),
109 /// The user named a file that the running command has no analyzer for.
110 ///
111 /// Only ever produced for an **explicitly named** path. A walk that turns
112 /// up nothing analyzable is legitimately empty - `drep check .` in a
113 /// documentation repository has correctly found no code. A path the user
114 /// typed is different: reporting "No issues found." for a file drep
115 /// declined to look at is the single failure this codebase is built to
116 /// prevent, and it is the same distinction `resolve_paths` already draws
117 /// for an argument that does not exist at all.
118 ///
119 /// `hint` names the command that *does* handle the type, when there is
120 /// one. Markdown has `drep lint-docs`, so the error is a redirection
121 /// rather than a dead end.
122 Unsupported {
123 /// The extension as written, with its dot. `None` when the file has
124 /// none, which reads differently in the message.
125 extension: Option<String>,
126 /// What to run instead, phrased as an imperative.
127 hint: Option<String>,
128 },
129 /// A failover chain produced no answer, with what each provider
130 /// contributed.
131 ///
132 /// Only produced for a chain of **two or more** providers. A one-provider
133 /// config - what `drep init` writes, and what almost every run uses -
134 /// collapses to that provider's own reason, so it reports exactly what it
135 /// did before failover existed, JSON `kind` included. The trigger is the
136 /// chain's length, not the number of providers that failed: a two-provider
137 /// chain stopped dead at the head by a 401 has one failure and is exactly
138 /// the case where "which provider, and why did my fallback not run" is the
139 /// user's live question.
140 ///
141 /// Keeping only the last reason would hide a dead local endpoint behind
142 /// the cloud fallback's 401; keeping only the first would hide the broken
143 /// fallback. A user fixing the run needs both.
144 ///
145 /// The list can be shorter than the chain - a 401 at the head stops it, and
146 /// the providers below were never consulted.
147 ChainFailed(Vec<ProviderFailure>),
148}
149
150/// One provider's contribution to a file that no provider could analyze.
151///
152/// `reason` is always a non-chain LLM-layer variant. That is a property of the
153/// only thing that builds these: the conversion runs over one provider's
154/// `LlmError`, so a nested `ChainFailed` is not merely absent but unreachable.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ProviderFailure {
157 /// Zero-based position in the chain. Rendered one-based, matching how
158 /// `doctor` numbers the same list.
159 pub provider: usize,
160 /// The model that provider asks for.
161 pub model: String,
162 /// Why it did not produce an answer.
163 pub reason: FailureReason,
164 /// True when the provider was already demoted and was not contacted for
165 /// this file. Worth reporting: a user needs to know the local endpoint has
166 /// been dead since the third file, not just that the fallback then failed.
167 pub skipped: bool,
168}
169
170impl FailureReason {
171 /// Build an [`Self::Unsupported`] for `path`.
172 ///
173 /// The extension convention (leading dot, `None` when there is none) is
174 /// stated here, beside the variant whose `one_line` renders it, rather than
175 /// at each command that raises one. It was written out twice, which is one
176 /// copy per command pointing at the other.
177 pub fn unsupported(path: &std::path::Path, hint: Option<String>) -> Self {
178 FailureReason::Unsupported {
179 extension: path
180 .extension()
181 .map(|ext| format!(".{}", ext.to_string_lossy())),
182 hint,
183 }
184 }
185
186 /// A single line suitable for a terminal, derived from the variant.
187 ///
188 /// The HTTP status is rendered next to the message so a 429 is visible
189 /// without the user having to match the message against a status code
190 /// list. This is the load-bearing reason the `Transport` variant carries
191 /// the status as a number rather than only inside the string.
192 pub fn one_line(&self) -> String {
193 match self {
194 FailureReason::Transport {
195 status: Some(code),
196 message,
197 } => {
198 format!("LLM transport failed (HTTP {code}): {message}")
199 }
200 FailureReason::Transport {
201 status: None,
202 message,
203 } => {
204 format!("LLM transport failed: {message}")
205 }
206 FailureReason::Unparseable(message) => {
207 format!("LLM response was unparseable: {message}")
208 }
209 FailureReason::CacheMiss => {
210 "LLM review is not cached; run a normal check to warm it".to_owned()
211 }
212 FailureReason::ReviewLimit { completed, limit } if completed < limit => format!(
213 "fresh LLM review capacity is currently reserved ({completed} completed of \
214 {limit}); wait for the in-flight review, pass `--max-review-rounds N`, or pass \
215 `--unlimited-reviews` to authorize another round"
216 ),
217 FailureReason::ReviewLimit { completed, limit } => format!(
218 "fresh LLM review limit reached ({completed} of {limit}); raise \
219 `max_review_rounds`, pass `--max-review-rounds N`, or pass \
220 `--unlimited-reviews` to authorize another round"
221 ),
222 FailureReason::Backend { kind, message } => {
223 format!("LLM backend {kind}: {message}")
224 }
225 // Deliberately says nothing about *which* command is running: both
226 // `check` and `lint-docs` produce this, pointing at each other.
227 FailureReason::Unsupported { extension, hint } => {
228 let what = match extension {
229 Some(ext) => format!("`{ext}` files"),
230 None => "files with no extension".to_owned(),
231 };
232 match hint {
233 Some(hint) => format!("no analyzer for {what}: {hint}"),
234 None => format!("no analyzer for {what}"),
235 }
236 }
237 // The message is already a sentence a user can act on; prefixing it
238 // with a category would bury the actionable half.
239 FailureReason::ModelStopped { message, .. } => message.clone(),
240 FailureReason::Truncated => "response was truncated".to_owned(),
241 FailureReason::MalformedFinding(detail) => format!("malformed finding: {detail}"),
242 FailureReason::ToolUnavailable { tool, detail } => {
243 format!("{tool} could not run: {detail}")
244 }
245 FailureReason::UncommittedChanges { tool, paths } => {
246 let paths = paths
247 .iter()
248 .map(|path| crate::text::path_label_of(path))
249 .collect::<Vec<_>>()
250 .join(", ");
251 format!(
252 "{tool} did not run: the working tree differs from the commit being checked at {paths}; before a commit, stage those changes or run `git stash --keep-index --include-untracked`; before a push, check out the pushed commit with nothing uncommitted"
253 )
254 }
255 FailureReason::SitePolicyRefused { marker, policy } => format!(
256 "semantic review is refused by site policy: {} is present (policy: {})",
257 crate::text::path_label_of(marker),
258 crate::text::path_label_of(policy)
259 ),
260 FailureReason::FileTooLarge { bytes, limit } => {
261 format!("file is too large to read ({bytes} bytes; limit is {limit})")
262 }
263 FailureReason::PayloadTooLarge { bytes, limit } => {
264 format!("the code sent for review is too large ({bytes} bytes; limit is {limit})")
265 }
266 FailureReason::Unreadable(detail) => format!("file could not be read: {detail}"),
267 FailureReason::ChainFailed(failures) => {
268 let each: Vec<String> = failures.iter().map(ProviderFailure::one_line).collect();
269 // Phrased by what happened, not by a count. "All N providers
270 // failed" is wrong for the case that matters most - a chain
271 // stopped at the head by a 401 has one entry and more
272 // providers behind it that were deliberately not asked.
273 if each.is_empty() {
274 "no LLM provider analyzed this file".to_owned()
275 } else {
276 format!("no LLM provider analyzed this file: {}", each.join("; "))
277 }
278 }
279 }
280 }
281
282 /// The HTTP status, when the failure had one.
283 ///
284 /// Only `Transport` ever carries one. Exposed as a number rather than
285 /// left inside the message because a caller has to distinguish a 429 from
286 /// a 401 - the message is prose and prose gets reworded.
287 pub fn status(&self) -> Option<u16> {
288 match self {
289 FailureReason::Transport { status, .. } => *status,
290 // Deliberately not "the first attempt's status". A chain failure
291 // has one status *per provider*, and flattening them to one number
292 // would tell a consumer a 401 was the whole story when a 500 came
293 // first. The JSON renderer exposes the per-provider list instead.
294 _ => None,
295 }
296 }
297}
298
299impl ProviderFailure {
300 /// One line naming the provider, its model, and what it said.
301 pub fn one_line(&self) -> String {
302 let skipped = if self.skipped {
303 " (already down earlier in this run)"
304 } else {
305 ""
306 };
307 format!(
308 "[{}] {}: {}{}",
309 self.provider + 1,
310 self.model,
311 self.reason.one_line(),
312 skipped
313 )
314 }
315}
316
317impl fmt::Display for FailureReason {
318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319 f.pad(&self.one_line())
320 }
321}
322
323/// What one analysis pass produced.
324///
325/// `findings` and `failed_files` are independent axes: a file can contribute
326/// findings AND be unanalyzed (a truncated response gives a partial list).
327/// Reporting the findings while forgetting the failure is the exact bug this
328/// type exists to prevent.
329#[derive(Debug, Default, Clone, PartialEq, Eq)]
330pub struct AnalysisResult {
331 /// Findings the analyzer could attribute to a real line of code.
332 pub findings: Vec<Finding>,
333 /// Files that could not be fully analyzed, with the reason. A `BTreeMap`
334 /// because two passes over the same file set must UNION, never sum —
335 /// summing counts one unreachable endpoint twice.
336 pub failed_files: BTreeMap<PathBuf, FailureReason>,
337 /// Findings discarded because their line was not in the payload's
338 /// `valid_lines`. Counted rather than silently dropped, so the drop is
339 /// observable.
340 pub dropped_out_of_range: usize,
341}
342
343/// Fold `src` into `dst`, keeping the reason already present on a collision.
344///
345/// The one statement of the failure-union rule. It was written out longhand as
346/// `entry().or_insert()` at four sites - `merge` here plus three in the CLI -
347/// each with its own comment re-explaining it. The two analysis layers cover
348/// the same files, so the sets union rather than sum: one unreachable endpoint
349/// is one failure, not two. First-wins because the reasons cannot be
350/// meaningfully combined and the earlier layer saw the file first.
351pub fn union_failures(
352 dst: &mut BTreeMap<PathBuf, FailureReason>,
353 src: BTreeMap<PathBuf, FailureReason>,
354) {
355 for (path, reason) in src {
356 dst.entry(path).or_insert(reason);
357 }
358}
359
360impl AnalysisResult {
361 /// One file, one failure, no findings.
362 ///
363 /// The shape was hand-assembled at four call sites - `default()`, insert,
364 /// return - each of which independently had to know that `findings` and
365 /// `dropped_out_of_range` stay at their defaults. Forgetting the insert at
366 /// any one of them reports an unanalyzed file as clean, which is the single
367 /// failure this whole type exists to prevent, so it gets a constructor.
368 pub fn failed(path: PathBuf, reason: FailureReason) -> Self {
369 let mut result = Self::default();
370 result.failed_files.insert(path, reason);
371 result
372 }
373
374 /// Fold `other` into `self`: findings concatenate, `failed_files`
375 /// unions, `dropped_out_of_range` sums.
376 ///
377 /// The merge semantics let a caller combine per-file and per-layer results
378 /// without losing the failure signal.
379 ///
380 /// On a key collision in `failed_files`, the **first** reason wins. A
381 /// file failing twice is still one failure, and the two reasons are not
382 /// meaningfully combinable - the first one is at least specific to the
383 /// file, while a hypothetical last-wins policy would let a later
384 /// analyzer overwrite a more informative first reason with a generic
385 /// one.
386 pub fn merge(&mut self, other: AnalysisResult) {
387 self.findings.extend(other.findings);
388 // `union_failures` rather than the loop written out again: the
389 // first-writer-wins rule is one decision, and two copies of it are two
390 // places for it to change independently.
391 union_failures(&mut self.failed_files, other.failed_files);
392 self.dropped_out_of_range = self
393 .dropped_out_of_range
394 .saturating_add(other.dropped_out_of_range);
395 }
396
397 /// True when any file went unanalyzed.
398 ///
399 /// The caller maps this to process exit 2: "could not analyze" is
400 /// distinct from both "clean" (exit 0) and "found issues" (exit 1),
401 /// because a gate that cannot distinguish them rubber-stamps the day
402 /// the LLM endpoint goes down.
403 pub fn has_failures(&self) -> bool {
404 !self.failed_files.is_empty()
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 /// `merge` keeps the first reason on a key collision - the documented
413 /// first-writer-wins rule. A last-wins policy would silently overwrite
414 /// the more informative first reason with a generic later one.
415 #[test]
416 fn merge_keeps_first_reason_on_key_collision() {
417 let mut a = AnalysisResult::default();
418 a.failed_files.insert(
419 PathBuf::from("src/lib.rs"),
420 FailureReason::Transport {
421 status: Some(429),
422 message: "rate limited".to_owned(),
423 },
424 );
425
426 let mut b = AnalysisResult::default();
427 b.failed_files.insert(
428 PathBuf::from("src/lib.rs"),
429 FailureReason::Transport {
430 status: Some(500),
431 message: "internal".to_owned(),
432 },
433 );
434
435 a.merge(b);
436
437 assert_eq!(a.failed_files.len(), 1);
438 let reason = a.failed_files.get(&PathBuf::from("src/lib.rs")).unwrap();
439 assert_eq!(
440 reason,
441 &FailureReason::Transport {
442 status: Some(429),
443 message: "rate limited".to_owned(),
444 },
445 "first reason wins on collision"
446 );
447 }
448
449 /// A `Transport` failure with a status surfaces the code in the rendered
450 /// line. The whole point of keeping the status as a number is that it
451 /// reaches the user; this pins that the rendering preserves it.
452 #[test]
453 fn transport_render_includes_the_http_status() {
454 let reason = FailureReason::Transport {
455 status: Some(429),
456 message: "rate limited".to_owned(),
457 };
458 let rendered = reason.one_line();
459 assert!(
460 rendered.contains("429"),
461 "rendered line must contain 429, got {rendered:?}"
462 );
463 }
464
465 /// A refusal names both the marker and the policy that asked for it.
466 ///
467 /// Pinned at the type that owns the wording, beside the `Transport`-status
468 /// test, and for the same reason: the paths are carried as fields precisely so
469 /// they reach the user. A developer meeting this line for the first time reads
470 /// it as a broken install unless it says which file caused it and where the
471 /// decision came from.
472 #[test]
473 fn site_policy_refusal_names_the_marker_and_the_policy_file() {
474 let reason = FailureReason::SitePolicyRefused {
475 marker: PathBuf::from("/work/repo/.drep-no-llm"),
476 policy: PathBuf::from("/etc/drep/site.toml"),
477 };
478 let rendered = reason.one_line();
479 assert!(
480 rendered.contains("/work/repo/.drep-no-llm"),
481 "must name the marker, got {rendered:?}"
482 );
483 assert!(
484 rendered.contains("/etc/drep/site.toml"),
485 "must name the policy, got {rendered:?}"
486 );
487 }
488
489 #[test]
490 fn display_honours_formatter_width_and_alignment() {
491 let reason = FailureReason::Truncated;
492 assert_eq!(
493 format!("{reason:>30}"),
494 format!("{:>30}", reason.one_line())
495 );
496 }
497}