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