use super::{Check, GateResult, diff, oracle_exec, ratchet, run_plugins};
use crate::config::Config;
use crate::paths::Paths;
use crate::plan::Plan;
use crate::projection::drift;
use crate::run::Run;
use crate::spec::Spec;
use crate::store;
use crate::trajectory::{Payload, Trajectory};
use anyhow::Result;
use globset::{Glob, GlobSetBuilder};
const INCIDENTAL: &[&str] = &[".keel/**", "**/*.lock", "*.lock"];
pub fn is_incidental_for(cfg: &Config, path: &str) -> bool {
let mut b = GlobSetBuilder::new();
for p in INCIDENTAL {
if let Ok(g) = Glob::new(p) {
b.add(g);
}
}
for a in cfg.adapters.iter().filter(|a| a.enabled) {
if let Ok(g) = Glob::new(&a.out) {
b.add(g);
}
}
b.build().map(|s| s.is_match(path)).unwrap_or(false)
}
pub fn run(
paths: &Paths,
cfg: &Config,
spec: &Spec,
plan: Option<&Plan>,
run: &Run,
traj: &mut Trajectory,
) -> Result<GateResult> {
let mut checks = Vec::new();
for (id, cmd) in [
("build", cfg.verify.build.as_ref()),
("test", cfg.verify.test.as_ref()),
("lint", cfg.verify.lint.as_ref()),
] {
checks.push(verify_step(paths, run, traj, id, cmd)?);
}
let coverage = oracle_exec::run_all(paths, cfg, spec);
for r in &coverage.runs {
traj.append(Payload::Oracle {
criterion: r.criterion.clone(),
oracle: r.oracle.clone(),
verdict: r.outcome.glyph().to_lowercase(),
exit_code: r.exit_code,
})?;
}
let evidence = run.write_evidence("oracles.json", &serde_json::to_string_pretty(&coverage)?)?;
checks.push(oracle_coverage(&coverage, &evidence));
let base = run.meta.base_commit.clone().unwrap_or_else(|| diff::default_base(paths));
match diff::against(paths, &base) {
Ok(d) => {
let stat = run.write_evidence("diff-stat.txt", &d.stat())?;
checks.push(blast_radius(cfg, spec, plan, &d, &stat));
checks.push(line_budget(cfg, spec, &d, &stat));
}
Err(e) => {
checks.push(Check::blocked("blast-radius", format!("could not read the diff: {e}")));
checks.push(Check::blocked("line-budget", "no diff to measure"));
}
}
checks.push(baseline_ratchet(paths, cfg, run)?);
checks.push(store_drift(paths, cfg)?);
checks.push(crate::gate::g0::shared_stores_check(paths, cfg));
checks.extend(lesson_checks(paths, cfg, run)?);
checks.extend(run_plugins(paths, cfg, "G2", Some(&spec.front.slug)));
Ok(GateResult::new("G2", Some(spec.front.slug.clone()), checks))
}
fn verify_step(
paths: &Paths,
run: &Run,
traj: &mut Trajectory,
id: &str,
cmd: Option<&String>,
) -> Result<Check> {
let Some(cmd) = cmd.filter(|c| !c.trim().is_empty()) else {
return Ok(Check::blocked(
id,
format!("no `{id}` command configured — set verify.{id} in .keel/keel.toml"),
));
};
let (code, output) = shell(paths, cmd);
let evidence = run.write_evidence(&format!("{id}.txt"), &output)?;
traj.append(Payload::Command {
cmd: cmd.clone(),
exit_code: code.unwrap_or(-1),
evidence: Some(evidence.clone()),
})?;
Ok(match code {
Some(0) => {
let mut c = Check::pass(id, format!("`{cmd}` exited 0"));
c.evidence = Some(evidence);
c
}
Some(n) => {
let mut c = Check::fail(id, format!("`{cmd}` exits 0"), format!("exited {n}"));
c.evidence = Some(evidence);
c
}
None => Check::blocked(id, format!("`{cmd}` could not be executed")),
})
}
fn oracle_coverage(coverage: &oracle_exec::Coverage, evidence: &str) -> Check {
let mut check = if coverage.failed > 0 {
let failing: Vec<String> = coverage
.unsatisfied()
.iter()
.map(|r| format!("{} ({})", r.criterion, r.detail.clone().unwrap_or_default()))
.collect();
Check::fail(
"oracle-coverage",
format!("all {} criteria satisfied", coverage.criteria),
super::join_capped(&failing, 4),
)
} else if coverage.blocked > 0 {
Check::blocked(
"oracle-coverage",
format!("{} oracle(s) could not be executed", coverage.blocked),
)
} else {
Check::pass(
"oracle-coverage",
format!(
"{}/{} oracles pass ({} awaiting human judgement)",
coverage.passed,
coverage.passed + coverage.human,
coverage.human
),
)
};
check.evidence = Some(evidence.to_string());
check
}
fn blast_radius(cfg: &Config, spec: &Spec, plan: Option<&Plan>, d: &diff::Diff, evidence: &str) -> Check {
if spec.front.scope.is_empty() {
return Check::blocked("blast-radius", "the spec declares no scope");
}
let mut builder = GlobSetBuilder::new();
for p in &spec.front.scope {
match Glob::new(p.trim()) {
Ok(g) => { builder.add(g); }
Err(e) => return Check::blocked("blast-radius", format!("scope glob `{p}` is invalid: {e}")),
}
}
for pattern in INCIDENTAL {
if let Ok(g) = Glob::new(pattern) {
builder.add(g);
}
}
for a in cfg.adapters.iter().filter(|a| a.enabled) {
if let Ok(g) = Glob::new(&a.out) {
builder.add(g);
}
}
let Ok(set) = builder.build() else {
return Check::blocked("blast-radius", "could not build the scope matcher");
};
let outside: Vec<String> = d
.files
.iter()
.filter(|f| !set.is_match(f.path.as_str()))
.map(|f| format!("{} (+{} -{})", f.path, f.added, f.removed))
.collect();
let reach = plan
.map(|p| format!(", {} file(s) downstream", p.front.blast.computed.len()))
.unwrap_or_default();
let substantive = d.files.iter().filter(|f| !is_incidental_for(cfg, &f.path)).count();
let mut check = if outside.is_empty() {
Check::pass(
"blast-radius",
format!("{substantive} changed file(s) inside scope{reach}"),
)
} else {
Check::fail(
"blast-radius",
format!("diff ⊆ {}", spec.front.scope.join(", ")),
format!("{} outside scope: {}", outside.len(), super::join_capped(&outside, 5)),
)
};
check.evidence = Some(evidence.to_string());
check
}
fn line_budget(cfg: &Config, spec: &Spec, d: &diff::Diff, evidence: &str) -> Check {
let Some(budget) = spec.front.budget.lines else {
return Check::blocked("line-budget", "the spec declares no `budget.lines`");
};
let churn: usize = d.files.iter().filter(|f| !is_incidental_for(cfg, &f.path)).map(|f| f.churn()).sum();
let mut check = if churn > budget {
Check::fail(
"line-budget",
format!("at most {budget} lines of churn"),
format!("{churn} lines — raise the budget deliberately or split the work"),
)
} else {
Check::pass("line-budget", format!("{churn}/{budget} lines of churn"))
};
check.evidence = Some(evidence.to_string());
check
}
fn baseline_ratchet(paths: &Paths, cfg: &Config, run: &Run) -> Result<Check> {
if cfg.ratchets.is_empty() {
return Ok(Check::blocked(
"baseline-ratchet",
"no ratchets configured — add a [[ratchet]] block to measure regressions",
));
}
let measurements = ratchet::measure(paths, cfg)?;
let evidence = run.write_evidence("ratchet.json", &serde_json::to_string_pretty(&measurements)?)?;
let regressed: Vec<String> = measurements.iter().filter(|m| m.regressed()).map(|m| m.describe()).collect();
let unmeasurable: Vec<String> = measurements
.iter()
.filter(|m| m.value.is_none())
.map(|m| format!("{} ({})", m.id, m.error.clone().unwrap_or_default()))
.collect();
let mut check = if !regressed.is_empty() {
Check::fail(
"baseline-ratchet",
"no metric moves the wrong way",
super::join_capped(®ressed, 5),
)
} else if !unmeasurable.is_empty() {
Check::blocked("baseline-ratchet", super::join_capped(&unmeasurable, 3))
} else {
let improved = measurements.iter().filter(|m| m.improved()).count();
Check::pass(
"baseline-ratchet",
format!(
"{} metric(s) held{}",
measurements.len(),
if improved > 0 { format!(", {improved} improved") } else { String::new() }
),
)
};
check.evidence = Some(evidence);
Ok(check)
}
fn store_drift(paths: &Paths, cfg: &Config) -> Result<Check> {
let hash = store::store_hash_with_shared(paths, cfg)?;
let reports = drift::check_all(paths, cfg, &hash)?;
let bad: Vec<String> = reports
.iter()
.filter(|r| r.state.is_blocking())
.map(|r| format!("{} ({})", r.path, r.state.glyph()))
.collect();
if bad.is_empty() {
Ok(Check::pass("store-drift", format!("{} projections current", reports.len())))
} else {
Ok(Check::fail("store-drift", "no drifted or stale projections", bad.join(", ")))
}
}
fn lesson_checks(paths: &Paths, cfg: &Config, run: &Run) -> Result<Vec<Check>> {
let lessons = crate::lesson::in_force(paths, cfg)?;
let mut ledger = crate::lesson::usage::Ledger::load(paths)?;
let mut out = Vec::new();
for l in lessons.iter().filter(|l| l.front.rule_kind.enforces()) {
let Some(raw) = l.oracle() else { continue };
let id = format!("lesson:{}", l.front.id);
let mut check = match crate::spec::oracle::parse(&raw) {
Err(e) => Check::blocked(&id, format!("{} has an unparseable oracle: {e}", l.front.id)),
Ok(oracle) => {
let outcome = crate::gate::oracle_exec::execute_standalone(paths, cfg, &oracle);
ledger.record_fire(&l.front.id);
match outcome.outcome {
crate::gate::oracle_exec::Outcome::Pass => {
Check::pass(&id, format!("{} holds", l.rule().unwrap_or_else(|| l.front.id.clone())))
}
crate::gate::oracle_exec::Outcome::Fail => Check::fail(
&id,
l.rule().unwrap_or_else(|| "the lesson's rule".into()),
outcome.detail.unwrap_or_else(|| "the lesson's oracle did not pass".into()),
),
_ => Check::blocked(
&id,
outcome.detail.unwrap_or_else(|| "the lesson's oracle could not run".into()),
),
}
}
};
check.from = Some(l.front.id.clone());
out.push(check);
}
ledger.save(paths)?;
let _ = run;
Ok(out)
}
fn shell(paths: &Paths, cmd: &str) -> (Option<i32>, String) {
let shell_bin = if cfg!(windows) { "cmd" } else { "sh" };
let flag = if cfg!(windows) { "/C" } else { "-c" };
match std::process::Command::new(shell_bin)
.arg(flag)
.arg(cmd)
.current_dir(&paths.repo)
.output()
{
Ok(o) => (
o.status.code(),
format!(
"$ {cmd}\n\n{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
),
Err(e) => (None, format!("$ {cmd}\n\ncould not execute: {e}")),
}
}