Skip to main content

majordomus_cli/gates/
judge.rs

1//! The judgement: what each applicable gate said, whether it still describes this tree,
2//! and whether the active task may therefore be called finished.
3//!
4//! # Why a status vocabulary of seven words and not two
5//!
6//! A colour is not a verdict. Two facts measured in this repository decide the shape of
7//! this enum, and both are recorded in `.ai/repo/rules/project/never-reported-is-not-green.v1.md`:
8//!
9//! - On 2026-09-10 four master runs had every Linux job finished — five of them red — while
10//!   three macOS jobs had been queued for between three and seven hours, so the `ci` verdict
11//!   never ran at all. Master carried the defects overnight with nothing showing red. **A
12//!   verdict that never arrived and a verdict that said pass must not be the same word**, and
13//!   neither may be `unknown`: `unknown` is what a reader gets when the question cannot be
14//!   asked, and it invites a shrug. [`GateStatus::Queued`] is what a reader gets when the
15//!   question was asked of a runner that never answered, and it is a debt.
16//! - The coverage gate here counts `#[cfg(test)]` code in its own denominator, so a
17//!   percentage that rises after tests are added proves nothing. Nothing in this module
18//!   reads a gate's *output*: it reads the gate's exit status and the hash of the inputs it
19//!   was taken over, which is a claim that cannot be flattered by adding code.
20//!
21//! # Staleness is the whole point
22//!
23//! A gate's evidence is bound to the files that select it. `GateModel::inputs_of` derives those
24//! from the model — the union of the classes that name the gate, and everything for a gate
25//! every plan selects — and the hash is [`crate::capability::builtin::obligations`]'s, the
26//! same definition `majordomus evidence` records with. Change one of those files and the
27//! recorded hash stops describing the tree: the gate goes [`GateStatus::Stale`], which
28//! blocks completion exactly as a failure does. Evidence that cannot expire is a claim
29//! about the past presented as a claim about the present.
30
31use std::collections::BTreeMap;
32use std::path::Path;
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37use super::model::{GateModel, GatePlan};
38
39/// The ledger event a recorded gate run is, as `share/events.yaml` registers it.
40pub const GATE_EVENT: &str = "task.gate";
41
42// ---------------------------------------------------------------- the status vocabulary
43
44#[derive(
45    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
46)]
47/// ```
48/// use majordomus_cli::gates::GateStatus;
49///
50/// // the three answers a reader is entitled to, and they are different answers
51/// assert!(GateStatus::Fail.refuses(), "a run that failed refuses");
52/// assert!(GateStatus::Queued.unverified(), "asked and unanswered is a debt");
53/// assert!(!GateStatus::Exempt.refuses() && !GateStatus::Exempt.unverified());
54///
55/// // and the one that is neither: a run whose tree has moved proves nothing about this one
56/// assert!(GateStatus::Stale.refuses());
57/// assert_eq!(GateStatus::Stale.as_str(), "stale");
58/// ```
59#[serde(rename_all = "lowercase")]
60/// What is known about one gate. Every word is one this repository's design tokens already
61/// carry (`share/design/tokens.yaml`, `status.states`), so a surface renders it without
62/// being taught a sixth vocabulary and the design gate does not refuse it.
63pub enum GateStatus {
64    /// It ran over these inputs and succeeded. The only word that discharges anything.
65    Pass,
66    /// It ran over these inputs and failed. Completion is refused.
67    Fail,
68    /// It ran, and the files that select it have changed since; what it proved is about a
69    /// tree that no longer exists. Completion is refused, because the alternative is
70    /// treating a claim about the past as a claim about the present.
71    Stale,
72    /// Something it cannot run without has not passed, so its own silence means nothing.
73    Blocked,
74    /// The plan selects it and no run has ever reported. Not `unknown`: the question was
75    /// asked and no answer came, which is a debt somebody owes, and not `pass`, which is
76    /// the mistake this word exists to make impossible.
77    Queued,
78    /// The plan does not select it: nothing the task changed can make it true or false.
79    Exempt,
80    /// It cannot be judged here at all — no model, no git, a ledger that would not read.
81    /// An honest gap, and never a pass.
82    Unknown,
83}
84
85impl GateStatus {
86    /// The word this status is reported under.
87    ///
88    /// ```
89    /// use majordomus_cli::gates::GateStatus;
90    /// assert_eq!(GateStatus::Queued.as_str(), "queued");
91    /// // green and never-reported are different words, which is the whole point
92    /// assert_ne!(GateStatus::Pass.as_str(), GateStatus::Queued.as_str());
93    /// ```
94    pub fn as_str(self) -> &'static str {
95        match self {
96            GateStatus::Pass => "pass",
97            GateStatus::Fail => "fail",
98            GateStatus::Stale => "stale",
99            GateStatus::Blocked => "blocked",
100            GateStatus::Queued => "queued",
101            GateStatus::Exempt => "exempt",
102            GateStatus::Unknown => "unknown",
103        }
104    }
105
106    /// Does this status, on a required gate, refuse canonical completion?
107    ///
108    /// True only for what is *known* to be wrong: a run that failed, a run whose evidence
109    /// no longer describes the tree, and a gate whose prerequisite is in one of those
110    /// states. Absence does not refuse — `queued` and `unknown` are reported as unverified,
111    /// and a worker who claims a task is finished over them is making a claim the record
112    /// contradicts rather than one the tool accepted.
113    ///
114    /// ```
115    /// use majordomus_cli::gates::GateStatus;
116    /// assert!(GateStatus::Fail.refuses() && GateStatus::Stale.refuses());
117    /// assert!(!GateStatus::Queued.refuses(), "absence is a debt, not a failure");
118    /// assert!(!GateStatus::Exempt.refuses());
119    /// ```
120    pub fn refuses(self) -> bool {
121        matches!(
122            self,
123            GateStatus::Fail | GateStatus::Stale | GateStatus::Blocked
124        )
125    }
126
127    /// Is this status the absence of a verdict rather than a verdict?
128    ///
129    /// ```
130    /// use majordomus_cli::gates::GateStatus;
131    /// // asked and unanswered, or unaskable: two different gaps, both reported as gaps
132    /// assert!(GateStatus::Queued.unverified() && GateStatus::Unknown.unverified());
133    /// // a verdict is a verdict whichever way it went
134    /// assert!(!GateStatus::Pass.unverified() && !GateStatus::Fail.unverified());
135    /// // and a gate the change cannot affect was never a question
136    /// assert!(!GateStatus::Exempt.unverified());
137    /// ```
138    pub fn unverified(self) -> bool {
139        matches!(self, GateStatus::Queued | GateStatus::Unknown)
140    }
141}
142
143// ---------------------------------------------------------------- the evidence
144
145/// ```
146/// use majordomus_cli::gates::GateRun;
147///
148/// // what `majordomus evidence --gate` writes: a run, and the tree it was taken over
149/// let run = GateRun {
150///     recorded_at: "2026-01-01T00:00:00Z".into(),
151///     head: "9b1e2d4".into(),
152///     branch: "master".into(),
153///     exit: 0,
154///     command: "scripts/rust-check --ci".into(),
155///     inputs_hash: "3f0a".into(),
156///     session: "s-1".into(),
157/// };
158/// // the hash is what makes a run go stale rather than merely old: the same run read
159/// // against a different tree is evidence about a tree that no longer exists
160/// assert_eq!(run.exit, 0);
161/// assert_ne!(run.inputs_hash, "0000");
162/// ```
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164/// The `task.gate` ledger line that last reported a gate, as the ledger holds it.
165pub struct GateRun {
166    /// When it was recorded, RFC 3339 UTC.
167    pub recorded_at: String,
168    /// The commit the ledger's envelope stamped on it.
169    pub head: String,
170    /// The branch it was recorded on.
171    pub branch: String,
172    /// The exit status the gate reported. `0` is a pass.
173    pub exit: i64,
174    /// The command that produced it — narrative is not evidence.
175    #[serde(default, skip_serializing_if = "String::is_empty")]
176    pub command: String,
177    /// The hash of the files that select the gate, as they stood when it ran.
178    #[serde(default, skip_serializing_if = "String::is_empty")]
179    pub inputs_hash: String,
180    /// The session that recorded it.
181    #[serde(default, skip_serializing_if = "String::is_empty")]
182    pub session: String,
183}
184
185/// The newest `task.gate` line per gate for one task. A line that is not JSON is skipped
186/// and counted: a ledger that has grown one bad line still holds the rest.
187pub(crate) fn runs_for(ledger: &Path, task: &str) -> (BTreeMap<String, GateRun>, usize) {
188    let Ok(text) = std::fs::read_to_string(ledger) else {
189        return (BTreeMap::new(), 0);
190    };
191    let mut out: BTreeMap<String, GateRun> = BTreeMap::new();
192    let mut skipped = 0usize;
193    for line in text.lines().filter(|l| !l.trim().is_empty()) {
194        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
195            skipped += 1;
196            continue;
197        };
198        let s = |k: &str| {
199            v.get(k)
200                .and_then(serde_json::Value::as_str)
201                .unwrap_or_default()
202                .to_string()
203        };
204        if s("event") != GATE_EVENT || s("task") != task {
205            continue;
206        }
207        let gate = s("gate");
208        if gate.is_empty() {
209            skipped += 1;
210            continue;
211        }
212        // the exit status is a number, and a line that carries it as a string is still
213        // readable: the shell writes JSON by hand and one quoting mistake must not silence
214        // a failing gate
215        let exit = v.get("exit").and_then(|e| {
216            e.as_i64()
217                .or_else(|| e.as_str().and_then(|s| s.parse().ok()))
218        });
219        let Some(exit) = exit else {
220            skipped += 1;
221            continue;
222        };
223        // last line wins: the ledger is append-only and ordered
224        out.insert(
225            gate,
226            GateRun {
227                recorded_at: s("ts"),
228                head: s("head"),
229                branch: s("branch"),
230                exit,
231                command: s("command"),
232                inputs_hash: s("inputs_hash"),
233                session: s("session"),
234            },
235        );
236    }
237    (out, skipped)
238}
239
240// ---------------------------------------------------------------- one gate, judged
241
242/// ```
243/// use majordomus_cli::gates::{Gate, GateStatus};
244///
245/// let gate = Gate {
246///     id: "rust-check".into(),
247///     title: "the crate builds, is formatted and passes its tests".into(),
248///     scope: "apps/majordomus-cli".into(),
249///     required: true,
250///     status: GateStatus::Queued,
251///     reason: "the plan selects it and no run has ever reported".into(),
252///     remediation: "majordomus evidence --gate rust-check --exit 0".into(),
253///     source: "the plan".into(),
254///     evidence: None,
255///     inputs: vec!["apps/majordomus-cli/**".into()],
256///     inputs_hash: "3f0a".into(),
257///     evaluated_at: "2026-01-01T00:00:00Z".into(),
258/// };
259/// // silence does not refuse and is not a pass either: it is a debt, reported as one
260/// assert!(!gate.status.refuses());
261/// assert!(gate.status.unverified());
262/// assert!(gate.evidence.is_none());
263/// ```
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265/// One completion gate: what it is, whether it applies, what it said, and what to do.
266pub struct Gate {
267    /// The gate's identity in the model.
268    pub id: String,
269    /// One line naming it, from the model's own summary.
270    pub title: String,
271    /// What it is a gate over: the CI job that runs it.
272    pub scope: String,
273    /// True when the plan selects it and its verdict therefore decides completion. False
274    /// for a gate the plan left out, and for one whose runner is only had on demand.
275    pub required: bool,
276    /// What is known about it.
277    pub status: GateStatus,
278    /// Why it is in this status, in words a worker can act on.
279    pub reason: String,
280    /// What would settle it, as a command.
281    pub remediation: String,
282    /// Where the gate is declared: the model, and the command it runs.
283    pub source: String,
284    /// The run that last reported, when one has.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub evidence: Option<GateRun>,
287    /// The pathspecs its evidence is taken over, derived from the model.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub inputs: Vec<String>,
290    /// The hash of those files as they stand now, when it could be taken.
291    #[serde(default, skip_serializing_if = "String::is_empty")]
292    pub inputs_hash: String,
293    /// When this judgement was made, RFC 3339 UTC. A judgement is about a moment.
294    pub evaluated_at: String,
295}
296
297/// Judge one gate of the model against the plan and the ledger.
298#[allow(clippy::too_many_arguments)]
299fn judge_one(
300    model: &GateModel,
301    plan: &GatePlan,
302    id: &str,
303    run: Option<&GateRun>,
304    hash_now: Option<&str>,
305    task: Option<&str>,
306    now: &str,
307) -> Gate {
308    let decl = model.gate(id);
309    let title = decl
310        .map(|d| d.summary.clone())
311        .filter(|s| !s.is_empty())
312        .unwrap_or_else(|| id.to_string());
313    let scope = decl.map_or_else(|| "unknown".to_string(), |d| d.job.clone());
314    let runs = decl.map_or_else(String::new, |d| d.runs.clone());
315    let inputs = model.inputs_of(id);
316    let selected = plan.selects(id);
317    let remediation = if runs.is_empty() {
318        format!("majordomus evidence --gate {id} --exit 0 --command '<the gate>'")
319    } else {
320        format!("{runs} && majordomus evidence --gate {id} --exit $?")
321    };
322
323    let (status, reason) = if !selected {
324        (
325            GateStatus::Exempt,
326            plan.excluded
327                .get(id)
328                .cloned()
329                .unwrap_or_else(|| "the plan does not select it".to_string()),
330        )
331    } else if task.is_none() {
332        (
333            GateStatus::Unknown,
334            "no active task in this checkout, so no run can be attributed to one".to_string(),
335        )
336    } else {
337        match run {
338            None => (
339                GateStatus::Queued,
340                format!(
341                    "planned ({}) and no run has reported; the change is unverified against it, \
342                     which is not the same as passing",
343                    plan.selected.get(id).map_or("selected", String::as_str)
344                ),
345            ),
346            Some(r) => {
347                let now_hash = hash_now.unwrap_or("");
348                if hash_now.is_none() {
349                    (
350                        GateStatus::Unknown,
351                        "the files that select this gate could not be hashed here, so the run \
352                         cannot be checked against this tree"
353                            .to_string(),
354                    )
355                } else if r.inputs_hash != now_hash {
356                    (
357                        GateStatus::Stale,
358                        format!(
359                            "the run was taken over inputs {} and this tree hashes to {}; \
360                             it no longer describes what it proved",
361                            short(&r.inputs_hash),
362                            short(now_hash)
363                        ),
364                    )
365                } else if r.exit == 0 {
366                    (
367                        GateStatus::Pass,
368                        format!(
369                            "exit 0 at {} over inputs {}",
370                            short(&r.head),
371                            short(now_hash)
372                        ),
373                    )
374                } else {
375                    (
376                        GateStatus::Fail,
377                        format!(
378                            "exit {} at {} over inputs {}",
379                            r.exit,
380                            short(&r.head),
381                            short(now_hash)
382                        ),
383                    )
384                }
385            }
386        }
387    };
388
389    Gate {
390        id: id.to_string(),
391        title,
392        scope,
393        required: selected && !decl.is_some_and(|d| d.on_demand),
394        status,
395        reason,
396        remediation,
397        source: if runs.is_empty() {
398            super::model::MODEL_PATH.to_string()
399        } else {
400            format!("{} ({runs})", super::model::MODEL_PATH)
401        },
402        evidence: run.cloned(),
403        inputs,
404        inputs_hash: hash_now.unwrap_or_default().to_string(),
405        evaluated_at: now.to_string(),
406    }
407}
408
409/// Twelve characters of a hash or a commit, the width every report here uses.
410fn short(v: &str) -> String {
411    if v.is_empty() {
412        "(none)".to_string()
413    } else {
414        v.chars().take(12).collect()
415    }
416}
417
418// ---------------------------------------------------------------- the whole judgement
419
420/// Judge every gate of the model, then let `requires` propagate: a gate whose prerequisite
421/// is refused is [`GateStatus::Blocked`], because its own silence proves nothing.
422///
423/// A gate that has already spoken keeps what it said. Blocking replaces only the absence of
424/// a verdict, so a failing prerequisite never hides a second real failure.
425pub(crate) fn judge(
426    model: &GateModel,
427    plan: &GatePlan,
428    runs: &BTreeMap<String, GateRun>,
429    hashes: &BTreeMap<String, Option<String>>,
430    task: Option<&str>,
431    now: &str,
432) -> Vec<Gate> {
433    let mut out: Vec<Gate> = model
434        .gates
435        .iter()
436        .map(|d| {
437            judge_one(
438                model,
439                plan,
440                &d.id,
441                runs.get(&d.id),
442                hashes.get(&d.id).and_then(Option::as_deref),
443                task,
444                now,
445            )
446        })
447        .collect();
448
449    // to a fixed point, so a chain of prerequisites propagates once
450    loop {
451        let refused: Vec<String> = out
452            .iter()
453            .filter(|g| g.status.refuses())
454            .map(|g| g.id.clone())
455            .collect();
456        let mut changed = false;
457        for gate in out.iter_mut() {
458            if !gate.status.unverified() {
459                continue;
460            }
461            let Some(decl) = model.gate(&gate.id) else {
462                continue;
463            };
464            let Some(cause) = decl.requires.iter().find(|r| refused.contains(r)) else {
465                continue;
466            };
467            gate.status = GateStatus::Blocked;
468            gate.reason = format!(
469                "{} has not passed, and this gate cannot run without it, so its silence \
470                 says nothing about this change",
471                cause
472            );
473            changed = true;
474        }
475        if !changed {
476            break;
477        }
478    }
479    out
480}
481
482/// Sort key: the order a reader wants — what refuses first, then what is unverified, then
483/// the rest — and by id inside each band, through [`crate::order`] so the comparison is the
484/// repository's one comparator.
485pub(crate) fn report_order(gates: &mut [Gate]) {
486    crate::order::canonical(gates);
487}
488
489impl crate::order::Ordered for Gate {
490    /// The band is the rank and the id is both the label and the identity: what refuses
491    /// comes first, then what is unverified, then the rest, and inside each band the
492    /// repository's one comparator decides. Declared on the type rather than written as a
493    /// comparator beside the report, so every projection of a gate list is in this order.
494    fn order_key(&self) -> crate::order::OrderKey<'_> {
495        crate::order::OrderKey::plain(&self.id, &self.id).ranked(band(self.status) as i64)
496    }
497}
498
499fn band(status: GateStatus) -> u8 {
500    match status {
501        GateStatus::Fail | GateStatus::Stale | GateStatus::Blocked => 0,
502        GateStatus::Queued | GateStatus::Unknown => 1,
503        GateStatus::Pass => 2,
504        GateStatus::Exempt => 3,
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::gates::model::plan;
512    use crate::metadata::yaml;
513
514    const MODEL: &str = r#"version: 1
515gates:
516  - id: lint
517    job: structure
518    always: true
519    runs: scripts/lint
520    summary: every script parses
521  - id: build
522    job: rust
523    runs: cargo build
524    summary: the crate builds
525  - id: probe
526    job: site
527    requires: [site]
528    runs: scripts/probe
529    summary: every route answers
530  - id: site
531    job: site
532    runs: scripts/site-build
533    summary: the site builds
534classes:
535  - id: rust
536    paths: [apps/**]
537    gates: [build]
538  - id: web
539    paths: [site/**]
540    gates: [site, probe]
541"#;
542
543    fn model() -> GateModel {
544        yaml::parse_into(MODEL).expect("the fixture model parses")
545    }
546
547    fn run(exit: i64, hash: &str) -> GateRun {
548        GateRun {
549            recorded_at: "2026-09-11T00:00:00Z".into(),
550            head: "0123456789abcdef".into(),
551            branch: "feature/x".into(),
552            exit,
553            command: "cargo build".into(),
554            inputs_hash: hash.into(),
555            session: "s-1".into(),
556        }
557    }
558
559    fn hashes(model: &GateModel, value: &str) -> BTreeMap<String, Option<String>> {
560        model
561            .gates
562            .iter()
563            .map(|g| (g.id.clone(), Some(value.to_string())))
564            .collect()
565    }
566
567    #[test]
568    fn a_planned_gate_nothing_reported_is_queued_and_not_unknown() {
569        let m = model();
570        let p = plan(&m, &["apps/x.rs".into()], false);
571        let g = judge(
572            &m,
573            &p,
574            &BTreeMap::new(),
575            &hashes(&m, "aaaa"),
576            Some("t-1"),
577            "now",
578        );
579        let build = g.iter().find(|g| g.id == "build").expect("planned");
580        assert_eq!(build.status, GateStatus::Queued);
581        assert!(build.required);
582        assert!(!build.status.refuses(), "absence is a debt, not a failure");
583        // and a gate the plan left out is exempt rather than unknown
584        let site = g.iter().find(|g| g.id == "site").expect("declared");
585        assert_eq!(site.status, GateStatus::Exempt);
586        assert!(!site.required);
587    }
588
589    #[test]
590    fn a_failing_run_refuses_and_a_passing_one_does_not() {
591        let m = model();
592        let p = plan(&m, &["apps/x.rs".into()], false);
593        let mut runs = BTreeMap::new();
594        runs.insert("build".to_string(), run(1, "aaaa"));
595        let g = judge(&m, &p, &runs, &hashes(&m, "aaaa"), Some("t-1"), "now");
596        let build = g.iter().find(|g| g.id == "build").unwrap();
597        assert_eq!(build.status, GateStatus::Fail);
598        assert!(build.status.refuses());
599        assert!(build.reason.contains("exit 1"));
600
601        runs.insert("build".to_string(), run(0, "aaaa"));
602        let g = judge(&m, &p, &runs, &hashes(&m, "aaaa"), Some("t-1"), "now");
603        assert_eq!(
604            g.iter().find(|g| g.id == "build").unwrap().status,
605            GateStatus::Pass
606        );
607    }
608
609    #[test]
610    fn a_passing_run_goes_stale_when_the_files_that_select_it_change() {
611        let m = model();
612        let p = plan(&m, &["apps/x.rs".into()], false);
613        let mut runs = BTreeMap::new();
614        runs.insert("build".to_string(), run(0, "aaaa"));
615        // the same run, judged against a tree that has moved on
616        let g = judge(&m, &p, &runs, &hashes(&m, "bbbb"), Some("t-1"), "now");
617        let build = g.iter().find(|g| g.id == "build").unwrap();
618        assert_eq!(build.status, GateStatus::Stale);
619        assert!(build.status.refuses(), "stale refuses exactly as fail does");
620        assert!(build.reason.contains("aaaa") && build.reason.contains("bbbb"));
621    }
622
623    #[test]
624    fn a_gate_whose_prerequisite_failed_is_blocked_rather_than_queued() {
625        let m = model();
626        let p = plan(&m, &["site/index.md".into()], false);
627        let mut runs = BTreeMap::new();
628        runs.insert("site".to_string(), run(2, "aaaa"));
629        let g = judge(&m, &p, &runs, &hashes(&m, "aaaa"), Some("t-1"), "now");
630        let probe = g.iter().find(|g| g.id == "probe").unwrap();
631        assert_eq!(probe.status, GateStatus::Blocked);
632        assert!(probe.reason.contains("site"));
633        // and a gate that already spoke keeps what it said
634        assert_eq!(
635            g.iter().find(|g| g.id == "site").unwrap().status,
636            GateStatus::Fail
637        );
638    }
639
640    #[test]
641    fn nothing_can_be_judged_without_a_task_and_that_is_unknown() {
642        let m = model();
643        let p = plan(&m, &["apps/x.rs".into()], false);
644        let g = judge(&m, &p, &BTreeMap::new(), &hashes(&m, "aaaa"), None, "now");
645        let build = g.iter().find(|g| g.id == "build").unwrap();
646        assert_eq!(build.status, GateStatus::Unknown);
647        assert!(!build.status.refuses());
648    }
649
650    #[test]
651    fn a_run_that_cannot_be_hashed_here_is_unknown_and_never_a_pass() {
652        let m = model();
653        let p = plan(&m, &["apps/x.rs".into()], false);
654        let mut runs = BTreeMap::new();
655        runs.insert("build".to_string(), run(0, "aaaa"));
656        let mut h: BTreeMap<String, Option<String>> = BTreeMap::new();
657        h.insert("build".to_string(), None);
658        let g = judge(&m, &p, &runs, &h, Some("t-1"), "now");
659        assert_eq!(
660            g.iter().find(|g| g.id == "build").unwrap().status,
661            GateStatus::Unknown
662        );
663    }
664
665    #[test]
666    fn the_ledger_reader_takes_the_newest_line_per_gate_and_skips_what_it_cannot_read() {
667        let dir = tempfile::tempdir().expect("a temporary directory");
668        let path = dir.path().join("ledger.jsonl");
669        std::fs::write(
670            &path,
671            concat!(
672                r#"{"event":"task.gate","task":"t-1","gate":"build","exit":1,"inputs_hash":"aaaa","ts":"1"}"#,
673                "\n",
674                r#"{"event":"task.gate","task":"t-1","gate":"build","exit":0,"inputs_hash":"bbbb","ts":"2"}"#,
675                "\n",
676                r#"{"event":"task.gate","task":"other","gate":"build","exit":1,"inputs_hash":"cccc","ts":"3"}"#,
677                "\n",
678                "not json at all\n",
679                r#"{"event":"task.evidence","task":"t-1","covers":"tests"}"#,
680                "\n",
681            ),
682        )
683        .expect("write");
684        let (runs, skipped) = runs_for(&path, "t-1");
685        assert_eq!(skipped, 1, "one unreadable line, counted rather than fatal");
686        let build = runs.get("build").expect("the task's own line");
687        assert_eq!(build.exit, 0, "the newest line wins");
688        assert_eq!(build.inputs_hash, "bbbb");
689        assert_eq!(runs.len(), 1, "another task's run is not this task's");
690    }
691
692    #[test]
693    fn what_refuses_first_is_reported_first() {
694        let m = model();
695        let p = plan(&m, &["site/index.md".into()], false);
696        let mut runs = BTreeMap::new();
697        runs.insert("site".to_string(), run(2, "aaaa"));
698        let mut g = judge(&m, &p, &runs, &hashes(&m, "aaaa"), Some("t-1"), "now");
699        report_order(&mut g);
700        assert!(
701            g[0].status.refuses(),
702            "a reader meets the refusal first, not the exempt list"
703        );
704        assert_eq!(g.last().unwrap().status, GateStatus::Exempt);
705    }
706}