1use std::collections::{BTreeMap, BTreeSet};
72use std::fmt;
73use std::path::{Path, PathBuf};
74
75use schemars::JsonSchema;
76use serde::{Deserialize, Serialize};
77use serde_json::{json, Value};
78
79use crate::capability::{CapabilityRegistry, Context, Provenance};
80use crate::evidence::freshness::{
81 aggregate, compare, freshness, weakened_by, Comparison, Judgement, Presented, Recorded,
82 Supplementary, TreeState, UNCOMMITTED_RUN,
83};
84use crate::evidence::{
85 ClaimProof, EvidenceReport, Execution, Ledger, PresentedRevision, ProofState, TestId,
86};
87use crate::git::Containment;
88use crate::index::Index;
89use crate::product::ProductModel;
90use crate::rules::{RuleProof, RuleState, RulesReport};
91use crate::Severity;
92
93pub const SCHEMA: &str = "majordomus-site-evidence-subjects/v1";
95
96pub const PATH: &str = "site/data/registry/evidence-subjects.json";
99
100pub const DOCUMENT: &str = "site-evidence-subjects";
102
103pub const SOURCE: &str = "the claims, rules, features, use cases, commands, documented \
105command-line examples and builtin capabilities this repository declares, and the first \
106coverage and negative headers of its test cases";
107
108pub const ROUTE: &str = "/evidence/";
110
111pub const FEATURE_WITHOUT_EVIDENCE: &str = "feature_without_evidence";
113
114pub const COMMAND_IN_TWO_PROGRAMS: &str = "command_in_two_programs";
116
117pub const FRESHNESS_UNKNOWN: &str = "freshness_unknown";
119
120pub const DANGLING_MEMBER: &str = "dangling_member";
122
123const COVERS: &str = "# majordomus-covers:";
125const NEGATIVE: &str = "# majordomus-negative:";
127const CLI_EXAMPLES: &str = "apps/majordomus-cli/tests/cli_examples.rs";
129const CASES_DIR: &str = "test/cases";
131const CRATE_TESTS_DIR: &str = "apps/majordomus-cli/tests";
133
134#[derive(
148 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
149)]
150#[serde(rename_all = "snake_case")]
151#[schemars(rename = "EvidenceSubjectKind")]
152pub enum SubjectKind {
153 Claim,
155 Rule,
157 Feature,
159 Command,
161 Capability,
163 Mcp,
165 UseCase,
167}
168
169impl SubjectKind {
170 pub const ALL: [SubjectKind; 7] = [
172 SubjectKind::Claim,
173 SubjectKind::Rule,
174 SubjectKind::Feature,
175 SubjectKind::Command,
176 SubjectKind::Capability,
177 SubjectKind::Mcp,
178 SubjectKind::UseCase,
179 ];
180
181 pub fn prefix(self) -> &'static str {
189 match self {
190 SubjectKind::Claim => "claim",
191 SubjectKind::Rule => "rule",
192 SubjectKind::Feature => "feature",
193 SubjectKind::Command => "command",
194 SubjectKind::Capability => "capability",
195 SubjectKind::Mcp => "mcp",
196 SubjectKind::UseCase => "use_case",
197 }
198 }
199
200 pub fn key(self, id: &str) -> String {
207 format!("{}:{id}", self.prefix())
208 }
209
210 pub fn parse(key: &str) -> Option<(SubjectKind, &str)> {
226 let (prefix, id) = key.split_once(':')?;
227 if id.is_empty() {
228 return None;
229 }
230 let kind = SubjectKind::ALL
231 .into_iter()
232 .find(|k| k.prefix() == prefix)?;
233 Some((kind, id))
234 }
235
236 pub fn section(self) -> Option<&'static str> {
245 match self {
246 SubjectKind::Claim => Some("claims"),
247 SubjectKind::Rule => None,
248 SubjectKind::Feature => Some("features"),
249 SubjectKind::Command => Some("commands"),
250 SubjectKind::Capability => Some("capabilities"),
251 SubjectKind::Mcp => None,
252 SubjectKind::UseCase => Some("use-cases"),
253 }
254 }
255}
256
257#[derive(
266 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
267)]
268#[serde(rename_all = "snake_case")]
269#[schemars(rename = "EvidenceVia")]
270pub enum Via {
271 Claim,
273 Rule,
275 Behaviour,
277 Negative,
279 Example,
281 Scenario,
283}
284
285#[derive(
294 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
295)]
296#[serde(rename_all = "snake_case")]
297#[schemars(rename = "EvidenceCategory")]
298pub enum Category {
299 E2e,
301 Integration,
303 Unit,
305 Doctest,
307 Scenario,
309}
310
311impl Category {
312 pub const ALL: [Category; 5] = [
314 Category::E2e,
315 Category::Integration,
316 Category::Unit,
317 Category::Doctest,
318 Category::Scenario,
319 ];
320
321 pub fn of(test: &str) -> Option<Category> {
332 if test.starts_with("suite:") {
333 Some(Category::E2e)
334 } else if test.starts_with("crate:lib::") {
335 Some(Category::Unit)
336 } else if test.starts_with("crate:") {
337 Some(Category::Integration)
338 } else if test.starts_with("doc:") {
339 Some(Category::Doctest)
340 } else {
341 None
342 }
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
364#[schemars(rename = "EvidenceSubjectRoute")]
365pub struct Route {
366 pub via: Via,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub test: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub path: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub category: Option<Category>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub inputs: Option<Vec<String>>,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399#[schemars(rename = "EvidenceSubjectFinding")]
400pub struct SubjectFinding {
401 pub code: String,
404 pub severity: Severity,
406 pub subject: String,
408 pub message: String,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
428#[serde(rename_all = "snake_case")]
429pub enum Program {
430 Native,
434 Shell,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct SubjectEntry {
457 pub kind: SubjectKind,
459 pub id: String,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub status: Option<String>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub page: Option<String>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub alias_of: Option<String>,
470 pub programs: Vec<Program>,
473 pub members: Vec<String>,
475 pub routes: Vec<Route>,
477 pub mechanisms: Vec<String>,
479 pub findings: Vec<SubjectFinding>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct SubjectIndex {
497 pub subjects: BTreeMap<String, SubjectEntry>,
499 pub excluded: Vec<String>,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct Page {
517 pub kind: SubjectKind,
519 pub subject: String,
521 pub path: String,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct UnknownSubject {
540 pub key: String,
542}
543
544impl fmt::Display for UnknownSubject {
545 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546 write!(
547 f,
548 "`{}` is not a subject of this repository: a subject is <kind>:<id> for a declared \
549 claim, rule, feature, command, capability, MCP tool or use case",
550 self.key
551 )
552 }
553}
554
555impl std::error::Error for UnknownSubject {}
556
557impl SubjectIndex {
558 pub fn get(&self, key: &str) -> Result<&SubjectEntry, UnknownSubject> {
571 self.subjects.get(key).ok_or_else(|| UnknownSubject {
572 key: key.to_string(),
573 })
574 }
575
576 pub fn pages(&self) -> Vec<Page> {
589 let mut by_path: BTreeMap<String, Page> = BTreeMap::new();
590 for (key, e) in &self.subjects {
591 if let Some(path) = &e.page {
592 by_path.entry(path.clone()).or_insert_with(|| Page {
593 kind: e.kind,
594 subject: key.clone(),
595 path: path.clone(),
596 });
597 }
598 }
599 by_path.into_values().collect()
600 }
601
602 pub fn duplicate_pages(&self) -> Vec<(String, Vec<String>)> {
615 let mut by_path: BTreeMap<&str, Vec<String>> = BTreeMap::new();
616 for (key, e) in &self.subjects {
617 if let Some(path) = &e.page {
618 by_path.entry(path.as_str()).or_default().push(key.clone());
619 }
620 }
621 by_path
622 .into_iter()
623 .filter(|(_, keys)| keys.len() > 1)
624 .map(|(path, keys)| (path.to_string(), keys))
625 .collect()
626 }
627
628 pub fn reach(&self, key: &str) -> Result<Vec<&Route>, UnknownSubject> {
648 let (key, entry) = self
649 .subjects
650 .get_key_value(key)
651 .ok_or_else(|| UnknownSubject {
652 key: key.to_string(),
653 })?;
654 let mut walked: BTreeSet<&str> = BTreeSet::new();
655 let mut listed: BTreeSet<&Route> = BTreeSet::new();
656 let mut out = Vec::new();
657 self.walk(key, entry, &mut walked, &mut listed, &mut out);
658 Ok(out)
659 }
660
661 fn walk<'s>(
664 &'s self,
665 key: &'s str,
666 entry: &'s SubjectEntry,
667 walked: &mut BTreeSet<&'s str>,
668 listed: &mut BTreeSet<&'s Route>,
669 out: &mut Vec<&'s Route>,
670 ) {
671 if !walked.insert(key) {
672 return;
673 }
674 for route in &entry.routes {
675 if listed.insert(route) {
676 out.push(route);
677 }
678 }
679 for (member, e) in entry
680 .members
681 .iter()
682 .filter_map(|m| self.subjects.get_key_value(m))
683 {
684 self.walk(member, e, walked, listed, out);
685 }
686 }
687
688 pub fn document(&self, version: &str) -> Value {
706 let kinds: Vec<Value> = SubjectKind::ALL
707 .into_iter()
708 .map(|k| match k.section() {
709 Some(section) => json!({ "kind": k, "section": section }),
710 None => json!({ "kind": k }),
711 })
712 .collect();
713 json!({
714 "schema": SCHEMA,
715 "generated": crate::generate::json_banner(SOURCE),
716 "generator": { "id": "majordomus-cli", "version": version },
717 "route": ROUTE,
718 "kinds": kinds,
719 "categories": Category::ALL,
720 "pages": self.pages(),
721 "subjects": self.subjects,
722 "excluded": self.excluded,
723 })
724 }
725}
726
727#[derive(Debug, Clone, Default)]
732pub(crate) struct Declarations {
733 pub(crate) claims: Vec<ClaimDecl>,
734 pub(crate) rules: Vec<RuleDecl>,
735 pub(crate) features: Vec<FeatureDecl>,
736 pub(crate) use_cases: Vec<UseCaseDecl>,
737 pub(crate) commands: Vec<String>,
739 pub(crate) examples: Vec<String>,
741 pub(crate) capabilities: Vec<CapabilityDecl>,
742 pub(crate) cases: Vec<CaseDecl>,
744 pub(crate) crate_tests: Vec<String>,
746}
747
748#[derive(Debug, Clone, Default)]
750pub(crate) struct ClaimDecl {
751 pub(crate) id: String,
752 pub(crate) status: String,
753 pub(crate) source: Option<String>,
754 pub(crate) implementation: Option<String>,
755 pub(crate) test_path: Option<String>,
756 pub(crate) test: Option<String>,
757}
758
759#[derive(Debug, Clone, Default)]
761pub(crate) struct RuleDecl {
762 pub(crate) id: String,
763 pub(crate) status: String,
764 pub(crate) path: String,
765 pub(crate) tests: Vec<String>,
766}
767
768#[derive(Debug, Clone, Default)]
770pub(crate) struct FeatureDecl {
771 pub(crate) id: String,
772 pub(crate) status: String,
773 pub(crate) claims: Vec<String>,
774 pub(crate) rules: Vec<String>,
775 pub(crate) commands: Vec<String>,
776 pub(crate) use_cases: Vec<String>,
777}
778
779#[derive(Debug, Clone, Default)]
781pub(crate) struct UseCaseDecl {
782 pub(crate) id: String,
783 pub(crate) status: Option<String>,
784 pub(crate) path: String,
785 pub(crate) commands: Vec<String>,
786 pub(crate) doctrines: Vec<String>,
787 pub(crate) claims: Vec<String>,
788 pub(crate) mcp_tools: Vec<String>,
789}
790
791#[derive(Debug, Clone, Default)]
794pub(crate) struct CapabilityDecl {
795 pub(crate) id: String,
796 pub(crate) cli: Option<String>,
797 pub(crate) mcp_tool: Option<String>,
798 pub(crate) source: String,
799}
800
801#[derive(Debug, Clone, Default)]
804pub(crate) struct CaseDecl {
805 pub(crate) test: String,
806 pub(crate) path: String,
807 pub(crate) covers: Vec<String>,
808 pub(crate) negative: Vec<String>,
809}
810
811pub fn index(index: &Index, product: &ProductModel, registry: &CapabilityRegistry) -> SubjectIndex {
833 derive(&declarations(index, product, registry))
834}
835
836pub(crate) fn declarations(
838 index: &Index,
839 product: &ProductModel,
840 registry: &CapabilityRegistry,
841) -> Declarations {
842 let root = PathBuf::from(&index.repository.root);
843 let claims = crate::evidence::report(index, &Ledger::empty())
846 .claims
847 .into_iter()
848 .map(|c| ClaimDecl {
849 id: c.id,
850 status: c.status,
851 source: c.source,
852 implementation: c.implementation,
853 test_path: c.test_path,
854 test: c.test,
855 })
856 .collect();
857 let rules = crate::rules::definitions(index)
858 .into_iter()
859 .map(|r| RuleDecl {
860 id: r.id,
861 status: r.status,
862 path: r.path,
863 tests: r.enforcement.tests,
864 })
865 .collect();
866 let features = product
867 .all()
868 .iter()
869 .map(|r| FeatureDecl {
870 id: r.feature.id.clone(),
871 status: r.feature.status.clone(),
872 claims: r.feature.claims.clone(),
873 rules: r.feature.rules.clone(),
874 commands: r.feature.commands.clone(),
875 use_cases: r.feature.use_cases.clone(),
876 })
877 .collect();
878 let strings = |o: &crate::Object, key: &str| crate::graph::metadata_strings(&o.metadata, key);
879 let use_cases = index
880 .objects
881 .iter()
882 .filter(|o| o.kind == "use-case")
883 .map(|o| UseCaseDecl {
884 id: o.identity.clone(),
885 status: o
886 .metadata
887 .get("status")
888 .and_then(Value::as_str)
889 .map(str::to_string),
890 path: o.provenance.path.clone(),
891 commands: strings(o, "commands"),
892 doctrines: strings(o, "doctrines"),
893 claims: strings(o, "claims"),
894 mcp_tools: strings(o, "mcp_tools"),
895 })
896 .collect();
897 let commands = index
900 .objects
901 .iter()
902 .filter(|o| o.kind == "command")
903 .filter(|o| o.metadata.get("visibility").and_then(Value::as_str) == Some("public"))
904 .map(|o| o.identity.clone())
905 .collect();
906 let examples = crate::cli::EXAMPLES
907 .iter()
908 .map(|e| e.command)
909 .filter(|c| !c.is_empty())
910 .map(str::to_string)
911 .collect();
912 let capabilities = registry
913 .iter()
914 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
915 .map(|c| CapabilityDecl {
916 id: c.id.as_str().to_string(),
917 cli: c.exposure.cli.as_ref().map(|x| x.path.join(" ")),
918 mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
919 source: c.provenance.source_path(),
920 })
921 .collect();
922 Declarations {
923 claims,
924 rules,
925 features,
926 use_cases,
927 commands,
928 examples,
929 capabilities,
930 cases: read_cases(&root),
931 crate_tests: read_crate_tests(&root),
932 }
933}
934
935fn header_words(text: &str, header: &str) -> Vec<String> {
939 text.lines()
940 .find_map(|line| line.strip_prefix(header))
941 .map(|rest| {
942 rest.split_whitespace()
943 .filter(|w| *w != "none")
944 .map(str::to_string)
945 .collect()
946 })
947 .unwrap_or_default()
948}
949
950fn test_files(root: &Path, dir: &str) -> Vec<(String, TestId, PathBuf)> {
953 let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
954 return Vec::new();
955 };
956 entries
957 .flatten()
958 .filter(|e| e.path().is_file())
959 .filter_map(|e| {
960 let name = e.file_name().to_string_lossy().into_owned();
963 let path = format!("{dir}/{name}");
964 let test = TestId::of(&path)?;
965 Some((path, test, e.path()))
966 })
967 .collect()
968}
969
970fn read_cases(root: &Path) -> Vec<CaseDecl> {
973 test_files(root, CASES_DIR)
974 .into_iter()
975 .map(|(path, test, file)| {
976 let text = std::fs::read(&file)
977 .map(|b| String::from_utf8_lossy(&b).into_owned())
978 .unwrap_or_default();
979 CaseDecl {
980 test: test.as_string(),
981 path,
982 covers: header_words(&text, COVERS),
983 negative: header_words(&text, NEGATIVE),
984 }
985 })
986 .collect()
987}
988
989fn read_crate_tests(root: &Path) -> Vec<String> {
991 test_files(root, CRATE_TESTS_DIR)
992 .into_iter()
993 .map(|(_, test, _)| test.as_string())
994 .collect()
995}
996
997fn slug(id: &str) -> String {
1003 id.chars()
1004 .map(|c| if matches!(c, '.' | '_' | ' ') { '-' } else { c })
1005 .collect()
1006}
1007
1008fn page_of(kind: SubjectKind, id: &str) -> Option<String> {
1010 kind.section()
1011 .map(|section| format!("{ROUTE}{section}/{}/", slug(id)))
1012}
1013
1014struct Draft {
1018 kind: SubjectKind,
1019 id: String,
1020 status: Option<String>,
1021 alias_of: Option<String>,
1022 programs: BTreeSet<Program>,
1023 members: BTreeSet<String>,
1024 routes: BTreeSet<Route>,
1025 mechanisms: BTreeSet<String>,
1026}
1027
1028fn draft_of<'d>(
1031 drafts: &'d mut BTreeMap<String, Draft>,
1032 kind: SubjectKind,
1033 id: &str,
1034) -> &'d mut Draft {
1035 drafts.entry(kind.key(id)).or_insert_with(|| Draft {
1036 kind,
1037 id: id.to_string(),
1038 status: None,
1039 alias_of: None,
1040 programs: BTreeSet::new(),
1041 members: BTreeSet::new(),
1042 routes: BTreeSet::new(),
1043 mechanisms: BTreeSet::new(),
1044 })
1045}
1046
1047impl Draft {
1048 fn status(&mut self, status: &str) {
1050 let keep = match self.status.take() {
1051 Some(held) if held.as_str() <= status => held,
1052 _ => status.to_string(),
1053 };
1054 self.status = Some(keep);
1055 }
1056}
1057
1058pub(crate) fn derive(d: &Declarations) -> SubjectIndex {
1061 let mut drafts: BTreeMap<String, Draft> = BTreeMap::new();
1062
1063 for c in &d.claims {
1065 let inputs: Vec<String> = [
1066 c.source.clone(),
1067 c.implementation.clone(),
1068 c.test_path
1069 .as_deref()
1070 .and_then(TestId::of)
1071 .map(|t| t.source()),
1072 ]
1073 .into_iter()
1074 .flatten()
1075 .collect();
1076 let draft = draft_of(&mut drafts, SubjectKind::Claim, &c.id);
1077 draft.status(&c.status);
1078 draft.routes.insert(Route {
1079 via: Via::Claim,
1080 test: c.test.clone(),
1081 path: c.test_path.clone(),
1082 category: c.test.as_deref().and_then(Category::of),
1083 inputs: Some(inputs),
1084 });
1085 }
1086 for r in &d.rules {
1087 let draft = draft_of(&mut drafts, SubjectKind::Rule, &r.id);
1088 draft.status(&r.status);
1089 for p in &r.tests {
1090 match TestId::of(p) {
1091 Some(t) => {
1092 let test = t.as_string();
1093 draft.routes.insert(Route {
1094 via: Via::Rule,
1095 category: Category::of(&test),
1096 test: Some(test),
1097 path: Some(p.clone()),
1098 inputs: Some(vec![t.source(), r.path.clone()]),
1099 });
1100 }
1101 None => {
1102 draft.mechanisms.insert(p.clone());
1103 }
1104 }
1105 }
1106 }
1107
1108 let shell: BTreeSet<&str> = d.commands.iter().map(String::as_str).collect();
1109 let examples: BTreeSet<&str> = d.examples.iter().map(String::as_str).collect();
1110 let native: BTreeSet<&str> = examples
1111 .iter()
1112 .copied()
1113 .chain(d.capabilities.iter().filter_map(|c| c.cli.as_deref()))
1114 .collect();
1115 let example_route = TestId::of(CLI_EXAMPLES)
1118 .filter(|t| d.crate_tests.contains(&t.as_string()))
1119 .map(|t| {
1120 let test = t.as_string();
1121 Route {
1122 via: Via::Example,
1123 category: Category::of(&test),
1124 test: Some(test),
1125 path: Some(t.source()),
1126 inputs: None,
1127 }
1128 });
1129 for w in shell.union(&native) {
1130 let draft = draft_of(&mut drafts, SubjectKind::Command, w);
1131 if native.contains(w) {
1132 draft.programs.insert(Program::Native);
1133 }
1134 if shell.contains(w) {
1135 draft.programs.insert(Program::Shell);
1136 }
1137 if let Some(route) = example_route.as_ref().filter(|_| examples.contains(w)) {
1138 draft.routes.insert(route.clone());
1139 }
1140 }
1141
1142 for c in &d.capabilities {
1144 let key = SubjectKind::Capability.key(&c.id);
1145 let draft = draft_of(&mut drafts, SubjectKind::Capability, &c.id);
1146 if let Some(cli) = &c.cli {
1147 draft.members.insert(SubjectKind::Command.key(cli));
1148 }
1149 for claim in &d.claims {
1152 if claim.implementation.as_deref() == Some(c.source.as_str()) {
1153 draft.members.insert(SubjectKind::Claim.key(&claim.id));
1154 }
1155 }
1156 if let Some(tool) = &c.mcp_tool {
1157 let alias = draft_of(&mut drafts, SubjectKind::Mcp, tool);
1158 alias.members.insert(key.clone());
1159 alias.alias_of = match alias.alias_of.take() {
1160 Some(held) if held <= key => Some(held),
1161 _ => Some(key),
1162 };
1163 }
1164 }
1165
1166 for case in &d.cases {
1169 for (via, words) in [
1170 (Via::Behaviour, &case.covers),
1171 (Via::Negative, &case.negative),
1172 ] {
1173 for w in words {
1174 let key = if w.contains(':') {
1175 match SubjectKind::parse(w) {
1176 Some((SubjectKind::Capability, _)) => w.clone(),
1177 _ => continue,
1178 }
1179 } else {
1180 SubjectKind::Command.key(w)
1181 };
1182 if let Some(draft) = drafts.get_mut(&key) {
1183 draft.routes.insert(Route {
1184 via,
1185 test: Some(case.test.clone()),
1186 path: Some(case.path.clone()),
1187 category: Category::of(&case.test),
1188 inputs: None,
1189 });
1190 }
1191 }
1192 }
1193 }
1194
1195 let exists = |drafts: &BTreeMap<String, Draft>, kind: SubjectKind, id: &str| {
1198 let key = kind.key(id);
1199 drafts.contains_key(&key).then_some(key)
1200 };
1201 for u in &d.use_cases {
1202 let members: BTreeSet<String> = [
1203 (SubjectKind::Command, &u.commands),
1204 (SubjectKind::Rule, &u.doctrines),
1205 (SubjectKind::Claim, &u.claims),
1206 (SubjectKind::Mcp, &u.mcp_tools),
1207 ]
1208 .into_iter()
1209 .flat_map(|(kind, ids)| ids.iter().map(move |id| (kind, id)))
1210 .filter_map(|(kind, id)| exists(&drafts, kind, id))
1211 .collect();
1212 let draft = draft_of(&mut drafts, SubjectKind::UseCase, &u.id);
1213 if let Some(status) = &u.status {
1214 draft.status(status);
1215 }
1216 draft.members.extend(members);
1217 draft.routes.insert(Route {
1218 via: Via::Scenario,
1219 test: None,
1220 path: Some(u.path.clone()),
1221 category: Some(Category::Scenario),
1222 inputs: None,
1223 });
1224 }
1225 for f in &d.features {
1226 let members: BTreeSet<String> = [
1227 (SubjectKind::Claim, &f.claims),
1228 (SubjectKind::Rule, &f.rules),
1229 (SubjectKind::Command, &f.commands),
1230 (SubjectKind::UseCase, &f.use_cases),
1231 ]
1232 .into_iter()
1233 .flat_map(|(kind, ids)| ids.iter().map(move |id| (kind, id)))
1234 .filter_map(|(kind, id)| exists(&drafts, kind, id))
1235 .collect();
1236 let draft = draft_of(&mut drafts, SubjectKind::Feature, &f.id);
1237 draft.status(&f.status);
1238 draft.members.extend(members);
1239 }
1240
1241 let mut subjects: BTreeMap<String, SubjectEntry> = BTreeMap::new();
1243 for (key, draft) in drafts {
1244 let mut findings = Vec::new();
1245 if draft.programs.len() > 1 {
1246 findings.push(SubjectFinding {
1247 code: COMMAND_IN_TWO_PROGRAMS.to_string(),
1248 severity: Severity::Warning,
1249 subject: key.clone(),
1250 message: format!(
1251 "the word `{}` is a public command of the shell tool and a command path \
1252 of the executable: its behaviour and negative routes come from the shell \
1253 tool's case headers, its example route from the executable's documented \
1254 examples, and one page shows both",
1255 draft.id
1256 ),
1257 });
1258 }
1259 let page = page_of(draft.kind, &draft.id);
1260 let entry = SubjectEntry {
1261 kind: draft.kind,
1262 id: draft.id,
1263 status: draft.status,
1264 page,
1265 alias_of: draft.alias_of,
1266 programs: draft.programs.into_iter().collect(),
1267 members: draft.members.into_iter().collect(),
1268 routes: draft.routes.into_iter().collect(),
1269 mechanisms: draft.mechanisms.into_iter().collect(),
1270 findings,
1271 };
1272 subjects.insert(key, entry);
1273 }
1274
1275 let reached: BTreeSet<&str> = subjects
1277 .values()
1278 .flat_map(|e| e.routes.iter().filter_map(|r| r.test.as_deref()))
1279 .collect();
1280 let excluded: Vec<String> = d
1281 .cases
1282 .iter()
1283 .map(|c| c.test.as_str())
1284 .chain(d.crate_tests.iter().map(String::as_str))
1285 .filter(|t| !reached.contains(t))
1286 .map(str::to_string)
1287 .collect::<BTreeSet<_>>()
1288 .into_iter()
1289 .collect();
1290
1291 let mut index = SubjectIndex { subjects, excluded };
1292
1293 let without_evidence: BTreeSet<String> = index
1295 .subjects
1296 .iter()
1297 .filter(|(_, e)| e.kind == SubjectKind::Feature)
1298 .filter(|(key, _)| {
1299 index
1300 .reach(key)
1301 .map(|routes| routes.iter().all(|r| r.test.is_none()))
1302 .unwrap_or(false)
1303 })
1304 .map(|(key, _)| key.clone())
1305 .collect();
1306 for (key, e) in index
1307 .subjects
1308 .iter_mut()
1309 .filter(|(key, _)| without_evidence.contains(key.as_str()))
1310 {
1311 let severity = if e.status.as_deref() == Some(crate::product::STABLE) {
1312 Severity::Error
1313 } else {
1314 Severity::Warning
1315 };
1316 e.findings.push(SubjectFinding {
1317 code: FEATURE_WITHOUT_EVIDENCE.to_string(),
1318 severity,
1319 subject: key.clone(),
1320 message: format!(
1321 "the feature `{}` reaches no test a runner drives through its claims, rules, \
1322 commands or use cases",
1323 e.id
1324 ),
1325 });
1326 }
1327 index
1328}
1329
1330pub fn artifact(ctx: &Context, version: &str) -> crate::error::Result<crate::generate::Artifact> {
1350 rendered(&index(&ctx.index, &ctx.product, &ctx.registry), version)
1351}
1352
1353fn rendered(s: &SubjectIndex, version: &str) -> crate::error::Result<crate::generate::Artifact> {
1355 let duplicates = s.duplicate_pages();
1356 if !duplicates.is_empty() {
1357 let named: Vec<String> = duplicates
1358 .iter()
1359 .map(|(path, keys)| format!("{path} ({})", keys.join(", ")))
1360 .collect();
1361 return Err(crate::error::Error::Protocol {
1362 reason: format!(
1363 "two subjects claim one evidence page, so neither page would say which it is \
1364 about: {}",
1365 named.join("; ")
1366 ),
1367 });
1368 }
1369 let content = crate::http::openapi::render(&s.document(version));
1370 if let Some((marker, what)) = crate::generate::forbidden_in(&content) {
1371 return Err(crate::error::Error::Protocol {
1372 reason: format!(
1373 "{PATH} would publish {what} (it contains `{marker}`); nothing was written"
1374 ),
1375 });
1376 }
1377 Ok(crate::generate::Artifact::verbatim(
1378 PATH,
1379 DOCUMENT,
1380 crate::generate::ArtifactFormat::Json,
1381 Some(SCHEMA.to_string()),
1382 SOURCE,
1383 content,
1384 ))
1385}
1386
1387pub fn verdict(parts: &[(ProofState, bool)]) -> (ProofState, bool) {
1404 match aggregate(parts, ProofState::Failing) {
1405 None => (ProofState::NoTest, false),
1406 Some(s) => (s, parts.iter().any(|(_, carries)| *carries)),
1407 }
1408}
1409
1410const SCENARIO_DETAIL: &str = "a use case's scenario run is not recorded in the ledger; its \
1412 catalogue evidence is supplementary and never decides a verdict";
1413
1414const NO_EXECUTION_DETAIL: &str = "the rules report named no execution for a proven test";
1416
1417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1434#[schemars(rename = "SubjectEvidence")]
1435pub struct SubjectEvidence {
1436 pub subject: String,
1438 pub kind: SubjectKind,
1440 pub id: String,
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1444 pub page: Option<String>,
1445 pub presented: PresentedRevision,
1447 pub verdict: ProofState,
1449 pub meaning: String,
1451 pub carries_proof: bool,
1453 #[serde(default, skip_serializing_if = "Option::is_none")]
1455 pub rule_state: Option<RuleState>,
1456 pub totals: BTreeMap<String, usize>,
1458 pub routes: Vec<RouteProof>,
1460 pub members: Vec<MemberProof>,
1462 pub mechanisms: Vec<String>,
1464 pub subject_findings: Vec<SubjectFinding>,
1466}
1467
1468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1481#[schemars(rename = "EvidenceSubjectRouteProof")]
1482pub struct RouteProof {
1483 #[serde(flatten)]
1485 pub route: Route,
1486 pub state: ProofState,
1488 pub carries_proof: bool,
1490 pub changed: Vec<String>,
1492 #[serde(default, skip_serializing_if = "Option::is_none")]
1494 pub detail: Option<String>,
1495 #[serde(default, skip_serializing_if = "Option::is_none")]
1497 pub execution: Option<Execution>,
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1500 pub reproduce: Option<String>,
1501}
1502
1503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1516#[schemars(rename = "EvidenceSubjectMember")]
1517pub struct MemberProof {
1518 pub subject: String,
1520 pub kind: SubjectKind,
1522 pub state: ProofState,
1524 pub carries_proof: bool,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1528 pub rule_state: Option<RuleState>,
1529}
1530
1531pub type Adjust<'a> = dyn Fn(&TestId, Option<&Execution>, Judgement) -> Judgement + 'a;
1543
1544type Verdict = (ProofState, bool, Option<RuleState>);
1546
1547pub struct Judge<'a> {
1581 subjects: &'a SubjectIndex,
1582 claims: BTreeMap<&'a str, &'a ClaimProof>,
1583 rules: BTreeMap<&'a str, &'a RuleProof>,
1584 revision: &'a PresentedRevision,
1585 ledger: &'a Ledger,
1586 uncommitted: &'a [Execution],
1587 root: &'a Path,
1588 presented: &'a Presented,
1589 presented_tree: TreeState,
1590 adjust: Option<&'a Adjust<'a>>,
1591 comparisons: BTreeMap<String, Comparison>,
1592 containments: BTreeMap<(String, String), Containment>,
1593 memo: BTreeMap<String, Verdict>,
1594}
1595
1596impl<'a> Judge<'a> {
1597 pub fn new(
1614 subjects: &'a SubjectIndex,
1615 claims: &'a EvidenceReport,
1616 rules: &'a RulesReport,
1617 ledger: &'a Ledger,
1618 uncommitted: &'a [Execution],
1619 root: &'a Path,
1620 presented: &'a Presented,
1621 ) -> Self {
1622 Judge {
1623 subjects,
1624 claims: claims.claims.iter().map(|c| (c.id.as_str(), c)).collect(),
1625 rules: rules
1626 .rules
1627 .iter()
1628 .map(|r| (r.rule.id.as_str(), r))
1629 .collect(),
1630 revision: &claims.presented,
1631 ledger,
1632 uncommitted,
1633 root,
1634 presented,
1635 presented_tree: presented.tree(),
1636 adjust: None,
1637 comparisons: BTreeMap::new(),
1638 containments: BTreeMap::new(),
1639 memo: BTreeMap::new(),
1640 }
1641 }
1642
1643 pub fn with_adjust(mut self, adjust: &'a Adjust<'a>) -> Self {
1668 self.adjust = Some(adjust);
1669 self
1670 }
1671
1672 fn comparison(&mut self, commit: &str) -> Comparison {
1674 let (root, presented) = (self.root, self.presented);
1675 self.comparisons
1676 .entry(commit.to_string())
1677 .or_insert_with(|| compare(root, commit, presented))
1678 .clone()
1679 }
1680
1681 fn contains(&mut self, descendant: &str, ancestor: &str) -> Containment {
1683 let root = self.root;
1684 *self
1685 .containments
1686 .entry((descendant.to_string(), ancestor.to_string()))
1687 .or_insert_with(|| crate::git::contains(root, descendant, ancestor))
1688 }
1689
1690 fn uncommitted_weakens(
1693 &mut self,
1694 judgement: Judgement,
1695 test: &str,
1696 execution: Option<&Execution>,
1697 ) -> Judgement {
1698 let (Some(p), Some(e)) = (self.presented.commit(), execution) else {
1699 return judgement;
1700 };
1701 if judgement.state >= ProofState::Stale {
1702 return judgement;
1703 }
1704 let uncommitted = self.uncommitted;
1705 let Some(u) = uncommitted.iter().find(|u| u.test == test) else {
1706 return judgement;
1707 };
1708 let record = Supplementary {
1709 execution: u,
1710 named: UNCOMMITTED_RUN,
1711 after_evidence: self.contains(&u.commit, &e.commit),
1712 in_presented: self.contains(p, &u.commit),
1713 };
1714 weakened_by(judgement, &[record])
1715 }
1716
1717 fn at_presented(&mut self, route: &Route, execution: Option<&Execution>) -> Judgement {
1719 let source = route
1720 .path
1721 .as_deref()
1722 .and_then(TestId::of)
1723 .map(|t| t.source())
1724 .unwrap_or_default();
1725 let (recorded, cmp, moved) = match execution {
1726 None => (Recorded::NotRun, None, false),
1727 Some(e) => {
1728 let moved = e.outcome.proves() && e.digest_matches(self.root) == Some(false);
1729 (Recorded::Ran(e), Some(self.comparison(&e.commit)), moved)
1730 }
1731 };
1732 freshness(
1733 recorded,
1734 route.inputs.as_deref(),
1735 &source,
1736 moved,
1737 cmp.as_ref(),
1738 self.presented_tree,
1739 )
1740 }
1741
1742 fn route(&mut self, entry: &SubjectEntry, route: &Route) -> (RouteProof, bool) {
1745 let test = route.test.as_deref();
1746 let mut carries = true;
1747 let mut reproduce = test.and(route.path.as_deref()).and_then(TestId::of);
1748 let (judgement, execution) = match route.via {
1749 Via::Claim => match self.claims.get(entry.id.as_str()) {
1750 Some(c) => {
1751 carries = !matches!(c.status.as_str(), "planned" | "rejected");
1752 let j = Judgement {
1753 state: c.state,
1754 changed: c.changed.clone(),
1755 detail: c.detail.clone(),
1756 };
1757 (j, c.execution.clone())
1758 }
1759 None => {
1760 carries = !matches!(entry.status.as_deref(), Some("planned" | "rejected"));
1761 (missing("claim"), None)
1762 }
1763 },
1764 Via::Rule => {
1765 let proof = self.rules.get(entry.id.as_str()).and_then(|r| {
1766 r.tests
1767 .iter()
1768 .find(|t| Some(t.path.as_str()) == route.path.as_deref())
1769 });
1770 match proof {
1771 None => (missing("rule"), None),
1772 Some(t) => {
1773 let e = t.execution.clone();
1774 let j = if self.presented_tree != TreeState::Clean
1775 && t.state == ProofState::Proven
1776 {
1777 match &e {
1780 Some(e) => self.at_presented(route, Some(e)),
1781 None => Judgement {
1782 state: ProofState::Stale,
1783 changed: Vec::new(),
1784 detail: Some(NO_EXECUTION_DETAIL.to_string()),
1785 },
1786 }
1787 } else {
1788 Judgement {
1789 state: t.state,
1790 changed: Vec::new(),
1791 detail: None,
1792 }
1793 };
1794 (j, e)
1795 }
1796 }
1797 }
1798 Via::Behaviour | Via::Negative | Via::Example => {
1799 let e = test.and_then(|t| self.ledger.latest(t)).cloned();
1800 (self.at_presented(route, e.as_ref()), e)
1801 }
1802 Via::Scenario => {
1803 carries = false;
1804 reproduce = None;
1805 let mut j = freshness(
1806 Recorded::NotRun,
1807 None,
1808 route.path.as_deref().unwrap_or_default(),
1809 false,
1810 None,
1811 self.presented_tree,
1812 );
1813 j.detail = Some(SCENARIO_DETAIL.to_string());
1814 (j, None)
1815 }
1816 };
1817 let own = judgement.state;
1818 let judgement = match (route.via, test) {
1819 (Via::Claim | Via::Scenario, _) | (_, None) => judgement,
1820 (_, Some(t)) => self.uncommitted_weakens(judgement, t, execution.as_ref()),
1821 };
1822 let judgement = self.adjusted(route, execution.as_ref(), judgement);
1823 let proof = RouteProof {
1824 route: route.clone(),
1825 state: judgement.state,
1826 carries_proof: carries,
1827 changed: judgement.changed,
1828 detail: judgement.detail,
1829 execution,
1830 reproduce: reproduce.map(|t| t.reproduce()),
1831 };
1832 (proof, own != judgement.state)
1833 }
1834
1835 fn adjusted(
1838 &self,
1839 route: &Route,
1840 execution: Option<&Execution>,
1841 judgement: Judgement,
1842 ) -> Judgement {
1843 let Some(adjust) = self.adjust else {
1844 return judgement;
1845 };
1846 let Some(id) = route
1847 .test
1848 .as_ref()
1849 .and(route.path.as_deref())
1850 .and_then(TestId::of)
1851 else {
1852 return judgement;
1853 };
1854 let state = judgement.state;
1855 let adjusted = adjust(&id, execution, judgement.clone());
1856 let weaker = aggregate(
1857 &[(state, true), (adjusted.state, true)],
1858 ProofState::Failing,
1859 )
1860 .unwrap_or(state);
1861 if weaker == state {
1862 judgement
1863 } else {
1864 Judgement {
1865 state: weaker,
1866 ..adjusted
1867 }
1868 }
1869 }
1870}
1871
1872impl<'a> Judge<'a> {
1873 pub fn subject(&mut self, key: &str) -> Result<SubjectEvidence, UnknownSubject> {
1892 let subjects = self.subjects;
1893 let entry = subjects.get(key)?;
1894 let (routes, v) = self.evaluate(key, entry);
1895 let members: Vec<MemberProof> = entry
1896 .members
1897 .iter()
1898 .filter_map(|m| {
1899 let kind = SubjectKind::parse(m)?.0;
1900 let (state, carries_proof, rule_state) = self.verdict_of(m)?;
1901 Some(MemberProof {
1902 subject: m.clone(),
1903 kind,
1904 state,
1905 carries_proof,
1906 rule_state,
1907 })
1908 })
1909 .collect();
1910 let mut totals: BTreeMap<String, usize> = BTreeMap::new();
1911 let counted = routes.iter().map(|r| r.state).chain(
1912 members
1913 .iter()
1914 .filter(|_| entry.kind != SubjectKind::Rule)
1915 .map(|m| m.state),
1916 );
1917 for state in counted {
1918 *totals.entry(state.label().to_string()).or_insert(0) += 1;
1919 }
1920 let subject_findings = self.findings(key, entry, &routes);
1921 Ok(SubjectEvidence {
1922 subject: key.to_string(),
1923 kind: entry.kind,
1924 id: entry.id.clone(),
1925 page: entry.page.clone(),
1926 presented: self.revision.clone(),
1927 verdict: v.0,
1928 meaning: v.0.meaning().to_string(),
1929 carries_proof: v.1,
1930 rule_state: v.2,
1931 totals,
1932 routes,
1933 members,
1934 mechanisms: entry.mechanisms.clone(),
1935 subject_findings,
1936 })
1937 }
1938
1939 fn verdict_of(&mut self, key: &str) -> Option<Verdict> {
1941 if let Some(v) = self.memo.get(key) {
1942 return Some(*v);
1943 }
1944 let subjects = self.subjects;
1945 let entry = subjects.subjects.get(key)?;
1946 Some(self.evaluate(key, entry).1)
1947 }
1948
1949 fn evaluate(&mut self, key: &str, entry: &'a SubjectEntry) -> (Vec<RouteProof>, Verdict) {
1951 let mut routes = Vec::new();
1952 let mut moved = Vec::new();
1953 for r in &entry.routes {
1954 let (proof, was_moved) = self.route(entry, r);
1955 if was_moved {
1956 moved.push(proof.state);
1957 }
1958 routes.push(proof);
1959 }
1960 let v = if entry.kind == SubjectKind::Rule {
1961 self.rule_verdict(entry, &moved)
1962 } else {
1963 let mut parts: Vec<(ProofState, bool)> =
1964 routes.iter().map(|r| (r.state, r.carries_proof)).collect();
1965 for m in &entry.members {
1966 if let Some((state, carries, _)) = self.verdict_of(m) {
1967 parts.push((state, carries));
1968 }
1969 }
1970 let (state, carries) = verdict(&parts);
1971 (state, carries, None)
1972 };
1973 self.memo.insert(key.to_string(), v);
1974 (routes, v)
1975 }
1976
1977 fn rule_verdict(&self, entry: &SubjectEntry, moved: &[ProofState]) -> Verdict {
1980 let Some(proof) = self.rules.get(entry.id.as_str()) else {
1981 return (ProofState::NoTest, false, None);
1982 };
1983 let mut s = proof.state;
1984 if self.presented_tree != TreeState::Clean && s == RuleState::Proven {
1985 s = RuleState::InputsUnchanged;
1986 }
1987 let (mut p, carries) = s.as_proof(proof.rule.class);
1988 if !moved.is_empty() {
1989 let parts: Vec<(ProofState, bool)> = std::iter::once((p, true))
1990 .chain(moved.iter().map(|m| (*m, true)))
1991 .collect();
1992 p = aggregate(&parts, ProofState::Failing).unwrap_or(p);
1993 }
1994 (p, carries, Some(s))
1995 }
1996
1997 fn findings(
2001 &mut self,
2002 key: &str,
2003 entry: &SubjectEntry,
2004 routes: &[RouteProof],
2005 ) -> Vec<SubjectFinding> {
2006 let mut findings = entry.findings.clone();
2007 for m in &entry.members {
2008 let Some((SubjectKind::Rule, id)) = SubjectKind::parse(m) else {
2009 continue;
2010 };
2011 if self
2012 .rules
2013 .get(id)
2014 .is_some_and(|r| r.state == RuleState::Dangling)
2015 {
2016 findings.push(SubjectFinding {
2017 code: DANGLING_MEMBER.to_string(),
2018 severity: Severity::Warning,
2019 subject: key.to_string(),
2020 message: format!("the rule `{id}` names a path that is not in the tree"),
2021 });
2022 }
2023 }
2024 let commits: BTreeSet<&str> = routes
2025 .iter()
2026 .filter_map(|r| r.execution.as_ref())
2027 .filter(|e| e.outcome.proves())
2028 .map(|e| e.commit.as_str())
2029 .collect();
2030 for commit in commits {
2031 let cmp = self.comparison(commit);
2032 if cmp.containment == Containment::CommitUnknown || cmp.changed.is_none() {
2033 let short: String = commit.chars().take(12).collect();
2034 findings.push(SubjectFinding {
2035 code: FRESHNESS_UNKNOWN.to_string(),
2036 severity: Severity::Warning,
2037 subject: key.to_string(),
2038 message: format!(
2039 "git could not compare the evidence commit {short} with the presented \
2040 revision, so the routes recorded on it read stale; not knowing is \
2041 not proof"
2042 ),
2043 });
2044 }
2045 }
2046 findings
2047 }
2048}
2049
2050pub fn answer(
2074 index: &Index,
2075 product: &ProductModel,
2076 registry: &CapabilityRegistry,
2077 ledger: &Ledger,
2078 uncommitted: &[Execution],
2079 presented: &Presented,
2080 key: &str,
2081) -> Result<SubjectEvidence, UnknownSubject> {
2082 let s = self::index(index, product, registry);
2083 s.get(key)?;
2084 let claims = crate::evidence::report_at(index, ledger, presented, uncommitted);
2085 let rules = crate::rules::report(index, ledger);
2086 let root = PathBuf::from(&index.repository.root);
2087 Judge::new(&s, &claims, &rules, ledger, uncommitted, &root, presented).subject(key)
2088}
2089
2090fn missing(what: &str) -> Judgement {
2092 Judgement {
2093 state: ProofState::NoTest,
2094 changed: Vec::new(),
2095 detail: Some(format!(
2096 "the report the judge was given does not carry this {what}"
2097 )),
2098 }
2099}
2100
2101#[cfg(test)]
2102mod tests {
2103 use super::*;
2104
2105 fn claim(id: &str, status: &str, implementation: &str, test: &str) -> ClaimDecl {
2106 let opt = |s: &str| (!s.is_empty() && s != "-").then(|| s.to_string());
2107 ClaimDecl {
2108 id: id.into(),
2109 status: status.into(),
2110 source: Some("docs/A.md".into()),
2111 implementation: opt(implementation),
2112 test_path: opt(test),
2113 test: opt(test)
2114 .as_deref()
2115 .and_then(TestId::of)
2116 .map(|t| t.as_string()),
2117 }
2118 }
2119 fn case(name: &str, covers: &[&str], negative: &[&str]) -> CaseDecl {
2120 let path = format!("test/cases/{name}.sh");
2121 CaseDecl {
2122 test: TestId::of(&path).unwrap().as_string(),
2123 path,
2124 covers: covers.iter().map(|s| s.to_string()).collect(),
2125 negative: negative.iter().map(|s| s.to_string()).collect(),
2126 }
2127 }
2128 fn strings(v: &[&str]) -> Vec<String> {
2129 v.iter().map(|s| s.to_string()).collect()
2130 }
2131 fn cap(id: &str, cli: Option<&str>, tool: Option<&str>, module: &str) -> CapabilityDecl {
2132 CapabilityDecl {
2133 id: id.into(),
2134 cli: cli.map(str::to_string),
2135 mcp_tool: tool.map(str::to_string),
2136 source: format!("apps/majordomus-cli/src/capability/builtin/{module}.rs"),
2137 }
2138 }
2139
2140 fn world() -> Declarations {
2143 Declarations {
2144 claims: vec![
2145 claim(
2146 "alpha",
2147 "guaranteed",
2148 "lib/alpha.sh",
2149 "test/cases/01_alpha.sh",
2150 ),
2151 claim("planned", "planned", "-", "-"),
2152 claim(
2153 "in-module",
2154 "advisory",
2155 "apps/majordomus-cli/src/capability/builtin/things.rs",
2156 "test/cases/02_mod.sh",
2157 ),
2158 ],
2159 rules: vec![RuleDecl {
2160 id: "project.alpha".into(),
2161 status: "active".into(),
2162 path: ".ai/repo/rules/project/alpha.v1.md".into(),
2163 tests: strings(&["test/cases/01_alpha.sh", "scripts/alpha-gate"]),
2164 }],
2165 features: vec![
2166 FeatureDecl {
2167 id: "alpha".into(),
2168 status: "stable".into(),
2169 claims: strings(&["alpha", "planned", "no-such-claim"]),
2170 rules: strings(&["project.alpha"]),
2171 commands: strings(&["version"]),
2172 use_cases: strings(&["see-alpha"]),
2173 },
2174 FeatureDecl {
2175 id: "lonely".into(),
2176 status: "stable".into(),
2177 claims: strings(&["planned"]),
2178 ..FeatureDecl::default()
2179 },
2180 FeatureDecl {
2181 id: "drafty".into(),
2182 status: "draft".into(),
2183 claims: strings(&["planned"]),
2184 ..FeatureDecl::default()
2185 },
2186 ],
2187 use_cases: vec![UseCaseDecl {
2188 id: "see-alpha".into(),
2189 status: Some("active".into()),
2190 path: ".ai/repo/use-cases/see-alpha.md".into(),
2191 commands: strings(&["version"]),
2192 doctrines: strings(&["project.alpha"]),
2193 claims: strings(&["alpha"]),
2194 mcp_tools: strings(&["majordomus_things"]),
2195 }],
2196 commands: strings(&["version", "bench"]),
2197 examples: strings(&["bench", "commit plan"]),
2198 capabilities: vec![
2199 cap(
2200 "things.list",
2201 Some("things list"),
2202 Some("majordomus_things"),
2203 "things",
2204 ),
2205 cap("things.show_one", None, None, "things"),
2206 cap("other.x", None, Some("majordomus_other"), "other"),
2207 ],
2208 cases: vec![
2209 case("01_alpha", &["version"], &["version"]),
2210 case("02_mod", &[], &[]),
2211 case("03_bench", &["bench", "gate:x"], &[]),
2212 case("04_orphan", &[], &[]),
2213 case(
2214 "05_cap",
2215 &["capability:things.list"],
2216 &["capability:no.such"],
2217 ),
2218 ],
2219 crate_tests: strings(&["crate:cli_examples", "crate:lonely"]),
2220 }
2221 }
2222
2223 fn entry<'a>(s: &'a SubjectIndex, key: &str) -> &'a SubjectEntry {
2224 s.get(key).unwrap_or_else(|e| panic!("{e}"))
2225 }
2226 fn tests_of(e: &SubjectEntry, via: Via) -> Vec<&str> {
2227 e.routes
2228 .iter()
2229 .filter(|r| r.via == via)
2230 .filter_map(|r| r.test.as_deref())
2231 .collect()
2232 }
2233
2234 #[test]
2235 fn a_key_parses_back_to_its_kind() {
2236 for k in SubjectKind::ALL {
2237 let key = k.key("an.id with space");
2238 assert_eq!(SubjectKind::parse(&key), Some((k, "an.id with space")));
2239 assert_eq!(serde_json::to_value(k).unwrap(), k.prefix());
2240 }
2241 assert_eq!(
2242 SubjectKind::parse("command:commit plan"),
2243 Some((SubjectKind::Command, "commit plan"))
2244 );
2245 assert_eq!(
2247 SubjectKind::parse("capability:a:b"),
2248 Some((SubjectKind::Capability, "a:b"))
2249 );
2250 for bad in ["gate:x", "claim:", "x", ""] {
2251 assert_eq!(SubjectKind::parse(bad), None, "{bad}");
2252 }
2253 }
2254
2255 #[test]
2256 fn the_category_is_read_off_the_test_identity() {
2257 assert_eq!(Category::of("suite:a"), Some(Category::E2e));
2258 assert_eq!(Category::of("crate:lib::x"), Some(Category::Unit));
2259 assert_eq!(Category::of("crate:why"), Some(Category::Integration));
2260 assert_eq!(Category::of("doc:x"), Some(Category::Doctest));
2261 assert_eq!(Category::of("other:x"), None);
2262 }
2263
2264 #[test]
2265 fn a_claim_has_one_route_with_the_joins_inputs() {
2266 let s = derive(&world());
2267 let alpha = entry(&s, "claim:alpha");
2268 assert_eq!(alpha.routes.len(), 1);
2269 let r = &alpha.routes[0];
2270 assert_eq!(r.via, Via::Claim);
2271 assert_eq!(r.test.as_deref(), Some("suite:01_alpha"));
2272 assert_eq!(r.path.as_deref(), Some("test/cases/01_alpha.sh"));
2273 assert_eq!(r.category, Some(Category::E2e));
2274 assert_eq!(
2275 r.inputs.as_deref(),
2276 Some(&strings(&["docs/A.md", "lib/alpha.sh", "test/cases/01_alpha.sh"])[..])
2277 );
2278 assert_eq!(alpha.status.as_deref(), Some("guaranteed"));
2279 assert_eq!(alpha.page.as_deref(), Some("/evidence/claims/alpha/"));
2280
2281 let planned = entry(&s, "claim:planned");
2283 assert_eq!(planned.routes.len(), 1);
2284 assert!(planned.routes[0].test.is_none() && planned.routes[0].path.is_none());
2285
2286 let mut d = world();
2288 d.claims = vec![claim("gamma", "advisory", "lib/g.sh", "lib/gamma.sh")];
2289 let s = derive(&d);
2290 let r = &entry(&s, "claim:gamma").routes[0];
2291 assert_eq!(r.path.as_deref(), Some("lib/gamma.sh"));
2292 assert!(r.test.is_none() && r.category.is_none());
2293 assert_eq!(
2294 r.inputs.as_deref(),
2295 Some(&strings(&["docs/A.md", "lib/g.sh"])[..])
2296 );
2297 }
2298
2299 #[test]
2300 fn a_rule_path_no_runner_drives_is_a_mechanism() {
2301 let s = derive(&world());
2302 let rule = entry(&s, "rule:project.alpha");
2303 assert_eq!(rule.mechanisms, strings(&["scripts/alpha-gate"]));
2304 assert_eq!(rule.routes.len(), 1);
2305 let r = &rule.routes[0];
2306 assert_eq!(
2307 (r.via, r.test.as_deref()),
2308 (Via::Rule, Some("suite:01_alpha"))
2309 );
2310 assert_eq!(
2311 r.inputs.as_deref(),
2312 Some(
2313 &strings(&[
2314 "test/cases/01_alpha.sh",
2315 ".ai/repo/rules/project/alpha.v1.md"
2316 ])[..]
2317 )
2318 );
2319 assert!(rule.page.is_none(), "a rule has no page of its own");
2320 assert_eq!(rule.status.as_deref(), Some("active"));
2321 }
2322
2323 #[test]
2324 fn the_first_header_decides() {
2325 let dir = tempfile::tempdir().unwrap();
2327 let cases = dir.path().join(CASES_DIR);
2328 std::fs::create_dir_all(&cases).unwrap();
2329 let header = |kind: &str, words: &str| format!("# majordomus-{kind}: {words}\n");
2330 std::fs::write(
2331 cases.join("01_first.sh"),
2332 header("covers", "none") + header("covers", "version").as_str(),
2333 )
2334 .unwrap();
2335 std::fs::write(
2336 cases.join("02_words.sh"),
2337 header("covers", "gate:x capability:c.x version")
2338 + header("negative", "none version").as_str(),
2339 )
2340 .unwrap();
2341 std::fs::write(cases.join("README.md"), header("covers", "version")).unwrap();
2342 std::fs::create_dir_all(cases.join("99_dir.sh")).unwrap();
2343 let read = read_cases(dir.path());
2344 assert_eq!(
2345 read.len(),
2346 2,
2347 "a file no runner drives and a directory are not cases"
2348 );
2349 let first = read.iter().find(|c| c.test == "suite:01_first").unwrap();
2350 assert!(
2351 first.covers.is_empty(),
2352 "`none` then a second line names nothing"
2353 );
2354 let words = read.iter().find(|c| c.test == "suite:02_words").unwrap();
2355 assert_eq!(
2356 words.covers,
2357 strings(&["gate:x", "capability:c.x", "version"])
2358 );
2359 assert_eq!(
2360 words.negative,
2361 strings(&["version"]),
2362 "`none` names nothing"
2363 );
2364
2365 let d = Declarations {
2366 commands: strings(&["version"]),
2367 capabilities: vec![cap("c.x", None, None, "c")],
2368 cases: read,
2369 ..Declarations::default()
2370 };
2371 let s = derive(&d);
2372 assert_eq!(
2373 tests_of(entry(&s, "command:version"), Via::Behaviour),
2374 vec!["suite:02_words"]
2375 );
2376 assert_eq!(
2377 tests_of(entry(&s, "capability:c.x"), Via::Behaviour),
2378 vec!["suite:02_words"]
2379 );
2380 assert!(
2381 s.get("gate:x").is_err(),
2382 "a prefixed word that is no kind is ignored"
2383 );
2384 assert!(s.subjects.keys().all(|k| !k.contains("gate")));
2385 }
2386
2387 #[test]
2388 fn a_missing_directory_holds_no_tests() {
2389 let dir = tempfile::tempdir().unwrap();
2390 assert!(read_cases(dir.path()).is_empty());
2391 assert!(read_crate_tests(dir.path()).is_empty());
2392 let tests = dir.path().join(CRATE_TESTS_DIR);
2393 std::fs::create_dir_all(&tests).unwrap();
2394 std::fs::write(tests.join("why.rs"), "").unwrap();
2395 std::fs::write(tests.join("notes.txt"), "").unwrap();
2396 assert_eq!(read_crate_tests(dir.path()), strings(&["crate:why"]));
2397 }
2398
2399 #[test]
2400 fn a_negative_header_is_its_own_route() {
2401 let s = derive(&world());
2402 let version = entry(&s, "command:version");
2403 assert_eq!(tests_of(version, Via::Behaviour), vec!["suite:01_alpha"]);
2404 assert_eq!(tests_of(version, Via::Negative), vec!["suite:01_alpha"]);
2405 assert_eq!(
2406 version.routes.len(),
2407 2,
2408 "one behaviour route and one negative route"
2409 );
2410 assert!(s.get("capability:no.such").is_err());
2412 }
2413
2414 #[test]
2415 fn a_documented_command_routes_to_the_examples_only_where_they_run() {
2416 let s = derive(&world());
2417 let plan = entry(&s, "command:commit plan");
2418 let example: Vec<&Route> = plan
2419 .routes
2420 .iter()
2421 .filter(|r| r.via == Via::Example)
2422 .collect();
2423 assert_eq!(example.len(), 1);
2424 assert_eq!(example[0].test.as_deref(), Some("crate:cli_examples"));
2425 assert_eq!(example[0].path.as_deref(), Some(CLI_EXAMPLES));
2426 assert_eq!(example[0].category, Some(Category::Integration));
2427 assert!(tests_of(entry(&s, "command:version"), Via::Example).is_empty());
2429
2430 let mut d = world();
2431 d.crate_tests.retain(|t| t != "crate:cli_examples");
2432 let s = derive(&d);
2433 assert!(tests_of(entry(&s, "command:commit plan"), Via::Example).is_empty());
2434 }
2435
2436 #[test]
2437 fn members_form_a_dag() {
2438 let s = derive(&world());
2439 let feature = entry(&s, "feature:alpha");
2440 assert!(feature.members.contains(&"use_case:see-alpha".to_string()));
2441 for (key, e) in &s.subjects {
2442 let starts = |p: &str| e.members.iter().all(|m| m.starts_with(p));
2443 match e.kind {
2444 SubjectKind::UseCase => assert!(
2445 e.members
2446 .iter()
2447 .all(|m| !m.starts_with("feature:") && !m.starts_with("use_case:")),
2448 "{key}"
2449 ),
2450 SubjectKind::Capability => assert!(
2451 e.members
2452 .iter()
2453 .all(|m| m.starts_with("command:") || m.starts_with("claim:")),
2454 "{key}"
2455 ),
2456 SubjectKind::Mcp => {
2457 assert_eq!(e.members.len(), 1, "{key}");
2458 assert!(starts("capability:"), "{key}");
2459 }
2460 SubjectKind::Claim | SubjectKind::Rule | SubjectKind::Command => {
2461 assert!(e.members.is_empty(), "{key}")
2462 }
2463 SubjectKind::Feature => assert!(
2464 e.members.iter().all(|m| !m.starts_with("feature:")
2465 && !m.starts_with("mcp:")
2466 && !m.starts_with("capability:")),
2467 "{key}"
2468 ),
2469 }
2470 }
2471 assert!(!feature.members.contains(&"claim:no-such-claim".to_string()));
2473 assert!(s.get("claim:no-such-claim").is_err());
2474 }
2475
2476 #[test]
2477 fn a_capability_is_made_of_its_modules_claims() {
2478 let mut d = world();
2479 d.claims.push(claim(
2480 "in-another-module",
2481 "advisory",
2482 "apps/majordomus-cli/src/capability/builtin/other.rs",
2483 "-",
2484 ));
2485 d.claims.push(claim(
2486 "in-the-cli",
2487 "advisory",
2488 "apps/majordomus-cli/src/cli.rs",
2489 "-",
2490 ));
2491 let s = derive(&d);
2492 for key in ["capability:things.list", "capability:things.show_one"] {
2493 let e = entry(&s, key);
2494 assert!(e.members.contains(&"claim:in-module".to_string()), "{key}");
2495 assert!(
2496 !e.members.contains(&"claim:in-another-module".to_string()),
2497 "{key}"
2498 );
2499 assert!(
2500 !e.members.contains(&"claim:in-the-cli".to_string()),
2501 "{key}"
2502 );
2503 }
2504 assert_eq!(
2505 entry(&s, "capability:things.list").members,
2506 strings(&["claim:in-module", "command:things list"])
2507 );
2508 assert_eq!(
2509 entry(&s, "capability:other.x").members,
2510 strings(&["claim:in-another-module"])
2511 );
2512 }
2513
2514 #[test]
2515 fn a_word_both_programs_answer_to_is_named() {
2516 let d = Declarations {
2517 commands: strings(&["bench", "version"]),
2518 examples: strings(&["bench", "commit plan"]),
2519 capabilities: vec![cap(
2520 "capabilities.list",
2521 Some("capabilities list"),
2522 None,
2523 "c",
2524 )],
2525 claims: vec![claim("a", "guaranteed", "-", "-")],
2526 ..Declarations::default()
2527 };
2528 let s = derive(&d);
2529 let bench = entry(&s, "command:bench");
2530 assert_eq!(bench.programs, vec![Program::Native, Program::Shell]);
2531 assert_eq!(bench.findings.len(), 1);
2532 assert_eq!(bench.findings[0].code, COMMAND_IN_TWO_PROGRAMS);
2533 assert_eq!(bench.findings[0].severity, Severity::Warning);
2534 assert_eq!(bench.findings[0].subject, "command:bench");
2535 assert_eq!(entry(&s, "command:version").programs, vec![Program::Shell]);
2536 for key in ["command:commit plan", "command:capabilities list"] {
2537 assert_eq!(entry(&s, key).programs, vec![Program::Native], "{key}");
2538 }
2539 for (key, e) in &s.subjects {
2540 if key != "command:bench" {
2541 assert!(e.findings.is_empty(), "{key}");
2542 }
2543 if e.kind != SubjectKind::Command {
2544 assert!(e.programs.is_empty(), "{key}");
2545 }
2546 }
2547 }
2548
2549 #[test]
2550 fn a_use_case_names_what_graph_rs_names() {
2551 let s = derive(&world());
2552 let u = entry(&s, "use_case:see-alpha");
2553 assert_eq!(
2554 u.members,
2555 strings(&[
2556 "claim:alpha",
2557 "command:version",
2558 "mcp:majordomus_things",
2559 "rule:project.alpha"
2560 ])
2561 );
2562 assert_eq!(u.routes.len(), 1);
2563 let r = &u.routes[0];
2564 assert_eq!(r.via, Via::Scenario);
2565 assert_eq!(r.category, Some(Category::Scenario));
2566 assert_eq!(r.path.as_deref(), Some(".ai/repo/use-cases/see-alpha.md"));
2567 assert!(r.test.is_none());
2568 assert_eq!(u.page.as_deref(), Some("/evidence/use-cases/see-alpha/"));
2569 assert_eq!(u.status.as_deref(), Some("active"));
2570 }
2571
2572 #[test]
2573 fn an_mcp_tool_is_an_alias_of_its_capability() {
2574 let s = derive(&world());
2575 let alias = entry(&s, "mcp:majordomus_things");
2576 assert_eq!(alias.alias_of.as_deref(), Some("capability:things.list"));
2577 assert_eq!(alias.members, strings(&["capability:things.list"]));
2578 assert!(alias.page.is_none() && alias.routes.is_empty());
2579 assert!(s
2580 .pages()
2581 .iter()
2582 .all(|p| p.subject != "mcp:majordomus_things"));
2583
2584 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2586 let ctx = repo.context().unwrap();
2587 let s = index(&ctx.index, &ctx.product, &ctx.registry);
2588 let mut aliases = 0;
2589 for (key, e) in s
2590 .subjects
2591 .iter()
2592 .filter(|(_, e)| e.kind == SubjectKind::Mcp)
2593 {
2594 let by_registry = ctx.registry.by_mcp_tool(&e.id).unwrap();
2595 assert_eq!(
2596 e.alias_of,
2597 Some(format!("capability:{}", by_registry.id)),
2598 "{key}"
2599 );
2600 aliases += 1;
2601 }
2602 assert!(aliases > 0, "a check over no alias is not a pass");
2603 }
2604
2605 #[test]
2606 fn the_slug_matches_the_site() {
2607 assert_eq!(slug("session_domain.identity"), "session-domain-identity");
2608 assert_eq!(slug("commit plan"), "commit-plan");
2609 assert_eq!(
2610 page_of(SubjectKind::Capability, "repository.scope_classify").as_deref(),
2611 Some("/evidence/capabilities/repository-scope-classify/")
2612 );
2613 assert_eq!(page_of(SubjectKind::Rule, "project.x"), None);
2614 }
2615
2616 #[test]
2617 fn a_feature_that_reaches_no_test_is_a_finding() {
2618 let s = derive(&world());
2619 let lonely = entry(&s, "feature:lonely");
2620 assert_eq!(lonely.findings.len(), 1);
2621 assert_eq!(lonely.findings[0].code, FEATURE_WITHOUT_EVIDENCE);
2622 assert_eq!(lonely.findings[0].severity, Severity::Error);
2623 assert_eq!(
2624 entry(&s, "feature:drafty").findings[0].severity,
2625 Severity::Warning
2626 );
2627 assert!(entry(&s, "feature:alpha").findings.is_empty());
2629
2630 let mut d = world();
2632 d.features = vec![FeatureDecl {
2633 id: "only-a-scenario".into(),
2634 status: "stable".into(),
2635 claims: strings(&["planned"]),
2636 use_cases: strings(&["bare"]),
2637 ..FeatureDecl::default()
2638 }];
2639 d.use_cases.push(UseCaseDecl {
2640 id: "bare".into(),
2641 path: ".ai/repo/use-cases/bare.md".into(),
2642 ..UseCaseDecl::default()
2643 });
2644 let s = derive(&d);
2645 let f = entry(&s, "feature:only-a-scenario");
2646 assert_eq!(f.findings.len(), 1);
2647 assert_eq!(f.findings[0].code, FEATURE_WITHOUT_EVIDENCE);
2648 }
2649
2650 #[test]
2651 fn excluded_lists_what_nothing_reaches() {
2652 let s = derive(&world());
2653 assert_eq!(s.excluded, strings(&["crate:lonely", "suite:04_orphan"]));
2654 for t in [
2656 "suite:01_alpha",
2657 "suite:02_mod",
2658 "suite:03_bench",
2659 "suite:05_cap",
2660 ] {
2661 assert!(!s.excluded.contains(&t.to_string()), "{t}");
2662 }
2663 let mut d = world();
2665 d.claims.clear();
2666 d.cases = vec![case("01_alpha", &[], &[])];
2667 let s = derive(&d);
2668 assert!(!s.excluded.contains(&"suite:01_alpha".to_string()));
2669 }
2670
2671 #[test]
2672 fn the_derivation_does_not_depend_on_input_order() {
2673 let d = world();
2674 let expected = crate::http::openapi::render(&derive(&d).document("1"));
2675 let permute = |d: &Declarations, f: &dyn Fn(&mut Vec<String>)| {
2676 let mut p = d.clone();
2677 p.claims.reverse();
2678 p.rules.reverse();
2679 p.features.reverse();
2680 p.use_cases.reverse();
2681 p.capabilities.reverse();
2682 p.cases.reverse();
2683 f(&mut p.commands);
2684 f(&mut p.examples);
2685 f(&mut p.crate_tests);
2686 for feature in &mut p.features {
2687 f(&mut feature.claims);
2688 f(&mut feature.use_cases);
2689 }
2690 for u in &mut p.use_cases {
2691 f(&mut u.commands);
2692 f(&mut u.mcp_tools);
2693 }
2694 p
2695 };
2696 let reverse = permute(&d, &|v: &mut Vec<String>| v.reverse());
2697 let rotate = permute(&d, &|v: &mut Vec<String>| {
2698 if !v.is_empty() {
2699 v.rotate_left(1)
2700 }
2701 });
2702 for p in [reverse, rotate] {
2703 assert_eq!(
2704 crate::http::openapi::render(&derive(&p).document("1")),
2705 expected
2706 );
2707 }
2708 }
2709
2710 #[test]
2711 fn the_document_carries_no_ledger_content() {
2712 let doc = derive(&world()).document("1");
2713 fn keys(v: &Value, out: &mut BTreeSet<String>) {
2714 match v {
2715 Value::Object(o) => {
2716 for (k, v) in o {
2717 out.insert(k.clone());
2718 keys(v, out);
2719 }
2720 }
2721 Value::Array(a) => a.iter().for_each(|v| keys(v, out)),
2722 _ => {}
2723 }
2724 }
2725 let mut found = BTreeSet::new();
2726 keys(&doc, &mut found);
2727 for forbidden in [
2728 "commit",
2729 "head",
2730 "outcome",
2731 "state",
2732 "execution",
2733 "at",
2734 "digest",
2735 ] {
2736 assert!(!found.contains(forbidden), "{forbidden}");
2737 }
2738 assert!(
2739 found.contains("subjects") && found.contains("routes"),
2740 "the walk saw the document"
2741 );
2742 assert!(crate::generate::forbidden_in(&crate::http::openapi::render(&doc)).is_none());
2743 assert!(PATH.starts_with(crate::generate::SITE_DATA_DIR));
2744 let order: Vec<&str> = doc
2745 .as_object()
2746 .unwrap()
2747 .keys()
2748 .map(String::as_str)
2749 .collect();
2750 assert_eq!(
2751 order,
2752 [
2753 "schema",
2754 "generated",
2755 "generator",
2756 "route",
2757 "kinds",
2758 "categories",
2759 "pages",
2760 "subjects",
2761 "excluded"
2762 ]
2763 );
2764 assert_eq!(doc["kinds"][1], json!({ "kind": "rule" }));
2765 assert_eq!(
2766 doc["kinds"][6],
2767 json!({ "kind": "use_case", "section": "use-cases" })
2768 );
2769 }
2770
2771 #[test]
2772 fn two_subjects_on_one_page_are_found() {
2773 let d = Declarations {
2775 capabilities: vec![cap("a.b", None, None, "a"), cap("a_b", None, None, "a")],
2776 ..Declarations::default()
2777 };
2778 let s = derive(&d);
2779 assert_eq!(
2780 s.duplicate_pages(),
2781 vec![(
2782 "/evidence/capabilities/a-b/".to_string(),
2783 strings(&["capability:a.b", "capability:a_b"])
2784 )]
2785 );
2786 let refused = rendered(&s, "1").unwrap_err().to_string();
2787 assert!(refused.contains("/evidence/capabilities/a-b/"), "{refused}");
2788 assert!(refused.contains("capability:a_b"), "{refused}");
2789 assert!(derive(&world()).duplicate_pages().is_empty());
2790 }
2791
2792 #[test]
2793 fn a_leak_is_refused_and_nothing_is_rendered() {
2794 let marker = format!("{}someone/x.sh", "/Users/");
2795 let d = Declarations {
2796 claims: vec![claim("leaky", "advisory", "-", &marker)],
2797 ..Declarations::default()
2798 };
2799 let refused = rendered(&derive(&d), "1").unwrap_err().to_string();
2800 assert!(
2801 refused.contains(PATH) && refused.contains("/Users/"),
2802 "{refused}"
2803 );
2804
2805 let a = rendered(&derive(&world()), "1").unwrap();
2806 assert_eq!((a.path.as_str(), a.document.as_str()), (PATH, DOCUMENT));
2807 assert!(a.content.contains("\"feature:alpha\""));
2808 }
2809
2810 #[test]
2811 fn reach_is_the_walk_the_finding_uses() {
2812 let s = derive(&world());
2813 let reached = s.reach("feature:alpha").unwrap();
2814 let shown: Vec<(Via, Option<&str>)> =
2815 reached.iter().map(|r| (r.via, r.test.as_deref())).collect();
2816 assert_eq!(
2820 shown,
2821 vec![
2822 (Via::Claim, Some("suite:01_alpha")),
2823 (Via::Claim, None),
2824 (Via::Behaviour, Some("suite:01_alpha")),
2825 (Via::Negative, Some("suite:01_alpha")),
2826 (Via::Rule, Some("suite:01_alpha")),
2827 (Via::Scenario, None),
2828 (Via::Behaviour, Some("suite:05_cap")),
2829 (Via::Claim, Some("suite:02_mod")),
2830 ]
2831 );
2832 let unique: BTreeSet<&&Route> = reached.iter().collect();
2834 assert_eq!(unique.len(), reached.len());
2835 assert!(s.reach("feature:no-such").is_err());
2836 let twins = Declarations {
2839 claims: vec![
2840 claim("one", "guaranteed", "lib/x.sh", "test/cases/01_x.sh"),
2841 claim("two", "guaranteed", "lib/x.sh", "test/cases/01_x.sh"),
2842 ],
2843 features: vec![FeatureDecl {
2844 id: "both".into(),
2845 status: "stable".into(),
2846 claims: strings(&["one", "two"]),
2847 ..FeatureDecl::default()
2848 }],
2849 ..Declarations::default()
2850 };
2851 let t = derive(&twins);
2852 assert_eq!(entry(&t, "claim:one").routes, entry(&t, "claim:two").routes);
2853 let reached = t.reach("feature:both").unwrap();
2854 assert_eq!(reached.len(), 1, "{reached:?}");
2855 assert_eq!(reached[0].test.as_deref(), Some("suite:01_x"));
2856 for (key, e) in s
2858 .subjects
2859 .iter()
2860 .filter(|(_, e)| e.kind == SubjectKind::Feature)
2861 {
2862 let none = s.reach(key).unwrap().iter().all(|r| r.test.is_none());
2863 let found = e
2864 .findings
2865 .iter()
2866 .any(|f| f.code == FEATURE_WITHOUT_EVIDENCE);
2867 assert_eq!(none, found, "{key}");
2868 }
2869 }
2870
2871 #[test]
2872 fn an_unknown_key_is_refused_by_name() {
2873 let s = derive(&world());
2874 for key in ["feature:no-such", "gate:x", "nonsense"] {
2875 let refused = s.get(key).unwrap_err();
2876 assert_eq!(refused.key, key);
2877 let said = refused.to_string();
2878 assert!(
2879 said.starts_with(&format!("`{key}` is not a subject of this repository: ")),
2880 "{said}"
2881 );
2882 assert!(said.ends_with("MCP tool or use case"), "{said}");
2883 let as_error: &dyn std::error::Error = &refused;
2884 assert_eq!(as_error.to_string(), said);
2885 }
2886 }
2887
2888 #[test]
2889 fn a_subject_declared_twice_is_one_subject_whatever_the_order() {
2890 let twice = |first: &str, second: &str| Declarations {
2894 claims: vec![claim("a", first, "-", "-"), claim("a", second, "-", "-")],
2895 capabilities: vec![
2896 cap("x.one", None, Some("majordomus_x"), "x"),
2897 cap("x.two", None, Some("majordomus_x"), "x"),
2898 ],
2899 ..Declarations::default()
2900 };
2901 let forward = derive(&twice("advisory", "guaranteed"));
2902 let mut backward = twice("guaranteed", "advisory");
2903 backward.capabilities.reverse();
2904 assert_eq!(derive(&backward), forward);
2905
2906 let once = entry(&forward, "claim:a");
2907 assert_eq!(
2908 once.status.as_deref(),
2909 Some("advisory"),
2910 "the smaller status"
2911 );
2912 assert_eq!(
2913 once.routes.len(),
2914 1,
2915 "one route, however often it is declared"
2916 );
2917 let alias = entry(&forward, "mcp:majordomus_x");
2918 assert_eq!(alias.alias_of.as_deref(), Some("capability:x.one"));
2919 assert_eq!(
2920 alias.members,
2921 strings(&["capability:x.one", "capability:x.two"])
2922 );
2923 }
2924
2925 fn object(kind: &str, identity: &str, path: &str, metadata: Value) -> crate::Object {
2928 crate::Object {
2929 kind: kind.into(),
2930 identity: identity.into(),
2931 uri: format!("majordomus://{kind}/{identity}"),
2932 title: None,
2933 description: None,
2934 metadata,
2935 body: String::new(),
2936 content: String::new(),
2937 media_type: "text/markdown",
2938 provenance: crate::model::Provenance {
2939 path: path.into(),
2940 directory: path
2941 .rsplit_once('/')
2942 .map(|(dir, _)| dir.to_string())
2943 .unwrap_or_default(),
2944 source_class: kind.into(),
2945 section: None,
2946 bytes: 0,
2947 member: None,
2948 },
2949 }
2950 }
2951
2952 #[test]
2953 fn the_declarations_are_read_from_their_owners() {
2954 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2955 let root = repo.root().to_path_buf();
2956 std::fs::create_dir_all(root.join(CASES_DIR)).unwrap();
2957 std::fs::write(
2958 root.join(CASES_DIR).join("01_alpha.sh"),
2959 format!("{COVERS} version\n{NEGATIVE} none\n"),
2960 )
2961 .unwrap();
2962 std::fs::create_dir_all(root.join(CRATE_TESTS_DIR)).unwrap();
2963 std::fs::write(root.join(CLI_EXAMPLES), "").unwrap();
2964
2965 let mut index = repo.index().unwrap();
2966 index.objects.extend([
2967 object(
2968 "claim",
2969 "alpha",
2970 "docs/CLAIMS.yaml",
2971 json!({
2972 "claim": "Alpha holds",
2973 "status": "guaranteed",
2974 "source": "docs/A.md",
2975 "implementation": "lib/alpha.sh",
2976 "test": "test/cases/01_alpha.sh",
2977 }),
2978 ),
2979 object(
2980 "feature",
2981 "alpha",
2982 ".ai/repo/features/alpha.md",
2983 json!({
2984 "id": "alpha",
2985 "title": "Alpha",
2986 "headline": "Alpha holds.",
2987 "summary": "A feature of the test.",
2988 "status": "stable",
2989 "claims": ["alpha"],
2990 "rules": ["project.rule-0"],
2991 "commands": ["version"],
2992 "use_cases": ["see-alpha"],
2993 }),
2994 ),
2995 object(
2996 "use-case",
2997 "see-alpha",
2998 ".ai/repo/use-cases/see-alpha.md",
2999 json!({
3000 "status": "active",
3001 "commands": ["version"],
3002 "doctrines": ["project.rule-0"],
3003 "claims": ["alpha"],
3004 "mcp_tools": "majordomus_capabilities",
3005 }),
3006 ),
3007 object("use-case", "bare", ".ai/repo/use-cases/bare.md", json!({})),
3008 object(
3009 "command",
3010 "version",
3011 "share/commands.yaml",
3012 json!({ "visibility": "public" }),
3013 ),
3014 object(
3015 "command",
3016 "hook",
3017 "share/commands.yaml",
3018 json!({ "visibility": "internal" }),
3019 ),
3020 object("command", "unstated", "share/commands.yaml", json!({})),
3021 ]);
3022 let registry = CapabilityRegistry::builder()
3023 .with_modules(crate::capability::builtin::modules())
3024 .with_index(&index)
3025 .build()
3026 .unwrap();
3027 let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
3028 let d = declarations(&ctx.index, &ctx.product, &ctx.registry);
3029
3030 assert_eq!(d.commands, strings(&["version"]));
3032
3033 let alpha = d.claims.iter().find(|c| c.id == "alpha").unwrap();
3034 assert_eq!(alpha.status, "guaranteed");
3035 assert_eq!(alpha.source.as_deref(), Some("docs/A.md"));
3036 assert_eq!(alpha.implementation.as_deref(), Some("lib/alpha.sh"));
3037 assert_eq!(alpha.test_path.as_deref(), Some("test/cases/01_alpha.sh"));
3038 assert_eq!(alpha.test.as_deref(), Some("suite:01_alpha"));
3039
3040 let rule = d.rules.iter().find(|r| r.id == "project.rule-0").unwrap();
3041 assert_eq!(rule.status, "active");
3042
3043 let feature = d
3044 .features
3045 .iter()
3046 .find(|f| f.id == "alpha")
3047 .expect("the product model reads the feature");
3048 assert_eq!(feature.status, "stable");
3049 assert_eq!(feature.claims, strings(&["alpha"]));
3050 assert_eq!(feature.rules, strings(&["project.rule-0"]));
3051 assert_eq!(feature.commands, strings(&["version"]));
3052 assert_eq!(feature.use_cases, strings(&["see-alpha"]));
3053
3054 let see = d.use_cases.iter().find(|u| u.id == "see-alpha").unwrap();
3055 assert_eq!(see.status.as_deref(), Some("active"));
3056 assert_eq!(see.path, ".ai/repo/use-cases/see-alpha.md");
3057 assert_eq!(see.commands, strings(&["version"]));
3058 assert_eq!(see.doctrines, strings(&["project.rule-0"]));
3059 assert_eq!(see.claims, strings(&["alpha"]));
3060 assert_eq!(
3061 see.mcp_tools,
3062 strings(&["majordomus_capabilities"]),
3063 "a lone string is a list of one"
3064 );
3065 let bare = d.use_cases.iter().find(|u| u.id == "bare").unwrap();
3066 assert!(bare.status.is_none() && bare.commands.is_empty() && bare.mcp_tools.is_empty());
3067
3068 let documented = crate::cli::EXAMPLES
3071 .iter()
3072 .filter(|e| !e.command.is_empty())
3073 .count();
3074 assert_eq!(d.examples.len(), documented);
3075 assert!(d.examples.iter().all(|e| !e.is_empty()));
3076 let listing = d
3077 .capabilities
3078 .iter()
3079 .find(|c| c.mcp_tool.as_deref() == Some("majordomus_capabilities"))
3080 .unwrap();
3081 assert_eq!(listing.cli.as_deref(), Some("capabilities list"));
3082 assert!(
3083 listing
3084 .source
3085 .starts_with("apps/majordomus-cli/src/capability/builtin/")
3086 && listing.source.ends_with(".rs"),
3087 "{}",
3088 listing.source
3089 );
3090
3091 assert_eq!(d.cases.len(), 1);
3092 assert_eq!(d.cases[0].test, "suite:01_alpha");
3093 assert_eq!(d.cases[0].covers, strings(&["version"]));
3094 assert!(d.cases[0].negative.is_empty());
3095 assert_eq!(d.crate_tests, strings(&["crate:cli_examples"]));
3096
3097 let a = artifact(&ctx, "1").unwrap();
3099 assert!(a.content.contains("\"use_case:see-alpha\""));
3100 assert!(a.content.contains("\"command:version\""));
3101 assert!(!a.content.contains("\"command:hook\""));
3102 assert!(!a.content.contains("\"command:unstated\""));
3103 }
3104
3105 #[test]
3106 fn generate_site_writes_the_index_and_stops_at_one_it_would_not_publish() {
3107 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3108 let args = crate::cli::RepoArgs {
3109 repo: Some(repo.root().to_path_buf()),
3110 share: Some(crate::synthetic::crate_share()),
3111 discovery: crate::cli::DiscoveryMode::Filesystem,
3112 ..Default::default()
3113 };
3114 let site = [crate::generate::Target::Site];
3115 let app = crate::app::App::load(&args).unwrap();
3116 let plan = crate::generate::plan(&app, &site).unwrap();
3117 assert!(plan
3118 .iter()
3119 .any(|a| a.path == PATH && a.document == DOCUMENT));
3120
3121 let cases = repo.root().join(CASES_DIR);
3125 std::fs::create_dir_all(&cases).unwrap();
3126 std::fs::write(cases.join(format!("{}BEGIN KEY.sh", "-----")), "").unwrap();
3127 let app = crate::app::App::load(&args).unwrap();
3128 let refused = crate::generate::plan(&app, &site)
3129 .map(|_| ())
3130 .unwrap_err()
3131 .to_string();
3132 assert!(refused.contains(PATH), "{refused}");
3133 assert!(refused.contains("nothing was written"), "{refused}");
3134 }
3135
3136 #[test]
3139 fn a_verdict_is_the_one_aggregation() {
3140 use ProofState::{Failing, NoTest, NotRun, Proven};
3141 assert_eq!(verdict(&[(Failing, true), (NotRun, true)]), (Failing, true));
3142 assert_eq!(verdict(&[(Proven, true), (NotRun, false)]), (Proven, true));
3143 assert_eq!(verdict(&[(NotRun, false)]), (NotRun, false));
3144 assert_eq!(verdict(&[]), (NoTest, false));
3145 assert_eq!(verdict(&[(Proven, true), (NotRun, true)]), (NotRun, true));
3146 }
3147
3148 #[test]
3149 fn as_proof_is_the_one_declared_mapping() {
3150 use crate::rules::Class;
3151 use ProofState as P;
3152 use RuleState as R;
3153 for class in [Class::Blocking, Class::Advisory, Class::Unknown] {
3154 let same = [
3155 (R::Proven, P::Proven),
3156 (R::InputsUnchanged, P::InputsUnchanged),
3157 (R::Stale, P::Stale),
3158 (R::Failing, P::Failing),
3159 (R::NotRun, P::NotRun),
3160 (R::Unrunnable, P::Unrunnable),
3161 ];
3162 for (r, p) in same {
3163 assert_eq!(r.as_proof(class), (p, true), "{r:?}");
3164 }
3165 assert_eq!(R::Gated.as_proof(class), (P::NotRun, true));
3166 assert_eq!(R::Reviewed.as_proof(class), (P::NoTest, false));
3167 assert_eq!(R::Dangling.as_proof(class), (P::Unrunnable, true));
3168 let blocking = class == Class::Blocking;
3169 assert_eq!(
3170 R::Unproven.as_proof(class),
3171 (P::NoTest, blocking),
3172 "{class:?}"
3173 );
3174 }
3175 }
3176
3177 struct Repo {
3181 dir: tempfile::TempDir,
3182 }
3183
3184 const CASE: &str = "test/cases/01_alpha.sh";
3185
3186 impl Repo {
3187 fn new() -> Repo {
3188 let repo = Repo {
3189 dir: tempfile::tempdir().unwrap(),
3190 };
3191 repo.git(&["init", "-q"]);
3192 repo.git(&["config", "user.email", "t@example.com"]);
3193 repo.git(&["config", "user.name", "t"]);
3194 repo.git(&["config", "commit.gpgsign", "false"]);
3195 repo.write(CASE, "echo ok\n");
3196 repo.write("lib/alpha.sh", "alpha\n");
3197 repo.commit("one");
3198 repo
3199 }
3200 fn root(&self) -> &Path {
3201 self.dir.path()
3202 }
3203 fn git(&self, args: &[&str]) -> String {
3204 let out = std::process::Command::new("git")
3205 .arg("-C")
3206 .arg(self.root())
3207 .args(args)
3208 .output()
3209 .unwrap();
3210 assert!(out.status.success(), "git {args:?}: {out:?}");
3211 String::from_utf8_lossy(&out.stdout).trim().to_string()
3212 }
3213 fn write(&self, path: &str, text: &str) {
3214 let p = self.root().join(path);
3215 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3216 std::fs::write(p, text).unwrap();
3217 }
3218 fn commit(&self, message: &str) -> String {
3219 self.git(&["add", "-A"]);
3220 self.git(&["commit", "-q", "-m", message]);
3221 self.git(&["rev-parse", "HEAD"])
3222 }
3223 fn presented(&self, tree: TreeState) -> Presented {
3224 crate::evidence::freshness::presented_commit(self.root(), "HEAD", Some(tree)).unwrap()
3225 }
3226 fn digest(&self) -> String {
3228 crate::evidence::digest_of(&std::fs::read(self.root().join(CASE)).unwrap())
3229 }
3230 }
3231
3232 fn ran(test: &str, outcome: &str, commit: &str, tree: &str, digest: &str) -> Execution {
3234 let source = TestId::of(CASE).unwrap().source();
3235 serde_json::from_value(json!({
3236 "test": test, "runner": "suite", "source": source, "outcome": outcome,
3237 "seconds": 1, "commit": commit, "working_tree": tree, "digest": digest,
3238 "at": "2026-09-26T00:00:00Z", "origin": "local", "command": "bash test/run.sh x"
3239 }))
3240 .unwrap()
3241 }
3242
3243 fn ledger(executions: &[&Execution]) -> Ledger {
3244 let mut l = Ledger::empty();
3245 l.merge(executions.iter().map(|e| (*e).clone()));
3246 l
3247 }
3248
3249 fn claim_proof(id: &str, status: &str, state: ProofState, e: Option<&Execution>) -> Value {
3251 json!({
3252 "id": id, "claim": id, "status": status, "source": "docs/A.md",
3253 "test_path": CASE, "test": "suite:01_alpha", "state": state, "meaning": "",
3254 "execution": e, "changed": [], "reproduce": "bash test/run.sh 01_alpha"
3255 })
3256 }
3257
3258 fn claims_report(claims: Vec<Value>, presented: &Presented) -> EvidenceReport {
3260 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3261 let index = repo.index().unwrap();
3262 let mut v =
3263 serde_json::to_value(crate::evidence::report(&index, &Ledger::empty())).unwrap();
3264 v["claims"] = Value::Array(claims);
3265 v["presented"] = json!({
3266 "revision": presented.commit().unwrap_or("working_tree"),
3267 "tree": presented.tree(),
3268 });
3269 serde_json::from_value(v).unwrap()
3270 }
3271
3272 fn test_proof(state: ProofState, e: Option<&Execution>) -> Value {
3274 json!({
3275 "path": CASE, "kind": crate::rules::ArtifactKind::Case, "present": true,
3276 "test": "suite:01_alpha", "state": state, "meaning": "", "execution": e,
3277 "reproduce": "bash test/run.sh 01_alpha"
3278 })
3279 }
3280
3281 fn rules_report(rules: &[(&str, RuleState, Vec<Value>)]) -> RulesReport {
3284 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3285 let index = repo.index().unwrap();
3286 let mut v = serde_json::to_value(crate::rules::report(&index, &Ledger::empty())).unwrap();
3287 let template = v["rules"][0].clone();
3288 let rules: Vec<Value> = rules
3289 .iter()
3290 .map(|(id, state, tests)| {
3291 let mut r = template.clone();
3292 r["rule"]["id"] = json!(id);
3293 r["rule"]["class"] = json!(crate::rules::Class::Blocking);
3294 r["rule"]["path"] = json!(".ai/repo/rules/project/alpha.v1.md");
3295 r["tests"] = json!(tests);
3296 r["state"] = json!(state);
3297 r
3298 })
3299 .collect();
3300 v["rules"] = Value::Array(rules);
3301 serde_json::from_value(v).unwrap()
3302 }
3303
3304 fn judged(
3307 d: &Declarations,
3308 g: &Given<'_>,
3309 root: &Path,
3310 presented: &Presented,
3311 keys: &[&str],
3312 ) -> Vec<SubjectEvidence> {
3313 let s = derive(d);
3314 let mut judge = Judge::new(
3315 &s,
3316 g.claims,
3317 g.rules,
3318 g.ledger,
3319 g.uncommitted,
3320 root,
3321 presented,
3322 );
3323 keys.iter().map(|k| judge.subject(k).unwrap()).collect()
3324 }
3325
3326 struct Given<'a> {
3328 claims: &'a EvidenceReport,
3329 rules: &'a RulesReport,
3330 ledger: &'a Ledger,
3331 uncommitted: &'a [Execution],
3332 }
3333
3334 fn route(answer: &SubjectEvidence, via: Via) -> &RouteProof {
3335 answer
3336 .routes
3337 .iter()
3338 .find(|r| r.route.via == via)
3339 .unwrap_or_else(|| panic!("{} has no {via:?} route", answer.subject))
3340 }
3341
3342 fn small() -> Declarations {
3345 Declarations {
3346 claims: vec![claim("alpha", "guaranteed", "lib/alpha.sh", CASE)],
3347 rules: vec![RuleDecl {
3348 id: "project.alpha".into(),
3349 status: "active".into(),
3350 path: ".ai/repo/rules/project/alpha.v1.md".into(),
3351 tests: strings(&[CASE]),
3352 }],
3353 features: vec![FeatureDecl {
3354 id: "alpha".into(),
3355 status: "stable".into(),
3356 claims: strings(&["alpha"]),
3357 rules: strings(&["project.alpha"]),
3358 commands: strings(&["version"]),
3359 ..FeatureDecl::default()
3360 }],
3361 commands: strings(&["version"]),
3362 cases: vec![case("01_alpha", &["version"], &[])],
3363 ..Declarations::default()
3364 }
3365 }
3366
3367 #[test]
3368 fn a_claim_route_is_the_claim_join() {
3369 let repo = Repo::new();
3370 let mut proof = claim_proof("alpha", "guaranteed", ProofState::Stale, None);
3371 proof["changed"] = json!(["lib/alpha.sh"]);
3372 proof["detail"] = json!("the join's own reason");
3373 let rules = rules_report(&[]);
3374 let empty = Ledger::empty();
3375 for presented in [Presented::WorkingTree, repo.presented(TreeState::Clean)] {
3376 let claims = claims_report(vec![proof.clone()], &presented);
3377 let g = Given {
3378 claims: &claims,
3379 rules: &rules,
3380 ledger: &empty,
3381 uncommitted: &[],
3382 };
3383 let a = &judged(&small(), &g, repo.root(), &presented, &["claim:alpha"])[0];
3384 let r = route(a, Via::Claim);
3385 assert_eq!(r.state, ProofState::Stale, "{presented:?}");
3386 assert_eq!(r.changed, vec!["lib/alpha.sh".to_string()]);
3387 assert_eq!(r.detail.as_deref(), Some("the join's own reason"));
3388 assert_eq!((a.verdict, a.carries_proof), (ProofState::Stale, true));
3389 }
3390 }
3391
3392 #[test]
3393 fn a_rule_subject_is_its_rule_proof_through_as_proof() {
3394 let repo = Repo::new();
3395 let c1 = repo.git(&["rev-parse", "HEAD"]);
3396 let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3397 let rules = rules_report(&[(
3398 "project.alpha",
3399 RuleState::Gated,
3400 vec![test_proof(ProofState::Proven, Some(&e))],
3401 )]);
3402 let p = Presented::WorkingTree;
3403 let claims = claims_report(vec![], &p);
3404 let l = ledger(&[&e]);
3405 let g = Given {
3406 claims: &claims,
3407 rules: &rules,
3408 ledger: &l,
3409 uncommitted: &[],
3410 };
3411 let a = &judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"])[0];
3412 assert_eq!(route(a, Via::Rule).state, ProofState::Proven);
3413 assert_eq!(
3414 a.verdict,
3415 ProofState::NotRun,
3416 "a gated rule is not a recorded run"
3417 );
3418 assert_eq!(a.rule_state, Some(RuleState::Gated));
3419 assert!(a.carries_proof);
3420 }
3421
3422 #[test]
3423 fn a_command_route_is_judged_at_the_presented_revision() {
3424 let repo = Repo::new();
3425 let c1 = repo.git(&["rev-parse", "HEAD"]);
3426 let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3427 let l = ledger(&[&e]);
3428 let rules = rules_report(&[]);
3429 let at = |presented: &Presented| {
3430 let claims = claims_report(vec![], presented);
3431 let g = Given {
3432 claims: &claims,
3433 rules: &rules,
3434 ledger: &l,
3435 uncommitted: &[],
3436 };
3437 let a = judged(&small(), &g, repo.root(), presented, &["command:version"]);
3438 let r = route(&a[0], Via::Behaviour);
3439 (r.state, r.detail.clone())
3440 };
3441 repo.write(crate::evidence::LEDGER_PATH, "{}\n");
3443 repo.commit("the ledger");
3444 assert_eq!(at(&repo.presented(TreeState::Clean)).0, ProofState::Proven);
3445 assert_eq!(
3446 at(&repo.presented(TreeState::Dirty)).0,
3447 ProofState::InputsUnchanged,
3448 "a dirty presented tree withholds proven"
3449 );
3450 repo.write("docs/other.md", "other\n");
3452 repo.commit("other");
3453 let (state, detail) = at(&repo.presented(TreeState::Clean));
3454 assert_eq!(state, ProofState::Stale);
3455 assert!(detail.is_some(), "a stale route says why");
3456 }
3457
3458 #[test]
3459 fn an_uncommitted_clean_failure_caps_a_behaviour_route_at_a_commit() {
3460 let repo = Repo::new();
3461 let c1 = repo.git(&["rev-parse", "HEAD"]);
3462 let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3463 repo.write(crate::evidence::LEDGER_PATH, "{}\n");
3464 let c2 = repo.commit("the ledger");
3465 let failed = ran("suite:01_alpha", "fail", &c2, "clean", &repo.digest());
3466 let l = ledger(&[&e]);
3467 let rules = rules_report(&[]);
3468 let uncommitted = vec![failed];
3469 let at = |presented: &Presented| {
3470 let claims = claims_report(vec![], presented);
3471 let g = Given {
3472 claims: &claims,
3473 rules: &rules,
3474 ledger: &l,
3475 uncommitted: &uncommitted,
3476 };
3477 let a = judged(&small(), &g, repo.root(), presented, &["command:version"]);
3478 route(&a[0], Via::Behaviour).clone()
3479 };
3480 let r = at(&repo.presented(TreeState::Clean));
3481 assert_eq!(r.state, ProofState::Stale);
3482 assert!(r.detail.unwrap().contains(UNCOMMITTED_RUN));
3483 assert_eq!(at(&Presented::WorkingTree).state, ProofState::Proven);
3484 }
3485
3486 #[test]
3487 fn a_failure_outranks_an_absence_in_a_subject() {
3488 let repo = Repo::new();
3489 let c1 = repo.git(&["rev-parse", "HEAD"]);
3490 let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3491 let l = ledger(&[&failed]);
3492 let p = Presented::WorkingTree;
3493 let claims = claims_report(
3494 vec![claim_proof("alpha", "guaranteed", ProofState::NotRun, None)],
3495 &p,
3496 );
3497 let rules = rules_report(&[(
3498 "project.alpha",
3499 RuleState::NotRun,
3500 vec![test_proof(ProofState::NotRun, None)],
3501 )]);
3502 let g = Given {
3503 claims: &claims,
3504 rules: &rules,
3505 ledger: &l,
3506 uncommitted: &[],
3507 };
3508 let a = judged(
3509 &small(),
3510 &g,
3511 repo.root(),
3512 &p,
3513 &["command:version", "feature:alpha"],
3514 );
3515 assert_eq!(a[0].verdict, ProofState::Failing);
3516 assert_eq!(a[1].verdict, ProofState::Failing, "{:?}", a[1].members);
3517 }
3518
3519 #[test]
3520 fn a_scenario_never_decides_unless_it_is_all_there_is() {
3521 let repo = Repo::new();
3522 let mut d = small();
3523 d.use_cases = vec![UseCaseDecl {
3524 id: "lone".into(),
3525 status: Some("active".into()),
3526 path: ".ai/repo/use-cases/lone.md".into(),
3527 ..UseCaseDecl::default()
3528 }];
3529 d.features = vec![FeatureDecl {
3530 id: "f".into(),
3531 status: "stable".into(),
3532 claims: strings(&["alpha"]),
3533 use_cases: strings(&["lone"]),
3534 ..FeatureDecl::default()
3535 }];
3536 let p = Presented::WorkingTree;
3537 let claims = claims_report(
3538 vec![claim_proof("alpha", "guaranteed", ProofState::Proven, None)],
3539 &p,
3540 );
3541 let rules = rules_report(&[]);
3542 let empty = Ledger::empty();
3543 let g = Given {
3544 claims: &claims,
3545 rules: &rules,
3546 ledger: &empty,
3547 uncommitted: &[],
3548 };
3549 let a = judged(&d, &g, repo.root(), &p, &["use_case:lone", "feature:f"]);
3550 assert_eq!(
3551 (a[0].verdict, a[0].carries_proof),
3552 (ProofState::NotRun, false)
3553 );
3554 assert!(route(&a[0], Via::Scenario).detail.is_some());
3555 assert_eq!(a[1].verdict, ProofState::Proven);
3556 }
3557
3558 #[test]
3559 fn git_that_cannot_compare_is_a_finding() {
3560 let repo = Repo::new();
3561 let mut d = small();
3562 d.cases = vec![case("01_alpha", &["version"], &["version"])];
3563 let e = ran(
3564 "suite:01_alpha",
3565 "pass",
3566 &"f".repeat(40),
3567 "clean",
3568 &repo.digest(),
3569 );
3570 let l = ledger(&[&e]);
3571 let p = Presented::WorkingTree;
3572 let claims = claims_report(vec![], &p);
3573 let rules = rules_report(&[]);
3574 let g = Given {
3575 claims: &claims,
3576 rules: &rules,
3577 ledger: &l,
3578 uncommitted: &[],
3579 };
3580 let a = &judged(&d, &g, repo.root(), &p, &["command:version"])[0];
3581 assert_eq!(a.routes.len(), 2, "a behaviour and a negative route");
3582 assert!(a.routes.iter().all(|r| r.state == ProofState::Stale));
3583 let unknown: Vec<_> = a
3584 .subject_findings
3585 .iter()
3586 .filter(|f| f.code == FRESHNESS_UNKNOWN)
3587 .collect();
3588 assert_eq!(unknown.len(), 1, "one per commit, not one per route");
3589 assert!(unknown[0].message.contains(&"f".repeat(12)));
3590 }
3591
3592 #[test]
3593 fn a_dangling_rule_member_is_named() {
3594 let repo = Repo::new();
3595 let p = Presented::WorkingTree;
3596 let claims = claims_report(vec![], &p);
3597 let rules = rules_report(&[("project.alpha", RuleState::Dangling, vec![])]);
3598 let empty = Ledger::empty();
3599 let g = Given {
3600 claims: &claims,
3601 rules: &rules,
3602 ledger: &empty,
3603 uncommitted: &[],
3604 };
3605 let a = &judged(&small(), &g, repo.root(), &p, &["feature:alpha"])[0];
3606 let dangling: Vec<_> = a
3607 .subject_findings
3608 .iter()
3609 .filter(|f| f.code == DANGLING_MEMBER)
3610 .collect();
3611 assert_eq!(dangling.len(), 1, "{:?}", a.subject_findings);
3612 assert!(dangling[0].message.contains("project.alpha"));
3613 }
3614
3615 #[test]
3616 fn an_unknown_subject_is_refused_by_name() {
3617 let repo = Repo::new();
3618 let p = Presented::WorkingTree;
3619 let claims = claims_report(vec![], &p);
3620 let rules = rules_report(&[]);
3621 let empty = Ledger::empty();
3622 let s = derive(&small());
3623 let mut judge = Judge::new(&s, &claims, &rules, &empty, &[], repo.root(), &p);
3624 for key in ["feature:no-such", "nonsense", "gate:x"] {
3625 let err = judge.subject(key).unwrap_err();
3626 assert!(err.to_string().contains(key), "{err}");
3627 }
3628 }
3629
3630 #[test]
3631 fn the_answer_names_the_revision_it_judged() {
3632 let repo = Repo::new();
3633 let head = repo.git(&["rev-parse", "HEAD"]);
3634 let rules = rules_report(&[]);
3635 let empty = Ledger::empty();
3636 for (p, revision) in [
3637 (Presented::WorkingTree, "working_tree".to_string()),
3638 (repo.presented(TreeState::Clean), head.clone()),
3639 ] {
3640 let claims = claims_report(vec![], &p);
3641 let g = Given {
3642 claims: &claims,
3643 rules: &rules,
3644 ledger: &empty,
3645 uncommitted: &[],
3646 };
3647 let a = &judged(&small(), &g, repo.root(), &p, &["command:version"])[0];
3648 assert_eq!(a.presented, claims.presented);
3649 assert_eq!(a.presented.revision, revision);
3650 }
3651 }
3652
3653 #[test]
3654 fn totals_count_every_part_by_label() {
3655 let repo = Repo::new();
3656 let p = Presented::WorkingTree;
3657 let claims = claims_report(
3658 vec![claim_proof("alpha", "guaranteed", ProofState::NotRun, None)],
3659 &p,
3660 );
3661 let rules = rules_report(&[(
3662 "project.alpha",
3663 RuleState::NotRun,
3664 vec![test_proof(ProofState::NotRun, None)],
3665 )]);
3666 let empty = Ledger::empty();
3667 let g = Given {
3668 claims: &claims,
3669 rules: &rules,
3670 ledger: &empty,
3671 uncommitted: &[],
3672 };
3673 let a = judged(
3674 &small(),
3675 &g,
3676 repo.root(),
3677 &p,
3678 &["feature:alpha", "rule:project.alpha"],
3679 );
3680 assert_eq!(a[0].totals, BTreeMap::from([("not run".to_string(), 3)]));
3682 assert_eq!(a[1].totals, BTreeMap::from([("not run".to_string(), 1)]));
3684 }
3685
3686 fn rule_only() -> Declarations {
3688 let mut d = small();
3689 d.features = vec![FeatureDecl {
3690 id: "f".into(),
3691 status: "stable".into(),
3692 rules: strings(&["project.alpha"]),
3693 ..FeatureDecl::default()
3694 }];
3695 d
3696 }
3697
3698 #[test]
3699 fn a_dirty_presented_tree_caps_every_rule_derived_state() {
3700 let repo = Repo::new();
3703 let c1 = repo.git(&["rev-parse", "HEAD"]);
3704 let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3705 let rules = rules_report(&[(
3706 "project.alpha",
3707 RuleState::Proven,
3708 vec![test_proof(ProofState::Proven, Some(&e))],
3709 )]);
3710 let l = ledger(&[&e]);
3711 let at = |tree: TreeState| {
3712 let p = repo.presented(tree);
3713 let claims = claims_report(vec![], &p);
3714 let g = Given {
3715 claims: &claims,
3716 rules: &rules,
3717 ledger: &l,
3718 uncommitted: &[],
3719 };
3720 judged(
3721 &rule_only(),
3722 &g,
3723 repo.root(),
3724 &p,
3725 &["rule:project.alpha", "feature:f"],
3726 )
3727 };
3728 let dirty = at(TreeState::Dirty);
3729 let r = route(&dirty[0], Via::Rule);
3730 assert_eq!(r.state, ProofState::InputsUnchanged);
3731 assert!(r.detail.is_some(), "the cap states A1's reason");
3732 assert_eq!(dirty[0].verdict, ProofState::InputsUnchanged);
3733 assert_eq!(dirty[0].rule_state, Some(RuleState::InputsUnchanged));
3734 assert_eq!(dirty[0].totals.get("proven"), None);
3735 assert_eq!(dirty[1].verdict, ProofState::InputsUnchanged);
3736 assert_eq!(
3737 dirty[1].members[0].rule_state,
3738 Some(RuleState::InputsUnchanged)
3739 );
3740 let clean = at(TreeState::Clean);
3742 assert_eq!(route(&clean[0], Via::Rule).state, ProofState::Proven);
3743 assert_eq!(clean[0].verdict, ProofState::Proven);
3744 assert_eq!(clean[0].rule_state, Some(RuleState::Proven));
3745 assert_eq!(clean[1].verdict, ProofState::Proven);
3746 }
3747
3748 #[test]
3749 fn an_adjuster_weakens_before_aggregation_and_never_strengthens() {
3750 let repo = Repo::new();
3751 let c1 = repo.git(&["rev-parse", "HEAD"]);
3752 let e = ran("suite:01_alpha", "pass", &c1, "clean", &repo.digest());
3753 let l = ledger(&[&e]);
3754 let p = Presented::WorkingTree;
3755 let claims = claims_report(
3756 vec![claim_proof(
3757 "alpha",
3758 "guaranteed",
3759 ProofState::Proven,
3760 Some(&e),
3761 )],
3762 &p,
3763 );
3764 let rules = rules_report(&[(
3765 "project.alpha",
3766 RuleState::Proven,
3767 vec![test_proof(ProofState::Proven, Some(&e))],
3768 )]);
3769 let mut d = rule_only();
3770 d.features.extend(small().features);
3771 let keys = [
3772 "command:version",
3773 "claim:alpha",
3774 "rule:project.alpha",
3775 "feature:f",
3776 ];
3777 let s = derive(&d);
3778 let run = |adjust: Option<&Adjust<'_>>, ledger: &Ledger| {
3779 let mut judge = Judge::new(&s, &claims, &rules, ledger, &[], repo.root(), &p);
3780 if let Some(a) = adjust {
3781 judge = judge.with_adjust(a);
3782 }
3783 keys.iter()
3784 .map(|k| judge.subject(k).unwrap())
3785 .collect::<Vec<_>>()
3786 };
3787 let plain = run(None, &l);
3788 assert!(
3789 plain.iter().all(|a| a.verdict == ProofState::Proven),
3790 "{plain:?}"
3791 );
3792 let identity: &Adjust<'_> = &|_, _, j| j;
3793 assert_eq!(run(Some(identity), &l), plain);
3794
3795 let cap: &Adjust<'_> = &|t, _, j| match t.as_string().as_str() {
3796 "suite:01_alpha" => Judgement {
3797 state: ProofState::Stale,
3798 changed: Vec::new(),
3799 detail: Some("capped by a record".into()),
3800 },
3801 _ => j,
3802 };
3803 let capped = run(Some(cap), &l);
3804 assert_eq!(route(&capped[0], Via::Behaviour).state, ProofState::Stale);
3805 for a in &capped {
3806 assert_eq!(a.verdict, ProofState::Stale, "{}", a.subject);
3807 }
3808 assert_eq!(
3809 capped[2].rule_state,
3810 Some(RuleState::Proven),
3811 "the rule state stays"
3812 );
3813
3814 let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3815 let proven: &Adjust<'_> = &|_, _, _| Judgement {
3816 state: ProofState::Proven,
3817 changed: Vec::new(),
3818 detail: None,
3819 };
3820 let never = run(Some(proven), &ledger(&[&failed]));
3821 assert_eq!(route(&never[0], Via::Behaviour).state, ProofState::Failing);
3822 }
3823
3824 #[test]
3825 fn a_planned_or_rejected_claim_carries_no_proof_joined_or_not() {
3826 let repo = Repo::new();
3827 let mut d = small();
3828 d.claims.extend([
3829 claim("beta", "planned", "lib/alpha.sh", CASE),
3830 claim("gamma", "rejected", "lib/alpha.sh", CASE),
3831 claim("delta", "guaranteed", "lib/alpha.sh", CASE),
3832 ]);
3833 let p = Presented::WorkingTree;
3834 let claims = claims_report(
3836 vec![
3837 claim_proof("alpha", "guaranteed", ProofState::NotRun, None),
3838 claim_proof("beta", "planned", ProofState::NotRun, None),
3839 ],
3840 &p,
3841 );
3842 let rules = rules_report(&[]);
3843 let empty = Ledger::empty();
3844 let g = Given {
3845 claims: &claims,
3846 rules: &rules,
3847 ledger: &empty,
3848 uncommitted: &[],
3849 };
3850 let keys = ["claim:alpha", "claim:beta", "claim:gamma", "claim:delta"];
3851 let a = judged(&d, &g, repo.root(), &p, &keys);
3852 let carries: Vec<bool> = a
3853 .iter()
3854 .map(|s| route(s, Via::Claim).carries_proof)
3855 .collect();
3856 assert_eq!(carries, [true, false, false, true], "{keys:?}");
3857 for unjoined in &a[2..] {
3858 let r = route(unjoined, Via::Claim);
3859 assert_eq!(r.state, ProofState::NoTest, "{}", unjoined.subject);
3860 assert!(r.detail.as_deref().unwrap().contains("this claim"));
3861 }
3862 assert!(!a[1].carries_proof && !a[2].carries_proof);
3863 }
3864
3865 #[test]
3866 fn a_proven_rule_test_with_no_run_reads_stale_on_a_dirty_presented_tree() {
3867 let repo = Repo::new();
3868 let rules = rules_report(&[(
3869 "project.alpha",
3870 RuleState::Proven,
3871 vec![test_proof(ProofState::Proven, None)],
3872 )]);
3873 let empty = Ledger::empty();
3874 let at = |tree: TreeState| {
3875 let p = repo.presented(tree);
3876 let claims = claims_report(vec![], &p);
3877 let g = Given {
3878 claims: &claims,
3879 rules: &rules,
3880 ledger: &empty,
3881 uncommitted: &[],
3882 };
3883 let a = judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"]);
3884 route(&a[0], Via::Rule).clone()
3885 };
3886 let dirty = at(TreeState::Dirty);
3887 assert_eq!(dirty.state, ProofState::Stale);
3888 assert_eq!(dirty.detail.as_deref(), Some(NO_EXECUTION_DETAIL));
3889 assert!(dirty.changed.is_empty());
3890 assert!(dirty.execution.is_none());
3891 let clean = at(TreeState::Clean);
3893 assert_eq!((clean.state, clean.detail), (ProofState::Proven, None));
3894 }
3895
3896 #[test]
3897 fn a_rule_the_report_does_not_carry_is_untested_and_carries_nothing() {
3898 let repo = Repo::new();
3899 let p = Presented::WorkingTree;
3900 let claims = claims_report(vec![], &p);
3901 let rules = rules_report(&[]);
3902 let empty = Ledger::empty();
3903 let g = Given {
3904 claims: &claims,
3905 rules: &rules,
3906 ledger: &empty,
3907 uncommitted: &[],
3908 };
3909 let a = &judged(&small(), &g, repo.root(), &p, &["rule:project.alpha"])[0];
3910 assert_eq!(
3911 (a.verdict, a.carries_proof, a.rule_state),
3912 (ProofState::NoTest, false, None)
3913 );
3914 let r = route(a, Via::Rule);
3915 assert_eq!(r.state, ProofState::NoTest);
3916 assert!(r.detail.as_deref().unwrap().contains("this rule"));
3917 }
3918
3919 #[test]
3920 fn an_adjuster_never_sees_a_route_that_names_no_test() {
3921 let repo = Repo::new();
3922 let mut d = small();
3923 d.claims = vec![claim("alpha", "guaranteed", "lib/alpha.sh", "-")];
3924 let p = Presented::WorkingTree;
3925 let mut proof = claim_proof("alpha", "guaranteed", ProofState::NoTest, None);
3926 proof["test_path"] = Value::Null;
3927 proof["test"] = Value::Null;
3928 let claims = claims_report(vec![proof], &p);
3929 let rules = rules_report(&[]);
3930 let empty = Ledger::empty();
3931 let s = derive(&d);
3932 let asked = std::cell::Cell::new(0);
3933 let failing: &Adjust<'_> = &|_, _, _| {
3934 asked.set(asked.get() + 1);
3935 Judgement {
3936 state: ProofState::Failing,
3937 changed: Vec::new(),
3938 detail: Some("the adjuster".into()),
3939 }
3940 };
3941 let mut judge =
3942 Judge::new(&s, &claims, &rules, &empty, &[], repo.root(), &p).with_adjust(failing);
3943 let a = judge.subject("claim:alpha").unwrap();
3944 let r = route(&a, Via::Claim);
3945 assert!(r.route.test.is_none(), "{:?}", r.route);
3946 assert_eq!(
3947 r.state,
3948 ProofState::NoTest,
3949 "a route without a test keeps its state"
3950 );
3951 assert_eq!(
3952 asked.get(),
3953 0,
3954 "the adjuster is asked about a test, and there is none"
3955 );
3956 let c = judge.subject("command:version").unwrap();
3958 assert_eq!(route(&c, Via::Behaviour).state, ProofState::Failing);
3959 assert_eq!(asked.get(), 1);
3960 }
3961
3962 #[test]
3963 fn a_member_the_index_does_not_hold_is_no_part_of_the_verdict() {
3964 let repo = Repo::new();
3967 let c1 = repo.git(&["rev-parse", "HEAD"]);
3968 let failed = ran("suite:01_alpha", "fail", &c1, "clean", &repo.digest());
3969 let l = ledger(&[&failed]);
3970 let p = Presented::WorkingTree;
3971 let claims = claims_report(
3972 vec![claim_proof("alpha", "guaranteed", ProofState::Proven, None)],
3973 &p,
3974 );
3975 let rules = rules_report(&[]);
3976 let mut s = derive(&small());
3977 let feature = s.subjects.get_mut("feature:alpha").unwrap();
3978 let declared = feature.members.clone();
3979 feature
3980 .members
3981 .extend(["claim:no-such".to_string(), "nonsense".to_string()]);
3982 let mut judge = Judge::new(&s, &claims, &rules, &l, &[], repo.root(), &p);
3983 let a = judge.subject("feature:alpha").unwrap();
3984 let named: Vec<&str> = a.members.iter().map(|m| m.subject.as_str()).collect();
3985 let expected: Vec<&str> = declared.iter().map(String::as_str).collect();
3986 assert!(expected.contains(&"command:version"), "{expected:?}");
3987 assert_eq!(named, expected, "only members the index holds are judged");
3988 assert_eq!(
3989 a.verdict,
3990 ProofState::Failing,
3991 "the held members still decide"
3992 );
3993 let counted: usize = a.totals.values().sum();
3994 assert_eq!(counted, a.members.len(), "{:?}", a.totals);
3995 }
3996
3997 #[test]
3998 fn answer_is_the_judge_over_the_reports_of_the_given_ledger() {
3999 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
4000 let ctx = repo.context().unwrap();
4001 let (ledger, p) = (Ledger::empty(), Presented::WorkingTree);
4002 let ask = |key: &str| {
4003 answer(
4004 &ctx.index,
4005 &ctx.product,
4006 &ctx.registry,
4007 &ledger,
4008 &[],
4009 &p,
4010 key,
4011 )
4012 };
4013 let err = ask("feature:no-such").unwrap_err();
4014 assert_eq!(err.key, "feature:no-such");
4015 let key = "capability:capabilities.list";
4016 let got = ask(key).unwrap();
4017 assert_eq!(
4018 (got.subject.as_str(), got.kind),
4019 (key, SubjectKind::Capability)
4020 );
4021 assert_eq!(got.presented.revision, "working_tree");
4022 let s = index(&ctx.index, &ctx.product, &ctx.registry);
4024 let claims = crate::evidence::report_at(&ctx.index, &ledger, &p, &[]);
4025 let rules = crate::rules::report(&ctx.index, &ledger);
4026 let root = PathBuf::from(&ctx.index.repository.root);
4027 let mut judge = Judge::new(&s, &claims, &rules, &ledger, &[], &root, &p);
4028 assert_eq!(got, judge.subject(key).unwrap());
4029 let tool = ask("mcp:majordomus_capabilities").unwrap();
4030 assert_eq!(tool.members.len(), 1, "an alias is made of its capability");
4031 }
4032}