fairlead 0.20.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
use super::*;
use serde_json::json;

const AT: &str = "2026-11-05T10:00:00.000Z";

fn done(decision: &str, ms: f64, extra: Value) -> String {
    let mut e = json!({"at": AT, "stage": "done", "decision": decision, "ms": ms});
    for (k, v) in extra.as_object().unwrap() {
        e[k] = v.clone();
    }
    e.to_string() + "\n"
}

fn reach(all: bool, reached: usize, suite: usize, ran: usize) -> Value {
    json!({"all": all, "reached": reached, "suite": suite, "ran": ran})
}

#[test]
fn the_median_is_the_middle_value_or_the_mean_of_the_two_middle_ones() {
    assert_eq!(median(&[]), None);
    assert_eq!(median(&[7.0]), Some(7.0));
    assert_eq!(median(&[9.0, 1.0, 5.0]), Some(5.0));
    assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
}

#[test]
fn a_percentile_is_the_nearest_rank_so_always_a_value_that_was_measured() {
    assert_eq!(percentile(&[], 90.0), None);
    assert_eq!(percentile(&[3.0], 90.0), Some(3.0));
    let ten: Vec<f64> = (1..=10).rev().map(f64::from).collect();
    assert_eq!(percentile(&ten, 90.0), Some(9.0));
    assert_eq!(percentile(&ten, 91.0), Some(10.0));
    assert_eq!(percentile(&ten, 50.0), Some(5.0));
    assert_eq!(percentile(&ten, 0.0), Some(1.0));
    assert_eq!(percentile(&ten, 100.0), Some(10.0));
    let twenty: Vec<f64> = (1..=20).map(f64::from).collect();
    assert_eq!(percentile(&twenty, 90.0), Some(18.0));
}

#[test]
fn runs_are_counted_by_how_they_ended_and_only_inside_the_window() {
    let log = done("pass", 1000.0, json!({}))
        + &done("fail", 3000.0, json!({}))
        + &done("deferred", 2000.0, json!({}))
        + &json!({"at": "2026-01-01T00:00:00.000Z", "stage": "done", "decision": "fail", "ms": 9.0})
            .to_string()
        + "\n"
        + &json!({"at": AT, "stage": "write", "decision": "allow", "ms": 9.0}).to_string()
        + "\nnot json\n";
    let s = score(&log, "2026-11-01", &Config::default(), None);
    assert_eq!((s.runs, s.passed, s.failed, s.deferred), (3, 1, 1, 1));
    assert_eq!(
        (s.gate.median_seconds, s.gate.p90_seconds),
        (Some(2.0), Some(3.0))
    );
    assert_eq!(s.plans, Plans::default(), "none of them carries a reach");
}

#[test]
fn plan_shares_are_medians_over_the_runs_that_can_give_one() {
    let log = done("pass", 1.0, json!({"reach": reach(true, 100, 100, 100)}))
        + &done(
            "pass",
            1.0,
            json!({"reach": reach(false, 10, 100, 5), "budget_seconds": 60}),
        )
        + &done(
            "pass",
            1.0,
            json!({"reach": reach(false, 20, 100, 5), "budget_seconds": 60}),
        )
        + &done("pass", 1.0, json!({"reach": reach(false, 0, 0, 0)}))
        + &done("pass", 1.0, json!({"budget_seconds": 60}));
    let s = score(&log, "2026-11-01", &Config::default(), None);
    assert_eq!((s.plans.measured, s.plans.everything), (4, 1));
    assert_eq!(s.plans.everything_share, Some(0.25));
    assert_eq!(
        s.plans.reached_share,
        Some(0.2),
        "of 1.0, 0.1 and 0.2: an empty suite gives no share"
    );
    assert_eq!(s.plans.ran_share, Some(0.5), "of 1.0, 0.5 and 0.25");
    assert_eq!(
        (s.budget.runs, s.budget.ran_share),
        (3, Some(0.375)),
        "the budgeted run with no reach counts as a run and gives no share"
    );
}

