majordomus_cli/reasoning/check.rs
1//! `reasoning.check`: the executable half of the rules `project.advisors-are-optional`
2//! and `project.review-is-recorded-not-claimed`. Each check reads files and says what it
3//! found; none of them asks an advisor anything.
4//!
5//! * `catalogue` — the advisor catalogue resolves against the provider table and the
6//! model catalogue (one inventory, referenced, never restated);
7//! * `adapters` — every adapter the catalogue names has its transport module, so the Node
8//! layer is derived from the catalogue rather than keeping a list of its own;
9//! * `coupling` — the provider-independent code (this module, its capability, its CLI
10//! rendering, its Cockpit page, the transport driver) names no advisor, adapter,
11//! executable, provider, vendor or model of the catalogue as a string literal;
12//! * `ci` — no CI workflow or gate names a model vendor's credential variable, so no
13//! required gate can depend on a live model;
14//! * `claims` — no document asserts a claim the catalogue forbids ("consensus
15//! guarantees ...") or that an advisor is required;
16//! * `records` — every stored record is readable, every consultation names an advisor
17//! its plan selected and one this repository knows, every conclusion's computed review
18//! matches the consultations it cites.
19//!
20//! The lifecycle: load the catalogue, its references and the store, then [`run`] every
21//! check over a repository root; an empty list is a clean repository.
22//!
23//! ```
24//! use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
25//! use majordomus_cli::reasoning::check::run;
26//! use majordomus_cli::reasoning::store::Store;
27//!
28//! let root = tempfile::tempdir().unwrap();
29//! let catalogue = AdvisorCatalogue {
30//! forbidden_claims: vec!["consensus guarantees correctness".into()],
31//! ..Default::default()
32//! };
33//! let loaded = Store::at(root.path()).load();
34//! assert!(run(root.path(), &catalogue, &References::default(), &loaded).is_empty());
35//!
36//! std::fs::write(root.path().join("README.md"), "Consensus guarantees correctness.\n").unwrap();
37//! let findings = run(root.path(), &catalogue, &References::default(), &loaded);
38//! assert_eq!(findings.len(), 1);
39//! assert_eq!((findings[0].check.as_str(), findings[0].subject.as_str()), ("claims", "README.md"));
40//! ```
41
42use std::path::Path;
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46
47use super::catalogue::{AdvisorCatalogue, References};
48use super::record::{ConsultationStatus, ReasoningBody};
49use super::store::Loaded;
50
51/// One finding of a reasoning check: which check, where, and what is wrong.
52///
53/// `check` is one of [`CHECKS`]; `subject` is a repository-relative path, a record id or
54/// an advisor; `message` is one sentence saying what the rule refuses. Findings are
55/// reported, never repaired.
56///
57/// ```
58/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
59/// use majordomus_cli::reasoning::check::{run, ReasoningFinding, CHECKS};
60/// use majordomus_cli::reasoning::store::Loaded;
61///
62/// let root = tempfile::tempdir().unwrap();
63/// let loaded = Loaded { records: vec![], unreadable: vec!["t/broken.json".into()] };
64/// let found = run(root.path(), &AdvisorCatalogue::default(), &References::default(), &loaded);
65/// let expected = ReasoningFinding {
66/// check: "records".into(),
67/// subject: "t/broken.json".into(),
68/// message: "a reasoning record that does not parse".into(),
69/// };
70/// assert_eq!(found, [expected]);
71/// assert!(CHECKS.contains(&found[0].check.as_str()));
72/// ```
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74pub struct ReasoningFinding {
75 /// The check that found it.
76 pub check: String,
77 /// Where: a file, a record, an advisor.
78 pub subject: String,
79 /// What is wrong.
80 pub message: String,
81}
82
83/// The source files that must stay provider-independent, relative to the root.
84pub const INDEPENDENT_SOURCES: &[&str] = &[
85 "apps/majordomus-cli/src/reasoning",
86 "apps/majordomus-cli/src/capability/builtin/reasoning.rs",
87 "apps/majordomus-cli/src/commands/reasoning.rs",
88 "apps/majordomus-cli/src/cockpit/reasoning.rs",
89 "scripts/lib/advisors/driver.mjs",
90 "scripts/lib/advisors/contract.mjs",
91];
92
93/// Where the transport adapters live, relative to the root.
94pub const ADAPTER_DIR: &str = "scripts/lib/advisors";
95
96fn files_under(root: &Path, rel: &str, ext: &[&str]) -> Vec<std::path::PathBuf> {
97 let path = root.join(rel);
98 // ordered by construction: a directory listing has no order of its own
99 let mut out = std::collections::BTreeSet::new();
100 if path.is_file() {
101 out.insert(path);
102 } else if let Ok(entries) = std::fs::read_dir(&path) {
103 for e in entries.flatten() {
104 let p = e.path();
105 if p.is_dir() {
106 if let Ok(r) = p.strip_prefix(root) {
107 out.extend(files_under(root, &r.display().to_string(), ext));
108 }
109 } else if p
110 .extension()
111 .and_then(|x| x.to_str())
112 .is_some_and(|x| ext.contains(&x))
113 {
114 out.insert(p);
115 }
116 }
117 }
118 out.into_iter().collect()
119}
120
121/// The names the independent code must not spell.
122///
123/// Every advisor's id, adapter, executable, provider, vendor and model, and the peer
124/// adapter when peers are declared: each once, in canonical order. These are the
125/// literals the `coupling` check looks for.
126///
127/// ```
128/// use majordomus_cli::reasoning::catalogue::AdvisorCatalogue;
129/// use majordomus_cli::reasoning::check::catalogue_names;
130///
131/// let catalogue: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
132/// "version: 1\nadvisors:\n - id: alpha\n title: Alpha\n transport: cli\n adapter: alpha-cli\n executable: alpha\n",
133/// )
134/// .unwrap();
135/// assert_eq!(catalogue_names(&catalogue), ["alpha", "alpha-cli"]);
136/// ```
137pub fn catalogue_names(catalogue: &AdvisorCatalogue) -> Vec<String> {
138 let mut names = Vec::new();
139 for a in &catalogue.advisors {
140 names.push(a.id.clone());
141 names.push(a.adapter.clone());
142 names.extend(a.executable.clone());
143 names.extend(a.provider.clone());
144 names.extend(a.vendor.clone());
145 names.extend(a.model.clone());
146 }
147 if let Some(p) = &catalogue.peers {
148 names.push(p.adapter.clone());
149 }
150 crate::order::canonical(&mut names);
151 names.dedup();
152 names
153}
154
155/// String literals of a source naming any of `names`: `"name"` or `'name'`, exactly.
156///
157/// A name inside prose, a path or a longer literal is not a hit: only a literal that is
158/// the name and nothing else couples code to an advisor. Each name is reported once, in
159/// the order of `names`.
160///
161/// ```
162/// use majordomus_cli::reasoning::check::literals_naming;
163///
164/// let names = vec!["alpha".to_string(), "beta".to_string()];
165/// let source = "let a = \"alpha\"; let b = 'alpha'; // the beta advisor\nimport('./beta.mjs')";
166/// assert_eq!(literals_naming(source, &names), ["alpha"]);
167/// ```
168pub fn literals_naming(text: &str, names: &[String]) -> Vec<String> {
169 let mut hits = Vec::new();
170 for n in names {
171 for quote in ['"', '\''] {
172 let needle = format!("{quote}{n}{quote}");
173 if text.contains(&needle) && !hits.contains(n) {
174 hits.push(n.clone());
175 }
176 }
177 }
178 hits
179}
180
181/// Run every check over the repository at `root`, in the order of [`CHECKS`].
182///
183/// It reads the catalogue, the adapter modules, the provider-independent sources, the CI
184/// workflows, the documentation and the loaded records, and asks no advisor anything.
185/// The findings come back in check order; an empty list means every rule holds.
186///
187/// ```
188/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
189/// use majordomus_cli::reasoning::check::run;
190/// use majordomus_cli::reasoning::record::ReasoningRecord;
191/// use majordomus_cli::reasoning::store::Loaded;
192///
193/// let record: ReasoningRecord = serde_json::from_value(serde_json::json!({
194/// "schema": "majordomus/reasoning-record", "version": 1, "id": "consultation-1",
195/// "task": "t", "recorded_at": "2026-10-01T00:00:01Z",
196/// "body": {
197/// "kind": "consultation", "plan": "plan-0", "advisor": "alpha", "status": "timeout",
198/// },
199/// }))
200/// .unwrap();
201/// let loaded = Loaded { records: vec![record], unreadable: vec![] };
202/// let root = tempfile::tempdir().unwrap();
203/// let found = run(root.path(), &AdvisorCatalogue::default(), &References::default(), &loaded);
204/// let messages: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
205/// assert_eq!(
206/// messages,
207/// [
208/// "names advisor 'alpha', which the catalogue does not declare",
209/// "cites plan plan-0, which is not recorded",
210/// ]
211/// );
212/// ```
213pub fn run(
214 root: &Path,
215 catalogue: &AdvisorCatalogue,
216 refs: &References,
217 loaded: &Loaded,
218) -> Vec<ReasoningFinding> {
219 run_checks(root, catalogue, refs, loaded, CHECKS)
220}
221
222/// Run the named `checks` over the repository at `root`, in the order of [`CHECKS`]; a
223/// name that is not one of them is ignored. [`run`] is this with every check, and
224/// [`applicable`] says which a repository is held to.
225///
226/// ```
227/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
228/// use majordomus_cli::reasoning::check::run_checks;
229/// use majordomus_cli::reasoning::store::Store;
230///
231/// let root = tempfile::tempdir().unwrap();
232/// std::fs::write(root.path().join("README.md"), "Consensus guarantees correctness.\n").unwrap();
233/// let catalogue = AdvisorCatalogue {
234/// forbidden_claims: vec!["consensus guarantees correctness".into()],
235/// ..Default::default()
236/// };
237/// let loaded = Store::at(root.path()).load();
238/// let refs = References::default();
239/// assert_eq!(run_checks(root.path(), &catalogue, &refs, &loaded, &["claims"]).len(), 1);
240/// assert!(run_checks(root.path(), &catalogue, &refs, &loaded, &["catalogue", "records"]).is_empty());
241/// ```
242pub fn run_checks(
243 root: &Path,
244 catalogue: &AdvisorCatalogue,
245 refs: &References,
246 loaded: &Loaded,
247 checks: &[&str],
248) -> Vec<ReasoningFinding> {
249 let on = |c: &str| checks.contains(&c);
250 let mut out = Vec::new();
251 let mut push = |check: &str, subject: String, message: String| {
252 out.push(ReasoningFinding {
253 check: check.into(),
254 subject,
255 message,
256 })
257 };
258
259 if on("catalogue") {
260 for d in catalogue.diagnostics(refs) {
261 push("catalogue", "share/advisors.yaml".into(), d);
262 }
263 }
264
265 if on("adapters") && root.join(ADAPTER_DIR).is_dir() {
266 let mut adapters: Vec<&str> = catalogue
267 .advisors
268 .iter()
269 .map(|a| a.adapter.as_str())
270 .collect();
271 if let Some(p) = &catalogue.peers {
272 adapters.push(&p.adapter);
273 }
274 for adapter in adapters {
275 let module = format!("{ADAPTER_DIR}/{adapter}.mjs");
276 if !root.join(&module).is_file() {
277 push(
278 "adapters",
279 module.clone(),
280 format!("the catalogue names adapter '{adapter}' and no transport module {module} implements it"),
281 );
282 }
283 }
284 }
285
286 let names = catalogue_names(catalogue);
287 for rel in INDEPENDENT_SOURCES.iter().filter(|_| on("coupling")) {
288 for file in files_under(root, rel, &["rs", "mjs"]) {
289 let Ok(text) = std::fs::read_to_string(&file) else {
290 continue;
291 };
292 for n in literals_naming(&text, &names) {
293 push(
294 "coupling",
295 file.strip_prefix(root).unwrap_or(&file).display().to_string(),
296 format!("provider-independent code names '{n}'; it must reach advisors through the catalogue"),
297 );
298 }
299 }
300 }
301
302 let credentials: Vec<&str> = refs
303 .models
304 .vendors
305 .iter()
306 .filter_map(|v| v.credential_env.as_deref())
307 .collect();
308 let mut ci_files = Vec::new();
309 if on("ci") {
310 ci_files.extend(files_under(root, ".github/workflows", &["yml", "yaml"]));
311 ci_files.extend(files_under(root, ".ai/repo/ci/gates.yaml", &["yaml"]));
312 }
313 for file in ci_files {
314 let Ok(text) = std::fs::read_to_string(&file) else {
315 continue;
316 };
317 for var in &credentials {
318 if text.contains(var) {
319 push(
320 "ci",
321 file.strip_prefix(root)
322 .unwrap_or(&file)
323 .display()
324 .to_string(),
325 format!(
326 "CI names the model credential {var}; no gate may depend on a live model"
327 ),
328 );
329 }
330 }
331 }
332
333 let mut phrases: Vec<String> = catalogue
334 .forbidden_claims
335 .iter()
336 .map(|p| p.to_lowercase())
337 .collect();
338 for a in &catalogue.advisors {
339 let t = a.title.to_lowercase();
340 phrases.push(format!("requires {t}"));
341 phrases.push(format!("{t} is required"));
342 }
343 let mut docs = Vec::new();
344 if on("claims") {
345 docs.extend(files_under(root, "docs", &["md"]));
346 docs.extend(files_under(root, "site/content", &["md"]));
347 docs.extend(files_under(root, ".ai/repo", &["md"]));
348 docs.push(root.join("README.md"));
349 }
350 for file in docs {
351 let Ok(text) = std::fs::read_to_string(&file) else {
352 continue;
353 };
354 let lower = text.to_lowercase();
355 for p in &phrases {
356 if lower.contains(p.as_str()) {
357 push(
358 "claims",
359 file.strip_prefix(root)
360 .unwrap_or(&file)
361 .display()
362 .to_string(),
363 format!("asserts '{p}', a claim the reasoning design refuses"),
364 );
365 }
366 }
367 }
368
369 let (unreadable, records) = if on("records") {
370 (&loaded.unreadable[..], &loaded.records[..])
371 } else {
372 (&[][..], &[][..])
373 };
374 for u in unreadable {
375 push(
376 "records",
377 u.clone(),
378 "a reasoning record that does not parse".into(),
379 );
380 }
381 for r in records {
382 match &r.body {
383 ReasoningBody::Consultation(c) => {
384 let known = catalogue.get(&c.advisor).is_some() || c.advisor.starts_with("peer:");
385 if !known {
386 push(
387 "records",
388 r.id.clone(),
389 format!(
390 "names advisor '{}', which the catalogue does not declare",
391 c.advisor
392 ),
393 );
394 }
395 match loaded.get(&c.plan).map(|p| &p.body) {
396 Some(ReasoningBody::Plan(p)) => {
397 if !p.plan.selected.iter().any(|s| s.advisor == c.advisor) {
398 push(
399 "records",
400 r.id.clone(),
401 format!(
402 "claims advisor '{}', which plan {} did not select",
403 c.advisor, c.plan
404 ),
405 );
406 }
407 }
408 _ => push(
409 "records",
410 r.id.clone(),
411 format!("cites plan {}, which is not recorded", c.plan),
412 ),
413 }
414 }
415 ReasoningBody::Conclusion(k) => {
416 let mut computed: Vec<String> = Vec::new();
417 for id in &k.input.consultations {
418 match loaded.get(id).map(|x| &x.body) {
419 Some(ReasoningBody::Consultation(c))
420 if c.status == ConsultationStatus::Completed =>
421 {
422 if !computed.contains(&c.advisor) {
423 computed.push(c.advisor.clone());
424 }
425 }
426 _ => push(
427 "records",
428 r.id.clone(),
429 format!("cites {id}, which is not a completed consultation"),
430 ),
431 }
432 }
433 crate::order::canonical(&mut computed);
434 if computed != k.reviewed_by || k.independent_review_count != k.reviewed_by.len() {
435 push(
436 "records",
437 r.id.clone(),
438 format!(
439 "claims review by [{}] ({}), and the consultations it cites show [{}]",
440 k.reviewed_by.join(", "),
441 k.independent_review_count,
442 computed.join(", ")
443 ),
444 );
445 }
446 }
447 _ => {}
448 }
449 }
450 out
451}
452
453/// The checks, by name, in the order [`run`] performs them.
454pub const CHECKS: &[&str] = &[
455 "catalogue",
456 "adapters",
457 "coupling",
458 "ci",
459 "claims",
460 "records",
461];
462
463/// The rule whose executable half `ci` and `claims` are: what Majordomus's own CI and
464/// documents may say about advisors. A repository that does not declare it is an adopter,
465/// and an adopter's workflows and documents are its product, not its use of the
466/// reasoning layer (I1996).
467pub const REPOSITORY_RULE: &str = "project.advisors-are-optional";
468
469/// The checks that judge a repository's own workflows and documents rather than the
470/// reasoning layer it adopted; they apply only where [`REPOSITORY_RULE`] is declared.
471pub const REPOSITORY_CHECKS: &[&str] = &["ci", "claims"];
472
473/// The checks a repository declaring the rules `rule_ids` is held to, in [`CHECKS`] order:
474/// every check where [`REPOSITORY_RULE`] is among them, and only what an adopter adopted
475/// (its catalogue, adapters, coupling and records) where it is not.
476///
477/// ```
478/// use majordomus_cli::reasoning::check::{applicable, CHECKS};
479///
480/// assert_eq!(applicable(["project.advisors-are-optional"]), CHECKS);
481/// assert_eq!(
482/// applicable(["majordomus.some-other-rule"]),
483/// ["catalogue", "adapters", "coupling", "records"]
484/// );
485/// ```
486pub fn applicable<'a>(rule_ids: impl IntoIterator<Item = &'a str>) -> Vec<&'static str> {
487 let own = rule_ids.into_iter().any(|id| id == REPOSITORY_RULE);
488 CHECKS
489 .iter()
490 .copied()
491 .filter(|c| own || !REPOSITORY_CHECKS.contains(c))
492 .collect()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn an_adopter_is_not_held_to_this_repositorys_ci_and_claims() {
501 assert_eq!(applicable(["project.advisors-are-optional", "x"]), CHECKS);
502 let adopter = applicable(std::iter::empty());
503 assert_eq!(adopter, ["catalogue", "adapters", "coupling", "records"]);
504
505 let root = tempfile::tempdir().unwrap();
506 let wf = root.path().join(".github/workflows");
507 std::fs::create_dir_all(&wf).unwrap();
508 std::fs::write(wf.join("ci.yml"), "env:\n K: ${{ secrets.ACME_KEY }}\n").unwrap();
509 std::fs::write(root.path().join("README.md"), "This requires Alpha.\n").unwrap();
510 let catalogue: AdvisorCatalogue = crate::metadata::yaml::parse_into(
511 "version: 1\nadvisors:\n - id: alpha\n title: Alpha\n transport: cli\n adapter: alpha-cli\n executable: alpha\n capabilities: [code_review]\n",
512 )
513 .unwrap();
514 let refs = References {
515 providers: Vec::new(),
516 models: crate::metadata::yaml::parse_into(
517 "version: 1\nvendors:\n - id: acme\n title: Acme\n credential_env: ACME_KEY\nmodels: []\n",
518 )
519 .unwrap(),
520 };
521 let loaded = super::super::store::Store::at(root.path()).load();
522 // the catalogue of this fixture draws findings of its own; what is measured here is
523 // which families reach the repository's workflow and document
524 let families = |found: Vec<ReasoningFinding>| -> Vec<String> {
525 found
526 .into_iter()
527 .filter(|f| REPOSITORY_CHECKS.contains(&f.check.as_str()))
528 .map(|f| format!("{} {}", f.check, f.subject))
529 .collect()
530 };
531 assert_eq!(
532 families(run(root.path(), &catalogue, &refs, &loaded)),
533 ["ci .github/workflows/ci.yml", "claims README.md"]
534 );
535 let held = run_checks(root.path(), &catalogue, &refs, &loaded, &adopter);
536 assert_eq!(families(held), Vec::<String>::new());
537 }
538
539 #[test]
540 fn a_literal_is_a_name_and_a_word_inside_prose_is_not() {
541 let names = vec!["alpha".to_string()];
542 assert_eq!(literals_naming("let x = \"alpha\";", &names), ["alpha"]);
543 assert_eq!(
544 literals_naming("import('./alpha.mjs')", &names),
545 Vec::<String>::new()
546 );
547 assert_eq!(
548 literals_naming("// the alpha advisor", &names),
549 Vec::<String>::new()
550 );
551 }
552
553 /// The anti-coupling proof for this crate: the shipped catalogue's names appear in no
554 /// provider-independent source file as a literal.
555 #[test]
556 fn the_reasoning_core_names_no_advisor_of_the_shipped_catalogue() {
557 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
558 let share = root.join("share");
559 let catalogue = AdvisorCatalogue::load(&share).unwrap();
560 assert!(
561 !catalogue.advisors.is_empty(),
562 "the shipped catalogue declares advisors"
563 );
564 let names = catalogue_names(&catalogue);
565 let mut scanned = 0;
566 for rel in INDEPENDENT_SOURCES {
567 for file in files_under(&root, rel, &["rs", "mjs"]) {
568 scanned += 1;
569 let text = std::fs::read_to_string(&file).unwrap();
570 assert_eq!(
571 literals_naming(&text, &names),
572 Vec::<String>::new(),
573 "{} names an advisor",
574 file.display()
575 );
576 }
577 }
578 assert!(
579 scanned >= 7,
580 "the scan reached the module ({scanned} files)"
581 );
582 }
583}