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