majordomus_cli/intent.rs
1//! Intents: what must become true above the milestones that realise it, and whether the
2//! recorded evidence says it has.
3//!
4//! An intent is a record under `.ai/repo/project/intents/` (kind `intent`, schema
5//! `majordomus.intent/v1`): a statement, the invariants that must stay true, the milestones
6//! that realise it, and the satisfaction criteria that settle it, each naming its evidence.
7//!
8//! **This is a derivation, not a lifecycle.** No stage is stored, nothing here transitions
9//! and nothing here writes. The stage of an intent is a pure function of two things that
10//! already exist: the status [`crate::plan::Plan`] derives for each milestone the intent
11//! names, and the proof state the evidence ledger holds for each criterion's test or
12//! claim. A second task model beside the plan would be a second source of truth about the
13//! same work, which is the defect the plan was built to remove (ADR 0070).
14//!
15//! The stages, in order, each implying the one before:
16//!
17//! | stage | derived when |
18//! |-------------|---------------------------------------------------------------------|
19//! | `declared` | a named milestone does not resolve, or none is named |
20//! | `planned` | every milestone resolves |
21//! | `executing` | any milestone is ACTIVE, VERIFY or DONE |
22//! | `verifying` | every milestone that is not cancelled or superseded is DONE |
23//! | `satisfied` | verifying, and every criterion has current evidence |
24//!
25//! `cancelled` and `superseded` are what the record says happened to it, as for a
26//! milestone. Nothing else is.
27//!
28//! Beside the stage stands the **verdict** (ADR 0107): the evidence question alone, derived
29//! from the criteria and never from the plan. It is `satisfied` when every criterion is met,
30//! `unsatisfied` when a criterion the ledger or the claim join can settle (`test`, `claim`) is
31//! not, and `unknown` when every unmet criterion is of a kind the ledger cannot yet settle
32//! (`command`, `deployment`) or the intent declares none. DONE neither implies it nor is
33//! required for it, so an intent whose criteria all hold while a milestone is open reads a
34//! `satisfied` verdict at stage `executing` — a disagreement the realization reports as drift.
35//!
36//! ```
37//! use majordomus_cli::intent::{IntentStage, IntentVerdictState};
38//! assert_eq!(IntentStage::Satisfied.as_str(), "satisfied");
39//! assert!(IntentStage::Verifying < IntentStage::Satisfied);
40//! assert_eq!(IntentVerdictState::Unknown.as_str(), "unknown");
41//! ```
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::path::{Path, PathBuf};
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48use serde_json::Value;
49
50use crate::evidence::{
51 self, compare, freshness, Comparison, EvidenceReport, Ledger, Presented, ProofState, Recorded,
52 TestId, TreeState,
53};
54use crate::index::Index;
55use crate::intent_plan::{coverage, IntentCoverage, IntentOutline};
56use crate::intent_review::{
57 observed_satisfied, review, CritiqueFinding, CritiqueRecord, GapCondition, GapRecord,
58 ResolutionState,
59};
60use crate::plan::{overlap, Plan};
61
62/// The kind of an intent record.
63pub const INTENT: &str = "intent";
64
65/// The directory intent records live in, repository-relative.
66pub const INTENTS_DIR: &str = ".ai/repo/project/intents";
67
68/// A failure: the intent model is invalid.
69pub const FAIL: &str = "FAIL";
70/// A warning: legal, and worth reading.
71pub const WARN: &str = "WARN";
72
73// ---------------------------------------------------------------- the derived vocabulary
74
75/// Where an intent stands, derived on every read and stored nowhere.
76///
77/// ```
78/// use majordomus_cli::intent::IntentStage;
79/// // one word every surface prints, and an order that follows the work
80/// assert_eq!(IntentStage::Executing.as_str(), "executing");
81/// assert!(IntentStage::Declared < IntentStage::Satisfied);
82/// ```
83#[derive(
84 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
85)]
86#[serde(rename_all = "snake_case")]
87pub enum IntentStage {
88 /// A named milestone does not resolve, or none is named.
89 Declared,
90 /// Every milestone resolves and none has started.
91 Planned,
92 /// A milestone is under way.
93 Executing,
94 /// Every milestone that counts is DONE; satisfaction is not yet evidenced.
95 Verifying,
96 /// Verifying, and every criterion has current evidence.
97 Satisfied,
98 /// The record says it was abandoned.
99 Cancelled,
100 /// The record names the intent that replaced it.
101 Superseded,
102}
103
104impl IntentStage {
105 /// The word every surface prints for this stage: the command line, the API, MCP and the
106 /// generated documentation all say the same one, so no surface spells a stage its own way.
107 ///
108 /// ```
109 /// use majordomus_cli::intent::IntentStage;
110 /// assert_eq!(IntentStage::Declared.as_str(), "declared");
111 /// ```
112 pub fn as_str(self) -> &'static str {
113 match self {
114 IntentStage::Declared => "declared",
115 IntentStage::Planned => "planned",
116 IntentStage::Executing => "executing",
117 IntentStage::Verifying => "verifying",
118 IntentStage::Satisfied => "satisfied",
119 IntentStage::Cancelled => "cancelled",
120 IntentStage::Superseded => "superseded",
121 }
122 }
123}
124
125/// What the evidence behind one criterion says, as a word. Only `Current` meets a criterion:
126/// a stale, failing, unrecorded or underivable reference never does, and neither does one that
127/// resolves to nothing.
128///
129/// Whether a recorded run is current is not decided here. A `test` or `claim` criterion is
130/// judged by the evidence module's one truth table ([`evidence::freshness()`]) at the working
131/// tree and read through [`evidence::current`], the same judgement the claim report and the
132/// subject pages make; [`IntentEvidenceState::judged`] is the whole of the translation. So
133/// the state follows the evidence both ways: a criterion met today reads stale or failing
134/// the moment the ledger or the tree says so, and met again once a current pass is recorded.
135///
136/// ```
137/// use majordomus_cli::intent::IntentEvidenceState;
138/// assert_eq!(serde_json::to_string(&IntentEvidenceState::NotRun).unwrap(), "\"not_run\"");
139/// assert_ne!(IntentEvidenceState::Stale, IntentEvidenceState::Current);
140/// ```
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142#[serde(rename_all = "snake_case")]
143pub enum IntentEvidenceState {
144 /// A passing run that is current evidence at the working tree: its commit is in this
145 /// history, it measured a tree that was its commit, the test still hashes to what ran,
146 /// and nothing the criterion names has changed since — for a test, its source and the
147 /// source and implementation of every claim that test proves; for a claim, what the
148 /// claim names. A change the criterion does not name, the evidence ledger's own included,
149 /// leaves it current. A test that proves no claim naming an implementation names no code
150 /// under test, so for it nothing but the ledger may have changed since the run. The only
151 /// state that meets a criterion; the criterion's `proof` says whether it rests on
152 /// `proven` or on `inputs_unchanged`.
153 Current,
154 /// A pass that is not current evidence: something the criterion names has changed since
155 /// it, it names no code under test and anything but the ledger and the plan's own records
156 /// has changed since, the
157 /// test no longer hashes to what ran, the run's commit is not in this history (or git
158 /// could not compare it), or the run measured a tree that was not its commit — a run on a
159 /// dirty tree, which no later tree can be matched against.
160 Stale,
161 /// The latest recorded run failed, timed out or errored.
162 Failing,
163 /// The reference resolves and no run counts for it: none was recorded, the latest run
164 /// declined to run (a skip), or the claim names no test a runner drives.
165 NotRun,
166 /// The reference resolves, and this kind of evidence (`command`, `deployment`) is not
167 /// derivable from the ledger, so it never meets a criterion by itself.
168 NotDerivable,
169 /// The reference is empty or names nothing this repository holds.
170 Unresolved,
171}
172
173impl IntentEvidenceState {
174 /// The word every surface prints for this state, the same one serde writes.
175 ///
176 /// ```
177 /// use majordomus_cli::intent::IntentEvidenceState;
178 /// assert_eq!(IntentEvidenceState::NotDerivable.as_str(), "not_derivable");
179 /// ```
180 pub fn as_str(self) -> &'static str {
181 match self {
182 IntentEvidenceState::Current => "current",
183 IntentEvidenceState::Stale => "stale",
184 IntentEvidenceState::Failing => "failing",
185 IntentEvidenceState::NotRun => "not_run",
186 IntentEvidenceState::NotDerivable => "not_derivable",
187 IntentEvidenceState::Unresolved => "unresolved",
188 }
189 }
190
191 /// A verdict of the evidence module's truth table, in this vocabulary: current only when
192 /// [`evidence::current`] says so, of a run on the tree `recorded` (the execution's own
193 /// `working_tree`; [`TreeState::Unknown`] when there is none). A pass that is not current
194 /// is stale — including a run on a dirty tree whose inputs read unchanged — a skip and an
195 /// absence are not run, and a failure is failing.
196 ///
197 /// ```
198 /// use majordomus_cli::evidence::{ProofState, TreeState};
199 /// use majordomus_cli::intent::IntentEvidenceState;
200 ///
201 /// let judged = IntentEvidenceState::judged;
202 /// assert_eq!(judged(ProofState::InputsUnchanged, TreeState::Clean), IntentEvidenceState::Current);
203 /// // a run on a tree that was not its commit proves nothing about a later one
204 /// assert_eq!(judged(ProofState::InputsUnchanged, TreeState::Dirty), IntentEvidenceState::Stale);
205 /// assert_eq!(judged(ProofState::Failing, TreeState::Clean), IntentEvidenceState::Failing);
206 /// ```
207 pub fn judged(state: ProofState, recorded: TreeState) -> IntentEvidenceState {
208 if evidence::current(state, recorded, Presented::WorkingTree.tree()) {
209 return IntentEvidenceState::Current;
210 }
211 match state {
212 ProofState::Proven | ProofState::InputsUnchanged | ProofState::Stale => {
213 IntentEvidenceState::Stale
214 }
215 ProofState::Failing => IntentEvidenceState::Failing,
216 ProofState::NotRun | ProofState::Unrunnable | ProofState::NoTest => {
217 IntentEvidenceState::NotRun
218 }
219 }
220 }
221}
222
223/// What the evidence alone says about an intent, as a word: derived from its criteria and
224/// never from the plan, so it can read `satisfied` while a milestone is still open (ADR 0107).
225///
226/// ```
227/// use majordomus_cli::intent::IntentVerdictState;
228/// let word = serde_json::to_string(&IntentVerdictState::Unsatisfied).unwrap();
229/// assert_eq!(word, "\"unsatisfied\"");
230/// assert_eq!(IntentVerdictState::Satisfied.as_str(), "satisfied");
231/// ```
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum IntentVerdictState {
235 /// Every criterion has current evidence.
236 Satisfied,
237 /// A criterion the ledger or the claim join can settle (`test`, `claim`) is not met.
238 Unsatisfied,
239 /// Every unmet criterion is of a kind the ledger cannot yet settle (`command`,
240 /// `deployment`), or the intent declares no criterion: the evidence cannot answer.
241 Unknown,
242}
243
244impl IntentVerdictState {
245 /// The word every surface prints for this verdict, the same one serde writes.
246 ///
247 /// ```
248 /// use majordomus_cli::intent::IntentVerdictState;
249 /// assert_eq!(IntentVerdictState::Unsatisfied.as_str(), "unsatisfied");
250 /// ```
251 pub fn as_str(self) -> &'static str {
252 match self {
253 IntentVerdictState::Satisfied => "satisfied",
254 IntentVerdictState::Unsatisfied => "unsatisfied",
255 IntentVerdictState::Unknown => "unknown",
256 }
257 }
258}
259
260/// One criterion that holds a verdict back from `satisfied`: which one, the kind of evidence
261/// it names, and the state that evidence is in — the criterion's own words, not new ones.
262///
263/// ```
264/// use majordomus_cli::intent::{IntentEvidenceState, IntentVerdictReason};
265/// let r = IntentVerdictReason {
266/// criterion: "probe-passes".into(),
267/// evidence: "test".into(),
268/// state: IntentEvidenceState::NotRun,
269/// };
270/// assert_eq!(r.state, IntentEvidenceState::NotRun);
271/// ```
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273pub struct IntentVerdictReason {
274 /// The criterion's id.
275 pub criterion: String,
276 /// Its kind of evidence: `test`, `claim`, `command` or `deployment`.
277 pub evidence: String,
278 /// The state of that evidence, as the criterion reports it.
279 pub state: IntentEvidenceState,
280}
281
282/// Whether the evidence alone settles an intent, and which criteria hold it back: every
283/// unmet criterion, in the order the record declares them. Empty when the verdict is
284/// `satisfied`, and when it is `unknown` because the intent declares no criterion.
285///
286/// ```
287/// use majordomus_cli::intent::{verdict, IntentVerdict, IntentVerdictState};
288/// // an intent that declares no criterion cannot be settled by evidence
289/// let v: IntentVerdict = verdict(&[]);
290/// assert_eq!(v.state, IntentVerdictState::Unknown);
291/// assert!(v.reasons.is_empty());
292/// ```
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
294pub struct IntentVerdict {
295 /// Derived: what the evidence says.
296 pub state: IntentVerdictState,
297 /// Every criterion that is not met, with the state of its evidence.
298 pub reasons: Vec<IntentVerdictReason>,
299}
300
301/// Whether the ledger or the claim join can settle a criterion of this evidence kind.
302fn ledger_settles(evidence: &str) -> bool {
303 matches!(evidence, "test" | "claim")
304}
305
306/// The verdict of an intent from its criteria alone. Pure: no plan, no index, no ledger — the
307/// criteria already carry what the evidence said.
308///
309/// ```
310/// use majordomus_cli::intent::{verdict, IntentCriterion, IntentEvidenceState, IntentVerdictState};
311/// let c = |evidence: &str, state: IntentEvidenceState| IntentCriterion {
312/// id: evidence.into(), criterion: "c".into(), evidence: evidence.into(),
313/// reference: "r".into(), met: state == IntentEvidenceState::Current, state,
314/// proof: None, reproduce: None,
315/// };
316/// let met = c("test", IntentEvidenceState::Current);
317/// assert_eq!(verdict(&[met.clone()]).state, IntentVerdictState::Satisfied);
318/// // a command the ledger cannot run leaves the evidence unable to answer
319/// let cmd = c("command", IntentEvidenceState::NotDerivable);
320/// assert_eq!(verdict(&[met.clone(), cmd.clone()]).state, IntentVerdictState::Unknown);
321/// // a test the ledger can settle, and has not, says no
322/// let unmet = c("test", IntentEvidenceState::NotRun);
323/// let v = verdict(&[met, cmd, unmet]);
324/// assert_eq!(v.state, IntentVerdictState::Unsatisfied);
325/// assert_eq!(v.reasons.len(), 2);
326/// ```
327pub fn verdict(criteria: &[IntentCriterion]) -> IntentVerdict {
328 let reasons: Vec<IntentVerdictReason> = criteria
329 .iter()
330 .filter(|c| !c.met)
331 .map(|c| IntentVerdictReason {
332 criterion: c.id.clone(),
333 evidence: c.evidence.clone(),
334 state: c.state,
335 })
336 .collect();
337 let state = if criteria.is_empty() {
338 IntentVerdictState::Unknown
339 } else if reasons.is_empty() {
340 IntentVerdictState::Satisfied
341 } else if reasons.iter().any(|r| ledger_settles(&r.evidence)) {
342 IntentVerdictState::Unsatisfied
343 } else {
344 IntentVerdictState::Unknown
345 };
346 IntentVerdict { state, reasons }
347}
348
349// ---------------------------------------------------------------- the record as authored
350
351/// One satisfaction criterion as the record declares it: what must be observably true, the
352/// kind of evidence that settles it, and what that evidence names.
353///
354/// ```
355/// use majordomus_cli::intent::CriterionRecord;
356/// let c = CriterionRecord {
357/// id: "surfaces-agree".into(),
358/// criterion: "the command line, HTTP and MCP answer the same intents".into(),
359/// evidence: "test".into(),
360/// reference: "apps/majordomus-cli/tests/intent.rs".into(),
361/// };
362/// assert_eq!(c.evidence, "test");
363/// ```
364#[derive(Debug, Clone, PartialEq, Eq, Default)]
365pub struct CriterionRecord {
366 /// Unique within the intent.
367 pub id: String,
368 /// What is observably true when the criterion is met.
369 pub criterion: String,
370 /// `test`, `claim`, `command` or `deployment`.
371 pub evidence: String,
372 /// What the evidence names.
373 pub reference: String,
374}
375
376/// One intent as its file declares it: the statement that must become true, the invariants that
377/// must stay true, the milestones that realise it and the criteria that settle it. Nothing
378/// derived is stored here.
379///
380/// ```
381/// use majordomus_cli::intent::IntentRecord;
382/// let r = IntentRecord::from_metadata(".ai/repo/project/intents/x.yaml", &serde_json::json!({
383/// "id": "x", "title": "X", "statement": "It becomes true.", "milestones": ["m"],
384/// "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test", "ref": "t"}],
385/// }));
386/// assert_eq!(r.id, "x");
387/// assert!(!r.cancelled);
388/// ```
389#[derive(Debug, Clone, PartialEq, Eq, Default)]
390pub struct IntentRecord {
391 /// The identity.
392 pub id: String,
393 /// Repository-relative path of the file.
394 pub source: String,
395 /// One line.
396 pub title: String,
397 /// What must become true.
398 pub statement: String,
399 /// What must stay true.
400 pub invariants: Vec<String>,
401 /// The criteria.
402 pub satisfaction: Vec<CriterionRecord>,
403 /// Milestone ids.
404 pub milestones: Vec<String>,
405 /// Governance references.
406 pub governance: Vec<String>,
407 /// What is deliberately not required.
408 pub non_goals: Vec<String>,
409 /// Abandoned.
410 pub cancelled: bool,
411 /// The replacement, when there is one.
412 pub superseded_by: String,
413}
414
415fn text(meta: &Value, key: &str) -> String {
416 meta.get(key)
417 .and_then(Value::as_str)
418 .unwrap_or_default()
419 .to_string()
420}
421
422fn texts(meta: &Value, key: &str) -> Vec<String> {
423 meta.get(key)
424 .and_then(Value::as_array)
425 .map(|a| {
426 a.iter()
427 .filter_map(Value::as_str)
428 .map(str::to_string)
429 .collect()
430 })
431 .unwrap_or_default()
432}
433
434impl IntentRecord {
435 /// The record an object of kind `intent` carries. The schema has already refused a
436 /// malformed file by the time the index holds it, so this reads values, never guesses.
437 ///
438 /// ```
439 /// use majordomus_cli::intent::IntentRecord;
440 /// use serde_json::json;
441 /// let r = IntentRecord::from_metadata(
442 /// ".ai/repo/project/intents/x.yaml",
443 /// &json!({"id": "x", "title": "X", "milestones": ["m"],
444 /// "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test",
445 /// "ref": "test/cases/1_x.sh"}]}),
446 /// );
447 /// assert_eq!(r.milestones, ["m"]);
448 /// assert_eq!(r.satisfaction[0].reference, "test/cases/1_x.sh");
449 /// ```
450 pub fn from_metadata(source: &str, meta: &Value) -> IntentRecord {
451 let satisfaction = meta
452 .get("satisfaction")
453 .and_then(Value::as_array)
454 .map(|a| {
455 a.iter()
456 .map(|c| CriterionRecord {
457 id: text(c, "id"),
458 criterion: text(c, "criterion"),
459 evidence: text(c, "evidence"),
460 reference: text(c, "ref"),
461 })
462 .collect()
463 })
464 .unwrap_or_default();
465 IntentRecord {
466 id: text(meta, "id"),
467 source: source.to_string(),
468 title: text(meta, "title"),
469 statement: text(meta, "statement"),
470 invariants: texts(meta, "invariants"),
471 satisfaction,
472 milestones: texts(meta, "milestones"),
473 governance: texts(meta, "governance"),
474 non_goals: texts(meta, "non_goals"),
475 cancelled: meta
476 .get("cancelled")
477 .and_then(Value::as_bool)
478 .unwrap_or(false),
479 superseded_by: text(meta, "superseded_by"),
480 }
481 }
482
483 /// Every intent record an index holds, in identity order, so nothing downstream depends
484 /// on the order the files were read.
485 ///
486 /// ```
487 /// use majordomus_cli::index::Index;
488 /// use majordomus_cli::intent::IntentRecord;
489 /// # fn demo(index: &Index) {
490 /// let records = IntentRecord::all(index);
491 /// assert!(records.windows(2).all(|w| w[0].id <= w[1].id));
492 /// # }
493 /// ```
494 pub fn all(index: &Index) -> Vec<IntentRecord> {
495 let by_id: BTreeMap<&str, IntentRecord> = index
496 .objects
497 .iter()
498 .filter(|o| o.kind == INTENT)
499 .map(|o| {
500 (
501 o.identity.as_str(),
502 IntentRecord::from_metadata(&o.provenance.path, &o.metadata),
503 )
504 })
505 .collect();
506 by_id.into_values().collect()
507 }
508}
509
510// ---------------------------------------------------------------- where evidence comes from
511
512/// What one test's recorded evidence says: whether its source is in the tree at all, and what
513/// its latest recorded run came to. A test that is not there cannot settle anything.
514///
515/// ```
516/// use majordomus_cli::intent::{IntentEvidenceState, TestStanding};
517/// let standing = TestStanding {
518/// present: false,
519/// state: IntentEvidenceState::Unresolved,
520/// proof: None,
521/// };
522/// assert!(!standing.present);
523/// ```
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct TestStanding {
526 /// Whether the test's source is in the tree.
527 pub present: bool,
528 /// The state of its latest recorded run.
529 pub state: IntentEvidenceState,
530 /// The evidence module's own verdict behind `state`, when the run was judged by it.
531 pub proof: Option<ProofState>,
532}
533
534/// What a declared claim's evidence says: the criterion's state, and the claim report's own
535/// verdict behind it — `proven` and `inputs_unchanged` both meet a criterion, and stay two
536/// answers.
537///
538/// ```
539/// use majordomus_cli::evidence::ProofState;
540/// use majordomus_cli::intent::{ClaimStanding, IntentEvidenceState};
541/// let standing = ClaimStanding {
542/// state: IntentEvidenceState::Current,
543/// proof: ProofState::InputsUnchanged,
544/// };
545/// assert_ne!(standing.proof, ProofState::Proven);
546/// ```
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct ClaimStanding {
549 /// What the claim's evidence means for a criterion.
550 pub state: IntentEvidenceState,
551 /// The claim report's verdict.
552 pub proof: ProofState,
553}
554
555/// The facts the derivation reads beyond the plan. The repository answers them from the
556/// ledger, the claim matrix and the tree; a test answers them from a table.
557///
558/// ```
559/// use majordomus_cli::evidence::TestId;
560/// use majordomus_cli::intent::{ClaimStanding, EvidenceLookup, IntentEvidenceState, TestStanding};
561/// struct NothingRecorded;
562/// impl EvidenceLookup for NothingRecorded {
563/// fn test(&self, _: &TestId, _: &[String]) -> TestStanding {
564/// TestStanding { present: true, state: IntentEvidenceState::NotRun, proof: None }
565/// }
566/// fn claim(&self, _: &str) -> Option<ClaimStanding> { None }
567/// fn deployment(&self, _: &str) -> bool { false }
568/// fn object(&self, _: &str, _: &str) -> bool { false }
569/// fn file(&self, _: &str) -> bool { false }
570/// }
571/// let id = TestId::of("test/cases/00_x.sh").unwrap();
572/// assert_eq!(NothingRecorded.test(&id, &[]).state, IntentEvidenceState::NotRun);
573/// ```
574pub trait EvidenceLookup {
575 /// What the ledger holds for one test, by its identity: whether its source is in the tree
576 /// and what its latest run came to. `scope` is the code the plan declares for the
577 /// criterion — the `scope` of every issue that `serves` it — which the judgement counts
578 /// among the test's inputs beside what the claims that test proves name.
579 ///
580 /// ```
581 /// use majordomus_cli::evidence::TestId;
582 /// use majordomus_cli::intent::{EvidenceLookup, TestStanding};
583 /// # fn demo(ev: &dyn EvidenceLookup) {
584 /// let id = TestId::of("test/cases/00_x.sh").unwrap();
585 /// let standing: TestStanding = ev.test(&id, &["lib/a".to_string()]);
586 /// assert!(standing.present || !standing.present);
587 /// # }
588 /// ```
589 fn test(&self, id: &TestId, scope: &[String]) -> TestStanding;
590 /// What a claim's evidence says, judged as [`IntentEvidenceState::judged`] judges it,
591 /// with the claim report's verdict behind it; `None` when no such claim is declared —
592 /// which is a refusal, not an unproven claim.
593 ///
594 /// ```
595 /// use majordomus_cli::intent::EvidenceLookup;
596 /// # fn demo(ev: &dyn EvidenceLookup) {
597 /// assert!(ev.claim("no-such-claim-exists").is_none());
598 /// # }
599 /// ```
600 fn claim(&self, id: &str) -> Option<ClaimStanding>;
601 /// Whether a deployment object with this id exists. A `deployment` criterion resolves
602 /// through this and is still never counted as met.
603 ///
604 /// ```
605 /// use majordomus_cli::intent::EvidenceLookup;
606 /// # fn demo(ev: &dyn EvidenceLookup) {
607 /// assert!(!ev.deployment("no-such-deployment"));
608 /// # }
609 /// ```
610 fn deployment(&self, id: &str) -> bool;
611 /// Whether an object of `kind` answers to `identity` (`rule` matches with or without
612 /// its `@version`). This is what makes a `governance:` entry resolve or fail.
613 ///
614 /// ```
615 /// use majordomus_cli::intent::EvidenceLookup;
616 /// # fn demo(ev: &dyn EvidenceLookup) {
617 /// assert!(!ev.object("rule", "project.no-such-rule"));
618 /// # }
619 /// ```
620 fn object(&self, kind: &str, identity: &str) -> bool;
621 /// Whether a repository-relative file exists, for a `file:` governance entry and for the
622 /// test sources a criterion names.
623 ///
624 /// ```
625 /// use majordomus_cli::intent::EvidenceLookup;
626 /// # fn demo(ev: &dyn EvidenceLookup) {
627 /// assert!(!ev.file("no/such/file.md"));
628 /// # }
629 /// ```
630 fn file(&self, path: &str) -> bool;
631}
632
633/// The evidence of a real repository: its ledger, its claim matrix joined to that ledger,
634/// its index and its tree, judged at the working tree by the evidence module's own
635/// derivation — [`evidence::report`] for a claim, [`evidence::freshness()`] over the same
636/// [`evidence::compare`] for a test — so a criterion is current exactly when the rest of
637/// Majordomus would call its evidence current. The claim join and the comparisons run git,
638/// so each is computed once, and only when a criterion or a governance entry asks.
639///
640/// ```
641/// use majordomus_cli::index::Index;
642/// use majordomus_cli::intent::{EvidenceLookup, RepositoryEvidence};
643/// # fn demo(index: &Index) {
644/// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
645/// // a file the repository does not hold is answered without running git
646/// assert!(!evidence.file("no/such/file.md"));
647/// # }
648/// ```
649pub struct RepositoryEvidence<'a> {
650 index: &'a Index,
651 root: PathBuf,
652 ledger: Ledger,
653 claims: std::cell::OnceCell<EvidenceReport>,
654 comparisons: std::cell::RefCell<BTreeMap<String, Comparison>>,
655}
656
657impl<'a> RepositoryEvidence<'a> {
658 /// The evidence of the repository an index was built from. A ledger this executable
659 /// cannot read is an error, never an empty ledger: a misread one could report a pass
660 /// that was never recorded.
661 ///
662 /// ```
663 /// use majordomus_cli::index::Index;
664 /// use majordomus_cli::intent::RepositoryEvidence;
665 /// # fn demo(index: &Index) {
666 /// // a ledger that cannot be read is an error here, never an empty ledger
667 /// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
668 /// let _ = &evidence;
669 /// # }
670 /// ```
671 pub fn load(index: &'a Index) -> crate::error::Result<RepositoryEvidence<'a>> {
672 let root = PathBuf::from(&index.repository.root);
673 let ledger = Ledger::load(&root)?;
674 Ok(RepositoryEvidence {
675 index,
676 root,
677 ledger,
678 claims: std::cell::OnceCell::new(),
679 comparisons: std::cell::RefCell::new(BTreeMap::new()),
680 })
681 }
682
683 fn root(&self) -> &Path {
684 &self.root
685 }
686
687 /// The claim join at the working tree, computed once.
688 fn claims(&self) -> &EvidenceReport {
689 self.claims
690 .get_or_init(|| evidence::report(self.index, &self.ledger))
691 }
692
693 /// The comparison of one evidence commit with the working tree, computed once per commit.
694 fn comparison(&self, commit: &str) -> Comparison {
695 self.comparisons
696 .borrow_mut()
697 .entry(commit.to_string())
698 .or_insert_with(|| compare(self.root(), commit, &Presented::WorkingTree))
699 .clone()
700 }
701
702 /// What a test criterion names: the test's own source; the source and implementation of
703 /// every claim that same test proves; and every path, among those `changed` since the run,
704 /// under the `scope` of an issue serving the criterion — the code under test, as the
705 /// repository declares it. A criterion names nothing else, so a change elsewhere, the
706 /// plan's own records included, does not move it.
707 ///
708 /// `None` when no claim that test proves names an implementation and no issue serving the
709 /// criterion declares a scope: the repository then declares nothing about the code under
710 /// test, and the test's own file is not that code. The truth table reads `None` as a route
711 /// that names no inputs (its row 14), so any change since the run, the code under test
712 /// included, leaves the pass stale rather than current; a run at the checkout's own commit
713 /// is still `proven`.
714 fn inputs(
715 &self,
716 id: &TestId,
717 scope: &[String],
718 changed: Option<&BTreeSet<String>>,
719 ) -> Option<Vec<String>> {
720 let test = id.as_string();
721 let mut inputs = vec![id.source()];
722 let mut declared = !scope.is_empty();
723 let mut push = |p: &String| {
724 if !inputs.contains(p) {
725 inputs.push(p.clone());
726 }
727 };
728 for c in &self.claims().claims {
729 if c.test.as_deref() == Some(test.as_str()) {
730 declared |= c.implementation.is_some();
731 [&c.source, &c.implementation]
732 .into_iter()
733 .flatten()
734 .for_each(&mut push);
735 }
736 }
737 changed
738 .into_iter()
739 .flatten()
740 .filter(|p| scope.iter().any(|s| within(p, s)))
741 .for_each(&mut push);
742 declared.then_some(inputs)
743 }
744}
745
746/// Where the plan keeps its records: a change there is a change to the plan, never to the code a
747/// criterion's test exercises.
748const PLAN_RECORDS: &str = ".ai/repo/project";
749
750impl EvidenceLookup for RepositoryEvidence<'_> {
751 fn test(&self, id: &TestId, scope: &[String]) -> TestStanding {
752 let source = id.source();
753 let present = self.root().join(&source).is_file();
754 let execution = self.ledger.latest(&id.as_string());
755 let Some(e) = execution else {
756 return TestStanding {
757 present,
758 state: IntentEvidenceState::NotRun,
759 proof: Some(ProofState::NotRun),
760 };
761 };
762 // the claim report's own reading of a run: a test that no longer hashes to what ran
763 // did not run in the form it is in now, whatever the diff says
764 let moved = e.outcome.proves() && e.digest_matches(self.root()) == Some(false);
765 // the plan's own records are not the code under test: closing the work a criterion is
766 // served by is a change to `.ai/repo/project/`, and it must not stale the pass that
767 // proves the criterion, whether or not the repository declares the code under test
768 let mut comparison = self.comparison(&e.commit);
769 // a comparison git could not make has no list to filter, and stays unknown
770 comparison
771 .changed
772 .iter_mut()
773 .for_each(|changed| changed.retain(|p| !within(p, PLAN_RECORDS)));
774 let inputs = self.inputs(id, scope, comparison.changed.as_ref());
775 let judgement = freshness(
776 Recorded::Ran(e),
777 inputs.as_deref(),
778 &source,
779 moved,
780 Some(&comparison),
781 Presented::WorkingTree.tree(),
782 );
783 TestStanding {
784 present,
785 state: IntentEvidenceState::judged(judgement.state, TreeState::parse(&e.working_tree)),
786 proof: Some(judgement.state),
787 }
788 }
789
790 fn claim(&self, id: &str) -> Option<ClaimStanding> {
791 let c = self.claims().claims.iter().find(|c| c.id == id)?;
792 let recorded = c
793 .execution
794 .as_ref()
795 .map_or(TreeState::Unknown, |e| TreeState::parse(&e.working_tree));
796 Some(ClaimStanding {
797 state: IntentEvidenceState::judged(c.state, recorded),
798 proof: c.state,
799 })
800 }
801
802 fn deployment(&self, id: &str) -> bool {
803 self.object("deployment", id)
804 }
805
806 fn object(&self, kind: &str, identity: &str) -> bool {
807 self.index.objects.iter().any(|o| {
808 o.kind == kind
809 && (o.identity == identity
810 || (kind == "rule"
811 && o.identity
812 .strip_prefix(identity)
813 .is_some_and(|rest| rest.starts_with('@'))))
814 })
815 }
816
817 fn file(&self, path: &str) -> bool {
818 !path.contains("..") && self.root().join(path).exists()
819 }
820}
821
822// ---------------------------------------------------------------- the derived model
823
824/// One milestone an intent names, as the plan derives it. A milestone that does not resolve is
825/// carried with `resolved: false` rather than dropped, because a name that points at nothing is
826/// a finding and not an absence.
827///
828/// ```
829/// use majordomus_cli::intent::IntentMilestone;
830/// let named = IntentMilestone { id: "no-such".into(), resolved: false, status: None };
831/// assert!(!named.resolved);
832/// assert!(named.status.is_none());
833/// ```
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
835pub struct IntentMilestone {
836 /// The id the intent names.
837 pub id: String,
838 /// Whether the plan holds a milestone with that id.
839 pub resolved: bool,
840 /// The status the plan derives for it; absent when it does not resolve.
841 #[serde(skip_serializing_if = "Option::is_none")]
842 pub status: Option<String>,
843}
844
845/// One criterion with the evidence behind it: what the record declared, what the ledger says
846/// about it, whether that meets it, and the command that would produce the evidence again.
847///
848/// ```
849/// use majordomus_cli::evidence::ProofState;
850/// use majordomus_cli::intent::{IntentCriterion, IntentEvidenceState};
851/// let c = IntentCriterion {
852/// id: "stage-is-derived".into(),
853/// criterion: "the stage follows the plan".into(),
854/// evidence: "test".into(),
855/// reference: "apps/majordomus-cli/tests/intent.rs".into(),
856/// state: IntentEvidenceState::NotRun,
857/// met: false,
858/// proof: Some(ProofState::NotRun),
859/// reproduce: None,
860/// };
861/// // nothing recorded is not a pass
862/// assert!(!c.met);
863/// ```
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
865pub struct IntentCriterion {
866 /// Unique within the intent.
867 pub id: String,
868 /// What is observably true when it is met.
869 pub criterion: String,
870 /// `test`, `claim`, `command` or `deployment`.
871 pub evidence: String,
872 /// What the evidence names, as authored.
873 #[serde(rename = "ref")]
874 pub reference: String,
875 /// Derived: what the evidence says.
876 pub state: IntentEvidenceState,
877 /// Derived: the state is `current`.
878 pub met: bool,
879 /// Derived: the evidence module's own verdict behind `state`, for a `test` or `claim`
880 /// criterion whose reference resolves. A met criterion rests on `proven` (a pass at this
881 /// revision) or `inputs_unchanged` (a pass whose named inputs have not moved since, which
882 /// is not proof at this revision), and every surface shows which, so `met` is never the
883 /// one tick for both.
884 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub proof: Option<ProofState>,
886 /// The command that produces the evidence again, when one is known.
887 #[serde(skip_serializing_if = "Option::is_none")]
888 pub reproduce: Option<String>,
889}
890
891/// One intent, as its record declares it and as the plan and the ledger derive it: the
892/// statement and invariants as authored, each milestone with its derived status, each criterion
893/// with the state of its evidence, the stage that follows from both, and the verdict that
894/// follows from the evidence alone.
895///
896/// ```
897/// use majordomus_cli::intent::{verdict, IntentStage, IntentVerdictState, IntentView};
898/// let view = IntentView {
899/// id: "intent-lifecycle".into(),
900/// title: "Work is traceable to the intent it serves".into(),
901/// statement: "A reader can ask what work is for.".into(),
902/// invariants: vec!["no intent status is stored".into()],
903/// stage: IntentStage::Declared,
904/// milestones: vec![],
905/// satisfaction: vec![],
906/// met: 0,
907/// verdict: verdict(&[]),
908/// governance: vec!["adr:adr-0070".into()],
909/// non_goals: vec![],
910/// superseded_by: None,
911/// source: ".ai/repo/project/intents/intent-lifecycle.yaml".into(),
912/// };
913/// // a record naming no milestone that resolves has not been planned
914/// assert_eq!(view.stage, IntentStage::Declared);
915/// // and one declaring no criterion cannot be settled by evidence
916/// assert_eq!(view.verdict.state, IntentVerdictState::Unknown);
917/// ```
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
919pub struct IntentView {
920 /// The identity, which is also the file name.
921 pub id: String,
922 /// One line.
923 pub title: String,
924 /// What must become true.
925 pub statement: String,
926 /// What must stay true.
927 pub invariants: Vec<String>,
928 /// Derived: the stage.
929 pub stage: IntentStage,
930 /// The milestones, each with its derived status.
931 pub milestones: Vec<IntentMilestone>,
932 /// The criteria, each with its evidence state.
933 pub satisfaction: Vec<IntentCriterion>,
934 /// Derived: how many criteria are met.
935 pub met: usize,
936 /// Derived from the criteria alone, never from the plan: whether the evidence settles the
937 /// intent, and which criteria hold it back.
938 pub verdict: IntentVerdict,
939 /// The governance a worker on this intent loads.
940 pub governance: Vec<String>,
941 /// What is deliberately not required.
942 pub non_goals: Vec<String>,
943 /// The replacement, when the record names one.
944 #[serde(skip_serializing_if = "Option::is_none")]
945 pub superseded_by: Option<String>,
946 /// Repository-relative path of the record.
947 pub source: String,
948}
949
950/// One validation finding: its level, a stable code a reader greps for, the intent or milestone
951/// it is about, one line of what is wrong, and the command that shows it again.
952///
953/// ```
954/// use majordomus_cli::intent::{IntentFinding, FAIL};
955/// let f = IntentFinding {
956/// level: FAIL.into(),
957/// code: "unknown_milestone".into(),
958/// subject: "intent-lifecycle".into(),
959/// message: "`no-such` is not a milestone".into(),
960/// reproduce: "majordomus intent validate".into(),
961/// };
962/// assert_eq!(f.level, "FAIL");
963/// assert_eq!(f.reproduce, "majordomus intent validate");
964/// ```
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
966pub struct IntentFinding {
967 /// `FAIL` or `WARN`. A failure means the intent model is invalid.
968 pub level: String,
969 /// The stable code a reader greps for.
970 pub code: String,
971 /// The intent or milestone the finding is about.
972 pub subject: String,
973 /// What is wrong, in one line.
974 pub message: String,
975 /// The command that shows it again.
976 pub reproduce: String,
977}
978
979/// Every intent, derived, with every finding: what `majordomus intent validate` answers out of,
980/// and what the list, the record and the preflight are read from. Nothing here is stored.
981///
982/// ```
983/// use majordomus_cli::intent::Intents;
984/// let empty = Intents { intents: vec![], findings: vec![] };
985/// // a repository that declares no intent is valid, and says so
986/// assert_eq!(empty.failures(), 0);
987/// assert!(empty.intent("intent-lifecycle").is_none());
988/// ```
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
990pub struct Intents {
991 /// Every intent, in identity order.
992 pub intents: Vec<IntentView>,
993 /// Every finding: per intent in identity order, then the milestones no intent serves.
994 pub findings: Vec<IntentFinding>,
995}
996
997/// The command that shows every intent finding again.
998pub const REPRODUCE: &str = "majordomus intent validate";
999
1000fn finding(out: &mut Vec<IntentFinding>, level: &str, code: &str, subject: &str, msg: String) {
1001 out.push(IntentFinding {
1002 level: level.into(),
1003 code: code.into(),
1004 subject: subject.into(),
1005 message: msg,
1006 reproduce: REPRODUCE.into(),
1007 });
1008}
1009
1010/// The milestone statuses that mean nothing is required of the milestone any more.
1011fn retired(status: &str) -> bool {
1012 matches!(status, "CANCELLED" | "SUPERSEDED")
1013}
1014
1015/// The stage of an intent from the facts it rests on. Pure: no index, no ledger.
1016///
1017/// ```
1018/// use majordomus_cli::intent::{stage, IntentStage};
1019/// let st = |s: &str| Some(s.to_string());
1020/// assert_eq!(stage(false, false, &[None], true), IntentStage::Declared);
1021/// assert_eq!(stage(false, false, &[st("PLANNED")], true), IntentStage::Planned);
1022/// assert_eq!(stage(false, false, &[st("ACTIVE"), st("PLANNED")], true), IntentStage::Executing);
1023/// assert_eq!(stage(false, false, &[st("DONE")], false), IntentStage::Verifying);
1024/// assert_eq!(stage(false, false, &[st("DONE")], true), IntentStage::Satisfied);
1025/// ```
1026pub fn stage(
1027 cancelled: bool,
1028 superseded: bool,
1029 milestones: &[Option<String>],
1030 all_met: bool,
1031) -> IntentStage {
1032 if cancelled {
1033 return IntentStage::Cancelled;
1034 }
1035 if superseded {
1036 return IntentStage::Superseded;
1037 }
1038 if milestones.is_empty() || milestones.iter().any(Option::is_none) {
1039 return IntentStage::Declared;
1040 }
1041 let statuses: Vec<&str> = milestones.iter().flatten().map(String::as_str).collect();
1042 let counted: Vec<&str> = statuses.iter().copied().filter(|s| !retired(s)).collect();
1043 if !counted.is_empty() && counted.iter().all(|s| *s == "DONE") {
1044 return if all_met {
1045 IntentStage::Satisfied
1046 } else {
1047 IntentStage::Verifying
1048 };
1049 }
1050 if counted
1051 .iter()
1052 .any(|s| matches!(*s, "ACTIVE" | "VERIFY" | "DONE"))
1053 {
1054 return IntentStage::Executing;
1055 }
1056 IntentStage::Planned
1057}
1058
1059fn criterion_of(
1060 intent: &str,
1061 c: &CriterionRecord,
1062 scope: &[String],
1063 ev: &dyn EvidenceLookup,
1064 findings: &mut Vec<IntentFinding>,
1065) -> IntentCriterion {
1066 let r = c.reference.trim();
1067 let mut reproduce = None;
1068 let mut proof = None;
1069 let unresolved = |findings: &mut Vec<IntentFinding>, why: String| {
1070 finding(
1071 findings,
1072 FAIL,
1073 "unresolved_evidence_ref",
1074 intent,
1075 format!("criterion `{}`: {why}", c.id),
1076 );
1077 IntentEvidenceState::Unresolved
1078 };
1079 let state = if r.is_empty() {
1080 finding(
1081 findings,
1082 FAIL,
1083 "criterion_without_ref",
1084 intent,
1085 format!(
1086 "criterion `{}` names no {} evidence, so nothing could ever settle it",
1087 c.id, c.evidence
1088 ),
1089 );
1090 IntentEvidenceState::Unresolved
1091 } else {
1092 match c.evidence.as_str() {
1093 "test" => match test_id(r) {
1094 None => unresolved(
1095 findings,
1096 format!(
1097 "`{r}` names no test: give a path under test/cases/ or \
1098 apps/majordomus-cli/tests/, or `suite:<case>` / `crate:<binary>`"
1099 ),
1100 ),
1101 Some(id) => {
1102 let standing = ev.test(&id, scope);
1103 if standing.present {
1104 reproduce = Some(id.reproduce());
1105 proof = standing.proof;
1106 standing.state
1107 } else {
1108 unresolved(
1109 findings,
1110 format!("the test `{r}` is not in the tree ({})", id.source()),
1111 )
1112 }
1113 }
1114 },
1115 "claim" => match ev.claim(r) {
1116 None => unresolved(
1117 findings,
1118 format!("`{r}` is not a claim of docs/CLAIMS.yaml"),
1119 ),
1120 Some(standing) => {
1121 proof = Some(standing.proof);
1122 standing.state
1123 }
1124 },
1125 "deployment" => {
1126 if ev.deployment(r) {
1127 IntentEvidenceState::NotDerivable
1128 } else {
1129 unresolved(
1130 findings,
1131 format!("`{r}` is not a deployment of this repository"),
1132 )
1133 }
1134 }
1135 // `command`: the schema admits nothing else
1136 _ => {
1137 reproduce = Some(r.to_string());
1138 IntentEvidenceState::NotDerivable
1139 }
1140 }
1141 };
1142 IntentCriterion {
1143 id: c.id.clone(),
1144 criterion: c.criterion.clone(),
1145 evidence: c.evidence.clone(),
1146 reference: c.reference.clone(),
1147 met: state == IntentEvidenceState::Current,
1148 state,
1149 proof,
1150 reproduce,
1151 }
1152}
1153
1154/// The code the plan declares for one criterion: the `scope` of every issue that `serves`
1155/// `<intent>#<criterion>`, in plan order, each path once.
1156fn serving_scope(plan: &Plan, intent: &str, criterion: &str) -> Vec<String> {
1157 let link = format!("{intent}#{criterion}");
1158 let mut scope: Vec<String> = Vec::new();
1159 for i in plan
1160 .issues
1161 .iter()
1162 .filter(|i| i.serves.iter().any(|s| s.trim() == link))
1163 {
1164 for p in &i.scope {
1165 let p = p.trim().trim_end_matches('/').to_string();
1166 if !p.is_empty() && !scope.contains(&p) {
1167 scope.push(p);
1168 }
1169 }
1170 }
1171 scope
1172}
1173
1174/// Whether a repository path is a declared scope entry or lies under it; `.` is the whole tree.
1175fn within(path: &str, scope: &str) -> bool {
1176 scope == "."
1177 || path == scope
1178 || path
1179 .strip_prefix(scope)
1180 .is_some_and(|rest| rest.starts_with('/'))
1181}
1182
1183fn test_id(r: &str) -> Option<TestId> {
1184 TestId::of(r).or_else(|| {
1185 let (prefix, name) = r.split_once(':')?;
1186 let runner = match prefix {
1187 "suite" => evidence::Runner::Suite,
1188 "crate" => evidence::Runner::Crate,
1189 _ => return None,
1190 };
1191 TestId::named(runner, name)
1192 })
1193}
1194
1195fn governance_resolves(g: &str, ev: &dyn EvidenceLookup) -> bool {
1196 let Some((kind, what)) = g.split_once(':') else {
1197 return false;
1198 };
1199 match kind {
1200 "rule" => ev.object("rule", what),
1201 "adr" => ev.object("adr", what),
1202 "claim" => ev.claim(what).is_some(),
1203 "file" => ev.file(what),
1204 _ => false,
1205 }
1206}
1207
1208/// What the planning half needs of each record: its criteria, its milestones, whether it is
1209/// retired, and the criteria its recorded gap observed already satisfied.
1210///
1211/// ```
1212/// use majordomus_cli::intent::{outlines, IntentRecord};
1213/// use serde_json::json;
1214/// let r = IntentRecord::from_metadata(".ai/repo/project/intents/x.yaml", &json!({
1215/// "id": "x", "milestones": ["m"],
1216/// "satisfaction": [{"id": "c", "evidence": "test", "ref": "t"}]}));
1217/// let o = outlines(std::slice::from_ref(&r), &[]);
1218/// assert_eq!(o[0].criteria, ["c"]);
1219/// assert!(!o[0].retired);
1220/// ```
1221pub fn outlines(records: &[IntentRecord], gaps: &[GapRecord]) -> Vec<IntentOutline> {
1222 let observed = observed_satisfied(gaps);
1223 records
1224 .iter()
1225 .map(|r| IntentOutline {
1226 id: r.id.clone(),
1227 criteria: r.satisfaction.iter().map(|c| c.id.clone()).collect(),
1228 milestones: r.milestones.clone(),
1229 retired: r.cancelled || !r.superseded_by.is_empty(),
1230 observed_satisfied: observed.get(&r.id).cloned().unwrap_or_default(),
1231 })
1232 .collect()
1233}
1234
1235impl Intents {
1236 /// Derive every intent of an index against its plan and its evidence. Nothing is read
1237 /// but what the three already hold, and nothing is written.
1238 ///
1239 /// The findings are the record's own (this module) followed by the planning half's
1240 /// (ADR 0073): whether the plan carries every criterion, why each issue exists, and
1241 /// whether the recorded gap and critique hold and were made before execution.
1242 ///
1243 /// ```
1244 /// use majordomus_cli::index::Index;
1245 /// use majordomus_cli::intent::{Intents, RepositoryEvidence};
1246 /// use majordomus_cli::plan::Plan;
1247 /// # fn demo(index: &Index) {
1248 /// let plan = Plan::build(index);
1249 /// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
1250 /// let intents = Intents::build(index, &plan, &evidence);
1251 /// // the record's findings, then the coverage of its criteria by the plan
1252 /// assert!(intents.failures() >= intents.findings.iter().filter(|f| f.level == "FAIL").count());
1253 /// # }
1254 /// ```
1255 pub fn build(index: &Index, plan: &Plan, ev: &dyn EvidenceLookup) -> Intents {
1256 let records = IntentRecord::all(index);
1257 let gaps = GapRecord::all(index);
1258 let critiques = CritiqueRecord::all(index);
1259 let outlines = outlines(&records, &gaps);
1260 let mut out = Intents::derive(records, plan, ev);
1261 out.findings.extend(coverage(&outlines, plan).findings);
1262 out.findings
1263 .extend(review(&outlines, plan, &gaps, &critiques));
1264 out
1265 }
1266
1267 /// The coverage of every criterion by the plan, and the reason every issue exists: the
1268 /// same derivation [`Intents::build`] reports findings from, answered on its own so a
1269 /// reader can ask which work carries which criterion (ADR 0073).
1270 ///
1271 /// ```
1272 /// use majordomus_cli::index::Index;
1273 /// use majordomus_cli::intent::Intents;
1274 /// use majordomus_cli::plan::Plan;
1275 /// # fn demo(index: &Index) {
1276 /// let plan = Plan::build(index);
1277 /// let coverage = Intents::coverage(index, &plan);
1278 /// // every issue of the plan answers why it exists, including the maintenance ones
1279 /// assert_eq!(coverage.issues.len(), plan.issues.len());
1280 /// # }
1281 /// ```
1282 pub fn coverage(index: &Index, plan: &Plan) -> IntentCoverage {
1283 let records = IntentRecord::all(index);
1284 coverage(&outlines(&records, &GapRecord::all(index)), plan)
1285 }
1286
1287 /// Derive from records already read: what [`Intents::build`] does after reading them,
1288 /// and what a test calls with records it wrote.
1289 ///
1290 /// ```
1291 /// use majordomus_cli::evidence::TestId;
1292 /// use majordomus_cli::intent::{
1293 /// ClaimStanding, EvidenceLookup, IntentEvidenceState, IntentRecord, Intents,
1294 /// TestStanding,
1295 /// };
1296 /// use majordomus_cli::plan::Plan;
1297 /// struct NothingRecorded;
1298 /// impl EvidenceLookup for NothingRecorded {
1299 /// fn test(&self, _: &TestId, _: &[String]) -> TestStanding {
1300 /// TestStanding { present: true, state: IntentEvidenceState::NotRun, proof: None }
1301 /// }
1302 /// fn claim(&self, _: &str) -> Option<ClaimStanding> { None }
1303 /// fn deployment(&self, _: &str) -> bool { false }
1304 /// fn object(&self, _: &str, _: &str) -> bool { false }
1305 /// fn file(&self, _: &str) -> bool { false }
1306 /// }
1307 /// # let plan: Plan = serde_json::from_value(serde_json::json!({
1308 /// # "project": {"name": "p", "repository": "o/p", "default_branch": "master",
1309 /// # "active_milestone": ""},
1310 /// # "statuses": {"issue": [], "milestone": []},
1311 /// # "milestones": [], "issues": [], "waves": [], "edges": [],
1312 /// # "milestone_edges": [], "findings": []})).unwrap();
1313 /// let record = IntentRecord::from_metadata(
1314 /// ".ai/repo/project/intents/x.yaml",
1315 /// &serde_json::json!({"id": "x", "title": "X", "milestones": ["absent"],
1316 /// "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test",
1317 /// "ref": "test/cases/00_x.sh"}]}),
1318 /// );
1319 /// let intents = Intents::derive(vec![record], &plan, &NothingRecorded);
1320 /// // the milestone it names is not in the plan, and that is a failure by name
1321 /// assert!(intents.findings.iter().any(|f| f.code == "unknown_milestone"));
1322 /// ```
1323 pub fn derive(records: Vec<IntentRecord>, plan: &Plan, ev: &dyn EvidenceLookup) -> Intents {
1324 let mut findings = Vec::new();
1325 let ids: BTreeSet<&str> = records.iter().map(|r| r.id.as_str()).collect();
1326 let mut served: BTreeSet<String> = BTreeSet::new();
1327 let mut intents = Vec::with_capacity(records.len());
1328
1329 for r in &records {
1330 let expected = format!("{INTENTS_DIR}/{}.yaml", r.id);
1331 if !r.source.is_empty() && r.source != expected {
1332 finding(
1333 &mut findings,
1334 FAIL,
1335 "file_name_mismatch",
1336 &r.id,
1337 format!("the record is at {} and its id says {expected}", r.source),
1338 );
1339 }
1340 // A proposal names neither a milestone nor a criterion yet (ADR 0144): its stage is
1341 // `declared`, nothing can serve it, and the two absences are warnings until it is
1342 // planned. An intent that names one and not the other is half planned, and each
1343 // absence stays a failure.
1344 let proposed = r.milestones.is_empty() && r.satisfaction.is_empty();
1345 let unplanned = if proposed { WARN } else { FAIL };
1346 if r.milestones.is_empty() {
1347 finding(
1348 &mut findings,
1349 unplanned,
1350 "intent_without_milestone",
1351 &r.id,
1352 if proposed {
1353 "a proposed intent: it names no milestone yet, so nothing realises it and no work may serve it until it is planned".into()
1354 } else {
1355 "the intent names no milestone, so nothing realises it".into()
1356 },
1357 );
1358 }
1359 let milestones: Vec<IntentMilestone> = r
1360 .milestones
1361 .iter()
1362 .map(|m| {
1363 served.insert(m.clone());
1364 let status = plan.milestone(m).map(|pm| pm.status.clone());
1365 if status.is_none() {
1366 finding(
1367 &mut findings,
1368 FAIL,
1369 "unknown_milestone",
1370 &r.id,
1371 format!("`{m}` is not a milestone under .ai/repo/project/milestones/"),
1372 );
1373 }
1374 IntentMilestone {
1375 id: m.clone(),
1376 resolved: status.is_some(),
1377 status,
1378 }
1379 })
1380 .collect();
1381
1382 let mut seen = BTreeSet::new();
1383 for c in &r.satisfaction {
1384 if !seen.insert(c.id.as_str()) {
1385 finding(
1386 &mut findings,
1387 FAIL,
1388 "duplicate_criterion",
1389 &r.id,
1390 format!("criterion `{}` is declared twice", c.id),
1391 );
1392 }
1393 }
1394 if r.satisfaction.is_empty() {
1395 finding(
1396 &mut findings,
1397 unplanned,
1398 "intent_without_criterion",
1399 &r.id,
1400 if proposed {
1401 "a proposed intent: it declares no satisfaction criterion yet, so it cannot be satisfied until it is planned".into()
1402 } else {
1403 "the intent declares no satisfaction criterion, so it can never be satisfied"
1404 .into()
1405 },
1406 );
1407 }
1408 let satisfaction: Vec<IntentCriterion> = r
1409 .satisfaction
1410 .iter()
1411 .map(|c| {
1412 let scope = serving_scope(plan, &r.id, &c.id);
1413 criterion_of(&r.id, c, &scope, ev, &mut findings)
1414 })
1415 .collect();
1416
1417 for g in &r.governance {
1418 if !governance_resolves(g, ev) {
1419 finding(
1420 &mut findings,
1421 FAIL,
1422 "unresolved_governance",
1423 &r.id,
1424 format!("governance `{g}` names nothing this repository holds"),
1425 );
1426 }
1427 }
1428 if !r.superseded_by.is_empty() && !ids.contains(r.superseded_by.as_str()) {
1429 finding(
1430 &mut findings,
1431 FAIL,
1432 "unknown_successor",
1433 &r.id,
1434 format!("superseded_by `{}` is not an intent", r.superseded_by),
1435 );
1436 }
1437
1438 let met = satisfaction.iter().filter(|c| c.met).count();
1439 let all_met = !satisfaction.is_empty() && met == satisfaction.len();
1440 let statuses: Vec<Option<String>> =
1441 milestones.iter().map(|m| m.status.clone()).collect();
1442 intents.push(IntentView {
1443 id: r.id.clone(),
1444 title: r.title.clone(),
1445 statement: r.statement.clone(),
1446 invariants: r.invariants.clone(),
1447 stage: stage(r.cancelled, !r.superseded_by.is_empty(), &statuses, all_met),
1448 milestones,
1449 verdict: verdict(&satisfaction),
1450 satisfaction,
1451 met,
1452 governance: r.governance.clone(),
1453 non_goals: r.non_goals.clone(),
1454 superseded_by: (!r.superseded_by.is_empty()).then(|| r.superseded_by.clone()),
1455 source: r.source.clone(),
1456 });
1457 }
1458
1459 for m in &plan.milestones {
1460 if !served.contains(&m.id) && !retired(&m.status) {
1461 finding(
1462 &mut findings,
1463 WARN,
1464 "milestone_serves_no_intent",
1465 &m.id,
1466 format!("the milestone is {} and no intent names it", m.status),
1467 );
1468 }
1469 }
1470 Intents { intents, findings }
1471 }
1472
1473 /// One intent by id, or `None` when this repository declares no such intent — which the
1474 /// capability turns into a refusal naming what it looked for.
1475 ///
1476 /// ```
1477 /// use majordomus_cli::intent::Intents;
1478 /// let intents = Intents { intents: vec![], findings: vec![] };
1479 /// assert!(intents.intent("absent").is_none());
1480 /// ```
1481 pub fn intent(&self, id: &str) -> Option<&IntentView> {
1482 self.intents.iter().find(|i| i.id == id)
1483 }
1484
1485 /// How many of the findings are failures rather than warnings: the count that decides
1486 /// whether the model is valid and whether `intent validate` exits 10.
1487 ///
1488 /// ```
1489 /// use majordomus_cli::intent::Intents;
1490 /// assert_eq!(Intents { intents: vec![], findings: vec![] }.failures(), 0);
1491 /// ```
1492 pub fn failures(&self) -> usize {
1493 self.findings.iter().filter(|f| f.level == FAIL).count()
1494 }
1495
1496 /// How many of the findings are warnings: reported, never fatal — a milestone no intent
1497 /// serves, an intent nobody has planned yet.
1498 ///
1499 /// ```
1500 /// use majordomus_cli::intent::Intents;
1501 /// assert_eq!(Intents { intents: vec![], findings: vec![] }.warnings(), 0);
1502 /// ```
1503 pub fn warnings(&self) -> usize {
1504 self.findings.iter().filter(|f| f.level == WARN).count()
1505 }
1506
1507 /// The intents that name a milestone, in identity order: the intents whose plan an issue
1508 /// under that milestone is part of, whatever it declares it serves.
1509 ///
1510 /// ```
1511 /// use majordomus_cli::intent::Intents;
1512 /// let intents = Intents { intents: vec![], findings: vec![] };
1513 /// // a milestone no intent names carries maintenance work
1514 /// assert!(intents.serving("fixture-milestone").is_empty());
1515 /// ```
1516 pub fn serving(&self, milestone: &str) -> Vec<&IntentView> {
1517 self.intents
1518 .iter()
1519 .filter(|i| i.milestones.iter().any(|m| m.id == milestone))
1520 .collect()
1521 }
1522}
1523
1524// ---------------------------------------------------------------- preflight
1525
1526/// What a preflight concluded, about one issue or about the whole piece of work.
1527///
1528/// ```
1529/// use majordomus_cli::intent::IntentPreflightVerdict;
1530/// // the words every surface answers with
1531/// assert_eq!(
1532/// serde_json::to_string(&IntentPreflightVerdict::Maintenance).unwrap(),
1533/// "\"maintenance\""
1534/// );
1535/// assert_ne!(IntentPreflightVerdict::Serves, IntentPreflightVerdict::Refused);
1536/// ```
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1538#[serde(rename_all = "snake_case")]
1539pub enum IntentPreflightVerdict {
1540 /// The work serves at least one criterion of a live intent, every link holds, and the
1541 /// plan of every intent it serves was critiqued with no blocking finding left open.
1542 Serves,
1543 /// The work sits under milestones no live intent names and serves no criterion of a live
1544 /// intent: operational work, allowed where `majordomus intent validate` allows it.
1545 Maintenance,
1546 /// A link is missing or points at nothing, or an intent it serves may not be executed yet.
1547 Refused,
1548}
1549
1550/// Why a preflight refused, as a word a reader greps for.
1551///
1552/// ```
1553/// use majordomus_cli::intent::IntentPreflightCause;
1554/// assert_eq!(
1555/// serde_json::to_string(&IntentPreflightCause::OpenBlockingFinding).unwrap(),
1556/// "\"open_blocking_finding\""
1557/// );
1558/// assert_ne!(
1559/// IntentPreflightCause::IntentNotCritiqued,
1560/// IntentPreflightCause::IssueServesNothing
1561/// );
1562/// ```
1563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1564#[serde(rename_all = "snake_case")]
1565pub enum IntentPreflightCause {
1566 /// The issue named is not an issue of the plan.
1567 UnknownIssue,
1568 /// No open issue's scope covers any of the paths.
1569 NoIssueCoversPaths,
1570 /// The issue's milestone realises a live intent and the issue serves none of its criteria.
1571 IssueServesNothing,
1572 /// The issue serves a criterion of an intent whose milestones do not include its own.
1573 ServesAnotherIntent,
1574 /// The issue serves an intent or a criterion that does not exist, or a token that is not
1575 /// `<intent>#<criterion>`.
1576 ServesUnknownCriterion,
1577 /// An intent the issue serves has no critique: its plan was never reviewed.
1578 IntentNotCritiqued,
1579 /// The critique of an intent the issue serves has a blocking finding still open.
1580 OpenBlockingFinding,
1581}
1582
1583/// One reason a preflight refused: which issue, the cause, and one sentence of what is wrong.
1584///
1585/// ```
1586/// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightRefusal};
1587/// let r = IntentPreflightRefusal::new(
1588/// Some("I1900"),
1589/// IntentPreflightCause::IntentNotCritiqued,
1590/// "intent x has no critique".into(),
1591/// );
1592/// assert_eq!(r.cause, IntentPreflightCause::IntentNotCritiqued);
1593/// assert_eq!(r.issue.as_deref(), Some("I1900"));
1594/// ```
1595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1596pub struct IntentPreflightRefusal {
1597 /// The issue it is about; absent when no issue was resolved at all.
1598 #[serde(skip_serializing_if = "Option::is_none")]
1599 pub issue: Option<String>,
1600 /// The cause.
1601 pub cause: IntentPreflightCause,
1602 /// What is wrong, in one sentence.
1603 pub message: String,
1604}
1605
1606impl IntentPreflightRefusal {
1607 /// A refusal about `issue`, or about no issue when none was resolved.
1608 ///
1609 /// ```
1610 /// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightRefusal};
1611 /// let r = IntentPreflightRefusal::new(None, IntentPreflightCause::UnknownIssue, "x".into());
1612 /// assert!(r.issue.is_none());
1613 /// ```
1614 pub fn new(issue: Option<&str>, cause: IntentPreflightCause, message: String) -> Self {
1615 IntentPreflightRefusal {
1616 issue: issue.map(str::to_string),
1617 cause,
1618 message,
1619 }
1620 }
1621}
1622
1623/// One issue the work resolved to, and what the preflight concluded about it.
1624///
1625/// ```
1626/// use majordomus_cli::intent::{IntentPreflightIssue, IntentPreflightVerdict};
1627/// let i = IntentPreflightIssue {
1628/// issue: "I0007".into(),
1629/// milestone: "ops".into(),
1630/// verdict: IntentPreflightVerdict::Maintenance,
1631/// serves: vec![],
1632/// intents: vec![],
1633/// };
1634/// // a milestone no intent names carries maintenance, which is not a refusal
1635/// assert_eq!(i.verdict, IntentPreflightVerdict::Maintenance);
1636/// ```
1637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1638pub struct IntentPreflightIssue {
1639 /// The issue id.
1640 pub issue: String,
1641 /// Its milestone.
1642 pub milestone: String,
1643 /// What the preflight concluded about this issue.
1644 pub verdict: IntentPreflightVerdict,
1645 /// The `<intent>#<criterion>` references it declares, as declared.
1646 pub serves: Vec<String>,
1647 /// The live intents whose milestones include its milestone, sorted.
1648 pub intents: Vec<String>,
1649}
1650
1651/// One intent a piece of work serves, and the link that says so: issue to the criteria it
1652/// declares in `serves`, each criterion's intent naming the issue's milestone.
1653///
1654/// ```
1655/// use majordomus_cli::intent::{IntentPreflightMatch, IntentStage};
1656/// let m = IntentPreflightMatch {
1657/// intent: "intent-lifecycle".into(),
1658/// title: "Work is traceable to the intent it serves".into(),
1659/// stage: IntentStage::Executing,
1660/// milestone: "intent-lifecycle".into(),
1661/// issue: "I1900".into(),
1662/// criteria: vec!["stage-is-derived".into()],
1663/// };
1664/// assert_eq!(m.issue, "I1900");
1665/// ```
1666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1667pub struct IntentPreflightMatch {
1668 /// The intent.
1669 pub intent: String,
1670 /// Its title.
1671 pub title: String,
1672 /// Its derived stage.
1673 pub stage: IntentStage,
1674 /// The milestone that links the work to it.
1675 pub milestone: String,
1676 /// The issue that links the work to that milestone.
1677 pub issue: String,
1678 /// The criteria of the intent the issue declares it serves, in the intent's order.
1679 pub criteria: Vec<String>,
1680}
1681
1682/// The critique of an intent as a preflight reads it: where it is, when and by whom the plan
1683/// was reviewed, and the blocking findings still open.
1684///
1685/// ```
1686/// use majordomus_cli::intent::IntentPreflightCritique;
1687/// let c = IntentPreflightCritique {
1688/// source: ".ai/repo/project/critiques/x.yaml".into(),
1689/// reviewed_at: "c3f20da".into(),
1690/// reviewed_by: "another session".into(),
1691/// open_blocking: vec![],
1692/// };
1693/// assert!(c.open_blocking.is_empty());
1694/// ```
1695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1696pub struct IntentPreflightCritique {
1697 /// Repository-relative path of the record.
1698 pub source: String,
1699 /// The commit the plan was reviewed at.
1700 pub reviewed_at: String,
1701 /// Who or what reviewed it.
1702 pub reviewed_by: String,
1703 /// Every blocking finding whose resolution is `open`; work may not start while one is.
1704 pub open_blocking: Vec<CritiqueFinding>,
1705}
1706
1707/// The recorded gap of an intent as a preflight reads it, bounded to the criteria the work
1708/// serves: what was observed of each, at which commit, and the risks.
1709///
1710/// ```
1711/// use majordomus_cli::intent::IntentPreflightGap;
1712/// let g = IntentPreflightGap {
1713/// source: ".ai/repo/project/gaps/x.yaml".into(),
1714/// observed_at: "c3f20da".into(),
1715/// conditions: vec![],
1716/// risks: vec!["the schema may refuse old records".into()],
1717/// };
1718/// assert_eq!(g.risks.len(), 1);
1719/// ```
1720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1721pub struct IntentPreflightGap {
1722 /// Repository-relative path of the record.
1723 pub source: String,
1724 /// The commit the observations were made on.
1725 pub observed_at: String,
1726 /// The gap's answer for each criterion the work serves, in the gap's order.
1727 pub conditions: Vec<GapCondition>,
1728 /// The risks it recorded.
1729 pub risks: Vec<String>,
1730}
1731
1732/// One intent the work is held to, with what a worker needs before touching it: the statement,
1733/// the criteria the work serves with the live state of their evidence, the invariants and
1734/// non-goals, the governance, the critique and the gap. Only the intents the work reaches are
1735/// carried, never every intent.
1736///
1737/// ```
1738/// use majordomus_cli::intent::{IntentPreflightIntent, IntentStage};
1739/// let i = IntentPreflightIntent {
1740/// id: "x".into(),
1741/// title: "X".into(),
1742/// stage: IntentStage::Planned,
1743/// statement: "It becomes true.".into(),
1744/// criteria: vec![],
1745/// invariants: vec![],
1746/// non_goals: vec![],
1747/// governance: vec![],
1748/// critique: None,
1749/// gap: None,
1750/// };
1751/// // no critique recorded is carried as an absence, never as an empty review
1752/// assert!(i.critique.is_none());
1753/// ```
1754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1755pub struct IntentPreflightIntent {
1756 /// The identity.
1757 pub id: String,
1758 /// One line.
1759 pub title: String,
1760 /// Its derived stage.
1761 pub stage: IntentStage,
1762 /// What must become true.
1763 pub statement: String,
1764 /// The criteria the work serves, each with the state of its evidence now, in the intent's
1765 /// order.
1766 pub criteria: Vec<IntentCriterion>,
1767 /// What must stay true.
1768 pub invariants: Vec<String>,
1769 /// What is deliberately not required.
1770 pub non_goals: Vec<String>,
1771 /// The governance a worker on this intent loads.
1772 pub governance: Vec<String>,
1773 /// The critique of its plan; absent when none was ever recorded.
1774 #[serde(skip_serializing_if = "Option::is_none")]
1775 pub critique: Option<IntentPreflightCritique>,
1776 /// Its recorded gap, bounded to the criteria above; absent when none was recorded.
1777 #[serde(skip_serializing_if = "Option::is_none")]
1778 pub gap: Option<IntentPreflightGap>,
1779}
1780
1781/// Which intent a piece of work serves, whether it is maintenance, or why it may not proceed:
1782/// the issues it resolved to with a verdict each, every intent reached with its link, what
1783/// those intents require of the worker, and every refusal with its cause.
1784///
1785/// ```
1786/// use majordomus_cli::intent::{
1787/// IntentPreflight, IntentPreflightCause, IntentPreflightRefusal, IntentPreflightVerdict,
1788/// };
1789/// let refused = IntentPreflight {
1790/// verdict: IntentPreflightVerdict::Refused,
1791/// issues: vec![],
1792/// matches: vec![],
1793/// intents: vec![],
1794/// governance: vec![],
1795/// refusals: vec![IntentPreflightRefusal::new(
1796/// None,
1797/// IntentPreflightCause::NoIssueCoversPaths,
1798/// "no open issue's scope covers README.md".into(),
1799/// )],
1800/// refusal: Some("no open issue's scope covers README.md".into()),
1801/// };
1802/// assert_eq!(refused.verdict, IntentPreflightVerdict::Refused);
1803/// assert_eq!(refused.refusals[0].cause, IntentPreflightCause::NoIssueCoversPaths);
1804/// ```
1805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1806pub struct IntentPreflight {
1807 /// `serves`, `maintenance` or `refused`: refused when any issue is, else serves when any
1808 /// issue serves an intent, else maintenance.
1809 pub verdict: IntentPreflightVerdict,
1810 /// The issues the work was resolved to — the one named, or the open issues whose scope
1811 /// covers a path — each with its own verdict.
1812 pub issues: Vec<IntentPreflightIssue>,
1813 /// Every intent an issue serves, with the link.
1814 pub matches: Vec<IntentPreflightMatch>,
1815 /// The intents the work serves, and no other, each with what it asks of the worker.
1816 pub intents: Vec<IntentPreflightIntent>,
1817 /// The governance of those intents, each entry once, in first-seen order.
1818 pub governance: Vec<String>,
1819 /// Every reason the preflight refused, in issue order; empty unless the verdict is
1820 /// `refused`.
1821 pub refusals: Vec<IntentPreflightRefusal>,
1822 /// When refused: every refusal's sentence, joined, for a reader who wants one line.
1823 #[serde(skip_serializing_if = "Option::is_none")]
1824 pub refusal: Option<String>,
1825}
1826
1827/// The coverage codes of the failures about an issue's own link, each as the preflight cause it
1828/// is. These are every `FAIL` finding coverage makes with an issue as its subject (its other
1829/// failure, `criterion_uncovered`, is about a criterion), so every broken link validation
1830/// reports is refused here. A failure coverage learns to make about an issue belongs in this
1831/// table too, or the preflight would let that broken link through.
1832const LINK_CAUSES: [(&str, IntentPreflightCause); 5] = [
1833 (
1834 "issue_without_purpose",
1835 IntentPreflightCause::IssueServesNothing,
1836 ),
1837 (
1838 "serves_outside_milestone",
1839 IntentPreflightCause::ServesAnotherIntent,
1840 ),
1841 (
1842 "malformed_serves",
1843 IntentPreflightCause::ServesUnknownCriterion,
1844 ),
1845 (
1846 "serves_unknown_intent",
1847 IntentPreflightCause::ServesUnknownCriterion,
1848 ),
1849 (
1850 "serves_unknown_criterion",
1851 IntentPreflightCause::ServesUnknownCriterion,
1852 ),
1853];
1854
1855/// The coverage code of a finding about an issue's own link, as the preflight cause it is.
1856fn link_cause(code: &str) -> Option<IntentPreflightCause> {
1857 LINK_CAUSES
1858 .iter()
1859 .find(|(c, _)| *c == code)
1860 .map(|(_, cause)| *cause)
1861}
1862
1863impl Intents {
1864 /// Which intent work on `issue`, or on `paths`, serves; whether it is maintenance; or why
1865 /// it may not proceed. This is the one join a session or a transition consumes, answered
1866 /// on every surface by the `intents.preflight` capability.
1867 ///
1868 /// ```
1869 /// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightVerdict, Intents};
1870 /// use majordomus_cli::plan::Plan;
1871 /// # let plan: Plan = serde_json::from_value(serde_json::json!({
1872 /// # "project": {"name": "p", "repository": "o/p", "default_branch": "master",
1873 /// # "active_milestone": ""},
1874 /// # "statuses": {"issue": [], "milestone": []},
1875 /// # "milestones": [], "issues": [], "waves": [], "edges": [],
1876 /// # "milestone_edges": [], "findings": []})).unwrap();
1877 /// let intents = Intents { intents: vec![], findings: vec![] };
1878 /// let answer = intents.preflight(&plan, &[], &[], Some("I0001"), &[]);
1879 /// // an issue this plan does not hold is the first missing link, and it is named
1880 /// assert_eq!(answer.verdict, IntentPreflightVerdict::Refused);
1881 /// assert_eq!(answer.refusals[0].cause, IntentPreflightCause::UnknownIssue);
1882 /// assert!(answer.refusal.unwrap().contains("I0001"));
1883 /// ```
1884 ///
1885 /// An issue is followed through the criteria it declares in `serves` — never through its
1886 /// milestone alone — and each link is judged by the same coverage `intent validate`
1887 /// reports from, so every link validation fails is refused here too, and the preflight
1888 /// is at least as strict (it also refuses work whose only links point at retired intents):
1889 /// an issue
1890 /// that serves nothing while its milestone realises an intent, a criterion of an intent
1891 /// that does not name its milestone, a criterion or an intent that does not exist. An
1892 /// issue under milestones no live intent names, serving nothing, is maintenance. Every
1893 /// intent served must have a critique with no blocking finding open. Paths are followed to
1894 /// the open issues whose scope covers one, and every one of them is judged: one refused
1895 /// issue refuses the work.
1896 pub fn preflight(
1897 &self,
1898 plan: &Plan,
1899 gaps: &[GapRecord],
1900 critiques: &[CritiqueRecord],
1901 issue: Option<&str>,
1902 paths: &[String],
1903 ) -> IntentPreflight {
1904 let refused = |r: IntentPreflightRefusal| IntentPreflight {
1905 verdict: IntentPreflightVerdict::Refused,
1906 issues: Vec::new(),
1907 matches: Vec::new(),
1908 intents: Vec::new(),
1909 governance: Vec::new(),
1910 refusal: Some(r.message.clone()),
1911 refusals: vec![r],
1912 };
1913 let issues: Vec<&crate::plan::PlanIssue> = match issue {
1914 Some(id) => match plan.issue(id) {
1915 Some(i) => vec![i],
1916 None => {
1917 return refused(IntentPreflightRefusal::new(
1918 Some(id),
1919 IntentPreflightCause::UnknownIssue,
1920 format!("`{id}` is not an issue under .ai/repo/project/issues/"),
1921 ))
1922 }
1923 },
1924 None => plan
1925 .issues
1926 .iter()
1927 .filter(|i| !matches!(i.status.as_str(), "DONE" | "CANCELLED"))
1928 .filter(|i| i.scope.iter().any(|s| paths.iter().any(|p| overlap(s, p))))
1929 .collect(),
1930 };
1931 if issues.is_empty() {
1932 return refused(IntentPreflightRefusal::new(
1933 None,
1934 IntentPreflightCause::NoIssueCoversPaths,
1935 format!(
1936 "no open issue's scope covers {}; work that names no issue serves no \
1937 declared intent",
1938 paths.join(", ")
1939 ),
1940 ));
1941 }
1942 self.preflight_issues(plan, gaps, critiques, &issues)
1943 }
1944
1945 /// The preflight over issues already resolved: what [`Intents::preflight`] answers once
1946 /// it knows which issues the work executes, and what [`crate::intent_binding`] asks when
1947 /// it resolved them another way — the open issues serving an intent a worker named.
1948 pub(crate) fn preflight_issues(
1949 &self,
1950 plan: &Plan,
1951 gaps: &[GapRecord],
1952 critiques: &[CritiqueRecord],
1953 issues: &[&crate::plan::PlanIssue],
1954 ) -> IntentPreflight {
1955 // The links are judged by the coverage validation reports from, over outlines of the
1956 // intents already derived, so a link `intent validate` calls broken is refused here
1957 // too; the preflight may refuse more, never less.
1958 let observed = observed_satisfied(gaps);
1959 let outlines: Vec<IntentOutline> = self
1960 .intents
1961 .iter()
1962 .map(|i| IntentOutline {
1963 id: i.id.clone(),
1964 criteria: i.satisfaction.iter().map(|c| c.id.clone()).collect(),
1965 milestones: i.milestones.iter().map(|m| m.id.clone()).collect(),
1966 retired: matches!(i.stage, IntentStage::Cancelled | IntentStage::Superseded),
1967 observed_satisfied: observed.get(&i.id).cloned().unwrap_or_default(),
1968 })
1969 .collect();
1970 let cov = coverage(&outlines, plan);
1971
1972 let mut out_issues = Vec::with_capacity(issues.len());
1973 let mut matches = Vec::new();
1974 let mut refusals = Vec::new();
1975 // intent id -> the criteria the work serves of it, in first-reached order: the payload
1976 // is bounded to these, never every intent
1977 let mut reached: Vec<(String, BTreeSet<String>)> = Vec::new();
1978
1979 for i in issues {
1980 let mut mine: Vec<IntentPreflightRefusal> = cov
1981 .findings
1982 .iter()
1983 .filter(|f| f.level == FAIL && f.subject == i.id)
1984 .filter_map(|f| {
1985 link_cause(&f.code).map(|cause| {
1986 IntentPreflightRefusal::new(
1987 Some(&i.id),
1988 cause,
1989 format!("{} {}", i.id, f.message),
1990 )
1991 })
1992 })
1993 .collect();
1994 let under: Vec<String> = cov
1995 .purpose(&i.id)
1996 .map(|p| p.intents.clone())
1997 .unwrap_or_default();
1998 // the criteria this issue carries, each a link coverage accepted
1999 let mut served: Vec<(&str, &str)> = cov
2000 .criteria
2001 .iter()
2002 .filter(|c| c.issues.iter().any(|x| x.id == i.id))
2003 .map(|c| (c.intent.as_str(), c.criterion.as_str()))
2004 .collect();
2005 // a cancelled issue is not live, so coverage lists it under no criterion; its
2006 // declared links are still what it was for
2007 if served.is_empty() && i.status == "CANCELLED" {
2008 served = i
2009 .serves
2010 .iter()
2011 .filter_map(|t| t.split_once('#'))
2012 .filter(|(iid, cid)| {
2013 outlines.iter().any(|o| {
2014 o.id == *iid
2015 && !o.retired
2016 && o.milestones.contains(&i.milestone)
2017 && o.criteria.iter().any(|c| c == cid)
2018 })
2019 })
2020 .collect();
2021 }
2022 if served.is_empty() && !under.is_empty() && mine.is_empty() {
2023 mine.push(IntentPreflightRefusal::new(
2024 Some(&i.id),
2025 IntentPreflightCause::IssueServesNothing,
2026 format!(
2027 "{} is under milestone {}, which realises {}, but serves none of its \
2028 live criteria",
2029 i.id,
2030 i.milestone,
2031 under.join(", ")
2032 ),
2033 ));
2034 }
2035 let mut by_intent: Vec<(&str, Vec<String>)> = Vec::new();
2036 for (iid, cid) in &served {
2037 match reached.iter_mut().find(|(x, _)| x == iid) {
2038 Some((_, cs)) => {
2039 cs.insert((*cid).to_string());
2040 }
2041 None => {
2042 reached.push(((*iid).to_string(), BTreeSet::from([(*cid).to_string()])))
2043 }
2044 }
2045 match by_intent.iter_mut().find(|e| e.0 == *iid) {
2046 Some((_, cs)) => cs.push((*cid).to_string()),
2047 None => by_intent.push((*iid, vec![(*cid).to_string()])),
2048 }
2049 }
2050 // every intent reached is one of these intents: coverage lists criteria only of the
2051 // outlines built from them, and a cancelled issue's links are filtered by the same
2052 for (iid, view, criteria) in by_intent
2053 .into_iter()
2054 .filter_map(|(iid, criteria)| self.intent(iid).map(|view| (iid, view, criteria)))
2055 {
2056 matches.push(IntentPreflightMatch {
2057 intent: view.id.clone(),
2058 title: view.title.clone(),
2059 stage: view.stage,
2060 milestone: i.milestone.clone(),
2061 issue: i.id.clone(),
2062 criteria,
2063 });
2064 match critiques.iter().find(|c| c.intent == iid) {
2065 None => mine.push(IntentPreflightRefusal::new(
2066 Some(&i.id),
2067 IntentPreflightCause::IntentNotCritiqued,
2068 format!(
2069 "{} serves intent {iid}, whose plan was never critiqued; record \
2070 .ai/repo/project/critiques/{iid}.yaml before its work starts",
2071 i.id
2072 ),
2073 )),
2074 Some(c) => {
2075 let open: Vec<&str> = open_blocking(c).map(|f| f.id.as_str()).collect();
2076 if !open.is_empty() {
2077 mine.push(IntentPreflightRefusal::new(
2078 Some(&i.id),
2079 IntentPreflightCause::OpenBlockingFinding,
2080 format!(
2081 "{} serves intent {iid}, whose critique has blocking \
2082 finding(s) {} open; plan or reject them first",
2083 i.id,
2084 open.join(", ")
2085 ),
2086 ));
2087 }
2088 }
2089 }
2090 }
2091
2092 let verdict = if !mine.is_empty() {
2093 IntentPreflightVerdict::Refused
2094 } else if !served.is_empty() {
2095 IntentPreflightVerdict::Serves
2096 } else {
2097 IntentPreflightVerdict::Maintenance
2098 };
2099 refusals.extend(mine);
2100 out_issues.push(IntentPreflightIssue {
2101 issue: i.id.clone(),
2102 milestone: i.milestone.clone(),
2103 verdict,
2104 serves: i.serves.clone(),
2105 intents: under,
2106 });
2107 }
2108
2109 let intents: Vec<IntentPreflightIntent> = reached
2110 .iter()
2111 .filter_map(|(id, criteria)| {
2112 self.intent(id)
2113 .map(|view| self.held_to(view, criteria, gaps, critiques))
2114 })
2115 .collect();
2116 let mut governance: Vec<String> = Vec::new();
2117 for g in intents.iter().flat_map(|i| i.governance.iter()) {
2118 if !governance.contains(g) {
2119 governance.push(g.clone());
2120 }
2121 }
2122 let verdict = if !refusals.is_empty() {
2123 IntentPreflightVerdict::Refused
2124 } else if out_issues
2125 .iter()
2126 .any(|i| i.verdict == IntentPreflightVerdict::Serves)
2127 {
2128 IntentPreflightVerdict::Serves
2129 } else {
2130 IntentPreflightVerdict::Maintenance
2131 };
2132 let refusal = (!refusals.is_empty()).then(|| {
2133 refusals
2134 .iter()
2135 .map(|r| r.message.as_str())
2136 .collect::<Vec<_>>()
2137 .join("; ")
2138 });
2139 IntentPreflight {
2140 verdict,
2141 issues: out_issues,
2142 matches,
2143 intents,
2144 governance,
2145 refusals,
2146 refusal,
2147 }
2148 }
2149
2150 /// One intent as the work is held to it: the criteria it serves, and the critique and the
2151 /// gap bounded to the same criteria.
2152 fn held_to(
2153 &self,
2154 view: &IntentView,
2155 served: &BTreeSet<String>,
2156 gaps: &[GapRecord],
2157 critiques: &[CritiqueRecord],
2158 ) -> IntentPreflightIntent {
2159 let wanted = |c: &str| served.contains(c);
2160 IntentPreflightIntent {
2161 id: view.id.clone(),
2162 title: view.title.clone(),
2163 stage: view.stage,
2164 statement: view.statement.clone(),
2165 criteria: view
2166 .satisfaction
2167 .iter()
2168 .filter(|c| wanted(&c.id))
2169 .cloned()
2170 .collect(),
2171 invariants: view.invariants.clone(),
2172 non_goals: view.non_goals.clone(),
2173 governance: view.governance.clone(),
2174 critique: critiques.iter().find(|c| c.intent == view.id).map(|c| {
2175 IntentPreflightCritique {
2176 source: c.source.clone(),
2177 reviewed_at: c.reviewed_at.clone(),
2178 reviewed_by: c.reviewed_by.clone(),
2179 open_blocking: open_blocking(c).cloned().collect(),
2180 }
2181 }),
2182 gap: gaps
2183 .iter()
2184 .find(|g| g.intent == view.id)
2185 .map(|g| IntentPreflightGap {
2186 source: g.source.clone(),
2187 observed_at: g.observed_at.clone(),
2188 conditions: g
2189 .conditions
2190 .iter()
2191 .filter(|c| wanted(&c.criterion))
2192 .cloned()
2193 .collect(),
2194 risks: g.risks.clone(),
2195 }),
2196 }
2197 }
2198}
2199
2200/// The blocking findings of a critique still open: the ones that refuse execution.
2201fn open_blocking(c: &CritiqueRecord) -> impl Iterator<Item = &CritiqueFinding> {
2202 c.findings
2203 .iter()
2204 .filter(|f| f.blocking && f.resolution == Some(ResolutionState::Open))
2205}
2206
2207// ---------------------------------------------------------------- proposing an intent
2208
2209/// The longest identity [`propose`] derives from a title, in characters. A file name a
2210/// person can read in a listing, and the slug of a sentence rather than the sentence.
2211const PROPOSED_ID_MAX: usize = 64;
2212
2213/// What [`propose`] wrote: the identity, the file, and the record as it now reads.
2214///
2215/// ```
2216/// use majordomus_cli::intent::Proposed;
2217/// let p: Proposed = serde_json::from_str(
2218/// r#"{"id": "fast-ui", "path": ".ai/repo/project/intents/fast-ui.yaml",
2219/// "title": "Fast UI", "statement": "The UI answers at once"}"#,
2220/// )
2221/// .unwrap();
2222/// // the path is the identity under the intents directory: one answer to where it lives
2223/// assert_eq!(p.path, format!(".ai/repo/project/intents/{}.yaml", p.id));
2224/// ```
2225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2226pub struct Proposed {
2227 /// The identity, which is also the file name.
2228 pub id: String,
2229 /// Repository-relative path of the record written.
2230 pub path: String,
2231 /// One line.
2232 pub title: String,
2233 /// What must become true, as it was given.
2234 pub statement: String,
2235}
2236
2237/// Why [`propose`] wrote nothing: the input cannot make a record, a record of that identity
2238/// already exists, or the file could not be written. Each carries the sentence a caller is
2239/// shown, and the three are kept apart so that a transport can answer invalid input, a
2240/// refusal and its own failure differently.
2241///
2242/// ```
2243/// use majordomus_cli::intent::{propose, ProposeError};
2244/// let nowhere = std::path::Path::new("/nonexistent");
2245/// // judged before anything is touched: an empty statement never reaches the disk
2246/// let e = propose(nowhere, " ", None, None, "2026-10-12").unwrap_err();
2247/// assert!(matches!(e, ProposeError::Invalid(_)));
2248/// assert_eq!(e.to_string(), "an intent needs a statement: what must become true");
2249/// ```
2250#[derive(Debug, Clone, PartialEq, Eq)]
2251pub enum ProposeError {
2252 /// The input cannot make a record: an empty statement, or an id that is not a slug.
2253 Invalid(String),
2254 /// A record with this identity exists; a proposal never overwrites one.
2255 Exists(String),
2256 /// The file could not be written.
2257 Write(String),
2258}
2259
2260impl std::fmt::Display for ProposeError {
2261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2262 match self {
2263 ProposeError::Invalid(m) | ProposeError::Exists(m) | ProposeError::Write(m) => {
2264 f.write_str(m)
2265 }
2266 }
2267 }
2268}
2269
2270/// The identity a proposal takes when none is given: the slug of its title, cut at a word
2271/// boundary within `PROPOSED_ID_MAX` (64) characters.
2272///
2273/// ```
2274/// use majordomus_cli::intent::proposed_id;
2275/// assert_eq!(proposed_id("Make the UI mobile-first!"), "make-the-ui-mobile-first");
2276/// let long = "word ".repeat(40);
2277/// assert!(proposed_id(&long).len() <= 64 && !proposed_id(&long).ends_with('-'));
2278/// ```
2279pub fn proposed_id(title: &str) -> String {
2280 let full = crate::entity::slug(title);
2281 if full.len() <= PROPOSED_ID_MAX {
2282 return full;
2283 }
2284 let cut = &full[..PROPOSED_ID_MAX];
2285 match cut.rfind('-') {
2286 Some(i) if i > 0 => cut[..i].to_string(),
2287 _ => cut.to_string(),
2288 }
2289}
2290
2291/// A YAML scalar that reads back as exactly `s`: a JSON string is a YAML double-quoted
2292/// scalar, so the escaping is serde_json's and no hand-written quoting rule can drift from it.
2293fn yaml_scalar(s: &str) -> String {
2294 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
2295}
2296
2297/// Write a new intent record that says what must become true and nothing else yet (ADR 0144).
2298///
2299/// The record names no milestone and no criterion: its stage derives as `declared`, `intent
2300/// validate` reports the two absences as warnings, and no issue can serve it until a person
2301/// or a later step plans it. Nothing is stored about its standing; the stage stays derived
2302/// (ADR 0070). The title defaults to the statement's first line, the identity to the
2303/// title's slug. An existing record is never overwritten, and the file appears whole or not
2304/// at all: it is written beside its final name and renamed into place.
2305///
2306/// ```
2307/// use majordomus_cli::intent::{propose, ProposeError};
2308/// let root = std::env::temp_dir().join(format!("mj-propose-doc-{}", std::process::id()));
2309/// let p = propose(&root, "The UI answers within 100 ms warm", None, None, "2026-10-12").unwrap();
2310/// assert_eq!(p.id, "the-ui-answers-within-100-ms-warm");
2311/// assert_eq!(p.path, ".ai/repo/project/intents/the-ui-answers-within-100-ms-warm.yaml");
2312/// let text = std::fs::read_to_string(root.join(&p.path)).unwrap();
2313/// assert!(text.contains("milestones: []") && text.contains("satisfaction: []"));
2314/// // a second proposal of the same identity is refused, and the first record is untouched
2315/// let again = propose(&root, "something else", None, Some(p.id.as_str()), "2026-10-12");
2316/// assert!(matches!(again, Err(ProposeError::Exists(_))));
2317/// assert_eq!(std::fs::read_to_string(root.join(&p.path)).unwrap(), text);
2318/// std::fs::remove_dir_all(&root).unwrap();
2319/// ```
2320pub fn propose(
2321 root: &Path,
2322 statement: &str,
2323 title: Option<&str>,
2324 id: Option<&str>,
2325 today: &str,
2326) -> Result<Proposed, ProposeError> {
2327 let statement = statement.trim();
2328 if statement.is_empty() {
2329 return Err(ProposeError::Invalid(
2330 "an intent needs a statement: what must become true".into(),
2331 ));
2332 }
2333 let title = match title.map(str::trim).filter(|t| !t.is_empty()) {
2334 Some(t) => t.to_string(),
2335 None => statement
2336 .lines()
2337 .next()
2338 .unwrap_or(statement)
2339 .trim()
2340 .to_string(),
2341 };
2342 let id = match id.map(str::trim).filter(|i| !i.is_empty()) {
2343 Some(i) => {
2344 if crate::entity::slug(i) != i || i.len() > PROPOSED_ID_MAX {
2345 return Err(ProposeError::Invalid(format!(
2346 "`{i}` is not an intent identity: lowercase letters, digits and single hyphens, at most {PROPOSED_ID_MAX} characters"
2347 )));
2348 }
2349 i.to_string()
2350 }
2351 None => proposed_id(&title),
2352 };
2353 if id.is_empty() {
2354 return Err(ProposeError::Invalid(
2355 "the title has no letter or digit to name the intent by; give an id".into(),
2356 ));
2357 }
2358 let rel = format!("{INTENTS_DIR}/{id}.yaml");
2359 let path = root.join(&rel);
2360 if path.exists() {
2361 return Err(ProposeError::Exists(format!(
2362 "an intent `{id}` exists at {rel}; a proposal never overwrites a record"
2363 )));
2364 }
2365 let text = format!(
2366 "id: {id}\ntitle: {title}\nstatement: {statement}\ninvariants: []\nmilestones: []\nsatisfaction: []\ncreated_at: {today}\nupdated_at: {today}\n",
2367 title = yaml_scalar(&title),
2368 statement = yaml_scalar(statement),
2369 );
2370 let dir = path.parent().unwrap_or(root);
2371 std::fs::create_dir_all(dir)
2372 .map_err(|e| ProposeError::Write(format!("{}: {e}", dir.display())))?;
2373 let tmp = dir.join(format!(".{id}.yaml.tmp.{}", std::process::id()));
2374 std::fs::write(&tmp, &text)
2375 .map_err(|e| ProposeError::Write(format!("{}: {e}", tmp.display())))?;
2376 // a hard link refuses an existing name where a rename would replace it, so two
2377 // proposals of one identity racing each other cannot both succeed
2378 let placed = std::fs::hard_link(&tmp, &path);
2379 let _ = std::fs::remove_file(&tmp);
2380 placed.map_err(|e| match e.kind() {
2381 std::io::ErrorKind::AlreadyExists => ProposeError::Exists(format!(
2382 "an intent `{id}` exists at {rel}; a proposal never overwrites a record"
2383 )),
2384 _ => ProposeError::Write(format!("{}: {e}", path.display())),
2385 })?;
2386 Ok(Proposed {
2387 id,
2388 path: rel,
2389 title,
2390 statement: statement.to_string(),
2391 })
2392}
2393
2394#[cfg(test)]
2395mod tests {
2396 use super::*;
2397 use crate::plan::{PlanCounts, PlanIssue, PlanMilestone, PlanProject, PlanVocabulary};
2398
2399 struct Table {
2400 tests: BTreeMap<String, TestStanding>,
2401 claims: BTreeMap<String, ClaimStanding>,
2402 }
2403
2404 impl Table {
2405 fn new() -> Self {
2406 Table {
2407 tests: BTreeMap::new(),
2408 claims: BTreeMap::new(),
2409 }
2410 }
2411 fn with_test(mut self, id: &str, state: IntentEvidenceState) -> Self {
2412 self.tests.insert(
2413 id.into(),
2414 TestStanding {
2415 present: true,
2416 state,
2417 proof: None,
2418 },
2419 );
2420 self
2421 }
2422 fn with_claim(mut self, id: &str, state: IntentEvidenceState) -> Self {
2423 let proof = match state {
2424 IntentEvidenceState::Current => ProofState::Proven,
2425 IntentEvidenceState::Stale => ProofState::Stale,
2426 IntentEvidenceState::Failing => ProofState::Failing,
2427 _ => ProofState::NotRun,
2428 };
2429 self.claims
2430 .insert(id.into(), ClaimStanding { state, proof });
2431 self
2432 }
2433 }
2434
2435 impl EvidenceLookup for Table {
2436 fn test(&self, id: &TestId, _: &[String]) -> TestStanding {
2437 self.tests
2438 .get(&id.as_string())
2439 .cloned()
2440 .unwrap_or(TestStanding {
2441 present: false,
2442 state: IntentEvidenceState::NotRun,
2443 proof: None,
2444 })
2445 }
2446 fn claim(&self, id: &str) -> Option<ClaimStanding> {
2447 self.claims.get(id).copied()
2448 }
2449 fn deployment(&self, id: &str) -> bool {
2450 id == "fly"
2451 }
2452 fn object(&self, kind: &str, identity: &str) -> bool {
2453 kind == "rule"
2454 && (identity == "project.alpha" || identity.starts_with("project.alpha@"))
2455 }
2456 fn file(&self, path: &str) -> bool {
2457 path == "docs/INTENT.md"
2458 }
2459 }
2460
2461 fn milestone(id: &str, status: &str) -> PlanMilestone {
2462 PlanMilestone {
2463 id: id.into(),
2464 status: status.into(),
2465 order: 0,
2466 priority: "p1".into(),
2467 title: id.into(),
2468 slug: id.into(),
2469 version: String::new(),
2470 rank: 0,
2471 depends_on: vec![],
2472 blocked_by: vec![],
2473 dependents: vec![],
2474 claims: vec![],
2475 counts: PlanCounts {
2476 total: 0,
2477 required: 0,
2478 by_status: BTreeMap::new(),
2479 },
2480 issues: vec![],
2481 outcome: String::new(),
2482 }
2483 }
2484
2485 fn issue(id: &str, milestone: &str, status: &str, scope: &[&str]) -> PlanIssue {
2486 PlanIssue {
2487 id: id.into(),
2488 milestone: milestone.into(),
2489 status: status.into(),
2490 wave: 0,
2491 priority: "p1".into(),
2492 profile: "implementation".into(),
2493 parallel_safe: true,
2494 title: id.into(),
2495 slug: id.into(),
2496 serves: vec![],
2497 depends_on: vec![],
2498 blocked_by: vec![],
2499 dependents: vec![],
2500 scope: scope.iter().map(|s| s.to_string()).collect(),
2501 objective: String::new(),
2502 evidence_have: 0,
2503 evidence_need: 0,
2504 started_at: String::new(),
2505 verified_at: String::new(),
2506 completed_at: String::new(),
2507 }
2508 }
2509
2510 fn plan(milestones: Vec<PlanMilestone>, issues: Vec<PlanIssue>) -> Plan {
2511 Plan {
2512 project: PlanProject {
2513 name: "p".into(),
2514 repository: "o/p".into(),
2515 default_branch: "master".into(),
2516 active_milestone: String::new(),
2517 },
2518 statuses: PlanVocabulary {
2519 issue: vec![],
2520 milestone: vec![],
2521 },
2522 milestones,
2523 issues,
2524 waves: vec![],
2525 edges: vec![],
2526 milestone_edges: vec![],
2527 findings: vec![],
2528 }
2529 }
2530
2531 fn record(id: &str, milestones: &[&str], criteria: &[(&str, &str, &str)]) -> IntentRecord {
2532 IntentRecord {
2533 id: id.into(),
2534 source: format!("{INTENTS_DIR}/{id}.yaml"),
2535 title: id.into(),
2536 statement: "it becomes true".into(),
2537 invariants: vec![],
2538 satisfaction: criteria
2539 .iter()
2540 .map(|(cid, kind, r)| CriterionRecord {
2541 id: (*cid).into(),
2542 criterion: "observable".into(),
2543 evidence: (*kind).into(),
2544 reference: (*r).into(),
2545 })
2546 .collect(),
2547 milestones: milestones.iter().map(|m| m.to_string()).collect(),
2548 ..IntentRecord::default()
2549 }
2550 }
2551
2552 fn codes(i: &Intents) -> Vec<&str> {
2553 i.findings.iter().map(|f| f.code.as_str()).collect()
2554 }
2555
2556 const CASE: (&str, &str, &str) = ("case", "test", "test/cases/1_x.sh");
2557
2558 #[test]
2559 fn the_stage_follows_the_milestones_and_only_then_the_evidence() {
2560 let cases: &[(&[&str], IntentEvidenceState, IntentStage)] = &[
2561 (
2562 &["PLANNED"],
2563 IntentEvidenceState::Current,
2564 IntentStage::Planned,
2565 ),
2566 (
2567 &["BLOCKED"],
2568 IntentEvidenceState::Current,
2569 IntentStage::Planned,
2570 ),
2571 (
2572 &["ACTIVE"],
2573 IntentEvidenceState::Current,
2574 IntentStage::Executing,
2575 ),
2576 (
2577 &["VERIFY"],
2578 IntentEvidenceState::NotRun,
2579 IntentStage::Executing,
2580 ),
2581 (
2582 &["DONE", "PLANNED"],
2583 IntentEvidenceState::Current,
2584 IntentStage::Executing,
2585 ),
2586 (
2587 &["DONE"],
2588 IntentEvidenceState::NotRun,
2589 IntentStage::Verifying,
2590 ),
2591 (
2592 &["DONE"],
2593 IntentEvidenceState::Stale,
2594 IntentStage::Verifying,
2595 ),
2596 (
2597 &["DONE"],
2598 IntentEvidenceState::Failing,
2599 IntentStage::Verifying,
2600 ),
2601 (
2602 &["DONE"],
2603 IntentEvidenceState::Current,
2604 IntentStage::Satisfied,
2605 ),
2606 (
2607 &["DONE", "CANCELLED"],
2608 IntentEvidenceState::Current,
2609 IntentStage::Satisfied,
2610 ),
2611 ];
2612 for (statuses, evidence, want) in cases {
2613 let ms: Vec<PlanMilestone> = statuses
2614 .iter()
2615 .enumerate()
2616 .map(|(n, s)| milestone(&format!("m{n}"), s))
2617 .collect();
2618 let names: Vec<String> = ms.iter().map(|m| m.id.clone()).collect();
2619 let names: Vec<&str> = names.iter().map(String::as_str).collect();
2620 let p = plan(ms, vec![]);
2621 let ev = Table::new().with_test("suite:1_x", *evidence);
2622 let i = Intents::derive(vec![record("x", &names, &[CASE])], &p, &ev);
2623 assert_eq!(i.intents[0].stage, *want, "{statuses:?} with {evidence:?}");
2624 assert_eq!(i.failures(), 0, "{:?}", i.findings);
2625 }
2626 }
2627
2628 /// The stage still waits for the plan, and since ADR 0107 the evidence's own answer is no
2629 /// longer hidden behind it: with every criterion current and the milestone ACTIVE, the
2630 /// stage reads `executing` and the verdict reads `satisfied`. The stage expectation is
2631 /// unchanged; the verdict is what this test now adds.
2632 #[test]
2633 fn evidence_satisfies_nothing_while_a_milestone_is_open() {
2634 let p = plan(vec![milestone("m", "ACTIVE")], vec![]);
2635 let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2636 let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &ev);
2637 assert_eq!(i.intents[0].met, 1, "the criterion itself is met");
2638 assert_eq!(i.intents[0].stage, IntentStage::Executing);
2639 assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Satisfied);
2640 assert!(i.intents[0].verdict.reasons.is_empty());
2641 }
2642
2643 #[test]
2644 fn the_verdict_is_unknown_when_only_a_kind_the_ledger_cannot_settle_is_unmet() {
2645 let p = plan(vec![milestone("m", "ACTIVE")], vec![]);
2646 let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2647 let i = Intents::derive(
2648 vec![record(
2649 "x",
2650 &["m"],
2651 &[
2652 CASE,
2653 ("ships", "command", "just verify"),
2654 ("live", "deployment", "fly"),
2655 ],
2656 )],
2657 &p,
2658 &ev,
2659 );
2660 let v = &i.intents[0].verdict;
2661 assert_eq!(v.state, IntentVerdictState::Unknown);
2662 let held: Vec<(&str, &str, IntentEvidenceState)> = v
2663 .reasons
2664 .iter()
2665 .map(|r| (r.criterion.as_str(), r.evidence.as_str(), r.state))
2666 .collect();
2667 assert_eq!(
2668 held,
2669 [
2670 ("ships", "command", IntentEvidenceState::NotDerivable),
2671 ("live", "deployment", IntentEvidenceState::NotDerivable),
2672 ]
2673 );
2674 assert_eq!(
2675 i.intents[0].stage,
2676 IntentStage::Executing,
2677 "the stage is the plan's"
2678 );
2679 }
2680
2681 #[test]
2682 fn the_verdict_is_unsatisfied_when_a_test_the_ledger_can_settle_is_unmet() {
2683 // a DONE milestone neither implies the verdict nor is required for it
2684 for status in ["ACTIVE", "DONE"] {
2685 let p = plan(vec![milestone("m", status)], vec![]);
2686 for state in [
2687 IntentEvidenceState::NotRun,
2688 IntentEvidenceState::Stale,
2689 IntentEvidenceState::Failing,
2690 ] {
2691 let ev = Table::new().with_test("suite:1_x", state);
2692 let i = Intents::derive(
2693 vec![record(
2694 "x",
2695 &["m"],
2696 &[CASE, ("ships", "command", "just verify")],
2697 )],
2698 &p,
2699 &ev,
2700 );
2701 let v = &i.intents[0].verdict;
2702 assert_eq!(
2703 v.state,
2704 IntentVerdictState::Unsatisfied,
2705 "{status} {state:?}"
2706 );
2707 assert_eq!(v.reasons[0].criterion, "case");
2708 assert_eq!(v.reasons[0].state, state);
2709 assert_eq!(v.reasons.len(), 2, "every unmet criterion is named");
2710 }
2711 }
2712 // an unproven claim says no the same way
2713 let p = plan(vec![milestone("m", "DONE")], vec![]);
2714 let ev = Table::new().with_claim("c", IntentEvidenceState::Stale);
2715 let i = Intents::derive(vec![record("x", &["m"], &[("k", "claim", "c")])], &p, &ev);
2716 assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Unsatisfied);
2717 assert_eq!(i.intents[0].stage, IntentStage::Verifying);
2718 }
2719
2720 #[test]
2721 fn the_verdict_of_an_intent_declaring_no_criterion_is_unknown() {
2722 let p = plan(vec![milestone("m", "DONE")], vec![]);
2723 let i = Intents::derive(vec![record("x", &["m"], &[])], &p, &Table::new());
2724 assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Unknown);
2725 assert!(i.intents[0].verdict.reasons.is_empty());
2726 assert!(codes(&i).contains(&"intent_without_criterion"));
2727 }
2728
2729 #[test]
2730 fn every_verdict_and_evidence_state_is_printed_as_the_word_it_serialises_to() {
2731 for v in [
2732 IntentVerdictState::Satisfied,
2733 IntentVerdictState::Unsatisfied,
2734 IntentVerdictState::Unknown,
2735 ] {
2736 assert_eq!(
2737 serde_json::to_value(v).unwrap(),
2738 serde_json::Value::from(v.as_str())
2739 );
2740 }
2741 for s in [
2742 IntentEvidenceState::Current,
2743 IntentEvidenceState::Stale,
2744 IntentEvidenceState::Failing,
2745 IntentEvidenceState::NotRun,
2746 IntentEvidenceState::NotDerivable,
2747 IntentEvidenceState::Unresolved,
2748 ] {
2749 assert_eq!(
2750 serde_json::to_value(s).unwrap(),
2751 serde_json::Value::from(s.as_str())
2752 );
2753 }
2754 }
2755
2756 #[test]
2757 fn evidence_is_current_only_when_the_evidence_module_calls_it_current() {
2758 use IntentEvidenceState::{Current, Failing, NotRun, Stale};
2759 use TreeState::{Clean, Dirty, Unknown};
2760 for (state, tree, want) in [
2761 (ProofState::Proven, Clean, Current),
2762 (ProofState::InputsUnchanged, Clean, Current),
2763 // a pass on a tree that was not its commit: nothing later can be matched to it
2764 (ProofState::InputsUnchanged, Dirty, Stale),
2765 (ProofState::InputsUnchanged, Unknown, Stale),
2766 (ProofState::Stale, Clean, Stale),
2767 (ProofState::Failing, Clean, Failing),
2768 (ProofState::NotRun, Unknown, NotRun),
2769 (ProofState::Unrunnable, Unknown, NotRun),
2770 (ProofState::NoTest, Unknown, NotRun),
2771 ] {
2772 assert_eq!(
2773 IntentEvidenceState::judged(state, tree),
2774 want,
2775 "{state:?} on a {tree:?} tree"
2776 );
2777 assert_eq!(
2778 want == Current,
2779 evidence::current(state, tree, TreeState::Clean),
2780 "{state:?} on a {tree:?} tree: one answer to whether it is current"
2781 );
2782 }
2783 }
2784
2785 #[test]
2786 fn a_claim_criterion_is_met_only_by_current_evidence() {
2787 let p = plan(vec![milestone("m", "DONE")], vec![]);
2788 for state in [
2789 IntentEvidenceState::Current,
2790 IntentEvidenceState::Stale,
2791 IntentEvidenceState::Failing,
2792 IntentEvidenceState::NotRun,
2793 ] {
2794 let ev = Table::new().with_claim("c", state);
2795 let i = Intents::derive(vec![record("x", &["m"], &[("k", "claim", "c")])], &p, &ev);
2796 let c = &i.intents[0].satisfaction[0];
2797 assert_eq!(c.state, state);
2798 assert_eq!(c.met, state == IntentEvidenceState::Current, "{state:?}");
2799 }
2800 }
2801
2802 #[test]
2803 fn a_command_or_deployment_never_meets_a_criterion() {
2804 let p = plan(vec![milestone("m", "DONE")], vec![]);
2805 let i = Intents::derive(
2806 vec![record(
2807 "x",
2808 &["m"],
2809 &[("a", "command", "just verify"), ("b", "deployment", "fly")],
2810 )],
2811 &p,
2812 &Table::new(),
2813 );
2814 let v = &i.intents[0];
2815 assert!(v
2816 .satisfaction
2817 .iter()
2818 .all(|c| c.state == IntentEvidenceState::NotDerivable && !c.met));
2819 assert_eq!(v.stage, IntentStage::Verifying);
2820 assert_eq!(i.failures(), 0, "{:?}", i.findings);
2821 }
2822
2823 #[test]
2824 fn cancelled_and_superseded_are_what_the_record_says() {
2825 let p = plan(vec![milestone("m", "DONE")], vec![]);
2826 let mut a = record("a", &["m"], &[CASE]);
2827 a.cancelled = true;
2828 let mut b = record("b", &["m"], &[CASE]);
2829 b.superseded_by = "a".into();
2830 let i = Intents::derive(vec![a, b], &p, &Table::new());
2831 assert_eq!(i.intents[0].stage, IntentStage::Cancelled);
2832 assert_eq!(i.intents[1].stage, IntentStage::Superseded);
2833 }
2834
2835 /// A proposal names neither a milestone nor a criterion: declared, valid, two warnings.
2836 /// Naming one and not the other is half a plan, and each absence is a failure again.
2837 #[test]
2838 fn a_proposal_is_declared_and_warned_about_while_a_half_plan_fails() {
2839 let p = plan(vec![milestone("m", "READY")], vec![]);
2840 let i = Intents::derive(vec![record("x", &[], &[])], &p, &Table::new());
2841 assert_eq!(i.intents[0].stage, IntentStage::Declared);
2842 assert_eq!(i.failures(), 0, "{:?}", i.findings);
2843 for code in ["intent_without_milestone", "intent_without_criterion"] {
2844 let f = i.findings.iter().find(|f| f.code == code).unwrap();
2845 assert_eq!(f.level, WARN, "{code}");
2846 assert!(f.message.starts_with("a proposed intent"), "{}", f.message);
2847 }
2848 let half = Intents::derive(vec![record("y", &["m"], &[])], &p, &Table::new());
2849 let f = half
2850 .findings
2851 .iter()
2852 .find(|f| f.code == "intent_without_criterion")
2853 .unwrap();
2854 assert_eq!(f.level, FAIL);
2855 }
2856
2857 fn scratch(name: &str) -> PathBuf {
2858 let dir = std::env::temp_dir().join(format!("mj-propose-{name}-{}", std::process::id()));
2859 let _ = std::fs::remove_dir_all(&dir);
2860 dir
2861 }
2862
2863 /// The record reads back as what was given, whatever the statement holds, and its
2864 /// defaults are the statement's first line and that line's slug.
2865 #[test]
2866 fn a_proposal_is_written_whole_and_reads_back_as_given() {
2867 let root = scratch("whole");
2868 let statement = "The \"UI\": answers # fast\nand stays so";
2869 let p = propose(&root, statement, None, None, "2026-10-12").unwrap();
2870 assert_eq!(p.title, "The \"UI\": answers # fast");
2871 assert_eq!(p.id, "the-ui-answers-fast");
2872 assert_eq!(p.statement, statement);
2873 let text = std::fs::read_to_string(root.join(&p.path)).unwrap();
2874 let line = text.lines().find(|l| l.starts_with("statement: ")).unwrap();
2875 let back: String = serde_json::from_str(&line["statement: ".len()..]).unwrap();
2876 assert_eq!(back, statement);
2877 assert!(text.ends_with("created_at: 2026-10-12\nupdated_at: 2026-10-12\n"));
2878 // nothing is left beside the record
2879 let left: Vec<_> = std::fs::read_dir(root.join(INTENTS_DIR)).unwrap().collect();
2880 assert_eq!(left.len(), 1);
2881 // an explicit title and id are taken as given
2882 let q = propose(&root, "s", Some(" A title "), Some("named"), "2026-10-12").unwrap();
2883 assert_eq!((q.id.as_str(), q.title.as_str()), ("named", "A title"));
2884 std::fs::remove_dir_all(&root).unwrap();
2885 }
2886
2887 #[test]
2888 fn a_proposal_that_cannot_be_a_record_writes_nothing() {
2889 let root = scratch("refused");
2890 for (statement, title, id) in [
2891 (" ", None, None),
2892 ("s", None, Some("Not A Slug")),
2893 ("s", Some("???"), None),
2894 ] {
2895 match propose(&root, statement, title, id, "2026-10-12") {
2896 Err(ProposeError::Invalid(m)) => assert!(!m.is_empty()),
2897 other => panic!("{statement:?} {title:?} {id:?} answered {other:?}"),
2898 }
2899 }
2900 let long = "x".repeat(PROPOSED_ID_MAX + 1);
2901 assert!(matches!(
2902 propose(&root, "s", None, Some(&long), "2026-10-12"),
2903 Err(ProposeError::Invalid(_))
2904 ));
2905 assert!(!root.exists(), "a refused proposal created something");
2906 // a title with no word boundary is cut at the bound
2907 assert_eq!(proposed_id(&long).len(), PROPOSED_ID_MAX);
2908 // a directory that cannot be made is a write failure, said with its path
2909 std::fs::create_dir_all(&root).unwrap();
2910 std::fs::write(root.join(".ai"), "a file where a directory must be").unwrap();
2911 match propose(&root, "s", None, None, "2026-10-12") {
2912 Err(e @ ProposeError::Write(_)) => assert!(e.to_string().contains(".ai"), "{e}"),
2913 other => panic!("an unwritable root answered {other:?}"),
2914 }
2915 std::fs::remove_dir_all(&root).unwrap();
2916 }
2917
2918 #[test]
2919 fn an_intent_with_no_milestone_is_declared_and_a_failure() {
2920 let p = plan(vec![], vec![]);
2921 let i = Intents::derive(vec![record("x", &[], &[CASE])], &p, &Table::new());
2922 assert_eq!(i.intents[0].stage, IntentStage::Declared);
2923 assert!(codes(&i).contains(&"intent_without_milestone"));
2924 assert!(i.failures() > 0);
2925 }
2926
2927 #[test]
2928 fn an_unknown_milestone_is_a_failure_and_holds_the_stage_at_declared() {
2929 let p = plan(vec![milestone("m", "DONE")], vec![]);
2930 let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2931 let i = Intents::derive(vec![record("x", &["m", "ghost"], &[CASE])], &p, &ev);
2932 assert_eq!(i.intents[0].stage, IntentStage::Declared);
2933 assert!(!i.intents[0].milestones[1].resolved);
2934 assert_eq!(codes(&i), ["unknown_milestone"]);
2935 }
2936
2937 #[test]
2938 fn a_criterion_without_a_ref_is_a_failure() {
2939 let p = plan(vec![milestone("m", "DONE")], vec![]);
2940 let i = Intents::derive(
2941 vec![record("x", &["m"], &[("c", "test", " ")])],
2942 &p,
2943 &Table::new(),
2944 );
2945 assert_eq!(codes(&i), ["criterion_without_ref"]);
2946 assert_eq!(
2947 i.intents[0].satisfaction[0].state,
2948 IntentEvidenceState::Unresolved
2949 );
2950 }
2951
2952 #[test]
2953 fn a_ref_that_does_not_resolve_is_a_failure_for_every_kind() {
2954 let p = plan(vec![milestone("m", "DONE")], vec![]);
2955 for (kind, r) in [
2956 ("test", "lib/not-a-test.sh"),
2957 ("test", "test/cases/2_absent.sh"),
2958 ("test", "suite:../x"),
2959 ("claim", "no-such-claim"),
2960 ("deployment", "nowhere"),
2961 ] {
2962 let i = Intents::derive(
2963 vec![record("x", &["m"], &[("c", kind, r)])],
2964 &p,
2965 &Table::new(),
2966 );
2967 assert_eq!(codes(&i), ["unresolved_evidence_ref"], "{kind} {r}");
2968 assert!(i.findings[0].message.contains("criterion `c`"));
2969 }
2970 }
2971
2972 #[test]
2973 fn a_milestone_serving_no_intent_is_a_warning_unless_it_is_retired() {
2974 let p = plan(
2975 vec![
2976 milestone("m", "DONE"),
2977 milestone("orphan", "ACTIVE"),
2978 milestone("gone", "CANCELLED"),
2979 ],
2980 vec![],
2981 );
2982 let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2983 let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &ev);
2984 assert_eq!(codes(&i), ["milestone_serves_no_intent"]);
2985 assert_eq!(i.findings[0].subject, "orphan");
2986 assert_eq!(i.findings[0].level, WARN);
2987 assert_eq!(i.failures(), 0);
2988 assert_eq!(i.warnings(), 1);
2989 }
2990
2991 #[test]
2992 fn the_remaining_findings_are_each_named() {
2993 let p = plan(vec![milestone("m", "DONE")], vec![]);
2994 let mut dup = record("dup", &["m"], &[CASE, CASE]);
2995 dup.governance = vec![
2996 "rule:project.alpha".into(),
2997 "rule:project.alpha@1".into(),
2998 "file:docs/INTENT.md".into(),
2999 "adr:adr-9999".into(),
3000 ];
3001 dup.superseded_by = "ghost".into();
3002 let mut moved = record("moved", &["m"], &[]);
3003 moved.source = format!("{INTENTS_DIR}/elsewhere.yaml");
3004 let i = Intents::derive(vec![dup, moved], &p, &Table::new());
3005 assert_eq!(
3006 codes(&i),
3007 [
3008 "duplicate_criterion",
3009 "unresolved_evidence_ref",
3010 "unresolved_evidence_ref",
3011 "unresolved_governance",
3012 "unknown_successor",
3013 "file_name_mismatch",
3014 "intent_without_criterion",
3015 ]
3016 );
3017 assert!(i.findings[3].message.contains("adr:adr-9999"));
3018 assert!(i.findings.iter().all(|f| f.reproduce == REPRODUCE));
3019 }
3020
3021 /// An issue under `milestone` serving each of `serves`.
3022 fn serving(id: &str, milestone: &str, scope: &[&str], serves: &[&str]) -> PlanIssue {
3023 PlanIssue {
3024 serves: serves.iter().map(|s| s.to_string()).collect(),
3025 ..issue(id, milestone, "READY", scope)
3026 }
3027 }
3028
3029 /// A critique of `intent` with `findings` as `(id, blocking, resolution)`.
3030 fn critique(intent: &str, findings: &[(&str, bool, &str)]) -> CritiqueRecord {
3031 let findings: Vec<serde_json::Value> = findings
3032 .iter()
3033 .map(|(id, blocking, state)| {
3034 serde_json::json!({"id": id, "class": "regression_risk", "subject": intent,
3035 "finding": "f", "blocking": blocking,
3036 "resolution": {"state": state, "issue": "I1", "because": "b"}})
3037 })
3038 .collect();
3039 CritiqueRecord::from_metadata(
3040 &format!(".ai/repo/project/critiques/{intent}.yaml"),
3041 &serde_json::json!({"intent": intent, "reviewed_at": "c0ffee",
3042 "reviewed_by": "the test", "findings": findings}),
3043 )
3044 }
3045
3046 fn causes(answer: &IntentPreflight) -> Vec<(&str, IntentPreflightCause)> {
3047 answer
3048 .refusals
3049 .iter()
3050 .map(|r| (r.issue.as_deref().unwrap_or(""), r.cause))
3051 .collect()
3052 }
3053
3054 #[test]
3055 fn preflight_follows_what_an_issue_serves_and_never_its_milestone_alone() {
3056 use IntentPreflightCause::*;
3057 let p = plan(
3058 vec![
3059 milestone("m", "ACTIVE"),
3060 milestone("n", "ACTIVE"),
3061 milestone("ops", "ACTIVE"),
3062 ],
3063 vec![
3064 serving("I1", "m", &["apps/cli/src"], &["x#case"]),
3065 serving("I2", "ops", &["lib"], &[]),
3066 serving("I3", "m", &["docs"], &[]),
3067 serving("I4", "m", &["share"], &["y#case"]),
3068 serving("I5", "m", &["share"], &["x#nope"]),
3069 serving("I6", "m", &["share"], &["ghost#case"]),
3070 serving("I7", "m", &["share"], &["not-a-reference"]),
3071 issue("I8", "m", "DONE", &["site"]),
3072 ],
3073 );
3074 let mut x = record("x", &["m"], &[CASE]);
3075 x.governance = vec!["rule:project.alpha".into()];
3076 let y = record("y", &["n"], &[CASE]);
3077 let i = Intents::derive(vec![x, y], &p, &Table::new());
3078 let reviewed = [critique("x", &[]), critique("y", &[])];
3079 let one = |id: &str| i.preflight(&p, &[], &reviewed, Some(id), &[]);
3080
3081 let served = one("I1");
3082 assert_eq!(served.verdict, IntentPreflightVerdict::Serves);
3083 assert_eq!(served.matches[0].intent, "x");
3084 assert_eq!(served.matches[0].criteria, ["case"]);
3085 assert_eq!(served.governance, ["rule:project.alpha"]);
3086 assert!(served.refusals.is_empty() && served.refusal.is_none());
3087
3088 // a milestone no intent names carries maintenance, exactly as validation allows it
3089 let maintenance = one("I2");
3090 assert_eq!(maintenance.verdict, IntentPreflightVerdict::Maintenance);
3091 assert!(maintenance.intents.is_empty() && maintenance.refusals.is_empty());
3092 assert_eq!(
3093 maintenance.issues[0].verdict,
3094 IntentPreflightVerdict::Maintenance
3095 );
3096
3097 // under the milestone of x, serving nothing: the milestone alone serves nothing
3098 for (id, cause) in [
3099 ("I3", IssueServesNothing),
3100 ("I4", ServesAnotherIntent),
3101 ("I5", ServesUnknownCriterion),
3102 ("I6", ServesUnknownCriterion),
3103 ("I7", ServesUnknownCriterion),
3104 ] {
3105 let answer = one(id);
3106 assert_eq!(answer.verdict, IntentPreflightVerdict::Refused, "{id}");
3107 assert_eq!(causes(&answer), [(id, cause)], "{id}");
3108 assert!(answer.refusal.unwrap().contains(id), "{id}");
3109 assert!(answer.matches.is_empty(), "{id} serves nothing it may");
3110 assert!(answer.intents.is_empty(), "{id}: no intent is served");
3111 }
3112 // a DONE issue under an intent milestone, serving nothing, is refused like any other
3113 assert_eq!(causes(&one("I8")), [("I8", IssueServesNothing)]);
3114
3115 let unknown = one("I404");
3116 assert_eq!(causes(&unknown), [("I404", UnknownIssue)]);
3117 assert!(unknown.refusal.unwrap().contains("I404"));
3118
3119 // a closed issue's scope claims nothing
3120 let closed = i.preflight(&p, &[], &reviewed, None, &["site/x.md".into()]);
3121 assert_eq!(causes(&closed), [("", NoIssueCoversPaths)]);
3122 assert!(closed.refusal.unwrap().contains("no open issue"));
3123 }
3124
3125 #[test]
3126 fn preflight_keeps_a_cancelled_issues_links_and_gathers_the_criteria_of_one_intent() {
3127 use IntentPreflightCause::*;
3128 let cancelled = |i: PlanIssue| PlanIssue {
3129 status: "CANCELLED".into(),
3130 ..i
3131 };
3132 let p = plan(
3133 vec![milestone("m", "ACTIVE")],
3134 vec![
3135 serving("I1", "m", &["lib"], &["x#case", "x#other"]),
3136 cancelled(serving("I2", "m", &["docs"], &["x#case"])),
3137 cancelled(serving("I3", "m", &["site"], &[])),
3138 serving("I4", "m", &["lib/x"], &["x#other"]),
3139 ],
3140 );
3141 let x = record("x", &["m"], &[CASE, ("other", "test", "test/cases/2_y.sh")]);
3142 let i = Intents::derive(vec![x], &p, &Table::new());
3143 let reviewed = [critique("x", &[])];
3144 let one = |id: &str| i.preflight(&p, &[], &reviewed, Some(id), &[]);
3145
3146 // two criteria of one intent are one match carrying both
3147 let both = one("I1");
3148 assert_eq!(both.verdict, IntentPreflightVerdict::Serves);
3149 assert_eq!(both.matches.len(), 1, "{:?}", both.matches);
3150 assert_eq!(both.matches[0].criteria, ["case", "other"]);
3151 assert_eq!(both.intents.len(), 1);
3152
3153 // a cancelled issue is not live, so coverage lists it under no criterion: what it
3154 // declared it served is still what it was for
3155 let gone = one("I2");
3156 assert_eq!(
3157 gone.verdict,
3158 IntentPreflightVerdict::Serves,
3159 "{:?}",
3160 gone.refusals
3161 );
3162 assert_eq!(gone.matches[0].criteria, ["case"]);
3163 // and a cancelled issue that served nothing under the milestone of x is refused
3164 assert_eq!(causes(&one("I3")), [("I3", IssueServesNothing)]);
3165
3166 // by path, two issues reach the same intent: it is held to once, with both criteria
3167 let by_path = i.preflight(&p, &[], &reviewed, None, &["lib/x/a.rs".into()]);
3168 assert_eq!(by_path.verdict, IntentPreflightVerdict::Serves);
3169 assert_eq!(by_path.issues.len(), 2);
3170 assert_eq!(by_path.intents.len(), 1);
3171 let held: Vec<&str> = by_path.intents[0]
3172 .criteria
3173 .iter()
3174 .map(|c| c.id.as_str())
3175 .collect();
3176 assert_eq!(held, ["case", "other"]);
3177 }
3178
3179 #[test]
3180 fn preflight_refuses_work_on_an_intent_whose_plan_was_not_reviewed_or_has_a_blocker_open() {
3181 use IntentPreflightCause::*;
3182 let p = plan(
3183 vec![milestone("m", "ACTIVE")],
3184 vec![serving("I1", "m", &["lib"], &["x#case"])],
3185 );
3186 let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &Table::new());
3187 let with = |critiques: &[CritiqueRecord]| i.preflight(&p, &[], critiques, Some("I1"), &[]);
3188
3189 let unreviewed = with(&[]);
3190 assert_eq!(causes(&unreviewed), [("I1", IntentNotCritiqued)]);
3191 // the intent is still carried, so the worker sees what it is held to
3192 assert_eq!(unreviewed.intents[0].id, "x");
3193 assert!(unreviewed.intents[0].critique.is_none());
3194 // a critique of another intent reviews nothing here
3195 assert_eq!(
3196 causes(&with(&[critique("other", &[])])),
3197 [("I1", IntentNotCritiqued)]
3198 );
3199
3200 let blocked = with(&[critique(
3201 "x",
3202 &[("open-one", true, "open"), ("advisory", false, "open")],
3203 )]);
3204 assert_eq!(causes(&blocked), [("I1", OpenBlockingFinding)]);
3205 assert!(blocked.refusal.unwrap().contains("open-one"));
3206 let held = blocked.intents[0].critique.as_ref().unwrap();
3207 let open: Vec<&str> = held.open_blocking.iter().map(|f| f.id.as_str()).collect();
3208 assert_eq!(
3209 open,
3210 ["open-one"],
3211 "a finding that blocks nothing is not listed"
3212 );
3213
3214 // planned or rejected, a blocking finding no longer holds the work
3215 let resolved = with(&[critique(
3216 "x",
3217 &[("planned", true, "planned"), ("rejected", true, "rejected")],
3218 )]);
3219 assert_eq!(resolved.verdict, IntentPreflightVerdict::Serves);
3220 let held = resolved.intents[0].critique.as_ref().unwrap();
3221 assert!(held.open_blocking.is_empty());
3222 assert_eq!(held.reviewed_at, "c0ffee");
3223 }
3224
3225 #[test]
3226 fn preflight_by_path_judges_every_issue_and_answers_the_worst() {
3227 use IntentPreflightVerdict::*;
3228 let p = plan(
3229 vec![milestone("m", "ACTIVE"), milestone("ops", "ACTIVE")],
3230 vec![
3231 serving("I1", "m", &["apps/cli"], &["x#case"]),
3232 serving("I2", "ops", &["apps/cli/src/ops"], &[]),
3233 serving("I3", "m", &["apps/cli/src/legacy"], &[]),
3234 ],
3235 );
3236 let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &Table::new());
3237 let reviewed = [critique("x", &[])];
3238 let at = |paths: &[&str]| {
3239 let paths: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
3240 i.preflight(&p, &[], &reviewed, None, &paths)
3241 };
3242 let verdicts = |a: &IntentPreflight| -> Vec<(String, IntentPreflightVerdict)> {
3243 a.issues
3244 .iter()
3245 .map(|x| (x.issue.clone(), x.verdict))
3246 .collect()
3247 };
3248
3249 assert_eq!(at(&["apps/cli/src/main.rs"]).verdict, Serves);
3250 // one served issue never vouches for another that serves nothing
3251 let mixed = at(&["apps/cli/src/legacy/old.rs"]);
3252 assert_eq!(mixed.verdict, Refused);
3253 assert_eq!(
3254 verdicts(&mixed),
3255 [("I1".to_string(), Serves), ("I3".to_string(), Refused)]
3256 );
3257 assert_eq!(
3258 causes(&mixed),
3259 [("I3", IntentPreflightCause::IssueServesNothing)]
3260 );
3261 // served work beside maintenance is held to the intent it serves
3262 let beside = at(&["apps/cli/src/ops/run.rs"]);
3263 assert_eq!(beside.verdict, Serves);
3264 assert_eq!(
3265 verdicts(&beside),
3266 [("I1".to_string(), Serves), ("I2".to_string(), Maintenance)]
3267 );
3268 assert_eq!(beside.intents.len(), 1);
3269 }
3270
3271 #[test]
3272 fn preflight_carries_only_the_served_intents_bounded_to_the_served_criteria() {
3273 let p = plan(
3274 vec![milestone("m", "ACTIVE"), milestone("elsewhere", "ACTIVE")],
3275 vec![
3276 serving("I1", "m", &["lib"], &["x#a"]),
3277 serving("I2", "elsewhere", &["share"], &["z#a"]),
3278 ],
3279 );
3280 let mut x = record(
3281 "x",
3282 &["m"],
3283 &[
3284 ("a", "test", "test/cases/1_x.sh"),
3285 ("b", "test", "test/cases/2_x.sh"),
3286 ],
3287 );
3288 x.invariants = vec!["nothing breaks".into()];
3289 x.non_goals = vec!["speed".into()];
3290 let z = record("z", &["elsewhere"], &[("a", "test", "test/cases/3_x.sh")]);
3291 let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Failing);
3292 let i = Intents::derive(vec![x, z], &p, &ev);
3293 let gap = GapRecord::from_metadata(
3294 ".ai/repo/project/gaps/x.yaml",
3295 &serde_json::json!({"intent": "x", "observed_at": "c0ffee",
3296 "conditions": [{"criterion": "a", "state": "missing"},
3297 {"criterion": "b", "state": "unknown"}],
3298 "risks": ["the schema may refuse old records"]}),
3299 );
3300 let answer = i.preflight(
3301 &p,
3302 &[gap],
3303 &[critique("x", &[]), critique("z", &[])],
3304 Some("I1"),
3305 &[],
3306 );
3307 assert_eq!(answer.verdict, IntentPreflightVerdict::Serves);
3308 let ids: Vec<&str> = answer.intents.iter().map(|x| x.id.as_str()).collect();
3309 assert_eq!(
3310 ids,
3311 ["x"],
3312 "an intent the work does not serve is not carried"
3313 );
3314 let x = &answer.intents[0];
3315 assert_eq!(x.statement, "it becomes true");
3316 assert_eq!(x.invariants, ["nothing breaks"]);
3317 assert_eq!(x.non_goals, ["speed"]);
3318 let criteria: Vec<(&str, IntentEvidenceState)> = x
3319 .criteria
3320 .iter()
3321 .map(|c| (c.id.as_str(), c.state))
3322 .collect();
3323 assert_eq!(criteria, [("a", IntentEvidenceState::Failing)]);
3324 let gap = x.gap.as_ref().unwrap();
3325 let conditions: Vec<&str> = gap
3326 .conditions
3327 .iter()
3328 .map(|c| c.criterion.as_str())
3329 .collect();
3330 assert_eq!(conditions, ["a"]);
3331 assert_eq!(gap.risks, ["the schema may refuse old records"]);
3332 }
3333
3334 #[test]
3335 fn preflight_names_a_governance_entry_two_served_intents_share_once() {
3336 let p = plan(
3337 vec![milestone("m", "ACTIVE")],
3338 vec![serving("I1", "m", &["lib"], &["a#case", "b#case"])],
3339 );
3340 let mut a = record("a", &["m"], &[CASE]);
3341 a.governance = vec!["rule:project.alpha".into(), "file:docs/INTENT.md".into()];
3342 let mut b = record("b", &["m"], &[CASE]);
3343 b.governance = vec!["file:docs/INTENT.md".into(), "rule:project.alpha@1".into()];
3344 let i = Intents::derive(vec![a, b], &p, &Table::new());
3345 let reviewed = [critique("a", &[]), critique("b", &[])];
3346 let answer = i.preflight(&p, &[], &reviewed, Some("I1"), &[]);
3347 assert_eq!(answer.verdict, IntentPreflightVerdict::Serves);
3348 let served: Vec<&str> = answer.matches.iter().map(|m| m.intent.as_str()).collect();
3349 assert_eq!(served, ["a", "b"]);
3350 assert_eq!(
3351 answer.governance,
3352 [
3353 "rule:project.alpha",
3354 "file:docs/INTENT.md",
3355 "rule:project.alpha@1"
3356 ]
3357 );
3358 }
3359
3360 #[test]
3361 fn every_stage_is_printed_as_the_word_it_serialises_to() {
3362 for stage in [
3363 IntentStage::Declared,
3364 IntentStage::Planned,
3365 IntentStage::Executing,
3366 IntentStage::Verifying,
3367 IntentStage::Satisfied,
3368 IntentStage::Cancelled,
3369 IntentStage::Superseded,
3370 ] {
3371 assert_eq!(
3372 serde_json::to_value(stage).unwrap(),
3373 serde_json::Value::from(stage.as_str()),
3374 "{stage:?} is spelled one way by serde and another by as_str"
3375 );
3376 }
3377 }
3378
3379 #[test]
3380 fn a_named_test_is_a_suite_case_or_a_crate_test_and_no_other_runner() {
3381 assert_eq!(
3382 test_id("suite:1_x"),
3383 TestId::named(evidence::Runner::Suite, "1_x")
3384 );
3385 assert_eq!(
3386 test_id("crate:intent"),
3387 TestId::named(evidence::Runner::Crate, "intent")
3388 );
3389 assert_ne!(test_id("crate:intent"), test_id("suite:intent"));
3390 assert_eq!(test_id("pytest:intent"), None);
3391 assert_eq!(test_id("no-prefix-at-all"), None);
3392
3393 // and a criterion naming a crate test is met by that test's current run
3394 let p = plan(vec![milestone("m", "DONE")], vec![]);
3395 let ev = Table::new().with_test(
3396 &TestId::named(evidence::Runner::Crate, "intent")
3397 .unwrap()
3398 .as_string(),
3399 IntentEvidenceState::Current,
3400 );
3401 let i = Intents::derive(
3402 vec![record("x", &["m"], &[("c", "test", "crate:intent")])],
3403 &p,
3404 &ev,
3405 );
3406 assert_eq!(codes(&i), Vec::<&str>::new());
3407 assert_eq!(i.intents[0].stage, IntentStage::Satisfied);
3408 let i = Intents::derive(
3409 vec![record("x", &["m"], &[("c", "test", "pytest:intent")])],
3410 &p,
3411 &ev,
3412 );
3413 assert_eq!(codes(&i), ["unresolved_evidence_ref"]);
3414 }
3415
3416 #[test]
3417 fn governance_resolves_only_a_known_kind_naming_what_the_repository_holds() {
3418 let ev = Table::new()
3419 .with_claim("known", IntentEvidenceState::Current)
3420 .with_test("suite:1_x", IntentEvidenceState::Current);
3421 for (entry, resolves) in [
3422 ("rule:project.alpha", true),
3423 ("rule:project.beta", false),
3424 ("claim:known", true),
3425 ("claim:unknown", false),
3426 ("file:docs/INTENT.md", true),
3427 ("file:docs/OTHER.md", false),
3428 ("adr:adr-0001", false),
3429 ("no-kind-at-all", false),
3430 ("ticket:42", false),
3431 ] {
3432 assert_eq!(governance_resolves(entry, &ev), resolves, "{entry}");
3433 }
3434 let p = plan(vec![milestone("m", "DONE")], vec![]);
3435 let mut x = record("x", &["m"], &[CASE]);
3436 x.governance = vec![
3437 "claim:known".into(),
3438 "no-kind-at-all".into(),
3439 "ticket:42".into(),
3440 ];
3441 let i = Intents::derive(vec![x], &p, &ev);
3442 assert_eq!(
3443 codes(&i),
3444 ["unresolved_governance", "unresolved_governance"]
3445 );
3446 assert!(i.findings[0].message.contains("`no-kind-at-all`"));
3447 assert!(i.findings[1].message.contains("`ticket:42`"));
3448 }
3449 /// The binding (ADR 0111) over the same plan the preflight is judged over: every way
3450 /// work is named resolves or says which link is missing, and every preflight cause
3451 /// reaches the binding under its own word.
3452 #[test]
3453 fn the_binding_resolves_every_way_work_is_named() {
3454 use crate::intent_binding::{bind, BindingCause, BindingRequest, BindingStanding};
3455 use crate::policy::IntentPolicy;
3456
3457 let mut finished = serving("I4", "zm", &["zed"], &["z#case"]);
3458 finished.status = "DONE".into();
3459 let p = plan(
3460 vec![
3461 milestone("m", "ACTIVE"),
3462 milestone("n", "ACTIVE"),
3463 milestone("zm", "ACTIVE"),
3464 milestone("rm", "ACTIVE"),
3465 milestone("ops", "ACTIVE"),
3466 ],
3467 vec![
3468 serving("I1", "m", &["lib"], &["x#case"]),
3469 serving("I2", "ops", &["ops"], &[]),
3470 serving("I3", "n", &["lib"], &["y#case"]),
3471 finished,
3472 serving("I5", "m", &["five"], &[]),
3473 serving("I6", "m", &["six"], &["y#case"]),
3474 serving("I7", "m", &["seven"], &["ghost#case"]),
3475 ],
3476 );
3477 let mut retired = record("r", &["rm"], &[CASE]);
3478 retired.cancelled = true;
3479 let i = Intents::derive(
3480 vec![
3481 record("x", &["m"], &[CASE]),
3482 record("y", &["n"], &[CASE]),
3483 record("z", &["zm"], &[CASE]),
3484 retired,
3485 ],
3486 &p,
3487 &Table::new(),
3488 );
3489 let reviewed = [critique("x", &[]), critique("y", &[]), critique("z", &[])];
3490 let policy = IntentPolicy::default();
3491 let ask = |critiques: &[CritiqueRecord], r: BindingRequest| {
3492 bind(&i, &p, &[], critiques, &policy, r)
3493 };
3494 let by_issue = |id: &str| BindingRequest {
3495 issue: Some(id.into()),
3496 ..Default::default()
3497 };
3498 let by_intent = |id: &str| BindingRequest {
3499 intent: Some(id.into()),
3500 ..Default::default()
3501 };
3502 let by_path = |path: &str| BindingRequest {
3503 paths: vec![path.into()],
3504 ..Default::default()
3505 };
3506 let first = |b: &crate::intent_binding::IntentBinding| b.refusals[0].cause;
3507
3508 // an issue, the intent it serves, and both together are one binding
3509 let bound = ask(&reviewed, by_issue("I1"));
3510 assert_eq!(bound.standing, BindingStanding::Bound);
3511 assert_eq!(bound.standing.as_str(), "bound");
3512 assert!(!bound.standing.refuses());
3513 assert_eq!(bound.plan_revision.len(), 64);
3514 assert_eq!(bound.evidence_standing.len(), 64);
3515 let named = ask(&reviewed, by_intent("x"));
3516 assert_eq!(named.standing, BindingStanding::Bound);
3517 assert_eq!(named.plan_revision, bound.plan_revision);
3518 let both = ask(
3519 &reviewed,
3520 BindingRequest {
3521 intent: Some("x".into()),
3522 ..by_issue("I1")
3523 },
3524 );
3525 assert_eq!(both.standing, BindingStanding::Bound);
3526 // an issue beside an intent it does not serve is a contradiction
3527 let contradiction = ask(
3528 &reviewed,
3529 BindingRequest {
3530 intent: Some("y".into()),
3531 ..by_issue("I1")
3532 },
3533 );
3534 assert_eq!(first(&contradiction), BindingCause::IssueOutsideIntent);
3535 assert!(contradiction.standing.refuses());
3536 assert_eq!(contradiction.standing.as_str(), "refused");
3537
3538 // paths that reach two intents are ambiguous; paths under no intent are maintenance
3539 let ambiguous = ask(&reviewed, by_path("lib/a.rs"));
3540 assert_eq!(first(&ambiguous), BindingCause::AmbiguousIntent);
3541 assert!(ambiguous.refusal.unwrap().contains("x, y"));
3542 let maintenance = ask(&reviewed, by_path("ops/run.sh"));
3543 assert_eq!(maintenance.standing, BindingStanding::Maintenance);
3544 assert_eq!(maintenance.standing.as_str(), "maintenance");
3545 assert!(maintenance.plan_revision.is_empty() && maintenance.evidence_standing.is_empty());
3546 assert_eq!(BindingStanding::Exempt.as_str(), "exempt");
3547
3548 // an intent with nothing open to do, and one that was retired
3549 assert_eq!(
3550 first(&ask(&reviewed, by_intent("z"))),
3551 BindingCause::IntentHasNoOpenWork
3552 );
3553 let gone = ask(&reviewed, by_intent("r"));
3554 assert_eq!(first(&gone), BindingCause::IntentRetired);
3555 assert!(gone.refusal.unwrap().contains("cancelled"));
3556
3557 // a path the named issue's scope does not cover is said, and refuses nothing
3558 let noted = ask(
3559 &reviewed,
3560 BindingRequest {
3561 paths: vec!["docs/x.md".into(), "lib/a.rs".into()],
3562 ..by_issue("I1")
3563 },
3564 );
3565 assert_eq!(noted.standing, BindingStanding::Bound);
3566 assert_eq!(noted.notes, ["docs/x.md lie outside the scope of I1"]);
3567
3568 // each of the preflight's causes is the binding's cause, under the same word
3569 assert_eq!(
3570 first(&ask(&[], by_issue("I1"))),
3571 BindingCause::IntentNotCritiqued
3572 );
3573 let blocked = [critique("x", &[("F1", true, "open")])];
3574 assert_eq!(
3575 first(&ask(&blocked, by_issue("I1"))),
3576 BindingCause::OpenBlockingFinding
3577 );
3578 for (id, cause) in [
3579 ("I5", BindingCause::IssueServesNothing),
3580 ("I6", BindingCause::ServesAnotherIntent),
3581 ("I7", BindingCause::ServesUnknownCriterion),
3582 ] {
3583 assert_eq!(first(&ask(&reviewed, by_issue(id))), cause, "{id}");
3584 }
3585 for (from, to) in [
3586 (
3587 IntentPreflightCause::UnknownIssue,
3588 BindingCause::UnknownIssue,
3589 ),
3590 (
3591 IntentPreflightCause::NoIssueCoversPaths,
3592 BindingCause::NoIssueCoversPaths,
3593 ),
3594 (
3595 IntentPreflightCause::IssueServesNothing,
3596 BindingCause::IssueServesNothing,
3597 ),
3598 (
3599 IntentPreflightCause::ServesAnotherIntent,
3600 BindingCause::ServesAnotherIntent,
3601 ),
3602 (
3603 IntentPreflightCause::ServesUnknownCriterion,
3604 BindingCause::ServesUnknownCriterion,
3605 ),
3606 (
3607 IntentPreflightCause::IntentNotCritiqued,
3608 BindingCause::IntentNotCritiqued,
3609 ),
3610 (
3611 IntentPreflightCause::OpenBlockingFinding,
3612 BindingCause::OpenBlockingFinding,
3613 ),
3614 ] {
3615 assert_eq!(BindingCause::from(from), to);
3616 assert_eq!(
3617 serde_json::to_value(from).unwrap(),
3618 serde_json::to_value(to).unwrap(),
3619 "the word changed on the way"
3620 );
3621 }
3622 }
3623}