1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct RulesReportInput {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub state: Option<RuleState>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub class: Option<Class>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub namespace: Option<String>,
83 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84 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)]
114pub struct RuleInput {
126 pub rule: String,
129}
130
131impl BenchmarkCases for RuleInput {
132 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
133 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)]
148pub struct RuleTestInput {
157 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175pub struct RuleDetail {
192 pub proof: RuleProof,
194 pub depends_on: Vec<RuleSummary>,
197 pub required_by: Vec<RuleSummary>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub missing: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
206pub struct RuleSummary {
221 pub id: String,
223 pub title: String,
225 pub class: Class,
227 pub state: RuleState,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
232pub struct TestSubjects {
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub test: Option<String>,
254 pub path: String,
256 pub present: bool,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub reproduce: Option<String>,
261 pub proves: Vec<RuleSummary>,
263 pub sole_proof_of: Vec<String>,
266}
267
268fn 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
278fn 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
296fn 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 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 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 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 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
478pub 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 #[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 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 #[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 #[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 #[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}