Skip to main content

majordomus_cli/gates/
mod.rs

1//! Completion as a provable state: which gates a task's own change set must pass, what
2//! each one said, whether that verdict still describes the tree, and therefore whether the
3//! task may be called finished.
4//!
5//! # The problem this exists for
6//!
7//! A worker saying "done" is a claim about a repository, and until this module the
8//! repository had no way to contradict it at the level of the *validation pipeline*.
9//! `finish` already refused over the task's `requires` — the obligation closure of ADR
10//! 0030 — but the eighteen-odd gates the CI model declares had no bearing on whether a task
11//! could be completed at all: they were a property of a pull request, not of a task. So a
12//! task could be finished with `rust-check` red, or with `shell-suite` never having run.
13//!
14//! # Nothing here is a second declaration
15//!
16//! - **The gates** are `.ai/repo/ci/gates.yaml`, read by `model::GateModel`. `scripts/ci-plan`
17//!   is the other reader; the behavioural case asserts they select the same set.
18//! - **Applicability** is that model's own rules, applied to the task's own change set —
19//!   the paths between the task's starting commit and the working tree. Not a table of
20//!   "source implies tests": the classes in the model already say which paths select which
21//!   gates, and a class edited tomorrow changes the answer tomorrow.
22//! - **The obligations** are `share/obligations.yaml`, judged by
23//!   [`crate::capability::builtin::obligations`]. This module derives which of them the
24//!   change set *implies* — from each token's own declared `inputs` — and reports whether
25//!   the task declared it. It does not re-judge a declared one; `obligations.closure` and
26//!   `mj_validate_obligations` own that and there is one judgement, not two.
27//! - **The evidence and its staleness** are the ledger and the inputs hash, the mechanism
28//!   `majordomus evidence` already writes and ADR 0030 already argues for.
29//!
30//! # The one thing that is new
31//!
32//! A gate run is now a fact the record can hold: `majordomus evidence --gate <id> --exit <n>`
33//! writes a `task.gate` line carrying the hash of the files that select that gate, and
34//! `judge` turns those lines into a status. Recording is the shell tool's, because the
35//! ledger has one writer (ADR 0030); judging is here, because a judgement with two
36//! implementations is how `check` and a gate come to disagree.
37//!
38//! # One lifecycle
39//!
40//! A change set goes through the model to a plan, the plan and the recorded runs through the
41//! judgement to a status per gate, and the statuses fold into one verdict.
42//!
43//! ```
44//! use majordomus_cli::gates::GateStatus;
45//!
46//! // the vocabulary the whole module reports in, and the two rules that make it useful
47//! assert!(GateStatus::Fail.refuses() && GateStatus::Stale.refuses());
48//! assert!(GateStatus::Queued.unverified() && !GateStatus::Queued.refuses());
49//! // and the one word that discharges anything
50//! assert!(!GateStatus::Pass.refuses() && !GateStatus::Pass.unverified());
51//! ```
52//!
53
54// The three halves of the judgement are the crate's own. What a reader outside the crate
55// needs is the *documents* — the completion report and the types inside it — and those are
56// re-exported below, so the public surface is the answer and not the machinery that makes
57// it.
58pub(crate) mod closing;
59pub(crate) mod done;
60pub(crate) mod judge;
61pub(crate) mod model;
62
63use std::collections::{BTreeMap, BTreeSet};
64use std::path::Path;
65
66use schemars::JsonSchema;
67use serde::{Deserialize, Serialize};
68
69use crate::capability::builtin::obligations::Obligation;
70use crate::capability::builtin::ActiveTask;
71use crate::discovery::glob::Glob;
72
73pub use done::{DoneQuestion, ObligationStanding};
74pub use judge::{Gate, GateRun, GateStatus};
75pub(crate) use model::GateModel;
76pub use model::{GateClass, GateClassMatch, GateDecl, GatePlan, GatePlanMode};
77
78/// The local half of the layer, relative to the repository root. The same constant
79/// `obligations` and `continuity` state, for the same reason: the shell tool decides where
80/// its state lives and a second opinion about the path would be a second source of truth.
81const STATE_DIR: &str = ".ai/local/state";
82
83// ---------------------------------------------------------------- derived obligations
84
85/// ```
86/// use majordomus_cli::gates::ImpliedObligation;
87///
88/// let owed = ImpliedObligation {
89///     id: "tests".into(),
90///     title: "The cases the change obliges were run".into(),
91///     applicable: true,
92///     declared: false,
93///     reason: "lib/check.sh is an input of this obligation".into(),
94///     because: vec!["lib/check.sh".into()],
95///     remediation: "majordomus start --requires tests".into(),
96/// };
97/// // applicable and undeclared is the gap nothing else reports: the change owes it and
98/// // the task promised nothing, so no validator holds anybody to it
99/// assert!(owed.applicable && !owed.declared);
100/// ```
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102/// One obligation the change set implies, and whether the task promised it.
103///
104/// Applicability is derived and nothing else: a token's own `inputs` in
105/// `share/obligations.yaml` name the files it is about, so a change to one of them is what
106/// makes the token owed. A token with no `inputs` names a fact outside the tree — a push, a
107/// publication, a deployment — which no change to a tree can imply, so those are owed when
108/// the task declares them and reported as not applicable when it does not.
109pub struct ImpliedObligation {
110    /// The token.
111    pub id: String,
112    /// The obligation as a heading, from the vocabulary.
113    pub title: String,
114    /// True when the change set, or the task's own declaration, makes it owed.
115    pub applicable: bool,
116    /// True when the task's `requires` names it, so `finish` already holds the task to it.
117    pub declared: bool,
118    /// Why it applies, or why it does not.
119    pub reason: String,
120    /// The changed paths that implied it, when paths did.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub because: Vec<String>,
123    /// What would discharge it.
124    pub remediation: String,
125}
126
127/// Which obligations a change set implies, against the shipped vocabulary and the task's
128/// own declaration.
129///
130/// The derivation, in one sentence each:
131///
132/// - a token whose declared `inputs` cover a changed path is owed, which is how source
133///   modification comes to imply `tests`, a document change to imply `docs`, and a change
134///   under `share/` or `.ai/repo/` to imply `generated` — every one of those from the
135///   token's own data and none from a table here;
136/// - `commit` is owed whenever anything changed at all, because work that exists only in a
137///   working tree is not work the repository has;
138/// - every other token names a fact outside this tree, so it is owed exactly when the task
139///   declared it: a deployment-scoped task declares `deploy` and owes verification, and a
140///   task that deploys nothing does not, which is the difference between "not applicable"
141///   and "unknown".
142pub(crate) fn implied(
143    vocabulary: &[Obligation],
144    changed: &[String],
145    task: Option<&ActiveTask>,
146) -> Vec<ImpliedObligation> {
147    let declared: BTreeSet<&str> = task
148        .map(|t| t.requires.iter().map(String::as_str).collect())
149        .unwrap_or_default();
150    let mut out = Vec::new();
151    for o in vocabulary {
152        let hits: Vec<String> = changed
153            .iter()
154            .filter(|p| o.inputs.iter().any(|spec| Glob::new(spec).matches(p)))
155            .cloned()
156            .collect();
157        let is_declared = declared.contains(o.id.as_str());
158        let (applicable, reason) = if !hits.is_empty() {
159            (
160                true,
161                format!(
162                    "the change touches {} file(s) this obligation is taken over",
163                    hits.len()
164                ),
165            )
166        } else if o.id == "commit" && !changed.is_empty() {
167            (
168                true,
169                "the change exists, so it owes being in the branch's history".to_string(),
170            )
171        } else if is_declared {
172            (true, "the task's own `requires` declares it".to_string())
173        } else if o.inputs.is_empty() {
174            (
175                false,
176                format!(
177                    "a fact outside this tree that no change to it can imply; declare it in \
178                     the task's `requires` to owe it ({})",
179                    o.discharged_by
180                ),
181            )
182        } else {
183            (
184                false,
185                "the change touches nothing this obligation is taken over".to_string(),
186            )
187        };
188        out.push(ImpliedObligation {
189            id: o.id.clone(),
190            title: o.title.clone(),
191            applicable,
192            declared: is_declared,
193            reason,
194            because: hits,
195            remediation: if o.discharged_by == "none" {
196                format!(
197                    "majordomus evidence --covers {} --command '<what proved it>'",
198                    o.id
199                )
200            } else {
201                format!(
202                    "{} && majordomus evidence --covers {} --command '{}'",
203                    o.discharged_by, o.id, o.discharged_by
204                )
205            },
206        });
207    }
208    out
209}
210
211// ---------------------------------------------------------------- the report
212
213/// ```
214/// use majordomus_cli::gates::Completion;
215///
216/// // the document every surface reads, as `gates.completion` answers with it. The property
217/// // that matters is the one asserted here: a gate that never reported leaves the task
218/// // finishable and *unverified*, which is a different thing from a task that passed.
219/// let c: Completion = serde_json::from_value(serde_json::json!({
220///     "present": true,
221///     "model": ".ai/repo/ci/gates.yaml",
222///     "plan": { "mode": "affected", "reason": "1 path in 1 class", "changed": ["docs/CLI.md"],
223///               "classes": [], "unclassified": [], "selected": {}, "excluded": {} },
224///     "finishable": true,
225///     "unverified": ["reference-check"],
226///     "tallies": { "queued": 1 },
227///     "gates": [],
228///     "obligations": [],
229///     "questions": []
230/// }))
231/// .unwrap();
232/// assert!(c.finishable, "absence of a verdict does not refuse");
233/// assert_eq!(c.unverified, ["reference-check"], "and it is never silently accepted");
234/// assert!(c.blocking.is_empty());
235/// ```
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237/// Whether the active task may be called finished, and everything the answer rests on.
238///
239/// `finishable` is the field a caller acts on and the one thing no surface may recompute:
240/// the shell tool's `check` and `finish` read it, the HTTP API serves it, the Cockpit
241/// renders it, and all of them are reading this one execution.
242pub struct Completion {
243    /// True when a task record was found in this checkout.
244    pub present: bool,
245    /// The task, as its record declares it.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub task: Option<ActiveTask>,
248    /// The CI model this was judged against.
249    pub model: String,
250    /// Affected or full, and why.
251    pub plan: GatePlan,
252    /// False when a required gate is known to be failing, stale, or blocked by one that is.
253    /// Absence of a verdict does not make this false — it makes `unverified` non-empty.
254    pub finishable: bool,
255    /// The required gates whose status refuses completion.
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub blocking: Vec<String>,
258    /// The required gates that have never reported, or cannot be judged here. A task
259    /// finished over these is finished unverified, and the record says so.
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub unverified: Vec<String>,
262    /// How many gates stand in each status, by word.
263    pub tallies: BTreeMap<String, usize>,
264    /// Every gate of the model, what refuses first.
265    pub gates: Vec<Gate>,
266    /// Which obligations the change set implies, and whether the task promised them. The
267    /// judgement of a declared one is `obligations.closure`'s, not this module's.
268    pub obligations: Vec<ImpliedObligation>,
269    /// The done invariant: the questions a task must answer before anybody may call it
270    /// finished, each with the source that answered it and the evidence it read. Composed
271    /// from the obligation closure, the gates, the change set, the repository's convergence
272    /// and the closing readings; a question nothing here reaches is `unknown` and names the
273    /// command that would answer it.
274    pub questions: Vec<DoneQuestion>,
275    /// What could not be established, each as one line. Never empty when something was
276    /// skipped: a gap is reported rather than left to be inferred.
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub findings: Vec<String>,
279}
280
281/// The paths a task changed: everything between the commit it started at and the working
282/// tree, the uncommitted half included.
283///
284/// The same two questions `scripts/ci-plan` asks of git without a base, and for the same
285/// reason: a plan computed over the committed half only would go green on a worker who has
286/// not committed, which is the failure mode this whole module exists to close.
287pub(crate) fn changed_paths(root: &Path, base: Option<&str>) -> Result<Vec<String>, String> {
288    let mut out: BTreeSet<String> = BTreeSet::new();
289    let git = |args: &[&str]| -> Result<String, String> {
290        let out = crate::git::read_only(root)
291            .args(args)
292            .output()
293            .map_err(|e| format!("git {}: {e}", args.join(" ")))?;
294        if !out.status.success() {
295            return Err(format!(
296                "git {} exited {}",
297                args.join(" "),
298                out.status.code().unwrap_or(-1)
299            ));
300        }
301        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
302    };
303    if let Some(base) = base.filter(|b| !b.is_empty() && *b != "NONE") {
304        for line in git(&["diff", "--name-only", base, "HEAD"])?.lines() {
305            if !line.trim().is_empty() {
306                out.insert(line.to_string());
307            }
308        }
309    }
310    for line in git(&["status", "--porcelain=v1"])?.lines() {
311        if line.len() < 4 {
312            continue;
313        }
314        let path = &line[3..];
315        // a rename reports "old -> new"; the new name is the one a gate would read
316        let path = path.rsplit(" -> ").next().unwrap_or(path);
317        if !path.trim().is_empty() {
318            out.insert(path.to_string());
319        }
320    }
321    Ok(out.into_iter().collect())
322}
323
324/// Compose the whole answer.
325///
326/// Everything expensive is passed in rather than fetched here, so that the capability, a
327/// test and a benchmark all drive the same function: the model, the change set, the task,
328/// the vocabulary and the per-gate hashes.
329#[allow(clippy::too_many_arguments)]
330pub(crate) fn complete(
331    model: &GateModel,
332    changed: &[String],
333    task: Option<&ActiveTask>,
334    vocabulary: &[Obligation],
335    runs: &BTreeMap<String, GateRun>,
336    hashes: &BTreeMap<String, Option<String>>,
337    standing: &BTreeMap<String, ObligationStanding>,
338    closure_reachable: bool,
339    convergence: Option<&crate::convergence::ConvergenceReport>,
340    closing: Option<&closing::Closing>,
341    on_demand: bool,
342    now: &str,
343    mut findings: Vec<String>,
344) -> Completion {
345    let plan = model::plan(model, changed, on_demand);
346    let mut gates = judge::judge(model, &plan, runs, hashes, task.map(|t| t.id.as_str()), now);
347    judge::report_order(&mut gates);
348
349    let blocking: Vec<String> = gates
350        .iter()
351        .filter(|g| g.required && g.status.refuses())
352        .map(|g| g.id.clone())
353        .collect();
354    let unverified: Vec<String> = gates
355        .iter()
356        .filter(|g| g.required && g.status.unverified())
357        .map(|g| g.id.clone())
358        .collect();
359    let mut tallies: BTreeMap<String, usize> = BTreeMap::new();
360    for g in &gates {
361        *tallies.entry(g.status.as_str().to_string()).or_default() += 1;
362    }
363    if !unverified.is_empty() {
364        findings.push(format!(
365            "{} required gate(s) have never reported: {}. Never-reported is not green \
366             (project.never-reported-is-not-green); a task completed over these is completed \
367             unverified",
368            unverified.len(),
369            unverified.join(" ")
370        ));
371    }
372    let obligations = implied(vocabulary, changed, task);
373    for o in &obligations {
374        if o.applicable && !o.declared {
375            findings.push(format!(
376                "the change implies the obligation '{}' and the task does not declare it, \
377                 so `finish` will not hold it to that: {}",
378                o.id, o.reason
379            ));
380        }
381    }
382
383    let questions = done::answer(
384        standing,
385        &obligations,
386        &gates,
387        changed,
388        closure_reachable,
389        convergence,
390        closing,
391    );
392
393    Completion {
394        present: task.is_some(),
395        task: task.cloned(),
396        model: model::MODEL_PATH.to_string(),
397        plan,
398        finishable: blocking.is_empty(),
399        blocking,
400        unverified,
401        tallies,
402        gates,
403        obligations,
404        questions,
405        findings,
406    }
407}
408
409/// Where this checkout's ledger is.
410pub(crate) fn ledger_path(root: &Path) -> std::path::PathBuf {
411    root.join(STATE_DIR).join("ledger.jsonl")
412}
413
414/// Where this checkout's task record is.
415pub(crate) fn task_path(root: &Path) -> std::path::PathBuf {
416    root.join(STATE_DIR).join("current.yaml")
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::metadata::yaml;
423
424    const MODEL: &str = r#"version: 1
425gates:
426  - id: lint
427    job: structure
428    always: true
429    runs: scripts/lint
430    summary: every script parses
431  - id: build
432    job: rust
433    runs: cargo build
434    summary: the crate builds
435classes:
436  - id: rust
437    paths: [apps/**]
438    gates: [build]
439  - id: docs
440    paths: [docs/**]
441    gates: []
442"#;
443
444    fn model() -> GateModel {
445        yaml::parse_into(MODEL).expect("the fixture model parses")
446    }
447
448    fn vocabulary() -> Vec<Obligation> {
449        serde_json::from_str(
450            r#"[
451              {"id":"tests","title":"The cases were run","summary":"s",
452               "discharged_by":"usecase impact","inputs":["apps/**","lib/**"],"remote":false},
453              {"id":"docs","title":"The documentation was written","summary":"s",
454               "discharged_by":"scripts/ci/reference-check","inputs":["docs/**"],"remote":false},
455              {"id":"commit","title":"The work is committed","summary":"s",
456               "discharged_by":"git","remote":false},
457              {"id":"deploy","title":"The application is running it","summary":"s",
458               "discharged_by":"majordomus deployment","remote":true}
459            ]"#,
460        )
461        .expect("the fixture vocabulary parses")
462    }
463
464    fn task(requires: &[&str]) -> ActiveTask {
465        ActiveTask {
466            id: "t-1".into(),
467            task: "work".into(),
468            profile: "implementation".into(),
469            outcome: "active".into(),
470            scope: vec![],
471            requires: requires.iter().map(|s| (*s).to_string()).collect(),
472            started_at: "2026-09-11T00:00:00Z".into(),
473            head: "0123456789ab".into(),
474            issue: String::new(),
475            intent: String::new(),
476            exemption: String::new(),
477            exemption_because: String::new(),
478            binding: String::new(),
479            plan_revision: String::new(),
480            evidence_standing: String::new(),
481        }
482    }
483
484    fn hashes(m: &GateModel, v: &str) -> BTreeMap<String, Option<String>> {
485        m.gates
486            .iter()
487            .map(|g| (g.id.clone(), Some(v.to_string())))
488            .collect()
489    }
490
491    #[test]
492    fn source_modification_implies_tests_and_a_document_change_does_not() {
493        let v = vocabulary();
494        let i = implied(&v, &["apps/majordomus-cli/src/lib.rs".into()], None);
495        let tests = i.iter().find(|o| o.id == "tests").unwrap();
496        assert!(tests.applicable, "{}", tests.reason);
497        assert!(!tests.declared, "the task declared nothing");
498        assert!(!i.iter().find(|o| o.id == "docs").unwrap().applicable);
499        // and the reverse
500        let i = implied(&v, &["docs/CLI.md".into()], None);
501        assert!(i.iter().find(|o| o.id == "docs").unwrap().applicable);
502        assert!(!i.iter().find(|o| o.id == "tests").unwrap().applicable);
503    }
504
505    #[test]
506    fn a_deployment_is_owed_when_the_task_declares_it_and_not_applicable_otherwise() {
507        let v = vocabulary();
508        let changed = vec!["apps/x.rs".to_string()];
509
510        let none = implied(&v, &changed, Some(&task(&[])));
511        let d = none.iter().find(|o| o.id == "deploy").unwrap();
512        assert!(!d.applicable, "no change to a tree can imply a deployment");
513        assert!(d.reason.contains("outside this tree"));
514
515        let declared = implied(&v, &changed, Some(&task(&["deploy"])));
516        let d = declared.iter().find(|o| o.id == "deploy").unwrap();
517        assert!(d.applicable && d.declared);
518    }
519
520    #[test]
521    fn anything_changed_owes_being_committed() {
522        let v = vocabulary();
523        assert!(
524            implied(&v, &["apps/x.rs".into()], None)
525                .iter()
526                .find(|o| o.id == "commit")
527                .unwrap()
528                .applicable
529        );
530        assert!(
531            !implied(&v, &[], None)
532                .iter()
533                .find(|o| o.id == "commit")
534                .unwrap()
535                .applicable
536        );
537    }
538
539    #[test]
540    fn a_known_failure_makes_the_task_unfinishable_and_silence_does_not() {
541        let m = model();
542        let v = vocabulary();
543        let t = task(&["tests"]);
544        let changed = vec!["apps/x.rs".to_string()];
545
546        // nothing reported: the task is not refused, and the report says it is unverified
547        let c = complete(
548            &m,
549            &changed,
550            Some(&t),
551            &v,
552            &BTreeMap::new(),
553            &hashes(&m, "aaaa"),
554            &BTreeMap::new(),
555            false,
556            None,
557            None,
558            false,
559            "now",
560            vec![],
561        );
562        assert!(c.finishable, "absence is not a failure");
563        assert!(c.unverified.contains(&"build".to_string()));
564        assert!(c.findings.iter().any(|f| f.contains("never-reported")));
565
566        // a failing run: refused, and it names the gate
567        let mut runs = BTreeMap::new();
568        runs.insert(
569            "build".to_string(),
570            GateRun {
571                recorded_at: "t".into(),
572                head: "abc".into(),
573                branch: "b".into(),
574                exit: 101,
575                command: "cargo build".into(),
576                inputs_hash: "aaaa".into(),
577                session: String::new(),
578            },
579        );
580        let c = complete(
581            &m,
582            &changed,
583            Some(&t),
584            &v,
585            &runs,
586            &hashes(&m, "aaaa"),
587            &BTreeMap::new(),
588            false,
589            None,
590            None,
591            false,
592            "now",
593            vec![],
594        );
595        assert!(!c.finishable);
596        assert_eq!(c.blocking, vec!["build".to_string()]);
597        assert_eq!(c.tallies.get("fail"), Some(&1));
598    }
599
600    #[test]
601    fn a_passing_run_stops_finishing_the_task_once_the_code_moves_underneath_it() {
602        let m = model();
603        let v = vocabulary();
604        let t = task(&[]);
605        let changed = vec!["apps/x.rs".to_string()];
606        let mut runs = BTreeMap::new();
607        runs.insert(
608            "build".to_string(),
609            GateRun {
610                recorded_at: "t".into(),
611                head: "abc".into(),
612                branch: "b".into(),
613                exit: 0,
614                command: "cargo build".into(),
615                inputs_hash: "aaaa".into(),
616                session: String::new(),
617            },
618        );
619        // over the tree it was taken on
620        let ok = complete(
621            &m,
622            &changed,
623            Some(&t),
624            &v,
625            &runs,
626            &hashes(&m, "aaaa"),
627            &BTreeMap::new(),
628            false,
629            None,
630            None,
631            false,
632            "now",
633            vec![],
634        );
635        assert!(ok.finishable && ok.blocking.is_empty());
636        assert_eq!(ok.tallies.get("pass"), Some(&1));
637
638        // one edit later, the same run proves nothing
639        let moved = complete(
640            &m,
641            &changed,
642            Some(&t),
643            &v,
644            &runs,
645            &hashes(&m, "bbbb"),
646            &BTreeMap::new(),
647            false,
648            None,
649            None,
650            false,
651            "now",
652            vec![],
653        );
654        assert!(
655            !moved.finishable,
656            "evidence that no longer describes the tree cannot finish a task"
657        );
658        assert_eq!(moved.blocking, vec!["build".to_string()]);
659        assert_eq!(moved.tallies.get("stale"), Some(&1));
660    }
661
662    #[test]
663    fn with_no_task_nothing_is_judged_and_nothing_passes() {
664        let m = model();
665        let v = vocabulary();
666        let c = complete(
667            &m,
668            &["apps/x.rs".to_string()],
669            None,
670            &v,
671            &BTreeMap::new(),
672            &hashes(&m, "aaaa"),
673            &BTreeMap::new(),
674            false,
675            None,
676            None,
677            false,
678            "now",
679            vec![],
680        );
681        assert!(!c.present);
682        assert_eq!(c.tallies.get("pass"), None);
683        assert!(c.unverified.contains(&"build".to_string()));
684    }
685}