Skip to main content

drep/cli/check/
mod.rs

1//! `drep check` - the commit gate, end to end.
2//!
3//! Three concerns, three sibling modules, one orchestrator here:
4//!
5//! - `input` resolves the four input modes (paths, `--staged`, `--diff`, and
6//!   pre-commit's pre-push ref environment)
7//!   into a uniform `[Hunk]` list. Whatever the caller passed, the rest of
8//!   the pipeline sees the same shape.
9//! - `deterministic` runs the configured per-language tools, collecting
10//!   their findings AND marking every file in a batch failed when a tool was
11//!   `Unavailable` — the per-tool/per-file join the exit-2 contract rests on.
12//! - `render` turns the two layers' findings plus the failure map into the
13//!   text or JSON output the user sees.
14//!
15//! The split is by topic, not by file size, because the smallest meaningful
16//! unit of `check` is "one input mode" or "one output format" and the
17//! dependencies between those are weak. The orchestrator ([`run`]) is the
18//! only place where the layers meet, which is what the loading-order
19//! invariants and the exit-code precedence are pinned against.
20
21mod deterministic;
22// `pub(crate)` for `READ_MAX_BYTES` alone: the guard's relationship to
23// `analysis::payload::PAYLOAD_MAX_BYTES` is load-bearing (it must never sit
24// below it), and the assertion pinning that has to see both constants.
25pub(crate) mod input;
26mod render;
27mod review_budget;
28mod semantic;
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::path::{Path, PathBuf};
32
33use anyhow::{Context, Result, anyhow};
34use clap::{ArgGroup, Args};
35
36use crate::analysis::acknowledgements;
37use crate::analysis::findings::{self, Finding, Severity};
38use crate::analysis::result::{FailureReason, union_failures};
39use crate::auth;
40use crate::cli::{OutputFormat, severity_parser};
41use crate::config;
42use crate::llm::cache::Cache;
43use crate::llm::chain::ProviderChain;
44use review_budget::Budget;
45
46#[derive(Debug, Args)]
47// One rule, stated once. Paired `conflicts_with_all` attributes say the same
48// thing from each side and have to be kept in agreement; a fourth input mode
49// would mean editing every existing one, and missing a single edit silently
50// permits an illegal combination.
51// Deliberately NOT `.required(true)`. Bare `drep check` is a supported
52// invocation meaning "the whole tree": `input::resolve` expands `root` through
53// `files::expand_paths`, exactly as an explicit `.` would. Requiring one of the
54// three would turn the plainest invocation into a usage error. Pinned by
55// `bare_check_with_no_paths_expands_the_root_instead_of_reading_a_directory`,
56// which exists because an earlier version passed the root through as a *file*
57// and exited 2 without analyzing anything.
58#[command(
59    group(
60        ArgGroup::new("input")
61            .args(["paths", "staged", "diff", "pre_commit_push"])
62            .multiple(false)
63    ),
64    group(ArgGroup::new("cache_mode").args(["cache_only", "push_gate"]).multiple(false))
65)]
66pub struct CheckArgs {
67    /// Files or directories to check. Duplicates and overlaps are collapsed,
68    /// so `drep check a.rs .` analyzes `a.rs` once.
69    #[arg(value_name = "PATH")]
70    pub paths: Vec<PathBuf>,
71
72    /// Check the files staged for commit. For a pre-commit hook.
73    #[arg(long)]
74    pub staged: bool,
75
76    /// Check the files changed since REF, e.g. `origin/main`. For pre-push.
77    #[arg(long, value_name = "REF")]
78    pub diff: Option<String>,
79
80    /// The commit to diff *to*. Defaults to `HEAD`. Only valid with `--diff`.
81    ///
82    /// A pre-push hook needs this: git can push a ref that is not the
83    /// checked-out one (`git push origin feature:feature` from another branch,
84    /// or `git push --all`), and diffing to `HEAD` there reviews a different
85    /// branch and lets the pushed code through unseen.
86    #[arg(long, value_name = "REF", requires = "diff")]
87    pub tip: Option<String>,
88
89    /// Read the pushed base and tip from pre-commit's hook environment.
90    ///
91    /// Used by the published `drep-check-push` hook. pre-commit otherwise
92    /// passes filenames, which would make drep review whole files instead of
93    /// the hunks between `PRE_COMMIT_FROM_REF` and `PRE_COMMIT_TO_REF`.
94    #[arg(long)]
95    pub pre_commit_push: bool,
96
97    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
98    pub format: OutputFormat,
99
100    /// Also block on LLM findings at or above this severity.
101    ///
102    /// Deterministic tool findings always block; this opts the LLM's findings
103    /// into gating too. Left unset, they inform without blocking - which is
104    /// the useful default, because the model emits style suggestions on
105    /// nearly every file.
106    #[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
107    pub fail_on: Option<Severity>,
108
109    /// Use cached LLM reviews only; never contact a provider.
110    ///
111    /// An uncached file exits 3 without warming it; run a normal check to
112    /// populate the missing entry. The generated pre-push hook uses
113    /// `--push-gate` for the full warm-and-reconnect handshake.
114    #[arg(long)]
115    pub cache_only: bool,
116
117    /// Prepare a push without resuming a stale remote connection.
118    ///
119    /// Cached reviews pass immediately. A cold review is completed and cached,
120    /// then exits 3 so Git reconnects; repeating `git push` uses the cache.
121    #[arg(long)]
122    pub push_gate: bool,
123
124    /// Override the repository's maximum fresh LLM review rounds.
125    #[arg(
126        long,
127        value_name = "N",
128        value_parser = clap::value_parser!(u32).range(1..),
129        conflicts_with = "unlimited_reviews"
130    )]
131    pub max_review_rounds: Option<u32>,
132
133    /// Permit fresh LLM review rounds without a limit for this invocation.
134    #[arg(long)]
135    pub unlimited_reviews: bool,
136}
137
138/// Everything one `check` run produced.
139///
140/// The two layers stay in separate fields all the way to rendering. Their
141/// findings are gated differently - deterministic ones always block, LLM ones
142/// only under `--fail-on` - and keeping them apart makes that structural
143/// rather than a tag on `Finding` that a caller could read wrong.
144pub struct CheckOutcome {
145    /// Findings from the configured deterministic tools. These always block.
146    pub tool_findings: Vec<Finding>,
147    /// Findings from the LLM. These only block under `--fail-on`.
148    pub llm_findings: Vec<Finding>,
149    /// Files that went unanalyzed for any reason. The two layers cover the
150    /// same files, so the CLI unions them; on a key collision the first
151    /// reason wins, matching `AnalysisResult::merge`.
152    pub failures: BTreeMap<PathBuf, FailureReason>,
153    /// Which providers answered, and for how many files.
154    ///
155    /// Empty when the LLM layer produced nothing at all. Only providers that
156    /// served at least one file appear, in chain order, so a run that never
157    /// left the head is one entry - and a run that did fall through says so
158    /// rather than leaving a silent switch from a local model to a paid one.
159    pub provider_uses: Vec<ProviderUse>,
160    /// A cold push review completed successfully and must be retried over a new
161    /// Git transport. False for ordinary checks and warm push gates.
162    pub retry_push: bool,
163    /// What this invocation did to the bounded semantic-review cycle.
164    pub review_activity: Option<ReviewActivity>,
165    /// The gate's verdict for this run.
166    ///
167    /// On the outcome rather than passed alongside it: `render` used to take
168    /// an `Exit` as a separate argument, which let the two disagree - and they
169    /// did, because `render` computed its own and ignored `--fail-on`. A field
170    /// set once by `run` makes the mismatch unrepresentable.
171    pub exit: Exit,
172}
173
174/// Visible accounting for a fresh semantic review.
175pub enum ReviewActivity {
176    /// Actionable findings survived suppression and acknowledgement, consuming
177    /// one remediation round.
178    Counted { round: u32, limit: u32 },
179    /// An authoritative clean result completed the current review cycle.
180    Reset,
181    /// The caller explicitly disabled the configured bound for this review.
182    Unlimited,
183}
184
185/// One provider's share of a run.
186///
187/// The backend location is carried, not just the model, because "gpt-5.6-sol" does not
188/// tell a user whether they paid for it - two entries can name the same model
189/// at a local proxy and at the vendor.
190pub struct ProviderUse {
191    /// Zero-based position in the chain. Rendered one-based.
192    pub index: usize,
193    pub model: String,
194    pub location: String,
195    pub files: usize,
196}
197
198/// One `check` invocation: resolve input, run both layers, gate, render, return
199/// `Exit`.
200///
201/// `root` is the working directory the input resolution runs against. The CLI
202/// passes `Path::new(".")`, the tests pass a `TempDir` so each one stands
203/// alone. Failure to load the config is a hard error: the LLM is mandatory in
204/// 2.x and there is no deterministic-only mode.
205pub async fn run(args: &CheckArgs, root: &Path) -> Result<Exit> {
206    run_with(
207        args,
208        root,
209        Cache::new(Cache::default_root(), CACHE_TTL_DAYS, CACHE_MAX_BYTES),
210    )
211    .await
212}
213
214/// How long a cached LLM response stays valid, and how large the store may
215/// grow. Documented tunables rather than magic numbers at the call site.
216pub const CACHE_TTL_DAYS: u64 = 30;
217/// See [`CACHE_TTL_DAYS`].
218pub const CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
219
220/// `run`, with the response cache supplied.
221///
222/// Split out so a test can point the cache at its own `TempDir`. The cache
223/// was built inside the run from `Cache::default_root()`, which is the user's
224/// real cache directory - so every test run wrote to it, and a stale entry
225/// from one test satisfied another: an unreachable-endpoint test once exited 0
226/// because a previous test had cached a clean response for the same payload.
227pub(crate) async fn run_with(args: &CheckArgs, root: &Path, cache: Cache) -> Result<Exit> {
228    run_against(args, root, cache, &auth::default_path()?).await
229}
230
231/// `run_with`, against a named auth store.
232///
233/// The store is user-level state outside the repository, so it is a parameter
234/// for the same reason `root` is one: a test using the real one reads whatever
235/// the developer has stored, and a config that omits `api_key` would then
236/// behave differently on their machine than in CI. `init::run_with` and
237/// `auth::run_at` already thread it for that reason; this was the one command
238/// that did not.
239pub(crate) async fn run_against(
240    args: &CheckArgs,
241    root: &Path,
242    cache: Cache,
243    auth_path: &Path,
244) -> Result<Exit> {
245    // Anchored on `root`, not the process cwd. `config::default_config_path()`
246    // resolves against the cwd, which would make `root` a half-truth: input
247    // resolution would read one directory and configuration another. The CLI
248    // passes ".", so production behaviour is unchanged, and a test can point
249    // the whole run at a `TempDir` without chdir-ing a shared process.
250    let default_config_path = config::default_config_path();
251    if default_config_path.is_absolute() {
252        return Err(anyhow!(
253            "default config path must be repository-relative, got {}",
254            default_config_path.display()
255        ));
256    }
257    let config_path = root.join(default_config_path);
258    let mut config = config::load(&config_path)
259        .with_context(|| format!("could not load {}", config_path.display()))?;
260
261    // Fill in the keys the file left unset from the user-level store. An
262    // explicit `api_key` in `drep.toml` always wins, so this cannot change what
263    // an existing config does; it only supplies what `drep init` stopped writing
264    // into the repository. A store that cannot be read is fatal rather than
265    // treated as empty - running the gate unauthenticated would surface as a 401
266    // per file, which reads as a broken key rather than a broken store.
267    let store = auth::AuthStore::load(auth_path)
268        .with_context(|| format!("could not read the auth store at {}", auth_path.display()))?;
269    auth::resolve(&mut config, &store);
270    // The whole enabled chain, not just its head: `providers()` is the
271    // failover order, and `load` has already rejected a config with none.
272    // The `is_empty` guard stays because `Config` is constructible without
273    // going through `load`, and a panic inside the commit gate is a worse
274    // failure than a message naming the file. The error is `load`'s own rather
275    // than a second sentence written here: two hand-written copies of "no
276    // provider configured" drift, and the copy that lived here had already
277    // lost the actionable "run `drep init`" half.
278    let providers = config.providers();
279    if providers.is_empty() {
280        return Err(config::ConfigError::NoProviders(config_path.clone()).into());
281    }
282
283    // Resolved *after* the config, because a missing config is fatal and
284    // resolution is not free: in `--staged` mode it spawns git, and in paths
285    // mode it reads every target file into memory. Discovering "no drep.toml"
286    // afterwards means paying for all of it and throwing it away.
287    let work = input::resolve(args, root)
288        .await
289        .with_context(|| format!("could not resolve input under {}", root.display()))?;
290    let acknowledgements = acknowledgements::Store::load(root)?;
291
292    let authoritative = review_budget::is_authoritative(args);
293    let effective_limit = args.max_review_rounds.unwrap_or(config.max_review_rounds);
294    let semantic_policy = semantic::Policy {
295        authoritative,
296        limit: effective_limit,
297    };
298    let chain =
299        ProviderChain::new(&providers).map_err(|e| anyhow!("could not build LLM analyzer: {e}"))?;
300    let analyzer = crate::analysis::code_quality::CodeQualityAnalyzer::new(chain, cache.clone())
301        .with_cache_only(args.cache_only || args.push_gate || authoritative);
302
303    // The two layers share no data - they only both report failures - so they
304    // run concurrently. The deterministic leg is pure added latency otherwise:
305    // a warm `cargo clippy` on this repo is ~3.5s, and the LLM leg is
306    // multi-second regardless, so joining hides essentially all of it.
307    let semantic = async {
308        let cached = analyzer.analyze_files(&work.by_file).await;
309        if args.push_gate {
310            Ok(semantic::Stage::Deferred(cached))
311        } else {
312            semantic::complete(args, root, &work, &analyzer, semantic_policy, cached, true)
313                .await
314                .map(Box::new)
315                .map(semantic::Stage::Complete)
316        }
317    };
318    let (deterministic_result, semantic_stage) =
319        tokio::join!(deterministic::run(&work, root), semantic,);
320    let (tool_findings, tool_failures, compiled_files) = deterministic_result;
321    let semantic_stage = semantic_stage?;
322    let (semantic_pass, eligible_push_warm) = match semantic_stage {
323        semantic::Stage::Deferred(cached) => {
324            let eligible = push_warm_eligible(
325                &cached,
326                work.read_failures.is_empty(),
327                tool_failures.is_empty(),
328                tool_findings.is_empty(),
329            );
330            (
331                semantic::complete(
332                    args,
333                    root,
334                    &work,
335                    &analyzer,
336                    semantic_policy,
337                    cached,
338                    eligible,
339                )
340                .await?,
341                eligible,
342            )
343        }
344        semantic::Stage::Complete(pass) => (*pass, false),
345    };
346    let semantic::Pass {
347        cached: mut llm_result,
348        live: mut live_result,
349        live_review,
350        budget,
351        should_review_live,
352        limit_reached,
353        live_answered,
354    } = semantic_pass;
355    let provider_uses = provider_uses(analyzer.chain());
356
357    // All concurrent writers have finished, so one oldest-first pass can
358    // enforce the configured disk ceiling without racing another put. Cache
359    // maintenance is best-effort: an unreadable cache must never turn an
360    // otherwise valid review into an unanalyzed file.
361    let _ = cache.evict_if_needed();
362
363    adjudicate_findings(
364        &mut llm_result.findings,
365        &compiled_files,
366        &work,
367        &acknowledgements,
368    );
369    adjudicate_findings(
370        &mut live_result.findings,
371        &compiled_files,
372        &work,
373        &acknowledgements,
374    );
375
376    let mut review_activity = None;
377    match live_review {
378        semantic::LiveReview::Reserved(claim) => {
379            if !live_result.findings.is_empty() {
380                let round = claim.round();
381                claim.commit()?;
382                review_activity = Some(ReviewActivity::Counted {
383                    round,
384                    limit: effective_limit,
385                });
386            }
387            // Otherwise Drop refunds the pending slot. Pure failures did not
388            // complete a review, and a clean answer consumes no remediation round.
389        }
390        semantic::LiveReview::Unbounded
391            if should_report_unlimited(args.unlimited_reviews, live_answered) =>
392        {
393            review_activity = Some(ReviewActivity::Unlimited);
394        }
395        semantic::LiveReview::Skip
396        | semantic::LiveReview::Unbounded
397        | semantic::LiveReview::Denied { .. } => {}
398    }
399    llm_result.merge(live_result);
400
401    if review_budget::is_completion_scope(args)
402        && !args.cache_only
403        && !work.by_file.is_empty()
404        && work.read_failures.is_empty()
405        && tool_findings.is_empty()
406        && tool_failures.is_empty()
407        && llm_result.findings.is_empty()
408        && llm_result.failed_files.is_empty()
409    {
410        // Reset is an authoritative quota-state transition, not cache
411        // maintenance. Failing it closed keeps this result from claiming a
412        // cycle reset that the next invocation cannot observe.
413        let reset = if let Some(budget) = &budget {
414            budget.reset()?
415        } else {
416            Budget::for_repo(root, effective_limit).await?.reset()?
417        };
418        if should_report_reset(live_answered, reset) {
419            review_activity = Some(ReviewActivity::Reset);
420        }
421    }
422
423    let warmed_for_push = eligible_push_warm && should_review_live && !limit_reached;
424
425    let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
426    // Union order is the reporting priority: a file that could not be read
427    // keeps that reason over a later layer's view of the same path.
428    union_failures(&mut failures, work.read_failures);
429    union_failures(&mut failures, tool_failures);
430    union_failures(&mut failures, llm_result.failed_files);
431
432    let mut outcome = CheckOutcome {
433        tool_findings,
434        llm_findings: llm_result.findings,
435        failures,
436        provider_uses,
437        retry_push: false,
438        review_activity,
439        exit: Exit::Clean,
440    };
441    outcome.exit = gate(&outcome, args.fail_on);
442    if warmed_for_push && outcome.exit == Exit::Clean {
443        outcome.retry_push = true;
444        outcome.exit = Exit::CacheMiss;
445    }
446
447    render::render(&outcome, args.format)?;
448    Ok(outcome.exit)
449}
450
451fn push_warm_eligible(
452    cached: &crate::analysis::result::AnalysisResult,
453    reads_clean: bool,
454    tools_analyzed: bool,
455    tools_clean: bool,
456) -> bool {
457    cached.has_failures()
458        && cached
459            .failed_files
460            .values()
461            .all(|reason| matches!(reason, FailureReason::CacheMiss))
462        && reads_clean
463        && tools_analyzed
464        && tools_clean
465}
466
467fn should_report_unlimited(requested: bool, live_answered: bool) -> bool {
468    requested && live_answered
469}
470
471fn should_report_reset(live_answered: bool, state_removed: bool) -> bool {
472    live_answered || state_removed
473}
474
475/// Apply the two repository-grounded filters in their load-bearing order.
476fn adjudicate_findings(
477    findings: &mut Vec<Finding>,
478    compiled: &BTreeSet<PathBuf>,
479    work: &input::Work,
480    acknowledgements: &acknowledgements::Store,
481) {
482    suppress_disproved_compile_claims(findings, compiled);
483    acknowledgements::apply(findings, &work.by_file, acknowledgements);
484}
485
486/// Drop only findings that explicitly claim compilation failure after a
487/// configured compiler has successfully checked the same file.
488fn suppress_disproved_compile_claims(findings: &mut Vec<Finding>, compiled: &BTreeSet<PathBuf>) {
489    findings.retain(|finding| {
490        !(finding.asserts_compile_failure && compiled.contains(Path::new(&finding.file_path)))
491    });
492}
493
494/// What the process should exit with.
495///
496/// Two precedence rules, in order:
497/// 1. Any failure → `Unanalyzed` (exit 2). A failure outranks a finding,
498///    because the file went unanalyzed whether or not the LLM also produced
499///    findings on a partial result.
500/// 2. Any blocking finding → `FoundIssues` (exit 1). Tool findings always
501///    block; LLM findings block when `fail_on` admits their severity and
502///    `fail_on` is set.
503fn gate(outcome: &CheckOutcome, fail_on: Option<Severity>) -> Exit {
504    if outcome
505        .failures
506        .values()
507        .any(|reason| !matches!(reason, FailureReason::CacheMiss))
508    {
509        return Exit::Unanalyzed;
510    }
511    if any_blocking_tool_finding(&outcome.tool_findings) {
512        return Exit::FoundIssues;
513    }
514    if let Some(threshold) = fail_on
515        && findings::any_at_or_above(&outcome.llm_findings, threshold)
516    {
517        return Exit::FoundIssues;
518    }
519    // The earlier arm returned for every non-cache failure, so only cache
520    // misses remain here. Findings deliberately outrank a retryable miss.
521    if !outcome.failures.is_empty() {
522        return Exit::CacheMiss;
523    }
524    Exit::Clean
525}
526
527/// A tool finding always blocks. A deterministic tool's verdict is the
528/// project's own choice, so the gate honors it without an allow-list.
529fn any_blocking_tool_finding(findings: &[Finding]) -> bool {
530    !findings.is_empty()
531}
532
533/// Who answered, and for how many files.
534///
535/// Read off the chain, which counted as it went - the counts never leave the
536/// object that produced them, so they cannot drift from the demotion state
537/// sitting beside them, and `AnalysisResult` needs no per-provider field whose
538/// merge rule would be the one exception to its union-not-sum invariant.
539/// Providers that served nothing are omitted: the report answers "who reviewed
540/// this code", and an untouched fallback did not.
541fn provider_uses(chain: &ProviderChain) -> Vec<ProviderUse> {
542    chain
543        .providers()
544        .iter()
545        .enumerate()
546        .filter(|(_, provider)| provider.served() > 0)
547        .map(|(index, provider)| ProviderUse {
548            index,
549            model: provider.model().to_owned(),
550            location: provider.location().to_owned(),
551            files: provider.served(),
552        })
553        .collect()
554}
555
556/// The crate's `Exit` re-exported so `cli::check::run` returns the same
557/// type the upper layer expects, without leaking the orchestrator's
558/// dependency path.
559pub use crate::Exit;
560
561#[cfg(test)]
562mod tests;