Skip to main content

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}