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;