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//! - `refusal` answers whether site policy permits a semantic layer here at
15//! all, and owns the ordering that keeps a refused repository from ever
16//! reaching a credential, a chain or the cache.
17//!
18//! The split is by topic, not by file size, because the smallest meaningful
19//! unit of `check` is "one input mode" or "one output format" and the
20//! dependencies between those are weak. The orchestrator ([`run`]) is the
21//! only place where the layers meet, which is what the loading-order
22//! invariants and the exit-code precedence are pinned against.
23
24mod args;
25mod deterministic;
26// `pub(crate)` for `READ_MAX_BYTES` alone: the guard's relationship to
27// `analysis::payload::PAYLOAD_MAX_BYTES` is load-bearing (it must never sit
28// below it), and the assertion pinning that has to see both constants.
29pub(crate) mod input;
30mod refusal;
31mod render;
32mod review_budget;
33mod semantic;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::path::{Path, PathBuf};
37
38use anyhow::{Context, Result, anyhow};
39
40use crate::analysis::acknowledgements;
41use crate::analysis::findings::{self, Finding, Severity};
42use crate::analysis::result::{FailureReason, union_failures};
43use crate::auth;
44use crate::cli::MachineFiles;
45use crate::config;
46use crate::llm::cache::Cache;
47use crate::llm::chain::ProviderChain;
48use review_budget::Budget;
49
50pub use args::CheckArgs;
51
52/// Everything one `check` run produced.
53///
54/// The two layers stay in separate fields all the way to rendering. Their
55/// findings are gated differently - deterministic ones always block, LLM ones
56/// only under `--fail-on` - and keeping them apart makes that structural
57/// rather than a tag on `Finding` that a caller could read wrong.
58pub struct CheckOutcome {
59 /// Findings from the configured deterministic tools. These always block.
60 pub tool_findings: Vec<Finding>,
61 /// Findings from the LLM. These only block under `--fail-on`.
62 pub llm_findings: Vec<Finding>,
63 /// Files that went unanalyzed for any reason. The two layers cover the
64 /// same files, so the CLI unions them; on a key collision the first
65 /// reason wins, matching `AnalysisResult::merge`.
66 pub failures: BTreeMap<PathBuf, FailureReason>,
67 /// Which providers answered, and for how many files.
68 ///
69 /// Empty when the LLM layer produced nothing at all. Only providers that
70 /// served at least one file appear, in chain order, so a run that never
71 /// left the head is one entry - and a run that did fall through says so
72 /// rather than leaving a silent switch from a local model to a paid one.
73 pub provider_uses: Vec<ProviderUse>,
74 /// A cold push review completed successfully and must be retried over a new
75 /// Git transport. False for ordinary checks and warm push gates.
76 pub retry_push: bool,
77 /// What this invocation did to the bounded semantic-review cycle.
78 pub review_activity: Option<ReviewActivity>,
79 /// The gate's verdict for this run.
80 ///
81 /// On the outcome rather than passed alongside it: `render` used to take
82 /// an `Exit` as a separate argument, which let the two disagree - and they
83 /// did, because `render` computed its own and ignored `--fail-on`. A field
84 /// set once by `run` makes the mismatch unrepresentable.
85 pub exit: Exit,
86}
87
88/// Visible accounting for a fresh semantic review.
89pub enum ReviewActivity {
90 /// Actionable findings survived suppression and acknowledgement, consuming
91 /// one remediation round.
92 Counted { round: u32, limit: u32 },
93 /// An authoritative clean result completed the current review cycle.
94 Reset,
95 /// The caller explicitly disabled the configured bound for this review.
96 Unlimited,
97}
98
99/// One provider's share of a run.
100///
101/// The backend location is carried, not just the model, because "gpt-5.6-sol" does not
102/// tell a user whether they paid for it - two entries can name the same model
103/// at a local proxy and at the vendor.
104pub struct ProviderUse {
105 /// Zero-based position in the chain. Rendered one-based.
106 pub index: usize,
107 pub model: String,
108 pub location: String,
109 pub files: usize,
110}
111
112/// One `check` invocation: resolve input, run both layers, gate, render, return
113/// `Exit`.
114///
115/// `root` is the working directory the input resolution runs against. The CLI
116/// passes `Path::new(".")`, the tests pass a `TempDir` so each one stands
117/// alone. Failure to load the config is a hard error: the LLM is mandatory in
118/// 2.x and there is no deterministic-only mode.
119pub async fn run(args: &CheckArgs, root: &Path) -> Result<Exit> {
120 run_with(
121 args,
122 root,
123 Cache::new(Cache::default_root(), CACHE_TTL_DAYS, CACHE_MAX_BYTES),
124 )
125 .await
126}
127
128/// How long a cached LLM response stays valid, and how large the store may
129/// grow. Documented tunables rather than magic numbers at the call site.
130pub const CACHE_TTL_DAYS: u64 = 30;
131/// See [`CACHE_TTL_DAYS`].
132pub const CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
133
134/// `run`, with the response cache supplied.
135///
136/// Split out so a test can point the cache at its own `TempDir`. The cache
137/// was built inside the run from `Cache::default_root()`, which is the user's
138/// real cache directory - so every test run wrote to it, and a stale entry
139/// from one test satisfied another: an unreachable-endpoint test once exited 0
140/// because a previous test had cached a clean response for the same payload.
141pub(crate) async fn run_with(args: &CheckArgs, root: &Path, cache: Cache) -> Result<Exit> {
142 run_against(
143 args,
144 root,
145 cache,
146 &MachineFiles {
147 auth: &auth::default_path()?,
148 policy: &config::site::default_path(),
149 },
150 )
151 .await
152}
153
154/// `run_with`, against a named auth store and a named site policy file.
155///
156/// Both are machine-level state outside the repository, so both are parameters
157/// for the same reason `root` is one: a test using the real ones reads whatever
158/// the developer's machine happens to hold, and a repository would then behave
159/// differently there than in CI. `init::run_with` and `auth::run_at` already
160/// thread the store for that reason; the policy file follows the same seam
161/// rather than being read inside the call.
162///
163/// They arrive as one [`MachineFiles`] rather than two `&Path`
164/// positionals because the two are the same type and a transposition compiles;
165/// see that struct for what the swap silently disables.
166pub(crate) async fn run_against(
167 args: &CheckArgs,
168 root: &Path,
169 cache: Cache,
170 machine: &MachineFiles<'_>,
171) -> Result<Exit> {
172 // Read before the repository's own config, and before a byte of source. A
173 // machine whose policy file is broken must not then run whatever the
174 // repository says, so a policy failure outranks a repo-config failure. No
175 // `.with_context`: unlike `ConfigError::Io`, the message already names the
176 // file and states the consequence, and a context line would say it twice.
177 let site = config::site::load(machine.policy)?;
178
179 let (config_path, mut config) = configured(root, site.as_ref())?;
180
181 // Resolved *after* the config, because a missing config is fatal and
182 // resolution is not free: in `--staged` mode it spawns git, and in paths
183 // mode it reads every target file into memory. Discovering "no drep.toml"
184 // afterwards means paying for all of it and throwing it away.
185 //
186 // And *before* the refusal, because the refusal is a question about the
187 // files this run would review rather than about `root` alone - see
188 // `input::Work::reviewed_directories`. Resolution reads local files and
189 // contacts nothing, so the ordering the refusal owns is untouched by it.
190 let collect_policy_scope = site
191 .as_ref()
192 .is_some_and(config::site::SiteConfig::has_refuse_markers);
193 let work = input::resolve(args, root, collect_policy_scope)
194 .await
195 .with_context(|| format!("could not resolve input under {}", root.display()))?;
196
197 // Fill in the keys the file left unset, and build the analyzer - unless site
198 // policy refuses review of this repository, in which case none of that
199 // happens at all. `refusal` owns that ordering; see its module doc.
200 let authoritative = review_budget::is_authoritative(args);
201 let source = refusal::source(
202 &refusal::Locations {
203 config: &config_path,
204 machine,
205 },
206 &mut config,
207 site.as_ref(),
208 &work,
209 cache.clone(),
210 args.cache_only || args.push_gate || authoritative,
211 )
212 .await?;
213
214 let acknowledgements = acknowledgements::Store::load(root)?;
215
216 let effective_limit = args.max_review_rounds.unwrap_or(config.max_review_rounds);
217 let semantic_policy = semantic::Policy {
218 authoritative,
219 limit: effective_limit,
220 };
221
222 // The two layers share no data - they only both report failures - so they
223 // run concurrently. The deterministic leg is pure added latency otherwise:
224 // a warm `cargo clippy` on this repo is ~3.5s, and the LLM leg is
225 // multi-second regardless, so joining hides essentially all of it.
226 //
227 // A refusal has no second leg to overlap with. The deterministic tools still
228 // run and still gate: they are local, they contact nothing, and they are the
229 // half of drep that works without a model.
230 let (deterministic_result, semantic_pass, eligible_push_warm, provider_uses, maintain_cache) =
231 match source {
232 refusal::Source::Refused(refusal) => (
233 deterministic::run(&work, root).await,
234 semantic::refused(&work, &refusal),
235 false,
236 Vec::new(),
237 false,
238 ),
239 refusal::Source::Analyze(analyzer) => {
240 let (deterministic, pass, eligible) =
241 analyzed(args, root, &work, &analyzer, semantic_policy).await?;
242 (
243 deterministic,
244 pass,
245 eligible,
246 provider_uses(analyzer.chain()),
247 true,
248 )
249 }
250 };
251 let (tool_findings, tool_failures, compiled_files) = deterministic_result;
252 let semantic::Pass {
253 cached: mut llm_result,
254 live: mut live_result,
255 live_review,
256 budget,
257 live_answered,
258 } = semantic_pass;
259 // All concurrent writers have finished, so one oldest-first pass can
260 // enforce the configured disk ceiling without racing another put. Cache
261 // maintenance is best-effort: an unreadable cache must never turn an
262 // otherwise valid review into an unanalyzed file. A refusal has no semantic
263 // layer, so it neither creates this directory nor evicts entries belonging
264 // to permitted repositories.
265 if maintain_cache {
266 let _ = cache.evict_if_needed();
267 }
268
269 adjudicate_findings(
270 &mut llm_result.findings,
271 &compiled_files,
272 &work,
273 &acknowledgements,
274 );
275 adjudicate_findings(
276 &mut live_result.findings,
277 &compiled_files,
278 &work,
279 &acknowledgements,
280 );
281
282 let warmed_for_push = eligible_push_warm
283 && matches!(
284 &live_review,
285 semantic::LiveReview::Unbounded | semantic::LiveReview::Reserved(_)
286 );
287 let mut review_activity = None;
288 match live_review {
289 semantic::LiveReview::Reserved(claim) => {
290 if !live_result.findings.is_empty() {
291 let round = claim.round();
292 claim.commit()?;
293 review_activity = Some(ReviewActivity::Counted {
294 round,
295 limit: effective_limit,
296 });
297 }
298 // Otherwise Drop refunds the pending slot. Pure failures did not
299 // complete a review, and a clean answer consumes no remediation round.
300 }
301 semantic::LiveReview::Unbounded
302 if should_report_unlimited(args.unlimited_reviews, live_answered) =>
303 {
304 review_activity = Some(ReviewActivity::Unlimited);
305 }
306 semantic::LiveReview::Skip
307 | semantic::LiveReview::Unbounded
308 | semantic::LiveReview::Denied { .. } => {}
309 }
310 llm_result.merge(live_result);
311
312 if review_budget::is_completion_scope(args)
313 && !args.cache_only
314 && !work.by_file.is_empty()
315 && work.read_failures.is_empty()
316 && tool_findings.is_empty()
317 && tool_failures.is_empty()
318 && llm_result.findings.is_empty()
319 && llm_result.failed_files.is_empty()
320 {
321 // Reset is an authoritative quota-state transition, not cache
322 // maintenance. Failing it closed keeps this result from claiming a
323 // cycle reset that the next invocation cannot observe.
324 let reset = if let Some(budget) = &budget {
325 budget.reset()?
326 } else {
327 Budget::for_repo(root, effective_limit).await?.reset()?
328 };
329 if should_report_reset(live_answered, reset) {
330 review_activity = Some(ReviewActivity::Reset);
331 }
332 }
333
334 let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
335 // Union order is the reporting priority: a file that could not be read
336 // keeps that reason over a later layer's view of the same path.
337 union_failures(&mut failures, work.read_failures);
338 union_failures(&mut failures, tool_failures);
339 union_failures(&mut failures, llm_result.failed_files);
340
341 let mut outcome = CheckOutcome {
342 tool_findings,
343 llm_findings: llm_result.findings,
344 failures,
345 provider_uses,
346 retry_push: false,
347 review_activity,
348 exit: Exit::Clean,
349 };
350 outcome.exit = gate(&outcome, args.fail_on);
351 if warmed_for_push && outcome.exit == Exit::Clean {
352 outcome.retry_push = true;
353 outcome.exit = Exit::CacheMiss;
354 }
355
356 render::render(&outcome, args.format)?;
357 Ok(outcome.exit)
358}
359
360/// What the deterministic leg produced: findings, failures, and the files a
361/// configured compiler successfully checked.
362type Deterministic = (
363 Vec<Finding>,
364 BTreeMap<PathBuf, FailureReason>,
365 BTreeSet<PathBuf>,
366);
367
368/// `drep.toml` as this run will use it: loaded from under `root`, then lowered to
369/// what the site allows.
370///
371/// One named function rather than three steps inside `run_against`, so a test can
372/// observe the clamp reaching a config a real run would use. Every ceiling test
373/// called [`config::site::SiteConfig::apply`] directly, so deleting the call from
374/// the orchestrator left the whole suite green while `max_concurrent_ceiling`
375/// constrained nothing - and `doctor`, which computes its note from the raw TOML
376/// tree, went on reporting the clamp as enforced.
377///
378/// The path is returned beside the config because the caller needs it for the
379/// no-providers error, which names the file the user has to edit.
380fn configured(
381 root: &Path,
382 site: Option<&config::site::SiteConfig>,
383) -> Result<(PathBuf, config::Config)> {
384 // Anchored on `root`, not the process cwd. `config::default_config_path()`
385 // resolves against the cwd, which would make `root` a half-truth: input
386 // resolution would read one directory and configuration another. The CLI
387 // passes ".", so production behaviour is unchanged, and a test can point
388 // the whole run at a `TempDir` without chdir-ing a shared process.
389 let default_config_path = config::default_config_path();
390 if default_config_path.is_absolute() {
391 return Err(anyhow!(
392 "default config path must be repository-relative, got {}",
393 default_config_path.display()
394 ));
395 }
396 let config_path = root.join(default_config_path);
397 let mut config = config::load(&config_path)
398 .with_context(|| format!("could not load {}", config_path.display()))?;
399
400 // Applied before anything reads a provider: a checkout may lower its own
401 // concurrency but not raise it past what the site allows. Nothing is printed
402 // here - a clamp is not an error, and `doctor` is where it is reported.
403 if let Some(site) = site {
404 site.apply(&mut config);
405 }
406 Ok((config_path, config))
407}
408
409/// Both legs, for a repository site policy permits review of.
410///
411/// Split out of `run_against` so the refusal is a two-arm match there rather than
412/// a flag threaded through this. It also keeps `semantic::Stage::Deferred` unable
413/// to escape the one scope that holds an analyzer: the push gate defers its live
414/// pass until the deterministic verdict is in, and there is no analyzer to resume
415/// it with in the refused arm.
416async fn analyzed(
417 args: &CheckArgs,
418 root: &Path,
419 work: &input::Work,
420 analyzer: &crate::analysis::code_quality::CodeQualityAnalyzer,
421 policy: semantic::Policy,
422) -> Result<(Deterministic, semantic::Pass, bool)> {
423 let semantic = async {
424 let cached = analyzer.analyze_files(&work.by_file).await;
425 if args.push_gate {
426 Ok(semantic::Stage::Deferred(cached))
427 } else {
428 semantic::complete(args, root, work, analyzer, policy, cached, true)
429 .await
430 .map(Box::new)
431 .map(semantic::Stage::Complete)
432 }
433 };
434 let (deterministic, stage) = tokio::join!(deterministic::run(work, root), semantic);
435 let (tool_findings, tool_failures, compiled_files) = deterministic;
436 let (pass, eligible) = match stage? {
437 semantic::Stage::Deferred(cached) => {
438 let eligible = push_warm_eligible(
439 &cached,
440 work.read_failures.is_empty(),
441 tool_failures.is_empty(),
442 tool_findings.is_empty(),
443 );
444 (
445 semantic::complete(args, root, work, analyzer, policy, cached, eligible).await?,
446 eligible,
447 )
448 }
449 semantic::Stage::Complete(pass) => (*pass, false),
450 };
451 Ok((
452 (tool_findings, tool_failures, compiled_files),
453 pass,
454 eligible,
455 ))
456}
457
458fn push_warm_eligible(
459 cached: &crate::analysis::result::AnalysisResult,
460 reads_clean: bool,
461 tools_analyzed: bool,
462 tools_clean: bool,
463) -> bool {
464 cached.has_failures()
465 && cached
466 .failed_files
467 .values()
468 .all(|reason| matches!(reason, FailureReason::CacheMiss))
469 && reads_clean
470 && tools_analyzed
471 && tools_clean
472}
473
474fn should_report_unlimited(requested: bool, live_answered: bool) -> bool {
475 requested && live_answered
476}
477
478fn should_report_reset(live_answered: bool, state_removed: bool) -> bool {
479 live_answered || state_removed
480}
481
482/// Apply the two repository-grounded filters in their load-bearing order.
483fn adjudicate_findings(
484 findings: &mut Vec<Finding>,
485 compiled: &BTreeSet<PathBuf>,
486 work: &input::Work,
487 acknowledgements: &acknowledgements::Store,
488) {
489 suppress_disproved_compile_claims(findings, compiled);
490 acknowledgements::apply(findings, &work.by_file, acknowledgements);
491}
492
493/// Drop only findings that explicitly claim compilation failure after a
494/// configured compiler has successfully checked the same file.
495fn suppress_disproved_compile_claims(findings: &mut Vec<Finding>, compiled: &BTreeSet<PathBuf>) {
496 findings.retain(|finding| {
497 !(finding.asserts_compile_failure && compiled.contains(Path::new(&finding.file_path)))
498 });
499}
500
501/// What the process should exit with.
502///
503/// Two precedence rules, in order:
504/// 1. Any failure → `Unanalyzed` (exit 2). A failure outranks a finding,
505/// because the file went unanalyzed whether or not the LLM also produced
506/// findings on a partial result.
507/// 2. Any blocking finding → `FoundIssues` (exit 1). Tool findings always
508/// block; LLM findings block when `fail_on` admits their severity and
509/// `fail_on` is set.
510fn gate(outcome: &CheckOutcome, fail_on: Option<Severity>) -> Exit {
511 if outcome
512 .failures
513 .values()
514 .any(|reason| !matches!(reason, FailureReason::CacheMiss))
515 {
516 return Exit::Unanalyzed;
517 }
518 if any_blocking_tool_finding(&outcome.tool_findings) {
519 return Exit::FoundIssues;
520 }
521 if let Some(threshold) = fail_on
522 && findings::any_at_or_above(&outcome.llm_findings, threshold)
523 {
524 return Exit::FoundIssues;
525 }
526 // The earlier arm returned for every non-cache failure, so only cache
527 // misses remain here. Findings deliberately outrank a retryable miss.
528 if !outcome.failures.is_empty() {
529 return Exit::CacheMiss;
530 }
531 Exit::Clean
532}
533
534/// A tool finding always blocks. A deterministic tool's verdict is the
535/// project's own choice, so the gate honors it without an allow-list.
536fn any_blocking_tool_finding(findings: &[Finding]) -> bool {
537 !findings.is_empty()
538}
539
540/// Who answered, and for how many files.
541///
542/// Read off the chain, which counted as it went - the counts never leave the
543/// object that produced them, so they cannot drift from the demotion state
544/// sitting beside them, and `AnalysisResult` needs no per-provider field whose
545/// merge rule would be the one exception to its union-not-sum invariant.
546/// Providers that served nothing are omitted: the report answers "who reviewed
547/// this code", and an untouched fallback did not.
548fn provider_uses(chain: &ProviderChain) -> Vec<ProviderUse> {
549 chain
550 .providers()
551 .iter()
552 .enumerate()
553 .filter(|(_, provider)| provider.served() > 0)
554 .map(|(index, provider)| ProviderUse {
555 index,
556 model: provider.model().to_owned(),
557 location: provider.location().to_owned(),
558 files: provider.served(),
559 })
560 .collect()
561}
562
563/// The crate's `Exit` re-exported so `cli::check::run` returns the same
564/// type the upper layer expects, without leaking the orchestrator's
565/// dependency path.
566pub use crate::Exit;
567
568#[cfg(test)]
569mod tests;