#[test]
fn debts_and_waivers_add_up_files_by_reason_and_by_when_they_fall_due() {
    let config: Config = toml::from_str(
        r#"
[[needs]]
id = "smtp"
tcp = ["127.0.0.1:1025"]
tests = ["test/mail/**"]
proved_in = "CI (services job)"

[[needs]]
id = "docker"
command = ["docker", "info"]
tests = ["test/containers/**"]

[[quarantine]]
path = "test/clock.test.ts"
signature = "timezone"
reason = "the clock differs here"
proved_in = "CI on Linux"
until = "2027-01-01"
"#,
    )
    .unwrap();
    let log = done(
        "pass",
        1.0,
        json!({
            "debts": [
                {"step": "unit", "files": 30, "why": "budget", "due": "merge"},
                {"step": "e2e", "files": 12, "why": "blanket", "due": "nightly"},
            ],
            "unmet": [
                {"need": "smtp", "step": "unit", "files": 4, "proved": true},
                {"need": "docker", "step": "unit", "files": 9, "proved": false},
            ],
            "steps": [
                {"id": "unit", "passed": true, "seconds": 1.0},
                {"id": "clock", "passed": false, "quarantined": true, "seconds": 1.0},
            ],
        }),
    ) + &done(
        "pass",
        1.0,
        json!({"debts": [{"step": "unit", "files": 8, "why": "untimed", "due": "merge"}]}),
    ) + &done("pass", 1.0, json!({}));
    let s = score(&log, "2026-11-01", &config, None);
    assert_eq!((s.debts.files, s.debts.runs), (50, 2));
    assert_eq!(
        serde_json::to_value(&s.debts.by_why).unwrap(),
        json!({"blanket": 12, "budget": 30, "untimed": 8})
    );
    assert_eq!(
        serde_json::to_value(&s.debts.by_due).unwrap(),
        json!({"merge": 38, "nightly": 12})
    );
    assert_eq!(
        s.waivers,
        Waivers {
            needs_proved_in: 1,
            quarantine_entries: 1,
            files_proved_in: 4,
            steps_quarantined: 1,
        }
    );
    let text = render(&s);
    assert!(
        text.contains("  debts    50 test files owed in 2 runs: 30 budget, 12 blanket, 8 untimed; due merge 38, nightly 12\n"),
        "{text}"
    );
    assert!(
        text.contains("  waivers  1 need with proved_in, 1 quarantine entry; 4 files left to proved_in, 1 step not provable here\n"),
        "{text}"
    );
}

#[test]
fn a_replay_report_gives_failures_misses_and_recall_as_insights_reads_them() {
    let report =
        json!({"recall": 0.972, "judged": 36, "misses": [{"fix": "a"}], "recurring_groups": []});
    let s = score("", "2026-11-01", &Config::default(), Some(&report));
    assert_eq!(
        s.replay,
        Some(Replay {
            failures: 36,
            misses: 1,
            recall: Some(0.972),
        })
    );
    assert!(
        render(&s).contains("  misses   1 of 36 failures (recall 97.2%)\n"),
        "{}",
        render(&s)
    );
    let whole = json!({"recall": 1.0, "judged": 36, "misses": []});
    let s = score("", "2026-11-01", &Config::default(), Some(&whole));
    assert!(
        render(&s).contains("misses   0 of 36 failures (recall 100%)"),
        "{}",
        render(&s)
    );
}

#[test]
fn a_line_with_nothing_to_say_is_left_out() {
    let s = score("", "2026-11-01", &Config::default(), None);
    assert_eq!(
        render(&s),
        "score  since 2026-11-01, no gate runs recorded on this machine: `fairlead done` records each one\n"
    );
    let log = done(
        "pass",
        41_000.0,
        json!({"reach": reach(false, 18, 100, 18)}),
    );
    let s = score(&log, "2026-11-01", &Config::default(), None);
    assert_eq!(
        render(&s),
        "score  since 2026-11-01, 1 gate run on this machine\n  plans    0 of 1 selected everything (0%); a median plan reaches 18% of the suite\n  gate     median 41 s, p90 41 s; 1 passed, 0 failed\n"
    );
}