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}