mod args;
mod deterministic;
pub(crate) mod input;
mod refusal;
mod render;
mod review_budget;
mod semantic;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use crate::analysis::acknowledgements;
use crate::analysis::findings::{self, Finding, Severity};
use crate::analysis::result::{FailureReason, union_failures};
use crate::auth;
use crate::cli::MachineFiles;
use crate::config;
use crate::llm::cache::Cache;
use crate::llm::chain::ProviderChain;
use review_budget::Budget;
pub use args::CheckArgs;
pub struct CheckOutcome {
pub tool_findings: Vec<Finding>,
pub llm_findings: Vec<Finding>,
pub failures: BTreeMap<PathBuf, FailureReason>,
pub provider_uses: Vec<ProviderUse>,
pub retry_push: bool,
pub review_activity: Option<ReviewActivity>,
pub exit: Exit,
}
pub enum ReviewActivity {
Counted { round: u32, limit: u32 },
Reset,
Unlimited,
}
pub struct ProviderUse {
pub index: usize,
pub model: String,
pub location: String,
pub files: usize,
}
pub async fn run(args: &CheckArgs, root: &Path) -> Result<Exit> {
run_with(
args,
root,
Cache::new(Cache::default_root(), CACHE_TTL_DAYS, CACHE_MAX_BYTES),
)
.await
}
pub const CACHE_TTL_DAYS: u64 = 30;
pub const CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
pub(crate) async fn run_with(args: &CheckArgs, root: &Path, cache: Cache) -> Result<Exit> {
run_against(
args,
root,
cache,
&MachineFiles {
auth: &auth::default_path()?,
policy: &config::site::default_path(),
},
)
.await
}
pub(crate) async fn run_against(
args: &CheckArgs,
root: &Path,
cache: Cache,
machine: &MachineFiles<'_>,
) -> Result<Exit> {
let site = config::site::load(machine.policy)?;
let (config_path, mut config) = configured(root, site.as_ref())?;
let collect_policy_scope = site
.as_ref()
.is_some_and(config::site::SiteConfig::has_refuse_markers);
let work = input::resolve(args, root, collect_policy_scope)
.await
.with_context(|| format!("could not resolve input under {}", root.display()))?;
let authoritative = review_budget::is_authoritative(args);
let source = refusal::source(
&refusal::Locations {
config: &config_path,
machine,
},
&mut config,
site.as_ref(),
&work,
cache.clone(),
args.cache_only || args.push_gate || authoritative,
)
.await?;
let acknowledgements = acknowledgements::Store::load(root)?;
let effective_limit = args.max_review_rounds.unwrap_or(config.max_review_rounds);
let semantic_policy = semantic::Policy {
authoritative,
limit: effective_limit,
};
let (deterministic_result, semantic_pass, eligible_push_warm, provider_uses, maintain_cache) =
match source {
refusal::Source::Refused(refusal) => (
deterministic::run(&work, root).await,
semantic::refused(&work, &refusal),
false,
Vec::new(),
false,
),
refusal::Source::Analyze(analyzer) => {
let (deterministic, pass, eligible) =
analyzed(args, root, &work, &analyzer, semantic_policy).await?;
(
deterministic,
pass,
eligible,
provider_uses(analyzer.chain()),
true,
)
}
};
let (tool_findings, tool_failures, compiled_files) = deterministic_result;
let semantic::Pass {
cached: mut llm_result,
live: mut live_result,
live_review,
budget,
live_answered,
} = semantic_pass;
if maintain_cache {
let _ = cache.evict_if_needed();
}
adjudicate_findings(
&mut llm_result.findings,
&compiled_files,
&work,
&acknowledgements,
);
adjudicate_findings(
&mut live_result.findings,
&compiled_files,
&work,
&acknowledgements,
);
let warmed_for_push = eligible_push_warm
&& matches!(
&live_review,
semantic::LiveReview::Unbounded | semantic::LiveReview::Reserved(_)
);
let mut review_activity = None;
match live_review {
semantic::LiveReview::Reserved(claim) => {
if !live_result.findings.is_empty() {
let round = claim.round();
claim.commit()?;
review_activity = Some(ReviewActivity::Counted {
round,
limit: effective_limit,
});
}
}
semantic::LiveReview::Unbounded
if should_report_unlimited(args.unlimited_reviews, live_answered) =>
{
review_activity = Some(ReviewActivity::Unlimited);
}
semantic::LiveReview::Skip
| semantic::LiveReview::Unbounded
| semantic::LiveReview::Denied { .. } => {}
}
llm_result.merge(live_result);
if review_budget::is_completion_scope(args)
&& !args.cache_only
&& !work.by_file.is_empty()
&& work.read_failures.is_empty()
&& tool_findings.is_empty()
&& tool_failures.is_empty()
&& llm_result.findings.is_empty()
&& llm_result.failed_files.is_empty()
{
let reset = if let Some(budget) = &budget {
budget.reset()?
} else {
Budget::for_repo(root, effective_limit).await?.reset()?
};
if should_report_reset(live_answered, reset) {
review_activity = Some(ReviewActivity::Reset);
}
}
let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
union_failures(&mut failures, work.read_failures);
union_failures(&mut failures, tool_failures);
union_failures(&mut failures, llm_result.failed_files);
let mut outcome = CheckOutcome {
tool_findings,
llm_findings: llm_result.findings,
failures,
provider_uses,
retry_push: false,
review_activity,
exit: Exit::Clean,
};
outcome.exit = gate(&outcome, args.fail_on);
if warmed_for_push && outcome.exit == Exit::Clean {
outcome.retry_push = true;
outcome.exit = Exit::CacheMiss;
}
render::render(&outcome, args.format)?;
Ok(outcome.exit)
}
type Deterministic = (
Vec<Finding>,
BTreeMap<PathBuf, FailureReason>,
BTreeSet<PathBuf>,
);
fn configured(
root: &Path,
site: Option<&config::site::SiteConfig>,
) -> Result<(PathBuf, config::Config)> {
let default_config_path = config::default_config_path();
if default_config_path.is_absolute() {
return Err(anyhow!(
"default config path must be repository-relative, got {}",
default_config_path.display()
));
}
let config_path = root.join(default_config_path);
let mut config = config::load(&config_path)
.with_context(|| format!("could not load {}", config_path.display()))?;
if let Some(site) = site {
site.apply(&mut config);
}
Ok((config_path, config))
}
async fn analyzed(
args: &CheckArgs,
root: &Path,
work: &input::Work,
analyzer: &crate::analysis::code_quality::CodeQualityAnalyzer,
policy: semantic::Policy,
) -> Result<(Deterministic, semantic::Pass, bool)> {
let semantic = async {
let cached = analyzer.analyze_files(&work.by_file).await;
if args.push_gate {
Ok(semantic::Stage::Deferred(cached))
} else {
semantic::complete(args, root, work, analyzer, policy, cached, true)
.await
.map(Box::new)
.map(semantic::Stage::Complete)
}
};
let (deterministic, stage) = tokio::join!(deterministic::run(work, root), semantic);
let (tool_findings, tool_failures, compiled_files) = deterministic;
let (pass, eligible) = match stage? {
semantic::Stage::Deferred(cached) => {
let eligible = push_warm_eligible(
&cached,
work.read_failures.is_empty(),
tool_failures.is_empty(),
tool_findings.is_empty(),
);
(
semantic::complete(args, root, work, analyzer, policy, cached, eligible).await?,
eligible,
)
}
semantic::Stage::Complete(pass) => (*pass, false),
};
Ok((
(tool_findings, tool_failures, compiled_files),
pass,
eligible,
))
}
fn push_warm_eligible(
cached: &crate::analysis::result::AnalysisResult,
reads_clean: bool,
tools_analyzed: bool,
tools_clean: bool,
) -> bool {
cached.has_failures()
&& cached
.failed_files
.values()
.all(|reason| matches!(reason, FailureReason::CacheMiss))
&& reads_clean
&& tools_analyzed
&& tools_clean
}
fn should_report_unlimited(requested: bool, live_answered: bool) -> bool {
requested && live_answered
}
fn should_report_reset(live_answered: bool, state_removed: bool) -> bool {
live_answered || state_removed
}
fn adjudicate_findings(
findings: &mut Vec<Finding>,
compiled: &BTreeSet<PathBuf>,
work: &input::Work,
acknowledgements: &acknowledgements::Store,
) {
suppress_disproved_compile_claims(findings, compiled);
acknowledgements::apply(findings, &work.by_file, acknowledgements);
}
fn suppress_disproved_compile_claims(findings: &mut Vec<Finding>, compiled: &BTreeSet<PathBuf>) {
findings.retain(|finding| {
!(finding.asserts_compile_failure && compiled.contains(Path::new(&finding.file_path)))
});
}
fn gate(outcome: &CheckOutcome, fail_on: Option<Severity>) -> Exit {
if outcome
.failures
.values()
.any(|reason| !matches!(reason, FailureReason::CacheMiss))
{
return Exit::Unanalyzed;
}
if any_blocking_tool_finding(&outcome.tool_findings) {
return Exit::FoundIssues;
}
if let Some(threshold) = fail_on
&& findings::any_at_or_above(&outcome.llm_findings, threshold)
{
return Exit::FoundIssues;
}
if !outcome.failures.is_empty() {
return Exit::CacheMiss;
}
Exit::Clean
}
fn any_blocking_tool_finding(findings: &[Finding]) -> bool {
!findings.is_empty()
}
fn provider_uses(chain: &ProviderChain) -> Vec<ProviderUse> {
chain
.providers()
.iter()
.enumerate()
.filter(|(_, provider)| provider.served() > 0)
.map(|(index, provider)| ProviderUse {
index,
model: provider.model().to_owned(),
location: provider.location().to_owned(),
files: provider.served(),
})
.collect()
}
pub use crate::Exit;
#[cfg(test)]
mod tests;