use std::io::Write as _;
use anyhow::{Context, Result};
use codelore_lib::cli_api::Options;
use codelore_lib::cli_api::facts::FactsDb;
use codelore_lib::cli_api::repo::{GixRepo, Repo as _};
use crate::args::{self, GateFormat};
use crate::{GATE_DELTA_TABLE_ROWS, GATE_FINDINGS_ROWS, vacuous_pass_notice, write_github_output};
pub(crate) fn run_gate_cmd(args: &args::GateArgs) -> Result<()> {
use codelore_lib::change_set::build_change_set_report;
use codelore_lib::cli_api::cache::default_cache_root;
use codelore_lib::cli_api::quality_gates::ledger::{append_gate_runs, now_utc_ts};
use codelore_lib::cli_api::quality_gates::{Thresholds, evaluate_gate_thresholds};
let cache_root = args.cache_dir.clone().unwrap_or_else(default_cache_root);
let thresholds = if let Some(path) = &args.thresholds_file {
Thresholds::from_path(path).context("load thresholds file")?
} else {
Thresholds::discover(&args.repo).context("discover thresholds file")?
};
if thresholds.is_empty() {
if !args.quiet {
eprintln!("{}", vacuous_pass_notice("gate"));
}
if matches!(args.format, GateFormat::Json) {
println!(
"{}",
serde_json::json!({ "changes": [], "findings": [], "violations": [] })
);
}
write_github_output("result", "pass");
write_github_output("violations", "0");
return Ok(());
}
let resolved_defect_calibration = args.defect_calibration.clone().or_else(|| {
thresholds.calibration.defect_artifact.clone().map(|p| {
if p.is_absolute() {
p
} else {
args.repo.join(p)
}
})
});
let opts = Options {
repo_path: args.repo.clone(),
defect_calibration: resolved_defect_calibration,
allow_foreign_calibration: args.allow_foreign_calibration,
temp_dir: args.temp_dir.clone(),
..Options::default()
};
opts.validate().context("validate options")?;
let repo = GixRepo::open(&args.repo).context("open repo")?;
let head_sha = repo.head_sha().context("get HEAD sha")?;
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &cache_root).context("ingest")?;
let changes = repo
.worktree_changes()
.context("enumerate working-tree changes")?;
if changes.is_empty() {
report_gate_clean_tree(args);
return Ok(());
}
db.ensure_ingest_witnessed(&head_sha)?;
let report = build_change_set_report(&db, &repo, &opts, &cache_root)
.context("build change-set report")?;
let mut violations = evaluate_gate_thresholds(&thresholds, &report);
let ts = now_utc_ts();
let records = gate_ledger_records(&thresholds, &report, &violations, &ts);
append_gate_runs(&cache_root, &args.repo, &records);
violations.extend(crate::skipped_gate_violations(
&records,
thresholds.gates.fail_on_skipped,
));
emit_gate_run_notices(args, &thresholds, &report);
if matches!(args.format, GateFormat::Json) {
render_gate_json(&report, &violations)?;
}
render_gate_verdict(args, &report, &violations)
}
fn report_gate_clean_tree(args: &args::GateArgs) {
let verdict = "✅ codelore gate: PASS (no working-tree changes to gate)";
if matches!(args.format, GateFormat::Json) {
println!(
"{}",
serde_json::json!({ "changes": [], "findings": [], "violations": [] })
);
eprintln!("{verdict}");
} else {
println!("{verdict}");
}
write_github_output("result", "pass");
write_github_output("violations", "0");
}
fn emit_gate_run_notices(
args: &args::GateArgs,
thresholds: &codelore_lib::cli_api::quality_gates::Thresholds,
report: &codelore_lib::change_set::ChangeSetReport,
) {
if args.quiet {
return;
}
if report.merge_in_progress {
eprintln!("note: merge/rebase in progress — projection reflects committed HEAD history");
}
if thresholds.diff.delta_code_health_min.is_some()
&& (report.health.baseline_median.is_none() || report.health.projected_median.is_none())
{
eprintln!(
" ⚠ delta_code_health_min: skipped — no whole-repo code-health median to compare"
);
}
}
fn render_gate_json(
report: &codelore_lib::change_set::ChangeSetReport,
violations: &[codelore_lib::cli_api::quality_gates::GateViolation],
) -> Result<()> {
let mut doc = serde_json::to_value(report).context("serialize change-set report")?;
doc["violations"] = serde_json::to_value(violations).context("serialize gate violations")?;
let rendered = serde_json::to_string_pretty(&doc).context("render gate JSON")?;
let mut out = std::io::stdout().lock();
writeln!(out, "{rendered}").context("write gate JSON")?;
Ok(())
}
fn render_gate_advisories(
args: &args::GateArgs,
report: &codelore_lib::change_set::ChangeSetReport,
) -> Result<()> {
if args.quiet {
return Ok(());
}
let mut out = std::io::stdout().lock();
for f in report.findings.iter().take(GATE_FINDINGS_ROWS) {
writeln!(out, "[{}] {}: {}", f.kind, f.path, f.detail).context("write gate advisory")?;
}
let hidden_findings = report.findings.len().saturating_sub(GATE_FINDINGS_ROWS);
if hidden_findings > 0 {
writeln!(out, "(+{hidden_findings} more findings)").context("write gate advisory")?;
}
for d in report.health.deltas.iter().take(GATE_DELTA_TABLE_ROWS) {
match (d.baseline_score, d.projected_score, d.delta) {
(Some(b), Some(p), Some(delta)) => {
writeln!(out, "{} {b:.1} → {p:.1} ({delta:+.1})", d.path)
.context("write gate advisory")?;
}
_ => writeln!(
out,
"{} — {}",
d.path,
d.reason.as_deref().unwrap_or("not scored")
)
.context("write gate advisory")?,
}
}
let hidden = report
.health
.deltas
.len()
.saturating_sub(GATE_DELTA_TABLE_ROWS);
if hidden > 0 {
writeln!(out, "(+{hidden} more files)").context("write gate advisory")?;
}
Ok(())
}
fn render_gate_verdict(
args: &args::GateArgs,
report: &codelore_lib::change_set::ChangeSetReport,
violations: &[codelore_lib::cli_api::quality_gates::GateViolation],
) -> Result<()> {
if violations.is_empty() {
if matches!(args.format, GateFormat::Text) {
println!(
"✅ codelore gate: PASS ({} changed file(s) evaluated)",
report.changes.len()
);
render_gate_advisories(args, report)?;
} else {
eprintln!(
"✅ codelore gate: PASS ({} changed file(s) evaluated)",
report.changes.len()
);
}
write_github_output("result", "pass");
write_github_output("violations", "0");
return Ok(());
}
eprintln!("❌ codelore gate: FAIL — {} violation(s)", violations.len());
if matches!(args.format, GateFormat::Text) {
if !args.quiet {
for v in violations {
eprintln!(
" - {gate}: {path} — actual {actual} vs threshold {threshold}",
gate = v.gate,
path = v.path,
actual = v.actual,
threshold = v.threshold,
);
}
}
render_gate_advisories(args, report)?;
}
write_github_output("result", "fail");
write_github_output("violations", &violations.len().to_string());
anyhow::bail!("{} gate violation(s) — see above", violations.len());
}
fn gate_ledger_records(
thresholds: &codelore_lib::cli_api::quality_gates::Thresholds,
report: &codelore_lib::change_set::ChangeSetReport,
violations: &[codelore_lib::cli_api::quality_gates::GateViolation],
ts: &str,
) -> Vec<codelore_lib::cli_api::quality_gates::ledger::GateRunRecord> {
use codelore_lib::cli_api::quality_gates::ledger::GateRunRecord;
use codelore_lib::cli_api::quality_gates::{change_set_gate_verdict, verdict_from};
let rec = |gate: &str, threshold: f64, value: f64, verdict: &str| GateRunRecord {
ts: ts.to_owned(),
head_sha: report.head_sha.clone(),
gate: gate.to_owned(),
threshold,
value,
verdict: verdict.to_owned(),
mode: "gate".to_owned(),
};
let count = |gate: &str| violations.iter().filter(|v| v.gate == gate).count();
let count_f64 = |gate: &str| f64::from(u32::try_from(count(gate)).unwrap_or(u32::MAX));
let verdict = |gate: &str| if count(gate) == 0 { "passed" } else { "failed" };
let mut records = Vec::new();
let d = &thresholds.diff;
if let Some(min) = d.delta_code_health_min {
let record = match (
report.health.baseline_median,
report.health.projected_median,
) {
(Some(base), Some(projected)) => rec(
"delta_code_health_min",
min,
projected - base,
verdict("delta_code_health_min"),
),
_ => rec("delta_code_health_min", min, 0.0, "skipped"),
};
records.push(record);
}
if let Some(min) = d.delta_code_health_min_per_file {
let measured = report.health.deltas.iter().any(|r| r.delta.is_some());
records.push(rec(
"delta_code_health_min_per_file",
min,
count_f64("delta_code_health_min_per_file"),
verdict_from(measured, count("delta_code_health_min_per_file")),
));
}
if let Some(min) = d.new_file_health_min {
records.push(rec(
"new_file_health_min",
min,
count_f64("new_file_health_min"),
change_set_gate_verdict(report, count("new_file_health_min")),
));
}
if d.no_new_cycles {
records.push(rec(
"no_new_cycles",
0.0,
count_f64("no_new_cycles"),
change_set_gate_verdict(report, count("no_new_cycles")),
));
}
records
}