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}