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            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;