keel-harness 0.3.2

A gated harness for AI-assisted delivery: auditable stopping conditions and durable memory across coding agents.
//! **G2 — is this implementation verified?**
//!
//! Checks (PLAN.md §4.4): build + test + lint green; every criterion's oracle
//! executed; diff ⊆ declared blast radius; line budget respected; baseline
//! ratchet not regressed.
//!
//! This is the gate that makes the rest of keel worth having. Everything before
//! it is scaffolding; everything after it is amplification.

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

/// Files that are part of the record or generated by tooling, not part of the
/// change being judged.
///
/// Both the blast radius and the line budget must agree about this or they
/// contradict each other: an earlier version excluded lockfiles from the scope
/// check but charged their thousands of generated lines against the author's
/// diff budget.
const INCIDENTAL: &[&str] = &[".keel/**", "**/*.lock", "*.lock"];

/// Generated projections are keel's own output, regenerated by
/// `keel store render` — a change that adds a lesson legitimately rewrites
/// them. They are excluded from the diff being judged for the same reason
/// `.keel/**` is.
///
/// This does not weaken anything: a *hand-edit* to a projection is caught by
/// `store-drift`, which is a separate check with a separate failure mode.
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();

    // --- the tree is healthy ------------------------------------------------
    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)?);
    }

    // --- every criterion was actually checked -------------------------------
    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));

    // --- the change stayed where it said it would ---------------------------
    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"));
        }
    }

    // --- nothing rotted ------------------------------------------------------
    checks.push(baseline_ratchet(paths, cfg, run)?);

    // --- the context is still current ---------------------------------------
    checks.push(store_drift(paths, cfg)?);
    checks.push(crate::gate::g0::shared_stores_check(paths, cfg));

    // --- lessons that compiled into checks ----------------------------------
    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))
}

/// build / test / lint. An unconfigured step blocks rather than passes: keel
/// cannot tell "there is no lint step" from "nobody wrote one down", and
/// guessing in favour of success is how a green gate stops meaning anything.
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
}

/// The diff must stay inside the declared blast radius.
///
/// The radius is the *computed* impact set when a plan exists, falling back to
/// the spec's declared scope. Files that only ever appear as importers are not
/// permission to edit them — the radius says what breaks, the scope says what
/// may be touched — so the scope is what is enforced, and the plan is used only
/// to report how far the consequences reach.
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(&regressed, 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(", ")))
    }
}

/// A lesson with an oracle **is** a gate check (PLAN.md §4.7, promotion rule 3).
///
/// This is the payoff of the whole learning design: a rule that is enforced
/// does not need to be read, so it costs no context at all. The check carries
/// `from: L-nnnn` so anyone can ask why it exists and get an answer.
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);
                // Firing is use: it keeps the lesson out of demotion review.
                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}")),
    }
}