mod deterministic;
pub(crate) mod input;
mod render;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use clap::{ArgGroup, Args};
use crate::analysis::acknowledgements;
use crate::analysis::findings::{self, Finding, Severity};
use crate::analysis::result::{FailureReason, union_failures};
use crate::auth;
use crate::cli::{OutputFormat, severity_parser};
use crate::config;
use crate::llm::cache::Cache;
use crate::llm::chain::ProviderChain;
#[derive(Debug, Args)]
#[command(
group(ArgGroup::new("input").args(["paths", "staged", "diff"]).multiple(false)),
group(ArgGroup::new("cache_mode").args(["cache_only", "push_gate"]).multiple(false))
)]
pub struct CheckArgs {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(long)]
pub staged: bool,
#[arg(long, value_name = "REF")]
pub diff: Option<String>,
#[arg(long, value_name = "REF", requires = "diff")]
pub tip: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
#[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
pub fail_on: Option<Severity>,
#[arg(long)]
pub cache_only: bool,
#[arg(long)]
pub push_gate: bool,
}
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 exit: Exit,
}
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, &auth::default_path()?).await
}
pub(crate) async fn run_against(
args: &CheckArgs,
root: &Path,
cache: Cache,
auth_path: &Path,
) -> Result<Exit> {
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()))?;
let store = auth::AuthStore::load(auth_path)
.with_context(|| format!("could not read the auth store at {}", auth_path.display()))?;
auth::resolve(&mut config, &store);
let providers = config.providers();
if providers.is_empty() {
return Err(config::ConfigError::NoProviders(config_path.clone()).into());
}
let work = input::resolve(args, root)
.await
.with_context(|| format!("could not resolve input under {}", root.display()))?;
let acknowledgements = acknowledgements::Store::load(root)?;
let chain =
ProviderChain::new(&providers).map_err(|e| anyhow!("could not build LLM analyzer: {e}"))?;
let analyzer = crate::analysis::code_quality::CodeQualityAnalyzer::new(chain, cache.clone())
.with_cache_only(args.cache_only || args.push_gate);
let (deterministic_result, llm_result) = tokio::join!(
deterministic::run(&work, root),
analyzer.analyze_files(&work.by_file),
);
let (tool_findings, tool_failures, compiled_files) = deterministic_result;
let mut llm_result = llm_result;
let cache_misses_only = !llm_result.failed_files.is_empty()
&& llm_result
.failed_files
.values()
.all(|reason| matches!(reason, FailureReason::CacheMiss));
let warmed_for_push = args.push_gate
&& cache_misses_only
&& work.read_failures.is_empty()
&& tool_failures.is_empty()
&& tool_findings.is_empty();
if warmed_for_push {
let misses: Vec<&[_]> = work
.by_file
.iter()
.filter(|hunks| {
hunks.first().is_some_and(|first| {
matches!(
llm_result.failed_files.get(&first.file_path),
Some(FailureReason::CacheMiss)
)
})
})
.map(Vec::as_slice)
.collect();
for hunks in &misses {
if let Some(first) = hunks.first() {
llm_result.failed_files.remove(&first.file_path);
}
}
llm_result.merge(analyzer.analyze_files_live(&misses).await);
}
let provider_uses = provider_uses(analyzer.chain());
let _ = cache.evict_if_needed();
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);
suppress_disproved_compile_claims(&mut llm_result.findings, &compiled_files);
acknowledgements::apply(&mut llm_result.findings, &work.by_file, &acknowledgements);
let mut outcome = CheckOutcome {
tool_findings,
llm_findings: llm_result.findings,
failures,
provider_uses,
retry_push: false,
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)
}
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;