1use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44use super::closing::{Closing, DEBT_DIRECTORY, DEBT_SUFFIX};
45use super::judge::{Gate, GateStatus};
46use super::ImpliedObligation;
47use crate::convergence::ConvergenceReport;
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66pub struct DoneQuestion {
68 pub id: String,
70 pub question: String,
72 pub status: GateStatus,
74 pub evidence: String,
76 pub source: String,
78 pub remediation: String,
80}
81
82enum Answers {
84 Obligation(&'static str),
86 Gates,
88 Gate(&'static str),
90 Change,
92 Convergence,
94 Debt,
96 Accumulated,
98 Backlog,
100 Elsewhere(&'static str),
102}
103
104#[allow(clippy::type_complexity)]
109fn invariant() -> Vec<(&'static str, &'static str, Answers, &'static str)> {
110 vec![
111 (
112 "implemented",
113 "Is all the work this task promised present in the tree?",
114 Answers::Obligation("implementation"),
115 "majordomus check",
116 ),
117 (
118 "tested",
119 "Were the cases the change obliges run, and did they pass?",
120 Answers::Obligation("tests"),
121 "majordomus usecase impact",
122 ),
123 (
124 "regression-tested",
125 "Does the change touch a test of its own?",
126 Answers::Change,
127 "add a case under test/cases/ or a test beside the code",
128 ),
129 (
130 "documented",
131 "Were the documents a reader needs updated?",
132 Answers::Obligation("docs"),
133 "scripts/ci/reference-check",
134 ),
135 (
136 "generated",
137 "Is every derived artifact current with its canonical source?",
138 Answers::Obligation("generated"),
139 "majordomus generate --check",
140 ),
141 (
142 "openapi",
143 "Are the OpenAPI document and the transport registries current?",
144 Answers::Obligation("generated"),
145 "majordomus generate --check",
146 ),
147 (
148 "parity",
149 "Does every surface — command line, HTTP, MCP, Cockpit — agree with the registry?",
150 Answers::Elsewhere("majordomus quality report"),
151 "majordomus quality report",
152 ),
153 (
154 "committed",
155 "Are the changes in the branch's history rather than in the working tree?",
156 Answers::Obligation("commit"),
157 "git commit",
158 ),
159 (
160 "pushed",
161 "Has the commit reached the remote?",
162 Answers::Obligation("push"),
163 "git push",
164 ),
165 (
166 "ci",
167 "Has every gate the change selects reported, and did it pass?",
168 Answers::Gates,
169 "majordomus evidence --gate <id> --exit <status>",
170 ),
171 (
172 "version",
173 "Has the declared version moved as far as the public surface has?",
174 Answers::Gate("version-surface"),
175 "majordomus release version",
176 ),
177 (
178 "changelog",
179 "Is the release's own record current with the tree?",
180 Answers::Gate("release-check"),
181 "scripts/ci/release-check",
182 ),
183 (
184 "integrated",
185 "Does the trunk reach this commit?",
186 Answers::Obligation("target"),
187 "open a pull request and land it",
188 ),
189 (
190 "published",
191 "Does the published site serve this commit?",
192 Answers::Obligation("pages"),
193 "scripts/pages verify --commit HEAD",
194 ),
195 (
196 "deployed",
197 "Is the environment this change targets running it?",
198 Answers::Obligation("deploy"),
199 "majordomus deployment",
200 ),
201 (
202 "deployment-verified",
203 "Was the deployed result looked at after it was deployed?",
204 Answers::Obligation("verify"),
205 "majordomus evidence --covers verify --command '<the live check>'",
206 ),
207 (
208 "no-stale-topology",
209 "Is a branch, worktree or pull request left behind?",
210 Answers::Convergence,
211 "majordomus convergence",
212 ),
213 (
214 "no-new-debt",
215 "Is the repository's recorded debt no larger than when this task started?",
216 Answers::Debt,
217 "take out what the change added to the baseline, or settle what the entry records",
218 ),
219 (
220 "nothing-accumulated",
221 "Has every branch and worktree created since this task started gone again?",
222 Answers::Accumulated,
223 "majordomus worktree cleanup",
224 ),
225 (
226 "backlog-not-grown",
227 "Has every pull request opened since this task started been closed?",
228 Answers::Backlog,
229 "majordomus prs status",
230 ),
231 (
232 "handover",
233 "Is the session's continuation record written?",
234 Answers::Elsewhere("majordomus handover"),
235 "majordomus handover < note.md",
236 ),
237 (
238 "issue",
239 "Do the issue and milestone this work belongs to say so?",
240 Answers::Elsewhere("majordomus plan status"),
241 "majordomus plan record",
242 ),
243 ]
244}
245
246#[derive(Debug, Clone, Default)]
260pub struct ObligationStanding {
261 pub state: String,
263 pub detail: String,
265 pub reproduce: String,
267}
268
269pub(crate) fn answer(
277 standing: &std::collections::BTreeMap<String, ObligationStanding>,
278 implied: &[ImpliedObligation],
279 gates: &[Gate],
280 changed: &[String],
281 closure_reachable: bool,
282 convergence: Option<&ConvergenceReport>,
283 closing: Option<&Closing>,
284) -> Vec<DoneQuestion> {
285 invariant()
286 .into_iter()
287 .map(|(id, question, from, remediation)| {
288 let (status, evidence, source) = match from {
289 Answers::Obligation(token) => {
290 let applicable = implied
291 .iter()
292 .find(|o| o.id == token)
293 .map(|o| (o.applicable, o.declared, o.reason.clone()));
294 match (standing.get(token), applicable) {
295 (Some(s), _) => (
297 match s.state.as_str() {
298 "discharged" => GateStatus::Pass,
299 "stale" => GateStatus::Stale,
300 "owed" => GateStatus::Queued,
301 _ => GateStatus::Unknown,
302 },
303 s.detail.clone(),
304 format!("obligation {token} (obligations.closure)"),
305 ),
306 (None, Some((true, false, why))) => (
309 GateStatus::Queued,
310 format!(
311 "the change implies this obligation and the task does not declare it, \
312 so nothing holds it to it: {why}"
313 ),
314 format!("obligation {token} (derived from its own inputs)"),
315 ),
316 (None, Some((false, _, why))) => (
317 GateStatus::Exempt,
318 why,
319 format!("obligation {token} (derived from its own inputs)"),
320 ),
321 _ if !closure_reachable => (
322 GateStatus::Unknown,
323 "the obligation closure could not be read in this checkout".into(),
324 format!("obligation {token}"),
325 ),
326 _ => (
327 GateStatus::Unknown,
328 format!("share/obligations.yaml declares no token '{token}' here"),
329 format!("obligation {token}"),
330 ),
331 }
332 }
333 Answers::Gates => {
334 let required: Vec<&Gate> = gates.iter().filter(|g| g.required).collect();
335 let refused = required.iter().filter(|g| g.status.refuses()).count();
336 let silent = required.iter().filter(|g| g.status.unverified()).count();
337 let passing = required
338 .iter()
339 .filter(|g| g.status == GateStatus::Pass)
340 .count();
341 let status = if required.is_empty() {
342 GateStatus::Exempt
343 } else if refused > 0 {
344 GateStatus::Fail
345 } else if silent > 0 {
346 GateStatus::Queued
347 } else {
348 GateStatus::Pass
349 };
350 (
351 status,
352 format!(
353 "{} gate(s) selected: {passing} passing, {refused} refusing, \
354 {silent} never reported",
355 required.len()
356 ),
357 ".ai/repo/ci/gates.yaml".to_string(),
358 )
359 }
360 Answers::Gate(gate) => match gates.iter().find(|g| g.id == gate) {
361 Some(g) => (
362 g.status,
363 g.reason.clone(),
364 format!("gate {gate} (.ai/repo/ci/gates.yaml)"),
365 ),
366 None => (
367 GateStatus::Unknown,
368 format!("this repository's CI model declares no gate '{gate}'"),
369 format!("gate {gate}"),
370 ),
371 },
372 Answers::Change => {
373 let tests: Vec<&String> = changed
374 .iter()
375 .filter(|p| is_test_path(p))
376 .take(3)
377 .collect();
378 if changed.is_empty() {
379 (
380 GateStatus::Exempt,
381 "the task has changed nothing".into(),
382 "the change set".to_string(),
383 )
384 } else if tests.is_empty() {
385 (
386 GateStatus::Queued,
387 format!(
388 "no test path among the {} file(s) this task touched",
389 changed.len()
390 ),
391 "the change set (profile verification.regression_test_required)"
392 .to_string(),
393 )
394 } else {
395 (
396 GateStatus::Pass,
397 format!(
398 "{} test path(s) touched, including {}",
399 tests.len(),
400 tests[0]
401 ),
402 "the change set (profile verification.regression_test_required)"
403 .to_string(),
404 )
405 }
406 }
407 Answers::Convergence => match convergence {
408 Some(report) => {
412 let at_risk: Vec<&str> = report
413 .holdings
414 .iter()
415 .filter(|h| h.at_risk)
416 .map(|h| h.identity.as_str())
417 .take(3)
418 .collect();
419 if report.converged {
420 (
421 GateStatus::Pass,
422 report.summary(),
423 "convergence.report".to_string(),
424 )
425 } else {
426 (
427 GateStatus::Fail,
428 format!("{}: {}", report.summary(), at_risk.join(", ")),
429 "convergence.report".to_string(),
430 )
431 }
432 }
433 None => (
434 GateStatus::Unknown,
435 "the repository's holdings could not be read in this checkout".into(),
436 "convergence.report".to_string(),
437 ),
438 },
439 Answers::Debt => debt_answer(closing),
440 Answers::Accumulated => accumulated_answer(closing),
441 Answers::Backlog => backlog_answer(closing),
442 Answers::Elsewhere(command) => (
443 GateStatus::Unknown,
444 format!(
445 "nothing in this report reaches that fact; `{command}` is what answers it"
446 ),
447 command.to_string(),
448 ),
449 };
450 DoneQuestion {
451 id: id.to_string(),
452 question: question.to_string(),
453 status,
454 evidence,
455 source,
456 remediation: remediation.to_string(),
457 }
458 })
459 .collect()
460}
461
462const NO_START: &str =
465 "no task record says where this work started, so there is nothing to measure it from";
466
467fn debt_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
474 let source = format!(
475 "the debt baselines ({DEBT_DIRECTORY}/*{DEBT_SUFFIX}) at the task's first commit and now"
476 );
477 let (status, evidence) = match closing.map(|c| &c.debt) {
478 None => (GateStatus::Unknown, NO_START.to_string()),
479 Some(Err(why)) => (
480 GateStatus::Unknown,
481 format!("the debt baselines could not be compared: {why}"),
482 ),
483 Some(Ok(baselines)) if baselines.is_empty() => (
484 GateStatus::Exempt,
485 "this repository keeps no debt baseline".to_string(),
486 ),
487 Some(Ok(baselines)) => {
488 let grown: Vec<String> = baselines
489 .iter()
490 .filter(|b| b.grown_by() > 0)
491 .map(|b| {
492 format!(
493 "{} {} -> {} (+{})",
494 b.path,
495 b.before.unwrap_or(0),
496 b.after,
497 b.grown_by()
498 )
499 })
500 .collect();
501 let first: Vec<&str> = baselines
502 .iter()
503 .filter(|b| b.before.is_none())
504 .map(|b| b.path.as_str())
505 .collect();
506 let recorded = if first.is_empty() {
507 String::new()
508 } else {
509 format!("; recorded for the first time: {}", first.join(", "))
510 };
511 if grown.is_empty() {
512 let before: usize = baselines.iter().filter_map(|b| b.before).sum();
513 let after: usize = baselines
514 .iter()
515 .filter(|b| b.before.is_some())
516 .map(|b| b.after)
517 .sum();
518 (
519 GateStatus::Pass,
520 format!(
521 "{} baseline(s) held {before} entries when the task started and hold {after} now{recorded}",
522 baselines.len() - first.len()
523 ),
524 )
525 } else {
526 (
527 GateStatus::Fail,
528 format!(
529 "{} baseline(s) grew: {}{recorded}",
530 grown.len(),
531 grown.join("; ")
532 ),
533 )
534 }
535 }
536 };
537 (status, evidence, source)
538}
539
540fn accumulated_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
546 let source = "git's reflogs: when each local branch and each linked worktree began".to_string();
547 let (status, evidence) = match closing.map(|c| (&c.accumulated, c.since.as_str())) {
548 None => (GateStatus::Unknown, NO_START.to_string()),
549 Some((Err(why), _)) => (
550 GateStatus::Unknown,
551 format!("the branches and worktrees could not be read: {why}"),
552 ),
553 Some((Ok(found), since)) => {
554 let totals = format!(
555 "{} linked worktree(s) and {} local branch(es) now",
556 found.worktrees_now, found.branches_now
557 );
558 let undated = if found.undated == 0 {
559 String::new()
560 } else {
561 format!(
562 "; {} have no creation record and are counted on neither side",
563 found.undated
564 )
565 };
566 if found.worktrees.is_empty() && found.branches.is_empty() {
567 (
568 GateStatus::Pass,
569 format!("nothing created since {since} remains; {totals}{undated}"),
570 )
571 } else {
572 let named: Vec<&str> = found
573 .worktrees
574 .iter()
575 .chain(found.branches.iter())
576 .map(String::as_str)
577 .take(3)
578 .collect();
579 (
580 GateStatus::Queued,
581 format!(
582 "{} worktree(s) and {} branch(es) created since {since} are still here, \
583 among them {}; {totals}{undated}",
584 found.worktrees.len(),
585 found.branches.len(),
586 named.join(", ")
587 ),
588 )
589 }
590 }
591 };
592 (status, evidence, source)
593}
594
595fn backlog_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
597 let source = "the recorded forge observation (majordomus prs refresh)".to_string();
598 let (status, evidence) = match closing.map(|c| (&c.backlog, c.since.as_str())) {
599 None => (GateStatus::Unknown, NO_START.to_string()),
600 Some((Err(why), _)) => (
601 GateStatus::Unknown,
602 format!("the backlog could not be read: {why}"),
603 ),
604 Some((Ok(backlog), since)) => {
605 let observed = format!(
606 "{} open when the forge was observed at {}",
607 backlog.open, backlog.observed_at
608 );
609 if backlog.opened_since.is_empty() {
610 (
611 GateStatus::Pass,
612 format!("no pull request opened since {since} is open; {observed}"),
613 )
614 } else {
615 let named: Vec<String> = backlog
616 .opened_since
617 .iter()
618 .take(5)
619 .map(|n| format!("#{n}"))
620 .collect();
621 (
622 GateStatus::Queued,
623 format!(
624 "{} pull request(s) opened since {since} are still open, among them {}; {observed}",
625 backlog.opened_since.len(),
626 named.join(" ")
627 ),
628 )
629 }
630 }
631 };
632 (status, evidence, source)
633}
634
635fn is_test_path(path: &str) -> bool {
638 let name = path.rsplit('/').next().unwrap_or(path);
639 path.split('/')
640 .any(|seg| matches!(seg, "test" | "tests" | "spec" | "specs"))
641 || name.contains("_test.")
642 || name.contains(".test.")
643 || name.contains("_spec.")
644 || name.contains(".spec.")
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use std::collections::BTreeMap;
651
652 fn implied_of(id: &str, applicable: bool, declared: bool) -> ImpliedObligation {
653 ImpliedObligation {
654 id: id.into(),
655 title: id.into(),
656 applicable,
657 declared,
658 reason: "because".into(),
659 because: vec![],
660 remediation: "run it".into(),
661 }
662 }
663
664 fn gate(id: &str, status: GateStatus, required: bool) -> Gate {
665 Gate {
666 id: id.into(),
667 title: id.into(),
668 scope: "structure".into(),
669 required,
670 status,
671 reason: "r".into(),
672 remediation: "m".into(),
673 source: "s".into(),
674 evidence: None,
675 inputs: vec![],
676 inputs_hash: String::new(),
677 evaluated_at: "now".into(),
678 }
679 }
680
681 fn standing(state: &str) -> ObligationStanding {
682 ObligationStanding {
683 state: state.into(),
684 detail: format!("the closure said {state}"),
685 reproduce: "x".into(),
686 }
687 }
688
689 use crate::gates::closing::{Accumulated, Backlog, DebtReading};
690
691 fn settled() -> Closing {
693 Closing {
694 since: "2026-10-10T12:00:00Z".into(),
695 debt: Ok(vec![DebtReading {
696 path: ".ai/repo/doc-command-baseline.txt".into(),
697 before: Some(4),
698 after: 3,
699 }]),
700 accumulated: Ok(Accumulated {
701 worktrees_now: 2,
702 branches_now: 5,
703 ..Accumulated::default()
704 }),
705 backlog: Ok(Backlog {
706 observed_at: "2026-10-10T15:00:00Z".into(),
707 open: 7,
708 opened_since: Default::default(),
709 }),
710 }
711 }
712
713 fn closing_answers(closing: Option<&Closing>) -> [DoneQuestion; 3] {
714 let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, closing);
715 let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
716 [
717 by("no-new-debt"),
718 by("nothing-accumulated"),
719 by("backlog-not-grown"),
720 ]
721 }
722
723 #[test]
724 fn a_task_that_added_nothing_passes_the_three_closing_questions_with_what_was_read() {
725 let [debt, accumulated, backlog] = closing_answers(Some(&settled()));
726 assert_eq!(debt.status, GateStatus::Pass);
727 assert!(
728 debt.evidence
729 .contains("held 4 entries when the task started and hold 3 now"),
730 "{}",
731 debt.evidence
732 );
733 assert_eq!(accumulated.status, GateStatus::Pass);
734 assert!(accumulated
735 .evidence
736 .contains("nothing created since 2026-10-10T12:00:00Z remains"));
737 assert!(accumulated
738 .evidence
739 .contains("2 linked worktree(s) and 5 local branch(es) now"));
740 assert_eq!(backlog.status, GateStatus::Pass);
741 assert!(backlog
742 .evidence
743 .contains("7 open when the forge was observed at 2026-10-10T15:00:00Z"));
744 }
745
746 #[test]
747 fn a_baseline_that_gained_an_entry_refuses_and_names_the_file_and_the_numbers() {
748 let mut closing = settled();
749 closing.debt = Ok(vec![
750 DebtReading {
751 path: ".ai/repo/a-baseline.txt".into(),
752 before: Some(2),
753 after: 5,
754 },
755 DebtReading {
756 path: ".ai/repo/b-baseline.txt".into(),
757 before: Some(9),
758 after: 1,
759 },
760 DebtReading {
761 path: ".ai/repo/new-baseline.txt".into(),
762 before: None,
763 after: 6,
764 },
765 ]);
766 let [debt, ..] = closing_answers(Some(&closing));
767 assert_eq!(
768 debt.status,
769 GateStatus::Fail,
770 "shrinking one baseline does not pay for growing another"
771 );
772 assert!(
773 debt.evidence
774 .contains("1 baseline(s) grew: .ai/repo/a-baseline.txt 2 -> 5 (+3)"),
775 "{}",
776 debt.evidence
777 );
778 assert!(
779 debt.evidence
780 .contains("recorded for the first time: .ai/repo/new-baseline.txt"),
781 "{}",
782 debt.evidence
783 );
784
785 closing.debt = Ok(vec![DebtReading {
787 path: ".ai/repo/new-baseline.txt".into(),
788 before: None,
789 after: 6,
790 }]);
791 let [debt, ..] = closing_answers(Some(&closing));
792 assert_eq!(debt.status, GateStatus::Pass);
793 assert!(
794 debt.evidence.contains("0 baseline(s) held 0 entries"),
795 "{}",
796 debt.evidence
797 );
798 assert!(
799 debt.evidence.contains("recorded for the first time"),
800 "{}",
801 debt.evidence
802 );
803
804 closing.debt = Ok(vec![]);
806 assert_eq!(
807 closing_answers(Some(&closing))[0].status,
808 GateStatus::Exempt
809 );
810 }
811
812 #[test]
813 fn what_was_created_since_the_start_and_remains_is_owed_and_named() {
814 let mut closing = settled();
815 closing.accumulated = Ok(Accumulated {
816 worktrees: ["/w/feature/a".to_string()].into(),
817 branches: ["feature/a", "feature/b", "feature/c"]
818 .map(String::from)
819 .into(),
820 worktrees_now: 3,
821 branches_now: 8,
822 undated: 2,
823 });
824 let [_, accumulated, _] = closing_answers(Some(&closing));
825 assert_eq!(
826 accumulated.status,
827 GateStatus::Queued,
828 "owed, not refused: the task's own branch is among them"
829 );
830 let said = &accumulated.evidence;
831 assert!(
832 said.contains(
833 "1 worktree(s) and 3 branch(es) created since 2026-10-10T12:00:00Z are still here"
834 ),
835 "{said}"
836 );
837 assert!(
838 said.contains("among them /w/feature/a, feature/a, feature/b;"),
839 "three are named: {said}"
840 );
841 assert!(
842 said.contains("2 have no creation record and are counted on neither side"),
843 "{said}"
844 );
845 }
846
847 #[test]
848 fn a_pull_request_opened_since_the_start_and_still_open_is_owed_and_numbered() {
849 let mut closing = settled();
850 closing.backlog = Ok(Backlog {
851 observed_at: "2026-10-10T15:00:00Z".into(),
852 open: 52,
853 opened_since: [897, 898, 899].into(),
854 });
855 let [.., backlog] = closing_answers(Some(&closing));
856 assert_eq!(backlog.status, GateStatus::Queued);
857 assert!(backlog.evidence.contains("3 pull request(s) opened since 2026-10-10T12:00:00Z are still open, among them #897 #898 #899; 52 open"), "{}", backlog.evidence);
858 }
859
860 #[test]
861 fn a_closing_reading_that_was_not_taken_is_unknown_and_says_why() {
862 for q in closing_answers(None) {
864 assert_eq!(q.status, GateStatus::Unknown, "{}", q.id);
865 assert!(
866 q.evidence
867 .contains("no task record says where this work started"),
868 "{}",
869 q.evidence
870 );
871 }
872 let unread = Closing {
874 since: "2026-10-10T12:00:00Z".into(),
875 debt: Err("the commit the task started at (abc) is not in this repository".into()),
876 accumulated: Err("git could not list the local branches".into()),
877 backlog: Err("the forge has never been observed from this checkout".into()),
878 };
879 let [debt, accumulated, backlog] = closing_answers(Some(&unread));
880 for q in [&debt, &accumulated, &backlog] {
881 assert_eq!(
882 q.status,
883 GateStatus::Unknown,
884 "{}: an unread reading is never a pass",
885 q.id
886 );
887 }
888 assert!(debt.evidence.contains("is not in this repository"));
889 assert!(accumulated
890 .evidence
891 .contains("could not list the local branches"));
892 assert!(backlog.evidence.contains("never been observed"));
893 }
894
895 #[test]
896 fn every_question_carries_a_source_and_something_to_run() {
897 let q = answer(&BTreeMap::new(), &[], &[], &[], false, None, None);
898 assert_eq!(q.len(), 22, "the invariant is twenty-two questions");
899 for question in &q {
900 assert!(!question.source.is_empty(), "{} has no source", question.id);
901 assert!(!question.remediation.is_empty(), "{}", question.id);
902 assert!(!question.evidence.is_empty(), "{}", question.id);
903 assert_ne!(
904 question.status,
905 GateStatus::Pass,
906 "{} passed with nothing behind it",
907 question.id
908 );
909 }
910 }
911
912 #[test]
913 fn an_obligation_answer_is_the_closures_word_and_not_a_second_opinion() {
914 let mut s = BTreeMap::new();
915 s.insert("tests".to_string(), standing("discharged"));
916 s.insert("docs".to_string(), standing("stale"));
917 s.insert("push".to_string(), standing("owed"));
918 let q = answer(&s, &[], &[], &[], true, None, None);
919 let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
920 assert_eq!(by("tested").status, GateStatus::Pass);
921 assert_eq!(by("documented").status, GateStatus::Stale);
922 assert_eq!(by("pushed").status, GateStatus::Queued);
923 assert!(by("tested")
924 .evidence
925 .contains("the closure said discharged"));
926 assert!(by("tested").source.contains("obligations.closure"));
927 }
928
929 #[test]
930 fn an_obligation_the_change_implies_and_nobody_declared_is_a_debt_not_a_pass() {
931 let implied = vec![
932 implied_of("tests", true, false),
933 implied_of("deploy", false, false),
934 ];
935 let q = answer(&BTreeMap::new(), &implied, &[], &[], true, None, None);
936 let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
937 assert_eq!(by("tested").status, GateStatus::Queued);
938 assert!(by("tested").evidence.contains("does not declare it"));
939 assert_eq!(
940 by("deployed").status,
941 GateStatus::Exempt,
942 "a task that deploys nothing is not owed a deployment"
943 );
944 }
945
946 #[test]
947 fn the_ci_question_separates_refused_from_never_reported() {
948 let none = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
949 assert_eq!(
950 none.iter().find(|q| q.id == "ci").unwrap().status,
951 GateStatus::Exempt
952 );
953
954 let silent = vec![
955 gate("a", GateStatus::Queued, true),
956 gate("b", GateStatus::Pass, true),
957 ];
958 let q = answer(&BTreeMap::new(), &[], &silent, &[], true, None, None);
959 assert_eq!(
960 q.iter().find(|q| q.id == "ci").unwrap().status,
961 GateStatus::Queued
962 );
963
964 let red = vec![
965 gate("a", GateStatus::Fail, true),
966 gate("b", GateStatus::Queued, true),
967 ];
968 let q = answer(&BTreeMap::new(), &[], &red, &[], true, None, None);
969 let ci = q.iter().find(|q| q.id == "ci").unwrap();
970 assert_eq!(ci.status, GateStatus::Fail, "a refusal outranks a silence");
971 assert!(ci.evidence.contains("1 refusing") && ci.evidence.contains("1 never reported"));
972
973 let green = vec![
974 gate("a", GateStatus::Pass, true),
975 gate("b", GateStatus::Exempt, false),
976 ];
977 let q = answer(&BTreeMap::new(), &[], &green, &[], true, None, None);
978 assert_eq!(
979 q.iter().find(|q| q.id == "ci").unwrap().status,
980 GateStatus::Pass
981 );
982 }
983
984 #[test]
985 fn a_release_question_a_model_declares_no_gate_for_is_unknown_not_exempt() {
986 let q = answer(
987 &BTreeMap::new(),
988 &[],
989 &[gate("version-surface", GateStatus::Pass, true)],
990 &[],
991 true,
992 None,
993 None,
994 );
995 assert_eq!(
996 q.iter().find(|q| q.id == "version").unwrap().status,
997 GateStatus::Pass
998 );
999 let missing = q.iter().find(|q| q.id == "changelog").unwrap();
1000 assert_eq!(missing.status, GateStatus::Unknown);
1001 assert!(missing.evidence.contains("declares no gate"));
1002 }
1003
1004 #[test]
1005 fn regression_is_answered_from_the_change_set_alone() {
1006 let q = answer(
1007 &BTreeMap::new(),
1008 &[],
1009 &[],
1010 &["lib/a.sh".to_string()],
1011 true,
1012 None,
1013 None,
1014 );
1015 let r = q.iter().find(|q| q.id == "regression-tested").unwrap();
1016 assert_eq!(r.status, GateStatus::Queued);
1017 assert!(r.evidence.contains("no test path"));
1018
1019 let q = answer(
1020 &BTreeMap::new(),
1021 &[],
1022 &[],
1023 &[
1024 "lib/a.sh".to_string(),
1025 "test/cases/131_completion_gates.sh".to_string(),
1026 ],
1027 true,
1028 None,
1029 None,
1030 );
1031 assert_eq!(
1032 q.iter()
1033 .find(|q| q.id == "regression-tested")
1034 .unwrap()
1035 .status,
1036 GateStatus::Pass
1037 );
1038 }
1039
1040 #[test]
1041 fn a_test_path_is_recognised_the_way_the_validator_recognises_one() {
1042 assert!(is_test_path("test/cases/131.sh"));
1043 assert!(is_test_path("apps/majordomus-cli/tests/cli.rs"));
1044 assert!(is_test_path("src/foo_test.rs"));
1045 assert!(is_test_path("web/a.spec.ts"));
1046 assert!(!is_test_path("lib/check.sh"));
1047 assert!(
1048 !is_test_path("docs/latest.md"),
1049 "a substring is not a segment"
1050 );
1051 }
1052
1053 #[test]
1057 fn no_stale_topology_is_answered_from_the_convergence_verdict() {
1058 use crate::convergence::{ConvergenceReport, Disposition, Holding, HoldingKind};
1059
1060 let at_risk = ConvergenceReport {
1061 schema: "convergence/v1".into(),
1062 repository: "/tmp/r".into(),
1063 trunk: Some("master".into()),
1064 holdings: vec![Holding {
1065 kind: HoldingKind::Branch,
1066 identity: "feature/never-pushed".into(),
1067 disposition: Disposition::LocalOnly,
1068 evidence: "abc on no remote".into(),
1069 remedy: "git push -u origin feature/never-pushed".into(),
1070 at_risk: true,
1071 }],
1072 tallies: Default::default(),
1073 at_risk: 1,
1074 converged: false,
1075 };
1076 let q = answer(&BTreeMap::new(), &[], &[], &[], true, Some(&at_risk), None);
1077 let question = q.iter().find(|q| q.id == "no-stale-topology").unwrap();
1078 assert_eq!(question.status, GateStatus::Fail);
1079 assert!(
1080 question.evidence.contains("feature/never-pushed"),
1081 "the finding must name the holding, not only count it: {}",
1082 question.evidence
1083 );
1084 assert_eq!(question.source, "convergence.report");
1085
1086 let converged = ConvergenceReport {
1087 holdings: vec![],
1088 at_risk: 0,
1089 converged: true,
1090 ..at_risk
1091 };
1092 let q = answer(
1093 &BTreeMap::new(),
1094 &[],
1095 &[],
1096 &[],
1097 true,
1098 Some(&converged),
1099 None,
1100 );
1101 assert_eq!(
1102 q.iter()
1103 .find(|q| q.id == "no-stale-topology")
1104 .unwrap()
1105 .status,
1106 GateStatus::Pass
1107 );
1108 }
1109
1110 #[test]
1113 fn an_unreadable_verdict_is_unknown_and_never_a_pass() {
1114 let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
1115 let question = q.iter().find(|q| q.id == "no-stale-topology").unwrap();
1116 assert_eq!(question.status, GateStatus::Unknown);
1117 assert!(!question.status.refuses());
1118 assert!(question.status.unverified());
1119 }
1120
1121 #[test]
1122 fn a_question_nothing_here_reaches_says_so_and_names_the_command() {
1123 let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
1124 for id in ["parity", "handover", "issue"] {
1125 let question = q.iter().find(|q| q.id == id).unwrap();
1126 assert_eq!(question.status, GateStatus::Unknown, "{id}");
1127 assert!(
1128 question.evidence.contains("majordomus"),
1129 "{id} names no command"
1130 );
1131 }
1132 }
1133}