Skip to main content

majordomus_cli/rules/
mod.rs

1//! The rules module: a rule, and everything the repository can show that proves it.
2//!
3//! A rule of this repository is a Markdown object under the rules section with YAML front
4//! matter: an id, a version, a class (`blocking` or `advisory`), a statement, and — since
5//! the proof relation became load-bearing — an `x-majordomus` block naming what enforces
6//! it. This module is the one typed reading of that object, and the one place the
7//! repository decides what a rule's proof amounts to.
8//!
9//! # Why this exists rather than another list
10//!
11//! Before it, "is this rule enforced?" was answered by `o.metadata.get("x-majordomus")
12//! .is_some()` — a green badge for the presence of a YAML key. A rule could name
13//! `test/cases/28_no_hardcoded_values.sh` after that case was deleted and still read as
14//! enforced on every surface that asked. The proof graph here answers the question the
15//! badge pretended to: the named validator is in the tree, the named cases are in the tree,
16//! a runner owns them, a run of them was recorded, and that run is not older than what it
17//! is about.
18//!
19//! # What it does not do
20//!
21//! It does not re-implement evidence. [`crate::evidence`] already binds a test path to the
22//! executions recorded against it and already decides, with [`ProofState`], what a passing
23//! run from an older commit is worth. A rule's test-level proof *is* that vocabulary; this
24//! module joins rules to it rather than inventing a second one. Nor does it dispatch: the
25//! portable shell layer's `lib/doctrine.sh` runs the validators, and `scripts/ci/
26//! rule-proof-check` is the gate. This is the reading all of those project from.
27//!
28//! # The two enforcement modes
29//!
30//! The canonical front matter allows exactly two shapes, and the distinction is not
31//! cosmetic:
32//!
33//! * **dispatched** — the block names a `validator`, which `lib/doctrine.sh` calls at run
34//!   time. It carries `category`, `exit_code` and `enforced_by` with it; a half-declaration
35//!   (a category without a validator) is a defect the loader refuses rather than a rule
36//!   with a missing field.
37//! * **gated** — the block names `tests` and no validator. Nothing dispatches it; the
38//!   behavioural cases are what prove it, and a CI gate is what runs them.
39//!
40//! Either mode names at least one test. A blocking rule that names neither is `Unproven`,
41//! which is the state this module exists to make visible rather than absent.
42
43//! # Example
44//!
45//! The vocabulary, without a repository: a class is read rather than guessed, the mode is
46//! whatever the block names, and every state ranks and says what it means.
47//!
48//! ```
49//! use majordomus_cli::rules::{Class, Mode, RuleState};
50//!
51//! assert_eq!(Class::parse("blocking"), Class::Blocking);
52//! assert_eq!(Class::parse("strict"), Class::Unknown);
53//! assert_eq!(Mode::Reviewed.label(), "reviewed");
54//! assert!(RuleState::Proven < RuleState::Dangling);
55//! assert!(!RuleState::Gated.passing());
56//! ```
57
58use std::collections::{BTreeMap, BTreeSet};
59use std::path::{Path, PathBuf};
60
61use schemars::JsonSchema;
62use serde::{Deserialize, Serialize};
63use serde_json::Value;
64
65use crate::evidence::freshness::{self, Comparison, Presented, Recorded, TreeState};
66use crate::evidence::{Execution, Ledger, ProofState, TestId};
67use crate::index::Index;
68
69/// The URI under which the whole rule verification report is read as an MCP resource.
70pub const RULES_URI: &str = "majordomus://rules";
71
72/// Where the CI model of this repository is declared. Read to answer "which gate runs the
73/// case this rule names", so that a rule's gate coverage is derived from the planner's own
74/// input rather than restated here.
75pub const GATES_PATH: &str = ".ai/repo/ci/gates.yaml";
76
77/// Where the behavioural cases live, and the runner that drives all of them at once. A gate
78/// whose command names that runner runs every case under that directory, which is how a
79/// case gets from a rule's front matter to a gate without either naming the other.
80const CASES_DIR: &str = "test/cases/";
81const SUITE_RUNNER: &str = "test/run.sh";
82/// The same, for the crate's own integration tests.
83const CRATE_TESTS_DIR: &str = "apps/majordomus-cli/tests/";
84const CRATE_RUNNER: &str = "rust-check";
85/// The crate's sources. A module there may keep its `#[cfg(test)] mod tests` in a file of
86/// its own (`src/<module>/tests.rs`), which `cargo test` compiles into the library's test
87/// target — so the crate runner drives it in every mode that runs `cargo test`.
88const CRATE_SRC_DIR: &str = "apps/majordomus-cli/src/";
89/// The crate runner's modes that run no test: `--integration` builds the executable and
90/// checks the registry, `--doc` builds the documentation.
91const CRATE_RUNNER_TESTLESS_MODES: [&str; 2] = ["--integration", "--doc"];
92
93// ---------------------------------------------------------------- the canonical definition
94
95/// What a rule declares about how strongly it binds.
96///
97/// The repository has exactly two, and they are not severities on a scale: `blocking` means
98/// a gate refuses the work, `advisory` means a reader is expected to have read it. A rule
99/// whose front matter says neither is reported as [`Class::Unknown`] rather than defaulted,
100/// because guessing here is how a blocking rule quietly becomes advice.
101#[derive(
102    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
103)]
104#[serde(rename_all = "snake_case")]
105#[schemars(rename = "RuleClass")]
106/// # Example
107///
108/// ```
109/// use majordomus_cli::rules::Class;
110/// assert_eq!(Class::Blocking.label(), "blocking");
111/// assert_eq!(Class::parse("advisory"), Class::Advisory);
112/// ```
113pub enum Class {
114    /// A gate refuses work that violates it.
115    Blocking,
116    /// Normative for whoever reads it; nothing refuses a violation.
117    Advisory,
118    /// The front matter declared no class, or one this repository does not know.
119    Unknown,
120}
121
122impl Class {
123    /// The word every surface prints for this class, written here once so the command
124    /// line, the API and the Cockpit cannot disagree about what to call it.
125    ///
126    /// ```
127    /// use majordomus_cli::rules::Class;
128    /// assert_eq!(Class::Unknown.label(), "unknown");
129    /// ```
130    pub fn label(self) -> &'static str {
131        match self {
132            Class::Blocking => "blocking",
133            Class::Advisory => "advisory",
134            Class::Unknown => "unknown",
135        }
136    }
137
138    /// Read a class from the front matter, without defaulting.
139    /// ```
140    /// use majordomus_cli::rules::Class;
141    /// assert_eq!(Class::parse(" blocking "), Class::Blocking);
142    /// assert_eq!(Class::parse(""), Class::Unknown);
143    /// ```
144    pub fn parse(word: &str) -> Class {
145        match word.trim() {
146            "blocking" => Class::Blocking,
147            "advisory" => Class::Advisory,
148            _ => Class::Unknown,
149        }
150    }
151}
152
153/// How a rule is enforced, as its own front matter declares it.
154#[derive(
155    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
156)]
157#[serde(rename_all = "snake_case")]
158#[schemars(rename = "RuleMode")]
159/// # Example
160///
161/// ```
162/// use majordomus_cli::rules::Mode;
163/// assert_eq!(Mode::Dispatched.label(), "dispatched");
164/// assert_eq!(Mode::Declarative.label(), "declarative");
165/// ```
166pub enum Mode {
167    /// Names a validator the doctrine dispatcher calls at run time.
168    Dispatched,
169    /// Names behavioural cases and no validator; a gate runs them.
170    Gated,
171    /// Carries a reason why nothing executable can express it, and so declares that a
172    /// reader enforces it. An honest declaration, not an absence: the reason *is* the
173    /// declaration — there is no flag beside it, because a flag can be set and a reason
174    /// cannot be set without writing one — it is counted apart from every executable mode,
175    /// and it separates a rule nobody could automate from a rule nobody got round to.
176    Reviewed,
177    /// Names neither. Correct for a rule nothing executable can express; a defect for a
178    /// blocking one.
179    Declarative,
180}
181
182impl Mode {
183    /// The word every surface prints for this enforcement mode, written here once so no
184    /// projection invents its own spelling of a mode.
185    ///
186    /// ```
187    /// use majordomus_cli::rules::Mode;
188    /// assert_eq!(Mode::Gated.label(), "gated");
189    /// ```
190    pub fn label(self) -> &'static str {
191        match self {
192            Mode::Dispatched => "dispatched",
193            Mode::Gated => "gated",
194            Mode::Reviewed => "reviewed",
195            Mode::Declarative => "declarative",
196        }
197    }
198}
199
200/// The enforcement block of a rule, read from `x-majordomus`.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[schemars(rename = "RuleEnforcement")]
203/// # Example
204///
205/// Read from front matter rather than built by hand; this is the shape a gated rule
206/// produces, and the mode is decided by what the block actually names.
207///
208/// ```
209/// use majordomus_cli::rules::{Enforcement, Mode};
210/// let e = Enforcement {
211///     mode: Mode::Gated,
212///     validator: None,
213///     category: None,
214///     exit_code: None,
215///     enforced_by: None,
216///     tests: vec!["test/cases/07_scope.sh".into()],
217///     reviewed_because: None,
218/// };
219/// assert_eq!(e.mode, Mode::Gated);
220/// ```
221pub struct Enforcement {
222    /// Which of the two canonical modes, or neither.
223    pub mode: Mode,
224    /// The validator the dispatcher calls, when the mode is dispatched.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub validator: Option<String>,
227    /// The diagnostic category the validator reports under.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub category: Option<String>,
230    /// The exit code the validator uses to refuse.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub exit_code: Option<i64>,
233    /// Which boundary enforces it: the word the rule's own block uses.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub enforced_by: Option<String>,
236    /// Every test the block names, in the order it names them.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub tests: Vec<String>,
239    /// Why nothing executable can express this rule, when the mode is reviewed. Required
240    /// there: a declared exemption with no reason is the debt it exists to replace.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub reviewed_because: Option<String>,
243}
244
245/// One rule, as the repository canonically declares it.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
247/// # Example
248///
249/// Built by [`definitions`] from the index rather than by hand. Its ordering is the one
250/// the crate has, so a report over the same tree reads the same twice.
251///
252/// ```
253/// use majordomus_cli::rules::{Class, RuleDefinition};
254/// # use majordomus_cli::rules::{Enforcement, Mode};
255/// let d = RuleDefinition {
256///     id: "project.scope-is-declared".into(),
257///     identity: "project.scope-is-declared@1".into(),
258///     uri: "majordomus://rule/project.scope-is-declared@1".into(),
259///     version: 1,
260///     title: "Scope is declared".into(),
261///     description: None,
262///     statement: None,
263///     status: "active".into(),
264///     class: Class::Blocking,
265///     namespace: "project".into(),
266///     depends_on: vec![],
267///     tags: vec![],
268///     path: ".ai/repo/rules/project/scope-is-declared.v1.md".into(),
269///     enforcement: Enforcement {
270///         mode: Mode::Declarative, validator: None, category: None, exit_code: None,
271///         enforced_by: None, tests: vec![], reviewed_because: None,
272///     },
273/// };
274/// assert_eq!(d.class, Class::Blocking);
275/// ```
276pub struct RuleDefinition {
277    /// The rule id, `<namespace>.<stem>`, without the version.
278    pub id: String,
279    /// The identity the index holds, with the version.
280    pub identity: String,
281    /// `majordomus://rule/<identity>`.
282    pub uri: String,
283    /// The declared version.
284    pub version: u64,
285    /// The title.
286    pub title: String,
287    /// The one-line description.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub description: Option<String>,
290    /// The normative sentence.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub statement: Option<String>,
293    /// `active`, or whatever else the front matter declares.
294    pub status: String,
295    /// How strongly it binds.
296    pub class: Class,
297    /// Whether it came with the vendored package or belongs to this project.
298    pub namespace: String,
299    /// The rules it depends on, by id.
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub depends_on: Vec<String>,
302    /// The declared tags.
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub tags: Vec<String>,
305    /// Repository-relative path of the rule object.
306    pub path: String,
307    /// How it is enforced, as declared.
308    pub enforcement: Enforcement,
309}
310
311/// A rule is ordered by its namespace, then by what a reader reads, then by its id — so
312/// that the vendored package and the project's own rules stay in their blocks and the
313/// sequence is total whatever order discovery handed them.
314impl crate::order::Ordered for RuleDefinition {
315    fn order_key(&self) -> crate::order::OrderKey<'_> {
316        crate::order::OrderKey::grouped(&self.namespace, &self.id, &self.identity)
317    }
318}
319
320/// A proof is ordered by the rule it is about: one comparator, so a filtered report and the
321/// whole report cannot disagree about the sequence.
322impl crate::order::Ordered for RuleProof {
323    fn order_key(&self) -> crate::order::OrderKey<'_> {
324        crate::order::Ordered::order_key(&self.rule)
325    }
326}
327
328// ---------------------------------------------------------------- the proof graph
329
330/// The validator a dispatched rule names, and whether anything defines it.
331///
332/// A validator is **not a path**. `validator: adr` names the shell function
333/// `mj_validate_adr`, which the doctrine dispatcher calls; the tool finds it by scanning
334/// `lib/`, exactly as `majordomus doctor` does. Reading it as a path is a mistake worth
335/// recording here, because it is the one this type was introduced to fix: it makes every
336/// dispatched rule in the vendored package report as naming something that is not in the
337/// tree, which is the opposite of true — those are the best-enforced rules there are.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
339#[schemars(rename = "RuleValidator")]
340/// # Example
341///
342/// ```
343/// use majordomus_cli::rules::ValidatorRef;
344/// let v = ValidatorRef {
345///     name: "adr".into(),
346///     function: "mj_validate_adr".into(),
347///     defined_in: Some("lib/adr.sh".into()),
348///     present: true,
349/// };
350/// assert_eq!(v.function, "mj_validate_adr");
351/// ```
352pub struct ValidatorRef {
353    /// The name the rule declares, without the `mj_validate_` prefix.
354    pub name: String,
355    /// The function the dispatcher calls.
356    pub function: String,
357    /// The file that defines it, when one does.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub defined_in: Option<String>,
360    /// Whether anything in `lib/` defines it.
361    pub present: bool,
362}
363
364/// What a path a rule names actually is, decided by what drives it rather than by where it
365/// sits.
366///
367/// The corpus names two kinds and this distinction is the difference between a verdict and
368/// a mechanism. A **case** is driven by a runner, so an execution of it can be recorded and
369/// the repository can show that it passed. A **gate** is an executable check a CI gate runs
370/// as its own command, or a unit-test module (`src/<module>/tests.rs`) the gate running the
371/// crate's `cargo test` compiles: it refuses violations on every run, and this repository
372/// records no verdict for it, so what can be shown is the mechanism and not the result.
373/// Anything else a rule names is proof only in prose.
374#[derive(
375    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
376)]
377#[serde(rename_all = "snake_case")]
378#[schemars(rename = "RuleArtifactKind")]
379/// # Example
380///
381/// ```
382/// use majordomus_cli::rules::ArtifactKind;
383/// assert_eq!(ArtifactKind::Case.label(), "case");
384/// assert_eq!(ArtifactKind::Gate.label(), "gate");
385/// ```
386pub enum ArtifactKind {
387    /// A behavioural case a runner drives; executions of it are recorded.
388    Case,
389    /// An executable check a CI gate runs as its own command, or a unit-test module the
390    /// gate running the crate's `cargo test` compiles.
391    Gate,
392    /// Neither. Nothing can run it, so nothing can ever record it.
393    Unknown,
394}
395
396impl ArtifactKind {
397    /// The word every surface prints for what drives this artifact, so that a gate and a
398    /// case are never rendered as the same kind of thing.
399    ///
400    /// ```
401    /// use majordomus_cli::rules::ArtifactKind;
402    /// assert_eq!(ArtifactKind::Unknown.label(), "unknown");
403    /// ```
404    pub fn label(self) -> &'static str {
405        match self {
406            ArtifactKind::Case => "case",
407            ArtifactKind::Gate => "gate",
408            ArtifactKind::Unknown => "unknown",
409        }
410    }
411}
412
413/// One test a rule names, joined to what was recorded against it.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
415#[schemars(rename = "RuleTestProof")]
416/// # Example
417///
418/// ```
419/// use majordomus_cli::rules::{ArtifactKind, TestProof};
420/// use majordomus_cli::evidence::ProofState;
421/// let t = TestProof {
422///     path: "test/cases/07_scope.sh".into(),
423///     kind: ArtifactKind::Case,
424///     present: true,
425///     test: Some("suite:07_scope".into()),
426///     state: ProofState::NotRun,
427///     meaning: ProofState::NotRun.meaning().into(),
428///     execution: None,
429///     reproduce: Some("bash test/run.sh 07_scope".into()),
430///     gates: vec!["shell-suite".into()],
431/// };
432/// assert_eq!(t.kind, ArtifactKind::Case);
433/// ```
434pub struct TestProof {
435    /// The path the rule names.
436    pub path: String,
437    /// What drives it: a runner, a CI gate, or nothing.
438    pub kind: ArtifactKind,
439    /// Whether that path is in the tree.
440    pub present: bool,
441    /// The canonical test identity (`suite:<case>`, `crate:<binary>`), when a runner owns
442    /// the path. Absent means no runner drives it, so no run of it can ever be recorded.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub test: Option<String>,
445    /// What the repository can say about this test's latest run.
446    pub state: ProofState,
447    /// That state, in one sentence, from the one place it is worded.
448    pub meaning: String,
449    /// The execution behind the state, when there is one.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub execution: Option<Execution>,
452    /// The command that runs this test again.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub reproduce: Option<String>,
455    /// The CI gates that run this test, derived from the repository's own CI model.
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub gates: Vec<String>,
458}
459
460/// What the repository can say about one rule's proof, as a whole.
461///
462/// Ordered strongest to weakest so that a summary sorted by this reads as a ranking. A rule's
463/// state is made from its parts' by [`crate::evidence::freshness::aggregate`]: a failing part
464/// makes it failing, and otherwise the weakest part that can carry proof decides.
465#[derive(
466    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
467)]
468#[serde(rename_all = "snake_case")]
469/// # Example
470///
471/// ```
472/// use majordomus_cli::rules::RuleState;
473/// assert!(RuleState::Proven < RuleState::Stale);
474/// assert!(RuleState::Stale.passing());
475/// assert!(!RuleState::Dangling.passing());
476/// ```
477pub enum RuleState {
478    /// Every artifact it names is present, and every test it names has a passing run that
479    /// nothing has invalidated.
480    Proven,
481    /// Every artifact is present and every test passed, but at least one run is only
482    /// un-invalidated rather than measured against this tree.
483    InputsUnchanged,
484    /// A passing run exists, but something it is about has changed since.
485    Stale,
486    /// An executable gate runs what this rule names, on every CI run that selects it. The
487    /// mechanism is in the tree and wired; no verdict for it is recorded here.
488    Gated,
489    /// The latest run of a test this rule names did not pass.
490    Failing,
491    /// Every artifact is present, and no run of at least one named test was ever recorded.
492    NotRun,
493    /// The rule declares, with a stated reason, that nothing executable can express it. An
494    /// honest exemption: counted apart from every proven rule, never counted as one.
495    Reviewed,
496    /// The rule names a path no runner in this repository drives.
497    Unrunnable,
498    /// The rule names a path that is not in the tree. A proof that does not exist is worse
499    /// than no proof, because the rule reads as proven.
500    Dangling,
501    /// The rule names neither a validator nor a test. Correct for a rule nothing executable
502    /// can express; a defect for a blocking one.
503    Unproven,
504}
505
506impl RuleState {
507    /// The word every surface prints for this state, written here once so that `proven`
508    /// and `inputs unchanged` cannot be collapsed into one badge by a renderer.
509    ///
510    /// ```
511    /// use majordomus_cli::rules::RuleState;
512    /// assert_eq!(RuleState::InputsUnchanged.label(), "inputs unchanged");
513    /// ```
514    pub fn label(self) -> &'static str {
515        match self {
516            RuleState::Proven => "proven",
517            RuleState::InputsUnchanged => "inputs unchanged",
518            RuleState::Stale => "stale",
519            RuleState::Gated => "gated",
520            RuleState::Failing => "failing",
521            RuleState::NotRun => "not run",
522            RuleState::Reviewed => "reviewed",
523            RuleState::Unrunnable => "unrunnable",
524            RuleState::Dangling => "dangling",
525            RuleState::Unproven => "unproven",
526        }
527    }
528
529    /// One sentence: what the state means, worded once so that every surface says the same
530    /// thing rather than each inventing its own gloss.
531    /// ```
532    /// use majordomus_cli::rules::RuleState;
533    /// assert!(RuleState::Dangling.meaning().contains("not in the tree"));
534    /// ```
535    pub fn meaning(self) -> &'static str {
536        match self {
537            RuleState::Proven => {
538                "Everything this rule names is in the tree, and every test it names has a passing \
539                 run that nothing in the repository has changed since."
540            }
541            RuleState::InputsUnchanged => {
542                "Everything this rule names is in the tree and passed, but at least one run is \
543                 only un-invalidated: nothing it names has changed since, which is the absence of \
544                 a known invalidation rather than proof against this commit."
545            }
546            RuleState::Stale => {
547                "A passing run exists, but a file it is about has changed since. The proof is \
548                 older than its subject."
549            }
550            RuleState::Gated => {
551                "An executable check refuses violations of this rule, and a CI gate runs it. \
552                 That is the mechanism; this repository records no verdict for a gate, so what \
553                 can be shown is that violations are refused and not that the last run passed."
554            }
555            RuleState::Failing => {
556                "The most recent recorded run of a test this rule names did not pass."
557            }
558            RuleState::NotRun => {
559                "This rule names a test a runner owns, and no run of it has ever been recorded."
560            }
561            RuleState::Reviewed => {
562                "This rule declares that nothing executable can express it and gives its reason. \
563                 A reader enforces it. That is a weaker guarantee than any gate, and it is \
564                 counted apart from every rule that has one."
565            }
566            RuleState::Unrunnable => {
567                "This rule names a path no runner in this repository drives, so no execution of \
568                 it can ever be recorded."
569            }
570            RuleState::Dangling => {
571                "This rule names a path that is not in the tree. It reads as proven on every \
572                 surface that checks only the name."
573            }
574            RuleState::Unproven => "This rule names neither a validator nor a test.",
575        }
576    }
577
578    /// Does this state carry a passing execution behind it, of any freshness?
579    /// ```
580    /// use majordomus_cli::rules::RuleState;
581    /// assert!(RuleState::Proven.passing());
582    /// assert!(!RuleState::Unproven.passing());
583    /// ```
584    pub fn passing(self) -> bool {
585        matches!(
586            self,
587            RuleState::Proven | RuleState::InputsUnchanged | RuleState::Stale
588        )
589    }
590
591    /// The one declared mapping of a rule's state onto the evidence vocabulary, and whether
592    /// that state can carry proof when a subject aggregates it.
593    ///
594    /// A gate refuses violations, but no recorded run proves the behaviour, so `gated` reads
595    /// `not_run`. A reviewed exemption and a rule that names nothing read `no_test`; only a
596    /// blocking rule that names nothing drags a subject, because only for it is that a
597    /// defect. A dangling path reads `unrunnable` and still drags.
598    ///
599    /// ```
600    /// use majordomus_cli::evidence::ProofState;
601    /// use majordomus_cli::rules::{Class, RuleState};
602    ///
603    /// assert_eq!(RuleState::Gated.as_proof(Class::Blocking), (ProofState::NotRun, true));
604    /// assert_eq!(RuleState::Reviewed.as_proof(Class::Blocking), (ProofState::NoTest, false));
605    /// assert_eq!(RuleState::Unproven.as_proof(Class::Blocking), (ProofState::NoTest, true));
606    /// assert_eq!(RuleState::Unproven.as_proof(Class::Advisory), (ProofState::NoTest, false));
607    /// assert_eq!(RuleState::Dangling.as_proof(Class::Advisory), (ProofState::Unrunnable, true));
608    /// ```
609    pub fn as_proof(self, class: Class) -> (ProofState, bool) {
610        match self {
611            RuleState::Proven => (ProofState::Proven, true),
612            RuleState::InputsUnchanged => (ProofState::InputsUnchanged, true),
613            RuleState::Stale => (ProofState::Stale, true),
614            RuleState::Failing => (ProofState::Failing, true),
615            RuleState::NotRun | RuleState::Gated => (ProofState::NotRun, true),
616            RuleState::Unrunnable | RuleState::Dangling => (ProofState::Unrunnable, true),
617            RuleState::Reviewed => (ProofState::NoTest, false),
618            RuleState::Unproven => (ProofState::NoTest, class == Class::Blocking),
619        }
620    }
621}
622
623/// One rule, joined to everything the repository can show about it.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625/// # Example
626///
627/// A `RuleProof` is what [`report`] produces per rule; `satisfied` is whether the state
628/// supports the class the rule declares, which is the whole question.
629///
630/// ```
631/// use majordomus_cli::rules::{report, RuleProof, RuleState};
632/// use majordomus_cli::evidence::Ledger;
633/// use majordomus_cli::synthetic::SyntheticRepository;
634/// let repo = SyntheticRepository::small().unwrap();
635/// let ledger = Ledger::load(repo.root()).unwrap();
636/// let r = report(&repo.index().unwrap(), &ledger);
637/// let first: &RuleProof = &r.rules[0];
638/// assert_eq!(first.state, RuleState::Unproven);
639/// assert!(first.satisfied, "an advisory rule owes no executable proof");
640/// ```
641pub struct RuleProof {
642    /// The canonical definition.
643    pub rule: RuleDefinition,
644    /// The validator, when the mode is dispatched.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub validator: Option<ValidatorRef>,
647    /// Every test the rule names, joined to the ledger.
648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
649    pub tests: Vec<TestProof>,
650    /// Every CI gate that runs something this rule names, deduplicated.
651    #[serde(default, skip_serializing_if = "Vec::is_empty")]
652    pub gates: Vec<String>,
653    /// The rules that depend on this one, by id — the converse of `depends_on`, which no
654    /// rule declares and which a reader always wants.
655    #[serde(default, skip_serializing_if = "Vec::is_empty")]
656    pub required_by: Vec<String>,
657    /// What the repository can say, as a whole.
658    pub state: RuleState,
659    /// That state, in one sentence.
660    pub meaning: String,
661    /// Whether the state is one this rule's class can live with.
662    pub satisfied: bool,
663}
664
665/// One rule whose declared class the proof does not support.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
667#[schemars(rename = "RuleFinding")]
668/// # Example
669///
670/// ```
671/// use majordomus_cli::rules::{Class, Finding, RuleState};
672/// let f = Finding {
673///     rule: "project.x".into(),
674///     class: Class::Blocking,
675///     state: RuleState::Dangling,
676///     reason: "it names a path that is not in the tree".into(),
677///     reproduce: "scripts/ci/rule-proof-check".into(),
678/// };
679/// assert_eq!(f.state, RuleState::Dangling);
680/// ```
681pub struct Finding {
682    /// The rule id.
683    pub rule: String,
684    /// The class it declares.
685    pub class: Class,
686    /// The state its proof is in.
687    pub state: RuleState,
688    /// Why that combination is a finding.
689    pub reason: String,
690    /// The command that shows it again.
691    pub reproduce: String,
692}
693
694/// How much of the rule corpus carries each part of a proof.
695///
696/// Every number here is counted from the tree. Nothing in this struct may be written down
697/// anywhere else: a hardcoded rule count is the defect this repository keeps rediscovering.
698#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
699#[schemars(rename = "RuleCoverage")]
700/// # Example
701///
702/// Every number is counted from the tree; an empty corpus counts zero rather than
703/// reading as complete, which is what `Coverage::default` is for.
704///
705/// ```
706/// use majordomus_cli::rules::Coverage;
707/// let c = Coverage::default();
708/// assert_eq!(c.rules, 0);
709/// assert_eq!(c.review_only, 0);
710/// ```
711pub struct Coverage {
712    /// Rules discovered.
713    pub rules: usize,
714    /// Of those, blocking.
715    pub blocking: usize,
716    /// Of those, advisory.
717    pub advisory: usize,
718    /// Rules naming a validator or at least one test.
719    pub named_proof: usize,
720    /// Rules every one of whose named paths is in the tree.
721    pub artifacts_present: usize,
722    /// Rules at least one of whose tests a runner owns.
723    pub runnable: usize,
724    /// Rules every one of whose tests has a recorded run.
725    pub recorded: usize,
726    /// Rules whose recorded runs all passed.
727    pub passing: usize,
728    /// Rules some CI gate runs.
729    pub gated: usize,
730    /// Rules whose only executable proof is a gate: a mechanism that refuses violations,
731    /// with no recorded verdict behind it. Counted apart from `passing` so that the two
732    /// are never read as one number.
733    pub mechanism_only: usize,
734    /// Rules that declare, with a reason, that a reader enforces them. Counted apart from
735    /// every executable mode: this number going up is governance getting weaker, and a
736    /// summary that folded it into a total would hide exactly that.
737    pub review_only: usize,
738    /// Rules whose state their class can live with.
739    pub satisfied: usize,
740}
741
742/// What the whole corpus can be said to prove, in one word, before any number is read.
743///
744/// `satisfied` answers a narrower question — are there findings — and a corpus in which no
745/// blocking rule was ever run has none, because `not run`, `gated` and `reviewed` are weaker
746/// states rather than defects (docs/DOCTRINE.md). Read alone, `satisfied: true` over such a
747/// corpus says "every rule is enforced" about a repository that has shown nothing. The
748/// verdict is the answer that cannot be read that way: it is `proven` only when every
749/// blocking rule is `proven` — a passing run recorded on a clean tree, at a commit that
750/// nothing but the evidence ledger has changed since. A rule whose inputs are merely
751/// unchanged is not that (ADR 0041), and leaves the corpus `unproven`.
752#[derive(
753    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
754)]
755#[serde(rename_all = "snake_case")]
756#[schemars(rename = "RulesVerdict")]
757/// # Example
758///
759/// ```
760/// use majordomus_cli::rules::{RuleState, RulesVerdict};
761/// // a corpus with a finding is failing, whatever else is true of it
762/// assert_eq!(RulesVerdict::of([RuleState::Proven], true), RulesVerdict::Failing);
763/// // one blocking rule never run is enough to leave the corpus unproven
764/// assert_eq!(
765///     RulesVerdict::of([RuleState::Proven, RuleState::NotRun], false),
766///     RulesVerdict::Unproven
767/// );
768/// assert_eq!(RulesVerdict::of([RuleState::Proven], false), RulesVerdict::Proven);
769/// // a pass whose inputs are merely unchanged is not proven
770/// assert_eq!(
771///     RulesVerdict::of([RuleState::Proven, RuleState::InputsUnchanged], false),
772///     RulesVerdict::Unproven
773/// );
774/// ```
775pub enum RulesVerdict {
776    /// Every blocking rule is proven: a passing run recorded on a clean tree, at a commit
777    /// that nothing but the evidence ledger has changed since.
778    Proven,
779    /// No rule is a finding, and at least one blocking rule is not proven — never run,
780    /// gated, reviewed, stale, or passing with only its inputs unchanged — or there is no
781    /// blocking rule at all.
782    Unproven,
783    /// At least one rule declares a class its proof does not support.
784    Failing,
785}
786
787impl RulesVerdict {
788    /// Decide the verdict from the state of every blocking rule and whether any finding
789    /// exists.
790    ///
791    /// Only `proven` counts. `stale` does not: a pass older than its subject is a pass of
792    /// something else. Nor does `inputs unchanged`: a run recorded on a dirty tree, or one
793    /// whose repository has moved since, is the absence of a known invalidation and not
794    /// proof (ADR 0041), and a corpus verdict of `proven` over it would be the one badge the
795    /// evidence states exist to refuse. An empty set of blocking rules is `unproven`, not
796    /// `proven` — a corpus that claims nothing has shown nothing, and success by vacancy is
797    /// the number that stops meaning anything.
798    /// ```
799    /// use majordomus_cli::rules::{RuleState, RulesVerdict};
800    /// assert_eq!(RulesVerdict::of([RuleState::Stale], false), RulesVerdict::Unproven);
801    /// assert_eq!(RulesVerdict::of([], false), RulesVerdict::Unproven);
802    /// assert_eq!(
803    ///     RulesVerdict::of([RuleState::InputsUnchanged], false),
804    ///     RulesVerdict::Unproven
805    /// );
806    /// ```
807    pub fn of(blocking: impl IntoIterator<Item = RuleState>, any_finding: bool) -> Self {
808        if any_finding {
809            return RulesVerdict::Failing;
810        }
811        let mut any = false;
812        for state in blocking {
813            any = true;
814            if state != RuleState::Proven {
815                return RulesVerdict::Unproven;
816            }
817        }
818        if any {
819            RulesVerdict::Proven
820        } else {
821            RulesVerdict::Unproven
822        }
823    }
824
825    /// The one word every surface prints for this verdict, and the one word a script greps
826    /// for: the serialised form and the rendered form are the same string, so a reader and
827    /// a parser can never be told different things about the same report. The three are
828    /// `proven`, `unproven` and `failing`, and the middle one exists because "no finding"
829    /// and "proven" are not the same answer — [`RulesVerdict::meaning`] says why in prose.
830    /// ```
831    /// use majordomus_cli::rules::RulesVerdict;
832    /// assert_eq!(RulesVerdict::Unproven.label(), "unproven");
833    /// assert_eq!(RulesVerdict::Proven.label(), "proven");
834    /// assert_eq!(RulesVerdict::Failing.label(), "failing");
835    /// ```
836    pub fn label(self) -> &'static str {
837        match self {
838            RulesVerdict::Proven => "proven",
839            RulesVerdict::Unproven => "unproven",
840            RulesVerdict::Failing => "failing",
841        }
842    }
843
844    /// What the verdict asserts, in one sentence, carried beside the label so that a reader
845    /// is never left to infer it from a colour or a word.
846    ///
847    /// The sentence is the whole point of the type. A report that found no fault used to be
848    /// rendered as a pass, and "no rule is a finding" is a far weaker statement than "every
849    /// blocking rule is proven": the first is satisfied by a corpus nothing has ever run. `unproven` says that distinction out loud, in the one place a
850    /// person reads, so that an absence of findings cannot be quoted as proof.
851    /// ```
852    /// use majordomus_cli::rules::RulesVerdict;
853    /// assert!(RulesVerdict::Unproven.meaning().contains("not a finding"));
854    /// assert!(RulesVerdict::Proven.meaning().contains("Every blocking rule"));
855    /// ```
856    pub fn meaning(self) -> &'static str {
857        match self {
858            RulesVerdict::Proven => {
859                "Every blocking rule is proven: a passing run recorded on a clean tree, at a \
860                 commit that nothing but the evidence ledger has changed since."
861            }
862            RulesVerdict::Unproven => {
863                "No rule is a finding, and at least one blocking rule is not proven — never run, \
864                 gated, reviewed, stale, or passing with only its inputs unchanged: not a finding \
865                 is not proven."
866            }
867            RulesVerdict::Failing => {
868                "At least one rule declares a class its proof does not support."
869            }
870        }
871    }
872}
873
874/// The whole rule corpus, verified.
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
876#[schemars(rename = "RulesReport")]
877/// # Example
878///
879/// The whole corpus joined to the tree and the ledger. `verdict` is what the corpus can be
880/// said to prove; `satisfied` is only that there are no findings, and the two differ
881/// whenever a blocking rule has never been run.
882///
883/// ```
884/// use majordomus_cli::rules::{RulesReport, RulesVerdict};
885/// let r = RulesReport {
886///     head: None,
887///     working_tree: "clean".into(),
888///     verdict: RulesVerdict::Unproven,
889///     rules: vec![],
890///     states: Default::default(),
891///     coverage: Default::default(),
892///     findings: vec![],
893///     satisfied: true,
894/// };
895/// assert!(r.satisfied());
896/// assert_ne!(r.verdict, RulesVerdict::Proven, "no findings is not proof");
897/// ```
898pub struct RulesReport {
899    /// The commit the report was derived against, when git could say.
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub head: Option<String>,
902    /// `clean`, `dirty` or `unknown` at the time of derivation.
903    pub working_tree: String,
904    /// What the whole corpus proves: `proven`, `unproven` or `failing`. Derived over the
905    /// whole corpus before any filter, like the tallies.
906    pub verdict: RulesVerdict,
907    /// Every rule, in canonical id order.
908    pub rules: Vec<RuleProof>,
909    /// How many rules are in each state, by the state's printed word.
910    pub states: BTreeMap<String, usize>,
911    /// The parts of a proof, counted.
912    pub coverage: Coverage,
913    /// Every rule whose declared class the proof does not support.
914    pub findings: Vec<Finding>,
915    /// Whether there are no findings — and nothing more. A corpus none of whose blocking
916    /// rules was ever run is `satisfied`; `verdict` is the field that says whether anything
917    /// was proven.
918    pub satisfied: bool,
919}
920
921impl RulesReport {
922    /// Are there no findings? Not whether anything is proven: that is [`RulesReport::verdict`].
923    pub fn satisfied(&self) -> bool {
924        self.findings.is_empty()
925    }
926}
927
928// ---------------------------------------------------------------- the derivation
929
930/// Read a string field of the front matter, treating an empty value as absence.
931fn field(meta: &Value, key: &str) -> Option<String> {
932    let v = meta.get(key)?;
933    let s = match v {
934        Value::String(s) => s.trim().trim_matches('\'').to_string(),
935        Value::Number(n) => n.to_string(),
936        Value::Bool(b) => b.to_string(),
937        _ => return None,
938    };
939    if s.is_empty() {
940        None
941    } else {
942        Some(s)
943    }
944}
945
946/// Read a list-of-strings field, accepting both the YAML flow list the rules use and a
947/// single scalar, which a hand-written rule occasionally is.
948fn list(meta: &Value, key: &str) -> Vec<String> {
949    match meta.get(key) {
950        Some(Value::Array(a)) => a
951            .iter()
952            .filter_map(|v| v.as_str())
953            .map(|s| s.trim().to_string())
954            .filter(|s| !s.is_empty())
955            .collect(),
956        Some(Value::String(s)) if !s.trim().is_empty() => vec![s.trim().to_string()],
957        _ => Vec::new(),
958    }
959}
960
961/// Read the enforcement block, deciding the mode from what it actually names rather than
962/// from a word it could also declare. A block that names a validator is dispatched; one
963/// that names only tests is gated; one that names neither is declarative, whatever else it
964/// carries.
965fn enforcement_of(meta: &Value) -> Enforcement {
966    let block = meta.get("x-majordomus");
967    let Some(block) = block else {
968        return Enforcement {
969            mode: Mode::Declarative,
970            validator: None,
971            category: None,
972            exit_code: None,
973            enforced_by: None,
974            tests: Vec::new(),
975            reviewed_because: None,
976        };
977    };
978    let validator = field(block, "validator");
979    let tests = list(block, "tests");
980    let enforced_by = field(block, "enforced_by");
981    let reviewed_because = field(block, "reviewed_because");
982    // The order matters. An executable proof wins over a declaration of review, so a rule
983    // that acquires a case stops being review-enforced without anyone remembering to
984    // delete the declaration. And a review declaration with no reason is not a mode: it
985    // falls through to declarative, which is the finding it was meant to replace.
986    let mode = if validator.is_some() {
987        Mode::Dispatched
988    } else if !tests.is_empty() {
989        Mode::Gated
990    } else if reviewed_because.is_some() {
991        Mode::Reviewed
992    } else {
993        Mode::Declarative
994    };
995    Enforcement {
996        mode,
997        validator,
998        category: field(block, "category"),
999        exit_code: block.get("exit_code").and_then(Value::as_i64),
1000        enforced_by,
1001        tests,
1002        reviewed_because,
1003    }
1004}
1005
1006/// Every rule the index holds, as a canonical definition.
1007///
1008/// The denominator is read from the index, so a rule added tomorrow is measured tomorrow
1009/// and this function never carries a list of what exists.
1010/// ```
1011/// use majordomus_cli::rules::definitions;
1012/// use majordomus_cli::synthetic::SyntheticRepository;
1013/// let repo = SyntheticRepository::small().unwrap();
1014/// let defs = definitions(&repo.index().unwrap());
1015/// assert_eq!(defs.len(), repo.shape.rules);
1016/// ```
1017pub fn definitions(index: &Index) -> Vec<RuleDefinition> {
1018    let mut out: Vec<RuleDefinition> = index
1019        .objects
1020        .iter()
1021        .filter(|o| o.kind == "rule")
1022        .map(|o| {
1023            let id = field(&o.metadata, "id").unwrap_or_else(|| o.identity.clone());
1024            let namespace = id.split('.').next().unwrap_or_default().to_string();
1025            RuleDefinition {
1026                version: o
1027                    .metadata
1028                    .get("version")
1029                    .and_then(Value::as_u64)
1030                    .unwrap_or(1),
1031                title: o.title.clone().unwrap_or_else(|| id.clone()),
1032                description: o.description.clone(),
1033                statement: field(&o.metadata, "statement"),
1034                status: field(&o.metadata, "status").unwrap_or_else(|| "unknown".into()),
1035                class: Class::parse(&field(&o.metadata, "class").unwrap_or_default()),
1036                namespace,
1037                depends_on: list(&o.metadata, "depends_on"),
1038                tags: list(&o.metadata, "tags"),
1039                path: o.provenance.path.clone(),
1040                enforcement: enforcement_of(&o.metadata),
1041                identity: o.identity.clone(),
1042                uri: o.uri.clone(),
1043                id,
1044            }
1045        })
1046        .collect();
1047    // canonical order, from the one comparator the crate has: two runs of this over the
1048    // same tree, whatever order discovery handed it, produce the same report
1049    crate::order::canonical(&mut out);
1050    out
1051}
1052
1053/// Which gates this repository declares, and the command each runs.
1054///
1055/// `gates.yaml` is the CI model: `scripts/ci-plan` reads it and nothing else to decide what
1056/// a change must run. What a rule needs from it is narrower — which gate would run the
1057/// thing this rule names — and that is answerable from the one line each gate states
1058/// directly, its `runs:`. No gate id is written down here: a gate renamed in that file is
1059/// renamed in this answer, which is the whole reason to read it rather than to restate it.
1060pub(crate) fn gate_commands(root: &Path) -> Vec<(String, String)> {
1061    let Ok(text) = std::fs::read_to_string(root.join(GATES_PATH)) else {
1062        return Vec::new();
1063    };
1064    let mut out = Vec::new();
1065    let mut gate: Option<String> = None;
1066    for line in text.lines() {
1067        let t = line.trim();
1068        if let Some(rest) = t.strip_prefix("- id: ") {
1069            gate = Some(rest.trim().to_string());
1070        } else if let Some(rest) = t.strip_prefix("runs: ") {
1071            if let Some(g) = gate.take() {
1072                out.push((g, rest.trim().to_string()));
1073            }
1074        }
1075    }
1076    out
1077}
1078
1079/// The gates that run one path the rule names.
1080///
1081/// Three bindings, each read from the gate's own command rather than assumed:
1082/// the gate whose command *is* that path (a validator wired as its own gate); the gate that
1083/// drives the behavioural suite, for a path under the cases directory; and the gate that
1084/// drives the crate's tests, for a path under the crate's test directory. A path none of
1085/// these resolves gets no gate, which is reported rather than guessed — an over-claimed
1086/// gate is the same defect as an over-claimed test.
1087pub(crate) fn gates_for(path: &str, commands: &[(String, String)]) -> Vec<String> {
1088    let mut out: BTreeSet<String> = BTreeSet::new();
1089    for (gate, runs) in commands {
1090        let names_path = runs.split_whitespace().any(|w| w == path);
1091        let drives_suite = path.starts_with(CASES_DIR) && runs.contains(SUITE_RUNNER);
1092        let drives_crate = path.starts_with(CRATE_TESTS_DIR) && runs.contains(CRATE_RUNNER);
1093        if names_path || drives_suite || drives_crate {
1094            out.insert(gate.clone());
1095        }
1096    }
1097    out.into_iter().collect()
1098}
1099
1100/// Is this path a unit-test module kept in its own file: a `tests.rs` under the crate's
1101/// sources, in the tree, that its parent module declares under `#[cfg(test)]`?
1102///
1103/// Read from the declaration rather than the file name, because `cargo test` compiles what
1104/// the parent declares: a product module that merely happens to be called `tests` (the web
1105/// report's is one) holds no test by that name, and calling it proof would be the same
1106/// over-claim as calling a helper script a case.
1107fn unit_test_module(root: &Path, path: &str) -> bool {
1108    let Some(rest) = path.strip_prefix(CRATE_SRC_DIR) else {
1109        return false;
1110    };
1111    let rel = Path::new(rest);
1112    if rel.file_name().is_none_or(|n| n != "tests.rs") || !root.join(path).is_file() {
1113        return false;
1114    }
1115    let src = root.join(CRATE_SRC_DIR);
1116    let parents = match rel.parent().filter(|d| !d.as_os_str().is_empty()) {
1117        Some(dir) => [
1118            src.join(dir).join("mod.rs"),
1119            src.join(dir.with_extension("rs")),
1120        ],
1121        None => [src.join("lib.rs"), src.join("main.rs")],
1122    };
1123    parents
1124        .iter()
1125        .any(|p| std::fs::read_to_string(p).is_ok_and(|text| declares_test_module(&text)))
1126}
1127
1128/// Does this module source declare `mod tests;` with `#[cfg(test)]` as the nearest non-blank
1129/// line above it, the way rustfmt writes the declaration?
1130fn declares_test_module(text: &str) -> bool {
1131    let mut under_cfg_test = false;
1132    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
1133        if under_cfg_test && (line == "mod tests;" || line.ends_with(" mod tests;")) {
1134            return true;
1135        }
1136        under_cfg_test = line == "#[cfg(test)]";
1137    }
1138    false
1139}
1140
1141/// The gates that run a unit-test module: every gate whose command runs the crate runner in
1142/// a mode that runs `cargo test`, which compiles and runs the library's test target. A mode
1143/// that runs no test (`--integration`, `--doc`) is not a binding, however the command is
1144/// spelled around it.
1145fn unit_test_gates(commands: &[(String, String)]) -> Vec<String> {
1146    let runs_crate_tests = |runs: &str| {
1147        runs.split(['&', '|', ';']).any(|command| {
1148            let words: Vec<&str> = command.split_whitespace().collect();
1149            words
1150                .iter()
1151                .position(|w| w.rsplit('/').next() == Some(CRATE_RUNNER))
1152                .is_some_and(|at| {
1153                    words[at + 1..]
1154                        .iter()
1155                        .all(|w| !CRATE_RUNNER_TESTLESS_MODES.contains(w))
1156                })
1157        })
1158    };
1159    commands
1160        .iter()
1161        .filter(|(_, runs)| runs_crate_tests(runs))
1162        .map(|(gate, _)| gate.clone())
1163        .collect::<BTreeSet<_>>()
1164        .into_iter()
1165        .collect()
1166}
1167
1168/// Where `lib/` defines `mj_validate_<name>`, if anywhere.
1169///
1170/// The same scan `majordomus doctor` does, and the same one `scripts/generate-site-data`
1171/// does before it will build a doctrine page: a validator is a shell function, and the
1172/// question "is it there" is answered by reading the library rather than the filesystem.
1173fn validator_defined_in(root: &Path, name: &str) -> Option<String> {
1174    let needle = format!("mj_validate_{name}()");
1175    let dir = root.join("lib");
1176    // `min` rather than collect-sort-first: one pass, no sort site, and the same answer —
1177    // the least file name, deterministically, whatever order the directory was read in.
1178    std::fs::read_dir(&dir)
1179        .ok()?
1180        .filter_map(Result::ok)
1181        .filter(|e| e.path().extension().is_some_and(|x| x == "sh"))
1182        .filter(|e| {
1183            std::fs::read_to_string(e.path())
1184                .map(|t| t.lines().any(|l| l.trim_start().starts_with(&needle)))
1185                .unwrap_or(false)
1186        })
1187        .map(|e| format!("lib/{}", e.file_name().to_string_lossy()))
1188        .min()
1189}
1190
1191/// Does this gate run exactly this path as its own command, as opposed to driving a whole
1192/// directory of cases? The distinction is what separates a check wired as a gate from a
1193/// case that happens to be swept up by the suite runner.
1194fn gate_runs_exactly(gate: &str, path: &str, commands: &[(String, String)]) -> bool {
1195    commands
1196        .iter()
1197        .any(|(g, runs)| g == gate && runs.split_whitespace().any(|w| w == path))
1198}
1199
1200/// For every commit the ledger recorded a run against, its comparison with the working tree,
1201/// through the one freshness function the evidence report uses.
1202///
1203/// Asked once per report, whatever the number of subjects: rules and skills both judge a
1204/// test's run against these, and a subject never runs `git diff` of its own.
1205pub(crate) fn ledger_comparisons(root: &Path, ledger: &Ledger) -> BTreeMap<String, Comparison> {
1206    let presented = Presented::WorkingTree;
1207    let mut comparisons: BTreeMap<String, Comparison> = BTreeMap::new();
1208    for e in &ledger.executions {
1209        comparisons
1210            .entry(e.commit.clone())
1211            .or_insert_with(|| freshness::compare(root, &e.commit, &presented));
1212    }
1213    comparisons
1214}
1215
1216/// What the ledger says about one named test, for one subject file: the one judgement of a
1217/// recorded run that rules and skills share, so the two can never disagree about a test.
1218///
1219/// No runner owns the path: `unrunnable`. Nothing recorded: `not_run`. Otherwise the run is
1220/// judged by [`freshness::freshness`] at the working tree, with the test's source and the
1221/// subject as its only declared inputs: a failing run is `failing`, a pass whose test or
1222/// subject changed (or whose test no longer hashes to what ran, or that git cannot compare)
1223/// is `stale`, and a pass is `proven` only when nothing changed but the ledger and the run
1224/// measured a clean tree — a run recorded on a dirty tree is capped at `inputs_unchanged`.
1225pub(crate) fn test_state(
1226    root: &Path,
1227    comparisons: &BTreeMap<String, Comparison>,
1228    id: Option<&TestId>,
1229    execution: Option<&Execution>,
1230    subject: &str,
1231) -> ProofState {
1232    let recorded = match (id, execution) {
1233        (None, _) => Recorded::Unrunnable,
1234        (Some(_), None) => Recorded::NotRun,
1235        (Some(_), Some(e)) => Recorded::Ran(e),
1236    };
1237    // what the subject names, and nothing else: the test and the subject's own file
1238    let test_source = id.map(TestId::source).unwrap_or_default();
1239    let inputs = [test_source.clone(), subject.to_string()];
1240    let test_moved =
1241        execution.is_some_and(|e| e.outcome.proves() && e.digest_matches(root) == Some(false));
1242    freshness::freshness(
1243        recorded,
1244        Some(&inputs),
1245        &test_source,
1246        test_moved,
1247        execution.and_then(|e| comparisons.get(&e.commit)),
1248        TreeState::Clean,
1249    )
1250    .state
1251}
1252
1253/// Map one test's [`ProofState`] onto the rule vocabulary. The two agree everywhere they
1254/// overlap; `Dangling` and `Unproven` are the rule-level states evidence has no word for,
1255/// because a claim naming a missing file is already `Unrunnable` to it.
1256fn rule_state_of(test: &TestProof) -> RuleState {
1257    if !test.present {
1258        return RuleState::Dangling;
1259    }
1260    // A gate is a mechanism, not a verdict: it is not joined to the ledger, so the
1261    // evidence vocabulary has nothing to say about it and reading its `Unrunnable` as a
1262    // defect would be wrong in the opposite direction from a fake green.
1263    if test.kind == ArtifactKind::Gate {
1264        return RuleState::Gated;
1265    }
1266    match test.state {
1267        ProofState::Proven => RuleState::Proven,
1268        ProofState::InputsUnchanged => RuleState::InputsUnchanged,
1269        ProofState::Stale => RuleState::Stale,
1270        ProofState::Failing => RuleState::Failing,
1271        ProofState::NotRun => RuleState::NotRun,
1272        ProofState::Unrunnable => RuleState::Unrunnable,
1273        ProofState::NoTest => RuleState::Unproven,
1274    }
1275}
1276
1277/// Why a rule's state does not support the class it declares, or `None` when it does.
1278///
1279/// A blocking rule is the subject: it claims a gate refuses work that violates it. The
1280/// states that make that claim false are findings — `unproven`, `failing` and `unrunnable`.
1281/// `not run`, `gated`, `reviewed` and `stale` are not findings: they are weaker states, said
1282/// out loud and counted, which is a different thing from a defect (docs/DOCTRINE.md). They
1283/// are not proof either, and [`RulesVerdict`] is where the report says so. An advisory rule
1284/// claims nothing executable, so only a name that does not resolve is a finding — a dangling
1285/// path is a defect at any class, because it reads as proof and is not.
1286fn unsupported(class: Class, state: RuleState) -> Option<String> {
1287    match (class, state) {
1288        (_, RuleState::Dangling) => {
1289            Some("it names a path that is not in the tree, so it reads as proven and is not".into())
1290        }
1291        // Reviewed is deliberately not a finding: the rule says in its own front matter
1292        // that nothing executable can express it and why, which is a declaration a reader
1293        // can audit. What it is not is proof, and the report counts it apart.
1294        (Class::Blocking, RuleState::Unproven) => {
1295            Some("it is blocking and names neither a validator nor a test that proves it".into())
1296        }
1297        (Class::Blocking, RuleState::Failing) => {
1298            Some("it is blocking and the latest run of what proves it did not pass".into())
1299        }
1300        // Gated is deliberately not a finding: an executable check refuses violations, which
1301        // is exactly what a blocking rule claims. What it does not have is a recorded
1302        // verdict, which the state itself says and the report counts separately.
1303        (Class::Blocking, RuleState::Unrunnable) => Some(
1304            "it is blocking and names a path no runner drives, so nothing can ever record it"
1305                .into(),
1306        ),
1307        _ => None,
1308    }
1309}
1310
1311/// Join every rule of the index with the tree and the ledger, and decide, per rule, what
1312/// the repository can honestly say.
1313///
1314/// The git comparison is done once per distinct recorded commit and shared by every rule
1315/// that recorded against it, as [`crate::evidence::report`] does — the two derivations read
1316/// the same ledger and must not disagree about what a commit's diff is.
1317/// ```
1318/// use majordomus_cli::rules::report;
1319/// use majordomus_cli::evidence::Ledger;
1320/// use majordomus_cli::synthetic::SyntheticRepository;
1321/// let repo = SyntheticRepository::small().unwrap();
1322/// let ledger = Ledger::load(repo.root()).unwrap();
1323/// let r = report(&repo.index().unwrap(), &ledger);
1324/// assert_eq!(r.coverage.rules, repo.shape.rules);
1325/// assert!(r.satisfied(), "the synthetic rules are advisory and owe no proof");
1326/// ```
1327pub fn report(index: &Index, ledger: &Ledger) -> RulesReport {
1328    let root = PathBuf::from(&index.repository.root);
1329    let git = crate::git::inspect(&root);
1330    let (head, working_tree) = match &git {
1331        crate::git::GitState::Available(i) => (i.head.clone(), i.working_tree.clone()),
1332        crate::git::GitState::Unavailable { .. } => (None, "unknown".to_string()),
1333    };
1334
1335    let comparisons = ledger_comparisons(&root, ledger);
1336    let gate_commands = gate_commands(&root);
1337
1338    let defs = definitions(index);
1339    // the converse of depends_on, which no rule declares
1340    let mut required_by: BTreeMap<String, Vec<String>> = BTreeMap::new();
1341    for d in &defs {
1342        for dep in &d.depends_on {
1343            required_by
1344                .entry(dep.clone())
1345                .or_default()
1346                .push(d.id.clone());
1347        }
1348    }
1349
1350    let mut rules = Vec::new();
1351    let mut states: BTreeMap<String, usize> = BTreeMap::new();
1352    let mut findings = Vec::new();
1353    let mut coverage = Coverage::default();
1354    let mut blocking_states = Vec::new();
1355
1356    for def in defs {
1357        let validator = def.enforcement.validator.as_ref().map(|v| {
1358            let defined_in = validator_defined_in(&root, v);
1359            ValidatorRef {
1360                name: v.clone(),
1361                function: format!("mj_validate_{v}"),
1362                present: defined_in.is_some(),
1363                defined_in,
1364            }
1365        });
1366
1367        let mut tests = Vec::new();
1368        for path in &def.enforcement.tests {
1369            let present = root.join(path).exists();
1370            let id = TestId::of(path);
1371            let unit_module = unit_test_module(&root, path);
1372            let gates = if unit_module {
1373                unit_test_gates(&gate_commands)
1374            } else {
1375                gates_for(path, &gate_commands)
1376            };
1377            let kind = if id.is_some() {
1378                ArtifactKind::Case
1379            } else if (unit_module && !gates.is_empty())
1380                || gates
1381                    .iter()
1382                    .any(|g| gate_runs_exactly(g, path, &gate_commands))
1383            {
1384                ArtifactKind::Gate
1385            } else {
1386                ArtifactKind::Unknown
1387            };
1388            let execution = id
1389                .as_ref()
1390                .and_then(|t| ledger.latest(&t.as_string()))
1391                .cloned();
1392            let state = test_state(
1393                &root,
1394                &comparisons,
1395                id.as_ref(),
1396                execution.as_ref(),
1397                &def.path,
1398            );
1399            tests.push(TestProof {
1400                gates,
1401                kind,
1402                path: path.clone(),
1403                present,
1404                test: id.as_ref().map(TestId::as_string),
1405                state,
1406                meaning: state.meaning().to_string(),
1407                execution,
1408                reproduce: id.as_ref().map(TestId::reproduce),
1409            });
1410        }
1411
1412        // A failing test makes the rule failing, whatever its other tests say; otherwise the
1413        // rule's state is the weakest of the parts that can carry proof — and which parts
1414        // those are is the whole subtlety, learned by running this against the real corpus.
1415        //
1416        // `dangling` always dominates: a named path that is not in the tree is a lie
1417        // whatever else the rule names, because the rule reads as proven on its strength.
1418        //
1419        // The failure comes next because the declared order ranks `failing` above `not_run`:
1420        // a plain weakest-of over a rule whose tests are one fail and one skip (which reads
1421        // `not_run`) would read `not_run`, which is not a finding, and the counter-evidence
1422        // would be hidden behind an absence.
1423        //
1424        // An artifact no runner and no gate drives is different. It contributes nothing,
1425        // and that is not a defect in the rule: `project.web-surface-declared-once` names a
1426        // case, two gates and `scripts/generate-site-data`, which nothing runs as its own
1427        // command. Letting that one path drag the rule to `unrunnable` reported six rules
1428        // as unprovable while each had a perfectly good case — a false finding, which is
1429        // the failure this whole subsystem exists to refuse. So such a path is reported,
1430        // and judged only when it is all the rule has. `freshness::aggregate` is that rule,
1431        // written once for every aggregation of states.
1432        let mut state = if def.enforcement.mode == Mode::Reviewed {
1433            RuleState::Reviewed
1434        } else if def.enforcement.mode == Mode::Declarative {
1435            RuleState::Unproven
1436        } else if tests.iter().any(|t| !t.present) {
1437            RuleState::Dangling
1438        } else {
1439            let parts: Vec<(RuleState, bool)> = tests
1440                .iter()
1441                .map(|t| (rule_state_of(t), t.kind != ArtifactKind::Unknown))
1442                .collect();
1443            freshness::aggregate(&parts, RuleState::Failing).unwrap_or(RuleState::Unproven)
1444        };
1445        if let Some(v) = &validator {
1446            if !v.present {
1447                state = RuleState::Dangling;
1448            } else if tests.is_empty() {
1449                // a dispatched rule with a present validator and no case: the dispatcher
1450                // runs it, and nothing behavioural proves it does the right thing
1451                state = state.max(RuleState::NotRun);
1452            }
1453        }
1454
1455        let gates: Vec<String> = tests
1456            .iter()
1457            .flat_map(|t| t.gates.iter().cloned())
1458            .collect::<BTreeSet<_>>()
1459            .into_iter()
1460            .collect();
1461
1462        let reason = unsupported(def.class, state);
1463        let satisfied = reason.is_none();
1464        if let Some(reason) = reason {
1465            findings.push(Finding {
1466                rule: def.id.clone(),
1467                class: def.class,
1468                state,
1469                reason,
1470                reproduce: "scripts/ci/rule-proof-check".to_string(),
1471            });
1472        }
1473
1474        // the tallies, all counted from what was just derived
1475        coverage.rules += 1;
1476        match def.class {
1477            Class::Blocking => {
1478                coverage.blocking += 1;
1479                blocking_states.push(state);
1480            }
1481            Class::Advisory => coverage.advisory += 1,
1482            Class::Unknown => {}
1483        }
1484        if matches!(def.enforcement.mode, Mode::Dispatched | Mode::Gated) {
1485            coverage.named_proof += 1;
1486        }
1487        if state != RuleState::Dangling
1488            && matches!(def.enforcement.mode, Mode::Dispatched | Mode::Gated)
1489        {
1490            coverage.artifacts_present += 1;
1491        }
1492        if tests.iter().any(|t| t.test.is_some()) {
1493            coverage.runnable += 1;
1494        }
1495        if !tests.is_empty() && tests.iter().all(|t| t.execution.is_some()) {
1496            coverage.recorded += 1;
1497        }
1498        if state.passing() {
1499            coverage.passing += 1;
1500        }
1501        if state == RuleState::Gated {
1502            coverage.mechanism_only += 1;
1503        }
1504        if state == RuleState::Reviewed {
1505            coverage.review_only += 1;
1506        }
1507        if !gates.is_empty() {
1508            coverage.gated += 1;
1509        }
1510        if satisfied {
1511            coverage.satisfied += 1;
1512        }
1513        *states.entry(state.label().to_string()).or_insert(0) += 1;
1514
1515        rules.push(RuleProof {
1516            required_by: required_by.get(&def.id).cloned().unwrap_or_default(),
1517            validator,
1518            tests,
1519            gates,
1520            state,
1521            meaning: state.meaning().to_string(),
1522            satisfied,
1523            rule: def,
1524        });
1525    }
1526
1527    RulesReport {
1528        head,
1529        working_tree,
1530        verdict: RulesVerdict::of(blocking_states, !findings.is_empty()),
1531        rules,
1532        states,
1533        coverage,
1534        satisfied: findings.is_empty(),
1535        findings,
1536    }
1537}
1538
1539#[cfg(test)]
1540mod tests;