Skip to main content

majordomus_cli/evidence/
mod.rs

1//! Evidence: what actually ran, against which commit, and whether it still proves anything.
2//!
3//! `docs/CLAIMS.yaml` binds a claim to a test with a **path**. A path is a declaration that
4//! a proof exists somewhere; it is not the proof. Until this module, a claim reading
5//!
6//! ```text
7//! status: guaranteed
8//! test:   test/cases/84_distribution_model.sh
9//! ```
10//!
11//! was rendered on the public site as a guarantee on the strength of that file existing.
12//! Nobody could answer, from the repository, whether the case had ever run, against which
13//! revision, with what result, or whether the result still applied. `scripts/generate-site-data
14//! --check` verified that the path resolves — which is a check that the *reference* is not
15//! broken, not that the *claim* is proven.
16//!
17//! # The model
18//!
19//! Three objects, and one derivation over them:
20//!
21//! * a [`TestId`] — the stable identity of a test, derived from the path a claim already
22//!   names, so nothing is entered by hand and no claim has to be migrated;
23//! * an [`Execution`] — one recorded run of one test: its outcome, its duration, the commit
24//!   it ran against, the digest of the test's own source at the time, when, by which runner
25//!   and from where;
26//! * a [`Ledger`] — the latest execution of every test, committed to the repository as
27//!   durable semantic evidence. Not logs: one line's worth of meaning per test.
28//!
29//! and [`report`], which joins the claims of the index with the ledger and decides, per
30//! claim, a [`ProofState`] that the surfaces render and the gate judges.
31//!
32//! # The lifecycle, end to end
33//!
34//! A claim's path becomes an identity, a run becomes an [`Execution`], the ledger keeps the
35//! latest one per test, and the digest is what notices that the test itself has moved since
36//! — without any commit having to change.
37//!
38//! ```
39//! use majordomus_cli::evidence::{digest_of, Execution, Ledger, Origin, Outcome, Runner, TestId};
40//!
41//! // the identity is derived from the path the claim already names; nothing is entered
42//! let id = TestId::of("test/cases/84_distribution_model.sh").unwrap();
43//! assert_eq!(id.as_string(), "suite:84_distribution_model");
44//! assert_eq!(id.reproduce(), "bash test/run.sh 84_distribution_model");
45//!
46//! let repo = tempfile::tempdir().unwrap();
47//! std::fs::create_dir_all(repo.path().join("test/cases")).unwrap();
48//! std::fs::write(repo.path().join(id.source()), "echo ok\n").unwrap();
49//!
50//! let run = Execution {
51//!     test: id.as_string(),
52//!     runner: Runner::Suite,
53//!     source: id.source(),
54//!     outcome: Outcome::parse("ok"),
55//!     seconds: 3,
56//!     commit: "0".repeat(40),
57//!     working_tree: "clean".into(),
58//!     digest: digest_of(b"echo ok\n"),
59//!     at: "2026-09-11T00:00:00Z".into(),
60//!     origin: Origin::Local,
61//!     command: id.reproduce(),
62//!     run: None,
63//! };
64//! assert!(run.outcome.proves());
65//!
66//! // the ledger is a tracked file, so the evidence survives the process that produced it
67//! let mut ledger = Ledger::empty();
68//! ledger.merge([run]);
69//! ledger.save(repo.path()).unwrap();
70//!
71//! let recorded = Ledger::load(repo.path()).unwrap();
72//! let latest = recorded.latest(&id.as_string()).unwrap();
73//! assert_eq!(latest.digest_matches(repo.path()), Some(true));
74//!
75//! // edit the case afterwards and the recorded run is no longer a run of the test that is
76//! // there now — which no diff against a commit would have shown, had the edit been reverted
77//! std::fs::write(repo.path().join(id.source()), "echo something else\n").unwrap();
78//! assert_eq!(latest.digest_matches(repo.path()), Some(false));
79//! ```
80//!
81//! # What "current" means here, exactly
82//!
83//! A passing run three commits ago is not automatically proof of the tree in front of you,
84//! and pretending otherwise is the failure this module exists to prevent. But declaring
85//! every result stale the instant anything anywhere changes is sound and useless — it
86//! would mean no claim is ever proven except in the seconds after a full run.
87//!
88//! So the derivation is conservative and says which of two things it found:
89//!
90//! * [`ProofState::Proven`] — the test passed, and the tree in front of you is byte for
91//!   byte the tree the run measured: the diff against the execution's own commit is empty.
92//!   The ledger's own row is excluded from that diff, because the evidence is about the
93//!   tree rather than part of what the tests measure — without that exclusion `proven`
94//!   would be unreachable by construction, since recording dirties the tree and committing
95//!   the record moves HEAD past the commit the record names.
96//! * [`ProofState::InputsUnchanged`] — the test passed, and **nothing the claim itself
97//!   names** (its source document, its implementation, its test) differs between the
98//!   recorded commit and the working tree. This is *not* proof at HEAD: something the claim
99//!   does not name may have broken it. It is the absence of any known invalidation, and it
100//!   is labelled as that everywhere it is shown.
101//! * [`ProofState::Stale`] — the test passed, and something the claim names has changed
102//!   since. The changed paths are named.
103//!
104//! The distinction is the point. A surface that collapsed the second into the first would
105//! be the green badge whose derivation cannot be inspected.
106//!
107//! # What this is not
108//!
109//! It is not tamper-proof, and it does not pretend to be. The ledger is a tracked file; a
110//! person can edit `"outcome": "pass"` into it, and the diff is what a reviewer sees. What
111//! the recorded digest *does* buy is staleness detection that survives a revert: an
112//! execution whose test source no longer hashes to the recorded digest did not run this
113//! test, whatever the commit says. Cryptographic ceremony against an attacker who can
114//! already commit to the repository would be ceremony with no threat model.
115//!
116//! It is also not the execution control plane. [`crate::execution`] is about capability
117//! executions of a running server — in-flight work, progress, events, gone with the
118//! process. This is about test runs, durable, committed and read long after the process
119//! that produced them exited. Two different subjects that share an English word.
120
121//! # Example
122//!
123//! The vocabulary, without a repository: a test is named by the path a claim writes down,
124//! a result word is a pass only when the runner said so, and the proof states rank.
125//!
126//! ```
127//! use majordomus_cli::evidence::{Outcome, ProofState, Runner, TestId};
128//!
129//! let t = TestId::of("test/cases/07_scope.sh").unwrap();
130//! assert_eq!(t.runner, Runner::Suite);
131//! assert_eq!(t.as_string(), "suite:07_scope");
132//! assert!(Outcome::parse("ok").proves());
133//! assert!(!Outcome::parse("FAIL").proves());
134//! assert!(ProofState::Proven < ProofState::Stale);
135//! ```
136
137use std::collections::{BTreeMap, BTreeSet};
138use std::path::{Path, PathBuf};
139
140use schemars::JsonSchema;
141use serde::{Deserialize, Serialize};
142use serde_json::Value;
143
144use crate::index::Index;
145
146pub mod coverage;
147pub mod freshness;
148pub mod ledger;
149pub mod provenance;
150pub mod record;
151pub mod run;
152pub mod subject;
153
154pub use coverage::{
155    CommitId, EvidenceCoverage, EvidenceCoverageCount, EvidenceCoverageDimensions,
156    EvidenceCoverageDomain, EvidenceCoverageFloors,
157};
158pub use freshness::{
159    aggregate, changed_between, compare, current, freshness, ledger_at, presented_commit,
160    uncommitted, weakened_by, Comparison, Judgement, Presented, Recorded, Supplementary, TreeState,
161    UNCOMMITTED_RUN,
162};
163pub use ledger::{outcome_counts, Ledger, LedgerTarget, LEDGER_PATH, LOCAL_LEDGER_PATH};
164pub use provenance::{
165    stamp, EvidenceHost, EvidenceProvenance, EvidenceReportArtifact, EvidenceToolchain,
166    EvidenceToolchainSource, StampRequest,
167};
168pub use record::{
169    parse_crate_binaries, read_crate_output, record, CrateBinary, CrateRead, RecordOutcome,
170    RecordRequest,
171};
172pub use run::{
173    run_id, weaken_by_records, weakened_by_records, EvidenceAbsent, EvidenceDropped,
174    EvidenceProducer, EvidenceRunRecord, EvidenceRunTotals, RUN_RECORD_SCHEMA,
175};
176
177/// Which runner produced a result, and therefore how the test is named and re-run.
178///
179/// The repository has exactly two that a claim may name today. A third would be a variant
180/// here and a branch in [`TestId::of`]; a claim naming something neither runner produces is
181/// reported as [`ProofState::Unrunnable`] rather than silently counted as covered.
182///
183/// ```
184/// use majordomus_cli::evidence::{Runner, TestId};
185///
186/// // which runner owns a test is read off where the test lives, never declared
187/// assert_eq!(TestId::of("test/cases/07_scope.sh").unwrap().runner, Runner::Suite);
188/// assert_eq!(
189///     TestId::of("apps/majordomus-cli/tests/why.rs").unwrap().runner,
190///     Runner::Crate
191/// );
192/// // and a path under neither directory belongs to no runner at all
193/// assert!(TestId::of("test/lib.sh").is_none());
194/// ```
195#[derive(
196    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
197)]
198#[serde(rename_all = "snake_case")]
199#[schemars(rename = "EvidenceRunner")]
200/// # Example
201///
202/// ```
203/// use majordomus_cli::evidence::Runner;
204/// assert_eq!(Runner::Suite.prefix(), "suite");
205/// assert_eq!(Runner::Crate.prefix(), "crate");
206/// ```
207pub enum Runner {
208    /// A behavioural case under `test/cases/`, run by `test/run.sh`.
209    Suite,
210    /// An integration test binary under `apps/majordomus-cli/tests/`, run by `cargo test`.
211    Crate,
212}
213
214impl Runner {
215    /// The prefix this runner's test ids carry, and the half of [`TestId::as_string`] that
216    /// says which runner has to be asked for the proof again.
217    ///
218    /// The two are distinct words on purpose: a case and a test binary may share a name,
219    /// and a ledger keyed on the bare name would let one record overwrite the other's.
220    ///
221    /// ```
222    /// use majordomus_cli::evidence::Runner;
223    /// assert_eq!(Runner::Suite.prefix(), "suite");
224    /// assert_eq!(Runner::Crate.prefix(), "crate");
225    /// assert_ne!(Runner::Suite.prefix(), Runner::Crate.prefix());
226    /// ```
227    pub fn prefix(self) -> &'static str {
228        match self {
229            Runner::Suite => "suite",
230            Runner::Crate => "crate",
231        }
232    }
233}
234
235/// What a run said about one test.
236///
237/// `NotRun` is deliberately absent: it is the absence of an execution, not an outcome one
238/// had. Encoding it here would let a recorder write "this did not run" and have it counted
239/// among the things that did.
240///
241/// ```
242/// use majordomus_cli::evidence::Outcome;
243///
244/// assert!(Outcome::parse("ok").proves());
245/// assert!(!Outcome::parse("timeout").proves());
246/// // exactly one of the five is evidence of anything; the other four are reasons there
247/// // is none, kept apart because "it declined" and "the harness broke" are different bugs
248/// assert_eq!(Outcome::parse("skipped"), Outcome::Skip);
249/// assert_eq!(Outcome::parse("nonsense"), Outcome::Error);
250/// ```
251#[derive(
252    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
253)]
254#[serde(rename_all = "snake_case")]
255#[schemars(rename = "EvidenceOutcome")]
256/// # Example
257///
258/// ```
259/// use majordomus_cli::evidence::Outcome;
260/// assert!(Outcome::parse("ok").proves());
261/// assert_eq!(Outcome::parse("wat"), Outcome::Error);
262/// ```
263pub enum Outcome {
264    /// It passed.
265    Pass,
266    /// It failed.
267    Fail,
268    /// It declined to run and said so (an unmet precondition the case reports itself).
269    Skip,
270    /// The bound fired before it finished.
271    Timeout,
272    /// The harness could not run it at all.
273    Error,
274}
275
276impl Outcome {
277    /// Did this run prove the behaviour it was about? Only a pass does.
278    ///
279    /// A skip is a test that declined, and a timeout or an error is a test whose verdict
280    /// nobody has; none of the three is a weaker kind of success, and every derivation in
281    /// this module asks this question rather than matching on the variants itself.
282    ///
283    /// ```
284    /// use majordomus_cli::evidence::Outcome;
285    /// assert!(Outcome::Pass.proves());
286    /// assert!(!Outcome::Skip.proves(), "a test that declined to run proved nothing");
287    /// assert!(!Outcome::Timeout.proves());
288    /// assert!(!Outcome::Error.proves());
289    /// ```
290    pub fn proves(self) -> bool {
291        matches!(self, Outcome::Pass)
292    }
293
294    /// The word the runner writes, read back. Anything unrecognised is [`Outcome::Error`]:
295    /// a result nobody can classify is not a pass.
296    ///
297    /// Case and surrounding space are the runner's business, not the ledger's, so both are
298    /// normalised away. The fallback is the whole point: a truncated line, a renamed status
299    /// word or a new runner's vocabulary fails loudly instead of widening into a green.
300    ///
301    /// ```
302    /// use majordomus_cli::evidence::Outcome;
303    /// assert_eq!(Outcome::parse("PASSED"), Outcome::Pass);
304    /// assert_eq!(Outcome::parse("  skip  "), Outcome::Skip);
305    /// // near-misses included: nothing here guesses at what was meant
306    /// assert_eq!(Outcome::parse("okay"), Outcome::Error);
307    /// assert!(!Outcome::parse("").proves());
308    /// ```
309    pub fn parse(word: &str) -> Outcome {
310        match word.trim().to_ascii_lowercase().as_str() {
311            "ok" | "pass" | "passed" => Outcome::Pass,
312            "fail" | "failed" => Outcome::Fail,
313            "skip" | "skipped" => Outcome::Skip,
314            "timeout" => Outcome::Timeout,
315            _ => Outcome::Error,
316        }
317    }
318}
319
320/// Where a run happened. The canonical model is provider-neutral: a CI adapter records
321/// `Ci`, and nothing here knows or cares which CI it was.
322///
323/// ```
324/// use majordomus_cli::evidence::Origin;
325///
326/// assert_eq!(Origin::parse("CI"), Some(Origin::Ci));
327/// assert_eq!(Origin::parse("local"), Some(Origin::Local));
328/// // naming the provider is the adapter's business; the ledger records the kind of run,
329/// // so a repository that changes CI does not change what its old evidence says
330/// assert!(Origin::parse("github-actions").is_none());
331/// ```
332#[derive(
333    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
334)]
335#[serde(rename_all = "snake_case")]
336#[schemars(rename = "EvidenceOrigin")]
337/// # Example
338///
339/// ```
340/// use majordomus_cli::evidence::Origin;
341/// assert_eq!(Origin::parse("ci"), Some(Origin::Ci));
342/// assert_eq!(Origin::parse("nowhere"), None);
343/// ```
344pub enum Origin {
345    /// Someone ran it on their own machine.
346    Local,
347    /// A continuous-integration run.
348    Ci,
349    /// A release run.
350    Release,
351}
352
353impl Origin {
354    /// Read an origin from the word a recorder was given, or `None` when it names none of
355    /// the three.
356    ///
357    /// The asymmetry with [`Outcome::parse`] is deliberate. An unreadable outcome still has
358    /// to be recorded as something, and the safe something is an error; an unreadable origin
359    /// is a caller passing a word this model does not have, which is worth refusing at the
360    /// boundary rather than recording as a local run nobody made.
361    ///
362    /// ```
363    /// use majordomus_cli::evidence::Origin;
364    /// assert_eq!(Origin::parse(" Release "), Some(Origin::Release));
365    /// assert!(Origin::parse("laptop").is_none());
366    /// ```
367    pub fn parse(word: &str) -> Option<Origin> {
368        match word.trim().to_ascii_lowercase().as_str() {
369            "local" => Some(Origin::Local),
370            "ci" => Some(Origin::Ci),
371            "release" => Some(Origin::Release),
372            _ => None,
373        }
374    }
375}
376
377/// The stable identity of a test: the runner that owns it and the name that runner knows
378/// it by.
379///
380/// Derived from the path a claim already names, which is what makes the migration from
381/// `test: <path>` deterministic and empty of data entry. `test/cases/84_x.sh` is the case
382/// `test/run.sh` calls `84_x`; `apps/majordomus-cli/tests/why.rs` is the binary `cargo test
383/// --test why` runs. Both spellings already exist in the repository; this only names the
384/// join.
385///
386/// ```
387/// use majordomus_cli::evidence::{Runner, TestId};
388///
389/// let id = TestId::of("test/cases/07_scope.sh").unwrap();
390/// assert_eq!(id, TestId { runner: Runner::Suite, name: "07_scope".into() });
391///
392/// // the identity and the path are two spellings of one thing, so the derivation is
393/// // reversible and no claim in `docs/CLAIMS.yaml` has to be rewritten to carry an id
394/// assert_eq!(TestId::of(&id.source()).unwrap(), id);
395/// ```
396#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
397#[schemars(rename = "EvidenceTestId")]
398/// # Example
399///
400/// ```
401/// use majordomus_cli::evidence::TestId;
402/// let t = TestId::of("apps/majordomus-cli/tests/product.rs").unwrap();
403/// assert_eq!(t.as_string(), "crate:product");
404/// assert_eq!(t.reproduce(), "cargo test --test product");
405/// assert!(TestId::of("lib/nothing.sh").is_none());
406/// ```
407pub struct TestId {
408    /// Which runner owns it.
409    pub runner: Runner,
410    /// The name that runner knows it by: a case's file stem, a test binary's file stem.
411    pub name: String,
412}
413
414impl TestId {
415    /// The canonical string form: `suite:84_distribution_model`, `crate:why`.
416    ///
417    /// This is the ledger's key. Every execution is stored and looked up under it, which is
418    /// why the runner is part of it rather than context a reader is expected to carry.
419    ///
420    /// ```
421    /// use majordomus_cli::evidence::TestId;
422    /// assert_eq!(
423    ///     TestId::of("test/cases/07_scope.sh").unwrap().as_string(),
424    ///     "suite:07_scope"
425    /// );
426    /// assert_eq!(
427    ///     TestId::of("apps/majordomus-cli/tests/why.rs").unwrap().as_string(),
428    ///     "crate:why"
429    /// );
430    /// ```
431    pub fn as_string(&self) -> String {
432        format!("{}:{}", self.runner.prefix(), self.name)
433    }
434
435    /// The test a path names, when a runner in this repository produces it.
436    ///
437    /// `None` is an answer, not a failure: a claim may name a file no runner drives (a
438    /// template, a fixture, a document), and the report says so rather than counting it.
439    ///
440    /// ```
441    /// use majordomus_cli::evidence::{Runner, TestId};
442    /// assert_eq!(
443    ///     TestId::of("test/cases/84_distribution_model.sh").unwrap().as_string(),
444    ///     "suite:84_distribution_model"
445    /// );
446    /// assert_eq!(TestId::of("apps/majordomus-cli/tests/why.rs").unwrap().runner, Runner::Crate);
447    /// assert!(TestId::of("share/install/install.sh.in").is_none());
448    /// assert!(TestId::of("-").is_none());
449    /// ```
450    pub fn of(path: &str) -> Option<TestId> {
451        let path = path.trim().trim_matches(|c| c == '\'' || c == '"');
452        if let Some(rest) = path.strip_prefix("test/cases/") {
453            return TestId::named(Runner::Suite, rest.strip_suffix(".sh")?);
454        }
455        if let Some(rest) = path.strip_prefix("apps/majordomus-cli/tests/") {
456            return TestId::named(Runner::Crate, rest.strip_suffix(".rs")?);
457        }
458        None
459    }
460
461    /// A test named directly, validated by the same rule [`TestId::of`] applies to a path.
462    ///
463    /// The identity form (`suite:84_x`) and the path form (`test/cases/84_x.sh`) name the
464    /// same thing, so they must agree about what a name may be. They did not: the path
465    /// form rejected a name containing a separator and the identity form did not, so
466    /// `suite:../../x` produced the source `test/cases/../../x.sh`. Two parsers for one
467    /// grammar is the one-way check this repository keeps finding; this is the grammar,
468    /// and both forms go through it.
469    ///
470    /// ```
471    /// use majordomus_cli::evidence::{Runner, TestId};
472    /// assert_eq!(TestId::named(Runner::Suite, "84_x").unwrap().as_string(), "suite:84_x");
473    /// assert!(TestId::named(Runner::Suite, "../../x").is_none(), "a name is not a path");
474    /// assert!(TestId::named(Runner::Suite, "").is_none());
475    /// assert!(TestId::named(Runner::Crate, "a/b").is_none());
476    /// assert!(TestId::named(Runner::Suite, ".hidden").is_none());
477    /// ```
478    pub fn named(runner: Runner, name: &str) -> Option<TestId> {
479        if name.is_empty() || name.contains('/') || name.contains('\\') || name.starts_with('.') {
480            return None;
481        }
482        Some(TestId {
483            runner,
484            name: name.to_string(),
485        })
486    }
487
488    /// The repository-relative path of the test's own source: the file that is hashed into
489    /// [`Execution::digest`], and one of the paths a claim's staleness is measured over.
490    ///
491    /// ```
492    /// use majordomus_cli::evidence::TestId;
493    /// let id = TestId::of("apps/majordomus-cli/tests/why.rs").unwrap();
494    /// assert_eq!(id.source(), "apps/majordomus-cli/tests/why.rs");
495    /// // a case's source is the file the runner executes, not the runner
496    /// assert_eq!(
497    ///     TestId::of("test/cases/07_scope.sh").unwrap().source(),
498    ///     "test/cases/07_scope.sh"
499    /// );
500    /// ```
501    pub fn source(&self) -> String {
502        match self.runner {
503            Runner::Suite => format!("test/cases/{}.sh", self.name),
504            Runner::Crate => format!("apps/majordomus-cli/tests/{}.rs", self.name),
505        }
506    }
507
508    /// The exact command that runs this one test, for a reader who wants the proof again
509    /// rather than the claim that it exists.
510    ///
511    /// One test, not the suite: a finding a reader can settle in seconds is settled, and
512    /// one that costs a full run is argued about instead.
513    ///
514    /// ```
515    /// use majordomus_cli::evidence::TestId;
516    /// assert_eq!(
517    ///     TestId::of("test/cases/84_distribution_model.sh").unwrap().reproduce(),
518    ///     "bash test/run.sh 84_distribution_model"
519    /// );
520    /// assert_eq!(
521    ///     TestId::of("apps/majordomus-cli/tests/why.rs").unwrap().reproduce(),
522    ///     "cargo test --test why"
523    /// );
524    /// ```
525    pub fn reproduce(&self) -> String {
526        match self.runner {
527            Runner::Suite => format!("bash test/run.sh {}", self.name),
528            Runner::Crate => format!("cargo test --test {}", self.name),
529        }
530    }
531}
532
533/// One recorded run of one test: the whole of what this repository durably remembers about
534/// it.
535///
536/// Every field is provenance. A result with no commit is an anonymous green, which is the
537/// thing a badge must never be derived from.
538///
539/// ```
540/// use majordomus_cli::evidence::{digest_of, Execution, Origin, Outcome, Runner, TestId};
541///
542/// let id = TestId::of("apps/majordomus-cli/tests/why.rs").unwrap();
543/// let run = Execution {
544///     test: id.as_string(),
545///     runner: Runner::Crate,
546///     source: id.source(),
547///     outcome: Outcome::Pass,
548///     seconds: 12,
549///     commit: "0".repeat(40),
550///     working_tree: "clean".into(),
551///     digest: digest_of(b"fn main() {}"),
552///     at: "2026-09-11T00:00:00Z".into(),
553///     origin: Origin::Ci,
554///     command: id.reproduce(),
555///     run: None,
556/// };
557///
558/// // it is a value in a tracked JSON file, so it has to survive the file unchanged
559/// let back: Execution = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
560/// assert_eq!(back, run);
561/// assert_eq!(back.test, "crate:why");
562/// assert_eq!(back.command, "cargo test --test why");
563/// ```
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
565#[schemars(rename = "EvidenceExecution")]
566/// # Example
567///
568/// One recorded run, with the provenance the runner did not carry: which commit, which
569/// tree state, the digest of the test's own source, when, and how to run it again.
570///
571/// ```
572/// use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
573/// let e = Execution {
574///     test: "suite:07_scope".into(),
575///     runner: Runner::Suite,
576///     source: "test/cases/07_scope.sh".into(),
577///     outcome: Outcome::Pass,
578///     seconds: 3,
579///     commit: "a04b65c9".into(),
580///     working_tree: "clean".into(),
581///     digest: "sha256:00".into(),
582///     at: "2026-09-11T00:00:00Z".into(),
583///     origin: Origin::Local,
584///     command: "bash test/run.sh 07_scope".into(),
585///     run: None,
586/// };
587/// assert!(e.outcome.proves());
588/// ```
589pub struct Execution {
590    /// The test, in [`TestId::as_string`] form.
591    pub test: String,
592    /// Which runner produced it.
593    pub runner: Runner,
594    /// The test's own source, repository-relative.
595    pub source: String,
596    /// What it said.
597    pub outcome: Outcome,
598    /// How long it took, in whole seconds.
599    pub seconds: u64,
600    /// The full commit id the run was made against.
601    pub commit: String,
602    /// `clean`, `dirty` or `unknown`: whether the tree the run measured was the commit.
603    pub working_tree: String,
604    /// `sha256:<hex>` of the test's own source as it was when the run was recorded. An
605    /// execution whose test no longer hashes to this did not run the test that is there now.
606    pub digest: String,
607    /// When it was recorded, RFC 3339, UTC.
608    pub at: String,
609    /// Where the run happened.
610    pub origin: Origin,
611    /// The exact command that runs this one test again.
612    pub command: String,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    /// The continuous-integration run the execution was recorded in, when it was recorded in
615    /// one that could name itself. Absent for a local run, and absent from every row recorded
616    /// before runs were named, which is why the ledger's version did not change.
617    pub run: Option<RunRef>,
618}
619
620/// The continuous-integration run an execution was recorded in: enough to navigate back to
621/// the run that produced it.
622///
623/// [`Origin`] says what kind of run it was and deliberately names no provider. This names the
624/// one run, in the words the provider addressed it with when it happened — its identifier,
625/// its attempt, the workflow and job, and the address a reader follows — because a link built
626/// later from a template would point wherever the template's idea of the provider had drifted
627/// to. Only an adapter constructs it; nothing in the model reads the provider's name.
628///
629/// ```
630/// use majordomus_cli::evidence::RunRef;
631///
632/// let actions = |k: &str| {
633///     let v = match k {
634///         "GITHUB_ACTIONS" => "true",
635///         "GITHUB_SERVER_URL" => "https://github.com",
636///         "GITHUB_REPOSITORY" => "owner/repo",
637///         "GITHUB_RUN_ID" => "42",
638///         "GITHUB_RUN_ATTEMPT" => "2",
639///         "GITHUB_WORKFLOW" => "validate",
640///         "GITHUB_JOB" => "evidence",
641///         _ => return None,
642///     };
643///     Some(v.to_string())
644/// };
645/// let run = RunRef::from_env(actions).unwrap();
646/// assert_eq!(run.id, "42");
647/// assert_eq!(run.attempt, 2);
648/// assert_eq!(run.url, "https://github.com/owner/repo/actions/runs/42/attempts/2");
649/// // a run that names neither its pull request's head nor its event still names itself
650/// assert_eq!((run.head_sha, run.event), (None, None));
651///
652/// let head = "0123456789abcdef0123456789abcdef01234567";
653/// let pull = |k: &str| match k {
654///     "MJ_RUN_HEAD_SHA" => Some(head.to_string()),
655///     "GITHUB_EVENT_NAME" => Some("pull_request".to_string()),
656///     _ => actions(k),
657/// };
658/// let run = RunRef::from_env(pull).unwrap();
659/// assert_eq!(run.head_sha.as_deref(), Some(head));
660/// assert_eq!(run.event.as_deref(), Some("pull_request"));
661///
662/// // outside a run there is nothing to name, and a half-described run is not a run
663/// assert!(RunRef::from_env(|_| None).is_none());
664/// assert!(RunRef::from_env(|k| (k == "GITHUB_ACTIONS").then(|| "true".to_string())).is_none());
665/// ```
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
667#[schemars(rename = "EvidenceRun")]
668pub struct RunRef {
669    /// The provider that ran it, as the adapter that recorded it names it.
670    pub provider: String,
671    /// The provider's identifier of the run.
672    pub id: String,
673    /// Which attempt of the run, counted from 1.
674    pub attempt: u32,
675    /// The workflow the run belongs to.
676    pub workflow: String,
677    /// The job within the run that recorded the execution.
678    pub job: String,
679    /// Where a reader finds the run, as the provider addressed it at the time.
680    pub url: String,
681    /// The pull request's head commit when the run was a pull-request run (its `commit` is
682    /// GitHub's merge commit, the tree that ran). Carried beside the commit, never
683    /// substituted for it.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub head_sha: Option<String>,
686    /// The event that started the run: `push`, `pull_request`, `schedule`,
687    /// `workflow_dispatch`.
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub event: Option<String>,
690}
691
692impl RunRef {
693    /// The run described by a GitHub Actions environment, read through `var`, or `None` when
694    /// the environment is not one or leaves out any part a reader would need to find it.
695    ///
696    /// The variable reader is a parameter so the adapter can be exercised without mutating
697    /// the process environment, which other threads of a test binary share.
698    ///
699    /// ```
700    /// use majordomus_cli::evidence::RunRef;
701    /// assert!(RunRef::from_env(|k| (k == "GITHUB_RUN_ID").then(|| "1".to_string())).is_none());
702    /// ```
703    pub fn from_env(var: impl Fn(&str) -> Option<String>) -> Option<RunRef> {
704        if var("GITHUB_ACTIONS").as_deref() != Some("true") {
705            return None;
706        }
707        let server = var("GITHUB_SERVER_URL")?;
708        let repository = var("GITHUB_REPOSITORY")?;
709        let id = var("GITHUB_RUN_ID")?;
710        let attempt: u32 = var("GITHUB_RUN_ATTEMPT")
711            .and_then(|a| a.parse().ok())
712            .unwrap_or(1);
713        Some(RunRef {
714            provider: "github_actions".into(),
715            url: format!(
716                "{}/{repository}/actions/runs/{id}/attempts/{attempt}",
717                server.trim_end_matches('/')
718            ),
719            id,
720            attempt,
721            workflow: var("GITHUB_WORKFLOW")?,
722            job: var("GITHUB_JOB")?,
723            head_sha: var("MJ_RUN_HEAD_SHA").filter(|h| coverage::CommitId::parse(h).is_ok()),
724            event: var("GITHUB_EVENT_NAME").filter(|e| !e.is_empty()),
725        })
726    }
727}
728
729/// A ledger holds one execution per test, so the test identifies it; the label and the
730/// identity are the same string because there is nothing else to read it by.
731impl crate::order::Ordered for Execution {
732    fn order_key(&self) -> crate::order::OrderKey<'_> {
733        crate::order::OrderKey::plain(&self.test, &self.test)
734    }
735}
736
737impl Execution {
738    /// Does the test's source still hash to what was recorded?
739    ///
740    /// `None` when the source is not there to hash — which is itself a finding, reported by
741    /// the path checks, not silently read as a match.
742    ///
743    /// This is the staleness check that survives a revert: an edit made and undone around a
744    /// run leaves no diff against any commit, and the recorded digest still says the test
745    /// that ran is not the test that is there.
746    ///
747    /// ```
748    /// use majordomus_cli::evidence::{digest_of, Execution, Origin, Outcome, Runner, TestId};
749    ///
750    /// let repo = tempfile::tempdir().unwrap();
751    /// let id = TestId::of("test/cases/07_scope.sh").unwrap();
752    /// std::fs::create_dir_all(repo.path().join("test/cases")).unwrap();
753    /// std::fs::write(repo.path().join(id.source()), "echo ok\n").unwrap();
754    ///
755    /// let run = Execution {
756    ///     test: id.as_string(),
757    ///     runner: Runner::Suite,
758    ///     source: id.source(),
759    ///     outcome: Outcome::Pass,
760    ///     seconds: 1,
761    ///     commit: "0".repeat(40),
762    ///     working_tree: "clean".into(),
763    ///     digest: digest_of(b"echo ok\n"),
764    ///     at: "2026-09-11T00:00:00Z".into(),
765    ///     origin: Origin::Local,
766    ///     command: id.reproduce(),
767    ///     run: None,
768    /// };
769    /// assert_eq!(run.digest_matches(repo.path()), Some(true));
770    ///
771    /// std::fs::write(repo.path().join(id.source()), "echo something else\n").unwrap();
772    /// assert_eq!(run.digest_matches(repo.path()), Some(false));
773    ///
774    /// // and a source that is gone cannot be compared, which is not the same as agreeing
775    /// std::fs::remove_file(repo.path().join(id.source())).unwrap();
776    /// assert_eq!(run.digest_matches(repo.path()), None);
777    /// ```
778    pub fn digest_matches(&self, root: &Path) -> Option<bool> {
779        let text = std::fs::read(root.join(&self.source)).ok()?;
780        Some(digest_of(&text) == self.digest)
781    }
782}
783
784/// `sha256:<hex>` of some bytes, the one spelling used in the ledger.
785///
786/// The algorithm is named in the value rather than assumed by the reader, so a ledger
787/// written before a change of algorithm stays readable and says which one produced it.
788///
789/// ```
790/// use majordomus_cli::evidence::digest_of;
791///
792/// let d = digest_of(b"echo ok\n");
793/// assert!(d.starts_with("sha256:"), "{d}");
794/// assert_eq!(d.len(), "sha256:".len() + 64);
795/// assert_eq!(d, digest_of(b"echo ok\n"), "the same bytes hash the same");
796/// assert_ne!(d, digest_of(b"echo ok"), "a trailing newline is a different file");
797/// ```
798pub fn digest_of(bytes: &[u8]) -> String {
799    use sha2::{Digest, Sha256};
800    let mut h = Sha256::new();
801    h.update(bytes);
802    format!("sha256:{:x}", h.finalize())
803}
804
805/// What the repository can say about one claim's proof, right now.
806///
807/// Ordered from strongest to weakest, so a summary that sorts by this reads as a ranking.
808///
809/// ```
810/// use majordomus_cli::evidence::ProofState;
811///
812/// let mut states = vec![ProofState::NotRun, ProofState::Stale, ProofState::Proven];
813/// states.sort();
814/// assert_eq!(
815///     states,
816///     vec![ProofState::Proven, ProofState::Stale, ProofState::NotRun]
817/// );
818///
819/// // the two strongest are deliberately not one state: `inputs unchanged` is the absence
820/// // of a known invalidation, and collapsing it into `proven` is the unaccountable badge
821/// assert_ne!(ProofState::Proven, ProofState::InputsUnchanged);
822/// assert!(ProofState::Proven < ProofState::InputsUnchanged);
823/// assert!(ProofState::InputsUnchanged.passing());
824/// ```
825#[derive(
826    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
827)]
828#[serde(rename_all = "snake_case")]
829/// # Example
830///
831/// ```
832/// use majordomus_cli::evidence::ProofState;
833/// assert!(ProofState::Proven.passing());
834/// assert!(!ProofState::NotRun.passing());
835/// assert!(!ProofState::InputsUnchanged.meaning().is_empty());
836/// ```
837pub enum ProofState {
838    /// A passing run, and nothing has changed since it — the diff against the execution's
839    /// own commit is empty but for the ledger. Proof of the tree in front of you.
840    Proven,
841    /// A passing run, and nothing this claim names has changed since. Not proof at HEAD:
842    /// something the claim does not name may have broken it.
843    InputsUnchanged,
844    /// A passing run, but something this claim names has changed since. The proof is older
845    /// than its subject.
846    Stale,
847    /// The latest run of this claim's test did not pass.
848    Failing,
849    /// The claim names a test a runner owns, and no run of it has ever been recorded.
850    NotRun,
851    /// The claim names a path no runner in this repository drives. Nothing can record it,
852    /// so nothing can prove it this way.
853    Unrunnable,
854    /// The claim names no test at all. Correct for a `planned` or `rejected` claim, and a
855    /// defect for any other.
856    NoTest,
857}
858
859impl ProofState {
860    /// The word every surface prints for this state, and the key the report's totals are
861    /// counted under.
862    ///
863    /// One spelling, defined here, so that the badge, the terminal rendering and the JSON
864    /// cannot disagree about what they are showing the same reader.
865    ///
866    /// ```
867    /// use majordomus_cli::evidence::ProofState;
868    /// assert_eq!(ProofState::Proven.label(), "proven");
869    /// assert_eq!(ProofState::InputsUnchanged.label(), "inputs unchanged");
870    /// assert_ne!(ProofState::Proven.label(), ProofState::InputsUnchanged.label());
871    /// ```
872    pub fn label(self) -> &'static str {
873        match self {
874            ProofState::Proven => "proven",
875            ProofState::InputsUnchanged => "inputs unchanged",
876            ProofState::Stale => "stale",
877            ProofState::Failing => "failing",
878            ProofState::NotRun => "not run",
879            ProofState::Unrunnable => "unrunnable",
880            ProofState::NoTest => "no test",
881        }
882    }
883
884    /// The design-token word for this state: its serialised name with `_` written as `-`.
885    ///
886    /// A surface that colours a state asks for its token and nothing else, so the mapping
887    /// from a state to a design token is this one spelling rather than a table in each
888    /// renderer.
889    ///
890    /// ```
891    /// use majordomus_cli::evidence::ProofState;
892    /// assert_eq!(ProofState::Proven.token(), "proven");
893    /// assert_eq!(ProofState::InputsUnchanged.token(), "inputs-unchanged");
894    /// assert_eq!(ProofState::NoTest.token(), "no-test");
895    /// ```
896    pub fn token(self) -> &'static str {
897        match self {
898            ProofState::Proven => "proven",
899            ProofState::InputsUnchanged => "inputs-unchanged",
900            ProofState::Stale => "stale",
901            ProofState::Failing => "failing",
902            ProofState::NotRun => "not-run",
903            ProofState::Unrunnable => "unrunnable",
904            ProofState::NoTest => "no-test",
905        }
906    }
907
908    /// One sentence: what the state means, for the reader who clicked the badge. This is
909    /// the derivation, in words, and it lives here so that every surface says the same
910    /// thing rather than each inventing its own gloss.
911    ///
912    /// The sentence for [`ProofState::InputsUnchanged`] is the one that has to be carried
913    /// everywhere, because it is the state a surface is tempted to render as a plain green.
914    ///
915    /// ```
916    /// use majordomus_cli::evidence::ProofState;
917    ///
918    /// let m = ProofState::InputsUnchanged.meaning();
919    /// assert!(m.contains("not proof against the current commit"), "{m}");
920    /// assert_ne!(m, ProofState::Proven.meaning());
921    /// ```
922    pub fn meaning(self) -> &'static str {
923        match self {
924            ProofState::Proven => {
925                "A passing run, and nothing in the repository has changed since it — the tree \
926                 in front of you is the tree the run measured."
927            }
928            ProofState::InputsUnchanged => {
929                "A passing run, and nothing this claim names has changed since it. This is the \
930                 absence of a known invalidation, not proof against the current commit: a change \
931                 the claim does not name could have broken it."
932            }
933            ProofState::Stale => {
934                "A passing run, but a file this claim names has changed since. The proof is older \
935                 than what it is about."
936            }
937            ProofState::Failing => {
938                "The most recent recorded run of this claim's test did not pass."
939            }
940            ProofState::NotRun => {
941                "The claim names a test, and no run of that test has ever been recorded."
942            }
943            ProofState::Unrunnable => {
944                "The claim names a path that no runner in this repository drives, so no execution \
945                 of it can ever be recorded."
946            }
947            ProofState::NoTest => "The claim names no test.",
948        }
949    }
950
951    /// Does this state carry a passing execution behind it, of any freshness?
952    ///
953    /// Freshness and outcome are two questions, and this answers only the second. A count
954    /// of passing claims is honest; a count that called them all proven would not be.
955    ///
956    /// ```
957    /// use majordomus_cli::evidence::ProofState;
958    /// assert!(ProofState::Proven.passing());
959    /// assert!(ProofState::Stale.passing(), "a pass older than its subject is still a pass");
960    /// assert!(!ProofState::Failing.passing());
961    /// assert!(!ProofState::NotRun.passing());
962    /// assert!(!ProofState::Unrunnable.passing());
963    /// ```
964    pub fn passing(self) -> bool {
965        matches!(
966            self,
967            ProofState::Proven | ProofState::InputsUnchanged | ProofState::Stale
968        )
969    }
970}
971
972/// What a state may still be once the tree the run measured is taken into account.
973///
974/// `proven` is "a passing run recorded against this exact commit with a clean tree"
975/// (ADR 0041), and docs/EVIDENCE.md says why the tree is half of that: "A dirty tree is
976/// recorded as `dirty` for exactly this reason: it is the one case where the commit does
977/// not describe what ran." A run measured against a tree that was not its commit is
978/// therefore capped at `inputs_unchanged`: a pass, joined to a commit, with no claim that
979/// the commit is what it saw. A weaker state is never strengthened here, and `unknown` —
980/// git could not be asked — is treated as not clean, because not knowing is not proof.
981///
982/// ```
983/// use majordomus_cli::evidence::{capped_by_working_tree, ProofState};
984///
985/// // a clean tree changes nothing
986/// assert_eq!(
987///     capped_by_working_tree(ProofState::Proven, "clean"),
988///     ProofState::Proven
989/// );
990/// // a dirty one costs the strongest state, and only that one
991/// assert_eq!(
992///     capped_by_working_tree(ProofState::Proven, "dirty"),
993///     ProofState::InputsUnchanged
994/// );
995/// assert_eq!(
996///     capped_by_working_tree(ProofState::Proven, "unknown"),
997///     ProofState::InputsUnchanged
998/// );
999/// assert_eq!(
1000///     capped_by_working_tree(ProofState::Stale, "dirty"),
1001///     ProofState::Stale
1002/// );
1003/// ```
1004pub fn capped_by_working_tree(state: ProofState, working_tree: &str) -> ProofState {
1005    match (state, working_tree) {
1006        (ProofState::Proven, t) if t != "clean" => ProofState::InputsUnchanged,
1007        (s, _) => s,
1008    }
1009}
1010
1011/// One claim, joined to whatever the repository actually recorded about it.
1012///
1013/// The claim's own fields are carried verbatim beside the derived ones, so that a reader
1014/// who disbelieves the state can check the join rather than take it.
1015///
1016/// ```
1017/// use majordomus_cli::evidence::{ClaimProof, ProofState};
1018///
1019/// let proof = ClaimProof {
1020///     id: "scope-integrity".into(),
1021///     claim: "a commit outside the task's scope is refused".into(),
1022///     status: "guaranteed".into(),
1023///     source: Some("docs/SCOPE.md".into()),
1024///     implementation: Some("apps/majordomus-cli/src/scope.rs".into()),
1025///     test_path: Some("test/cases/07_scope.sh".into()),
1026///     test: Some("suite:07_scope".into()),
1027///     state: ProofState::NotRun,
1028///     meaning: ProofState::NotRun.meaning().to_string(),
1029///     execution: None,
1030///     changed: vec![],
1031///     detail: None,
1032///     reproduce: Some("bash test/run.sh 07_scope".into()),
1033/// };
1034///
1035/// let json = serde_json::to_value(&proof).unwrap();
1036/// // `changed` is emitted empty rather than omitted: a client must not have to tell
1037/// // "no file changed" from "the server did not say"
1038/// assert_eq!(json["changed"], serde_json::json!([]));
1039/// // an execution that does not exist is absent, not a null standing in for one
1040/// assert!(json.get("execution").is_none());
1041/// // and so is a detail the state did not need
1042/// assert!(json.get("detail").is_none());
1043/// assert_eq!(json["meaning"], serde_json::json!(ProofState::NotRun.meaning()));
1044/// ```
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1046#[schemars(rename = "EvidenceClaimProof")]
1047/// # Example
1048///
1049/// A `ClaimProof` is produced by [`report`]; its `state` is the whole answer and its
1050/// `meaning` is that answer in a sentence, worded once for every surface.
1051///
1052/// ```
1053/// use majordomus_cli::evidence::{report, ClaimProof, Ledger};
1054/// use majordomus_cli::synthetic::SyntheticRepository;
1055/// let repo = SyntheticRepository::small().unwrap();
1056/// let ledger = Ledger::load(repo.root()).unwrap();
1057/// let r = report(&repo.index().unwrap(), &ledger);
1058/// let proofs: &[ClaimProof] = &r.claims;
1059/// assert!(proofs.is_empty(), "a synthetic tree declares no claims");
1060/// ```
1061pub struct ClaimProof {
1062    /// The claim's id, as `docs/CLAIMS.yaml` spells it.
1063    pub id: String,
1064    /// The claim sentence.
1065    pub claim: String,
1066    /// The status the claim declares: `guaranteed`, `advisory`, `planned`, `rejected`.
1067    pub status: String,
1068    /// The document that defines the behaviour.
1069    #[serde(skip_serializing_if = "Option::is_none")]
1070    pub source: Option<String>,
1071    /// The file that implements it.
1072    #[serde(skip_serializing_if = "Option::is_none")]
1073    pub implementation: Option<String>,
1074    /// The path the claim names as its test, verbatim.
1075    #[serde(skip_serializing_if = "Option::is_none")]
1076    pub test_path: Option<String>,
1077    /// The test's stable identity, when a runner owns it.
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    pub test: Option<String>,
1080    /// What the repository can say about the proof.
1081    pub state: ProofState,
1082    /// That state, in one sentence.
1083    pub meaning: String,
1084    /// The execution the state was derived from, when there is one.
1085    #[serde(skip_serializing_if = "Option::is_none")]
1086    pub execution: Option<Execution>,
1087    /// The files this claim names that differ between the recorded commit and the working
1088    /// tree. Empty unless the state is [`ProofState::Stale`].
1089    ///
1090    /// Always emitted, empty included: the schema this capability publishes says the key is
1091    /// there, and a client that has to tell "no files changed" from "the server did not say"
1092    /// is a client reading two different answers as one.
1093    pub changed: Vec<String>,
1094    /// Why the state is what it is, in one sentence, when the state alone does not say: a
1095    /// test that declined to run, a run on a commit the presented revision does not
1096    /// contain, a tree that was not its commit, a failing run the checkout holds uncommitted.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub detail: Option<String>,
1099    /// The command that produces the proof again, when a runner owns the test.
1100    #[serde(skip_serializing_if = "Option::is_none")]
1101    pub reproduce: Option<String>,
1102}
1103
1104/// A claim whose declared status the evidence does not support.
1105///
1106/// Only a `guaranteed` claim can produce one: the other statuses already say that a current
1107/// proof is not what they are claiming. A finding says what was claimed, what the evidence
1108/// supports instead, and — where running something would settle it — what to run.
1109///
1110/// ```
1111/// use majordomus_cli::evidence::{Finding, ProofState};
1112///
1113/// let finding = Finding {
1114///     claim: "scope-integrity".into(),
1115///     status: "guaranteed".into(),
1116///     state: ProofState::NotRun,
1117///     reason: "the claim guarantees a behaviour and names a test, and no run of that \
1118///              test has ever been recorded"
1119///         .into(),
1120///     reproduce: Some("bash test/run.sh 07_scope".into()),
1121/// };
1122///
1123/// // the gap is the finding: what the matrix declares against what the ledger holds
1124/// assert_ne!(finding.status, finding.state.label());
1125/// assert!(finding.reproduce.unwrap().starts_with("bash test/run.sh"));
1126/// ```
1127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1128#[schemars(rename = "EvidenceFinding")]
1129/// # Example
1130///
1131/// ```
1132/// use majordomus_cli::evidence::{Finding, ProofState};
1133/// let f = Finding {
1134///     claim: "some-claim".into(),
1135///     status: "guaranteed".into(),
1136///     state: ProofState::NotRun,
1137///     reason: "the claim names a test nobody has run".into(),
1138///     reproduce: Some("bash test/run.sh 07_scope".into()),
1139/// };
1140/// assert_eq!(f.state, ProofState::NotRun);
1141/// ```
1142pub struct Finding {
1143    /// The claim.
1144    pub claim: String,
1145    /// What the claim says it is.
1146    pub status: String,
1147    /// What the evidence supports.
1148    pub state: ProofState,
1149    /// Why this is a finding.
1150    pub reason: String,
1151    /// What to run, when running something would settle it.
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub reproduce: Option<String>,
1154}
1155
1156/// What the ledger is and whether it is there at all.
1157///
1158/// A report that did not say where its evidence came from would be asking to be believed.
1159/// This is the provenance of the whole report: the file, how much it holds, how old the
1160/// newest entry is, and which commits anything was ever recorded against.
1161///
1162/// ```
1163/// use majordomus_cli::evidence::{Ledger, LedgerSummary, LEDGER_PATH};
1164///
1165/// let summary: LedgerSummary = Ledger::empty().summary();
1166/// assert_eq!(summary.path, LEDGER_PATH);
1167/// assert!(!summary.present, "a ledger holding nothing has recorded nothing");
1168/// assert_eq!(summary.executions, 0);
1169/// assert!(summary.newest.is_none());
1170/// assert!(summary.commits.is_empty());
1171/// ```
1172
1173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1174#[schemars(rename = "EvidenceLedgerSummary")]
1175/// # Example
1176///
1177/// ```
1178/// use majordomus_cli::evidence::LedgerSummary;
1179/// let s = LedgerSummary {
1180///     path: ".ai/repo/evidence/ledger.json".into(),
1181///     present: false,
1182///     executions: 0,
1183///     newest: None,
1184///     commits: vec![],
1185/// };
1186/// assert!(!s.present, "a repository that recorded nothing says so");
1187/// ```
1188pub struct LedgerSummary {
1189    /// Where it lives, repository-relative.
1190    pub path: String,
1191    /// Whether the ledger holds at least one execution.
1192    ///
1193    /// Not whether the file exists. A ledger file holding nothing and no ledger file at
1194    /// all are the same answer to the only question a reader is asking — has anything
1195    /// been recorded — and both make every claim read `not_run`. [`Ledger::present`]
1196    /// answers the filesystem question, and nothing derives a proof state from it.
1197    pub present: bool,
1198    /// How many executions it holds.
1199    pub executions: usize,
1200    /// The newest `at` in it, when it holds any.
1201    #[serde(skip_serializing_if = "Option::is_none")]
1202    pub newest: Option<String>,
1203    /// The distinct commits its executions were recorded against. Always emitted, empty
1204    /// included, for the reason [`ClaimProof::changed`] is.
1205    pub commits: Vec<String>,
1206}
1207
1208/// Whether the report could see its whole subject.
1209///
1210/// A verdict over a matrix the index silently shrank is the failure this repository met
1211/// four times in one day: an object is excluded, every projection of the smaller index is a
1212/// faithful projection, and each check prints a true statement about the half it can see.
1213/// A freshness check answers "was this generated from this tree" and cannot answer "did the
1214/// generator see everything in it", so the answer has to carry its own denominator.
1215///
1216/// ```
1217/// use majordomus_cli::evidence::Subject;
1218/// let whole = Subject { examined: 149, complete: true, excluded: vec![] };
1219/// assert!(whole.complete);
1220///
1221/// let partial = Subject {
1222///     examined: 148,
1223///     complete: false,
1224///     excluded: vec!["docs/CLAIMS.yaml: unknown_key".into()],
1225/// };
1226/// assert!(!partial.complete, "a shrunken index must not report a clean verdict");
1227/// ```
1228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1229#[schemars(rename = "EvidenceSubject")]
1230pub struct Subject {
1231    /// How many claims the index held, and this report therefore examined.
1232    pub examined: usize,
1233    /// Whether the index that produced them carried no error diagnostic. When this is
1234    /// false, every count and every absence below is about a smaller world than the
1235    /// repository, and no verdict over it means what it says.
1236    pub complete: bool,
1237    /// The diagnostics that excluded something, when any did: `<path>: <code>`.
1238    pub excluded: Vec<String>,
1239}
1240
1241/// What a report was judged at: the revision, its tree, and the runs the checkout holds that
1242/// the revision's own ledger does not.
1243///
1244/// A verdict is always a verdict at something. `evidence show` judges the working tree;
1245/// `evidence show --presented HEAD` judges the checked-out commit as committed, from the
1246/// ledger committed in it — which is the reading a site built from that commit needs.
1247///
1248/// ```
1249/// use majordomus_cli::evidence::{PresentedRevision, TreeState};
1250///
1251/// let here = PresentedRevision {
1252///     revision: "working_tree".into(),
1253///     tree: TreeState::Dirty,
1254///     uncommitted: vec![],
1255/// };
1256/// let json = serde_json::to_value(&here).unwrap();
1257/// assert_eq!(json["tree"], "dirty");
1258/// // nothing held uncommitted is absent, not an empty list to read past
1259/// assert!(json.get("uncommitted").is_none());
1260/// ```
1261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1262#[schemars(rename = "EvidencePresentedRevision")]
1263pub struct PresentedRevision {
1264    /// `working_tree`, or the full id of the commit judged as committed.
1265    pub revision: String,
1266    /// Whether that revision's tree was its commit: for the working tree, the checkout's
1267    /// state; for a commit, the checkout measured ignoring the ledger's working copy and
1268    /// weakened by anything the caller knew.
1269    pub tree: TreeState,
1270    /// The tests whose execution in the working copy of the ledger the presented commit's
1271    /// ledger does not hold, sorted. Read only to withhold `proven`, never to decide a
1272    /// verdict; always empty for the working tree, whose ledger is itself the authority.
1273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1274    pub uncommitted: Vec<String>,
1275}
1276
1277/// The whole joined picture: every claim of the matrix against every execution recorded.
1278///
1279/// What the capability answers, what the gate judges and what the site renders are this one
1280/// value; the totals and the findings are derived from the claims, never counted twice.
1281///
1282/// ```
1283/// use majordomus_cli::evidence::{
1284///     EvidenceReport, Finding, Ledger, PresentedRevision, ProofState, Subject, TreeState,
1285/// };
1286///
1287/// let mut report = EvidenceReport {
1288///     head: None,
1289///     working_tree: "unknown".into(),
1290///     presented: PresentedRevision {
1291///         revision: "working_tree".into(),
1292///         tree: TreeState::Unknown,
1293///         uncommitted: vec![],
1294///     },
1295///     ledger: Ledger::empty().summary(),
1296///     subject: Subject { examined: 0, complete: true, excluded: vec![] },
1297///     claims: vec![],
1298///     totals: Default::default(),
1299///     findings: vec![],
1300/// };
1301/// assert!(report.satisfied(), "nothing claimed, so nothing unsupported");
1302///
1303/// // and the same report over a subject it could not wholly read is not a pass
1304/// let mut partial = report.clone();
1305/// partial.subject.complete = false;
1306/// assert!(!partial.satisfied(), "an absent answer is not a clean one");
1307///
1308/// report.findings.push(Finding {
1309///     claim: "scope-integrity".into(),
1310///     status: "guaranteed".into(),
1311///     state: ProofState::Failing,
1312///     reason: "the claim guarantees a behaviour whose test most recently failed".into(),
1313///     reproduce: None,
1314/// });
1315/// // one unsupported guarantee is enough: the gate is not a proportion
1316/// assert!(!report.satisfied());
1317/// ```
1318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1319/// # Example
1320///
1321/// The whole matrix joined to the ledger. `satisfied` is true when no claim declares a
1322/// guarantee the recorded evidence does not support.
1323///
1324/// ```
1325/// use majordomus_cli::evidence::{report, EvidenceReport, Ledger};
1326/// use majordomus_cli::synthetic::SyntheticRepository;
1327/// let repo = SyntheticRepository::small().unwrap();
1328/// let ledger = Ledger::load(repo.root()).unwrap();
1329/// let r: EvidenceReport = report(&repo.index().unwrap(), &ledger);
1330/// assert!(r.satisfied(), "no claim, so no unsupported guarantee");
1331/// ```
1332pub struct EvidenceReport {
1333    /// The commit the report was derived against.
1334    #[serde(skip_serializing_if = "Option::is_none")]
1335    pub head: Option<String>,
1336    /// `clean`, `dirty` or `unknown`.
1337    pub working_tree: String,
1338    /// What the verdicts are about: the working tree, or the checked-out commit judged as
1339    /// committed.
1340    pub presented: PresentedRevision,
1341    /// The ledger this was joined against.
1342    pub ledger: LedgerSummary,
1343    /// What the report could reach. Read this before reading the tallies: they are counts
1344    /// over the claims the index held, and an index that dropped a file holds fewer.
1345    pub subject: Subject,
1346    /// Every claim, in the matrix's own order.
1347    pub claims: Vec<ClaimProof>,
1348    /// How many claims are in each state.
1349    pub totals: BTreeMap<String, usize>,
1350    /// Claims whose declared status the evidence does not support.
1351    pub findings: Vec<Finding>,
1352}
1353
1354impl EvidenceReport {
1355    /// Does the evidence support every claim that declares a guarantee?
1356    ///
1357    /// False when the index was incomplete, whatever the findings say. An empty finding
1358    /// list over a matrix that lost entries is not a pass — it is the absence of an
1359    /// answer, and the two must never be spelled the same way.
1360    ///
1361    /// ```
1362    /// # use majordomus_cli::evidence::{EvidenceReport, LedgerSummary, PresentedRevision};
1363    /// # use majordomus_cli::evidence::{Subject, TreeState};
1364    /// # use std::collections::BTreeMap;
1365    /// let mut r = EvidenceReport {
1366    ///     head: None,
1367    ///     working_tree: "clean".into(),
1368    ///     presented: PresentedRevision {
1369    ///         revision: "working_tree".into(),
1370    ///         tree: TreeState::Clean,
1371    ///         uncommitted: vec![],
1372    ///     },
1373    ///     ledger: LedgerSummary {
1374    ///         path: ".ai/repo/evidence/ledger.json".into(),
1375    ///         present: false,
1376    ///         executions: 0,
1377    ///         newest: None,
1378    ///         commits: vec![],
1379    ///     },
1380    ///     subject: Subject { examined: 0, complete: true, excluded: vec![] },
1381    ///     claims: vec![],
1382    ///     totals: BTreeMap::new(),
1383    ///     findings: vec![],
1384    /// };
1385    /// assert!(r.satisfied(), "no findings over a whole subject is a pass");
1386    ///
1387    /// r.subject.complete = false;
1388    /// assert!(!r.satisfied(), "no findings over a partial subject is not a pass");
1389    /// ```
1390    pub fn satisfied(&self) -> bool {
1391        self.subject.complete && self.findings.is_empty()
1392    }
1393}
1394
1395// ---------------------------------------------------------------- the derivation
1396
1397/// A claim as the index holds it: the fields of one `docs/CLAIMS.yaml` entry.
1398struct IndexedClaim {
1399    id: String,
1400    claim: String,
1401    status: String,
1402    source: Option<String>,
1403    implementation: Option<String>,
1404    test: Option<String>,
1405}
1406
1407/// Read one metadata field, treating the matrix's `'-'` placeholder as absence — which is
1408/// what it means, and what every reader of this file has to agree on.
1409fn field(meta: &Value, key: &str) -> Option<String> {
1410    let v = meta.get(key)?.as_str()?.trim().trim_matches('\'');
1411    if v.is_empty() || v == "-" {
1412        None
1413    } else {
1414        Some(v.to_string())
1415    }
1416}
1417
1418/// Every claim the index holds, in the order the index holds them, which is the matrix's.
1419fn claims_of(index: &Index) -> Vec<IndexedClaim> {
1420    index
1421        .objects
1422        .iter()
1423        .filter(|o| o.kind == "claim")
1424        .map(|o| IndexedClaim {
1425            id: o.identity.clone(),
1426            claim: field(&o.metadata, "claim")
1427                .or_else(|| o.title.clone())
1428                .unwrap_or_else(|| o.identity.clone()),
1429            status: field(&o.metadata, "status").unwrap_or_else(|| "unknown".into()),
1430            source: field(&o.metadata, "source"),
1431            implementation: field(&o.metadata, "implementation"),
1432            test: field(&o.metadata, "test"),
1433        })
1434        .collect()
1435}
1436
1437/// Every path that differs between `commit` and the working tree — committed since, staged,
1438/// or merely edited. One subprocess per distinct commit in the ledger, which in practice is
1439/// one.
1440///
1441/// `None` when git could not answer, which the caller must not read as "nothing changed":
1442/// an unanswerable comparison is why [`ProofState`] has to be able to say it does not know.
1443///
1444/// Shared with the preflight (`environment::preflight`), whose verdict about recorded test
1445/// runs must mean what `proven` means here and nothing looser.
1446pub(crate) fn changed_since(root: &Path, commit: &str) -> Option<BTreeSet<String>> {
1447    let git = |args: &[&str]| -> Option<Vec<String>> {
1448        let out = crate::git::read_only(root).args(args).output().ok()?;
1449        if !out.status.success() {
1450            return None;
1451        }
1452        Some(
1453            String::from_utf8_lossy(&out.stdout)
1454                .lines()
1455                .map(|l| l.trim().to_string())
1456                .filter(|l| !l.is_empty())
1457                .collect(),
1458        )
1459    };
1460    // Tracked differences, and then the files that are there but git is not tracking.
1461    // `git diff` never lists an untracked file, so a tree whose only difference from the
1462    // recorded commit is a new source file would compare equal — and `proven` would say
1463    // "the tree in front of you is the tree the run measured" about a tree carrying code
1464    // the run never saw. Ignored files stay ignored: `--exclude-standard` is what makes
1465    // this the set a person would call "new here" rather than every build artifact.
1466    //
1467    // `--no-renames`: with rename detection a moved file is listed by its new path only, so
1468    // the path the run measured, now gone, never counted as changed; and pairing thousands
1469    // of differing paths into renames was half of what each comparison cost.
1470    let mut changed: BTreeSet<String> =
1471        git(&["diff", "--no-renames", "--name-only", commit, "--"])?
1472            .into_iter()
1473            .collect();
1474    changed.extend(git(&["ls-files", "--others", "--exclude-standard"])?);
1475    Some(changed)
1476}
1477
1478/// Join the claims of the index with the executions of the ledger and decide, per claim,
1479/// what the repository can honestly say.
1480///
1481/// The git comparison is done once per distinct recorded commit and shared by every claim
1482/// that recorded against it.
1483///
1484/// Claims come from the index and nothing else; executions come from the ledger and nothing
1485/// else. A claim the ledger has never heard of is [`ProofState::NotRun`], and if it declares
1486/// a guarantee, that is a finding — the file existing was never the proof.
1487///
1488/// ```
1489/// use majordomus_cli::evidence::{self, Ledger, ProofState};
1490/// use majordomus_cli::git::GitState;
1491/// use majordomus_cli::index::{Index, RepositoryInfo, State};
1492/// use majordomus_cli::{Object, Provenance};
1493///
1494/// let repo = tempfile::tempdir().unwrap();
1495/// let claim = Object {
1496///     kind: "claim".into(),
1497///     identity: "scope-integrity".into(),
1498///     uri: "majordomus://claim/scope-integrity".into(),
1499///     title: None,
1500///     description: None,
1501///     metadata: serde_json::json!({
1502///         "claim": "a commit outside the task's scope is refused",
1503///         "status": "guaranteed",
1504///         "source": "docs/SCOPE.md",
1505///         "test": "test/cases/07_scope.sh",
1506///     }),
1507///     body: String::new(),
1508///     content: String::new(),
1509///     media_type: "application/yaml",
1510///     provenance: Provenance {
1511///         path: "docs/CLAIMS.yaml".into(),
1512///         directory: "docs".into(),
1513///         source_class: "claim".into(),
1514///         section: None,
1515///         bytes: 0,
1516///         member: Some("claims.0".into()),
1517///     },
1518/// };
1519/// let index = Index {
1520///     repository: RepositoryInfo {
1521///         root: repo.path().display().to_string(),
1522///         layer_schema: "ai-repository/v1".into(),
1523///         sections: Default::default(),
1524///         git: GitState::Unavailable { reason: "doc".into() },
1525///         discovery: "filesystem".into(),
1526///         source_classes: vec![],
1527///         kind_sources: vec![],
1528///         scope_origin: majordomus_cli::scope::Origin::Distribution,
1529///         scope_path: String::new(),
1530///         observed: Default::default(),
1531///     },
1532///     objects: vec![claim],
1533///     diagnostics: vec![],
1534///     state: State::Ok,
1535///     fingerprint: String::new(),
1536///     scoped: Default::default(),
1537///     distribution: None,
1538///     providers: Default::default(),
1539///     share: None,
1540/// };
1541///
1542/// let report = evidence::report(&index, &Ledger::empty());
1543/// let proof = &report.claims[0];
1544/// assert_eq!(proof.test.as_deref(), Some("suite:07_scope"), "the id came from the path");
1545/// assert_eq!(proof.state, ProofState::NotRun);
1546/// assert_eq!(report.totals.get("not run"), Some(&1));
1547///
1548/// // a guarantee with nothing recorded behind it is a finding, and the reader is told
1549/// // exactly what would settle it
1550/// assert!(!report.satisfied());
1551/// assert_eq!(report.findings[0].claim, "scope-integrity");
1552/// assert_eq!(
1553///     report.findings[0].reproduce.as_deref(),
1554///     Some("bash test/run.sh 07_scope")
1555/// );
1556/// ```
1557pub fn report(index: &Index, ledger: &Ledger) -> EvidenceReport {
1558    report_at(index, ledger, &Presented::WorkingTree, &[])
1559}
1560
1561/// The same join, judged at a named revision: the working tree, or the checked-out commit
1562/// as committed.
1563///
1564/// The caller supplies what is read for that revision, so this stays one derivation for both:
1565/// for [`Presented::WorkingTree`], [`Ledger::load`] and no uncommitted executions — the
1566/// working ledger is itself the authority there; for a presented commit, [`ledger_at`] that
1567/// commit and [`uncommitted`] against it. Each claim's state, changed paths and detail come
1568/// from [`freshness()`] alone. For a presented commit, a clean failing run of the same test
1569/// that the checkout holds uncommitted, recorded between the evidence and the presented
1570/// commit, then caps the verdict at `stale` through [`weakened_by`]; it never decides one.
1571///
1572/// ```
1573/// use majordomus_cli::evidence::{report_at, Ledger, Presented};
1574/// use majordomus_cli::synthetic::SyntheticRepository;
1575///
1576/// let repo = SyntheticRepository::small().unwrap();
1577/// let ledger = Ledger::load(repo.root()).unwrap();
1578/// let r = report_at(&repo.index().unwrap(), &ledger, &Presented::WorkingTree, &[]);
1579/// assert_eq!(r.presented.revision, "working_tree");
1580/// assert!(r.presented.uncommitted.is_empty(), "the working ledger is the authority here");
1581/// ```
1582pub fn report_at(
1583    index: &Index,
1584    ledger: &Ledger,
1585    presented: &Presented,
1586    uncommitted: &[Execution],
1587) -> EvidenceReport {
1588    let root = PathBuf::from(&index.repository.root);
1589    let git = crate::git::inspect(&root);
1590    // Reported so a reader knows what the report was derived against; the proof states
1591    // are decided by each execution's own commit against the presented revision.
1592    let (head, working_tree) = match &git {
1593        crate::git::GitState::Available(i) => (i.head.clone(), i.working_tree.clone()),
1594        crate::git::GitState::Unavailable { .. } => (None, "unknown".to_string()),
1595    };
1596
1597    // one comparison per commit the ledger names, not one per claim
1598    let mut comparisons: BTreeMap<String, Comparison> = BTreeMap::new();
1599    for e in &ledger.executions {
1600        comparisons
1601            .entry(e.commit.clone())
1602            .or_insert_with(|| compare(&root, &e.commit, presented));
1603    }
1604    // and one containment per pair of commits the monotone rule asks about
1605    let mut containments: BTreeMap<(String, String), crate::git::Containment> = BTreeMap::new();
1606    let mut contains = |descendant: &str, ancestor: &str| {
1607        *containments
1608            .entry((descendant.to_string(), ancestor.to_string()))
1609            .or_insert_with(|| crate::git::contains(&root, descendant, ancestor))
1610    };
1611
1612    let mut claims = Vec::new();
1613    let mut totals: BTreeMap<String, usize> = BTreeMap::new();
1614    let mut findings = Vec::new();
1615
1616    for c in claims_of(index) {
1617        let test_id = c.test.as_deref().and_then(TestId::of);
1618        let execution = test_id
1619            .as_ref()
1620            .and_then(|t| ledger.latest(&t.as_string()))
1621            .cloned();
1622
1623        let recorded = match (&c.test, &test_id, &execution) {
1624            (None, _, _) => Recorded::NoTest,
1625            (Some(_), None, _) => Recorded::Unrunnable,
1626            (Some(_), Some(_), None) => Recorded::NotRun,
1627            (Some(_), Some(_), Some(e)) => Recorded::Ran(e),
1628        };
1629        let test_source = test_id.as_ref().map(TestId::source).unwrap_or_default();
1630        // what this claim names, and nothing else: the derivation is explicit about its own
1631        // reach, and a surface can repeat it
1632        let inputs: Vec<String> = [
1633            c.source.clone(),
1634            c.implementation.clone(),
1635            test_id.as_ref().map(TestId::source),
1636        ]
1637        .into_iter()
1638        .flatten()
1639        .collect();
1640        // A test whose source no longer hashes to what ran did not run in the form it is in
1641        // now, whatever the diff says — an edit made and reverted around the run leaves no
1642        // diff and is still not the thing that was measured. A presented commit is HEAD, so
1643        // the checkout's sources are the commit's whenever its tree is clean.
1644        let test_moved = execution
1645            .as_ref()
1646            .is_some_and(|e| e.outcome.proves() && e.digest_matches(&root) == Some(false));
1647        let mut judgement = freshness(
1648            recorded,
1649            Some(&inputs),
1650            &test_source,
1651            test_moved,
1652            execution.as_ref().and_then(|e| comparisons.get(&e.commit)),
1653            presented.tree(),
1654        );
1655
1656        // The monotone rule, over the one supplementary source a presented commit has: the
1657        // run of the same test the checkout holds and the commit's ledger does not.
1658        if let (Some(p), Some(e), Some(t)) = (presented.commit(), &execution, &test_id) {
1659            let key = t.as_string();
1660            if judgement.state < ProofState::Stale {
1661                if let Some(u) = uncommitted.iter().find(|u| u.test == key) {
1662                    let record = Supplementary {
1663                        execution: u,
1664                        named: UNCOMMITTED_RUN,
1665                        after_evidence: contains(&u.commit, &e.commit),
1666                        in_presented: contains(p, &u.commit),
1667                    };
1668                    judgement = weakened_by(judgement, &[record]);
1669                }
1670            }
1671        }
1672        let Judgement {
1673            state,
1674            changed,
1675            detail,
1676        } = judgement;
1677
1678        *totals.entry(state.label().to_string()).or_insert(0) += 1;
1679
1680        if let Some(reason) = unsupported(&c.status, state, detail.as_deref()) {
1681            findings.push(Finding {
1682                claim: c.id.clone(),
1683                status: c.status.clone(),
1684                state,
1685                reason,
1686                reproduce: test_id.as_ref().map(TestId::reproduce),
1687            });
1688        }
1689
1690        claims.push(ClaimProof {
1691            id: c.id,
1692            claim: c.claim,
1693            status: c.status,
1694            source: c.source,
1695            implementation: c.implementation,
1696            test_path: c.test,
1697            test: test_id.as_ref().map(TestId::as_string),
1698            state,
1699            meaning: state.meaning().to_string(),
1700            execution,
1701            changed,
1702            detail,
1703            reproduce: test_id.as_ref().map(TestId::reproduce),
1704        });
1705    }
1706
1707    // What the index could not read is what this report cannot be a verdict over. An
1708    // excluded file takes its objects out of the index, and every count below is then a
1709    // count over what survived.
1710    let excluded: Vec<String> = index
1711        .diagnostics
1712        .iter()
1713        .filter(|d| d.severity == crate::Severity::Error)
1714        .map(|d| match &d.path {
1715            Some(p) => format!("{p}: {}", d.code),
1716            None => d.code.clone(),
1717        })
1718        .collect();
1719    let subject = Subject {
1720        examined: claims.len(),
1721        complete: excluded.is_empty(),
1722        excluded,
1723    };
1724
1725    let presented = match presented.commit() {
1726        None => PresentedRevision {
1727            revision: "working_tree".to_string(),
1728            tree: TreeState::parse(&working_tree),
1729            uncommitted: Vec::new(),
1730        },
1731        Some(commit) => PresentedRevision {
1732            revision: commit.to_string(),
1733            tree: presented.tree(),
1734            uncommitted: uncommitted
1735                .iter()
1736                .map(|e| e.test.clone())
1737                .collect::<BTreeSet<_>>()
1738                .into_iter()
1739                .collect(),
1740        },
1741    };
1742
1743    EvidenceReport {
1744        head,
1745        working_tree,
1746        presented,
1747        ledger: ledger.summary(),
1748        subject,
1749        claims,
1750        totals,
1751        findings,
1752    }
1753}
1754
1755/// Does a claim's declared status survive the evidence found for it? `Some(reason)` when it
1756/// does not.
1757///
1758/// Only `guaranteed` is judged. `advisory` states that enforcement is not observable from
1759/// outside, `planned` states that nothing implements it and `rejected` that nothing will;
1760/// demanding a current proof of those would be demanding proof of a thing the claim already
1761/// says is not there.
1762///
1763/// Three states pass and three do not, and [`ProofState::Stale`] is the line between them.
1764/// A stale run is a pass — [`ProofState::passing`] says so, and the summary keeps it apart
1765/// from a failure for good reason — but it is a pass of a subject that has since moved: the
1766/// claim's own implementation or its test changed after the run offered as its proof.
1767/// Accepting it here made the gate accept, as support for a guarantee, a measurement of
1768/// something else. What is left is [`ProofState::InputsUnchanged`], weaker than `proven`
1769/// but at least a pass of *this* subject, and that is the floor.
1770///
1771/// The reason reads the judgement's detail where the state alone would mislead: a test that
1772/// declined to run is `not_run` and was not "never recorded", and a pass the presented
1773/// revision does not contain is `stale` without anything the claim names having changed.
1774fn unsupported(status: &str, state: ProofState, detail: Option<&str>) -> Option<String> {
1775    if status != "guaranteed" {
1776        return None;
1777    }
1778    match (state, detail) {
1779        (ProofState::Proven | ProofState::InputsUnchanged, _) => None,
1780        (ProofState::Stale, None) => Some(
1781            "the claim guarantees a behaviour whose only recorded run is older than what it is \
1782             about: the implementation or the test changed after that run, so the pass measured \
1783             a subject this claim no longer names"
1784                .into(),
1785        ),
1786        (ProofState::Stale, Some(d)) => Some(format!(
1787            "the claim guarantees a behaviour whose recorded pass proves nothing at the presented \
1788             revision: {d}"
1789        )),
1790        (ProofState::Failing, None | Some("failed")) => {
1791            Some("the claim guarantees a behaviour whose test most recently failed".into())
1792        }
1793        (ProofState::Failing, Some(d)) => Some(format!(
1794            "the claim guarantees a behaviour whose latest run did not pass: {d}"
1795        )),
1796        (ProofState::NotRun, None) => Some(
1797            "the claim guarantees a behaviour and names a test, and no run of that test has ever \
1798             been recorded"
1799                .into(),
1800        ),
1801        (ProofState::NotRun, Some(_)) => {
1802            Some("the claim guarantees a behaviour whose test declined to run".into())
1803        }
1804        (ProofState::Unrunnable, _) => Some(
1805            "the claim guarantees a behaviour and names a path no runner drives, so no execution \
1806             of it can be recorded"
1807                .into(),
1808        ),
1809        (ProofState::NoTest, _) => {
1810            Some("the claim guarantees a behaviour and names no test at all".into())
1811        }
1812    }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    use super::*;
1818
1819    #[test]
1820    fn a_case_path_and_a_crate_path_each_name_their_runner() {
1821        let s = TestId::of("test/cases/07_scope.sh").unwrap();
1822        assert_eq!(s.runner, Runner::Suite);
1823        assert_eq!(s.name, "07_scope");
1824        assert_eq!(s.source(), "test/cases/07_scope.sh");
1825        assert_eq!(s.reproduce(), "bash test/run.sh 07_scope");
1826
1827        let c = TestId::of("apps/majordomus-cli/tests/product.rs").unwrap();
1828        assert_eq!(c.runner, Runner::Crate);
1829        assert_eq!(c.as_string(), "crate:product");
1830        assert_eq!(c.reproduce(), "cargo test --test product");
1831    }
1832
1833    /// The matrix's own placeholder for "nothing", and a path that is real but that no
1834    /// runner drives, must both fail to name a test — the first is a claim with no proof,
1835    /// the second a claim whose proof can never be recorded, and conflating either with a
1836    /// test id would invent evidence.
1837    #[test]
1838    fn a_path_no_runner_drives_names_no_test() {
1839        assert!(TestId::of("-").is_none());
1840        assert!(TestId::of("share/install/install.sh.in").is_none());
1841        assert!(TestId::of("test/cases/").is_none());
1842        assert!(TestId::of("test/lib.sh").is_none());
1843        assert!(TestId::of("apps/majordomus-cli/src/why.rs").is_none());
1844        // a nested path under the cases directory is not a case the runner names
1845        assert!(TestId::of("test/cases/sub/x.sh").is_none());
1846    }
1847
1848    /// Anything the runner did not spell `ok` is not a pass. A result word nobody
1849    /// recognises is an error, never silently the good outcome.
1850    #[test]
1851    fn only_a_recognised_pass_proves_anything() {
1852        assert!(Outcome::parse("ok").proves());
1853        assert!(Outcome::parse("PASS").proves());
1854        assert!(!Outcome::parse("FAIL").proves());
1855        assert!(!Outcome::parse("TIMEOUT").proves());
1856        assert_eq!(Outcome::parse("TIMEOUT"), Outcome::Timeout);
1857        assert_eq!(Outcome::parse("wat"), Outcome::Error);
1858        assert!(!Outcome::parse("").proves());
1859    }
1860
1861    /// A verdict over a subject the reader could not wholly see is not a verdict. This is
1862    /// the property the gate and `--check` both depend on: an empty finding list over an
1863    /// index that dropped a file must not spell the same as a pass.
1864    #[test]
1865    fn an_incomplete_subject_is_never_satisfied() {
1866        let base = EvidenceReport {
1867            head: None,
1868            working_tree: "clean".into(),
1869            presented: PresentedRevision {
1870                revision: "working_tree".into(),
1871                tree: TreeState::Clean,
1872                uncommitted: vec![],
1873            },
1874            ledger: LedgerSummary {
1875                path: LEDGER_PATH.into(),
1876                present: false,
1877                executions: 0,
1878                newest: None,
1879                commits: vec![],
1880            },
1881            subject: Subject {
1882                examined: 3,
1883                complete: true,
1884                excluded: vec![],
1885            },
1886            claims: vec![],
1887            totals: BTreeMap::new(),
1888            findings: vec![],
1889        };
1890        assert!(
1891            base.satisfied(),
1892            "no findings over a whole subject is a pass"
1893        );
1894
1895        let mut partial = base.clone();
1896        partial.subject.complete = false;
1897        partial.subject.excluded = vec!["docs/CLAIMS.yaml: unknown_key".into()];
1898        assert!(
1899            !partial.satisfied(),
1900            "an empty finding list over a shrunken index read as a pass"
1901        );
1902
1903        // and a whole subject with a finding is still not satisfied, for the ordinary reason
1904        let mut failing = base;
1905        failing.findings.push(Finding {
1906            claim: "x".into(),
1907            status: "guaranteed".into(),
1908            state: ProofState::NotRun,
1909            reason: "never run".into(),
1910            reproduce: None,
1911        });
1912        assert!(!failing.satisfied());
1913    }
1914
1915    /// Only a guarantee is judged against the evidence; the other three statuses already
1916    /// say that a current proof is not what they are claiming.
1917    #[test]
1918    fn only_a_guarantee_is_held_to_its_evidence() {
1919        assert!(unsupported("guaranteed", ProofState::NotRun, None).is_some());
1920        assert!(unsupported("guaranteed", ProofState::Failing, None).is_some());
1921        assert!(unsupported("guaranteed", ProofState::NoTest, None).is_some());
1922        assert!(unsupported("guaranteed", ProofState::Unrunnable, None).is_some());
1923        assert!(unsupported("guaranteed", ProofState::Stale, None).is_some());
1924        assert!(unsupported("guaranteed", ProofState::Proven, None).is_none());
1925        assert!(unsupported("guaranteed", ProofState::InputsUnchanged, None).is_none());
1926        for status in ["advisory", "planned", "rejected"] {
1927            for state in [
1928                ProofState::NotRun,
1929                ProofState::NoTest,
1930                ProofState::Failing,
1931                ProofState::Stale,
1932            ] {
1933                assert!(
1934                    unsupported(status, state, None).is_none(),
1935                    "{status} must not be held to a current proof"
1936                );
1937            }
1938        }
1939    }
1940
1941    /// A stale run is a pass, and it is not support for a guarantee. The pass is real —
1942    /// `passing()` keeps saying so, and the summary must keep it apart from a failure — but
1943    /// its subject moved after it: a guarantee whose implementation or test changed since
1944    /// the only run recorded for it is a guarantee nothing current has measured, and
1945    /// reporting that as supported is the gate accepting a measurement of something else.
1946    #[test]
1947    fn a_guarantee_whose_proof_is_older_than_its_subject_is_not_supported() {
1948        let reason = unsupported("guaranteed", ProofState::Stale, None)
1949            .expect("a stale proof was accepted as support for a guarantee");
1950        assert!(
1951            reason.contains("older than what it is about"),
1952            "the reason must say the proof is older than its subject, not merely that it is \
1953             stale: {reason}"
1954        );
1955        // the pass is still a pass, and the two passing-but-weaker states are not the same
1956        assert!(ProofState::Stale.passing());
1957        assert!(unsupported("guaranteed", ProofState::InputsUnchanged, None).is_none());
1958    }
1959
1960    /// `proven` is a passing run recorded against this exact commit *with a clean tree*
1961    /// (ADR 0041). The tree the run measured is recorded per execution, so a run made on a
1962    /// dirty tree is capped at `inputs_unchanged` however empty the diff since its commit
1963    /// is — and no weaker state is moved by the cap in either direction.
1964    #[test]
1965    fn a_run_recorded_on_a_dirty_tree_is_never_proven() {
1966        // positive: a run whose tree was the commit keeps the strongest state
1967        assert_eq!(
1968            capped_by_working_tree(ProofState::Proven, "clean"),
1969            ProofState::Proven
1970        );
1971        // negative: the commit did not describe what ran, so it is not proof of the commit
1972        for tree in ["dirty", "unknown", ""] {
1973            assert_eq!(
1974                capped_by_working_tree(ProofState::Proven, tree),
1975                ProofState::InputsUnchanged,
1976                "a run recorded on a {tree} tree read as proven"
1977            );
1978        }
1979        // and nothing else moves: the cap only ever takes the one claim it is about
1980        for state in [
1981            ProofState::InputsUnchanged,
1982            ProofState::Stale,
1983            ProofState::Failing,
1984            ProofState::NotRun,
1985            ProofState::Unrunnable,
1986            ProofState::NoTest,
1987        ] {
1988            for tree in ["clean", "dirty", "unknown"] {
1989                assert_eq!(capped_by_working_tree(state, tree), state);
1990            }
1991        }
1992    }
1993
1994    /// The ranking a summary sorts by, and the guarantee that the two passing-but-not-current
1995    /// states are never collapsed into the strongest one.
1996    #[test]
1997    fn the_states_rank_from_strongest_to_weakest() {
1998        assert!(ProofState::Proven < ProofState::InputsUnchanged);
1999        assert!(ProofState::InputsUnchanged < ProofState::Stale);
2000        assert!(ProofState::Stale < ProofState::Failing);
2001        assert!(ProofState::Failing < ProofState::NotRun);
2002        assert!(ProofState::Proven.passing());
2003        assert!(ProofState::InputsUnchanged.passing());
2004        assert!(ProofState::Stale.passing());
2005        assert!(!ProofState::NotRun.passing());
2006        // every state says what it means, so no surface has to invent a gloss
2007        for s in [
2008            ProofState::Proven,
2009            ProofState::InputsUnchanged,
2010            ProofState::Stale,
2011            ProofState::Failing,
2012            ProofState::NotRun,
2013            ProofState::Unrunnable,
2014            ProofState::NoTest,
2015        ] {
2016            assert!(!s.meaning().is_empty(), "{} has no meaning", s.label());
2017            assert!(!s.label().is_empty());
2018        }
2019    }
2020
2021    #[test]
2022    fn a_digest_is_over_the_bytes_and_is_prefixed() {
2023        let d = digest_of(b"majordomus");
2024        assert!(d.starts_with("sha256:"), "{d}");
2025        assert_eq!(d.len(), "sha256:".len() + 64);
2026        assert_eq!(d, digest_of(b"majordomus"));
2027        assert_ne!(d, digest_of(b"majordomu"));
2028    }
2029
2030    /// The matrix writes absence as `-`, sometimes quoted. Every reader has to agree, or a
2031    /// claim with no implementation reads as a claim implemented by a file called `-`.
2032    #[test]
2033    fn the_matrixs_placeholder_for_nothing_reads_as_nothing() {
2034        let meta = serde_json::json!({
2035            "a": "-", "b": "'-'", "c": "", "d": "docs/CLI.md", "e": "  docs/X.md  "
2036        });
2037        assert_eq!(field(&meta, "a"), None);
2038        assert_eq!(field(&meta, "b"), None);
2039        assert_eq!(field(&meta, "c"), None);
2040        assert_eq!(field(&meta, "d"), Some("docs/CLI.md".into()));
2041        assert_eq!(field(&meta, "e"), Some("docs/X.md".into()));
2042        assert_eq!(field(&meta, "missing"), None);
2043    }
2044
2045    /// Every state's design token is its serialised name with `_` written as `-`, and a
2046    /// token is a word a stylesheet can carry.
2047    #[test]
2048    fn a_states_token_is_its_serialised_name_with_hyphens() {
2049        for s in [
2050            ProofState::Proven,
2051            ProofState::InputsUnchanged,
2052            ProofState::Stale,
2053            ProofState::Failing,
2054            ProofState::NotRun,
2055            ProofState::Unrunnable,
2056            ProofState::NoTest,
2057        ] {
2058            let name = serde_json::to_value(s).unwrap();
2059            let name = name.as_str().unwrap().replace('_', "-");
2060            assert_eq!(s.token(), name);
2061            let t = s.token();
2062            assert!(
2063                t.starts_with(|c: char| c.is_ascii_lowercase())
2064                    && t.chars()
2065                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
2066                "{t} is not a token"
2067            );
2068        }
2069    }
2070
2071    /// A finding says why in the judgement's words where the state alone would mislead.
2072    #[test]
2073    fn a_findings_reason_reads_the_judgements_detail() {
2074        let declined = unsupported(
2075            "guaranteed",
2076            ProofState::NotRun,
2077            Some("the test declined to run"),
2078        )
2079        .unwrap();
2080        assert_eq!(
2081            declined,
2082            "the claim guarantees a behaviour whose test declined to run"
2083        );
2084        let never = unsupported("guaranteed", ProofState::NotRun, None).unwrap();
2085        assert!(never.contains("no run of that test has ever been recorded"));
2086
2087        let failed = unsupported("guaranteed", ProofState::Failing, Some("failed")).unwrap();
2088        assert_eq!(
2089            failed,
2090            "the claim guarantees a behaviour whose test most recently failed"
2091        );
2092        let timed = unsupported("guaranteed", ProofState::Failing, Some("timed out")).unwrap();
2093        assert!(timed.ends_with("did not pass: timed out"), "{timed}");
2094
2095        let elsewhere = unsupported(
2096            "guaranteed",
2097            ProofState::Stale,
2098            Some("recorded on abc, which the presented revision does not contain"),
2099        )
2100        .unwrap();
2101        assert!(elsewhere.contains("does not contain"), "{elsewhere}");
2102        assert!(!elsewhere.contains("changed after that run"), "{elsewhere}");
2103        assert!(unsupported("advisory", ProofState::NotRun, Some("declined")).is_none());
2104    }
2105
2106    // ---------------------------------------------------------------- at a presented commit
2107
2108    /// A commit git cannot compare against is an unanswerable comparison, never "nothing
2109    /// changed"; one it can answers with the moved file at both of its paths.
2110    #[test]
2111    fn a_commit_git_cannot_compare_against_answers_nothing() {
2112        let f = Fixture::new(&["01_a"]);
2113        let head = f.git(&["rev-parse", "HEAD"]);
2114        assert_eq!(
2115            changed_since(f.root(), "0000000000000000000000000000000000000000"),
2116            None
2117        );
2118        f.git(&["mv", "test/cases/01_a.sh", "test/cases/02_b.sh"]);
2119        let changed = changed_since(f.root(), &head).expect("git answers");
2120        assert!(changed.contains("test/cases/01_a.sh"), "{changed:?}");
2121        assert!(changed.contains("test/cases/02_b.sh"), "{changed:?}");
2122    }
2123
2124    /// A repository with one guaranteed claim per case, and the index the report reads.
2125    struct Fixture {
2126        dir: tempfile::TempDir,
2127    }
2128
2129    impl Fixture {
2130        fn new(cases: &[&str]) -> Fixture {
2131            let f = Fixture {
2132                dir: tempfile::tempdir().unwrap(),
2133            };
2134            f.git(&["init", "-q"]);
2135            f.git(&["config", "user.email", "t@example.com"]);
2136            f.git(&["config", "user.name", "t"]);
2137            for c in cases {
2138                f.write(&format!("test/cases/{c}.sh"), &format!("# {c}\n"));
2139            }
2140            f.git(&["add", "-A"]);
2141            f.git(&["commit", "-q", "-m", "fixture"]);
2142            f
2143        }
2144        fn root(&self) -> &Path {
2145            self.dir.path()
2146        }
2147        fn git(&self, args: &[&str]) -> String {
2148            let out = std::process::Command::new("git")
2149                .arg("-C")
2150                .arg(self.root())
2151                .args(args)
2152                .output()
2153                .unwrap();
2154            String::from_utf8_lossy(&out.stdout).trim().to_string()
2155        }
2156        fn write(&self, rel: &str, text: &str) {
2157            let p = self.root().join(rel);
2158            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2159            std::fs::write(p, text).unwrap();
2160        }
2161        /// Record one run of a case at HEAD on a clean tree, into the working ledger.
2162        fn record(&self, case: &str, outcome: &str) {
2163            let head = self.git(&["rev-parse", "HEAD"]);
2164            let source = format!("test/cases/{case}.sh");
2165            let digest = digest_of(&std::fs::read(self.root().join(&source)).unwrap());
2166            let e: Execution = serde_json::from_value(serde_json::json!({
2167                "test": format!("suite:{case}"),
2168                "runner": "suite",
2169                "source": source,
2170                "outcome": outcome,
2171                "seconds": 1,
2172                "commit": head,
2173                "working_tree": "clean",
2174                "digest": digest,
2175                "at": "2026-09-26T00:00:00Z",
2176                "origin": "local",
2177                "command": format!("bash test/run.sh {case}")
2178            }))
2179            .unwrap();
2180            let mut l = Ledger::load(self.root()).unwrap();
2181            l.merge([e]);
2182            l.save(self.root()).unwrap();
2183        }
2184        fn commit_all(&self, message: &str) -> String {
2185            self.git(&["add", "-A"]);
2186            self.git(&["commit", "-q", "-m", message]);
2187            self.git(&["rev-parse", "HEAD"])
2188        }
2189        fn index(&self, cases: &[&str]) -> Index {
2190            let claims: Vec<(String, String)> = cases
2191                .iter()
2192                .map(|c| (format!("{c}-holds"), format!("test/cases/{c}.sh")))
2193                .collect();
2194            self.index_of(&claims)
2195        }
2196        /// The index of guaranteed claims, each naming the test path given, verbatim.
2197        fn index_of(&self, claims: &[(String, String)]) -> Index {
2198            use crate::index::{RepositoryInfo, State};
2199            use crate::{Object, Provenance};
2200            let objects = claims
2201                .iter()
2202                .enumerate()
2203                .map(|(i, (id, test))| Object {
2204                    kind: "claim".into(),
2205                    identity: id.clone(),
2206                    uri: format!("majordomus://claim/{id}"),
2207                    title: None,
2208                    description: None,
2209                    metadata: serde_json::json!({
2210                        "claim": id,
2211                        "status": "guaranteed",
2212                        "test": test,
2213                    }),
2214                    body: String::new(),
2215                    content: String::new(),
2216                    media_type: "application/yaml",
2217                    provenance: Provenance {
2218                        path: "docs/CLAIMS.yaml".into(),
2219                        directory: "docs".into(),
2220                        source_class: "claim".into(),
2221                        section: None,
2222                        bytes: 0,
2223                        member: Some(format!("claims.{i}")),
2224                    },
2225                })
2226                .collect();
2227            Index {
2228                repository: RepositoryInfo {
2229                    root: self.root().display().to_string(),
2230                    layer_schema: "ai-repository/v1".into(),
2231                    sections: Default::default(),
2232                    git: crate::git::GitState::Unavailable {
2233                        reason: "unit".into(),
2234                    },
2235                    discovery: "filesystem".into(),
2236                    source_classes: vec![],
2237                    kind_sources: vec![],
2238                    scope_origin: crate::scope::Origin::Distribution,
2239                    scope_path: String::new(),
2240                    observed: Default::default(),
2241                },
2242                objects,
2243                diagnostics: vec![],
2244                state: State::Ok,
2245                fingerprint: String::new(),
2246                scoped: Default::default(),
2247                distribution: None,
2248                providers: Default::default(),
2249                share: None,
2250            }
2251        }
2252        /// The report at the checked-out commit, as `evidence show --presented HEAD` reads it.
2253        fn at_head(&self, cases: &[&str], given: Option<TreeState>) -> EvidenceReport {
2254            let presented = presented_commit(self.root(), "HEAD", given).unwrap();
2255            let commit = presented.commit().unwrap().to_string();
2256            let ledger = ledger_at(self.root(), &commit).unwrap();
2257            let held = uncommitted(self.root(), &ledger).unwrap();
2258            report_at(&self.index(cases), &ledger, &presented, &held)
2259        }
2260        fn here(&self, cases: &[&str]) -> EvidenceReport {
2261            report(&self.index(cases), &Ledger::load(self.root()).unwrap())
2262        }
2263    }
2264
2265    fn state_of(r: &EvidenceReport, claim: &str) -> ProofState {
2266        r.claims.iter().find(|c| c.id == claim).unwrap().state
2267    }
2268
2269    /// Judged at the commit, from the ledger committed in it: a pass recorded on the commit
2270    /// before, with only the ledger committed since, is proven there.
2271    #[test]
2272    fn a_presented_commit_is_judged_by_the_ledger_it_holds() {
2273        let f = Fixture::new(&["01_alpha"]);
2274        f.record("01_alpha", "pass");
2275        let c2 = f.commit_all("the ledger");
2276        let r = f.at_head(&["01_alpha"], None);
2277        assert_eq!(state_of(&r, "01_alpha-holds"), ProofState::Proven);
2278        assert_eq!(r.presented.revision, c2);
2279        assert_eq!(r.presented.tree, TreeState::Clean);
2280        assert!(r.presented.uncommitted.is_empty());
2281        assert!(r.findings.is_empty());
2282
2283        // the working tree says the same, and says it is the working tree
2284        let here = f.here(&["01_alpha"]);
2285        assert_eq!(state_of(&here, "01_alpha-holds"), ProofState::Proven);
2286        assert_eq!(here.presented.revision, "working_tree");
2287
2288        // a tree the caller knows was not its commit is not proven
2289        let told = f.at_head(&["01_alpha"], Some(TreeState::Dirty));
2290        let claim = &told.claims[0];
2291        assert_eq!(claim.state, ProofState::InputsUnchanged);
2292        assert!(claim
2293            .detail
2294            .as_deref()
2295            .unwrap()
2296            .contains("presented revision"));
2297    }
2298
2299    /// The monotone rule's first source: a clean failure the checkout holds uncommitted
2300    /// withholds `proven` at the commit, and does not decide the verdict there.
2301    #[test]
2302    fn a_failure_the_checkout_holds_caps_the_commit_at_stale() {
2303        let f = Fixture::new(&["01_alpha"]);
2304        f.record("01_alpha", "pass");
2305        let c2 = f.commit_all("the ledger");
2306        f.record("01_alpha", "fail");
2307
2308        let here = f.here(&["01_alpha"]);
2309        assert_eq!(state_of(&here, "01_alpha-holds"), ProofState::Failing);
2310
2311        let r = f.at_head(&["01_alpha"], None);
2312        let claim = &r.claims[0];
2313        assert_eq!(claim.state, ProofState::Stale, "{claim:?}");
2314        let detail = claim.detail.as_deref().unwrap();
2315        assert!(detail.contains("uncommitted"), "{detail}");
2316        assert!(detail.contains(&c2[..12]), "{detail}");
2317        assert_eq!(r.presented.uncommitted, ["suite:01_alpha"]);
2318        assert_eq!(
2319            r.presented.tree,
2320            TreeState::Clean,
2321            "the ledger is not the tree"
2322        );
2323        assert_eq!(r.findings.len(), 1);
2324        assert!(r.findings[0].reason.contains("uncommitted"));
2325    }
2326
2327    /// A clean failure the checkout holds, recorded on a commit the presented one does not
2328    /// contain, is about another history: it says nothing at the presented commit.
2329    #[test]
2330    fn a_failure_on_another_history_says_nothing_about_the_commit() {
2331        let f = Fixture::new(&["01_alpha"]);
2332        let trunk = f.git(&["symbolic-ref", "--short", "HEAD"]);
2333        let e = f.git(&["rev-parse", "HEAD"]);
2334        f.record("01_alpha", "pass");
2335        f.commit_all("the ledger");
2336        f.git(&["checkout", "-q", "-b", "side"]);
2337        f.write("side.txt", "side");
2338        let side = f.commit_all("side");
2339        f.record("01_alpha", "fail");
2340        let held = std::fs::read_to_string(f.root().join(LEDGER_PATH)).unwrap();
2341        f.git(&["checkout", "-q", "--", LEDGER_PATH]);
2342        f.git(&["checkout", "-q", &trunk]);
2343        f.write(LEDGER_PATH, &held);
2344
2345        // the record is after the evidence, and only the presented commit's history lacks it
2346        let r = f.at_head(&["01_alpha"], None);
2347        let claim = &r.claims[0];
2348        assert_eq!(claim.execution.as_ref().unwrap().commit, e);
2349        assert_eq!(r.presented.uncommitted, ["suite:01_alpha"]);
2350        assert_eq!(
2351            crate::git::contains(f.root(), &side, &e),
2352            crate::git::Containment::Contains
2353        );
2354        assert_eq!(claim.state, ProofState::Proven, "{claim:?}");
2355        assert!(r.findings.is_empty());
2356    }
2357
2358    /// A clean failure the checkout holds, recorded on a commit that does not contain the
2359    /// evidence, came before the pass it would contradict: it says nothing about that pass.
2360    #[test]
2361    fn a_failure_from_before_the_evidence_says_nothing_about_it() {
2362        let f = Fixture::new(&["01_alpha"]);
2363        let before = f.git(&["rev-parse", "HEAD"]);
2364        f.record("01_alpha", "fail");
2365        let held = std::fs::read_to_string(f.root().join(LEDGER_PATH)).unwrap();
2366        std::fs::remove_file(f.root().join(LEDGER_PATH)).unwrap();
2367        f.write("later.txt", "later");
2368        let e = f.commit_all("later");
2369        f.record("01_alpha", "pass");
2370        f.commit_all("the ledger");
2371        f.write(LEDGER_PATH, &held);
2372
2373        // the presented commit contains the record, and the record does not contain E
2374        let r = f.at_head(&["01_alpha"], None);
2375        let claim = &r.claims[0];
2376        assert_eq!(claim.execution.as_ref().unwrap().commit, e);
2377        assert_eq!(r.presented.uncommitted, ["suite:01_alpha"]);
2378        assert_eq!(
2379            crate::git::contains(f.root(), &r.presented.revision, &before),
2380            crate::git::Containment::Contains
2381        );
2382        assert_eq!(claim.state, ProofState::Proven, "{claim:?}");
2383        assert!(r.findings.is_empty());
2384    }
2385
2386    /// The monotone rule reads the run of the same test and no other: a clean failure of
2387    /// beta the checkout holds uncommitted caps beta at the commit, and says nothing about
2388    /// alpha.
2389    #[test]
2390    fn another_tests_uncommitted_failure_says_nothing_about_this_one() {
2391        let f = Fixture::new(&["01_alpha", "02_beta"]);
2392        f.record("01_alpha", "pass");
2393        f.record("02_beta", "pass");
2394        let c2 = f.commit_all("the ledger");
2395        f.record("02_beta", "fail");
2396        let cases = ["01_alpha", "02_beta"];
2397
2398        let r = f.at_head(&cases, None);
2399        assert_eq!(r.presented.uncommitted, ["suite:02_beta"]);
2400        assert_eq!(state_of(&r, "01_alpha-holds"), ProofState::Proven);
2401        let beta = r.claims.iter().find(|c| c.id == "02_beta-holds").unwrap();
2402        assert_eq!(beta.state, ProofState::Stale, "{beta:?}");
2403        assert!(beta.detail.as_deref().unwrap().contains(&c2[..12]));
2404        let findings: Vec<&str> = r.findings.iter().map(|f| f.claim.as_str()).collect();
2405        assert_eq!(findings, ["02_beta-holds"]);
2406    }
2407
2408    /// And never the other way: a pass the checkout holds uncommitted, of a test the commit's
2409    /// ledger has no row for, leaves the commit's verdict `not_run`.
2410    #[test]
2411    fn an_uncommitted_pass_never_strengthens_the_commit() {
2412        let f = Fixture::new(&["01_alpha", "02_beta"]);
2413        f.record("01_alpha", "pass");
2414        f.commit_all("the ledger");
2415        f.record("02_beta", "pass");
2416        let cases = ["01_alpha", "02_beta"];
2417        assert_eq!(
2418            state_of(&f.here(&cases), "02_beta-holds"),
2419            ProofState::Proven
2420        );
2421        let r = f.at_head(&cases, None);
2422        assert_eq!(state_of(&r, "02_beta-holds"), ProofState::NotRun);
2423        assert_eq!(r.presented.uncommitted, ["suite:02_beta"]);
2424    }
2425
2426    /// A claim that names no test, and one that names a path no runner drives, are judged
2427    /// before anything is compared, and neither is ever proven.
2428    #[test]
2429    fn a_claim_with_no_test_or_an_unrunnable_one_is_judged_without_a_comparison() {
2430        let f = Fixture::new(&[]);
2431        let index = f.index_of(&[
2432            ("nothing".to_string(), "-".to_string()),
2433            ("elsewhere".to_string(), "lib/helper.sh".to_string()),
2434        ]);
2435        let r = report(&index, &Ledger::load(f.root()).unwrap());
2436        assert_eq!(state_of(&r, "nothing"), ProofState::NoTest);
2437        assert_eq!(state_of(&r, "elsewhere"), ProofState::Unrunnable);
2438        assert_eq!(
2439            r.findings.len(),
2440            2,
2441            "both are guarantees nothing can support"
2442        );
2443    }
2444
2445    /// A skip is `not_run`, its detail says it declined, and so does the guarantee's finding.
2446    #[test]
2447    fn a_skipped_guarantee_is_not_run_and_the_finding_says_it_declined() {
2448        let f = Fixture::new(&["01_alpha"]);
2449        f.record("01_alpha", "skip");
2450        let r = f.here(&["01_alpha"]);
2451        let claim = &r.claims[0];
2452        assert_eq!(claim.state, ProofState::NotRun);
2453        assert!(claim.detail.as_deref().unwrap().contains("declined"));
2454        assert_eq!(
2455            r.findings[0].reason,
2456            "the claim guarantees a behaviour whose test declined to run"
2457        );
2458    }
2459
2460    /// A pass recorded on a commit the checked-out branch does not contain is about some
2461    /// other history.
2462    #[test]
2463    fn a_pass_on_a_commit_head_does_not_contain_is_stale() {
2464        let f = Fixture::new(&["01_alpha"]);
2465        let trunk = f.git(&["symbolic-ref", "--short", "HEAD"]);
2466        f.git(&["checkout", "-q", "-b", "side"]);
2467        f.write("side.txt", "side");
2468        f.commit_all("side");
2469        f.record("01_alpha", "pass");
2470        let ledger = std::fs::read_to_string(f.root().join(LEDGER_PATH)).unwrap();
2471        std::fs::remove_file(f.root().join(LEDGER_PATH)).unwrap();
2472        f.git(&["checkout", "-q", &trunk]);
2473        f.write(LEDGER_PATH, &ledger);
2474        let r = f.here(&["01_alpha"]);
2475        let claim = &r.claims[0];
2476        assert_eq!(claim.state, ProofState::Stale);
2477        assert!(claim
2478            .detail
2479            .as_deref()
2480            .unwrap()
2481            .contains("does not contain"));
2482    }
2483
2484    /// A CI run carries the pull request's head and its event when the job states them, and
2485    /// drops a head that is not a full commit id rather than recording a guess.
2486    #[test]
2487    fn a_ci_run_carries_its_head_and_event_when_stated() {
2488        let head = "0123456789abcdef0123456789abcdef01234567";
2489        let env = |sha: &'static str| {
2490            move |k: &str| {
2491                let v = match k {
2492                    "GITHUB_ACTIONS" => "true",
2493                    "GITHUB_SERVER_URL" => "https://github.com",
2494                    "GITHUB_REPOSITORY" => "o/r",
2495                    "GITHUB_RUN_ID" => "7",
2496                    "GITHUB_WORKFLOW" => "validate",
2497                    "GITHUB_JOB" => "evidence",
2498                    "MJ_RUN_HEAD_SHA" => sha,
2499                    "GITHUB_EVENT_NAME" => "pull_request",
2500                    _ => return None,
2501                };
2502                Some(v.to_string())
2503            }
2504        };
2505        let run = RunRef::from_env(env(head)).unwrap();
2506        assert_eq!(run.head_sha.as_deref(), Some(head));
2507        assert_eq!(run.event.as_deref(), Some("pull_request"));
2508        assert_eq!(RunRef::from_env(env("HEAD")).unwrap().head_sha, None);
2509    }
2510}