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}