drep/analysis/code_quality.rs
1//! The code-quality analyzer: render a payload, ask the LLM, turn the
2//! response into findings — and never report an unanalyzed file as clean.
3//!
4//! The analyzer enforces these contracts:
5//!
6//! 1. No language, no analysis. drep has no opinion on a file type it does
7//! not claim, and silently returning an empty result is the correct
8//! behavior — *not* a failure. The CLI surfaces "no language" by simply
9//! not including the file in the work set.
10//! 2. Empty hunks → empty result, no LLM call.
11//! 3. Build the payload with `payload::render`. `None` → empty result.
12//! 4. Cache first. A hit is parsed exactly as a `Complete` response would
13//! be, and the duplicate is silent — the caller's view is identical.
14//! 5. Concurrency. A limiter slot is acquired before the LLM call and held
15//! for the duration. A cache hit must not acquire a slot: the slot
16//! represents in-flight HTTP work, and a cache read is not in-flight.
17//! 6. `Ok(Extracted::Complete)` → parse, store in the cache.
18//! 7. `Ok(Extracted::Truncated)` → parse the partial result AND mark the
19//! file failed. Never cache a truncated response — caching it makes one
20//! truncation permanent for the whole TTL, and this layer does not know
21//! about `--fail-on` (a caller deciding otherwise would make
22//! `failed_files` depend on a CLI flag, which is the wrong layering).
23//! 8. `Err(LlmError::*)` → no findings, file in `failed_files` with the
24//! specific LLM layer that failed.
25//!
26//! The five rules around out-of-range lines, missing fields, unknown
27//! severities, and `issues` itself being absent are at the boundary between
28//! "model misreported" and "we could not understand the response". The first
29//! is a *finding* we drop; the others are *file-level failures*, because a
30//! file we did not fully understand must never be reported clean.
31
32use std::path::Path;
33
34use futures::future::join_all;
35use serde_json::Value;
36
37use crate::analysis::findings::{Finding, LlmSeverity};
38use crate::analysis::payload;
39use crate::analysis::prompt::build_analysis_prompt;
40use crate::analysis::response_contract::{
41 CATEGORY, COMPILE_FAILURE, ISSUES, LINE, MESSAGE, SEVERITY, SUGGESTION,
42};
43use crate::analysis::result::{AnalysisResult, FailureReason, ProviderFailure};
44use crate::diff::hunks::Hunk;
45use crate::diff::hunks::group_by_file;
46use crate::languages;
47use crate::llm::cache::Cache;
48use crate::llm::chain::{ChainError, ProviderChain};
49use crate::llm::error::LlmError;
50use crate::llm::json_parsing::Extracted;
51
52/// The code-quality analyzer.
53///
54/// Built once per process. The `chain` and the `cache` are both passed in
55/// rather than constructed here: they are process-wide resources. The chain in
56/// particular carries the concurrency limiter for each provider *and* the
57/// record of which providers have been demoted, so a second chain would both
58/// double the in-flight requests against one endpoint and re-discover a dead
59/// provider the first one already knew about.
60///
61/// The model and temperature are **not** duplicated onto this struct. They
62/// belong to the provider that ends up answering, which is not known until the
63/// chain has run, and a second copy is exactly what lets a request go to one
64/// model while the cache key names another.
65pub struct CodeQualityAnalyzer {
66 pub(crate) chain: ProviderChain,
67 pub(crate) cache: Cache,
68 cache_only: bool,
69}
70
71impl CodeQualityAnalyzer {
72 /// Build from a provider chain and a shared cache.
73 ///
74 /// Infallible: `ProviderChain::new` has already rejected an empty or
75 /// misconfigured chain, so there is nothing left for this to validate.
76 /// `cache` is a parameter rather than constructed here so the key stays
77 /// independent of the `Cache` root (see [`Cache::key`]) and a test can
78 /// point it at a `TempDir`.
79 pub fn new(chain: ProviderChain, cache: Cache) -> Self {
80 Self {
81 chain,
82 cache,
83 cache_only: false,
84 }
85 }
86
87 /// Select a cache-only pass. The analyzer keeps parsing cached responses
88 /// through the normal schema path; only the backend request is forbidden.
89 pub fn with_cache_only(mut self, cache_only: bool) -> Self {
90 self.cache_only = cache_only;
91 self
92 }
93
94 /// The provider chain, for a caller reporting which providers served.
95 pub fn chain(&self) -> &ProviderChain {
96 &self.chain
97 }
98
99 /// Analyze one file's hunks.
100 ///
101 /// Returns an [`AnalysisResult`] populated with whatever the file
102 /// produced: findings, a failure marker, or both. The result is never a
103 /// bare `Vec<Finding>`; the failure axis is part of the return type so
104 /// the caller cannot forget it.
105 pub async fn analyze_file(&self, hunks: &[Hunk]) -> AnalysisResult {
106 self.analyze_files_in_mode(std::iter::once(hunks), self.cache_only)
107 .await
108 }
109
110 async fn analyze_file_in_mode(&self, hunks: &[Hunk], cache_only: bool) -> AnalysisResult {
111 // Rule 1: no language, no analysis. `languages::detect` on the
112 // first hunk's file path is enough because every hunk in `by_file`
113 // shares a path (the diff module groups by file).
114 let Some(first) = hunks.first() else {
115 return AnalysisResult::default();
116 };
117 let Some(language) = languages::detect(&first.file_path) else {
118 return AnalysisResult::default();
119 };
120
121 // Rule 3: payload. `render` returns `None` only for an empty slice,
122 // which the `hunks.first()` guard above has already excluded - rule 2
123 // and rule 3 are the same check, so a second `hunks.is_empty()` here
124 // would be unreachable rather than defensive.
125 let Some(payload) = payload::render(language, hunks) else {
126 return AnalysisResult::default();
127 };
128
129 // The size ceiling is enforced on the *rendered payload*, so it holds
130 // for every input mode. Checking the file size during paths-mode input
131 // resolution - as this used to, and still does as a pre-filter - left
132 // `--staged` and `--diff` unguarded, which are the two modes a commit
133 // gate actually runs in: a newly-added 5 MB file reached the model
134 // whole. Too large is a *failure*, not a skip; a file drep declined to
135 // analyze is not clean.
136 let rendered = u64::try_from(payload.text.len()).unwrap_or(u64::MAX);
137 if rendered > payload::PAYLOAD_MAX_BYTES {
138 return AnalysisResult::failed(
139 first.file_path.clone(),
140 FailureReason::PayloadTooLarge {
141 bytes: rendered,
142 limit: payload::PAYLOAD_MAX_BYTES,
143 },
144 );
145 }
146
147 let system_prompt = build_analysis_prompt(language);
148
149 // Rules 4, 5 and the failover loop all live in the chain: the cache is
150 // consulted per provider (a hit costs no concurrency slot, because the
151 // slot represents in-flight HTTP work), and the key comes back naming
152 // whoever answered. Computing a key here would be computing it for a
153 // provider that may not be the one that serves the file.
154 let served = if cache_only {
155 match self
156 .chain
157 .cached_json(&system_prompt, &payload.text, &self.cache)
158 {
159 Some(served) => Ok(served),
160 None => {
161 return AnalysisResult::failed(
162 first.file_path.clone(),
163 FailureReason::CacheMiss,
164 );
165 }
166 }
167 } else {
168 self.chain
169 .complete_json(&system_prompt, &payload.text, &self.cache)
170 .await
171 };
172
173 match served {
174 // Rule 6: complete → parse, store in the cache.
175 // Rules 6 and 7 in one arm. Both never-cache rules live in the
176 // `if let` below rather than being split across two arms, where
177 // the `Complete` arm's guard could only ever be true.
178 Ok(served) => {
179 let result = parse_response(&payload, &first.file_path, &served.extracted);
180 // Cache only a `Complete` response we fully understood. A
181 // truncated one is a prefix, and a body can be valid JSON and
182 // still schema-invalid - a missing `issues` array, a record
183 // with an unknown severity - which yields a file-level
184 // failure. Caching either replays it for the whole TTL
185 // instead of letting the next run ask again.
186 //
187 // `served.key`, never a key computed here: the entry must be
188 // filed under the model that produced it, or a later run with
189 // the head restored gets a hit that never came from the head.
190 //
191 // The write itself is best-effort: a cache failure is a
192 // diagnostic, not a failure of the analysis.
193 if let Extracted::Complete(value) = &served.extracted
194 && !served.from_cache
195 && result.failed_files.is_empty()
196 {
197 let _ = self.cache.put(&served.key, value);
198 }
199 result
200 }
201 // Rule 8: no provider produced an answer → no findings, file in
202 // `failed_files` with every provider's reason. The detail is kept
203 // rather than discarded, so the CLI can render a line the user can
204 // act on.
205 Err(err) => AnalysisResult::failed(first.file_path.clone(), chain_failure_reason(err)),
206 }
207 }
208
209 /// Analyze many files concurrently, bounded by the limiter.
210 ///
211 /// Each entry of `by_file` is one file's hunks; the limiter bounds the
212 /// in-flight requests, so we spawn them all and let it queue. The
213 /// per-file results are merged with [`AnalysisResult::merge`].
214 pub async fn analyze_files(&self, by_file: &[Vec<Hunk>]) -> AnalysisResult {
215 self.analyze_files_in_mode(by_file.iter().map(Vec::as_slice), self.cache_only)
216 .await
217 }
218
219 /// Analyze selected cache misses without rebuilding the provider chain.
220 ///
221 /// Push-gate mode uses this after its cache-only pass, preserving sticky
222 /// demotion and backend diagnostics while avoiding a second pass over
223 /// every cache hit in the diff.
224 pub async fn analyze_files_live(&self, by_file: &[&[Hunk]]) -> AnalysisResult {
225 self.analyze_files_in_mode(by_file.iter().copied(), false)
226 .await
227 }
228
229 async fn analyze_files_in_mode<'a>(
230 &self,
231 by_file: impl IntoIterator<Item = &'a [Hunk]>,
232 cache_only: bool,
233 ) -> AnalysisResult {
234 let groups: Vec<HunkGroup<'a>> = by_file.into_iter().flat_map(partition_hunks).collect();
235 let futures = groups
236 .iter()
237 .map(|group| self.analyze_file_in_mode(group.as_slice(), cache_only));
238 let results = join_all(futures).await;
239 let mut merged = AnalysisResult::default();
240 for result in results {
241 merged.merge(result);
242 }
243 merged
244 }
245}
246
247/// A correctly grouped borrowed slice, or owned groups recovered from a mixed one.
248enum HunkGroup<'a> {
249 Borrowed(&'a [Hunk]),
250 Owned(Vec<Hunk>),
251}
252
253impl HunkGroup<'_> {
254 fn as_slice(&self) -> &[Hunk] {
255 match self {
256 Self::Borrowed(hunks) => hunks,
257 Self::Owned(hunks) => hunks,
258 }
259 }
260}
261
262/// Preserve the zero-copy normal path and partition only malformed input.
263fn partition_hunks(hunks: &[Hunk]) -> Vec<HunkGroup<'_>> {
264 let mixed = hunks
265 .first()
266 .is_some_and(|first| hunks.iter().any(|hunk| hunk.file_path != first.file_path));
267 if !mixed {
268 return vec![HunkGroup::Borrowed(hunks)];
269 }
270 group_by_file(hunks.iter().cloned())
271 .into_iter()
272 .map(HunkGroup::Owned)
273 .collect()
274}
275
276/// Map an `LlmError` to the failure reason the caller carries in
277/// `AnalysisResult::failed_files`.
278///
279/// Distinct from the parsing-path reasons because the LLM layer's failure
280/// modes are a different axis. HTTP failures preserve a numeric status and
281/// process backends preserve a stable typed kind, so callers never have to
282/// recover policy from human-readable messages.
283pub(crate) fn into_failure_reason(err: LlmError) -> FailureReason {
284 match err {
285 LlmError::Transport { status, message } => FailureReason::Transport { status, message },
286 LlmError::Unparseable(message) => FailureReason::Unparseable(message),
287 LlmError::ModelStopped { finish, message } => {
288 FailureReason::ModelStopped { finish, message }
289 }
290 // `NotConfigured` is a configuration failure at the LLM boundary —
291 // not a connectivity failure, but indistinguishable from one to the
292 // gate, which only cares whether the file was analyzed. Mapping to
293 // `Transport { status: None }` keeps the exit code 2 path uniform
294 // without inventing a new variant the JSON output would have to
295 // distinguish.
296 LlmError::NotConfigured(message) => FailureReason::Transport {
297 status: None,
298 message,
299 },
300 LlmError::Backend { kind, message } => FailureReason::Backend { kind, message },
301 }
302}
303
304/// Map a whole-chain failure to the reason the caller carries.
305///
306/// **A one-provider chain collapses to that provider's own reason.** That keeps
307/// a single-provider config - what `drep init` writes - reporting exactly what
308/// it reported before failover existed, down to the JSON `kind`.
309///
310/// The trigger is the chain's *length*, not the number of providers that
311/// failed. Those differ precisely where it matters: a two-provider chain
312/// stopped at the head by a 401 produces one attempt, and collapsing it would
313/// discard the provider index and the model name just as the user is asking
314/// "I configured a fallback - why didn't it run?".
315fn chain_failure_reason(err: ChainError) -> FailureReason {
316 let mut attempts = err.attempts;
317 if err.chain_len == 1
318 && let Some(only) = attempts.pop()
319 {
320 // Move the attempt out so the error string is not cloned on the
321 // overwhelmingly common one-provider path.
322 return into_failure_reason(only.error);
323 }
324 FailureReason::ChainFailed(
325 attempts
326 .into_iter()
327 .map(|attempt| ProviderFailure {
328 provider: attempt.provider,
329 model: attempt.model,
330 reason: into_failure_reason(attempt.error),
331 skipped: attempt.skipped,
332 })
333 .collect(),
334 )
335}
336
337/// Turn an `Extracted` value into an [`AnalysisResult`].
338///
339/// A free function, not a method: it reads no analyzer state, and keeping it
340/// free means the whole parsing core is testable without a `MockServer`, a
341/// `Cache` and a `TempDir`.
342///
343/// The truncation flag is **read off the discriminant**, never passed in
344/// beside it. An earlier shape took `extracted` *and* a `truncated: bool`,
345/// which let `(Extracted::Truncated(v), false)` compile and report a
346/// truncated file as clean - the single outcome this module exists to
347/// prevent, resting on four call sites agreeing by convention.
348fn parse_response(
349 payload: &payload::Payload,
350 file_path: &Path,
351 extracted: &Extracted,
352) -> AnalysisResult {
353 let mut result = AnalysisResult::default();
354
355 let (value, truncated) = match extracted {
356 Extracted::Complete(value) => (value, false),
357 // Rule 7: a truncated response is a prefix of what the model meant,
358 // so the file is unanalyzed however good the partial findings look.
359 Extracted::Truncated(value) => (value, true),
360 };
361
362 // The response shape is `{"issues": [...], "summary": "..."}`. Anything
363 // else is malformed, and the whole file is unanalyzed.
364 let Some(issues) = value.get(ISSUES).and_then(Value::as_array) else {
365 // Truncation wins over "no `issues` array": a response cut off before
366 // it reached `issues` has no array *because* it was truncated, and
367 // reporting that as a malformed record hides the real cause.
368 let reason = if truncated {
369 FailureReason::Truncated
370 } else {
371 FailureReason::MalformedFinding("response has no `issues` array".to_owned())
372 };
373 result.failed_files.insert(file_path.to_path_buf(), reason);
374 return result;
375 };
376
377 // Every finding carries the same path string. Allocate it only once the
378 // response has an issues array; the missing-array failure above does not
379 // need it.
380 let path_string = file_path.to_string_lossy().into_owned();
381 let mut failure = truncated.then_some(FailureReason::Truncated);
382
383 // `issues: []` is a legitimate clean result: empty findings, no failure.
384 for issue in issues {
385 match parse_issue(issue, payload, &path_string) {
386 IssueOutcome::Finding(finding) => result.findings.push(finding),
387 IssueOutcome::Dropped => result.dropped_out_of_range += 1,
388 // Do not return early: a malformed record in the middle of an
389 // otherwise-valid array should still let the well-formed records
390 // through. The failure class is "we do not fully understand the
391 // response", not "every record is wrong".
392 // First reason wins, matching `union_failures`: the reasons are not
393 // meaningfully combinable, and the last-writer version reported
394 // whichever malformed record happened to sit at the end of the
395 // array rather than the one that first told us the response was
396 // not understood.
397 IssueOutcome::Malformed(detail) => {
398 failure.get_or_insert(FailureReason::MalformedFinding(detail));
399 }
400 }
401 }
402
403 if let Some(reason) = failure {
404 result.failed_files.insert(file_path.to_path_buf(), reason);
405 }
406 result
407}
408
409/// Parse one issue record into a [`Finding`], a drop, or a malformed
410/// marker with a reason.
411///
412/// The three outcomes are deliberately distinct:
413///
414/// - `Finding`: a valid record attributed to a real line in the
415/// payload. The caller adds it to `findings`.
416/// - `Dropped`: a valid record whose line was not in
417/// `payload.valid_lines`. The caller increments
418/// `dropped_out_of_range`. The file is **not** marked failed: we
419/// understood the record perfectly, it was simply about code the
420/// model was never shown.
421/// - `Malformed`: an unparseable record (unknown severity, missing
422/// field, non-integer `line`). The caller adds the file to
423/// `failed_files`. We cannot know what the record meant, so we
424/// cannot trust the rest of the response either.
425fn parse_issue(issue: &Value, payload: &payload::Payload, file_path: &str) -> IssueOutcome {
426 // `line` must be a positive integer. A missing field, a string,
427 // a float, or a non-positive number are all malformed.
428 let Some(line) = issue.get(LINE).and_then(Value::as_u64) else {
429 return IssueOutcome::Malformed("missing or non-integer `line`".to_owned());
430 };
431 // `Value::as_u64` already rejects non-integers; the only
432 // remaining "not a positive integer" case is zero. A line of
433 // zero is a model artifact, not a real line.
434 if line == 0 {
435 return IssueOutcome::Malformed("`line` is zero".to_owned());
436 }
437 // A line beyond `u32` is a model artifact of exactly the same class as
438 // a line of zero, and this module's whole thesis is that we do not guess.
439 // Clamping to `u32::MAX` happened to land in `Dropped` because that value
440 // is never in `valid_lines` - correct by accident, via a silent clamp.
441 let Ok(line) = u32::try_from(line) else {
442 return IssueOutcome::Malformed("`line` is beyond u32".to_owned());
443 };
444
445 // `severity` must be one of the levels the prompt asked for. Anything
446 // else is malformed: we cannot map it to a `Severity`, and we do not
447 // silently coerce. The vocabulary lives on `LlmSeverity` so the prompt
448 // and this parser cannot list different levels.
449 let Some(severity_str) = issue.get(SEVERITY).and_then(Value::as_str) else {
450 return IssueOutcome::Malformed("missing `severity`".to_owned());
451 };
452 let Ok(severity) = severity_str.parse::<LlmSeverity>() else {
453 return IssueOutcome::Malformed(format!("unknown severity `{severity_str}`"));
454 };
455 let severity = severity.to_severity();
456
457 // `message` is the only remaining required field. Missing or
458 // non-string is malformed.
459 let Some(message) = issue.get(MESSAGE).and_then(Value::as_str) else {
460 return IssueOutcome::Malformed("missing or non-string `message`".to_owned());
461 };
462
463 // `category` is optional, but a *present* non-string one is malformed, not
464 // a missing one. `.get().and_then(as_str).unwrap_or("unknown")` collapses
465 // the two, so `"category": 7` was reported as the finding kind "unknown" -
466 // a response we demonstrably did not understand, recorded as understood and
467 // then cached for the whole TTL. Same rule `severity` and `message` follow.
468 let kind = match issue.get(CATEGORY) {
469 None => "unknown".to_owned(),
470 Some(Value::String(text)) => text.clone(),
471 Some(_) => return IssueOutcome::Malformed("non-string `category`".to_owned()),
472 };
473 let message = message.to_owned();
474 let asserts_compile_failure = match issue.get(COMPILE_FAILURE) {
475 None => false,
476 Some(Value::Bool(value)) => *value,
477 Some(_) => return IssueOutcome::Malformed("non-boolean `compile_failure`".to_owned()),
478 };
479
480 // `suggestion` is optional on the same terms. Absent or empty → `None`, not
481 // `Some("")`: an empty suggestion is not a suggestion. A present non-string
482 // one is malformed rather than silently absent.
483 let suggestion = match issue.get(SUGGESTION) {
484 None => None,
485 Some(Value::String(text)) if text.is_empty() => None,
486 Some(Value::String(text)) => Some(text.clone()),
487 Some(_) => return IssueOutcome::Malformed("non-string `suggestion`".to_owned()),
488 };
489
490 // Membership is checked LAST, after the record's shape.
491 //
492 // Shape asks "did the model answer in our schema"; membership asks "did it
493 // talk about code we sent". A record with an unknown severity is evidence
494 // the response's vocabulary is wrong, and that contaminates the records we
495 // did accept - so it must fail the file even when the record also cites a
496 // line we never sent. Checking membership first would let a demonstrably
497 // schema-violating response be reported as fully understood.
498 //
499 // A well-formed record about code we did not send is different: we
500 // understood it perfectly, it is simply out of scope. It is dropped and
501 // counted, never clamped onto the nearest valid line, which would attach a
502 // real-looking finding to arbitrary code. Pinned by
503 // `out_of_range_line_is_dropped_not_clamped`.
504 if !payload.valid_lines.contains(&line) {
505 return IssueOutcome::Dropped;
506 }
507
508 IssueOutcome::Finding(Finding {
509 kind,
510 severity,
511 file_path: file_path.to_owned(),
512 line,
513 column: None,
514 message,
515 suggestion,
516 asserts_compile_failure,
517 fingerprint: None,
518 })
519}
520
521/// What one issue record became after parsing.
522enum IssueOutcome {
523 /// A valid record attributed to a real line in the payload.
524 Finding(Finding),
525 /// A valid record whose line was not in `payload.valid_lines`.
526 Dropped,
527 /// An unparseable record, with a reason naming what was wrong.
528 Malformed(String),
529}
530
531#[cfg(test)]
532mod partition_tests {
533 use super::{HunkGroup, partition_hunks};
534 use crate::diff::hunks::Hunk;
535 use std::path::PathBuf;
536
537 #[test]
538 fn already_grouped_hunks_remain_borrowed() {
539 let hunks = [Hunk::whole_file(PathBuf::from("same.rs"), "one\ntwo\n")];
540 let groups = partition_hunks(&hunks);
541 let [HunkGroup::Borrowed(group)] = groups.as_slice() else {
542 panic!("an already-grouped slice must stay on the zero-copy path");
543 };
544 assert!(std::ptr::eq(group.as_ptr(), hunks.as_ptr()));
545 }
546}