majordomus_cli/capability/builtin/evidence.rs
1//! The `evidence` module: from a claim to the run that proves it, and back.
2//!
3//! `docs/CLAIMS.yaml` binds a claim to a test by path. [`crate::evidence`] binds that path
4//! to the runs actually recorded against it. These capabilities are the one declaration of
5//! that join, and every surface — the command line, the HTTP API and its OpenAPI operation,
6//! the MCP tool a client reads — is derived from it. There is no second evidence model
7//! behind any of them.
8//!
9//! # Both directions
10//!
11//! A relation that can only be walked one way is half a relation. `evidence.claim` answers
12//! "what proves this claim"; `evidence.test` answers "what does this test prove", listing
13//! every claim that names it. They read the same derivation, so the two answers cannot
14//! disagree.
15//!
16//! # Why the recorder is here and not exposed over the network
17//!
18//! `evidence.record` writes a tracked file, and this executable's MCP and HTTP surfaces are
19//! read-only — that is a property of the server, not an accident of what has been built.
20//! So it declares a command line and nothing else, which
21//! [`Visibility::Developer`](crate::capability::Visibility) is exactly the word for: offered
22//! to whoever runs the executable and to nobody over a network. Declaring it as a capability
23//! rather than as a hand-written command is what keeps it inside the registry, the command
24//! graph and the generated reference instead of in the inventory of commands nothing derives.
25//!
26//! ```
27//! use majordomus_cli::capability::builtin::evidence;
28//!
29//! let m = evidence::module();
30//! assert_eq!(m.id.as_str(), "evidence");
31//! let ids: Vec<&str> = m
32//! .capabilities
33//! .iter()
34//! .map(|e| e.capability.id.as_str())
35//! .collect();
36//! assert_eq!(
37//! ids,
38//! [
39//! "evidence.report",
40//! "evidence.claim",
41//! "evidence.test",
42//! "evidence.record",
43//! "evidence.stamp"
44//! ]
45//! );
46//!
47//! // the recorder writes a tracked file, so it is offered to whoever runs the executable
48//! // and to nobody over a network; that is what keeps the server read-only
49//! let record = m
50//! .capabilities
51//! .iter()
52//! .find(|e| e.capability.id.as_str() == "evidence.record")
53//! .unwrap();
54//! assert!(record.capability.exposure.mcp.is_none());
55//! assert!(record.capability.exposure.http.is_none());
56//! assert_eq!(
57//! record.capability.exposure.cli.as_ref().unwrap().path,
58//! ["evidence".to_string(), "record".to_string()]
59//! );
60//! ```
61//!
62//! # Why the stamp is offered only to whoever runs the executable
63//!
64//! `evidence.stamp` writes nothing, but it reads a file whose path its caller names (the
65//! report it digests) and measures the checkout it runs in. A network client must not choose
66//! a path this executable reads, so the stamp too declares a command line and nothing else.
67
68//! # Example
69//!
70//! The capabilities, and every projection — the command line, the HTTP operation, the MCP
71//! tool — is derived from that one declaration.
72//!
73//! ```
74//! use majordomus_cli::capability::builtin::evidence::module;
75//! let m = module();
76//! assert_eq!(m.id.as_str(), "evidence");
77//! assert_eq!(m.capabilities.len(), 5);
78//! ```
79
80use schemars::JsonSchema;
81use serde::{Deserialize, Serialize};
82
83use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
84use crate::capability::handler::{CapabilityError, Context};
85use crate::capability::model::{
86 BenchmarkPolicy, CachePolicy, CapabilityKind, CliExposure, Exposure, McpResource, Stability,
87 WaiverReason,
88};
89use crate::capability::module::ModuleDescriptor;
90use crate::evidence::{
91 self, freshness, ClaimProof, EvidenceDropped, EvidenceProducer, EvidenceProvenance,
92 EvidenceReport, EvidenceRunRecord, Execution, Ledger, LedgerTarget, Origin, ProofState,
93 RecordRequest, StampRequest, TreeState,
94};
95use crate::{capability, module};
96
97use super::{get, mcp};
98
99/// The URI under which the whole evidence report is read as an MCP resource.
100pub const EVIDENCE_URI: &str = "majordomus://evidence";
101
102// ---------------------------------------------------------------- inputs
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
105#[serde(deny_unknown_fields)]
106/// Which part of the matrix to answer for, and at which revision.
107///
108/// Every filter narrows, and they compose; saying nothing asks for the whole matrix at the
109/// working tree. A key nothing recognises is refused rather than dropped, because a
110/// misspelled filter that read as "no filter" would answer a question nobody asked and look
111/// like a clean one.
112///
113/// ```
114/// use majordomus_cli::capability::builtin::evidence::EvidenceReportInput;
115/// use majordomus_cli::evidence::{ProofState, TreeState};
116///
117/// let all: EvidenceReportInput = serde_json::from_str("{}").unwrap();
118/// assert!(all.state.is_none() && all.status.is_none() && !all.findings_only);
119/// assert!(all.presented.is_none(), "the working tree unless a commit is named");
120///
121/// let narrowed: EvidenceReportInput =
122/// serde_json::from_str(r#"{"state": "stale", "findings_only": true}"#).unwrap();
123/// assert_eq!(narrowed.state, Some(ProofState::Stale));
124/// assert!(narrowed.findings_only);
125///
126/// let at: EvidenceReportInput =
127/// serde_json::from_str(r#"{"presented": "HEAD", "presented_tree": "dirty"}"#).unwrap();
128/// assert_eq!(at.presented.as_deref(), Some("HEAD"));
129/// assert_eq!(at.presented_tree, Some(TreeState::Dirty));
130///
131/// assert!(serde_json::from_str::<EvidenceReportInput>(r#"{"states": "stale"}"#).is_err());
132/// ```
133pub struct EvidenceReportInput {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 /// Only claims in this proof state (`proven`, `inputs_unchanged`, `stale`, `failing`,
136 /// `not_run`, `unrunnable`, `no_test`). Absent: every claim.
137 pub state: Option<ProofState>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 /// Only claims declaring this status (`guaranteed`, `advisory`, `planned`, `rejected`).
140 /// Absent: every claim.
141 pub status: Option<String>,
142 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143 /// Only the claims whose declared status the evidence does not support, with the
144 /// findings. The tallies still count the whole matrix, so a filtered answer never
145 /// misreports how much of it was examined.
146 pub findings_only: bool,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 /// Judge at this revision, as committed, rather than at the working tree: `HEAD`, or
149 /// any name of the checked-out commit. It must be the checked-out commit; the ledger is
150 /// read as that commit holds it, and a run the working ledger holds that the commit's
151 /// does not can only withhold `proven`. Absent: the working tree.
152 pub presented: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 /// What the caller knows about the presented commit's tree (`clean`, `dirty`,
155 /// `unknown`). It can only weaken the measured state, never assert a clean tree over a
156 /// dirty checkout, and it means nothing without `presented`.
157 pub presented_tree: Option<TreeState>,
158}
159
160impl BenchmarkCases for EvidenceReportInput {
161 /// The whole matrix and the findings, at the working tree. No case fills `presented`:
162 /// the benchmark repository is not a git repository, so every revision it could name is
163 /// refused, and such a case would time the refusal and be counted as coverage of the
164 /// judgement it never reached.
165 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
166 vec![
167 NamedCase::new("all", EvidenceReportInput::default()),
168 NamedCase::new(
169 "findings",
170 EvidenceReportInput {
171 findings_only: true,
172 ..Default::default()
173 },
174 ),
175 ]
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180#[serde(deny_unknown_fields)]
181/// One claim of the matrix, named by the id it is declared under.
182///
183/// The id is required, and there is no spelling of this input that means "every claim" —
184/// that question belongs to `evidence.report`. An id the matrix does not declare is a
185/// not-found from the handler rather than an empty proof.
186///
187/// ```
188/// use majordomus_cli::capability::builtin::evidence::EvidenceClaimInput;
189///
190/// let input: EvidenceClaimInput =
191/// serde_json::from_str(r#"{"claim": "evidence-both-directions"}"#).unwrap();
192/// assert_eq!(input.claim, "evidence-both-directions");
193///
194/// // no id is a usage error, never a request for all of them
195/// assert!(serde_json::from_str::<EvidenceClaimInput>("{}").is_err());
196/// ```
197pub struct EvidenceClaimInput {
198 /// The claim id, as `docs/CLAIMS.yaml` spells it.
199 pub claim: String,
200}
201
202impl BenchmarkCases for EvidenceClaimInput {
203 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
204 // a claim this repository actually has, so the case measures the join rather than
205 // the refusal; a hard-coded id would be a second declaration of the matrix
206 let claim = ctx
207 .index
208 .objects
209 .iter()
210 .find(|o| o.kind == "claim")
211 .map(|o| o.identity.clone())
212 .unwrap_or_else(|| "no-claim".to_string());
213 vec![NamedCase::new("first-claim", EvidenceClaimInput { claim })]
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219/// One test, by its stable identity or by the path a claim names it with.
220///
221/// Both spellings reach the same test. `docs/CLAIMS.yaml` names a path, the ledger keys on
222/// an identity, and a caller holding one of them should not have to know the other; the
223/// handler resolves a path to the identity and reads the same derivation either way.
224///
225/// ```
226/// use majordomus_cli::capability::builtin::evidence::EvidenceTestInput;
227/// use majordomus_cli::evidence::{Runner, TestId};
228///
229/// let by_path: EvidenceTestInput =
230/// serde_json::from_str(r#"{"test": "test/cases/84_distribution_model.sh"}"#).unwrap();
231/// let id = TestId::of(&by_path.test).unwrap();
232/// assert_eq!(id.as_string(), "suite:84_distribution_model");
233/// assert_eq!(id.runner, Runner::Suite);
234///
235/// // an identity is not a path: `TestId::of` reads paths, and the handler parses the
236/// // `<runner>:<name>` form itself
237/// let by_id: EvidenceTestInput = serde_json::from_str(r#"{"test": "crate:why"}"#).unwrap();
238/// assert!(TestId::of(&by_id.test).is_none());
239/// ```
240pub struct EvidenceTestInput {
241 /// `suite:84_distribution_model`, `crate:why`, or the path itself
242 /// (`test/cases/84_distribution_model.sh`), which is resolved to the same identity.
243 pub test: String,
244}
245
246impl BenchmarkCases for EvidenceTestInput {
247 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
248 let test = ctx
249 .index
250 .objects
251 .iter()
252 .filter(|o| o.kind == "claim")
253 .find_map(|o| {
254 o.metadata
255 .get("test")
256 .and_then(|v| v.as_str())
257 .and_then(evidence::TestId::of)
258 })
259 .map(|t| t.as_string())
260 .unwrap_or_else(|| "suite:00_yaml_flatten".to_string());
261 vec![NamedCase::new("first-test", EvidenceTestInput { test })]
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267/// A run to record into the ledger.
268///
269/// Paths to what the runners already wrote, not a run to perform: the recorder reads a
270/// report, carries into each result what that run measured about its own checkout, and
271/// merges it. The reports are optional and may be given together; an absent origin is
272/// `local`, and an absent ledger is the tracked one.
273///
274/// ```
275/// use majordomus_cli::capability::builtin::evidence::EvidenceRecordInput;
276///
277/// let input: EvidenceRecordInput =
278/// serde_json::from_str(r#"{"suite": "tmp/report.tsv"}"#).unwrap();
279/// assert_eq!(input.suite.as_deref(), Some("tmp/report.tsv"));
280/// assert!(input.crate_output.is_none() && input.origin.is_none());
281///
282/// // what was not given is not written back as null: the shape a client reads is the
283/// // shape it sent
284/// assert_eq!(
285/// serde_json::to_string(&input).unwrap(),
286/// r#"{"suite":"tmp/report.tsv"}"#
287/// );
288/// ```
289pub struct EvidenceRecordInput {
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 /// The runner's TSV report: `MJ_TEST_REPORT=<file> bash test/run.sh`.
292 pub suite: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 /// A file holding `cargo test`'s output, for the crate's own integration tests.
295 pub crate_output: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 /// Where the run happened: `local` (the default), `ci` or `release`.
298 pub origin: Option<String>,
299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 /// A measurement per report: the file `majordomus evidence stamp` wrote at the end of
301 /// that run, as `<file>` or `<producer>=<file>` (`suite`, `crate`, `coverage`). Without a
302 /// producer word, the producer is the file's own, else the only report given.
303 pub provenance: Vec<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 /// A coverage summary: what `scripts/rust-coverage --summary-json` wrote.
306 pub coverage: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 /// The ledger to merge into: `repo` (the default, the tracked ledger) or `local` (the
309 /// ignored one under `.ai/local/`, which moves no verdict).
310 pub ledger: Option<LedgerTarget>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 /// Also write the run record to this file.
313 pub run_record: Option<String>,
314}
315
316impl BenchmarkCases for EvidenceRecordInput {
317 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
318 // nothing: the capability is waived from the benchmark because it writes a tracked
319 // file, and timing it in a loop would rewrite the repository's evidence
320 Vec::new()
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326/// What a run asks to have measured about the checkout it leaves: its producer, its report,
327/// and its own untracked outputs, which are not a change to what it tested.
328///
329/// ```
330/// use majordomus_cli::capability::builtin::evidence::EvidenceStampInput;
331/// use majordomus_cli::evidence::EvidenceProducer;
332///
333/// let input: EvidenceStampInput =
334/// serde_json::from_str(r#"{"producer": "suite", "exclude": ["dist"]}"#).unwrap();
335/// assert_eq!(input.producer, Some(EvidenceProducer::Suite));
336/// assert_eq!(input.exclude, ["dist"]);
337/// assert_eq!(serde_json::to_string(&input).unwrap(), r#"{"producer":"suite","exclude":["dist"]}"#);
338/// ```
339pub struct EvidenceStampInput {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 /// The producer whose run this is: `suite`, `crate` or `coverage`.
342 pub producer: Option<EvidenceProducer>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 /// The report the run wrote; a local recording needs it named.
345 pub report: Option<String>,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 /// The run's own untracked outputs, as repository paths: a file, or a directory with
348 /// everything under it. A pattern, the whole checkout or a path holding tracked files is
349 /// refused.
350 pub exclude: Vec<String>,
351}
352
353impl BenchmarkCases for EvidenceStampInput {
354 /// Nothing: the benchmark repository (`src/synthetic.rs`) is not a git work tree, so
355 /// every case would time a refusal ("there is no commit to measure") rather than a
356 /// measurement. The capability is waived as depending on an external `git`.
357 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
358 Vec::new()
359 }
360}
361
362// ---------------------------------------------------------------- outputs
363
364#[derive(Debug, Clone, Serialize, JsonSchema)]
365/// One claim with everything the repository can say about its proof, and the way back out
366/// to the test that carries it.
367///
368/// The proof is flattened in, so a client reads one claim rather than an evidence wrapper
369/// around one; the executions and the other claims the same test proves sit beside it.
370///
371/// ```
372/// use majordomus_cli::capability::builtin::evidence::ClaimEvidence;
373/// use majordomus_cli::evidence::ClaimProof;
374///
375/// let proof: ClaimProof = serde_json::from_value(serde_json::json!({
376/// "id": "evidence-both-directions",
377/// "claim": "a claim and its test answer for each other",
378/// "status": "guaranteed",
379/// "source": null,
380/// "implementation": null,
381/// "test_path": "test/cases/84_distribution_model.sh",
382/// "test": "suite:84_distribution_model",
383/// "state": "not_run",
384/// "meaning": "no run of this test has been recorded",
385/// "execution": null,
386/// "changed": [],
387/// "reproduce": "bash test/run.sh 84_distribution_model"
388/// }))
389/// .unwrap();
390///
391/// let answer = ClaimEvidence {
392/// proof,
393/// executions: Vec::new(),
394/// also_proves: vec!["evidence-record-is-not-networked".to_string()],
395/// };
396/// let json = serde_json::to_value(&answer).unwrap();
397/// assert_eq!(json["id"], "evidence-both-directions");
398/// assert!(json.get("proof").is_none(), "the claim is flattened in, not nested");
399/// assert_eq!(json["also_proves"], serde_json::json!(["evidence-record-is-not-networked"]));
400/// // a list even where the ledger keeps one: retention is a decision that may change,
401/// // and a client that read a bare object would break when it did
402/// assert_eq!(json["executions"], serde_json::json!([]));
403/// ```
404pub struct ClaimEvidence {
405 /// The claim, joined to its evidence.
406 #[serde(flatten)]
407 pub proof: ClaimProof,
408 /// Every execution the ledger holds for this claim's test — today one, the latest, and
409 /// a list because the ledger's retention is a decision that may change and a client
410 /// that read a bare object would break when it did.
411 pub executions: Vec<Execution>,
412 /// The other claims the same test proves. A test shared by twelve claims is a test
413 /// whose failure is twelve findings, and a reader of one of them should be able to see
414 /// the other eleven without searching.
415 pub also_proves: Vec<String>,
416}
417
418#[derive(Debug, Clone, Serialize, JsonSchema)]
419/// One test, and what it proves.
420///
421/// The other direction of [`ClaimEvidence`], read from the test's end: its identity, its
422/// source and whether that source is still the one that ran, the command that runs it
423/// again, and every claim that names it.
424///
425/// ```
426/// use majordomus_cli::capability::builtin::evidence::TestEvidence;
427/// use majordomus_cli::evidence::TestId;
428///
429/// let id = TestId::of("apps/majordomus-cli/tests/why.rs").unwrap();
430/// let answer = TestEvidence {
431/// test: id.as_string(),
432/// runner: id.runner,
433/// source: id.source(),
434/// present: true,
435/// reproduce: id.reproduce(),
436/// execution: None,
437/// digest_matches: None,
438/// proves: Vec::new(),
439/// };
440/// assert_eq!(answer.test, "crate:why");
441/// assert_eq!(answer.reproduce, "cargo test --test why");
442///
443/// // nothing recorded: the two provenance keys are absent rather than null, so "no
444/// // execution" cannot be read as an execution that said nothing
445/// let json = serde_json::to_value(&answer).unwrap();
446/// assert!(json.get("execution").is_none() && json.get("digest_matches").is_none());
447/// assert_eq!(json["proves"], serde_json::json!([]));
448/// ```
449pub struct TestEvidence {
450 /// The stable identity: `suite:<case>` or `crate:<binary>`.
451 pub test: String,
452 /// Which runner owns it.
453 pub runner: evidence::Runner,
454 /// Its own source, repository-relative.
455 pub source: String,
456 /// Whether that source is in this checkout.
457 pub present: bool,
458 /// The command that runs this one test.
459 pub reproduce: String,
460 /// The latest recorded execution, when there is one.
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub execution: Option<Execution>,
463 /// Whether the test's source still hashes to what the execution recorded. `None` when
464 /// there is no execution, or the source is not there to hash.
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub digest_matches: Option<bool>,
467 /// Every claim that names this test, with the state each is in.
468 pub proves: Vec<ClaimProof>,
469}
470
471#[derive(Debug, Clone, Serialize, JsonSchema)]
472/// What a recording did: how much of the run reached the ledger, and under what provenance.
473///
474/// The recorder decides nothing, and this report says so. A result no runner in this
475/// repository owns is named in `unknown` rather than written under a guessed identity, and
476/// a failure is recorded like a pass — the ledger holds what happened, so `passed` is a
477/// count and never a filter.
478///
479/// ```
480/// use majordomus_cli::capability::builtin::evidence::RecordReport;
481///
482/// let report = RecordReport {
483/// recorded: 3,
484/// passed: 2,
485/// commit: "06fa258913a1b2c3d4e5f60718293a4b5c6d7e8f".to_string(),
486/// working_tree: "clean".to_string(),
487/// unknown: vec!["docs/CLAIMS.yaml".to_string()],
488/// dropped: vec![],
489/// ledger: majordomus_cli::evidence::LEDGER_PATH.to_string(),
490/// run_record: serde_json::from_value(serde_json::json!({
491/// "schema": 1, "id": "local:06fa258913a1:20260926T120000Z", "origin": "local",
492/// "commit": "06fa258913a1b2c3d4e5f60718293a4b5c6d7e8f", "working_tree": "clean",
493/// "recorded_at": "2026-09-26T12:00:00Z", "ledger": "repo"
494/// }))
495/// .unwrap(),
496/// };
497/// // the failing test is one of the three recorded, not a fourth thing that was dropped
498/// assert_eq!(report.recorded - report.passed, 1);
499/// assert!(report.ledger.starts_with(".ai/repo/"));
500///
501/// let json = serde_json::to_value(&report).unwrap();
502/// assert_eq!(json["unknown"].as_array().unwrap().len(), 1);
503/// assert_eq!(json["working_tree"], "clean");
504/// // a suite-only recording listed nothing, and its document is what it always was
505/// assert!(json.get("dropped").is_none());
506/// ```
507pub struct RecordReport {
508 /// How many executions were written.
509 pub recorded: usize,
510 /// How many of them passed.
511 pub passed: usize,
512 /// The commit they were recorded against.
513 pub commit: String,
514 /// The tree's state at the time.
515 pub working_tree: String,
516 /// Results the run named that no runner in this repository owns.
517 pub unknown: Vec<String>,
518 /// What the reports held that no claim can name yet: the crate's unit-test binary, its
519 /// doctests. Listed rather than ignored, and absent from the document when empty.
520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
521 pub dropped: Vec<EvidenceDropped>,
522 /// Where the ledger was written: the path of the target the recording named.
523 pub ledger: String,
524 /// The recording as a typed run.
525 pub run_record: EvidenceRunRecord,
526}
527
528// ---------------------------------------------------------------- handlers
529
530fn root_of(ctx: &Context) -> std::path::PathBuf {
531 std::path::PathBuf::from(&ctx.index.repository.root)
532}
533
534fn internal(e: crate::error::Error) -> CapabilityError {
535 CapabilityError::Internal(e.to_string())
536}
537
538/// The whole joined report, read fresh: the ledger is a file that changes outside this
539/// process, and an answer kept past a change of it would be the stale evidence this module
540/// exists to name. The executor keeps the capability's answer per checkout state only
541/// (`CachePolicy::Checkout`), which an edit of the ledger moves.
542fn full_report(ctx: &Context) -> Result<EvidenceReport, CapabilityError> {
543 let root = root_of(ctx);
544 let ledger = Ledger::load(&root).map_err(internal)?;
545 Ok(evidence::report(&ctx.index, &ledger))
546}
547
548/// The joined report at the checked-out commit, as committed: the ledger that commit holds,
549/// and the runs the working ledger holds beside it, which can only withhold `proven`.
550fn report_at_commit(
551 ctx: &Context,
552 rev: &str,
553 given: Option<TreeState>,
554) -> Result<EvidenceReport, CapabilityError> {
555 let root = root_of(ctx);
556 let presented =
557 freshness::presented_commit(&root, rev, given).map_err(CapabilityError::InvalidInput)?;
558 let commit = presented.commit().unwrap_or_default().to_string();
559 let ledger = freshness::ledger_at(&root, &commit).map_err(internal)?;
560 let held = freshness::uncommitted(&root, &ledger).map_err(internal)?;
561 Ok(evidence::report_at(&ctx.index, &ledger, &presented, &held))
562}
563
564fn report(ctx: &Context, input: EvidenceReportInput) -> Result<EvidenceReport, CapabilityError> {
565 let mut r = match (&input.presented, input.presented_tree) {
566 (Some(rev), given) => report_at_commit(ctx, rev, given)?,
567 (None, Some(_)) => {
568 return Err(CapabilityError::InvalidInput(
569 "`presented_tree` weakens the measured tree of a presented commit, and no \
570 commit was presented: name one with `presented`"
571 .to_string(),
572 ))
573 }
574 (None, None) => full_report(ctx)?,
575 };
576 // the tallies are taken before the filter: a filtered answer says how much of the
577 // matrix it looked at, never how much it returned
578 if input.findings_only {
579 let named: std::collections::BTreeSet<&str> =
580 r.findings.iter().map(|f| f.claim.as_str()).collect();
581 r.claims.retain(|c| named.contains(c.id.as_str()));
582 }
583 if let Some(state) = input.state {
584 r.claims.retain(|c| c.state == state);
585 }
586 if let Some(status) = &input.status {
587 r.claims.retain(|c| &c.status == status);
588 }
589 Ok(r)
590}
591
592fn claim(ctx: &Context, input: EvidenceClaimInput) -> Result<ClaimEvidence, CapabilityError> {
593 let r = full_report(ctx)?;
594 let Some(proof) = r.claims.iter().find(|c| c.id == input.claim).cloned() else {
595 return Err(CapabilityError::NotFound(format!(
596 "`{}` is not a claim of docs/CLAIMS.yaml",
597 input.claim
598 )));
599 };
600 let also_proves = proof
601 .test
602 .as_ref()
603 .map(|t| {
604 r.claims
605 .iter()
606 .filter(|c| c.test.as_ref() == Some(t) && c.id != proof.id)
607 .map(|c| c.id.clone())
608 .collect()
609 })
610 .unwrap_or_default();
611 let executions = proof.execution.clone().into_iter().collect();
612 Ok(ClaimEvidence {
613 proof,
614 executions,
615 also_proves,
616 })
617}
618
619fn test(ctx: &Context, input: EvidenceTestInput) -> Result<TestEvidence, CapabilityError> {
620 // a path and an identity both name the same test; a client that has one should not
621 // have to know the other's spelling
622 let id = evidence::TestId::of(&input.test)
623 .or_else(|| {
624 let (prefix, name) = input.test.split_once(':')?;
625 let runner = match prefix {
626 "suite" => evidence::Runner::Suite,
627 "crate" => evidence::Runner::Crate,
628 _ => return None,
629 };
630 // the same rule the path form applies: one grammar, not two that disagree
631 evidence::TestId::named(runner, name)
632 })
633 .ok_or_else(|| {
634 CapabilityError::InvalidInput(format!(
635 "`{}` names no test: give `suite:<case>`, `crate:<binary>`, or the path of a \
636 case under test/cases/ or an integration test under apps/majordomus-cli/tests/",
637 input.test
638 ))
639 })?;
640
641 let root = root_of(ctx);
642 let r = full_report(ctx)?;
643 let key = id.as_string();
644 let proves: Vec<ClaimProof> = r
645 .claims
646 .iter()
647 .filter(|c| c.test.as_deref() == Some(key.as_str()))
648 .cloned()
649 .collect();
650 let ledger = Ledger::load(&root).map_err(internal)?;
651 let execution = ledger.latest(&key).cloned();
652 let digest_matches = execution.as_ref().and_then(|e| e.digest_matches(&root));
653 Ok(TestEvidence {
654 test: key,
655 runner: id.runner,
656 source: id.source(),
657 present: root.join(id.source()).exists(),
658 reproduce: id.reproduce(),
659 execution,
660 digest_matches,
661 proves,
662 })
663}
664
665fn record(ctx: &Context, input: EvidenceRecordInput) -> Result<RecordReport, CapabilityError> {
666 let root = root_of(ctx);
667 let origin = match input.origin.as_deref() {
668 None => Origin::Local,
669 Some(w) => Origin::parse(w).ok_or_else(|| {
670 CapabilityError::InvalidInput(format!(
671 "`{w}` is not an origin: it is `local`, `ci` or `release`"
672 ))
673 })?,
674 };
675 // A CI run names itself from its environment; a local run has no run to name, and a
676 // local recording inside a CI job's shell is still not that job's evidence.
677 let run = match origin {
678 Origin::Ci => evidence::RunRef::from_env(|k| std::env::var(k).ok()),
679 Origin::Local | Origin::Release => None,
680 };
681 let ledger = input.ledger.unwrap_or_default();
682 let given: Vec<EvidenceProducer> = [
683 (EvidenceProducer::Suite, input.suite.is_some()),
684 (EvidenceProducer::Crate, input.crate_output.is_some()),
685 (EvidenceProducer::Coverage, input.coverage.is_some()),
686 ]
687 .into_iter()
688 .filter_map(|(p, is)| is.then_some(p))
689 .collect();
690 let provenance =
691 EvidenceProvenance::resolve(&input.provenance, &given).map_err(CapabilityError::Refused)?;
692 let req = RecordRequest {
693 suite: input.suite.map(std::path::PathBuf::from),
694 crate_output: input.crate_output.map(std::path::PathBuf::from),
695 run,
696 provenance,
697 coverage: input.coverage.map(std::path::PathBuf::from),
698 ledger,
699 run_record: input.run_record.map(std::path::PathBuf::from),
700 ..RecordRequest::new(origin)
701 };
702 let got = evidence::record(&root, &req).map_err(refused_or_internal)?;
703 Ok(RecordReport {
704 recorded: got.recorded,
705 passed: got.passed,
706 commit: got.commit,
707 working_tree: got.working_tree,
708 unknown: got.unknown,
709 dropped: got.dropped,
710 ledger: ledger.path().to_string(),
711 run_record: got.run_record,
712 })
713}
714
715/// A refusal of the evidence surface is the caller's to fix; anything else is this
716/// executable's.
717fn refused_or_internal(e: crate::error::Error) -> CapabilityError {
718 match e {
719 crate::error::Error::InvalidSurface { reason, .. } => CapabilityError::Refused(reason),
720 other => internal(other),
721 }
722}
723
724fn stamp(ctx: &Context, input: EvidenceStampInput) -> Result<EvidenceProvenance, CapabilityError> {
725 let req = StampRequest {
726 producer: input.producer,
727 report: input.report.map(std::path::PathBuf::from),
728 exclude: input.exclude,
729 run: evidence::RunRef::from_env(|k| std::env::var(k).ok()),
730 };
731 evidence::stamp(&root_of(ctx), &req).map_err(refused_or_internal)
732}
733
734// ---------------------------------------------------------------- the module
735
736/// The `evidence` module: claims joined to the runs recorded against them.
737///
738/// The declaration is the whole of what the capabilities are — every projection, from
739/// the HTTP route to the MCP tool to the word a person types, is read out of what it
740/// returns. Nothing here caches: the ledger is a file that changes outside this process,
741/// and a cached answer would be exactly the stale evidence the module exists to name.
742///
743/// ```
744/// use majordomus_cli::capability::builtin::evidence::module;
745/// use majordomus_cli::capability::CachePolicy;
746///
747/// let m = module();
748/// let report = m
749/// .capabilities
750/// .iter()
751/// .find(|e| e.capability.id.as_str() == "evidence.report")
752/// .unwrap();
753/// assert_eq!(report.capability.exposure.http.as_ref().unwrap().path, "/api/v1/evidence");
754/// assert_eq!(
755/// report.capability.exposure.mcp.as_ref().unwrap().tool.as_deref(),
756/// Some("majordomus_evidence")
757/// );
758///
759/// // `evidence.test` is spelled `majordomus evidence proves`: `test` is a word the fish
760/// // completion adapter refuses, and the identity is not the command
761/// let test = m
762/// .capabilities
763/// .iter()
764/// .find(|e| e.capability.id.as_str() == "evidence.test")
765/// .unwrap();
766/// assert_eq!(test.capability.exposure.cli.as_ref().unwrap().path[1], "proves");
767///
768/// // the ledger changes outside this process: kept, if at all, per checkout state only
769/// assert!(m.capabilities.iter().all(|e| matches!(
770/// e.capability.cache,
771/// CachePolicy::Disabled | CachePolicy::Checkout { .. }
772/// )));
773/// ```
774pub fn module() -> ModuleDescriptor {
775 module! {
776 id: "evidence",
777 title: "Evidence",
778 description: "What actually ran, against which commit, and whether it still proves anything. The claims matrix binds a claim to a test by path; the ledger under .ai/repo/evidence records the latest execution of every test with the commit, the tree state, the digest of the test's own source, the time, the origin and the command that runs it again. Joining the two answers, per claim, whether the repository can honestly call it proven — and distinguishes a run recorded against this very commit from one whose inputs merely have not changed since, because collapsing those two is how a green badge stops meaning anything.",
779 stability: Stability::BehaviorallyVerified,
780 capabilities: [
781 capability! {
782 id: "evidence.report",
783 title: "Every claim against the evidence recorded for it",
784 description: "The whole claims matrix joined to the ledger and judged at the presented revision: the working tree by default, or the checked-out commit as committed, read from the ledger that commit holds. Per claim: the proof state, the sentence explaining how that state was derived, why when the state alone does not say, the execution behind it, the files that changed since it, and the command that produces it again. A run on a commit the presented revision does not contain is stale, a test that declined to run is not run, and a run the working ledger holds that the presented commit's does not can only withhold proven. The tallies count the whole matrix even when the answer is filtered, and the findings name every claim that declares a guarantee the evidence does not support. Kept per checkout state, never longer: the ledger is a file that changes outside this process, and an edit of it, of a test or of any tracked file, a new untracked file, a commit or a new ref is a new answer.",
785 input: EvidenceReportInput,
786 output: EvidenceReport,
787 stability: Stability::BehaviorallyVerified,
788 exposure: Exposure {
789 mcp: Some(crate::capability::model::McpExposure {
790 tool: Some("majordomus_evidence".into()),
791 resource: Some(McpResource { uri: EVIDENCE_URI.into(), name: "evidence".into() }),
792 }),
793 http: get("/api/v1/evidence"),
794 cli: Some(CliExposure { path: vec!["evidence".into(), "show".into()] }),
795 },
796 tags: ["evidence", "claims", "tests", "verification", "provenance"],
797 cache: CachePolicy::Checkout { max_entries: 8 },
798 handler: report,
799 },
800 capability! {
801 id: "evidence.claim",
802 title: "What proves this claim",
803 description: "One claim with its proof state, the execution behind it — outcome, duration, commit, tree state, digest, time and origin — the command that reproduces it, and the other claims the same test proves. A claim the matrix does not declare is a not-found rather than an empty answer, because a typo that read as 'this claim has no evidence' is the one answer this capability must never give.",
804 input: EvidenceClaimInput,
805 output: ClaimEvidence,
806 stability: Stability::BehaviorallyVerified,
807 exposure: Exposure {
808 mcp: mcp("majordomus_evidence_claim"),
809 http: get("/api/v1/evidence/claim"),
810 cli: Some(CliExposure { path: vec!["evidence".into(), "claim".into()] }),
811 },
812 tags: ["evidence", "claims", "verification", "provenance"],
813 cache: CachePolicy::Checkout { max_entries: 32 },
814 handler: claim,
815 },
816 capability! {
817 id: "evidence.test",
818 title: "What this test proves",
819 description: "One test — by identity or by the path a claim names it with — with its latest execution, whether its source still hashes to what that execution recorded, the command that runs it again, and every claim that names it. This is the reverse of evidence.claim and reads the same derivation, so the two directions cannot disagree.",
820 input: EvidenceTestInput,
821 output: TestEvidence,
822 stability: Stability::BehaviorallyVerified,
823 exposure: Exposure {
824 mcp: mcp("majordomus_evidence_test"),
825 http: get("/api/v1/evidence/test"),
826 cli: Some(CliExposure { path: vec!["evidence".into(), "proves".into()] }),
827 },
828 tags: ["evidence", "tests", "verification", "provenance"],
829 cache: CachePolicy::Disabled,
830 handler: test,
831 },
832 capability! {
833 id: "evidence.record",
834 kind: CapabilityKind::Command,
835 title: "Record a run that happened",
836 description: "Reads what the runs already wrote — the suite's TSV report, cargo test's output, a coverage summary — carries into each result what that run measured about its own checkout (the commit and the tree, from the measurement `evidence stamp` wrote), with the digest of the test's own source, the time and the origin, and merges it into the ledger. It records; it decides nothing: a case that failed is a case the runner said failed, and a test no report named is left exactly as it was, so recording one case never erases the evidence for the rest. It refuses a tree with no commit, a measurement of another commit or of another report, a local measurement that names no report, and a CI report without a measurement; a local report recorded without one carries the tree unknown, which never reads proven. Every recording returns a run record (its commit, the tree each report's run measured, its totals and what was absent, dropped or unknown, and a coverage summary bound to its commit), and --ledger local writes only the ignored local ledger. A crate binary that ran no test, or only a filtered subset, is recorded as a skip, and what no claim can name yet (the crate's own unit tests, its doctests) is listed as dropped.",
837 input: EvidenceRecordInput,
838 output: RecordReport,
839 stability: Stability::BehaviorallyVerified,
840 exposure: Exposure {
841 mcp: None,
842 http: None,
843 cli: Some(CliExposure { path: vec!["evidence".into(), "record".into()] }),
844 },
845 tags: ["evidence", "tests", "provenance"],
846 cache: CachePolicy::Disabled,
847 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
848 handler: record,
849 },
850 capability! {
851 id: "evidence.stamp",
852 title: "Measure the tree a run left behind",
853 description: "The one measurement a runner takes at the end of its run, for evidence record --provenance: the commit; the tree, ignoring the evidence ledger and the run's own untracked outputs, never a tracked change; the producer; its toolchain; the recorder's version; the report's digest; the host; the CI run. It writes nothing. It is offered only on the command line because it reads a path its caller names.",
854 input: EvidenceStampInput,
855 output: EvidenceProvenance,
856 stability: Stability::BehaviorallyVerified,
857 exposure: Exposure {
858 mcp: None,
859 http: None,
860 cli: Some(CliExposure { path: vec!["evidence".into(), "stamp".into()] }),
861 },
862 tags: ["evidence", "tests", "provenance"],
863 cache: CachePolicy::Disabled,
864 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
865 handler: stamp,
866 },
867 ],
868 }
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 /// One capability's declared projections: its id, the MCP tool it answers as, the HTTP
876 /// path it is served at, and the words of its command line. Named because the tuple is
877 /// read four times below and an anonymous one of this width is a type nobody can talk
878 /// about.
879 type Projection<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a [&'a str]);
880
881 /// The declaration is the only place these names exist; every projection derives from
882 /// it. This is the assertion a refactor that dropped an exposure would fail.
883 #[test]
884 fn the_declaration_yields_the_projections_it_claims() {
885 let m = module();
886 assert_eq!(m.id.as_str(), "evidence");
887 let expected: &[Projection<'_>] = &[
888 (
889 "evidence.report",
890 Some("majordomus_evidence"),
891 Some("/api/v1/evidence"),
892 &["evidence", "show"],
893 ),
894 (
895 "evidence.claim",
896 Some("majordomus_evidence_claim"),
897 Some("/api/v1/evidence/claim"),
898 &["evidence", "claim"],
899 ),
900 (
901 "evidence.test",
902 Some("majordomus_evidence_test"),
903 Some("/api/v1/evidence/test"),
904 &["evidence", "proves"],
905 ),
906 ("evidence.record", None, None, &["evidence", "record"]),
907 ("evidence.stamp", None, None, &["evidence", "stamp"]),
908 ];
909 let ids: Vec<&str> = m
910 .capabilities
911 .iter()
912 .map(|e| e.capability.id.as_str())
913 .collect();
914 let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
915 assert_eq!(ids, want);
916 for (e, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
917 let x = &e.capability.exposure;
918 assert_eq!(
919 x.mcp.as_ref().and_then(|m| m.tool.as_deref()),
920 *tool,
921 "{id}"
922 );
923 assert_eq!(x.http.as_ref().map(|h| h.path.as_str()), *path, "{id}");
924 assert_eq!(
925 x.cli.as_ref().map(|c| c.path.clone()),
926 Some(cli.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
927 "{id} lost or renamed its command line"
928 );
929 // the ledger changes outside this process, so an answer is kept, if at all, only
930 // per checkout state, which an edit of the ledger moves
931 assert!(
932 matches!(
933 e.capability.cache,
934 CachePolicy::Disabled | CachePolicy::Checkout { .. }
935 ),
936 "{id} may cache only per checkout state: the ledger changes outside this process"
937 );
938 }
939 }
940
941 /// The recorder writes a tracked file. It must be a command, and it must be offered to
942 /// whoever runs the executable and to nobody over a network — this server is read-only,
943 /// and a write reachable over MCP or HTTP would end that.
944 #[test]
945 fn only_the_recorder_writes_and_it_is_not_reachable_over_the_network() {
946 for e in module().capabilities {
947 let id = e.capability.id.as_str().to_string();
948 let x = &e.capability.exposure;
949 if id == "evidence.record" {
950 assert!(
951 !e.capability.kind.is_read_only(),
952 "{id} is declared a query"
953 );
954 assert!(x.mcp.is_none(), "{id} is reachable over MCP");
955 assert!(x.http.is_none(), "{id} is reachable over HTTP");
956 assert_eq!(
957 e.capability.visibility,
958 crate::capability::Visibility::Developer,
959 "{id} must be a developer capability"
960 );
961 assert!(
962 matches!(e.capability.benchmark, BenchmarkPolicy::Waived { .. }),
963 "{id} must not be timed in a loop: it writes a tracked file"
964 );
965 } else if id == "evidence.stamp" {
966 // a read, but of a path its caller names: a network client must not choose a
967 // file this executable reads, so it is offered only on the command line; and
968 // it is waived, because the benchmark repository is not a git work tree
969 assert!(e.capability.kind.is_read_only(), "{id} writes");
970 assert!(x.mcp.is_none(), "{id} is reachable over MCP");
971 assert!(x.http.is_none(), "{id} is reachable over HTTP");
972 assert_eq!(
973 e.capability.visibility,
974 crate::capability::Visibility::Developer,
975 "{id} must be a developer capability"
976 );
977 assert!(
978 matches!(
979 e.capability.benchmark,
980 BenchmarkPolicy::Waived {
981 reason: WaiverReason::ExternalDependency
982 }
983 ),
984 "{id} is waived for its external git"
985 );
986 } else {
987 assert!(e.capability.kind.is_read_only(), "{id} writes");
988 assert!(
989 x.mcp.is_some() && x.http.is_some(),
990 "{id} lost a projection"
991 );
992 }
993 }
994 }
995
996 /// Every read capability carries a command line, and every command line it carries is
997 /// under one word. A capability that claimed a path `cli.rs` does not declare would
998 /// fail the projection closure; this is the half that says the module meant to have one.
999 #[test]
1000 fn every_capability_is_reachable_from_the_command_line() {
1001 for e in module().capabilities {
1002 let cli = e
1003 .capability
1004 .exposure
1005 .cli
1006 .as_ref()
1007 .unwrap_or_else(|| panic!("{} has no command line", e.capability.id));
1008 assert_eq!(
1009 cli.path.first().map(String::as_str),
1010 Some("evidence"),
1011 "{} is not under `majordomus evidence`",
1012 e.capability.id
1013 );
1014 assert_eq!(cli.path.len(), 2, "{}", e.capability.id);
1015 }
1016 }
1017
1018 /// A tree state means something only about a presented commit, and a revision the
1019 /// repository cannot name is refused with its name rather than judged at something else.
1020 #[test]
1021 fn a_presented_revision_is_checked_before_anything_is_judged() {
1022 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1023 let ctx = repo.context().unwrap();
1024 match ctx.execute(
1025 "evidence.report",
1026 serde_json::json!({ "presented_tree": "dirty" }),
1027 ) {
1028 Err(CapabilityError::InvalidInput(m)) => {
1029 assert!(m.contains("presented"), "{m}")
1030 }
1031 other => panic!("a tree state with no commit was accepted: {other:?}"),
1032 }
1033 // the benchmark repository is not a git repository, so no revision resolves there
1034 match ctx.execute(
1035 "evidence.report",
1036 serde_json::json!({ "presented": "HEAD", "presented_tree": "clean" }),
1037 ) {
1038 Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("HEAD"), "{m}"),
1039 other => panic!("a revision that names nothing was judged: {other:?}"),
1040 }
1041 // and the working tree is still the default
1042 let v = ctx
1043 .execute("evidence.report", serde_json::json!({}))
1044 .unwrap();
1045 assert_eq!(v["presented"]["revision"], "working_tree");
1046 }
1047
1048 /// What a crate run held that no claim can name yet reaches the recording's answer, in
1049 /// the order the output held it, and a recording with nothing dropped carries no
1050 /// `dropped` at all, so a suite-only recording answers as it did before the list existed.
1051 #[test]
1052 fn the_recording_answers_with_what_it_dropped() {
1053 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1054 let git = |args: &[&str]| {
1055 let out = std::process::Command::new("git")
1056 .arg("-C")
1057 .arg(repo.root())
1058 .args(args)
1059 .output()
1060 .unwrap();
1061 assert!(out.status.success(), "git {args:?}");
1062 };
1063 git(&["init", "-q"]);
1064 git(&["config", "user.email", "t@example.com"]);
1065 git(&["config", "user.name", "t"]);
1066 git(&["add", "-A"]);
1067 git(&["commit", "-q", "-m", "fixture"]);
1068 let ctx = repo.context().unwrap();
1069
1070 let reports = tempfile::tempdir().unwrap();
1071 let log = reports.path().join("crate.log");
1072 std::fs::write(
1073 &log,
1074 " Running unittests src/lib.rs (target/debug/deps/majordomus_cli-1)\n\
1075 test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1076 Doc-tests majordomus_cli\n\
1077 test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n",
1078 )
1079 .unwrap();
1080 let v = ctx
1081 .execute(
1082 "evidence.record",
1083 serde_json::json!({ "crate_output": log.to_string_lossy() }),
1084 )
1085 .unwrap();
1086 assert_eq!(v["recorded"], 0, "{v}");
1087 let dropped: Vec<(&str, &str)> = v["dropped"]
1088 .as_array()
1089 .unwrap_or_else(|| panic!("no dropped list: {v}"))
1090 .iter()
1091 .map(|d| {
1092 (
1093 d["producer"].as_str().unwrap_or("?"),
1094 d["what"].as_str().unwrap_or("?"),
1095 )
1096 })
1097 .collect();
1098 assert_eq!(
1099 dropped,
1100 [
1101 ("crate", "unittests src/lib.rs"),
1102 ("crate", "doc-tests majordomus_cli")
1103 ]
1104 );
1105
1106 let tsv = reports.path().join("run.tsv");
1107 std::fs::write(&tsv, "99_ghost\tok\t1\tparallel\n").unwrap();
1108 let v = ctx
1109 .execute(
1110 "evidence.record",
1111 serde_json::json!({ "suite": tsv.to_string_lossy() }),
1112 )
1113 .unwrap();
1114 assert!(v.get("dropped").is_none(), "{v}");
1115 }
1116
1117 /// Each ledger a report reads — the one a presented commit holds, the working copy
1118 /// beside it, and the working tree's own — is refused when it cannot be read, rather
1119 /// than read as a ledger that recorded nothing.
1120 #[test]
1121 fn an_unreadable_ledger_is_refused_at_every_revision() {
1122 use crate::evidence::LEDGER_PATH;
1123 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1124 let git = |args: &[&str]| {
1125 let out = std::process::Command::new("git")
1126 .arg("-C")
1127 .arg(repo.root())
1128 .args(args)
1129 .output()
1130 .unwrap();
1131 String::from_utf8_lossy(&out.stdout).trim().to_string()
1132 };
1133 git(&["init", "-q"]);
1134 git(&["config", "user.email", "t@example.com"]);
1135 git(&["config", "user.name", "t"]);
1136 git(&["add", "-A"]);
1137 git(&["commit", "-q", "-m", "fixture"]);
1138 let ctx = repo.context().unwrap();
1139 let at_head = serde_json::json!({ "presented": "HEAD" });
1140
1141 let v = ctx.execute("evidence.report", at_head.clone()).unwrap();
1142 assert_eq!(v["presented"]["revision"], git(&["rev-parse", "HEAD"]));
1143 assert_eq!(v["presented"]["tree"], "clean");
1144
1145 let refused = |input: serde_json::Value| match ctx.execute("evidence.report", input) {
1146 Err(CapabilityError::Internal(m)) => m,
1147 other => panic!("an unreadable ledger was judged past: {other:?}"),
1148 };
1149 // the working copy cannot be read: a run it may hold cannot be ruled out
1150 std::fs::create_dir_all(repo.root().join(".ai/repo/evidence")).unwrap();
1151 std::fs::write(repo.root().join(LEDGER_PATH), "not a ledger").unwrap();
1152 assert!(refused(at_head.clone()).contains("cannot be ruled out"));
1153 assert!(refused(serde_json::json!({})).contains(LEDGER_PATH));
1154 // and the ledger the commit holds cannot be read either
1155 git(&["add", "-A"]);
1156 git(&["commit", "-q", "-m", "an unreadable ledger"]);
1157 assert!(refused(at_head).contains(LEDGER_PATH));
1158 }
1159}