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