Skip to main content

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