Skip to main content

majordomus_cli/evidence/
subject.rs

1//! Evidence subjects: every declaration a verdict can be asked about, what it is made of,
2//! and the tests it reaches.
3//!
4//! A *subject* is a declaration a person or a page asks "is this proven?" about: a claim of
5//! `docs/CLAIMS.yaml`, a rule of the layer, a feature, a public command, a builtin capability
6//! of the executable, an MCP tool (an alias of its capability) or a use case. Its key is
7//! `<kind>:<id>` — `claim:scope-integrity`, `command:commit plan`, `mcp:majordomus_get`.
8//!
9//! # Derived only
10//!
11//! Nothing lists a subject, a member or a route by hand. [`index`] reads the declarations
12//! that already exist and nothing else:
13//!
14//! * the claims, through the evidence join ([`crate::evidence::report`]) with an empty
15//!   ledger, so a claim's test identity is the one grammar [`TestId::of`] owns;
16//! * the rules, through [`crate::rules::definitions`];
17//! * the features, through the product model;
18//! * the use cases and the public shell commands, from the index;
19//! * the documented command-line examples ([`crate::cli::EXAMPLES`]);
20//! * the builtin capabilities of the registry, with the file each was composed in;
21//! * the first `# majordomus-covers:` and the first `# majordomus-negative:` line of every
22//!   case under `test/cases/`, and the integration test binaries of the crate.
23//!
24//! Adding a declaration adds its subject, and removing it removes it. The committed index,
25//! [`PATH`], carries structure only — no ledger row, no commit — so recording a run never
26//! makes it stale, and `majordomus generate --check` refuses a copy that differs from this
27//! derivation.
28//!
29//! # The shell derivations it replaces or holds equal
30//!
31//! Before this index, six shell programs derived "which tests prove this subject":
32//!
33//! * the command pages (`scripts/generate-site-data`, `commands.json`) read the first
34//!   covers and negative header of every case — the index reads the same first line, and
35//!   case 507 holds the two equal;
36//! * the command-coverage doctrine (`lib/commands.sh`) reads the same first line;
37//! * the `command-furnished` gate and the use-case impact trace match a command as a whole
38//!   word of *any* header line, so they would read a second header of a kind where the index
39//!   reads only the first; a prefixed word such as `capability:commit.plan` is a command for
40//!   none of them — case 507 holds every case to one header of each kind, and every header
41//!   to naming the same public commands under both readings, so they cannot yet disagree;
42//! * the capability pages (`scripts/lib/executable-site.jq`) give every capability the
43//!   claims implemented in its module's file — the index gives `capability:<id>` the same
44//!   claim members, and case 507 holds the two equal;
45//! * the doctrine pages (`doctrines.json`) name the first test of each rule — case 507
46//!   holds every one of them to a route or a mechanism of `rule:<id>`.
47//!
48//! The command words are the command graph's ([`crate::command_graph`]) as well: the public
49//! commands of the shell tool and the runnable commands of the executable, which the
50//! documented examples name one for one. Case 507 holds the command subjects, and the
51//! programs each names, equal to `majordomus commands graph`.
52//!
53//! # Example
54//!
55//! ```
56//! use majordomus_cli::evidence::subject::{self, SubjectKind};
57//! use majordomus_cli::synthetic::SyntheticRepository;
58//!
59//! let repo = SyntheticRepository::small().unwrap();
60//! let ctx = repo.context().unwrap();
61//! let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
62//!
63//! // an MCP tool is an alias of the capability that declares it, never a subject of its own
64//! let tool = s.get("mcp:majordomus_capabilities").unwrap();
65//! let capability = tool.alias_of.as_deref().unwrap();
66//! assert_eq!(SubjectKind::parse(capability).unwrap().0, SubjectKind::Capability);
67//! assert_eq!(tool.members, vec![capability.to_string()]);
68//! assert!(tool.page.is_none(), "an alias has no page of its own");
69//! ```
70
71use std::collections::{BTreeMap, BTreeSet};
72use std::fmt;
73use std::path::{Path, PathBuf};
74
75use schemars::JsonSchema;
76use serde::{Deserialize, Serialize};
77use serde_json::{json, Value};
78
79use crate::capability::{CapabilityRegistry, Context, Provenance};
80use crate::evidence::freshness::{
81    aggregate, compare, freshness, weakened_by, Comparison, Judgement, Presented, Recorded,
82    Supplementary, TreeState, UNCOMMITTED_RUN,
83};
84use crate::evidence::{
85    ClaimProof, EvidenceReport, Execution, Ledger, PresentedRevision, ProofState, TestId,
86};
87use crate::git::Containment;
88use crate::index::Index;
89use crate::product::ProductModel;
90use crate::rules::{RuleProof, RuleState, RulesReport};
91use crate::Severity;
92
93/// The schema the committed index declares, validated against its published contract.
94pub const SCHEMA: &str = "majordomus-site-evidence-subjects/v1";
95
96/// Where the committed index lives: the Rust executable's site directory, never the shell
97/// site generator's, which that generator replaces wholesale.
98pub const PATH: &str = "site/data/registry/evidence-subjects.json";
99
100/// The manifest's document id of the committed index.
101pub const DOCUMENT: &str = "site-evidence-subjects";
102
103/// What the committed index is derived from, as its banner says.
104pub const SOURCE: &str = "the claims, rules, features, use cases, commands, documented \
105command-line examples and builtin capabilities this repository declares, and the first \
106coverage and negative headers of its test cases";
107
108/// The site route every subject page lives under.
109pub const ROUTE: &str = "/evidence/";
110
111/// A feature that reaches no test a runner drives: structural, advisory until the gate.
112pub const FEATURE_WITHOUT_EVIDENCE: &str = "feature_without_evidence";
113
114/// A command word both the shell tool and the executable answer to: structural, advisory.
115pub const COMMAND_IN_TWO_PROGRAMS: &str = "command_in_two_programs";
116
117/// Git could not compare an evidence commit with the presented revision.
118pub const FRESHNESS_UNKNOWN: &str = "freshness_unknown";
119
120/// A rule a subject is made of names a path that is not in the tree.
121pub const DANGLING_MEMBER: &str = "dangling_member";
122
123/// The header a case declares the commands it exercises with.
124const COVERS: &str = "# majordomus-covers:";
125/// The header a case declares the commands it refutes with.
126const NEGATIVE: &str = "# majordomus-negative:";
127/// The integration test binary that runs every documented command-line example.
128const CLI_EXAMPLES: &str = "apps/majordomus-cli/tests/cli_examples.rs";
129/// Where the suite's cases live.
130const CASES_DIR: &str = "test/cases";
131/// Where the crate's integration test binaries live.
132const CRATE_TESTS_DIR: &str = "apps/majordomus-cli/tests";
133
134// ---------------------------------------------------------------- the vocabulary
135
136/// What kind of declaration a subject is. The declaration order is the order the committed
137/// index lists its kinds in.
138///
139/// ```
140/// use majordomus_cli::evidence::subject::SubjectKind;
141///
142/// let key = SubjectKind::Command.key("commit plan");
143/// assert_eq!(key, "command:commit plan");
144/// assert_eq!(SubjectKind::parse(&key), Some((SubjectKind::Command, "commit plan")));
145/// assert_eq!(serde_json::to_value(SubjectKind::UseCase).unwrap(), "use_case");
146/// ```
147#[derive(
148    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
149)]
150#[serde(rename_all = "snake_case")]
151#[schemars(rename = "EvidenceSubjectKind")]
152pub enum SubjectKind {
153    /// A claim of `docs/CLAIMS.yaml`.
154    Claim,
155    /// A rule of the layer, by id without its version.
156    Rule,
157    /// A feature under `.ai/repo/features/`.
158    Feature,
159    /// A public shell command or a native command path, its words joined by one space.
160    Command,
161    /// A builtin capability of the executable.
162    Capability,
163    /// An MCP tool name: an alias of its capability.
164    Mcp,
165    /// A use case under `.ai/repo/use-cases/`.
166    UseCase,
167}
168
169impl SubjectKind {
170    /// Every kind, in declaration order.
171    pub const ALL: [SubjectKind; 7] = [
172        SubjectKind::Claim,
173        SubjectKind::Rule,
174        SubjectKind::Feature,
175        SubjectKind::Command,
176        SubjectKind::Capability,
177        SubjectKind::Mcp,
178        SubjectKind::UseCase,
179    ];
180
181    /// The word a key starts with: the kind's serialised name.
182    ///
183    /// ```
184    /// use majordomus_cli::evidence::subject::SubjectKind;
185    /// assert_eq!(SubjectKind::Claim.prefix(), "claim");
186    /// assert_eq!(SubjectKind::UseCase.prefix(), "use_case");
187    /// ```
188    pub fn prefix(self) -> &'static str {
189        match self {
190            SubjectKind::Claim => "claim",
191            SubjectKind::Rule => "rule",
192            SubjectKind::Feature => "feature",
193            SubjectKind::Command => "command",
194            SubjectKind::Capability => "capability",
195            SubjectKind::Mcp => "mcp",
196            SubjectKind::UseCase => "use_case",
197        }
198    }
199
200    /// The key of the subject of this kind with this id: `<prefix>:<id>`.
201    ///
202    /// ```
203    /// use majordomus_cli::evidence::subject::SubjectKind;
204    /// assert_eq!(SubjectKind::Rule.key("project.scope"), "rule:project.scope");
205    /// ```
206    pub fn key(self, id: &str) -> String {
207        format!("{}:{id}", self.prefix())
208    }
209
210    /// Read a key back into its kind and its id, or `None` when it is not a subject key.
211    ///
212    /// The key splits at its **first** `:`, so an id may carry colons and spaces of its own;
213    /// the prefix must be a kind's, and the id must not be empty.
214    ///
215    /// ```
216    /// use majordomus_cli::evidence::subject::SubjectKind;
217    /// assert_eq!(
218    ///     SubjectKind::parse("command:commit plan"),
219    ///     Some((SubjectKind::Command, "commit plan"))
220    /// );
221    /// assert_eq!(SubjectKind::parse("gate:x"), None, "not a kind");
222    /// assert_eq!(SubjectKind::parse("claim:"), None, "no id");
223    /// assert_eq!(SubjectKind::parse("nonsense"), None);
224    /// ```
225    pub fn parse(key: &str) -> Option<(SubjectKind, &str)> {
226        let (prefix, id) = key.split_once(':')?;
227        if id.is_empty() {
228            return None;
229        }
230        let kind = SubjectKind::ALL
231            .into_iter()
232            .find(|k| k.prefix() == prefix)?;
233        Some((kind, id))
234    }
235
236    /// The site section a subject of this kind has its page in, or `None` when the kind has
237    /// no page of its own (a rule, an MCP alias).
238    ///
239    /// ```
240    /// use majordomus_cli::evidence::subject::SubjectKind;
241    /// assert_eq!(SubjectKind::UseCase.section(), Some("use-cases"));
242    /// assert_eq!(SubjectKind::Mcp.section(), None);
243    /// ```
244    pub fn section(self) -> Option<&'static str> {
245        match self {
246            SubjectKind::Claim => Some("claims"),
247            SubjectKind::Rule => None,
248            SubjectKind::Feature => Some("features"),
249            SubjectKind::Command => Some("commands"),
250            SubjectKind::Capability => Some("capabilities"),
251            SubjectKind::Mcp => None,
252            SubjectKind::UseCase => Some("use-cases"),
253        }
254    }
255}
256
257/// How a subject reaches a test. The declaration order is the order routes are listed in,
258/// which puts the behavioural routes before the scenario.
259///
260/// ```
261/// use majordomus_cli::evidence::subject::Via;
262/// assert!(Via::Behaviour < Via::Scenario);
263/// assert_eq!(serde_json::to_value(Via::Negative).unwrap(), "negative");
264/// ```
265#[derive(
266    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
267)]
268#[serde(rename_all = "snake_case")]
269#[schemars(rename = "EvidenceVia")]
270pub enum Via {
271    /// The test a claim names.
272    Claim,
273    /// A test a rule's enforcement block names.
274    Rule,
275    /// A case whose first covers header names the subject.
276    Behaviour,
277    /// A case whose first negative header names the subject.
278    Negative,
279    /// The binary that runs every documented command-line example.
280    Example,
281    /// A use case's own scenario, which the ledger does not record.
282    Scenario,
283}
284
285/// What kind of test a route reaches, read off the test's identity and never declared.
286///
287/// ```
288/// use majordomus_cli::evidence::subject::Category;
289/// assert_eq!(Category::of("suite:07_scope"), Some(Category::E2e));
290/// assert_eq!(Category::of("crate:why"), Some(Category::Integration));
291/// assert_eq!(Category::of("lib/x.sh"), None);
292/// ```
293#[derive(
294    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
295)]
296#[serde(rename_all = "snake_case")]
297#[schemars(rename = "EvidenceCategory")]
298pub enum Category {
299    /// A behavioural case of the suite, driving the tools end to end.
300    E2e,
301    /// An integration test binary of the crate.
302    Integration,
303    /// A unit test inside the library (no runner records one yet).
304    Unit,
305    /// A documentation example (no runner records one yet).
306    Doctest,
307    /// A use case's scenario.
308    Scenario,
309}
310
311impl Category {
312    /// Every category, in declaration order.
313    pub const ALL: [Category; 5] = [
314        Category::E2e,
315        Category::Integration,
316        Category::Unit,
317        Category::Doctest,
318        Category::Scenario,
319    ];
320
321    /// The category of a test identity, or `None` when no runner's prefix is on it. The
322    /// first matching prefix wins, so a library unit test is never read as an integration
323    /// binary.
324    ///
325    /// ```
326    /// use majordomus_cli::evidence::subject::Category;
327    /// assert_eq!(Category::of("crate:lib::evidence::x"), Some(Category::Unit));
328    /// assert_eq!(Category::of("crate:cli_examples"), Some(Category::Integration));
329    /// assert_eq!(Category::of("doc:x"), Some(Category::Doctest));
330    /// ```
331    pub fn of(test: &str) -> Option<Category> {
332        if test.starts_with("suite:") {
333            Some(Category::E2e)
334        } else if test.starts_with("crate:lib::") {
335            Some(Category::Unit)
336        } else if test.starts_with("crate:") {
337            Some(Category::Integration)
338        } else if test.starts_with("doc:") {
339            Some(Category::Doctest)
340        } else {
341            None
342        }
343    }
344}
345
346/// One way a subject reaches a test, or the path it names when no runner drives it.
347///
348/// ```
349/// use majordomus_cli::evidence::subject::{Category, Route, Via};
350///
351/// let route = Route {
352///     via: Via::Behaviour,
353///     test: Some("suite:07_scope".into()),
354///     path: Some("test/cases/07_scope.sh".into()),
355///     category: Category::of("suite:07_scope"),
356///     inputs: None,
357/// };
358/// let json = serde_json::to_value(&route).unwrap();
359/// assert_eq!(json["category"], "e2e");
360/// // a route that declares no inputs says so by omission, never with an empty list
361/// assert!(json.get("inputs").is_none());
362/// ```
363#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
364#[schemars(rename = "EvidenceSubjectRoute")]
365pub struct Route {
366    /// How the subject reaches this test.
367    pub via: Via,
368    /// `TestId::as_string()` when a runner drives the path; absent otherwise.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub test: Option<String>,
371    /// The path the declaration names, verbatim, when there is one.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub path: Option<String>,
374    /// What kind of test it is, read off the test's identity.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub category: Option<Category>,
377    /// The paths whose change since the run makes it stale. Absent = the route declares
378    /// none.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub inputs: Option<Vec<String>>,
381}
382
383/// A structural finding about a subject: something its declarations say, not something a
384/// run said. Advisory until the gate slice holds it.
385///
386/// ```
387/// use majordomus_cli::evidence::subject::{SubjectFinding, FEATURE_WITHOUT_EVIDENCE};
388/// use majordomus_cli::Severity;
389///
390/// let finding = SubjectFinding {
391///     code: FEATURE_WITHOUT_EVIDENCE.into(),
392///     severity: Severity::Error,
393///     subject: "feature:lonely".into(),
394///     message: "the feature `lonely` reaches no test".into(),
395/// };
396/// assert_eq!(serde_json::to_value(&finding).unwrap()["severity"], "error");
397/// ```
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399#[schemars(rename = "EvidenceSubjectFinding")]
400pub struct SubjectFinding {
401    /// One of [`FEATURE_WITHOUT_EVIDENCE`], [`COMMAND_IN_TWO_PROGRAMS`],
402    /// [`FRESHNESS_UNKNOWN`] and [`DANGLING_MEMBER`].
403    pub code: String,
404    /// How much it matters.
405    pub severity: Severity,
406    /// The subject key it is about.
407    pub subject: String,
408    /// What it means, in one sentence.
409    pub message: String,
410}
411
412/// Which program answers to a command word: the shell tool, or the Rust executable. A word
413/// both answer to names both, in this order.
414///
415/// These are two of the command graph's [`crate::command_graph::Origin`]s, read from the
416/// same declarations: `Native` is a runnable command of the executable
417/// (`Origin::Executable`), and `Shell` a command of the shell tool (`Origin::Tool`), whose
418/// registry `share/commands.yaml` both read. A group of the executable that only holds
419/// other commands, such as `majordomus evidence`, is not a command the executable answers
420/// to. Case 507 holds the command subjects and their programs equal to the command graph's.
421///
422/// ```
423/// use majordomus_cli::evidence::subject::Program;
424/// assert!(Program::Native < Program::Shell);
425/// assert_eq!(serde_json::to_value(Program::Shell).unwrap(), "shell");
426/// ```
427#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
428#[serde(rename_all = "snake_case")]
429pub enum Program {
430    /// The Rust executable: a documented example path or a capability's command-line path.
431    /// `cli::validate` gives every runnable command an example and refuses one on a group,
432    /// so these are exactly the executable's runnable commands.
433    Native,
434    /// The shell tool: a public command of its registry.
435    Shell,
436}
437
438// ---------------------------------------------------------------- the index
439
440/// One subject of the index: what it is, what it is made of and the tests it reaches.
441///
442/// ```
443/// use majordomus_cli::evidence::subject::{self, SubjectEntry, SubjectKind};
444/// use majordomus_cli::synthetic::SyntheticRepository;
445///
446/// let repo = SyntheticRepository::small().unwrap();
447/// let ctx = repo.context().unwrap();
448/// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
449/// let entry: &SubjectEntry = s.get("capability:capabilities.list").unwrap();
450/// assert_eq!(entry.kind, SubjectKind::Capability);
451/// assert_eq!(entry.page.as_deref(), Some("/evidence/capabilities/capabilities-list/"));
452/// assert!(entry.members.contains(&"command:capabilities list".to_string()));
453/// assert!(entry.programs.is_empty(), "only a command names its programs");
454/// ```
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct SubjectEntry {
457    /// What kind of declaration it is.
458    pub kind: SubjectKind,
459    /// Its id within the kind.
460    pub id: String,
461    /// The status its declaration states: a claim, a rule, a feature or a use case.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub status: Option<String>,
464    /// Its page on the site: `/evidence/<section>/<slug>/`.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub page: Option<String>,
467    /// The capability an MCP tool is an alias of.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub alias_of: Option<String>,
470    /// The programs a command word belongs to, in declaration order; empty for every other
471    /// kind.
472    pub programs: Vec<Program>,
473    /// The subjects it is made of, by key, unique, in byte order.
474    pub members: Vec<String>,
475    /// Its own routes, in (via, test, path) order.
476    pub routes: Vec<Route>,
477    /// A rule's named paths that no runner drives.
478    pub mechanisms: Vec<String>,
479    /// Its structural findings.
480    pub findings: Vec<SubjectFinding>,
481}
482
483/// Every subject of a repository, and every test nothing reaches.
484///
485/// ```
486/// use majordomus_cli::evidence::subject::{self, SubjectIndex};
487/// use majordomus_cli::synthetic::SyntheticRepository;
488///
489/// let repo = SyntheticRepository::small().unwrap();
490/// let ctx = repo.context().unwrap();
491/// let s: SubjectIndex = subject::index(&ctx.index, &ctx.product, &ctx.registry);
492/// assert!(s.subjects.keys().any(|k| k.starts_with("capability:")));
493/// assert!(s.excluded.is_empty(), "a synthetic tree carries no tests");
494/// ```
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct SubjectIndex {
497    /// Every subject, by key.
498    pub subjects: BTreeMap<String, SubjectEntry>,
499    /// Every test a runner owns in this tree that no route reaches, as test ids, byte order.
500    pub excluded: Vec<String>,
501}
502
503/// One page the site renders for a subject.
504///
505/// ```
506/// use majordomus_cli::evidence::subject::{self, Page, SubjectKind};
507/// use majordomus_cli::synthetic::SyntheticRepository;
508///
509/// let repo = SyntheticRepository::small().unwrap();
510/// let ctx = repo.context().unwrap();
511/// let pages: Vec<Page> = subject::index(&ctx.index, &ctx.product, &ctx.registry).pages();
512/// assert!(pages.iter().all(|p| p.path.starts_with("/evidence/")));
513/// assert!(pages.iter().all(|p| p.kind != SubjectKind::Mcp), "an alias has no page");
514/// ```
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct Page {
517    /// The subject's kind.
518    pub kind: SubjectKind,
519    /// The subject's key.
520    pub subject: String,
521    /// The page's path on the site.
522    pub path: String,
523}
524
525/// A key that names no subject of this repository.
526///
527/// ```
528/// use majordomus_cli::evidence::subject::{self, UnknownSubject};
529/// use majordomus_cli::synthetic::SyntheticRepository;
530///
531/// let repo = SyntheticRepository::small().unwrap();
532/// let ctx = repo.context().unwrap();
533/// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
534/// let refused: UnknownSubject = s.get("feature:no-such").unwrap_err();
535/// assert_eq!(refused.key, "feature:no-such");
536/// assert!(refused.to_string().contains("`feature:no-such` is not a subject"));
537/// ```
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct UnknownSubject {
540    /// The key that was asked for.
541    pub key: String,
542}
543
544impl fmt::Display for UnknownSubject {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        write!(
547            f,
548            "`{}` is not a subject of this repository: a subject is <kind>:<id> for a declared \
549             claim, rule, feature, command, capability, MCP tool or use case",
550            self.key
551        )
552    }
553}
554
555impl std::error::Error for UnknownSubject {}
556
557impl SubjectIndex {
558    /// The subject a key names, or the refusal that names the key.
559    ///
560    /// ```
561    /// use majordomus_cli::evidence::subject;
562    /// use majordomus_cli::synthetic::SyntheticRepository;
563    ///
564    /// let repo = SyntheticRepository::small().unwrap();
565    /// let ctx = repo.context().unwrap();
566    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
567    /// assert!(s.get("command:capabilities list").is_ok());
568    /// assert!(s.get("gate:x").is_err());
569    /// ```
570    pub fn get(&self, key: &str) -> Result<&SubjectEntry, UnknownSubject> {
571        self.subjects.get(key).ok_or_else(|| UnknownSubject {
572            key: key.to_string(),
573        })
574    }
575
576    /// Every page the site renders, ordered by path. An MCP alias has no page of its own.
577    ///
578    /// ```
579    /// use majordomus_cli::evidence::subject;
580    /// use majordomus_cli::synthetic::SyntheticRepository;
581    ///
582    /// let repo = SyntheticRepository::small().unwrap();
583    /// let ctx = repo.context().unwrap();
584    /// let pages = subject::index(&ctx.index, &ctx.product, &ctx.registry).pages();
585    /// let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();
586    /// assert!(paths.windows(2).all(|w| w[0] < w[1]), "ordered by path, each once");
587    /// ```
588    pub fn pages(&self) -> Vec<Page> {
589        let mut by_path: BTreeMap<String, Page> = BTreeMap::new();
590        for (key, e) in &self.subjects {
591            if let Some(path) = &e.page {
592                by_path.entry(path.clone()).or_insert_with(|| Page {
593                    kind: e.kind,
594                    subject: key.clone(),
595                    path: path.clone(),
596                });
597            }
598        }
599        by_path.into_values().collect()
600    }
601
602    /// Every page path more than one subject claims, with those subjects' keys. Empty on a
603    /// sound index; [`artifact`] refuses to write one that is not.
604    ///
605    /// ```
606    /// use majordomus_cli::evidence::subject;
607    /// use majordomus_cli::synthetic::SyntheticRepository;
608    ///
609    /// let repo = SyntheticRepository::small().unwrap();
610    /// let ctx = repo.context().unwrap();
611    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
612    /// assert!(s.duplicate_pages().is_empty());
613    /// ```
614    pub fn duplicate_pages(&self) -> Vec<(String, Vec<String>)> {
615        let mut by_path: BTreeMap<&str, Vec<String>> = BTreeMap::new();
616        for (key, e) in &self.subjects {
617            if let Some(path) = &e.page {
618                by_path.entry(path.as_str()).or_default().push(key.clone());
619            }
620        }
621        by_path
622            .into_iter()
623            .filter(|(_, keys)| keys.len() > 1)
624            .map(|(path, keys)| (path.to_string(), keys))
625            .collect()
626    }
627
628    /// Every route a subject reaches: its own routes, then each member's, depth first in
629    /// member order, each subject walked once and each route listed once.
630    ///
631    /// This is the one walk of the member graph; the structural finding of a feature is
632    /// read off it, and a surface that needs what a subject reaches calls it rather than
633    /// walking the members again.
634    ///
635    /// ```
636    /// use majordomus_cli::evidence::subject::{self, Via};
637    /// use majordomus_cli::synthetic::SyntheticRepository;
638    ///
639    /// let repo = SyntheticRepository::small().unwrap();
640    /// let ctx = repo.context().unwrap();
641    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
642    /// // an alias reaches what its capability reaches, and nothing the tree does not carry
643    /// let reached = s.reach("mcp:majordomus_capabilities").unwrap();
644    /// assert!(reached.iter().all(|r| r.via != Via::Scenario));
645    /// assert!(s.reach("feature:no-such").is_err());
646    /// ```
647    pub fn reach(&self, key: &str) -> Result<Vec<&Route>, UnknownSubject> {
648        let (key, entry) = self
649            .subjects
650            .get_key_value(key)
651            .ok_or_else(|| UnknownSubject {
652                key: key.to_string(),
653            })?;
654        let mut walked: BTreeSet<&str> = BTreeSet::new();
655        let mut listed: BTreeSet<&Route> = BTreeSet::new();
656        let mut out = Vec::new();
657        self.walk(key, entry, &mut walked, &mut listed, &mut out);
658        Ok(out)
659    }
660
661    /// One step of [`SubjectIndex::reach`]. A member is always a key of the index, because
662    /// `derive` adds only members that exist; one that did not would be passed over.
663    fn walk<'s>(
664        &'s self,
665        key: &'s str,
666        entry: &'s SubjectEntry,
667        walked: &mut BTreeSet<&'s str>,
668        listed: &mut BTreeSet<&'s Route>,
669        out: &mut Vec<&'s Route>,
670    ) {
671        if !walked.insert(key) {
672            return;
673        }
674        for route in &entry.routes {
675            if listed.insert(route) {
676                out.push(route);
677            }
678        }
679        for (member, e) in entry
680            .members
681            .iter()
682            .filter_map(|m| self.subjects.get_key_value(m))
683        {
684            self.walk(member, e, walked, listed, out);
685        }
686    }
687
688    /// The committed index as a document: the header, the kinds, the categories, the pages,
689    /// every subject by key and every test nothing reaches, in that order. Structure only:
690    /// no commit, no ledger row and no time.
691    ///
692    /// ```
693    /// use majordomus_cli::evidence::subject::{self, SCHEMA};
694    /// use majordomus_cli::synthetic::SyntheticRepository;
695    ///
696    /// let repo = SyntheticRepository::small().unwrap();
697    /// let ctx = repo.context().unwrap();
698    /// let doc = subject::index(&ctx.index, &ctx.product, &ctx.registry).document("0.0.0");
699    /// assert_eq!(doc["schema"], SCHEMA);
700    /// assert_eq!(doc["generator"]["version"], "0.0.0");
701    /// let members: Vec<&String> = doc.as_object().unwrap().keys().collect();
702    /// assert_eq!(members.first().map(|k| k.as_str()), Some("schema"));
703    /// assert_eq!(members.last().map(|k| k.as_str()), Some("excluded"));
704    /// ```
705    pub fn document(&self, version: &str) -> Value {
706        let kinds: Vec<Value> = SubjectKind::ALL
707            .into_iter()
708            .map(|k| match k.section() {
709                Some(section) => json!({ "kind": k, "section": section }),
710                None => json!({ "kind": k }),
711            })
712            .collect();
713        json!({
714            "schema": SCHEMA,
715            "generated": crate::generate::json_banner(SOURCE),
716            "generator": { "id": "majordomus-cli", "version": version },
717            "route": ROUTE,
718            "kinds": kinds,
719            "categories": Category::ALL,
720            "pages": self.pages(),
721            "subjects": self.subjects,
722            "excluded": self.excluded,
723        })
724    }
725}
726
727// ---------------------------------------------------------------- the declarations
728
729/// What the index is derived from: every declaration, as its owner reads it, and the tests
730/// on disk. `derive` is a pure function of this.
731#[derive(Debug, Clone, Default)]
732pub(crate) struct Declarations {
733    pub(crate) claims: Vec<ClaimDecl>,
734    pub(crate) rules: Vec<RuleDecl>,
735    pub(crate) features: Vec<FeatureDecl>,
736    pub(crate) use_cases: Vec<UseCaseDecl>,
737    /// Public shell commands.
738    pub(crate) commands: Vec<String>,
739    /// Native command paths with documented examples, non-empty.
740    pub(crate) examples: Vec<String>,
741    pub(crate) capabilities: Vec<CapabilityDecl>,
742    /// `test/cases/*.sh` on disk.
743    pub(crate) cases: Vec<CaseDecl>,
744    /// `apps/majordomus-cli/tests/*.rs` on disk, as `crate:<stem>`.
745    pub(crate) crate_tests: Vec<String>,
746}
747
748/// A claim, with structure only: what the evidence join reads, never a state.
749#[derive(Debug, Clone, Default)]
750pub(crate) struct ClaimDecl {
751    pub(crate) id: String,
752    pub(crate) status: String,
753    pub(crate) source: Option<String>,
754    pub(crate) implementation: Option<String>,
755    pub(crate) test_path: Option<String>,
756    pub(crate) test: Option<String>,
757}
758
759/// A rule: its id without the version, its status, its file and every path it names.
760#[derive(Debug, Clone, Default)]
761pub(crate) struct RuleDecl {
762    pub(crate) id: String,
763    pub(crate) status: String,
764    pub(crate) path: String,
765    pub(crate) tests: Vec<String>,
766}
767
768/// A feature and the four references a subject is made of.
769#[derive(Debug, Clone, Default)]
770pub(crate) struct FeatureDecl {
771    pub(crate) id: String,
772    pub(crate) status: String,
773    pub(crate) claims: Vec<String>,
774    pub(crate) rules: Vec<String>,
775    pub(crate) commands: Vec<String>,
776    pub(crate) use_cases: Vec<String>,
777}
778
779/// A use case and the four references `graph.rs` draws edges from.
780#[derive(Debug, Clone, Default)]
781pub(crate) struct UseCaseDecl {
782    pub(crate) id: String,
783    pub(crate) status: Option<String>,
784    pub(crate) path: String,
785    pub(crate) commands: Vec<String>,
786    pub(crate) doctrines: Vec<String>,
787    pub(crate) claims: Vec<String>,
788    pub(crate) mcp_tools: Vec<String>,
789}
790
791/// A builtin capability: its id, its command-line path, its MCP tool and the file its
792/// module is composed in.
793#[derive(Debug, Clone, Default)]
794pub(crate) struct CapabilityDecl {
795    pub(crate) id: String,
796    pub(crate) cli: Option<String>,
797    pub(crate) mcp_tool: Option<String>,
798    pub(crate) source: String,
799}
800
801/// A case of the suite: its identity, its path and the words of its first covers and
802/// first negative header.
803#[derive(Debug, Clone, Default)]
804pub(crate) struct CaseDecl {
805    pub(crate) test: String,
806    pub(crate) path: String,
807    pub(crate) covers: Vec<String>,
808    pub(crate) negative: Vec<String>,
809}
810
811/// Every subject of a repository, derived from what it declares.
812///
813/// ```
814/// use majordomus_cli::capability::Provenance;
815/// use majordomus_cli::evidence::subject;
816/// use majordomus_cli::synthetic::SyntheticRepository;
817///
818/// let repo = SyntheticRepository::small().unwrap();
819/// let ctx = repo.context().unwrap();
820/// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
821/// // every builtin capability with a tool is reachable by that tool's name
822/// for c in ctx.registry.iter() {
823///     if !matches!(c.provenance, Provenance::Builtin { .. }) {
824///         continue;
825///     }
826///     if let Some(tool) = c.exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()) {
827///         let alias = s.get(&format!("mcp:{tool}")).unwrap();
828///         assert_eq!(alias.alias_of, Some(format!("capability:{}", c.id)));
829///     }
830/// }
831/// ```
832pub fn index(index: &Index, product: &ProductModel, registry: &CapabilityRegistry) -> SubjectIndex {
833    derive(&declarations(index, product, registry))
834}
835
836/// Read every declaration from its owner. Deterministic, and reads no clock and no ledger.
837pub(crate) fn declarations(
838    index: &Index,
839    product: &ProductModel,
840    registry: &CapabilityRegistry,
841) -> Declarations {
842    let root = PathBuf::from(&index.repository.root);
843    // the claims as the evidence join reads them, with structure only: an empty ledger, so
844    // no state of it is read and the join's own test grammar is the one used
845    let claims = crate::evidence::report(index, &Ledger::empty())
846        .claims
847        .into_iter()
848        .map(|c| ClaimDecl {
849            id: c.id,
850            status: c.status,
851            source: c.source,
852            implementation: c.implementation,
853            test_path: c.test_path,
854            test: c.test,
855        })
856        .collect();
857    let rules = crate::rules::definitions(index)
858        .into_iter()
859        .map(|r| RuleDecl {
860            id: r.id,
861            status: r.status,
862            path: r.path,
863            tests: r.enforcement.tests,
864        })
865        .collect();
866    let features = product
867        .all()
868        .iter()
869        .map(|r| FeatureDecl {
870            id: r.feature.id.clone(),
871            status: r.feature.status.clone(),
872            claims: r.feature.claims.clone(),
873            rules: r.feature.rules.clone(),
874            commands: r.feature.commands.clone(),
875            use_cases: r.feature.use_cases.clone(),
876        })
877        .collect();
878    let strings = |o: &crate::Object, key: &str| crate::graph::metadata_strings(&o.metadata, key);
879    let use_cases = index
880        .objects
881        .iter()
882        .filter(|o| o.kind == "use-case")
883        .map(|o| UseCaseDecl {
884            id: o.identity.clone(),
885            status: o
886                .metadata
887                .get("status")
888                .and_then(Value::as_str)
889                .map(str::to_string),
890            path: o.provenance.path.clone(),
891            commands: strings(o, "commands"),
892            doctrines: strings(o, "doctrines"),
893            claims: strings(o, "claims"),
894            mcp_tools: strings(o, "mcp_tools"),
895        })
896        .collect();
897    // the set the product model reads: a public command only, since an internal one is
898    // dispatched and absent from the help text
899    let commands = index
900        .objects
901        .iter()
902        .filter(|o| o.kind == "command")
903        .filter(|o| o.metadata.get("visibility").and_then(Value::as_str) == Some("public"))
904        .map(|o| o.identity.clone())
905        .collect();
906    let examples = crate::cli::EXAMPLES
907        .iter()
908        .map(|e| e.command)
909        .filter(|c| !c.is_empty())
910        .map(str::to_string)
911        .collect();
912    let capabilities = registry
913        .iter()
914        .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
915        .map(|c| CapabilityDecl {
916            id: c.id.as_str().to_string(),
917            cli: c.exposure.cli.as_ref().map(|x| x.path.join(" ")),
918            mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
919            source: c.provenance.source_path(),
920        })
921        .collect();
922    Declarations {
923        claims,
924        rules,
925        features,
926        use_cases,
927        commands,
928        examples,
929        capabilities,
930        cases: read_cases(&root),
931        crate_tests: read_crate_tests(&root),
932    }
933}
934
935/// The words of the first line of `text` that starts with `header`, `none` dropped. The
936/// first line decides, as the command pages read it; a later line of the same kind names
937/// nothing.
938fn header_words(text: &str, header: &str) -> Vec<String> {
939    text.lines()
940        .find_map(|line| line.strip_prefix(header))
941        .map(|rest| {
942            rest.split_whitespace()
943                .filter(|w| *w != "none")
944                .map(str::to_string)
945                .collect()
946        })
947        .unwrap_or_default()
948}
949
950/// The regular files of `root/dir` whose repository path `TestId::of` accepts, with that
951/// path. A directory that is not there holds none.
952fn test_files(root: &Path, dir: &str) -> Vec<(String, TestId, PathBuf)> {
953    let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
954        return Vec::new();
955    };
956    entries
957        .flatten()
958        .filter(|e| e.path().is_file())
959        .filter_map(|e| {
960            // read lossily rather than dropped: the suite's own glob runs a file whatever
961            // bytes its name holds, so a test the runner sees is never one the index hides
962            let name = e.file_name().to_string_lossy().into_owned();
963            let path = format!("{dir}/{name}");
964            let test = TestId::of(&path)?;
965            Some((path, test, e.path()))
966        })
967        .collect()
968}
969
970/// Every case of the suite on disk, with its first covers and first negative header. An
971/// unreadable file declares no words.
972fn read_cases(root: &Path) -> Vec<CaseDecl> {
973    test_files(root, CASES_DIR)
974        .into_iter()
975        .map(|(path, test, file)| {
976            let text = std::fs::read(&file)
977                .map(|b| String::from_utf8_lossy(&b).into_owned())
978                .unwrap_or_default();
979            CaseDecl {
980                test: test.as_string(),
981                path,
982                covers: header_words(&text, COVERS),
983                negative: header_words(&text, NEGATIVE),
984            }
985        })
986        .collect()
987}
988
989/// Every integration test binary of the crate on disk, as `crate:<stem>`.
990fn read_crate_tests(root: &Path) -> Vec<String> {
991    test_files(root, CRATE_TESTS_DIR)
992        .into_iter()
993        .map(|(_, test, _)| test.as_string())
994        .collect()
995}
996
997// ---------------------------------------------------------------- the derivation
998
999/// The site's route segment for an id: every `.`, `_` and space becomes `-`, as Zola
1000/// slugifies a path (`scripts/lib/executable-site.jq`'s `slug`, plus the space of a command
1001/// path).
1002fn slug(id: &str) -> String {
1003    id.chars()
1004        .map(|c| if matches!(c, '.' | '_' | ' ') { '-' } else { c })
1005        .collect()
1006}
1007
1008/// The page of a subject of a kind that has one.
1009fn page_of(kind: SubjectKind, id: &str) -> Option<String> {
1010    kind.section()
1011        .map(|section| format!("{ROUTE}{section}/{}/", slug(id)))
1012}
1013
1014/// A subject while it is being derived: its kind and id as it was opened with, and every
1015/// collection a set, so that the result does not depend on the order the declarations came
1016/// in.
1017struct Draft {
1018    kind: SubjectKind,
1019    id: String,
1020    status: Option<String>,
1021    alias_of: Option<String>,
1022    programs: BTreeSet<Program>,
1023    members: BTreeSet<String>,
1024    routes: BTreeSet<Route>,
1025    mechanisms: BTreeSet<String>,
1026}
1027
1028/// The draft of the subject of this kind with this id, opened empty the first time it is
1029/// asked for. Every draft is opened here, so its key, kind and id always agree.
1030fn draft_of<'d>(
1031    drafts: &'d mut BTreeMap<String, Draft>,
1032    kind: SubjectKind,
1033    id: &str,
1034) -> &'d mut Draft {
1035    drafts.entry(kind.key(id)).or_insert_with(|| Draft {
1036        kind,
1037        id: id.to_string(),
1038        status: None,
1039        alias_of: None,
1040        programs: BTreeSet::new(),
1041        members: BTreeSet::new(),
1042        routes: BTreeSet::new(),
1043        mechanisms: BTreeSet::new(),
1044    })
1045}
1046
1047impl Draft {
1048    /// Two declarations of one subject keep the smaller status, whichever came first.
1049    fn status(&mut self, status: &str) {
1050        let keep = match self.status.take() {
1051            Some(held) if held.as_str() <= status => held,
1052            _ => status.to_string(),
1053        };
1054        self.status = Some(keep);
1055    }
1056}
1057
1058/// Derive the index. Pure: no I/O, and the result does not depend on the order of any
1059/// input vector.
1060pub(crate) fn derive(d: &Declarations) -> SubjectIndex {
1061    let mut drafts: BTreeMap<String, Draft> = BTreeMap::new();
1062
1063    // ---- leaves: claims, rules, commands
1064    for c in &d.claims {
1065        let inputs: Vec<String> = [
1066            c.source.clone(),
1067            c.implementation.clone(),
1068            c.test_path
1069                .as_deref()
1070                .and_then(TestId::of)
1071                .map(|t| t.source()),
1072        ]
1073        .into_iter()
1074        .flatten()
1075        .collect();
1076        let draft = draft_of(&mut drafts, SubjectKind::Claim, &c.id);
1077        draft.status(&c.status);
1078        draft.routes.insert(Route {
1079            via: Via::Claim,
1080            test: c.test.clone(),
1081            path: c.test_path.clone(),
1082            category: c.test.as_deref().and_then(Category::of),
1083            inputs: Some(inputs),
1084        });
1085    }
1086    for r in &d.rules {
1087        let draft = draft_of(&mut drafts, SubjectKind::Rule, &r.id);
1088        draft.status(&r.status);
1089        for p in &r.tests {
1090            match TestId::of(p) {
1091                Some(t) => {
1092                    let test = t.as_string();
1093                    draft.routes.insert(Route {
1094                        via: Via::Rule,
1095                        category: Category::of(&test),
1096                        test: Some(test),
1097                        path: Some(p.clone()),
1098                        inputs: Some(vec![t.source(), r.path.clone()]),
1099                    });
1100                }
1101                None => {
1102                    draft.mechanisms.insert(p.clone());
1103                }
1104            }
1105        }
1106    }
1107
1108    let shell: BTreeSet<&str> = d.commands.iter().map(String::as_str).collect();
1109    let examples: BTreeSet<&str> = d.examples.iter().map(String::as_str).collect();
1110    let native: BTreeSet<&str> = examples
1111        .iter()
1112        .copied()
1113        .chain(d.capabilities.iter().filter_map(|c| c.cli.as_deref()))
1114        .collect();
1115    // the example route exists only where the binary that runs the examples does: a route
1116    // to a test the repository does not carry would read `not_run` forever
1117    let example_route = TestId::of(CLI_EXAMPLES)
1118        .filter(|t| d.crate_tests.contains(&t.as_string()))
1119        .map(|t| {
1120            let test = t.as_string();
1121            Route {
1122                via: Via::Example,
1123                category: Category::of(&test),
1124                test: Some(test),
1125                path: Some(t.source()),
1126                inputs: None,
1127            }
1128        });
1129    for w in shell.union(&native) {
1130        let draft = draft_of(&mut drafts, SubjectKind::Command, w);
1131        if native.contains(w) {
1132            draft.programs.insert(Program::Native);
1133        }
1134        if shell.contains(w) {
1135            draft.programs.insert(Program::Shell);
1136        }
1137        if let Some(route) = example_route.as_ref().filter(|_| examples.contains(w)) {
1138            draft.routes.insert(route.clone());
1139        }
1140    }
1141
1142    // ---- capabilities and their aliases
1143    for c in &d.capabilities {
1144        let key = SubjectKind::Capability.key(&c.id);
1145        let draft = draft_of(&mut drafts, SubjectKind::Capability, &c.id);
1146        if let Some(cli) = &c.cli {
1147            draft.members.insert(SubjectKind::Command.key(cli));
1148        }
1149        // a claim implemented in the file the capability's module is composed in belongs to
1150        // every capability of that module, and a claim implemented anywhere else to none
1151        for claim in &d.claims {
1152            if claim.implementation.as_deref() == Some(c.source.as_str()) {
1153                draft.members.insert(SubjectKind::Claim.key(&claim.id));
1154            }
1155        }
1156        if let Some(tool) = &c.mcp_tool {
1157            let alias = draft_of(&mut drafts, SubjectKind::Mcp, tool);
1158            alias.members.insert(key.clone());
1159            alias.alias_of = match alias.alias_of.take() {
1160                Some(held) if held <= key => Some(held),
1161                _ => Some(key),
1162            };
1163        }
1164    }
1165
1166    // ---- the case headers: a command word routes to its command, a `capability:<id>` word
1167    // to that capability, and every other prefixed word to nothing
1168    for case in &d.cases {
1169        for (via, words) in [
1170            (Via::Behaviour, &case.covers),
1171            (Via::Negative, &case.negative),
1172        ] {
1173            for w in words {
1174                let key = if w.contains(':') {
1175                    match SubjectKind::parse(w) {
1176                        Some((SubjectKind::Capability, _)) => w.clone(),
1177                        _ => continue,
1178                    }
1179                } else {
1180                    SubjectKind::Command.key(w)
1181                };
1182                if let Some(draft) = drafts.get_mut(&key) {
1183                    draft.routes.insert(Route {
1184                        via,
1185                        test: Some(case.test.clone()),
1186                        path: Some(case.path.clone()),
1187                        category: Category::of(&case.test),
1188                        inputs: None,
1189                    });
1190                }
1191            }
1192        }
1193    }
1194
1195    // ---- composites: use cases, then features. A member is a key that exists; a
1196    // reference that resolves to nothing is the product validation's to report.
1197    let exists = |drafts: &BTreeMap<String, Draft>, kind: SubjectKind, id: &str| {
1198        let key = kind.key(id);
1199        drafts.contains_key(&key).then_some(key)
1200    };
1201    for u in &d.use_cases {
1202        let members: BTreeSet<String> = [
1203            (SubjectKind::Command, &u.commands),
1204            (SubjectKind::Rule, &u.doctrines),
1205            (SubjectKind::Claim, &u.claims),
1206            (SubjectKind::Mcp, &u.mcp_tools),
1207        ]
1208        .into_iter()
1209        .flat_map(|(kind, ids)| ids.iter().map(move |id| (kind, id)))
1210        .filter_map(|(kind, id)| exists(&drafts, kind, id))
1211        .collect();
1212        let draft = draft_of(&mut drafts, SubjectKind::UseCase, &u.id);
1213        if let Some(status) = &u.status {
1214            draft.status(status);
1215        }
1216        draft.members.extend(members);
1217        draft.routes.insert(Route {
1218            via: Via::Scenario,
1219            test: None,
1220            path: Some(u.path.clone()),
1221            category: Some(Category::Scenario),
1222            inputs: None,
1223        });
1224    }
1225    for f in &d.features {
1226        let members: BTreeSet<String> = [
1227            (SubjectKind::Claim, &f.claims),
1228            (SubjectKind::Rule, &f.rules),
1229            (SubjectKind::Command, &f.commands),
1230            (SubjectKind::UseCase, &f.use_cases),
1231        ]
1232        .into_iter()
1233        .flat_map(|(kind, ids)| ids.iter().map(move |id| (kind, id)))
1234        .filter_map(|(kind, id)| exists(&drafts, kind, id))
1235        .collect();
1236        let draft = draft_of(&mut drafts, SubjectKind::Feature, &f.id);
1237        draft.status(&f.status);
1238        draft.members.extend(members);
1239    }
1240
1241    // ---- the entries
1242    let mut subjects: BTreeMap<String, SubjectEntry> = BTreeMap::new();
1243    for (key, draft) in drafts {
1244        let mut findings = Vec::new();
1245        if draft.programs.len() > 1 {
1246            findings.push(SubjectFinding {
1247                code: COMMAND_IN_TWO_PROGRAMS.to_string(),
1248                severity: Severity::Warning,
1249                subject: key.clone(),
1250                message: format!(
1251                    "the word `{}` is a public command of the shell tool and a command path \
1252                     of the executable: its behaviour and negative routes come from the shell \
1253                     tool's case headers, its example route from the executable's documented \
1254                     examples, and one page shows both",
1255                    draft.id
1256                ),
1257            });
1258        }
1259        let page = page_of(draft.kind, &draft.id);
1260        let entry = SubjectEntry {
1261            kind: draft.kind,
1262            id: draft.id,
1263            status: draft.status,
1264            page,
1265            alias_of: draft.alias_of,
1266            programs: draft.programs.into_iter().collect(),
1267            members: draft.members.into_iter().collect(),
1268            routes: draft.routes.into_iter().collect(),
1269            mechanisms: draft.mechanisms.into_iter().collect(),
1270            findings,
1271        };
1272        subjects.insert(key, entry);
1273    }
1274
1275    // ---- every test a runner owns that no route reaches
1276    let reached: BTreeSet<&str> = subjects
1277        .values()
1278        .flat_map(|e| e.routes.iter().filter_map(|r| r.test.as_deref()))
1279        .collect();
1280    let excluded: Vec<String> = d
1281        .cases
1282        .iter()
1283        .map(|c| c.test.as_str())
1284        .chain(d.crate_tests.iter().map(String::as_str))
1285        .filter(|t| !reached.contains(t))
1286        .map(str::to_string)
1287        .collect::<BTreeSet<_>>()
1288        .into_iter()
1289        .collect();
1290
1291    let mut index = SubjectIndex { subjects, excluded };
1292
1293    // ---- the structural finding, read off the one walk
1294    let without_evidence: BTreeSet<String> = index
1295        .subjects
1296        .iter()
1297        .filter(|(_, e)| e.kind == SubjectKind::Feature)
1298        .filter(|(key, _)| {
1299            index
1300                .reach(key)
1301                .map(|routes| routes.iter().all(|r| r.test.is_none()))
1302                .unwrap_or(false)
1303        })
1304        .map(|(key, _)| key.clone())
1305        .collect();
1306    for (key, e) in index
1307        .subjects
1308        .iter_mut()
1309        .filter(|(key, _)| without_evidence.contains(key.as_str()))
1310    {
1311        let severity = if e.status.as_deref() == Some(crate::product::STABLE) {
1312            Severity::Error
1313        } else {
1314            Severity::Warning
1315        };
1316        e.findings.push(SubjectFinding {
1317            code: FEATURE_WITHOUT_EVIDENCE.to_string(),
1318            severity,
1319            subject: key.clone(),
1320            message: format!(
1321                "the feature `{}` reaches no test a runner drives through its claims, rules, \
1322                 commands or use cases",
1323                e.id
1324            ),
1325        });
1326    }
1327    index
1328}
1329
1330// ---------------------------------------------------------------- the artifact
1331
1332/// The committed index, as `majordomus generate site` writes it to [`PATH`].
1333///
1334/// Refuses rather than writes when two subjects claim one page, and when the rendered
1335/// document carries anything `generate::forbidden_in` keeps out of published text.
1336///
1337/// ```
1338/// use majordomus_cli::evidence::subject::{self, DOCUMENT, PATH, SCHEMA};
1339/// use majordomus_cli::synthetic::SyntheticRepository;
1340///
1341/// let repo = SyntheticRepository::small().unwrap();
1342/// let ctx = repo.context().unwrap();
1343/// let a = subject::artifact(&ctx, "0.0.0").unwrap();
1344/// assert_eq!(a.path, PATH);
1345/// assert_eq!(a.document, DOCUMENT);
1346/// assert_eq!(a.schema.as_deref(), Some(SCHEMA));
1347/// assert!(a.content.ends_with("}\n"));
1348/// ```
1349pub fn artifact(ctx: &Context, version: &str) -> crate::error::Result<crate::generate::Artifact> {
1350    rendered(&index(&ctx.index, &ctx.product, &ctx.registry), version)
1351}
1352
1353/// Steps two to five of [`artifact`], over an index already derived.
1354fn rendered(s: &SubjectIndex, version: &str) -> crate::error::Result<crate::generate::Artifact> {
1355    let duplicates = s.duplicate_pages();
1356    if !duplicates.is_empty() {
1357        let named: Vec<String> = duplicates
1358            .iter()
1359            .map(|(path, keys)| format!("{path} ({})", keys.join(", ")))
1360            .collect();
1361        return Err(crate::error::Error::Protocol {
1362            reason: format!(
1363                "two subjects claim one evidence page, so neither page would say which it is \
1364                 about: {}",
1365                named.join("; ")
1366            ),
1367        });
1368    }
1369    let content = crate::http::openapi::render(&s.document(version));
1370    if let Some((marker, what)) = crate::generate::forbidden_in(&content) {
1371        return Err(crate::error::Error::Protocol {
1372            reason: format!(
1373                "{PATH} would publish {what} (it contains `{marker}`); nothing was written"
1374            ),
1375        });
1376    }
1377    Ok(crate::generate::Artifact::verbatim(
1378        PATH,
1379        DOCUMENT,
1380        crate::generate::ArtifactFormat::Json,
1381        Some(SCHEMA.to_string()),
1382        SOURCE,
1383        content,
1384    ))
1385}
1386
1387// ---------------------------------------------------------------- the judgement
1388
1389/// A subject's verdict over its parts: the one aggregation
1390/// ([`crate::evidence::freshness::aggregate`]) over the declared order, and whether any part
1391/// can carry proof. A failing part makes the subject failing; otherwise the weakest part
1392/// that can carry proof decides; otherwise the weakest of all; and no part reads `no_test`.
1393///
1394/// ```
1395/// use majordomus_cli::evidence::subject::verdict;
1396/// use majordomus_cli::evidence::ProofState::{Failing, NoTest, NotRun, Proven};
1397///
1398/// assert_eq!(verdict(&[(Failing, true), (NotRun, true)]), (Failing, true));
1399/// assert_eq!(verdict(&[(Proven, true), (NotRun, false)]), (Proven, true));
1400/// assert_eq!(verdict(&[(NotRun, false)]), (NotRun, false));
1401/// assert_eq!(verdict(&[]), (NoTest, false));
1402/// ```
1403pub fn verdict(parts: &[(ProofState, bool)]) -> (ProofState, bool) {
1404    match aggregate(parts, ProofState::Failing) {
1405        None => (ProofState::NoTest, false),
1406        Some(s) => (s, parts.iter().any(|(_, carries)| *carries)),
1407    }
1408}
1409
1410/// Why a scenario route reads `not_run`, and why it never decides.
1411const SCENARIO_DETAIL: &str = "a use case's scenario run is not recorded in the ledger; its \
1412                               catalogue evidence is supplementary and never decides a verdict";
1413
1414/// Why a proven rule test with no execution reads `stale`.
1415const NO_EXECUTION_DETAIL: &str = "the rules report named no execution for a proven test";
1416
1417/// What the repository can say about one subject at the presented revision: its verdict,
1418/// the parts it was made from and the totals beside it.
1419///
1420/// ```
1421/// use majordomus_cli::evidence::subject::SubjectEvidence;
1422///
1423/// let answer: SubjectEvidence = serde_json::from_value(serde_json::json!({
1424///     "subject": "claim:x", "kind": "claim", "id": "x",
1425///     "presented": { "revision": "working_tree", "tree": "clean" },
1426///     "verdict": "not_run", "meaning": "", "carries_proof": true,
1427///     "totals": { "not run": 1 }, "routes": [], "members": [], "mechanisms": [],
1428///     "subject_findings": []
1429/// }))
1430/// .unwrap();
1431/// assert_eq!(answer.verdict.label(), "not run");
1432/// ```
1433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1434#[schemars(rename = "SubjectEvidence")]
1435pub struct SubjectEvidence {
1436    /// The subject's key.
1437    pub subject: String,
1438    /// Its kind.
1439    pub kind: SubjectKind,
1440    /// Its id within the kind.
1441    pub id: String,
1442    /// Its page on the site.
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub page: Option<String>,
1445    /// The revision it was judged at, as the claim report states it.
1446    pub presented: PresentedRevision,
1447    /// The verdict over its parts.
1448    pub verdict: ProofState,
1449    /// That verdict, in one sentence.
1450    pub meaning: String,
1451    /// Whether any part can carry proof.
1452    pub carries_proof: bool,
1453    /// A rule subject's state, as capped by the presented tree.
1454    #[serde(default, skip_serializing_if = "Option::is_none")]
1455    pub rule_state: Option<RuleState>,
1456    /// The parts in each state, by the state's printed word.
1457    pub totals: BTreeMap<String, usize>,
1458    /// Its own routes, judged.
1459    pub routes: Vec<RouteProof>,
1460    /// Its members, each with its own verdict.
1461    pub members: Vec<MemberProof>,
1462    /// A rule's named paths that no runner drives.
1463    pub mechanisms: Vec<String>,
1464    /// Advisory findings about the subject; never a finding of the evidence report.
1465    pub subject_findings: Vec<SubjectFinding>,
1466}
1467
1468/// One route of a subject, judged at the presented revision.
1469///
1470/// ```
1471/// use majordomus_cli::evidence::subject::{RouteProof, Via};
1472///
1473/// let r: RouteProof = serde_json::from_value(serde_json::json!({
1474///     "via": "behaviour", "test": "suite:07_scope", "path": "test/cases/07_scope.sh",
1475///     "state": "not_run", "carries_proof": true, "changed": []
1476/// }))
1477/// .unwrap();
1478/// assert_eq!(r.route.via, Via::Behaviour);
1479/// ```
1480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1481#[schemars(rename = "EvidenceSubjectRouteProof")]
1482pub struct RouteProof {
1483    /// The route.
1484    #[serde(flatten)]
1485    pub route: Route,
1486    /// Its state.
1487    pub state: ProofState,
1488    /// Whether it can carry proof.
1489    pub carries_proof: bool,
1490    /// The declared inputs that changed since the run, when they made it stale.
1491    pub changed: Vec<String>,
1492    /// Why, when the state alone does not say.
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub detail: Option<String>,
1495    /// The execution behind the state.
1496    #[serde(default, skip_serializing_if = "Option::is_none")]
1497    pub execution: Option<Execution>,
1498    /// The command that runs the test again.
1499    #[serde(default, skip_serializing_if = "Option::is_none")]
1500    pub reproduce: Option<String>,
1501}
1502
1503/// One member of a subject, with the member's own verdict.
1504///
1505/// ```
1506/// use majordomus_cli::evidence::subject::{MemberProof, SubjectKind};
1507///
1508/// let m: MemberProof = serde_json::from_value(serde_json::json!({
1509///     "subject": "rule:project.x", "kind": "rule", "state": "not_run",
1510///     "carries_proof": true, "rule_state": "gated"
1511/// }))
1512/// .unwrap();
1513/// assert_eq!(m.kind, SubjectKind::Rule);
1514/// ```
1515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1516#[schemars(rename = "EvidenceSubjectMember")]
1517pub struct MemberProof {
1518    /// The member's key.
1519    pub subject: String,
1520    /// Its kind.
1521    pub kind: SubjectKind,
1522    /// Its verdict.
1523    pub state: ProofState,
1524    /// Whether it can carry proof.
1525    pub carries_proof: bool,
1526    /// A rule member's state, as capped by the presented tree.
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub rule_state: Option<RuleState>,
1529}
1530
1531/// A route judgement as a surface that reads records beyond the tracked ledger may weaken
1532/// it: called with the test, the execution the judge holds for it and the judge's
1533/// judgement, before any aggregation. The identity leaves every judgement as the tracked
1534/// ledger made it.
1535///
1536/// ```
1537/// use majordomus_cli::evidence::subject::Adjust;
1538///
1539/// let identity: &Adjust<'_> = &|_, _, judgement| judgement;
1540/// let _ = identity;
1541/// ```
1542pub type Adjust<'a> = dyn Fn(&TestId, Option<&Execution>, Judgement) -> Judgement + 'a;
1543
1544/// One subject's verdict, carries flag and capped rule state, as members read it.
1545type Verdict = (ProofState, bool, Option<RuleState>);
1546
1547/// Judges many subjects at one presented revision with one set of comparisons.
1548///
1549/// **Precondition:** `claims` is [`crate::evidence::report_at`]`(index, ledger, presented,
1550/// uncommitted)` and `rules` is [`crate::rules::report`]`(index, ledger)`, for the same
1551/// index, the same ledger and the same `uncommitted`. At a presented commit the ledger is
1552/// [`crate::evidence::freshness::ledger_at`] of it and `uncommitted` is
1553/// [`crate::evidence::freshness::uncommitted`]; for the working tree it is empty.
1554///
1555/// The rules report judges at the working tree, so when the presented tree is not clean a
1556/// rule-derived `proven` is re-judged at the presented revision and reads `inputs
1557/// unchanged`, as every other route does. At a presented commit, every route the judge
1558/// computes passes through the monotone rule over the working ledger's uncommitted run of
1559/// its test, as the claim report applies it, so a subject never reads greener than
1560/// `evidence show --presented`.
1561///
1562/// ```
1563/// use majordomus_cli::evidence::subject::{self, Judge};
1564/// use majordomus_cli::evidence::{report_at, Ledger, Presented};
1565/// use majordomus_cli::synthetic::SyntheticRepository;
1566///
1567/// let repo = SyntheticRepository::small().unwrap();
1568/// let ctx = repo.context().unwrap();
1569/// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
1570/// let ledger = Ledger::empty();
1571/// let claims = report_at(&ctx.index, &ledger, &Presented::WorkingTree, &[]);
1572/// let rules = majordomus_cli::rules::report(&ctx.index, &ledger);
1573/// let presented = Presented::WorkingTree;
1574/// let mut judge = Judge::new(&s, &claims, &rules, &ledger, &[], repo.root(), &presented);
1575/// let key = s.subjects.keys().find(|k| k.starts_with("capability:")).unwrap().clone();
1576/// let answer = judge.subject(&key).unwrap();
1577/// assert_eq!(answer.presented.revision, "working_tree");
1578/// assert!(judge.subject("gate:x").is_err());
1579/// ```
1580pub struct Judge<'a> {
1581    subjects: &'a SubjectIndex,
1582    claims: BTreeMap<&'a str, &'a ClaimProof>,
1583    rules: BTreeMap<&'a str, &'a RuleProof>,
1584    revision: &'a PresentedRevision,
1585    ledger: &'a Ledger,
1586    uncommitted: &'a [Execution],
1587    root: &'a Path,
1588    presented: &'a Presented,
1589    presented_tree: TreeState,
1590    adjust: Option<&'a Adjust<'a>>,
1591    comparisons: BTreeMap<String, Comparison>,
1592    containments: BTreeMap<(String, String), Containment>,
1593    memo: BTreeMap<String, Verdict>,
1594}
1595
1596impl<'a> Judge<'a> {
1597    /// A judge over one subject index and the two reports made for the same ledger.
1598    ///
1599    /// ```
1600    /// use majordomus_cli::evidence::subject::{self, Judge};
1601    /// use majordomus_cli::evidence::{report_at, Ledger, Presented};
1602    /// use majordomus_cli::synthetic::SyntheticRepository;
1603    ///
1604    /// let repo = SyntheticRepository::small().unwrap();
1605    /// let ctx = repo.context().unwrap();
1606    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
1607    /// let ledger = Ledger::empty();
1608    /// let claims = report_at(&ctx.index, &ledger, &Presented::WorkingTree, &[]);
1609    /// let rules = majordomus_cli::rules::report(&ctx.index, &ledger);
1610    /// let p = Presented::WorkingTree;
1611    /// let _judge = Judge::new(&s, &claims, &rules, &ledger, &[], repo.root(), &p);
1612    /// ```
1613    pub fn new(
1614        subjects: &'a SubjectIndex,
1615        claims: &'a EvidenceReport,
1616        rules: &'a RulesReport,
1617        ledger: &'a Ledger,
1618        uncommitted: &'a [Execution],
1619        root: &'a Path,
1620        presented: &'a Presented,
1621    ) -> Self {
1622        Judge {
1623            subjects,
1624            claims: claims.claims.iter().map(|c| (c.id.as_str(), c)).collect(),
1625            rules: rules
1626                .rules
1627                .iter()
1628                .map(|r| (r.rule.id.as_str(), r))
1629                .collect(),
1630            revision: &claims.presented,
1631            ledger,
1632            uncommitted,
1633            root,
1634            presented,
1635            presented_tree: presented.tree(),
1636            adjust: None,
1637            comparisons: BTreeMap::new(),
1638            containments: BTreeMap::new(),
1639            memo: BTreeMap::new(),
1640        }
1641    }
1642
1643    /// Pass every route judgement that has a test identity through `adjust` before any
1644    /// aggregation. The route keeps the weaker of the two states, so an adjuster can weaken
1645    /// a route and never strengthen it. Without it, the judge is the identity adjuster.
1646    ///
1647    /// ```
1648    /// use majordomus_cli::evidence::subject::{self, Adjust, Judge};
1649    /// use majordomus_cli::evidence::{report_at, Ledger, Presented};
1650    /// use majordomus_cli::synthetic::SyntheticRepository;
1651    ///
1652    /// let repo = SyntheticRepository::small().unwrap();
1653    /// let ctx = repo.context().unwrap();
1654    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
1655    /// let ledger = Ledger::empty();
1656    /// let claims = report_at(&ctx.index, &ledger, &Presented::WorkingTree, &[]);
1657    /// let rules = majordomus_cli::rules::report(&ctx.index, &ledger);
1658    /// let p = Presented::WorkingTree;
1659    /// let identity: &Adjust<'_> = &|_, _, j| j;
1660    /// let key = s.subjects.keys().next().unwrap().clone();
1661    /// let plain = Judge::new(&s, &claims, &rules, &ledger, &[], repo.root(), &p).subject(&key);
1662    /// let adjusted = Judge::new(&s, &claims, &rules, &ledger, &[], repo.root(), &p)
1663    ///     .with_adjust(identity)
1664    ///     .subject(&key);
1665    /// assert_eq!(plain.unwrap(), adjusted.unwrap());
1666    /// ```
1667    pub fn with_adjust(mut self, adjust: &'a Adjust<'a>) -> Self {
1668        self.adjust = Some(adjust);
1669        self
1670    }
1671
1672    /// One comparison per evidence commit, shared by every route and subject.
1673    fn comparison(&mut self, commit: &str) -> Comparison {
1674        let (root, presented) = (self.root, self.presented);
1675        self.comparisons
1676            .entry(commit.to_string())
1677            .or_insert_with(|| compare(root, commit, presented))
1678            .clone()
1679    }
1680
1681    /// One containment per pair of commits the monotone rule asks about.
1682    fn contains(&mut self, descendant: &str, ancestor: &str) -> Containment {
1683        let root = self.root;
1684        *self
1685            .containments
1686            .entry((descendant.to_string(), ancestor.to_string()))
1687            .or_insert_with(|| crate::git::contains(root, descendant, ancestor))
1688    }
1689
1690    /// The monotone rule over the working ledger's uncommitted run of `test`, at a presented
1691    /// commit, built exactly as the claim report builds it.
1692    fn uncommitted_weakens(
1693        &mut self,
1694        judgement: Judgement,
1695        test: &str,
1696        execution: Option<&Execution>,
1697    ) -> Judgement {
1698        let (Some(p), Some(e)) = (self.presented.commit(), execution) else {
1699            return judgement;
1700        };
1701        if judgement.state >= ProofState::Stale {
1702            return judgement;
1703        }
1704        let uncommitted = self.uncommitted;
1705        let Some(u) = uncommitted.iter().find(|u| u.test == test) else {
1706            return judgement;
1707        };
1708        let record = Supplementary {
1709            execution: u,
1710            named: UNCOMMITTED_RUN,
1711            after_evidence: self.contains(&u.commit, &e.commit),
1712            in_presented: self.contains(p, &u.commit),
1713        };
1714        weakened_by(judgement, &[record])
1715    }
1716
1717    /// A recorded run judged at the presented revision, as a behaviour route is.
1718    fn at_presented(&mut self, route: &Route, execution: Option<&Execution>) -> Judgement {
1719        let source = route
1720            .path
1721            .as_deref()
1722            .and_then(TestId::of)
1723            .map(|t| t.source())
1724            .unwrap_or_default();
1725        let (recorded, cmp, moved) = match execution {
1726            None => (Recorded::NotRun, None, false),
1727            Some(e) => {
1728                let moved = e.outcome.proves() && e.digest_matches(self.root) == Some(false);
1729                (Recorded::Ran(e), Some(self.comparison(&e.commit)), moved)
1730            }
1731        };
1732        freshness(
1733            recorded,
1734            route.inputs.as_deref(),
1735            &source,
1736            moved,
1737            cmp.as_ref(),
1738            self.presented_tree,
1739        )
1740    }
1741
1742    /// One route of `entry`, judged, and whether the working ledger or the adjuster moved it
1743    /// away from the state its own row gave.
1744    fn route(&mut self, entry: &SubjectEntry, route: &Route) -> (RouteProof, bool) {
1745        let test = route.test.as_deref();
1746        let mut carries = true;
1747        let mut reproduce = test.and(route.path.as_deref()).and_then(TestId::of);
1748        let (judgement, execution) = match route.via {
1749            Via::Claim => match self.claims.get(entry.id.as_str()) {
1750                Some(c) => {
1751                    carries = !matches!(c.status.as_str(), "planned" | "rejected");
1752                    let j = Judgement {
1753                        state: c.state,
1754                        changed: c.changed.clone(),
1755                        detail: c.detail.clone(),
1756                    };
1757                    (j, c.execution.clone())
1758                }
1759                None => {
1760                    carries = !matches!(entry.status.as_deref(), Some("planned" | "rejected"));
1761                    (missing("claim"), None)
1762                }
1763            },
1764            Via::Rule => {
1765                let proof = self.rules.get(entry.id.as_str()).and_then(|r| {
1766                    r.tests
1767                        .iter()
1768                        .find(|t| Some(t.path.as_str()) == route.path.as_deref())
1769                });
1770                match proof {
1771                    None => (missing("rule"), None),
1772                    Some(t) => {
1773                        let e = t.execution.clone();
1774                        let j = if self.presented_tree != TreeState::Clean
1775                            && t.state == ProofState::Proven
1776                        {
1777                            // the presented-tree cap: the rules report judged a clean
1778                            // working tree, and the presented tree is weaker
1779                            match &e {
1780                                Some(e) => self.at_presented(route, Some(e)),
1781                                None => Judgement {
1782                                    state: ProofState::Stale,
1783                                    changed: Vec::new(),
1784                                    detail: Some(NO_EXECUTION_DETAIL.to_string()),
1785                                },
1786                            }
1787                        } else {
1788                            Judgement {
1789                                state: t.state,
1790                                changed: Vec::new(),
1791                                detail: None,
1792                            }
1793                        };
1794                        (j, e)
1795                    }
1796                }
1797            }
1798            Via::Behaviour | Via::Negative | Via::Example => {
1799                let e = test.and_then(|t| self.ledger.latest(t)).cloned();
1800                (self.at_presented(route, e.as_ref()), e)
1801            }
1802            Via::Scenario => {
1803                carries = false;
1804                reproduce = None;
1805                let mut j = freshness(
1806                    Recorded::NotRun,
1807                    None,
1808                    route.path.as_deref().unwrap_or_default(),
1809                    false,
1810                    None,
1811                    self.presented_tree,
1812                );
1813                j.detail = Some(SCENARIO_DETAIL.to_string());
1814                (j, None)
1815            }
1816        };
1817        let own = judgement.state;
1818        let judgement = match (route.via, test) {
1819            (Via::Claim | Via::Scenario, _) | (_, None) => judgement,
1820            (_, Some(t)) => self.uncommitted_weakens(judgement, t, execution.as_ref()),
1821        };
1822        let judgement = self.adjusted(route, execution.as_ref(), judgement);
1823        let proof = RouteProof {
1824            route: route.clone(),
1825            state: judgement.state,
1826            carries_proof: carries,
1827            changed: judgement.changed,
1828            detail: judgement.detail,
1829            execution,
1830            reproduce: reproduce.map(|t| t.reproduce()),
1831        };
1832        (proof, own != judgement.state)
1833    }
1834
1835    /// The adjuster, when one is set and the route names a test: the route keeps the weaker
1836    /// of the two states, and the adjusted reasons only when they are what weakened it.
1837    fn adjusted(
1838        &self,
1839        route: &Route,
1840        execution: Option<&Execution>,
1841        judgement: Judgement,
1842    ) -> Judgement {
1843        let Some(adjust) = self.adjust else {
1844            return judgement;
1845        };
1846        let Some(id) = route
1847            .test
1848            .as_ref()
1849            .and(route.path.as_deref())
1850            .and_then(TestId::of)
1851        else {
1852            return judgement;
1853        };
1854        let state = judgement.state;
1855        let adjusted = adjust(&id, execution, judgement.clone());
1856        let weaker = aggregate(
1857            &[(state, true), (adjusted.state, true)],
1858            ProofState::Failing,
1859        )
1860        .unwrap_or(state);
1861        if weaker == state {
1862            judgement
1863        } else {
1864            Judgement {
1865                state: weaker,
1866                ..adjusted
1867            }
1868        }
1869    }
1870}
1871
1872impl<'a> Judge<'a> {
1873    /// Everything the repository can say about the subject `key` at the presented revision.
1874    ///
1875    /// ```
1876    /// use majordomus_cli::evidence::subject::{self, Judge};
1877    /// use majordomus_cli::evidence::{report_at, Ledger, Presented};
1878    /// use majordomus_cli::synthetic::SyntheticRepository;
1879    ///
1880    /// let repo = SyntheticRepository::small().unwrap();
1881    /// let ctx = repo.context().unwrap();
1882    /// let s = subject::index(&ctx.index, &ctx.product, &ctx.registry);
1883    /// let ledger = Ledger::empty();
1884    /// let claims = report_at(&ctx.index, &ledger, &Presented::WorkingTree, &[]);
1885    /// let rules = majordomus_cli::rules::report(&ctx.index, &ledger);
1886    /// let p = Presented::WorkingTree;
1887    /// let mut judge = Judge::new(&s, &claims, &rules, &ledger, &[], repo.root(), &p);
1888    /// let err = judge.subject("feature:no-such").unwrap_err();
1889    /// assert!(err.to_string().contains("feature:no-such"));
1890    /// ```
1891    pub fn subject(&mut self, key: &str) -> Result<SubjectEvidence, UnknownSubject> {
1892        let subjects = self.subjects;
1893        let entry = subjects.get(key)?;
1894        let (routes, v) = self.evaluate(key, entry);
1895        let members: Vec<MemberProof> = entry
1896            .members
1897            .iter()
1898            .filter_map(|m| {
1899                let kind = SubjectKind::parse(m)?.0;
1900                let (state, carries_proof, rule_state) = self.verdict_of(m)?;
1901                Some(MemberProof {
1902                    subject: m.clone(),
1903                    kind,
1904                    state,
1905                    carries_proof,
1906                    rule_state,
1907                })
1908            })
1909            .collect();
1910        let mut totals: BTreeMap<String, usize> = BTreeMap::new();
1911        let counted = routes.iter().map(|r| r.state).chain(
1912            members
1913                .iter()
1914                .filter(|_| entry.kind != SubjectKind::Rule)
1915                .map(|m| m.state),
1916        );
1917        for state in counted {
1918            *totals.entry(state.label().to_string()).or_insert(0) += 1;
1919        }
1920        let subject_findings = self.findings(key, entry, &routes);
1921        Ok(SubjectEvidence {
1922            subject: key.to_string(),
1923            kind: entry.kind,
1924            id: entry.id.clone(),
1925            page: entry.page.clone(),
1926            presented: self.revision.clone(),
1927            verdict: v.0,
1928            meaning: v.0.meaning().to_string(),
1929            carries_proof: v.1,
1930            rule_state: v.2,
1931            totals,
1932            routes,
1933            members,
1934            mechanisms: entry.mechanisms.clone(),
1935            subject_findings,
1936        })
1937    }
1938
1939    /// A member's verdict, memoised: members form a DAG, and each is judged once.
1940    fn verdict_of(&mut self, key: &str) -> Option<Verdict> {
1941        if let Some(v) = self.memo.get(key) {
1942            return Some(*v);
1943        }
1944        let subjects = self.subjects;
1945        let entry = subjects.subjects.get(key)?;
1946        Some(self.evaluate(key, entry).1)
1947    }
1948
1949    /// A subject's own judged routes and its verdict, memoised by key.
1950    fn evaluate(&mut self, key: &str, entry: &'a SubjectEntry) -> (Vec<RouteProof>, Verdict) {
1951        let mut routes = Vec::new();
1952        let mut moved = Vec::new();
1953        for r in &entry.routes {
1954            let (proof, was_moved) = self.route(entry, r);
1955            if was_moved {
1956                moved.push(proof.state);
1957            }
1958            routes.push(proof);
1959        }
1960        let v = if entry.kind == SubjectKind::Rule {
1961            self.rule_verdict(entry, &moved)
1962        } else {
1963            let mut parts: Vec<(ProofState, bool)> =
1964                routes.iter().map(|r| (r.state, r.carries_proof)).collect();
1965            for m in &entry.members {
1966                if let Some((state, carries, _)) = self.verdict_of(m) {
1967                    parts.push((state, carries));
1968                }
1969            }
1970            let (state, carries) = verdict(&parts);
1971            (state, carries, None)
1972        };
1973        self.memo.insert(key.to_string(), v);
1974        (routes, v)
1975    }
1976
1977    /// A rule subject: its rule proof through [`RuleState::as_proof`], capped by the
1978    /// presented tree, and re-aggregated only with the routes something moved.
1979    fn rule_verdict(&self, entry: &SubjectEntry, moved: &[ProofState]) -> Verdict {
1980        let Some(proof) = self.rules.get(entry.id.as_str()) else {
1981            return (ProofState::NoTest, false, None);
1982        };
1983        let mut s = proof.state;
1984        if self.presented_tree != TreeState::Clean && s == RuleState::Proven {
1985            s = RuleState::InputsUnchanged;
1986        }
1987        let (mut p, carries) = s.as_proof(proof.rule.class);
1988        if !moved.is_empty() {
1989            let parts: Vec<(ProofState, bool)> = std::iter::once((p, true))
1990                .chain(moved.iter().map(|m| (*m, true)))
1991                .collect();
1992            p = aggregate(&parts, ProofState::Failing).unwrap_or(p);
1993        }
1994        (p, carries, Some(s))
1995    }
1996
1997    /// The entry's structural findings, then one `dangling_member` per direct rule member
1998    /// that names a missing path, then one `freshness_unknown` per evidence commit of its own
1999    /// passing routes that git could not compare.
2000    fn findings(
2001        &mut self,
2002        key: &str,
2003        entry: &SubjectEntry,
2004        routes: &[RouteProof],
2005    ) -> Vec<SubjectFinding> {
2006        let mut findings = entry.findings.clone();
2007        for m in &entry.members {
2008            let Some((SubjectKind::Rule, id)) = SubjectKind::parse(m) else {
2009                continue;
2010            };
2011            if self
2012                .rules
2013                .get(id)
2014                .is_some_and(|r| r.state == RuleState::Dangling)
2015            {
2016                findings.push(SubjectFinding {
2017                    code: DANGLING_MEMBER.to_string(),
2018                    severity: Severity::Warning,
2019                    subject: key.to_string(),
2020                    message: format!("the rule `{id}` names a path that is not in the tree"),
2021                });
2022            }
2023        }
2024        let commits: BTreeSet<&str> = routes
2025            .iter()
2026            .filter_map(|r| r.execution.as_ref())
2027            .filter(|e| e.outcome.proves())
2028            .map(|e| e.commit.as_str())
2029            .collect();
2030        for commit in commits {
2031            let cmp = self.comparison(commit);
2032            if cmp.containment == Containment::CommitUnknown || cmp.changed.is_none() {
2033                let short: String = commit.chars().take(12).collect();
2034                findings.push(SubjectFinding {
2035                    code: FRESHNESS_UNKNOWN.to_string(),
2036                    severity: Severity::Warning,
2037                    subject: key.to_string(),
2038                    message: format!(
2039                        "git could not compare the evidence commit {short} with the presented \
2040                         revision, so the routes recorded on it read stale; not knowing is \
2041                         not proof"
2042                    ),
2043                });
2044            }
2045        }
2046        findings
2047    }
2048}
2049
2050/// One subject, judged at the presented revision from the two reports of the given ledger.
2051///
2052/// The caller passes the ledger and its uncommitted executions as `evidence show
2053/// --presented` reads them: the checkout's ledger and nothing for the working tree, the
2054/// commit's ledger and [`crate::evidence::freshness::uncommitted`] for a presented commit.
2055/// An unknown key costs no git work. No adjuster is set; a caller that needs one builds the
2056/// [`Judge`] itself.
2057///
2058/// ```
2059/// use majordomus_cli::evidence::subject;
2060/// use majordomus_cli::evidence::{Ledger, Presented};
2061/// use majordomus_cli::synthetic::SyntheticRepository;
2062///
2063/// let repo = SyntheticRepository::small().unwrap();
2064/// let ctx = repo.context().unwrap();
2065/// let (ledger, p) = (Ledger::empty(), Presented::WorkingTree);
2066/// let ask = |key: &str| {
2067///     subject::answer(&ctx.index, &ctx.product, &ctx.registry, &ledger, &[], &p, key)
2068/// };
2069/// assert!(ask("nonsense").is_err());
2070/// let tool = ask("mcp:majordomus_capabilities").unwrap();
2071/// assert_eq!(tool.members.len(), 1, "an alias is made of its capability");
2072/// ```
2073pub fn answer(
2074    index: &Index,
2075    product: &ProductModel,
2076    registry: &CapabilityRegistry,
2077    ledger: &Ledger,
2078    uncommitted: &[Execution],
2079    presented: &Presented,
2080    key: &str,
2081) -> Result<SubjectEvidence, UnknownSubject> {
2082    let s = self::index(index, product, registry);
2083    s.get(key)?;
2084    let claims = crate::evidence::report_at(index, ledger, presented, uncommitted);
2085    let rules = crate::rules::report(index, ledger);
2086    let root = PathBuf::from(&index.repository.root);
2087    Judge::new(&s, &claims, &rules, ledger, uncommitted, &root, presented).subject(key)
2088}
2089
2090/// A route whose proof the report the judge was given does not carry.
2091fn missing(what: &str) -> Judgement {
2092    Judgement {
2093        state: ProofState::NoTest,
2094        changed: Vec::new(),
2095        detail: Some(format!(
2096            "the report the judge was given does not carry this {what}"
2097        )),
2098    }
2099}
2100
2101#[cfg(test)]
2102mod tests {
2103    use super::*;
2104
2105    fn claim(id: &str, status: &str, implementation: &str, test: &str) -> ClaimDecl {
2106        let opt = |s: &str| (!s.is_empty() && s != "-").then(|| s.to_string());
2107        ClaimDecl {
2108            id: id.into(),
2109            status: status.into(),
2110            source: Some("docs/A.md".into()),
2111            implementation: opt(implementation),
2112            test_path: opt(test),
2113            test: opt(test)
2114                .as_deref()
2115                .and_then(TestId::of)
2116                .map(|t| t.as_string()),
2117        }
2118    }
2119    fn case(name: &str, covers: &[&str], negative: &[&str]) -> CaseDecl {
2120        let path = format!("test/cases/{name}.sh");
2121        CaseDecl {
2122            test: TestId::of(&path).unwrap().as_string(),
2123            path,
2124            covers: covers.iter().map(|s| s.to_string()).collect(),
2125            negative: negative.iter().map(|s| s.to_string()).collect(),
2126        }
2127    }
2128    fn strings(v: &[&str]) -> Vec<String> {
2129        v.iter().map(|s| s.to_string()).collect()
2130    }
2131    fn cap(id: &str, cli: Option<&str>, tool: Option<&str>, module: &str) -> CapabilityDecl {
2132        CapabilityDecl {
2133            id: id.into(),
2134            cli: cli.map(str::to_string),
2135            mcp_tool: tool.map(str::to_string),
2136            source: format!("apps/majordomus-cli/src/capability/builtin/{module}.rs"),
2137        }
2138    }
2139
2140    /// A small world: a claim, a rule, two commands, a capability with a tool, a use case,
2141    /// two features and a few cases.
2142    fn world() -> Declarations {
2143        Declarations {
2144            claims: vec![
2145                claim(
2146                    "alpha",
2147                    "guaranteed",
2148                    "lib/alpha.sh",
2149                    "test/cases/01_alpha.sh",
2150                ),
2151                claim("planned", "planned", "-", "-"),
2152                claim(
2153                    "in-module",
2154                    "advisory",
2155                    "apps/majordomus-cli/src/capability/builtin/things.rs",
2156                    "test/cases/02_mod.sh",
2157                ),
2158            ],
2159            rules: vec![RuleDecl {
2160                id: "project.alpha".into(),
2161                status: "active".into(),
2162                path: ".ai/repo/rules/project/alpha.v1.md".into(),
2163                tests: strings(&["test/cases/01_alpha.sh", "scripts/alpha-gate"]),
2164            }],
2165            features: vec![
2166                FeatureDecl {
2167                    id: "alpha".into(),
2168                    status: "stable".into(),
2169                    claims: strings(&["alpha", "planned", "no-such-claim"]),
2170                    rules: strings(&["project.alpha"]),
2171                    commands: strings(&["version"]),
2172                    use_cases: strings(&["see-alpha"]),
2173                },
2174                FeatureDecl {
2175                    id: "lonely".into(),
2176                    status: "stable".into(),
2177                    claims: strings(&["planned"]),
2178                    ..FeatureDecl::default()
2179                },
2180                FeatureDecl {
2181                    id: "drafty".into(),
2182                    status: "draft".into(),
2183                    claims: strings(&["planned"]),
2184                    ..FeatureDecl::default()
2185                },
2186            ],
2187            use_cases: vec![UseCaseDecl {
2188                id: "see-alpha".into(),
2189                status: Some("active".into()),
2190                path: ".ai/repo/use-cases/see-alpha.md".into(),
2191                commands: strings(&["version"]),
2192                doctrines: strings(&["project.alpha"]),
2193                claims: strings(&["alpha"]),
2194                mcp_tools: strings(&["majordomus_things"]),
2195            }],
2196            commands: strings(&["version", "bench"]),
2197            examples: strings(&["bench", "commit plan"]),
2198            capabilities: vec![
2199                cap(
2200                    "things.list",
2201                    Some("things list"),
2202                    Some("majordomus_things"),
2203                    "things",
2204                ),
2205                cap("things.show_one", None, None, "things"),
2206                cap("other.x", None, Some("majordomus_other"), "other"),
2207            ],
2208            cases: vec![
2209                case("01_alpha", &["version"], &["version"]),
2210                case("02_mod", &[], &[]),
2211                case("03_bench", &["bench", "gate:x"], &[]),
2212                case("04_orphan", &[], &[]),
2213                case(
2214                    "05_cap",
2215                    &["capability:things.list"],
2216                    &["capability:no.such"],
2217                ),
2218            ],
2219            crate_tests: strings(&["crate:cli_examples", "crate:lonely"]),
2220        }
2221    }
2222
2223    fn entry<'a>(s: &'a SubjectIndex, key: &str) -> &'a SubjectEntry {
2224        s.get(key).unwrap_or_else(|e| panic!("{e}"))
2225    }
2226    fn tests_of(e: &SubjectEntry, via: Via) -> Vec<&str> {
2227        e.routes
2228            .iter()
2229            .filter(|r| r.via == via)
2230            .filter_map(|r| r.test.as_deref())
2231            .collect()
2232    }
2233
2234    #[test]
2235    fn a_key_parses_back_to_its_kind() {
2236        for k in SubjectKind::ALL {
2237            let key = k.key("an.id with space");
2238            assert_eq!(SubjectKind::parse(&key), Some((k, "an.id with space")));
2239            assert_eq!(serde_json::to_value(k).unwrap(), k.prefix());
2240        }
2241        assert_eq!(
2242            SubjectKind::parse("command:commit plan"),
2243            Some((SubjectKind::Command, "commit plan"))
2244        );
2245        // the first colon splits, so an id keeps its own
2246        assert_eq!(
2247            SubjectKind::parse("capability:a:b"),
2248            Some((SubjectKind::Capability, "a:b"))
2249        );
2250        for bad in ["gate:x", "claim:", "x", ""] {
2251            assert_eq!(SubjectKind::parse(bad), None, "{bad}");
2252        }
2253    }
2254
2255    #[test]
2256    fn the_category_is_read_off_the_test_identity() {
2257        assert_eq!(Category::of("suite:a"), Some(Category::E2e));
2258        assert_eq!(Category::of("crate:lib::x"), Some(Category::Unit));
2259        assert_eq!(Category::of("crate:why"), Some(Category::Integration));
2260        assert_eq!(Category::of("doc:x"), Some(Category::Doctest));
2261        assert_eq!(Category::of("other:x"), None);
2262    }
2263
2264    #[test]
2265    fn a_claim_has_one_route_with_the_joins_inputs() {
2266        let s = derive(&world());
2267        let alpha = entry(&s, "claim:alpha");
2268        assert_eq!(alpha.routes.len(), 1);
2269        let r = &alpha.routes[0];
2270        assert_eq!(r.via, Via::Claim);
2271        assert_eq!(r.test.as_deref(), Some("suite:01_alpha"));
2272        assert_eq!(r.path.as_deref(), Some("test/cases/01_alpha.sh"));
2273        assert_eq!(r.category, Some(Category::E2e));
2274        assert_eq!(
2275            r.inputs.as_deref(),
2276            Some(&strings(&["docs/A.md", "lib/alpha.sh", "test/cases/01_alpha.sh"])[..])
2277        );
2278        assert_eq!(alpha.status.as_deref(), Some("guaranteed"));
2279        assert_eq!(alpha.page.as_deref(), Some("/evidence/claims/alpha/"));
2280
2281        // a claim naming '-' still has its route, with neither test nor path
2282        let planned = entry(&s, "claim:planned");
2283        assert_eq!(planned.routes.len(), 1);
2284        assert!(planned.routes[0].test.is_none() && planned.routes[0].path.is_none());
2285
2286        // a path no runner drives is named, and has no test
2287        let mut d = world();
2288        d.claims = vec![claim("gamma", "advisory", "lib/g.sh", "lib/gamma.sh")];
2289        let s = derive(&d);
2290        let r = &entry(&s, "claim:gamma").routes[0];
2291        assert_eq!(r.path.as_deref(), Some("lib/gamma.sh"));
2292        assert!(r.test.is_none() && r.category.is_none());
2293        assert_eq!(
2294            r.inputs.as_deref(),
2295            Some(&strings(&["docs/A.md", "lib/g.sh"])[..])
2296        );
2297    }
2298
2299    #[test]
2300    fn a_rule_path_no_runner_drives_is_a_mechanism() {
2301        let s = derive(&world());
2302        let rule = entry(&s, "rule:project.alpha");
2303        assert_eq!(rule.mechanisms, strings(&["scripts/alpha-gate"]));
2304        assert_eq!(rule.routes.len(), 1);
2305        let r = &rule.routes[0];
2306        assert_eq!(
2307            (r.via, r.test.as_deref()),
2308            (Via::Rule, Some("suite:01_alpha"))
2309        );
2310        assert_eq!(
2311            r.inputs.as_deref(),
2312            Some(
2313                &strings(&[
2314                    "test/cases/01_alpha.sh",
2315                    ".ai/repo/rules/project/alpha.v1.md"
2316                ])[..]
2317            )
2318        );
2319        assert!(rule.page.is_none(), "a rule has no page of its own");
2320        assert_eq!(rule.status.as_deref(), Some("active"));
2321    }
2322
2323    #[test]
2324    fn the_first_header_decides() {
2325        // read from files, as the index reads them
2326        let dir = tempfile::tempdir().unwrap();
2327        let cases = dir.path().join(CASES_DIR);
2328        std::fs::create_dir_all(&cases).unwrap();
2329        let header = |kind: &str, words: &str| format!("# majordomus-{kind}: {words}\n");
2330        std::fs::write(
2331            cases.join("01_first.sh"),
2332            header("covers", "none") + header("covers", "version").as_str(),
2333        )
2334        .unwrap();
2335        std::fs::write(
2336            cases.join("02_words.sh"),
2337            header("covers", "gate:x capability:c.x version")
2338                + header("negative", "none version").as_str(),
2339        )
2340        .unwrap();
2341        std::fs::write(cases.join("README.md"), header("covers", "version")).unwrap();
2342        std::fs::create_dir_all(cases.join("99_dir.sh")).unwrap();
2343        let read = read_cases(dir.path());
2344        assert_eq!(
2345            read.len(),
2346            2,
2347            "a file no runner drives and a directory are not cases"
2348        );
2349        let first = read.iter().find(|c| c.test == "suite:01_first").unwrap();
2350        assert!(
2351            first.covers.is_empty(),
2352            "`none` then a second line names nothing"
2353        );
2354        let words = read.iter().find(|c| c.test == "suite:02_words").unwrap();
2355        assert_eq!(
2356            words.covers,
2357            strings(&["gate:x", "capability:c.x", "version"])
2358        );
2359        assert_eq!(
2360            words.negative,
2361            strings(&["version"]),
2362            "`none` names nothing"
2363        );
2364
2365        let d = Declarations {
2366            commands: strings(&["version"]),
2367            capabilities: vec![cap("c.x", None, None, "c")],
2368            cases: read,
2369            ..Declarations::default()
2370        };
2371        let s = derive(&d);
2372        assert_eq!(
2373            tests_of(entry(&s, "command:version"), Via::Behaviour),
2374            vec!["suite:02_words"]
2375        );
2376        assert_eq!(
2377            tests_of(entry(&s, "capability:c.x"), Via::Behaviour),
2378            vec!["suite:02_words"]
2379        );
2380        assert!(
2381            s.get("gate:x").is_err(),
2382            "a prefixed word that is no kind is ignored"
2383        );
2384        assert!(s.subjects.keys().all(|k| !k.contains("gate")));
2385    }
2386
2387    #[test]
2388    fn a_missing_directory_holds_no_tests() {
2389        let dir = tempfile::tempdir().unwrap();
2390        assert!(read_cases(dir.path()).is_empty());
2391        assert!(read_crate_tests(dir.path()).is_empty());
2392        let tests = dir.path().join(CRATE_TESTS_DIR);
2393        std::fs::create_dir_all(&tests).unwrap();
2394        std::fs::write(tests.join("why.rs"), "").unwrap();
2395        std::fs::write(tests.join("notes.txt"), "").unwrap();
2396        assert_eq!(read_crate_tests(dir.path()), strings(&["crate:why"]));
2397    }
2398
2399    #[test]
2400    fn a_negative_header_is_its_own_route() {
2401        let s = derive(&world());
2402        let version = entry(&s, "command:version");
2403        assert_eq!(tests_of(version, Via::Behaviour), vec!["suite:01_alpha"]);
2404        assert_eq!(tests_of(version, Via::Negative), vec!["suite:01_alpha"]);
2405        assert_eq!(
2406            version.routes.len(),
2407            2,
2408            "one behaviour route and one negative route"
2409        );
2410        // a negative word naming a capability that is not there routes nowhere
2411        assert!(s.get("capability:no.such").is_err());
2412    }
2413
2414    #[test]
2415    fn a_documented_command_routes_to_the_examples_only_where_they_run() {
2416        let s = derive(&world());
2417        let plan = entry(&s, "command:commit plan");
2418        let example: Vec<&Route> = plan
2419            .routes
2420            .iter()
2421            .filter(|r| r.via == Via::Example)
2422            .collect();
2423        assert_eq!(example.len(), 1);
2424        assert_eq!(example[0].test.as_deref(), Some("crate:cli_examples"));
2425        assert_eq!(example[0].path.as_deref(), Some(CLI_EXAMPLES));
2426        assert_eq!(example[0].category, Some(Category::Integration));
2427        // a shell command has no example route, whatever runs
2428        assert!(tests_of(entry(&s, "command:version"), Via::Example).is_empty());
2429
2430        let mut d = world();
2431        d.crate_tests.retain(|t| t != "crate:cli_examples");
2432        let s = derive(&d);
2433        assert!(tests_of(entry(&s, "command:commit plan"), Via::Example).is_empty());
2434    }
2435
2436    #[test]
2437    fn members_form_a_dag() {
2438        let s = derive(&world());
2439        let feature = entry(&s, "feature:alpha");
2440        assert!(feature.members.contains(&"use_case:see-alpha".to_string()));
2441        for (key, e) in &s.subjects {
2442            let starts = |p: &str| e.members.iter().all(|m| m.starts_with(p));
2443            match e.kind {
2444                SubjectKind::UseCase => assert!(
2445                    e.members
2446                        .iter()
2447                        .all(|m| !m.starts_with("feature:") && !m.starts_with("use_case:")),
2448                    "{key}"
2449                ),
2450                SubjectKind::Capability => assert!(
2451                    e.members
2452                        .iter()
2453                        .all(|m| m.starts_with("command:") || m.starts_with("claim:")),
2454                    "{key}"
2455                ),
2456                SubjectKind::Mcp => {
2457                    assert_eq!(e.members.len(), 1, "{key}");
2458                    assert!(starts("capability:"), "{key}");
2459                }
2460                SubjectKind::Claim | SubjectKind::Rule | SubjectKind::Command => {
2461                    assert!(e.members.is_empty(), "{key}")
2462                }
2463                SubjectKind::Feature => assert!(
2464                    e.members.iter().all(|m| !m.starts_with("feature:")
2465                        && !m.starts_with("mcp:")
2466                        && !m.starts_with("capability:")),
2467                    "{key}"
2468                ),
2469            }
2470        }
2471        // a reference that resolves to nothing is skipped, never invented
2472        assert!(!feature.members.contains(&"claim:no-such-claim".to_string()));
2473        assert!(s.get("claim:no-such-claim").is_err());
2474    }
2475
2476    #[test]
2477    fn a_capability_is_made_of_its_modules_claims() {
2478        let mut d = world();
2479        d.claims.push(claim(
2480            "in-another-module",
2481            "advisory",
2482            "apps/majordomus-cli/src/capability/builtin/other.rs",
2483            "-",
2484        ));
2485        d.claims.push(claim(
2486            "in-the-cli",
2487            "advisory",
2488            "apps/majordomus-cli/src/cli.rs",
2489            "-",
2490        ));
2491        let s = derive(&d);
2492        for key in ["capability:things.list", "capability:things.show_one"] {
2493            let e = entry(&s, key);
2494            assert!(e.members.contains(&"claim:in-module".to_string()), "{key}");
2495            assert!(
2496                !e.members.contains(&"claim:in-another-module".to_string()),
2497                "{key}"
2498            );
2499            assert!(
2500                !e.members.contains(&"claim:in-the-cli".to_string()),
2501                "{key}"
2502            );
2503        }
2504        assert_eq!(
2505            entry(&s, "capability:things.list").members,
2506            strings(&["claim:in-module", "command:things list"])
2507        );
2508        assert_eq!(
2509            entry(&s, "capability:other.x").members,
2510            strings(&["claim:in-another-module"])
2511        );
2512    }
2513
2514    #[test]
2515    fn a_word_both_programs_answer_to_is_named() {
2516        let d = Declarations {
2517            commands: strings(&["bench", "version"]),
2518            examples: strings(&["bench", "commit plan"]),
2519            capabilities: vec![cap(
2520                "capabilities.list",
2521                Some("capabilities list"),
2522                None,
2523                "c",
2524            )],
2525            claims: vec![claim("a", "guaranteed", "-", "-")],
2526            ..Declarations::default()
2527        };
2528        let s = derive(&d);
2529        let bench = entry(&s, "command:bench");
2530        assert_eq!(bench.programs, vec![Program::Native, Program::Shell]);
2531        assert_eq!(bench.findings.len(), 1);
2532        assert_eq!(bench.findings[0].code, COMMAND_IN_TWO_PROGRAMS);
2533        assert_eq!(bench.findings[0].severity, Severity::Warning);
2534        assert_eq!(bench.findings[0].subject, "command:bench");
2535        assert_eq!(entry(&s, "command:version").programs, vec![Program::Shell]);
2536        for key in ["command:commit plan", "command:capabilities list"] {
2537            assert_eq!(entry(&s, key).programs, vec![Program::Native], "{key}");
2538        }
2539        for (key, e) in &s.subjects {
2540            if key != "command:bench" {
2541                assert!(e.findings.is_empty(), "{key}");
2542            }
2543            if e.kind != SubjectKind::Command {
2544                assert!(e.programs.is_empty(), "{key}");
2545            }
2546        }
2547    }
2548
2549    #[test]
2550    fn a_use_case_names_what_graph_rs_names() {
2551        let s = derive(&world());
2552        let u = entry(&s, "use_case:see-alpha");
2553        assert_eq!(
2554            u.members,
2555            strings(&[
2556                "claim:alpha",
2557                "command:version",
2558                "mcp:majordomus_things",
2559                "rule:project.alpha"
2560            ])
2561        );
2562        assert_eq!(u.routes.len(), 1);
2563        let r = &u.routes[0];
2564        assert_eq!(r.via, Via::Scenario);
2565        assert_eq!(r.category, Some(Category::Scenario));
2566        assert_eq!(r.path.as_deref(), Some(".ai/repo/use-cases/see-alpha.md"));
2567        assert!(r.test.is_none());
2568        assert_eq!(u.page.as_deref(), Some("/evidence/use-cases/see-alpha/"));
2569        assert_eq!(u.status.as_deref(), Some("active"));
2570    }
2571
2572    #[test]
2573    fn an_mcp_tool_is_an_alias_of_its_capability() {
2574        let s = derive(&world());
2575        let alias = entry(&s, "mcp:majordomus_things");
2576        assert_eq!(alias.alias_of.as_deref(), Some("capability:things.list"));
2577        assert_eq!(alias.members, strings(&["capability:things.list"]));
2578        assert!(alias.page.is_none() && alias.routes.is_empty());
2579        assert!(s
2580            .pages()
2581            .iter()
2582            .all(|p| p.subject != "mcp:majordomus_things"));
2583
2584        // over the real registry: the alias is the registry's own answer for the tool
2585        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2586        let ctx = repo.context().unwrap();
2587        let s = index(&ctx.index, &ctx.product, &ctx.registry);
2588        let mut aliases = 0;
2589        for (key, e) in s
2590            .subjects
2591            .iter()
2592            .filter(|(_, e)| e.kind == SubjectKind::Mcp)
2593        {
2594            let by_registry = ctx.registry.by_mcp_tool(&e.id).unwrap();
2595            assert_eq!(
2596                e.alias_of,
2597                Some(format!("capability:{}", by_registry.id)),
2598                "{key}"
2599            );
2600            aliases += 1;
2601        }
2602        assert!(aliases > 0, "a check over no alias is not a pass");
2603    }
2604
2605    #[test]
2606    fn the_slug_matches_the_site() {
2607        assert_eq!(slug("session_domain.identity"), "session-domain-identity");
2608        assert_eq!(slug("commit plan"), "commit-plan");
2609        assert_eq!(
2610            page_of(SubjectKind::Capability, "repository.scope_classify").as_deref(),
2611            Some("/evidence/capabilities/repository-scope-classify/")
2612        );
2613        assert_eq!(page_of(SubjectKind::Rule, "project.x"), None);
2614    }
2615
2616    #[test]
2617    fn a_feature_that_reaches_no_test_is_a_finding() {
2618        let s = derive(&world());
2619        let lonely = entry(&s, "feature:lonely");
2620        assert_eq!(lonely.findings.len(), 1);
2621        assert_eq!(lonely.findings[0].code, FEATURE_WITHOUT_EVIDENCE);
2622        assert_eq!(lonely.findings[0].severity, Severity::Error);
2623        assert_eq!(
2624            entry(&s, "feature:drafty").findings[0].severity,
2625            Severity::Warning
2626        );
2627        // a feature that reaches a test through a transitive member has none
2628        assert!(entry(&s, "feature:alpha").findings.is_empty());
2629
2630        // a planned claim and a scenario route count for nothing
2631        let mut d = world();
2632        d.features = vec![FeatureDecl {
2633            id: "only-a-scenario".into(),
2634            status: "stable".into(),
2635            claims: strings(&["planned"]),
2636            use_cases: strings(&["bare"]),
2637            ..FeatureDecl::default()
2638        }];
2639        d.use_cases.push(UseCaseDecl {
2640            id: "bare".into(),
2641            path: ".ai/repo/use-cases/bare.md".into(),
2642            ..UseCaseDecl::default()
2643        });
2644        let s = derive(&d);
2645        let f = entry(&s, "feature:only-a-scenario");
2646        assert_eq!(f.findings.len(), 1);
2647        assert_eq!(f.findings[0].code, FEATURE_WITHOUT_EVIDENCE);
2648    }
2649
2650    #[test]
2651    fn excluded_lists_what_nothing_reaches() {
2652        let s = derive(&world());
2653        assert_eq!(s.excluded, strings(&["crate:lonely", "suite:04_orphan"]));
2654        // reached by a claim (01, 02), a rule (01), a covers header (03, 05) or the examples
2655        for t in [
2656            "suite:01_alpha",
2657            "suite:02_mod",
2658            "suite:03_bench",
2659            "suite:05_cap",
2660        ] {
2661            assert!(!s.excluded.contains(&t.to_string()), "{t}");
2662        }
2663        // a case only a rule reaches is reached
2664        let mut d = world();
2665        d.claims.clear();
2666        d.cases = vec![case("01_alpha", &[], &[])];
2667        let s = derive(&d);
2668        assert!(!s.excluded.contains(&"suite:01_alpha".to_string()));
2669    }
2670
2671    #[test]
2672    fn the_derivation_does_not_depend_on_input_order() {
2673        let d = world();
2674        let expected = crate::http::openapi::render(&derive(&d).document("1"));
2675        let permute = |d: &Declarations, f: &dyn Fn(&mut Vec<String>)| {
2676            let mut p = d.clone();
2677            p.claims.reverse();
2678            p.rules.reverse();
2679            p.features.reverse();
2680            p.use_cases.reverse();
2681            p.capabilities.reverse();
2682            p.cases.reverse();
2683            f(&mut p.commands);
2684            f(&mut p.examples);
2685            f(&mut p.crate_tests);
2686            for feature in &mut p.features {
2687                f(&mut feature.claims);
2688                f(&mut feature.use_cases);
2689            }
2690            for u in &mut p.use_cases {
2691                f(&mut u.commands);
2692                f(&mut u.mcp_tools);
2693            }
2694            p
2695        };
2696        let reverse = permute(&d, &|v: &mut Vec<String>| v.reverse());
2697        let rotate = permute(&d, &|v: &mut Vec<String>| {
2698            if !v.is_empty() {
2699                v.rotate_left(1)
2700            }
2701        });
2702        for p in [reverse, rotate] {
2703            assert_eq!(
2704                crate::http::openapi::render(&derive(&p).document("1")),
2705                expected
2706            );
2707        }
2708    }
2709
2710    #[test]
2711    fn the_document_carries_no_ledger_content() {
2712        let doc = derive(&world()).document("1");
2713        fn keys(v: &Value, out: &mut BTreeSet<String>) {
2714            match v {
2715                Value::Object(o) => {
2716                    for (k, v) in o {
2717                        out.insert(k.clone());
2718                        keys(v, out);
2719                    }
2720                }
2721                Value::Array(a) => a.iter().for_each(|v| keys(v, out)),
2722                _ => {}
2723            }
2724        }
2725        let mut found = BTreeSet::new();
2726        keys(&doc, &mut found);
2727        for forbidden in [
2728            "commit",
2729            "head",
2730            "outcome",
2731            "state",
2732            "execution",
2733            "at",
2734            "digest",
2735        ] {
2736            assert!(!found.contains(forbidden), "{forbidden}");
2737        }
2738        assert!(
2739            found.contains("subjects") && found.contains("routes"),
2740            "the walk saw the document"
2741        );
2742        assert!(crate::generate::forbidden_in(&crate::http::openapi::render(&doc)).is_none());
2743        assert!(PATH.starts_with(crate::generate::SITE_DATA_DIR));
2744        let order: Vec<&str> = doc
2745            .as_object()
2746            .unwrap()
2747            .keys()
2748            .map(String::as_str)
2749            .collect();
2750        assert_eq!(
2751            order,
2752            [
2753                "schema",
2754                "generated",
2755                "generator",
2756                "route",
2757                "kinds",
2758                "categories",
2759                "pages",
2760                "subjects",
2761                "excluded"
2762            ]
2763        );
2764        assert_eq!(doc["kinds"][1], json!({ "kind": "rule" }));
2765        assert_eq!(
2766            doc["kinds"][6],
2767            json!({ "kind": "use_case", "section": "use-cases" })
2768        );
2769    }
2770
2771    #[test]
2772    fn two_subjects_on_one_page_are_found() {
2773        // `a.b` and `a_b` slug to one page
2774        let d = Declarations {
2775            capabilities: vec![cap("a.b", None, None, "a"), cap("a_b", None, None, "a")],
2776            ..Declarations::default()
2777        };
2778        let s = derive(&d);
2779        assert_eq!(
2780            s.duplicate_pages(),
2781            vec![(
2782                "/evidence/capabilities/a-b/".to_string(),
2783                strings(&["capability:a.b", "capability:a_b"])
2784            )]
2785        );
2786        let refused = rendered(&s, "1").unwrap_err().to_string();
2787        assert!(refused.contains("/evidence/capabilities/a-b/"), "{refused}");
2788        assert!(refused.contains("capability:a_b"), "{refused}");
2789        assert!(derive(&world()).duplicate_pages().is_empty());
2790    }
2791
2792    #[test]
2793    fn a_leak_is_refused_and_nothing_is_rendered() {
2794        let marker = format!("{}someone/x.sh", "/Users/");
2795        let d = Declarations {
2796            claims: vec![claim("leaky", "advisory", "-", &marker)],
2797            ..Declarations::default()
2798        };
2799        let refused = rendered(&derive(&d), "1").unwrap_err().to_string();
2800        assert!(
2801            refused.contains(PATH) && refused.contains("/Users/"),
2802            "{refused}"
2803        );
2804
2805        let a = rendered(&derive(&world()), "1").unwrap();
2806        assert_eq!((a.path.as_str(), a.document.as_str()), (PATH, DOCUMENT));
2807        assert!(a.content.contains("\"feature:alpha\""));
2808    }
2809
2810    #[test]
2811    fn reach_is_the_walk_the_finding_uses() {
2812        let s = derive(&world());
2813        let reached = s.reach("feature:alpha").unwrap();
2814        let shown: Vec<(Via, Option<&str>)> =
2815            reached.iter().map(|r| (r.via, r.test.as_deref())).collect();
2816        // members in byte order: claim:alpha, claim:planned, command:version,
2817        // rule:project.alpha, use_case:see-alpha — whose own members were walked already,
2818        // except its MCP alias and that alias's capability
2819        assert_eq!(
2820            shown,
2821            vec![
2822                (Via::Claim, Some("suite:01_alpha")),
2823                (Via::Claim, None),
2824                (Via::Behaviour, Some("suite:01_alpha")),
2825                (Via::Negative, Some("suite:01_alpha")),
2826                (Via::Rule, Some("suite:01_alpha")),
2827                (Via::Scenario, None),
2828                (Via::Behaviour, Some("suite:05_cap")),
2829                (Via::Claim, Some("suite:02_mod")),
2830            ]
2831        );
2832        // each route once, although the claim is reached directly and through the use case
2833        let unique: BTreeSet<&&Route> = reached.iter().collect();
2834        assert_eq!(unique.len(), reached.len());
2835        assert!(s.reach("feature:no-such").is_err());
2836        // and once when two members each carry it: two claims naming one test from one
2837        // implementation have one and the same route
2838        let twins = Declarations {
2839            claims: vec![
2840                claim("one", "guaranteed", "lib/x.sh", "test/cases/01_x.sh"),
2841                claim("two", "guaranteed", "lib/x.sh", "test/cases/01_x.sh"),
2842            ],
2843            features: vec![FeatureDecl {
2844                id: "both".into(),
2845                status: "stable".into(),
2846                claims: strings(&["one", "two"]),
2847                ..FeatureDecl::default()
2848            }],
2849            ..Declarations::default()
2850        };
2851        let t = derive(&twins);
2852        assert_eq!(entry(&t, "claim:one").routes, entry(&t, "claim:two").routes);
2853        let reached = t.reach("feature:both").unwrap();
2854        assert_eq!(reached.len(), 1, "{reached:?}");
2855        assert_eq!(reached[0].test.as_deref(), Some("suite:01_x"));
2856        // the finding is exactly a feature whose reach has no route with a test
2857        for (key, e) in s
2858            .subjects
2859            .iter()
2860            .filter(|(_, e)| e.kind == SubjectKind::Feature)
2861        {
2862            let none = s.reach(key).unwrap().iter().all(|r| r.test.is_none());
2863            let found = e
2864                .findings
2865                .iter()
2866                .any(|f| f.code == FEATURE_WITHOUT_EVIDENCE);
2867            assert_eq!(none, found, "{key}");
2868        }
2869    }
2870
2871    #[test]
2872    fn an_unknown_key_is_refused_by_name() {
2873        let s = derive(&world());
2874        for key in ["feature:no-such", "gate:x", "nonsense"] {
2875            let refused = s.get(key).unwrap_err();
2876            assert_eq!(refused.key, key);
2877            let said = refused.to_string();
2878            assert!(
2879                said.starts_with(&format!("`{key}` is not a subject of this repository: ")),
2880                "{said}"
2881            );
2882            assert!(said.ends_with("MCP tool or use case"), "{said}");
2883            let as_error: &dyn std::error::Error = &refused;
2884            assert_eq!(as_error.to_string(), said);
2885        }
2886    }
2887
2888    #[test]
2889    fn a_subject_declared_twice_is_one_subject_whatever_the_order() {
2890        // a claim id written twice, and one MCP tool two capabilities declare: neither is
2891        // sound, both are reported by their owners, and the index must not depend on which
2892        // declaration came first
2893        let twice = |first: &str, second: &str| Declarations {
2894            claims: vec![claim("a", first, "-", "-"), claim("a", second, "-", "-")],
2895            capabilities: vec![
2896                cap("x.one", None, Some("majordomus_x"), "x"),
2897                cap("x.two", None, Some("majordomus_x"), "x"),
2898            ],
2899            ..Declarations::default()
2900        };
2901        let forward = derive(&twice("advisory", "guaranteed"));
2902        let mut backward = twice("guaranteed", "advisory");
2903        backward.capabilities.reverse();
2904        assert_eq!(derive(&backward), forward);
2905
2906        let once = entry(&forward, "claim:a");
2907        assert_eq!(
2908            once.status.as_deref(),
2909            Some("advisory"),
2910            "the smaller status"
2911        );
2912        assert_eq!(
2913            once.routes.len(),
2914            1,
2915            "one route, however often it is declared"
2916        );
2917        let alias = entry(&forward, "mcp:majordomus_x");
2918        assert_eq!(alias.alias_of.as_deref(), Some("capability:x.one"));
2919        assert_eq!(
2920            alias.members,
2921            strings(&["capability:x.one", "capability:x.two"])
2922        );
2923    }
2924
2925    /// An object as the index holds it, for a kind the synthetic repository does not
2926    /// declare.
2927    fn object(kind: &str, identity: &str, path: &str, metadata: Value) -> crate::Object {
2928        crate::Object {
2929            kind: kind.into(),
2930            identity: identity.into(),
2931            uri: format!("majordomus://{kind}/{identity}"),
2932            title: None,
2933            description: None,
2934            metadata,
2935            body: String::new(),
2936            content: String::new(),
2937            media_type: "text/markdown",
2938            provenance: crate::model::Provenance {
2939                path: path.into(),
2940                directory: path
2941                    .rsplit_once('/')
2942                    .map(|(dir, _)| dir.to_string())
2943                    .unwrap_or_default(),
2944                source_class: kind.into(),
2945                section: None,
2946                bytes: 0,
2947                member: None,
2948            },
2949        }
2950    }
2951
2952    #[test]
2953    fn the_declarations_are_read_from_their_owners() {
2954        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2955        let root = repo.root().to_path_buf();
2956        std::fs::create_dir_all(root.join(CASES_DIR)).unwrap();
2957        std::fs::write(
2958            root.join(CASES_DIR).join("01_alpha.sh"),
2959            format!("{COVERS} version\n{NEGATIVE} none\n"),
2960        )
2961        .unwrap();
2962        std::fs::create_dir_all(root.join(CRATE_TESTS_DIR)).unwrap();
2963        std::fs::write(root.join(CLI_EXAMPLES), "").unwrap();
2964
2965        let mut index = repo.index().unwrap();
2966        index.objects.extend([
2967            object(
2968                "claim",
2969                "alpha",
2970                "docs/CLAIMS.yaml",
2971                json!({
2972                    "claim": "Alpha holds",
2973                    "status": "guaranteed",
2974                    "source": "docs/A.md",
2975                    "implementation": "lib/alpha.sh",
2976                    "test": "test/cases/01_alpha.sh",
2977                }),
2978            ),
2979            object(
2980                "feature",
2981                "alpha",
2982                ".ai/repo/features/alpha.md",
2983                json!({
2984                    "id": "alpha",
2985                    "title": "Alpha",
2986                    "headline": "Alpha holds.",
2987                    "summary": "A feature of the test.",
2988                    "status": "stable",
2989                    "claims": ["alpha"],
2990                    "rules": ["project.rule-0"],
2991                    "commands": ["version"],
2992                    "use_cases": ["see-alpha"],
2993                }),
2994            ),
2995            object(
2996                "use-case",
2997                "see-alpha",
2998                ".ai/repo/use-cases/see-alpha.md",
2999                json!({
3000                    "status": "active",
3001                    "commands": ["version"],
3002                    "doctrines": ["project.rule-0"],
3003                    "claims": ["alpha"],
3004                    "mcp_tools": "majordomus_capabilities",
3005                }),
3006            ),
3007            object("use-case", "bare", ".ai/repo/use-cases/bare.md", json!({})),
3008            object(
3009                "command",
3010                "version",
3011                "share/commands.yaml",
3012                json!({ "visibility": "public" }),
3013            ),
3014            object(
3015                "command",
3016                "hook",
3017                "share/commands.yaml",
3018                json!({ "visibility": "internal" }),
3019            ),
3020            object("command", "unstated", "share/commands.yaml", json!({})),
3021        ]);
3022        let registry = CapabilityRegistry::builder()
3023            .with_modules(crate::capability::builtin::modules())
3024            .with_index(&index)
3025            .build()
3026            .unwrap();
3027        let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
3028        let d = declarations(&ctx.index, &ctx.product, &ctx.registry);
3029
3030        // a public command of the registry only: an internal one is dispatched and unlisted
3031        assert_eq!(d.commands, strings(&["version"]));
3032
3033        let alpha = d.claims.iter().find(|c| c.id == "alpha").unwrap();
3034        assert_eq!(alpha.status, "guaranteed");
3035        assert_eq!(alpha.source.as_deref(), Some("docs/A.md"));
3036        assert_eq!(alpha.implementation.as_deref(), Some("lib/alpha.sh"));
3037        assert_eq!(alpha.test_path.as_deref(), Some("test/cases/01_alpha.sh"));
3038        assert_eq!(alpha.test.as_deref(), Some("suite:01_alpha"));
3039
3040        let rule = d.rules.iter().find(|r| r.id == "project.rule-0").unwrap();
3041        assert_eq!(rule.status, "active");
3042
3043        let feature = d
3044            .features
3045            .iter()
3046            .find(|f| f.id == "alpha")
3047            .expect("the product model reads the feature");
3048        assert_eq!(feature.status, "stable");
3049        assert_eq!(feature.claims, strings(&["alpha"]));
3050        assert_eq!(feature.rules, strings(&["project.rule-0"]));
3051        assert_eq!(feature.commands, strings(&["version"]));
3052        assert_eq!(feature.use_cases, strings(&["see-alpha"]));
3053
3054        let see = d.use_cases.iter().find(|u| u.id == "see-alpha").unwrap();
3055        assert_eq!(see.status.as_deref(), Some("active"));
3056        assert_eq!(see.path, ".ai/repo/use-cases/see-alpha.md");
3057        assert_eq!(see.commands, strings(&["version"]));
3058        assert_eq!(see.doctrines, strings(&["project.rule-0"]));
3059        assert_eq!(see.claims, strings(&["alpha"]));
3060        assert_eq!(
3061            see.mcp_tools,
3062            strings(&["majordomus_capabilities"]),
3063            "a lone string is a list of one"
3064        );
3065        let bare = d.use_cases.iter().find(|u| u.id == "bare").unwrap();
3066        assert!(bare.status.is_none() && bare.commands.is_empty() && bare.mcp_tools.is_empty());
3067
3068        // the documented examples, each non-empty, and the builtin capabilities with the
3069        // file each module is composed in
3070        let documented = crate::cli::EXAMPLES
3071            .iter()
3072            .filter(|e| !e.command.is_empty())
3073            .count();
3074        assert_eq!(d.examples.len(), documented);
3075        assert!(d.examples.iter().all(|e| !e.is_empty()));
3076        let listing = d
3077            .capabilities
3078            .iter()
3079            .find(|c| c.mcp_tool.as_deref() == Some("majordomus_capabilities"))
3080            .unwrap();
3081        assert_eq!(listing.cli.as_deref(), Some("capabilities list"));
3082        assert!(
3083            listing
3084                .source
3085                .starts_with("apps/majordomus-cli/src/capability/builtin/")
3086                && listing.source.ends_with(".rs"),
3087            "{}",
3088            listing.source
3089        );
3090
3091        assert_eq!(d.cases.len(), 1);
3092        assert_eq!(d.cases[0].test, "suite:01_alpha");
3093        assert_eq!(d.cases[0].covers, strings(&["version"]));
3094        assert!(d.cases[0].negative.is_empty());
3095        assert_eq!(d.crate_tests, strings(&["crate:cli_examples"]));
3096
3097        // and the committed index of that repository carries what was read
3098        let a = artifact(&ctx, "1").unwrap();
3099        assert!(a.content.contains("\"use_case:see-alpha\""));
3100        assert!(a.content.contains("\"command:version\""));
3101        assert!(!a.content.contains("\"command:hook\""));
3102        assert!(!a.content.contains("\"command:unstated\""));
3103    }
3104
3105    #[test]
3106    fn generate_site_writes_the_index_and_stops_at_one_it_would_not_publish() {
3107        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3108        let args = crate::cli::RepoArgs {
3109            repo: Some(repo.root().to_path_buf()),
3110            share: Some(crate::synthetic::crate_share()),
3111            discovery: crate::cli::DiscoveryMode::Filesystem,
3112            ..Default::default()
3113        };
3114        let site = [crate::generate::Target::Site];
3115        let app = crate::app::App::load(&args).unwrap();
3116        let plan = crate::generate::plan(&app, &site).unwrap();
3117        assert!(plan
3118            .iter()
3119            .any(|a| a.path == PATH && a.document == DOCUMENT));
3120
3121        // a case whose very name is a marker no published file may carry: the index would
3122        // publish it, so the site plan stops there and nothing is written. The marker is
3123        // joined at run time, so that this file does not carry it.
3124        let cases = repo.root().join(CASES_DIR);
3125        std::fs::create_dir_all(&cases).unwrap();
3126        std::fs::write(cases.join(format!("{}BEGIN KEY.sh", "-----")), "").unwrap();
3127        let app = crate::app::App::load(&args).unwrap();
3128        let refused = crate::generate::plan(&app, &site)
3129            .map(|_| ())
3130            .unwrap_err()
3131            .to_string();
3132        assert!(refused.contains(PATH), "{refused}");
3133        assert!(refused.contains("nothing was written"), "{refused}");
3134    }
3135
3136    // ------------------------------------------------------------ verdict and mapping
3137
3138    #[test]
3139    fn a_verdict_is_the_one_aggregation() {
3140        use ProofState::{Failing, NoTest, NotRun, Proven};
3141        assert_eq!(verdict(&[(Failing, true), (NotRun, true)]), (Failing, true));
3142        assert_eq!(verdict(&[(Proven, true), (NotRun, false)]), (Proven, true));
3143        assert_eq!(verdict(&[(NotRun, false)]), (NotRun, false));
3144        assert_eq!(verdict(&[]), (NoTest, false));
3145        assert_eq!(verdict(&[(Proven, true), (NotRun, true)]), (NotRun, true));
3146    }
3147
3148    #[test]
3149    fn as_proof_is_the_one_declared_mapping() {
3150        use crate::rules::Class;
3151        use ProofState as P;
3152        use RuleState as R;
3153        for class in [Class::Blocking, Class::Advisory, Class::Unknown] {
3154            let same = [
3155                (R::Proven, P::Proven),
3156                (R::InputsUnchanged, P::InputsUnchanged),
3157                (R::Stale, P::Stale),
3158                (R::Failing, P::Failing),
3159                (R::NotRun, P::NotRun),
3160                (R::Unrunnable, P::Unrunnable),
3161            ];
3162            for (r, p) in same {
3163                assert_eq!(r.as_proof(class), (p, true), "{r:?}");
3164            }
3165            assert_eq!(R::Gated.as_proof(class), (P::NotRun, true));
3166            assert_eq!(R::Reviewed.as_proof(class), (P::NoTest, false));
3167            assert_eq!(R::Dangling.as_proof(class), (P::Unrunnable, true));
3168            let blocking = class == Class::Blocking;
3169            assert_eq!(
3170                R::Unproven.as_proof(class),
3171                (P::NoTest, blocking),
3172                "{class:?}"
3173            );
3174        }
3175    }
3176
3177    // ------------------------------------------------------------ the judge
3178
3179    /// A git repository of its own, with the one case the small world names.
3180    struct Repo {
3181        dir: tempfile::TempDir,
3182    }
3183
3184    const CASE: &str = "test/cases/01_alpha.sh";
3185
3186    impl Repo {
3187        fn new() -> Repo {
3188            let repo = Repo {
3189                dir: tempfile::tempdir().unwrap(),
3190            };
3191            repo.git(&["init", "-q"]);
3192            repo.git(&["config", "user.email", "t@example.com"]);
3193            repo.git(&["config", "user.name", "t"]);
3194            repo.git(&["config", "commit.gpgsign", "false"]);
3195            repo.write(CASE, "echo ok\n");
3196            repo.write("lib/alpha.sh", "alpha\n");
3197            repo.commit("one");
3198            repo
3199        }
3200        fn root(&self) -> &Path {
3201            self.dir.path()
3202        }
3203        fn git(&self, args: &[&str]) -> String {
3204            let out = std::process::Command::new("git")
3205                .arg("-C")
3206                .arg(self.root())
3207                .args(args)
3208                .output()
3209                .unwrap();
3210            assert!(out.status.success(), "git {args:?}: {out:?}");
3211            String::from_utf8_lossy(&out.stdout).trim().to_string()
3212        }
3213        fn write(&self, path: &str, text: &str) {
3214            let p = self.root().join(path);
3215            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3216            std::fs::write(p, text).unwrap();
3217        }
3218        fn commit(&self, message: &str) -> String {
3219            self.git(&["add", "-A"]);
3220            self.git(&["commit", "-q", "-m", message]);
3221            self.git(&["rev-parse", "HEAD"])
3222        }
3223        fn presented(&self, tree: TreeState) -> Presented {
3224            crate::evidence::freshness::presented_commit(self.root(), "HEAD", Some(tree)).unwrap()
3225        }
3226        /// The digest the ledger records for the case as it is now.
3227        fn digest(&self) -> String {
3228            crate::evidence::digest_of(&std::fs::read(self.root().join(CASE)).unwrap())
3229        }
3230    }
3231
3232    /// One execution, built the way the ledger reads one.
3233    fn ran(test: &str, outcome: &str, commit: &str, tree: &str, digest: &str) -> Execution {
3234        let source = TestId::of(CASE).unwrap().source();
3235        serde_json::from_value(json!({
3236            "test": test, "runner": "suite", "source": source, "outcome": outcome,
3237            "seconds": 1, "commit": commit, "working_tree": tree, "digest": digest,
3238            "at": "2026-09-26T00:00:00Z", "origin": "local", "command": "bash test/run.sh x"
3239        }))
3240        .unwrap()
3241    }
3242
3243    fn ledger(executions: &[&Execution]) -> Ledger {
3244        let mut l = Ledger::empty();
3245        l.merge(executions.iter().map(|e| (*e).clone()));
3246        l
3247    }
3248
3249    /// A claim proof as the claim join would give it.
3250    fn claim_proof(id: &str, status: &str, state: ProofState, e: Option<&Execution>) -> Value {
3251        json!({
3252            "id": id, "claim": id, "status": status, "source": "docs/A.md",
3253            "test_path": CASE, "test": "suite:01_alpha", "state": state, "meaning": "",
3254            "execution": e, "changed": [], "reproduce": "bash test/run.sh 01_alpha"
3255        })
3256    }
3257
3258    /// A claim report over `claims`, stating `presented` as the report would.
3259    fn claims_report(claims: Vec<Value>, presented: &Presented) -> EvidenceReport {
3260        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3261        let index = repo.index().unwrap();
3262        let mut v =
3263            serde_json::to_value(crate::evidence::report(&index, &Ledger::empty())).unwrap();
3264        v["claims"] = Value::Array(claims);
3265        v["presented"] = json!({
3266            "revision": presented.commit().unwrap_or("working_tree"),
3267            "tree": presented.tree(),
3268        });
3269        serde_json::from_value(v).unwrap()
3270    }
3271
3272    /// A test proof as the rules report would give it.
3273    fn test_proof(state: ProofState, e: Option<&Execution>) -> Value {
3274        json!({
3275            "path": CASE, "kind": crate::rules::ArtifactKind::Case, "present": true,
3276            "test": "suite:01_alpha", "state": state, "meaning": "", "execution": e,
3277            "reproduce": "bash test/run.sh 01_alpha"
3278        })
3279    }
3280
3281    /// A rules report over `(id, state, tests)`, each a blocking rule, on the synthetic
3282    /// report's shape.
3283    fn rules_report(rules: &[(&str, RuleState, Vec<Value>)]) -> RulesReport {
3284        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3285        let index = repo.index().unwrap();
3286        let mut v = serde_json::to_value(crate::rules::report(&index, &Ledger::empty())).unwrap();
3287        let template = v["rules"][0].clone();
3288        let rules: Vec<Value> = rules
3289            .iter()
3290            .map(|(id, state, tests)| {
3291                let mut r = template.clone();
3292                r["rule"]["id"] = json!(id);
3293                r["rule"]["class"] = json!(crate::rules::Class::Blocking);
3294                r["rule"]["path"] = json!(".ai/repo/rules/project/alpha.v1.md");
3295                r["tests"] = json!(tests);
3296                r["state"] = json!(state);
3297                r
3298            })
3299            .collect();
3300        v["rules"] = Value::Array(rules);
3301        serde_json::from_value(v).unwrap()
3302    }
3303
3304    /// The small world: `claim:alpha`, `rule:project.alpha` and `command:version` all reach
3305    /// `suite:01_alpha`, and `feature:alpha` is made of them.
3306    fn judged(
3307        d: &Declarations,
3308        g: &Given<'_>,
3309        root: &Path,
3310        presented: &Presented,
3311        keys: &[&str],
3312    ) -> Vec<SubjectEvidence> {
3313        let s = derive(d);
3314        let mut judge = Judge::new(
3315            &s,
3316            g.claims,
3317            g.rules,
3318            g.ledger,
3319            g.uncommitted,
3320            root,
3321            presented,
3322        );
3323        keys.iter().map(|k| judge.subject(k).unwrap()).collect()
3324    }
3325
3326    /// What a judge is given besides the index.
3327    struct Given<'a> {
3328        claims: &'a EvidenceReport,
3329        rules: &'a RulesReport,
3330        ledger: &'a Ledger,
3331        uncommitted: &'a [Execution],
3332    }
3333
3334    fn route(answer: &SubjectEvidence, via: Via) -> &RouteProof {
3335        answer
3336            .routes
3337            .iter()
3338            .find(|r| r.route.via == via)
3339            .unwrap_or_else(|| panic!("{} has no {via:?} route", answer.subject))
3340    }
3341
3342    /// `claim:alpha`, `rule:project.alpha` and `command:version` each reach `suite:01_alpha`;
3343    /// `feature:alpha` is made of the three.
3344    fn small() -> Declarations {
3345        Declarations {
3346            claims: vec![claim("alpha", "guaranteed", "lib/alpha.sh", CASE)],
3347            rules: vec![RuleDecl {
3348                id: "project.alpha".into(),
3349                status: "active".into(),
3350                path: ".ai/repo/rules/project/alpha.v1.md".into(),
3351                tests: strings(&[CASE]),
3352            }],
3353            features: vec![FeatureDecl {
3354                id: "alpha".into(),
3355                status: "stable".into(),
3356                claims: strings(&["alpha"]),
3357                rules: strings(&["project.alpha"]),
3358                commands: strings(&["version"]),
3359                ..FeatureDecl::default()
3360            }],
3361            commands: strings(&["version"]),
3362            cases: vec![case("01_alpha", &["version"], &[])],
3363            ..Declarations::default()
3364        }
3365    }
3366
3367    #[test]
3368    fn a_claim_route_is_the_claim_join() {
3369        let repo = Repo::new();
3370        let mut proof = claim_proof("alpha", "guaranteed", ProofState::Stale, None);
3371        proof["changed"] = json!(["lib/alpha.sh"]);
3372        proof["detail"] = json!("the join's own reason");
3373        let rules = rules_report(&[]);
3374        let empty = Ledger::empty();
3375        for presented in [Presented::WorkingTree, repo.presented(TreeState::Clean)] {
3376            let claims = claims_report(vec![proof.clone()], &presented);
3377            let g = Given {
3378                claims: &claims,
3379                rules: &rules,
3380                ledger: &empty,
3381                uncommitted: &[],
3382            };
3383            let a = &judged(&small(), &g, repo.root(), &presented, &["claim:alpha"])[0];
3384            let r = route(a, Via::Claim);
3385            assert_eq!(r.state, ProofState::Stale, "{presented:?}");
3386            assert_eq!(r.changed, vec!["lib/alpha.sh".to_string()]);
3387            assert_eq!(r.detail.as_deref(), Some("the join's own reason"));
3388            assert_eq!((a.verdict, a.carries_proof), (ProofState::Stale, true));
3389        }
3390    }
3391
3392    #[test]
3393    fn a_rule_subject_is_its_rule_proof_through_as_proof() {
3394        let repo = Repo::new();
3395        let c1 = repo.git(&["rev-parse", "HEAD"]);
3396        let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3397        let rules = rules_report(&[(
3398            "project.alpha",
3399            RuleState::Gated,
3400            vec![test_proof(ProofState::Proven, Some(&e))],
3401        )]);
3402        let p = Presented::WorkingTree;
3403        let claims = claims_report(vec![], &p);
3404        let l = ledger(&[&e]);
3405        let g = Given {
3406            claims: &claims,
3407            rules: &rules,
3408            ledger: &l,
3409            uncommitted: &[],
3410        };
3411        let a = &judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"])[0];
3412        assert_eq!(route(a, Via::Rule).state, ProofState::Proven);
3413        assert_eq!(
3414            a.verdict,
3415            ProofState::NotRun,
3416            "a gated rule is not a recorded run"
3417        );
3418        assert_eq!(a.rule_state, Some(RuleState::Gated));
3419        assert!(a.carries_proof);
3420    }
3421
3422    #[test]
3423    fn a_command_route_is_judged_at_the_presented_revision() {
3424        let repo = Repo::new();
3425        let c1 = repo.git(&["rev-parse", "HEAD"]);
3426        let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3427        let l = ledger(&[&e]);
3428        let rules = rules_report(&[]);
3429        let at = |presented: &Presented| {
3430            let claims = claims_report(vec![], presented);
3431            let g = Given {
3432                claims: &claims,
3433                rules: &rules,
3434                ledger: &l,
3435                uncommitted: &[],
3436            };
3437            let a = judged(&small(), &g, repo.root(), presented, &["command:version"]);
3438            let r = route(&a[0], Via::Behaviour);
3439            (r.state, r.detail.clone())
3440        };
3441        // C2 commits only the ledger: nothing the route could be about changed
3442        repo.write(crate::evidence::LEDGER_PATH, "{}\n");
3443        repo.commit("the ledger");
3444        assert_eq!(at(&repo.presented(TreeState::Clean)).0, ProofState::Proven);
3445        assert_eq!(
3446            at(&repo.presented(TreeState::Dirty)).0,
3447            ProofState::InputsUnchanged,
3448            "a dirty presented tree withholds proven"
3449        );
3450        // C3 commits an unrelated file, and the route declares no inputs
3451        repo.write("docs/other.md", "other\n");
3452        repo.commit("other");
3453        let (state, detail) = at(&repo.presented(TreeState::Clean));
3454        assert_eq!(state, ProofState::Stale);
3455        assert!(detail.is_some(), "a stale route says why");
3456    }
3457
3458    #[test]
3459    fn an_uncommitted_clean_failure_caps_a_behaviour_route_at_a_commit() {
3460        let repo = Repo::new();
3461        let c1 = repo.git(&["rev-parse", "HEAD"]);
3462        let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3463        repo.write(crate::evidence::LEDGER_PATH, "{}\n");
3464        let c2 = repo.commit("the ledger");
3465        let failed = ran("suite:01_alpha", "fail", &c2, "clean", &repo.digest());
3466        let l = ledger(&[&e]);
3467        let rules = rules_report(&[]);
3468        let uncommitted = vec![failed];
3469        let at = |presented: &Presented| {
3470            let claims = claims_report(vec![], presented);
3471            let g = Given {
3472                claims: &claims,
3473                rules: &rules,
3474                ledger: &l,
3475                uncommitted: &uncommitted,
3476            };
3477            let a = judged(&small(), &g, repo.root(), presented, &["command:version"]);
3478            route(&a[0], Via::Behaviour).clone()
3479        };
3480        let r = at(&repo.presented(TreeState::Clean));
3481        assert_eq!(r.state, ProofState::Stale);
3482        assert!(r.detail.unwrap().contains(UNCOMMITTED_RUN));
3483        assert_eq!(at(&Presented::WorkingTree).state, ProofState::Proven);
3484    }
3485
3486    #[test]
3487    fn a_failure_outranks_an_absence_in_a_subject() {
3488        let repo = Repo::new();
3489        let c1 = repo.git(&["rev-parse", "HEAD"]);
3490        let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3491        let l = ledger(&[&failed]);
3492        let p = Presented::WorkingTree;
3493        let claims = claims_report(
3494            vec![claim_proof("alpha", "guaranteed", ProofState::NotRun, None)],
3495            &p,
3496        );
3497        let rules = rules_report(&[(
3498            "project.alpha",
3499            RuleState::NotRun,
3500            vec![test_proof(ProofState::NotRun, None)],
3501        )]);
3502        let g = Given {
3503            claims: &claims,
3504            rules: &rules,
3505            ledger: &l,
3506            uncommitted: &[],
3507        };
3508        let a = judged(
3509            &small(),
3510            &g,
3511            repo.root(),
3512            &p,
3513            &["command:version", "feature:alpha"],
3514        );
3515        assert_eq!(a[0].verdict, ProofState::Failing);
3516        assert_eq!(a[1].verdict, ProofState::Failing, "{:?}", a[1].members);
3517    }
3518
3519    #[test]
3520    fn a_scenario_never_decides_unless_it_is_all_there_is() {
3521        let repo = Repo::new();
3522        let mut d = small();
3523        d.use_cases = vec![UseCaseDecl {
3524            id: "lone".into(),
3525            status: Some("active".into()),
3526            path: ".ai/repo/use-cases/lone.md".into(),
3527            ..UseCaseDecl::default()
3528        }];
3529        d.features = vec![FeatureDecl {
3530            id: "f".into(),
3531            status: "stable".into(),
3532            claims: strings(&["alpha"]),
3533            use_cases: strings(&["lone"]),
3534            ..FeatureDecl::default()
3535        }];
3536        let p = Presented::WorkingTree;
3537        let claims = claims_report(
3538            vec![claim_proof("alpha", "guaranteed", ProofState::Proven, None)],
3539            &p,
3540        );
3541        let rules = rules_report(&[]);
3542        let empty = Ledger::empty();
3543        let g = Given {
3544            claims: &claims,
3545            rules: &rules,
3546            ledger: &empty,
3547            uncommitted: &[],
3548        };
3549        let a = judged(&d, &g, repo.root(), &p, &["use_case:lone", "feature:f"]);
3550        assert_eq!(
3551            (a[0].verdict, a[0].carries_proof),
3552            (ProofState::NotRun, false)
3553        );
3554        assert!(route(&a[0], Via::Scenario).detail.is_some());
3555        assert_eq!(a[1].verdict, ProofState::Proven);
3556    }
3557
3558    #[test]
3559    fn git_that_cannot_compare_is_a_finding() {
3560        let repo = Repo::new();
3561        let mut d = small();
3562        d.cases = vec![case("01_alpha", &["version"], &["version"])];
3563        let e = ran(
3564            "suite:01_alpha",
3565            "pass",
3566            &"f".repeat(40),
3567            "clean",
3568            &repo.digest(),
3569        );
3570        let l = ledger(&[&e]);
3571        let p = Presented::WorkingTree;
3572        let claims = claims_report(vec![], &p);
3573        let rules = rules_report(&[]);
3574        let g = Given {
3575            claims: &claims,
3576            rules: &rules,
3577            ledger: &l,
3578            uncommitted: &[],
3579        };
3580        let a = &judged(&d, &g, repo.root(), &p, &["command:version"])[0];
3581        assert_eq!(a.routes.len(), 2, "a behaviour and a negative route");
3582        assert!(a.routes.iter().all(|r| r.state == ProofState::Stale));
3583        let unknown: Vec<_> = a
3584            .subject_findings
3585            .iter()
3586            .filter(|f| f.code == FRESHNESS_UNKNOWN)
3587            .collect();
3588        assert_eq!(unknown.len(), 1, "one per commit, not one per route");
3589        assert!(unknown[0].message.contains(&"f".repeat(12)));
3590    }
3591
3592    #[test]
3593    fn a_dangling_rule_member_is_named() {
3594        let repo = Repo::new();
3595        let p = Presented::WorkingTree;
3596        let claims = claims_report(vec![], &p);
3597        let rules = rules_report(&[("project.alpha", RuleState::Dangling, vec![])]);
3598        let empty = Ledger::empty();
3599        let g = Given {
3600            claims: &claims,
3601            rules: &rules,
3602            ledger: &empty,
3603            uncommitted: &[],
3604        };
3605        let a = &judged(&small(), &g, repo.root(), &p, &["feature:alpha"])[0];
3606        let dangling: Vec<_> = a
3607            .subject_findings
3608            .iter()
3609            .filter(|f| f.code == DANGLING_MEMBER)
3610            .collect();
3611        assert_eq!(dangling.len(), 1, "{:?}", a.subject_findings);
3612        assert!(dangling[0].message.contains("project.alpha"));
3613    }
3614
3615    #[test]
3616    fn an_unknown_subject_is_refused_by_name() {
3617        let repo = Repo::new();
3618        let p = Presented::WorkingTree;
3619        let claims = claims_report(vec![], &p);
3620        let rules = rules_report(&[]);
3621        let empty = Ledger::empty();
3622        let s = derive(&small());
3623        let mut judge = Judge::new(&s, &claims, &rules, &empty, &[], repo.root(), &p);
3624        for key in ["feature:no-such", "nonsense", "gate:x"] {
3625            let err = judge.subject(key).unwrap_err();
3626            assert!(err.to_string().contains(key), "{err}");
3627        }
3628    }
3629
3630    #[test]
3631    fn the_answer_names_the_revision_it_judged() {
3632        let repo = Repo::new();
3633        let head = repo.git(&["rev-parse", "HEAD"]);
3634        let rules = rules_report(&[]);
3635        let empty = Ledger::empty();
3636        for (p, revision) in [
3637            (Presented::WorkingTree, "working_tree".to_string()),
3638            (repo.presented(TreeState::Clean), head.clone()),
3639        ] {
3640            let claims = claims_report(vec![], &p);
3641            let g = Given {
3642                claims: &claims,
3643                rules: &rules,
3644                ledger: &empty,
3645                uncommitted: &[],
3646            };
3647            let a = &judged(&small(), &g, repo.root(), &p, &["command:version"])[0];
3648            assert_eq!(a.presented, claims.presented);
3649            assert_eq!(a.presented.revision, revision);
3650        }
3651    }
3652
3653    #[test]
3654    fn totals_count_every_part_by_label() {
3655        let repo = Repo::new();
3656        let p = Presented::WorkingTree;
3657        let claims = claims_report(
3658            vec![claim_proof("alpha", "guaranteed", ProofState::NotRun, None)],
3659            &p,
3660        );
3661        let rules = rules_report(&[(
3662            "project.alpha",
3663            RuleState::NotRun,
3664            vec![test_proof(ProofState::NotRun, None)],
3665        )]);
3666        let empty = Ledger::empty();
3667        let g = Given {
3668            claims: &claims,
3669            rules: &rules,
3670            ledger: &empty,
3671            uncommitted: &[],
3672        };
3673        let a = judged(
3674            &small(),
3675            &g,
3676            repo.root(),
3677            &p,
3678            &["feature:alpha", "rule:project.alpha"],
3679        );
3680        // three members, each not run; the feature has no route of its own
3681        assert_eq!(a[0].totals, BTreeMap::from([("not run".to_string(), 3)]));
3682        // a rule counts its routes only
3683        assert_eq!(a[1].totals, BTreeMap::from([("not run".to_string(), 1)]));
3684    }
3685
3686    /// A world where one rule is the only member of a feature.
3687    fn rule_only() -> Declarations {
3688        let mut d = small();
3689        d.features = vec![FeatureDecl {
3690            id: "f".into(),
3691            status: "stable".into(),
3692            rules: strings(&["project.alpha"]),
3693            ..FeatureDecl::default()
3694        }];
3695        d
3696    }
3697
3698    #[test]
3699    fn a_dirty_presented_tree_caps_every_rule_derived_state() {
3700        // a clean checkout at C1, and the rule's one case passed there on a clean tree: the
3701        // rules report, which judges the working tree, reads it proven
3702        let repo = Repo::new();
3703        let c1 = repo.git(&["rev-parse", "HEAD"]);
3704        let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3705        let rules = rules_report(&[(
3706            "project.alpha",
3707            RuleState::Proven,
3708            vec![test_proof(ProofState::Proven, Some(&e))],
3709        )]);
3710        let l = ledger(&[&e]);
3711        let at = |tree: TreeState| {
3712            let p = repo.presented(tree);
3713            let claims = claims_report(vec![], &p);
3714            let g = Given {
3715                claims: &claims,
3716                rules: &rules,
3717                ledger: &l,
3718                uncommitted: &[],
3719            };
3720            judged(
3721                &rule_only(),
3722                &g,
3723                repo.root(),
3724                &p,
3725                &["rule:project.alpha", "feature:f"],
3726            )
3727        };
3728        let dirty = at(TreeState::Dirty);
3729        let r = route(&dirty[0], Via::Rule);
3730        assert_eq!(r.state, ProofState::InputsUnchanged);
3731        assert!(r.detail.is_some(), "the cap states A1's reason");
3732        assert_eq!(dirty[0].verdict, ProofState::InputsUnchanged);
3733        assert_eq!(dirty[0].rule_state, Some(RuleState::InputsUnchanged));
3734        assert_eq!(dirty[0].totals.get("proven"), None);
3735        assert_eq!(dirty[1].verdict, ProofState::InputsUnchanged);
3736        assert_eq!(
3737            dirty[1].members[0].rule_state,
3738            Some(RuleState::InputsUnchanged)
3739        );
3740        // the control: a clean presented tree leaves all three proven
3741        let clean = at(TreeState::Clean);
3742        assert_eq!(route(&clean[0], Via::Rule).state, ProofState::Proven);
3743        assert_eq!(clean[0].verdict, ProofState::Proven);
3744        assert_eq!(clean[0].rule_state, Some(RuleState::Proven));
3745        assert_eq!(clean[1].verdict, ProofState::Proven);
3746    }
3747
3748    #[test]
3749    fn an_adjuster_weakens_before_aggregation_and_never_strengthens() {
3750        let repo = Repo::new();
3751        let c1 = repo.git(&["rev-parse", "HEAD"]);
3752        let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3753        let l = ledger(&[&e]);
3754        let p = Presented::WorkingTree;
3755        let claims = claims_report(
3756            vec![claim_proof(
3757                "alpha",
3758                "guaranteed",
3759                ProofState::Proven,
3760                Some(&e),
3761            )],
3762            &p,
3763        );
3764        let rules = rules_report(&[(
3765            "project.alpha",
3766            RuleState::Proven,
3767            vec![test_proof(ProofState::Proven, Some(&e))],
3768        )]);
3769        let mut d = rule_only();
3770        d.features.extend(small().features);
3771        let keys = [
3772            "command:version",
3773            "claim:alpha",
3774            "rule:project.alpha",
3775            "feature:f",
3776        ];
3777        let s = derive(&d);
3778        let run = |adjust: Option<&Adjust<'_>>, ledger: &Ledger| {
3779            let mut judge = Judge::new(&s, &claims, &rules, ledger, &[], repo.root(), &p);
3780            if let Some(a) = adjust {
3781                judge = judge.with_adjust(a);
3782            }
3783            keys.iter()
3784                .map(|k| judge.subject(k).unwrap())
3785                .collect::<Vec<_>>()
3786        };
3787        let plain = run(None, &l);
3788        assert!(
3789            plain.iter().all(|a| a.verdict == ProofState::Proven),
3790            "{plain:?}"
3791        );
3792        let identity: &Adjust<'_> = &|_, _, j| j;
3793        assert_eq!(run(Some(identity), &l), plain);
3794
3795        let cap: &Adjust<'_> = &|t, _, j| match t.as_string().as_str() {
3796            "suite:01_alpha" => Judgement {
3797                state: ProofState::Stale,
3798                changed: Vec::new(),
3799                detail: Some("capped by a record".into()),
3800            },
3801            _ => j,
3802        };
3803        let capped = run(Some(cap), &l);
3804        assert_eq!(route(&capped[0], Via::Behaviour).state, ProofState::Stale);
3805        for a in &capped {
3806            assert_eq!(a.verdict, ProofState::Stale, "{}", a.subject);
3807        }
3808        assert_eq!(
3809            capped[2].rule_state,
3810            Some(RuleState::Proven),
3811            "the rule state stays"
3812        );
3813
3814        let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3815        let proven: &Adjust<'_> = &|_, _, _| Judgement {
3816            state: ProofState::Proven,
3817            changed: Vec::new(),
3818            detail: None,
3819        };
3820        let never = run(Some(proven), &ledger(&[&failed]));
3821        assert_eq!(route(&never[0], Via::Behaviour).state, ProofState::Failing);
3822    }
3823
3824    #[test]
3825    fn a_planned_or_rejected_claim_carries_no_proof_joined_or_not() {
3826        let repo = Repo::new();
3827        let mut d = small();
3828        d.claims.extend([
3829            claim("beta", "planned", "lib/alpha.sh", CASE),
3830            claim("gamma", "rejected", "lib/alpha.sh", CASE),
3831            claim("delta", "guaranteed", "lib/alpha.sh", CASE),
3832        ]);
3833        let p = Presented::WorkingTree;
3834        // alpha and beta are in the claim join; gamma and delta are not
3835        let claims = claims_report(
3836            vec![
3837                claim_proof("alpha", "guaranteed", ProofState::NotRun, None),
3838                claim_proof("beta", "planned", ProofState::NotRun, None),
3839            ],
3840            &p,
3841        );
3842        let rules = rules_report(&[]);
3843        let empty = Ledger::empty();
3844        let g = Given {
3845            claims: &claims,
3846            rules: &rules,
3847            ledger: &empty,
3848            uncommitted: &[],
3849        };
3850        let keys = ["claim:alpha", "claim:beta", "claim:gamma", "claim:delta"];
3851        let a = judged(&d, &g, repo.root(), &p, &keys);
3852        let carries: Vec<bool> = a
3853            .iter()
3854            .map(|s| route(s, Via::Claim).carries_proof)
3855            .collect();
3856        assert_eq!(carries, [true, false, false, true], "{keys:?}");
3857        for unjoined in &a[2..] {
3858            let r = route(unjoined, Via::Claim);
3859            assert_eq!(r.state, ProofState::NoTest, "{}", unjoined.subject);
3860            assert!(r.detail.as_deref().unwrap().contains("this claim"));
3861        }
3862        assert!(!a[1].carries_proof && !a[2].carries_proof);
3863    }
3864
3865    #[test]
3866    fn a_proven_rule_test_with_no_run_reads_stale_on_a_dirty_presented_tree() {
3867        let repo = Repo::new();
3868        let rules = rules_report(&[(
3869            "project.alpha",
3870            RuleState::Proven,
3871            vec![test_proof(ProofState::Proven, None)],
3872        )]);
3873        let empty = Ledger::empty();
3874        let at = |tree: TreeState| {
3875            let p = repo.presented(tree);
3876            let claims = claims_report(vec![], &p);
3877            let g = Given {
3878                claims: &claims,
3879                rules: &rules,
3880                ledger: &empty,
3881                uncommitted: &[],
3882            };
3883            let a = judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"]);
3884            route(&a[0], Via::Rule).clone()
3885        };
3886        let dirty = at(TreeState::Dirty);
3887        assert_eq!(dirty.state, ProofState::Stale);
3888        assert_eq!(dirty.detail.as_deref(), Some(NO_EXECUTION_DETAIL));
3889        assert!(dirty.changed.is_empty());
3890        assert!(dirty.execution.is_none());
3891        // the control: a clean presented tree keeps the report's own state and no reason
3892        let clean = at(TreeState::Clean);
3893        assert_eq!((clean.state, clean.detail), (ProofState::Proven, None));
3894    }
3895
3896    #[test]
3897    fn a_rule_the_report_does_not_carry_is_untested_and_carries_nothing() {
3898        let repo = Repo::new();
3899        let p = Presented::WorkingTree;
3900        let claims = claims_report(vec![], &p);
3901        let rules = rules_report(&[]);
3902        let empty = Ledger::empty();
3903        let g = Given {
3904            claims: &claims,
3905            rules: &rules,
3906            ledger: &empty,
3907            uncommitted: &[],
3908        };
3909        let a = &judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"])[0];
3910        assert_eq!(
3911            (a.verdict, a.carries_proof, a.rule_state),
3912            (ProofState::NoTest, false, None)
3913        );
3914        let r = route(a, Via::Rule);
3915        assert_eq!(r.state, ProofState::NoTest);
3916        assert!(r.detail.as_deref().unwrap().contains("this rule"));
3917    }
3918
3919    #[test]
3920    fn an_adjuster_never_sees_a_route_that_names_no_test() {
3921        let repo = Repo::new();
3922        let mut d = small();
3923        d.claims = vec![claim("alpha", "guaranteed", "lib/alpha.sh", "-")];
3924        let p = Presented::WorkingTree;
3925        let mut proof = claim_proof("alpha", "guaranteed", ProofState::NoTest, None);
3926        proof["test_path"] = Value::Null;
3927        proof["test"] = Value::Null;
3928        let claims = claims_report(vec![proof], &p);
3929        let rules = rules_report(&[]);
3930        let empty = Ledger::empty();
3931        let s = derive(&d);
3932        let asked = std::cell::Cell::new(0);
3933        let failing: &Adjust<'_> = &|_, _, _| {
3934            asked.set(asked.get() + 1);
3935            Judgement {
3936                state: ProofState::Failing,
3937                changed: Vec::new(),
3938                detail: Some("the adjuster".into()),
3939            }
3940        };
3941        let mut judge =
3942            Judge::new(&s, &claims, &rules, &empty, &[], repo.root(), &p).with_adjust(failing);
3943        let a = judge.subject("claim:alpha").unwrap();
3944        let r = route(&a, Via::Claim);
3945        assert!(r.route.test.is_none(), "{:?}", r.route);
3946        assert_eq!(
3947            r.state,
3948            ProofState::NoTest,
3949            "a route without a test keeps its state"
3950        );
3951        assert_eq!(
3952            asked.get(),
3953            0,
3954            "the adjuster is asked about a test, and there is none"
3955        );
3956        // the control: the same adjuster weakens the command route, which names a test
3957        let c = judge.subject("command:version").unwrap();
3958        assert_eq!(route(&c, Via::Behaviour).state, ProofState::Failing);
3959        assert_eq!(asked.get(), 1);
3960    }
3961
3962    #[test]
3963    fn a_member_the_index_does_not_hold_is_no_part_of_the_verdict() {
3964        // an index read from elsewhere (it is deserialisable) can name a member it does not
3965        // carry, or a key that is not a key at all; the judge skips both rather than failing
3966        let repo = Repo::new();
3967        let c1 = repo.git(&["rev-parse", "HEAD"]);
3968        let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3969        let l = ledger(&[&failed]);
3970        let p = Presented::WorkingTree;
3971        let claims = claims_report(
3972            vec![claim_proof("alpha", "guaranteed", ProofState::Proven, None)],
3973            &p,
3974        );
3975        let rules = rules_report(&[]);
3976        let mut s = derive(&small());
3977        let feature = s.subjects.get_mut("feature:alpha").unwrap();
3978        let declared = feature.members.clone();
3979        feature
3980            .members
3981            .extend(["claim:no-such".to_string(), "nonsense".to_string()]);
3982        let mut judge = Judge::new(&s, &claims, &rules, &l, &[], repo.root(), &p);
3983        let a = judge.subject("feature:alpha").unwrap();
3984        let named: Vec<&str> = a.members.iter().map(|m| m.subject.as_str()).collect();
3985        let expected: Vec<&str> = declared.iter().map(String::as_str).collect();
3986        assert!(expected.contains(&"command:version"), "{expected:?}");
3987        assert_eq!(named, expected, "only members the index holds are judged");
3988        assert_eq!(
3989            a.verdict,
3990            ProofState::Failing,
3991            "the held members still decide"
3992        );
3993        let counted: usize = a.totals.values().sum();
3994        assert_eq!(counted, a.members.len(), "{:?}", a.totals);
3995    }
3996
3997    #[test]
3998    fn answer_is_the_judge_over_the_reports_of_the_given_ledger() {
3999        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
4000        let ctx = repo.context().unwrap();
4001        let (ledger, p) = (Ledger::empty(), Presented::WorkingTree);
4002        let ask = |key: &str| {
4003            answer(
4004                &ctx.index,
4005                &ctx.product,
4006                &ctx.registry,
4007                &ledger,
4008                &[],
4009                &p,
4010                key,
4011            )
4012        };
4013        let err = ask("feature:no-such").unwrap_err();
4014        assert_eq!(err.key, "feature:no-such");
4015        let key = "capability:capabilities.list";
4016        let got = ask(key).unwrap();
4017        assert_eq!(
4018            (got.subject.as_str(), got.kind),
4019            (key, SubjectKind::Capability)
4020        );
4021        assert_eq!(got.presented.revision, "working_tree");
4022        // the same answer a judge built by hand gives, over the same two reports
4023        let s = index(&ctx.index, &ctx.product, &ctx.registry);
4024        let claims = crate::evidence::report_at(&ctx.index, &ledger, &p, &[]);
4025        let rules = crate::rules::report(&ctx.index, &ledger);
4026        let root = PathBuf::from(&ctx.index.repository.root);
4027        let mut judge = Judge::new(&s, &claims, &rules, &ledger, &[], &root, &p);
4028        assert_eq!(got, judge.subject(key).unwrap());
4029        let tool = ask("mcp:majordomus_capabilities").unwrap();
4030        assert_eq!(tool.members.len(), 1, "an alias is made of its capability");
4031    }
4032}