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//! - `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            review_activity = should_report_unlimited(args.unlimited_reviews, live_answered)
303                .then_some(ReviewActivity::Unlimited);
304        }
305        semantic::LiveReview::Skip | semantic::LiveReview::Denied { .. } => {}
306    }
307    llm_result.merge(live_result);
308
309    if review_budget::is_completion_scope(args)
310        && !args.cache_only
311        && !work.by_file.is_empty()
312        && work.read_failures.is_empty()
313        && tool_findings.is_empty()
314        && tool_failures.is_empty()
315        && llm_result.findings.is_empty()
316        && llm_result.failed_files.is_empty()
317    {
318        // Reset is an authoritative quota-state transition, not cache
319        // maintenance. Failing it closed keeps this result from claiming a
320        // cycle reset that the next invocation cannot observe.
321        let reset = if let Some(budget) = &budget {
322            budget.reset()?
323        } else {
324            Budget::for_repo(root, effective_limit).await?.reset()?
325        };
326        if should_report_reset(live_answered, reset) {
327            review_activity = Some(ReviewActivity::Reset);
328        }
329    }
330
331    let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
332    // Union order is the reporting priority: a file that could not be read
333    // keeps that reason over a later layer's view of the same path.
334    union_failures(&mut failures, work.read_failures);
335    union_failures(&mut failures, tool_failures);
336    union_failures(&mut failures, llm_result.failed_files);
337
338    let mut outcome = CheckOutcome {
339        tool_findings,
340        llm_findings: llm_result.findings,
341        failures,
342        provider_uses,
343        retry_push: false,
344        review_activity,
345        exit: Exit::Clean,
346    };
347    outcome.exit = gate(&outcome, args.fail_on);
348    if warmed_for_push && outcome.exit == Exit::Clean {
349        outcome.retry_push = true;
350        outcome.exit = Exit::CacheMiss;
351    }
352
353    render::render(&outcome, args.format)?;
354    Ok(outcome.exit)
355}
356
357/// What the deterministic leg produced: findings, failures, and the files a
358/// configured compiler successfully checked.
359type Deterministic = (
360    Vec<Finding>,
361    BTreeMap<PathBuf, FailureReason>,
362    BTreeSet<PathBuf>,
363);
364
365/// `drep.toml` as this run will use it: loaded from under `root`, then lowered to
366/// what the site allows.
367///
368/// One named function rather than three steps inside `run_against`, so a test can
369/// observe the clamp reaching a config a real run would use. Every ceiling test
370/// called [`config::site::SiteConfig::apply`] directly, so deleting the call from
371/// the orchestrator left the whole suite green while `max_concurrent_ceiling`
372/// constrained nothing - and `doctor`, which computes its note from the raw TOML
373/// tree, went on reporting the clamp as enforced.
374///
375/// The path is returned beside the config because the caller needs it for the
376/// no-providers error, which names the file the user has to edit.
377fn configured(
378    root: &Path,
379    site: Option<&config::site::SiteConfig>,
380) -> Result<(PathBuf, config::Config)> {
381    // Anchored on `root`, not the process cwd. `config::default_config_path()`
382    // resolves against the cwd, which would make `root` a half-truth: input
383    // resolution would read one directory and configuration another. The CLI
384    // passes ".", so production behaviour is unchanged, and a test can point
385    // the whole run at a `TempDir` without chdir-ing a shared process.
386    let default_config_path = config::default_config_path();
387    if default_config_path.is_absolute() {
388        return Err(anyhow!(
389            "default config path must be repository-relative, got {}",
390            default_config_path.display()
391        ));
392    }
393    let config_path = root.join(default_config_path);
394    let mut config = config::load(&config_path)
395        .with_context(|| format!("could not load {}", config_path.display()))?;
396
397    // Applied before anything reads a provider: a checkout may lower its own
398    // concurrency but not raise it past what the site allows. Nothing is printed
399    // here - a clamp is not an error, and `doctor` is where it is reported.
400    if let Some(site) = site {
401        site.apply(&mut config);
402    }
403    Ok((config_path, config))
404}
405
406/// Both legs, for a repository site policy permits review of.
407///
408/// Split out of `run_against` so the refusal is a two-arm match there rather than
409/// a flag threaded through this. It also keeps `semantic::Stage::Deferred` unable
410/// to escape the one scope that holds an analyzer: the push gate defers its live
411/// pass until the deterministic verdict is in, and there is no analyzer to resume
412/// it with in the refused arm.
413async fn analyzed(
414    args: &CheckArgs,
415    root: &Path,
416    work: &input::Work,
417    analyzer: &crate::analysis::code_quality::CodeQualityAnalyzer,
418    policy: semantic::Policy,
419) -> Result<(Deterministic, semantic::Pass, bool)> {
420    let semantic = async {
421        let cached = analyzer.analyze_files(&work.by_file).await;
422        if args.push_gate {
423            Ok(semantic::Stage::Deferred(cached))
424        } else {
425            semantic::complete(args, root, work, analyzer, policy, cached, true)
426                .await
427                .map(Box::new)
428                .map(semantic::Stage::Complete)
429        }
430    };
431    let (deterministic, stage) = tokio::join!(deterministic::run(work, root), semantic);
432    let (tool_findings, tool_failures, compiled_files) = deterministic;
433    let (pass, eligible) = match stage? {
434        semantic::Stage::Deferred(cached) => {
435            let eligible = push_warm_eligible(
436                &cached,
437                work.read_failures.is_empty(),
438                tool_failures.is_empty(),
439                tool_findings.is_empty(),
440            );
441            (
442                semantic::complete(args, root, work, analyzer, policy, cached, eligible).await?,
443                eligible,
444            )
445        }
446        semantic::Stage::Complete(pass) => (*pass, false),
447    };
448    Ok((
449        (tool_findings, tool_failures, compiled_files),
450        pass,
451        eligible,
452    ))
453}
454
455fn push_warm_eligible(
456    cached: &crate::analysis::result::AnalysisResult,
457    reads_clean: bool,
458    tools_analyzed: bool,
459    tools_clean: bool,
460) -> bool {
461    cached.has_failures()
462        && cached
463            .failed_files
464            .values()
465            .all(|reason| matches!(reason, FailureReason::CacheMiss))
466        && reads_clean
467        && tools_analyzed
468        && tools_clean
469}
470
471fn should_report_unlimited(requested: bool, live_answered: bool) -> bool {
472    requested && live_answered
473}
474
475fn should_report_reset(live_answered: bool, state_removed: bool) -> bool {
476    live_answered || state_removed
477}
478
479/// Apply the two repository-grounded filters in their load-bearing order.
480fn adjudicate_findings(
481    findings: &mut Vec<Finding>,
482    compiled: &BTreeSet<PathBuf>,
483    work: &input::Work,
484    acknowledgements: &acknowledgements::Store,
485) {
486    suppress_disproved_compile_claims(findings, compiled);
487    acknowledgements::apply(findings, &work.by_file, acknowledgements);
488}
489
490/// Drop only findings that explicitly claim compilation failure after a
491/// configured compiler has successfully checked the same file.
492fn suppress_disproved_compile_claims(findings: &mut Vec<Finding>, compiled: &BTreeSet<PathBuf>) {
493    findings.retain(|finding| {
494        !(finding.asserts_compile_failure && compiled.contains(Path::new(&finding.file_path)))
495    });
496}
497
498/// What the process should exit with.
499///
500/// Two precedence rules, in order:
501/// 1. Any failure → `Unanalyzed` (exit 2). A failure outranks a finding,
502///    because the file went unanalyzed whether or not the LLM also produced
503///    findings on a partial result.
504/// 2. Any blocking finding → `FoundIssues` (exit 1). Tool findings always
505///    block; LLM findings block when `fail_on` admits their severity and
506///    `fail_on` is set.
507fn gate(outcome: &CheckOutcome, fail_on: Option<Severity>) -> Exit {
508    if outcome
509        .failures
510        .values()
511        .any(|reason| !matches!(reason, FailureReason::CacheMiss))
512    {
513        return Exit::Unanalyzed;
514    }
515    if any_blocking_tool_finding(&outcome.tool_findings) {
516        return Exit::FoundIssues;
517    }
518    if let Some(threshold) = fail_on
519        && findings::any_at_or_above(&outcome.llm_findings, threshold)
520    {
521        return Exit::FoundIssues;
522    }
523    // The earlier arm returned for every non-cache failure, so only cache
524    // misses remain here. Findings deliberately outrank a retryable miss.
525    if !outcome.failures.is_empty() {
526        return Exit::CacheMiss;
527    }
528    Exit::Clean
529}
530
531/// A tool finding always blocks. A deterministic tool's verdict is the
532/// project's own choice, so the gate honors it without an allow-list.
533fn any_blocking_tool_finding(findings: &[Finding]) -> bool {
534    !findings.is_empty()
535}
536
537/// Who answered, and for how many files.
538///
539/// Read off the chain, which counted as it went - the counts never leave the
540/// object that produced them, so they cannot drift from the demotion state
541/// sitting beside them, and `AnalysisResult` needs no per-provider field whose
542/// merge rule would be the one exception to its union-not-sum invariant.
543/// Providers that served nothing are omitted: the report answers "who reviewed
544/// this code", and an untouched fallback did not.
545fn provider_uses(chain: &ProviderChain) -> Vec<ProviderUse> {
546    chain
547        .providers()
548        .iter()
549        .enumerate()
550        .filter(|(_, provider)| provider.served() > 0)
551        .map(|(index, provider)| ProviderUse {
552            index,
553            model: provider.model().to_owned(),
554            location: provider.location().to_owned(),
555            files: provider.served(),
556        })
557        .collect()
558}
559
560/// The crate's `Exit` re-exported so `cli::check::run` returns the same
561/// type the upper layer expects, without leaking the orchestrator's
562/// dependency path.
563pub use crate::Exit;
564
565#[cfg(test)]
566mod tests;