Skip to main content

majordomus_cli/capability/builtin/
rules.rs

1//! The `rules` module: from a rule to what proves it, and back.
2//!
3//! The rules section declares what this repository refuses; [`crate::rules`] decides what
4//! the repository can actually show for each one. These capabilities are the one
5//! declaration of that reading, and every surface — the command line, the HTTP API and its
6//! OpenAPI operation, the MCP tool an agent reads, the Cockpit page, the generated
7//! reference — is derived from it. There is no second rule inventory behind any of them,
8//! which is the property the whole exercise exists to buy: a rule added to the rules
9//! section tomorrow appears on all of them without a line being edited anywhere.
10//!
11//! # Validate and verify are different questions
12//!
13//! `rules.verify` asks whether the *rule* is in order: is what it names in the tree, does a
14//! runner own it, was it ever run, did that run pass, is the run older than its subject.
15//! Whether the *repository* currently satisfies a rule is the dispatcher's question, and
16//! the portable layer's `majordomus doctrine` is where it is asked — one rule, one
17//! validator, run against the working tree. Collapsing the two is how "the rule is fine"
18//! and "the repository is fine" become one green badge that means neither.
19//!
20//! # Both directions
21//!
22//! A relation that can only be walked one way is half a relation. `rules.show` answers
23//! "what proves this rule"; `rules.proves` answers "what does this test prove", listing
24//! every rule that names it. They read the same derivation, so the two answers cannot
25//! disagree — and the second is the one that says whether deleting a case would leave a
26//! rule unenforced, which is the question nobody could ask before.
27
28//! # Example
29//!
30//! The module declares three capabilities, and every projection — the command line, the
31//! HTTP operation, the MCP tool — is derived from that one declaration.
32//!
33//! ```
34//! use majordomus_cli::capability::builtin::rules::module;
35//! let m = module();
36//! assert_eq!(m.id.as_str(), "rules");
37//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
38//! assert_eq!(ids, ["rules.report", "rules.show", "rules.proves"]);
39//! ```
40
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
45use crate::capability::handler::{CapabilityError, Context};
46use crate::capability::model::{CachePolicy, CliExposure, Exposure, McpResource, Stability};
47use crate::capability::module::ModuleDescriptor;
48use crate::evidence::{Ledger, TestId};
49use crate::rules::{self, Class, RuleProof, RuleState, RulesReport, RULES_URI};
50use crate::{capability, module};
51
52use super::{get, mcp};
53
54// ---------------------------------------------------------------- inputs
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58/// Which part of the corpus to answer for.
59/// # Example
60///
61/// Every field narrows the answer; the tallies still count the whole corpus, so a filtered
62/// report never misreports how much of it was examined.
63///
64/// ```
65/// use majordomus_cli::capability::builtin::rules::RulesReportInput;
66/// use majordomus_cli::rules::Class;
67/// let i = RulesReportInput { class: Some(Class::Blocking), ..Default::default() };
68/// assert_eq!(i.class, Some(Class::Blocking));
69/// assert!(!i.findings_only);
70/// ```
71pub struct RulesReportInput {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    /// Only rules in this proof state (`proven`, `inputs_unchanged`, `stale`, `failing`,
74    /// `not_run`, `unrunnable`, `dangling`, `unproven`). Absent: every rule.
75    pub state: Option<RuleState>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    /// Only rules of this class (`blocking`, `advisory`). Absent: every rule.
78    pub class: Option<Class>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    /// Only rules of this namespace — `project` for this repository's own, `majordomus` for
81    /// the vendored package. Absent: both.
82    pub namespace: Option<String>,
83    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84    /// Only the rules whose declared class the proof does not support, with the findings.
85    /// The tallies still count the whole corpus, so a filtered answer never misreports how
86    /// much of it was examined.
87    pub findings_only: bool,
88}
89
90impl BenchmarkCases for RulesReportInput {
91    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
92        vec![
93            NamedCase::new("all", RulesReportInput::default()),
94            NamedCase::new(
95                "findings",
96                RulesReportInput {
97                    findings_only: true,
98                    ..Default::default()
99                },
100            ),
101            NamedCase::new(
102                "blocking",
103                RulesReportInput {
104                    class: Some(Class::Blocking),
105                    ..Default::default()
106                },
107            ),
108        ]
109    }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114/// One rule of the corpus.
115/// # Example
116///
117/// One rule of the corpus, named by its id or by the identity the index holds; a client
118/// that has either spelling should not need to know the other.
119///
120/// ```
121/// use majordomus_cli::capability::builtin::rules::RuleInput;
122/// let i = RuleInput { rule: "project.scope-is-declared".into() };
123/// assert_eq!(i.rule, "project.scope-is-declared");
124/// ```
125pub struct RuleInput {
126    /// The rule id, with or without its version: `project.scope-is-declared`, or the
127    /// identity the index holds.
128    pub rule: String,
129}
130
131impl BenchmarkCases for RuleInput {
132    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
133        // the corpus is read from the index, so the case names a rule that exists in
134        // whatever repository the benchmark runs against rather than one written down here
135        let first = ctx
136            .index
137            .objects
138            .iter()
139            .find(|o| o.kind == "rule")
140            .map(|o| o.identity.clone())
141            .unwrap_or_else(|| "project.scope-is-declared@1".into());
142        vec![NamedCase::new("first", RuleInput { rule: first })]
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148/// One test, by the path a rule names it with or by its canonical identity.
149/// # Example
150///
151/// ```
152/// use majordomus_cli::capability::builtin::rules::RuleTestInput;
153/// let i = RuleTestInput { test: "test/cases/125_rule_proof.sh".into() };
154/// assert!(i.test.starts_with("test/cases/"));
155/// ```
156pub struct RuleTestInput {
157    /// `test/cases/07_scope.sh`, or `suite:07_scope`, or `crate:product`.
158    pub test: String,
159}
160
161impl BenchmarkCases for RuleTestInput {
162    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
163        vec![NamedCase::new(
164            "suite",
165            RuleTestInput {
166                test: "test/cases/125_rule_proof.sh".into(),
167            },
168        )]
169    }
170}
171
172// ---------------------------------------------------------------- outputs
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175/// One rule, with everything that proves it and everything that depends on it.
176/// # Example
177///
178/// What `rules show` answers: the proof, the rules either side of it in the dependency
179/// graph, and the one sentence saying what would make it proven.
180///
181/// ```
182/// use majordomus_cli::capability::builtin::rules::RuleSummary;
183/// use majordomus_cli::rules::{Class, RuleState};
184/// // a RuleDetail carries these summaries for depends_on and required_by
185/// let dep = RuleSummary {
186///     id: "project.dep".into(), title: "Dep".into(),
187///     class: Class::Blocking, state: RuleState::NotRun,
188/// };
189/// assert_eq!(dep.class, Class::Blocking);
190/// ```
191pub struct RuleDetail {
192    /// The rule, joined to the tree and the ledger.
193    pub proof: RuleProof,
194    /// The rules this one depends on, with the state each is in — a rule whose dependency
195    /// is unproven is proven only as far as that dependency is.
196    pub depends_on: Vec<RuleSummary>,
197    /// The rules that depend on this one.
198    pub required_by: Vec<RuleSummary>,
199    /// What would have to be true for this rule to reach `proven`, in words, or nothing
200    /// when it is there already.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub missing: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
206/// A rule named from somewhere else, with just enough to decide whether to follow it.
207/// # Example
208///
209/// ```
210/// use majordomus_cli::capability::builtin::rules::RuleSummary;
211/// use majordomus_cli::rules::{Class, RuleState};
212/// let s = RuleSummary {
213///     id: "project.x".into(),
214///     title: "X".into(),
215///     class: Class::Advisory,
216///     state: RuleState::Reviewed,
217/// };
218/// assert_eq!(s.state, RuleState::Reviewed);
219/// ```
220pub struct RuleSummary {
221    /// The rule id.
222    pub id: String,
223    /// The title.
224    pub title: String,
225    /// How strongly it binds.
226    pub class: Class,
227    /// What the repository can say about its proof.
228    pub state: RuleState,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
232/// What one test proves: the reverse of [`RuleDetail`].
233/// # Example
234///
235/// The reverse of `rules show`: what a test proves, and which rules would be left with no
236/// proof at all if it were deleted.
237///
238/// ```
239/// use majordomus_cli::capability::builtin::rules::TestSubjects;
240/// let t = TestSubjects {
241///     test: Some("suite:07_scope".into()),
242///     path: "test/cases/07_scope.sh".into(),
243///     present: true,
244///     reproduce: Some("bash test/run.sh 07_scope".into()),
245///     proves: vec![],
246///     sole_proof_of: vec![],
247/// };
248/// assert!(t.present);
249/// ```
250pub struct TestSubjects {
251    /// The canonical test identity, when a runner owns the path.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub test: Option<String>,
254    /// The path as given.
255    pub path: String,
256    /// Whether that path is in the tree.
257    pub present: bool,
258    /// The command that runs it again.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub reproduce: Option<String>,
261    /// Every rule that names this test, with the state it is in.
262    pub proves: Vec<RuleSummary>,
263    /// Every rule that would lose its only proof if this test were deleted. This is the
264    /// question the one-way relation could never answer.
265    pub sole_proof_of: Vec<String>,
266}
267
268// ---------------------------------------------------------------- handlers
269
270fn root_of(ctx: &Context) -> std::path::PathBuf {
271    std::path::PathBuf::from(&ctx.index.repository.root)
272}
273
274fn internal(e: crate::error::Error) -> CapabilityError {
275    CapabilityError::Internal(e.to_string())
276}
277
278/// The whole joined report, read fresh: the ledger is a file that changes outside this
279/// process, and an answer kept past a change of it would be the stale evidence this module
280/// exists to name. The executor keeps the capability's answer per checkout state only
281/// (`CachePolicy::Checkout`), which an edit of the ledger moves.
282fn full_report(ctx: &Context) -> Result<RulesReport, CapabilityError> {
283    let ledger = Ledger::load(&root_of(ctx)).map_err(internal)?;
284    Ok(rules::report(&ctx.index, &ledger))
285}
286
287fn summary(p: &RuleProof) -> RuleSummary {
288    RuleSummary {
289        id: p.rule.id.clone(),
290        title: p.rule.title.clone(),
291        class: p.rule.class,
292        state: p.state,
293    }
294}
295
296/// What is missing, in the words a reader can act on. One sentence per state, derived from
297/// the rule in hand rather than from a template, because "governance failed" is not a
298/// diagnostic.
299fn missing_for(p: &RuleProof) -> Option<String> {
300    let named = |paths: Vec<&str>| paths.join(", ");
301    match p.state {
302        RuleState::Proven => None,
303        RuleState::InputsUnchanged => Some(
304            "Run what it names against this commit and record the run: \
305             the latest run passed but was measured against an earlier tree."
306                .into(),
307        ),
308        RuleState::Stale => Some(format!(
309            "Run what it names again and record the run: {} passed against an older tree than \
310             the one it is about.",
311            named(p.tests.iter().map(|t| t.path.as_str()).collect())
312        )),
313        RuleState::Gated => Some(
314            "Record a verdict for the gate that refuses violations of it: the mechanism is \
315             wired and nothing here says what its last run decided."
316                .into(),
317        ),
318        RuleState::Failing => Some(format!(
319            "Fix the behaviour or the case: the latest recorded run of {} did not pass.",
320            named(
321                p.tests
322                    .iter()
323                    .filter(|t| !t.state.passing())
324                    .map(|t| t.path.as_str())
325                    .collect()
326            )
327        )),
328        RuleState::Reviewed => Some(
329            "Nothing here is broken: this rule declares that no program can express it and \
330             says why. If that ever stops being true, name the case and the declaration \
331             falls away on its own."
332                .into(),
333        ),
334        RuleState::NotRun => Some(format!(
335            "Run and record: {}",
336            p.tests
337                .iter()
338                .filter_map(|t| t.reproduce.clone())
339                .collect::<Vec<_>>()
340                .join("; ")
341        )),
342        RuleState::Unrunnable => Some(format!(
343            "Name a path a runner drives — a case under test/cases/ or an integration test of \
344             the crate. {} is driven by neither.",
345            named(
346                p.tests
347                    .iter()
348                    .filter(|t| t.test.is_none())
349                    .map(|t| t.path.as_str())
350                    .collect()
351            )
352        )),
353        RuleState::Dangling => Some(format!(
354            "Restore or rename what it names: {} is not in the tree, so this rule reads as \
355             proven and is not.",
356            named(
357                p.tests
358                    .iter()
359                    .filter(|t| !t.present)
360                    .map(|t| t.path.as_str())
361                    .chain(
362                        p.validator
363                            .iter()
364                            .filter(|v| !v.present)
365                            .map(|v| v.function.as_str())
366                    )
367                    .collect()
368            )
369        )),
370        RuleState::Unproven => Some(
371            "Name the behavioural case that proves it in its x-majordomus block: \
372             tests: [test/cases/NN_name.sh]."
373                .into(),
374        ),
375    }
376}
377
378fn report(ctx: &Context, input: RulesReportInput) -> Result<RulesReport, CapabilityError> {
379    let mut r = full_report(ctx)?;
380    // the tallies are taken before the filter: a filtered answer says how much of the
381    // corpus it looked at, never how much it returned
382    if input.findings_only {
383        let named: std::collections::BTreeSet<&str> =
384            r.findings.iter().map(|f| f.rule.as_str()).collect();
385        r.rules.retain(|p| named.contains(p.rule.id.as_str()));
386    }
387    if let Some(state) = input.state {
388        r.rules.retain(|p| p.state == state);
389    }
390    if let Some(class) = input.class {
391        r.rules.retain(|p| p.rule.class == class);
392    }
393    if let Some(ns) = &input.namespace {
394        r.rules.retain(|p| &p.rule.namespace == ns);
395    }
396    Ok(r)
397}
398
399fn show(ctx: &Context, input: RuleInput) -> Result<RuleDetail, CapabilityError> {
400    let r = full_report(ctx)?;
401    // a rule is named by its id or by the identity the index holds; a client that has one
402    // should not have to know the other's spelling
403    let wanted = input.rule.trim();
404    let Some(proof) = r
405        .rules
406        .iter()
407        .find(|p| p.rule.id == wanted || p.rule.identity == wanted)
408        .cloned()
409    else {
410        return Err(CapabilityError::NotFound(format!(
411            "`{wanted}` is not a rule of this repository; `rules show` lists what is"
412        )));
413    };
414    let by_id = |id: &String| r.rules.iter().find(|p| &p.rule.id == id).map(summary);
415    Ok(RuleDetail {
416        depends_on: proof.rule.depends_on.iter().filter_map(by_id).collect(),
417        required_by: proof.required_by.iter().filter_map(by_id).collect(),
418        missing: missing_for(&proof),
419        proof,
420    })
421}
422
423fn proves(ctx: &Context, input: RuleTestInput) -> Result<TestSubjects, CapabilityError> {
424    let r = full_report(ctx)?;
425    let given = input.test.trim();
426    // a path and an identity both name the same test
427    let id = TestId::of(given).or_else(|| {
428        let (prefix, name) = given.split_once(':')?;
429        let runner = match prefix {
430            "suite" => crate::evidence::Runner::Suite,
431            "crate" => crate::evidence::Runner::Crate,
432            _ => return None,
433        };
434        Some(TestId {
435            runner,
436            name: name.to_string(),
437        })
438    });
439    let key = id.as_ref().map(TestId::as_string);
440    let path = id
441        .as_ref()
442        .map(TestId::source)
443        .unwrap_or_else(|| given.to_string());
444
445    let mut proves = Vec::new();
446    let mut sole = Vec::new();
447    for p in &r.rules {
448        let names_it = p
449            .tests
450            .iter()
451            .any(|t| t.path == path || (key.is_some() && t.test == key) || t.path == given);
452        if !names_it {
453            continue;
454        }
455        proves.push(summary(p));
456        // the question the one-way relation could never answer: would deleting this leave
457        // the rule with nothing?
458        if p.tests.len() == 1 && p.validator.is_none() {
459            sole.push(p.rule.id.clone());
460        }
461    }
462    if proves.is_empty() && id.is_none() {
463        return Err(CapabilityError::InvalidInput(format!(
464            "`{given}` names no test and no rule names it: give `suite:<case>`, `crate:<binary>`, \
465             or the path of a case under test/cases/ or an integration test of the crate"
466        )));
467    }
468    Ok(TestSubjects {
469        present: root_of(ctx).join(&path).exists(),
470        reproduce: id.as_ref().map(TestId::reproduce),
471        test: key,
472        path,
473        proves,
474        sole_proof_of: sole,
475    })
476}
477
478// ---------------------------------------------------------------- the module
479
480/// The `rules` module: rules joined to what proves them.
481/// ```
482/// use majordomus_cli::capability::builtin::rules::module;
483/// let m = module();
484/// assert_eq!(m.id.as_str(), "rules");
485/// assert_eq!(m.capabilities.len(), 3);
486/// ```
487pub fn module() -> ModuleDescriptor {
488    module! {
489        id: "rules",
490        title: "Rules",
491        description: "What this repository refuses, and what it can actually show for each refusal. A rule declares a class — blocking, where a gate refuses the work, or advisory, where a reader is expected to have read it — and an enforcement block naming either the validator the dispatcher calls or the behavioural cases that prove it. This module joins that declaration to the tree and to the ledger of recorded runs, and reports, per rule, whether what it names is there, whether a runner owns it, whether it was ever run, whether the run passed, and whether the run is older than what it is about. A blocking rule whose proof is missing, dangling or failing is a finding, because a rule nobody can prove is a rule nobody enforces.",
492        stability: Stability::BehaviorallyVerified,
493        capabilities: [
494            capability! {
495                id: "rules.report",
496                title: "Every rule against the proof there is for it",
497                description: "The whole rule corpus joined to the tree and the ledger: per rule, its class, its enforcement mode, the validator and the cases it names, whether each is in the tree, the execution behind each, the CI gates that run them, and the sentence explaining how the state was derived. The tallies count the whole corpus even when the answer is filtered, and the findings name every rule whose declared class the proof does not support. Kept per checkout state, never longer: the ledger is a file that changes outside this process, and an edit of it, of a validator or of any tracked file, a new untracked file, a commit or a new ref is a new answer.",
498                input: RulesReportInput,
499                output: RulesReport,
500                stability: Stability::BehaviorallyVerified,
501                exposure: Exposure {
502                    mcp: Some(crate::capability::model::McpExposure {
503                        tool: Some("majordomus_rules".into()),
504                        resource: Some(McpResource { uri: RULES_URI.into(), name: "rules".into() }),
505                    }),
506                    http: get("/api/v1/rules"),
507                    cli: Some(CliExposure { path: vec!["rules".into(), "report".into()] }),
508                },
509                tags: ["rules", "governance", "verification", "evidence", "gates"],
510                cache: CachePolicy::Checkout { max_entries: 8 },
511                handler: report,
512            },
513            capability! {
514                id: "rules.show",
515                title: "What proves this rule",
516                description: "One rule with its full proof: the validator and the cases it names, the execution behind each, the gates that run them, the rules it depends on and the rules that depend on it — each with the state it is in, because a rule is proven only as far as its dependencies are — and, when it is not proven, the one sentence saying what would have to be true for it to be. A rule this repository does not declare is a not-found rather than an empty answer, because a typo that read as 'this rule has no proof' is the one answer this capability must never give.",
517                input: RuleInput,
518                output: RuleDetail,
519                stability: Stability::BehaviorallyVerified,
520                exposure: Exposure {
521                    mcp: mcp("majordomus_rule"),
522                    http: get("/api/v1/rules/rule"),
523                    cli: Some(CliExposure { path: vec!["rules".into(), "show".into()] }),
524                },
525                tags: ["rules", "governance", "verification", "evidence"],
526                cache: CachePolicy::Checkout { max_entries: 32 },
527                handler: show,
528            },
529            capability! {
530                id: "rules.proves",
531                title: "What this test proves",
532                description: "One test — by identity or by the path a rule names it with — with every rule that names it and the rules that would be left with no proof at all if it were deleted. This is the reverse of rules.show and reads the same derivation, so the two directions cannot disagree. It is the question that could not be asked while the relation ran one way only: before deleting or renaming a case, what stops being enforced.",
533                input: RuleTestInput,
534                output: TestSubjects,
535                stability: Stability::BehaviorallyVerified,
536                exposure: Exposure {
537                    mcp: mcp("majordomus_rule_proves"),
538                    http: get("/api/v1/rules/proves"),
539                    cli: Some(CliExposure { path: vec!["rules".into(), "proves".into()] }),
540                },
541                tags: ["rules", "governance", "tests", "verification"],
542                cache: CachePolicy::Disabled,
543                handler: proves,
544            },
545        ],
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    /// The declaration is the only place these names exist; every projection derives from
554    /// it. This is the assertion a refactor that dropped an exposure would fail.
555    #[test]
556    fn the_declaration_yields_the_projections_it_claims() {
557        let m = module();
558        assert_eq!(m.id.as_str(), "rules");
559        let expected: &[(&str, &str, &str, &[&str])] = &[
560            (
561                "rules.report",
562                "majordomus_rules",
563                "/api/v1/rules",
564                &["rules", "report"],
565            ),
566            (
567                "rules.show",
568                "majordomus_rule",
569                "/api/v1/rules/rule",
570                &["rules", "show"],
571            ),
572            (
573                "rules.proves",
574                "majordomus_rule_proves",
575                "/api/v1/rules/proves",
576                &["rules", "proves"],
577            ),
578        ];
579        let ids: Vec<&str> = m
580            .capabilities
581            .iter()
582            .map(|e| e.capability.id.as_str())
583            .collect();
584        let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
585        assert_eq!(ids, want);
586        for (e, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
587            let x = &e.capability.exposure;
588            assert_eq!(
589                x.mcp.as_ref().and_then(|m| m.tool.as_deref()),
590                Some(*tool),
591                "{id} lost its MCP tool"
592            );
593            assert_eq!(
594                x.http.as_ref().map(|h| h.path.as_str()),
595                Some(*path),
596                "{id} lost its HTTP operation"
597            );
598            assert_eq!(
599                x.cli.as_ref().map(|c| c.path.clone()),
600                Some(cli.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
601                "{id} lost or renamed its command line"
602            );
603            // the ledger changes outside this process, so an answer is kept, if at all, only
604            // per checkout state, which an edit of the ledger moves
605            assert!(
606                matches!(
607                    e.capability.cache,
608                    CachePolicy::Disabled | CachePolicy::Checkout { .. }
609                ),
610                "{id} may cache only per checkout state: the ledger changes outside this process"
611            );
612            assert!(e.capability.kind.is_read_only(), "{id} writes");
613        }
614    }
615
616    /// Every capability of this module is a read, reachable from all three surfaces. A rule
617    /// is governance: an agent that can read it over MCP and a person who can read it on the
618    /// command line must be reading the same thing, and neither may be able to edit it from
619    /// there — a rule changed over the network is a rule changed without review.
620    #[test]
621    fn the_whole_module_is_read_only_and_reachable_everywhere() {
622        for e in module().capabilities {
623            let id = e.capability.id.as_str().to_string();
624            let x = &e.capability.exposure;
625            assert!(e.capability.kind.is_read_only(), "{id} writes");
626            assert!(x.mcp.is_some(), "{id} is not reachable over MCP");
627            assert!(x.http.is_some(), "{id} is not reachable over HTTP");
628            let cli = x
629                .cli
630                .as_ref()
631                .unwrap_or_else(|| panic!("{id} has no command line"));
632            assert_eq!(cli.path.first().map(String::as_str), Some("rules"), "{id}");
633            assert_eq!(cli.path.len(), 2, "{id}");
634        }
635    }
636
637    /// The whole report is the resource an agent reads without calling a tool, and it is
638    /// the one this module names. A URI that drifted from the module's own constant would
639    /// leave the resource pointing at nothing.
640    #[test]
641    fn the_report_is_the_resource() {
642        let m = module();
643        let r = m
644            .capabilities
645            .iter()
646            .find(|e| e.capability.id.as_str() == "rules.report")
647            .expect("the report is declared");
648        let res = r
649            .capability
650            .exposure
651            .mcp
652            .as_ref()
653            .and_then(|m| m.resource.as_ref())
654            .expect("the report is a resource");
655        assert_eq!(res.uri, RULES_URI);
656        assert_eq!(res.name, "rules");
657    }
658
659    /// Every state a rule can be in says what to do about it, except the one that needs
660    /// nothing done. A state with no remedy is a dead end for whoever hits it.
661    #[test]
662    fn every_state_but_proven_says_what_would_fix_it() {
663        let base = |state: RuleState| RuleProof {
664            rule: crate::rules::RuleDefinition {
665                id: "project.x".into(),
666                identity: "project.x@1".into(),
667                uri: "majordomus://rule/project.x@1".into(),
668                version: 1,
669                title: "X".into(),
670                description: None,
671                statement: None,
672                status: "active".into(),
673                class: Class::Blocking,
674                namespace: "project".into(),
675                depends_on: vec![],
676                tags: vec![],
677                path: ".ai/repo/rules/project/x.v1.md".into(),
678                enforcement: crate::rules::Enforcement {
679                    mode: crate::rules::Mode::Gated,
680                    validator: None,
681                    category: None,
682                    exit_code: None,
683                    enforced_by: None,
684                    tests: vec!["test/cases/07_scope.sh".into()],
685                    reviewed_because: None,
686                },
687            },
688            validator: None,
689            tests: vec![crate::rules::TestProof {
690                path: "test/cases/07_scope.sh".into(),
691                kind: crate::rules::ArtifactKind::Case,
692                present: state != RuleState::Dangling,
693                test: Some("suite:07_scope".into()),
694                state: crate::evidence::ProofState::NotRun,
695                meaning: String::new(),
696                execution: None,
697                reproduce: Some("bash test/run.sh 07_scope".into()),
698                gates: vec![],
699            }],
700            gates: vec![],
701            required_by: vec![],
702            state,
703            meaning: state.meaning().into(),
704            satisfied: false,
705        };
706        assert!(missing_for(&base(RuleState::Proven)).is_none());
707        for state in [
708            RuleState::InputsUnchanged,
709            RuleState::Stale,
710            RuleState::Gated,
711            RuleState::Reviewed,
712            RuleState::Failing,
713            RuleState::NotRun,
714            RuleState::Unrunnable,
715            RuleState::Dangling,
716            RuleState::Unproven,
717        ] {
718            let m = missing_for(&base(state)).unwrap_or_else(|| {
719                panic!("{} says nothing about what would fix it", state.label())
720            });
721            assert!(m.len() > 20, "{}: `{m}` is not a diagnostic", state.label());
722        }
723    }
724}