Skip to main content

majordomus_cli/
intent_plan.rs

1//! Intent coverage: which work makes which intent criterion true, derived from the plan.
2//!
3//! An intent names the milestones that realise it and the criteria that settle it; an issue
4//! names, in `serves`, the `<intent>#<criterion>` it exists to make true. Neither side stores
5//! the join. This module derives it on every read and refuses the shapes that mean the plan
6//! does not carry the intent (ADR 0073):
7//!
8//! - a criterion no live issue serves is **uncovered** — nothing will make it true;
9//! - an issue under an intent's milestone that serves nothing has **no purpose** under it;
10//! - an issue that serves a criterion of an intent whose milestones do not include its own
11//!   milestone serves it from **outside** the intent's plan;
12//! - a reference to an intent or a criterion that does not exist is a **dangling** link.
13//!
14//! Three weaker shapes are warnings, because they are sometimes right and always worth a
15//! look: a criterion whose every serving issue requires no evidence is only **weakly**
16//! covered; a milestone an intent names that serves none of its criteria **contributes
17//! nothing** yet; two live issues serving one criterion over overlapping scope are
18//! **duplicate work**.
19//!
20//! An issue under a milestone no intent names is maintenance work. It is reported with that
21//! origin, never invented an intent, and never a finding.
22//!
23//! The derivation is pure over an [`IntentOutline`] per intent and the derived [`Plan`], so a
24//! test can walk every branch without an index, and the intent engine converts its own record
25//! into an outline rather than this module reading intent files a second way.
26//!
27//! ```
28//! use majordomus_cli::intent_plan::{coverage, CoverageStrength, IntentOutline};
29//! use majordomus_cli::plan::Plan;
30//! # let plan: Plan = serde_json::from_value(serde_json::json!({
31//! #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
32//! #                 "active_milestone": ""},
33//! #     "statuses": {"issue": [], "milestone": []},
34//! #     "milestones": [], "waves": [], "edges": [], "milestone_edges": [], "findings": [],
35//! #     "issues": [{"id": "I1", "milestone": "m", "status": "READY", "wave": 1,
36//! #         "priority": "p1", "profile": "implementation", "parallel_safe": true,
37//! #         "title": "t", "slug": "", "depends_on": [], "blocked_by": [], "dependents": [],
38//! #         "scope": ["src"], "serves": ["x#a"], "objective": "", "evidence_have": 0,
39//! #         "evidence_need": 1, "started_at": "", "verified_at": "", "completed_at": ""}]
40//! # })).unwrap();
41//! let intent = IntentOutline {
42//!     id: "x".into(),
43//!     criteria: vec!["a".into(), "b".into()],
44//!     milestones: vec!["m".into()],
45//!     ..Default::default()
46//! };
47//! let covered = coverage(&[intent], &plan);
48//! // I1 serves x#a, so that criterion is carried; x#b is not, and that is a failure.
49//! assert_eq!(covered.criterion("x", "a").unwrap().strength, CoverageStrength::Covered);
50//! assert_eq!(covered.criterion("x", "b").unwrap().strength, CoverageStrength::Uncovered);
51//! assert_eq!(covered.failures(), 1);
52//! ```
53
54use std::collections::{BTreeMap, BTreeSet};
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58
59use crate::intent::{IntentFinding, FAIL, REPRODUCE, WARN};
60use crate::order::{canonical, OrderKey, Ordered};
61use crate::plan::{overlap, Plan, PlanIssue};
62
63/// What coverage needs of one intent: its identity, its criterion ids, its milestones, and
64/// whether it is retired (cancelled or superseded) and so owes nothing.
65///
66/// The intent engine builds one of these from each [`crate::intent::IntentRecord`], so the
67/// intent files are read one way and this module never reads them a second time.
68///
69/// ```
70/// use majordomus_cli::intent_plan::IntentOutline;
71/// let outline = IntentOutline {
72///     id: "intent-planning".into(),
73///     criteria: vec!["criteria-are-covered".into()],
74///     milestones: vec!["intent-planning".into()],
75///     ..Default::default()
76/// };
77/// assert!(!outline.retired);
78/// assert!(outline.observed_satisfied.is_empty());
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub struct IntentOutline {
82    /// The intent's identity.
83    pub id: String,
84    /// Its satisfaction criterion ids, in declaration order.
85    pub criteria: Vec<String>,
86    /// The milestone ids that realise it.
87    pub milestones: Vec<String>,
88    /// Cancelled or superseded: no coverage is required of it.
89    pub retired: bool,
90    /// The criteria its recorded gap observed already satisfied, with observations behind
91    /// them (`intent_review::observed_satisfied`): no work is asked of these.
92    pub observed_satisfied: Vec<String>,
93}
94
95/// How well a criterion is carried by the plan: whether work exists for it, whether that work
96/// is gated by evidence, and whether the recorded gap already saw it true.
97///
98/// ```
99/// use majordomus_cli::intent_plan::CoverageStrength;
100/// // the wire spelling every surface answers with
101/// assert_eq!(serde_json::to_string(&CoverageStrength::Weak).unwrap(), "\"weak\"");
102/// // the order is the one a reader ranks by: uncovered is the worst
103/// assert!(CoverageStrength::Uncovered < CoverageStrength::Covered);
104/// ```
105#[derive(
106    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
107)]
108#[serde(rename_all = "snake_case")]
109pub enum CoverageStrength {
110    /// No live issue serves it.
111    Uncovered,
112    /// No live issue serves it, and none needs to: the recorded gap observed it already true.
113    /// Whether it is *met* is still the evidence's question, never this one's.
114    Observed,
115    /// Every live issue serving it requires no evidence, so nothing gates its completion.
116    Weak,
117    /// At least one live issue serving it requires evidence before it is DONE.
118    Covered,
119}
120
121/// One issue as coverage sees it: which milestone carries it, the status the plan derived for
122/// it, and how much evidence it owes before it may be DONE.
123///
124/// ```
125/// use majordomus_cli::intent_plan::CoveringIssue;
126/// let issue = CoveringIssue {
127///     id: "I1901".into(),
128///     milestone: "intent-planning".into(),
129///     status: "READY".into(),
130///     evidence_need: 2,
131/// };
132/// // an issue that owes no evidence covers a criterion only weakly
133/// assert!(issue.evidence_need > 0);
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136pub struct CoveringIssue {
137    /// The issue id.
138    pub id: String,
139    /// Its milestone.
140    pub milestone: String,
141    /// Its derived status.
142    pub status: String,
143    /// How many evidence tokens it requires before it may be DONE.
144    pub evidence_need: u32,
145}
146
147/// One criterion of one intent, and the work that carries it: the live issues that serve it,
148/// the milestones those issues sit under, and the strength derived from them.
149///
150/// ```
151/// use majordomus_cli::intent_plan::{CoverageStrength, CriterionCoverage};
152/// let c = CriterionCoverage {
153///     intent: "intent-planning".into(),
154///     criterion: "gap-is-recorded".into(),
155///     strength: CoverageStrength::Uncovered,
156///     issues: vec![],
157///     milestones: vec![],
158/// };
159/// // nothing serves it, so nothing in the plan will make it true
160/// assert_eq!(c.strength, CoverageStrength::Uncovered);
161/// assert!(c.issues.is_empty());
162/// ```
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164pub struct CriterionCoverage {
165    /// The intent.
166    pub intent: String,
167    /// The criterion id inside it.
168    pub criterion: String,
169    /// The strength derived from the serving issues.
170    pub strength: CoverageStrength,
171    /// Every live issue that serves it, in id order.
172    pub issues: Vec<CoveringIssue>,
173    /// The milestones those issues belong to, sorted and unique.
174    pub milestones: Vec<String>,
175}
176
177/// Why an issue exists: because it serves a criterion, because it is maintenance under a
178/// milestone no intent names, or because nobody said — which is a failure under an intent.
179///
180/// ```
181/// use majordomus_cli::intent_plan::IssueOrigin;
182/// assert_eq!(serde_json::to_string(&IssueOrigin::Maintenance).unwrap(), "\"maintenance\"");
183/// // work under no intent is legitimate and says so; it is never given an invented intent
184/// assert_ne!(IssueOrigin::Maintenance, IssueOrigin::Unexplained);
185/// ```
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187#[serde(rename_all = "snake_case")]
188pub enum IssueOrigin {
189    /// It serves at least one criterion of a declared intent.
190    Intent,
191    /// Its milestone is named by no intent: maintenance or operational work, stated as such.
192    Maintenance,
193    /// Its milestone realises an intent, but it names nothing it serves.
194    Unexplained,
195}
196
197/// The answer to "why does this issue exist?": the criteria it declares it serves, the intents
198/// whose milestones contain it, and which of the three origins that adds up to.
199///
200/// ```
201/// use majordomus_cli::intent_plan::{IssueOrigin, IssuePurpose};
202/// let purpose = IssuePurpose {
203///     issue: "I1901".into(),
204///     milestone: "intent-planning".into(),
205///     origin: IssueOrigin::Intent,
206///     serves: vec!["intent-planning#criteria-are-covered".into()],
207///     intents: vec!["intent-planning".into()],
208/// };
209/// assert_eq!(purpose.origin, IssueOrigin::Intent);
210/// assert_eq!(purpose.serves.len(), 1);
211/// ```
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
213pub struct IssuePurpose {
214    /// The issue id.
215    pub issue: String,
216    /// Its milestone.
217    pub milestone: String,
218    /// Which kind of reason it has.
219    pub origin: IssueOrigin,
220    /// The `<intent>#<criterion>` references it declares, as declared.
221    pub serves: Vec<String>,
222    /// The intents whose milestones include this issue's milestone, sorted.
223    pub intents: Vec<String>,
224}
225
226/// Issues are listed in the natural order of their identity, as everywhere else.
227impl Ordered for IssuePurpose {
228    fn order_key(&self) -> OrderKey<'_> {
229        OrderKey::plain(&self.issue, &self.issue)
230    }
231}
232
233/// The whole derived coverage: every criterion of every live intent, every issue with the
234/// reason it exists, and every finding the two produced. Nothing here is stored; it is derived
235/// on each read from the intents and the plan.
236///
237/// ```
238/// use majordomus_cli::intent_plan::{IntentCoverage, IssueOrigin, IssuePurpose};
239/// let coverage = IntentCoverage {
240///     criteria: vec![],
241///     issues: vec![IssuePurpose {
242///         issue: "I0007".into(),
243///         milestone: "ops".into(),
244///         origin: IssueOrigin::Maintenance,
245///         serves: vec![],
246///         intents: vec![],
247///     }],
248///     findings: vec![],
249/// };
250/// assert_eq!(coverage.failures(), 0);
251/// assert_eq!(coverage.purpose("I0007").unwrap().origin, IssueOrigin::Maintenance);
252/// ```
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
254pub struct IntentCoverage {
255    /// Every criterion of every live intent, in intent then declaration order.
256    pub criteria: Vec<CriterionCoverage>,
257    /// Every issue of the plan, in id order, with the reason it exists.
258    pub issues: Vec<IssuePurpose>,
259    /// Every finding, in derivation order.
260    pub findings: Vec<IntentFinding>,
261}
262
263impl IntentCoverage {
264    /// How many of the findings are failures rather than warnings: the count that decides
265    /// whether `majordomus intent validate` exits 10.
266    ///
267    /// ```
268    /// use majordomus_cli::intent_plan::IntentCoverage;
269    /// let clean = IntentCoverage { criteria: vec![], issues: vec![], findings: vec![] };
270    /// assert_eq!(clean.failures(), 0);
271    /// ```
272    pub fn failures(&self) -> usize {
273        self.findings.iter().filter(|f| f.level == FAIL).count()
274    }
275
276    /// The purpose of one issue by id, or `None` when the plan holds no such issue — the
277    /// answer to "why does this issue exist?".
278    ///
279    /// ```
280    /// use majordomus_cli::intent_plan::IntentCoverage;
281    /// let coverage = IntentCoverage { criteria: vec![], issues: vec![], findings: vec![] };
282    /// assert!(coverage.purpose("I0001").is_none());
283    /// ```
284    pub fn purpose(&self, issue: &str) -> Option<&IssuePurpose> {
285        self.issues.iter().find(|p| p.issue == issue)
286    }
287
288    /// The coverage of one `<intent>#<criterion>`, or `None` when no live intent declares it
289    /// — the answer to "which work carries this criterion?".
290    ///
291    /// ```
292    /// use majordomus_cli::intent_plan::IntentCoverage;
293    /// let coverage = IntentCoverage { criteria: vec![], issues: vec![], findings: vec![] };
294    /// assert!(coverage.criterion("intent-planning", "gap-is-recorded").is_none());
295    /// ```
296    pub fn criterion(&self, intent: &str, criterion: &str) -> Option<&CriterionCoverage> {
297        self.criteria
298            .iter()
299            .find(|c| c.intent == intent && c.criterion == criterion)
300    }
301}
302
303/// Is `token` an `<intent-id>#<criterion-id>` reference: two lowercase slugs joined by one
304/// `#`? The same pattern the issue schema declares for `serves`.
305///
306/// ```
307/// use majordomus_cli::intent_plan::serves_token;
308/// assert!(serves_token("intent-lifecycle#stage-is-derived"));
309/// assert!(!serves_token("intent-lifecycle"));
310/// assert!(!serves_token("Intent#x"));
311/// assert!(!serves_token("a#b#c"));
312/// ```
313pub fn serves_token(token: &str) -> bool {
314    let slug = |s: &str| {
315        s.chars()
316            .next()
317            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
318            && s.chars()
319                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
320    };
321    matches!(token.split_once('#'), Some((i, c)) if slug(i) && slug(c))
322}
323
324fn live(i: &PlanIssue) -> bool {
325    i.status != "CANCELLED"
326}
327
328struct Findings(Vec<IntentFinding>);
329
330impl Findings {
331    fn push(&mut self, level: &str, code: &str, subject: &str, message: String) {
332        self.0.push(IntentFinding {
333            level: level.into(),
334            code: code.into(),
335            subject: subject.into(),
336            message,
337            reproduce: REPRODUCE.into(),
338        });
339    }
340}
341
342/// Derive the coverage of `intents` by `plan`.
343///
344/// ```
345/// use majordomus_cli::intent_plan::{coverage, CoverageStrength, IntentOutline, IssueOrigin};
346/// use majordomus_cli::plan::Plan;
347/// let plan: Plan = serde_json::from_value(serde_json::json!({
348///     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
349///                 "active_milestone": ""},
350///     "statuses": {"issue": [], "milestone": []},
351///     "milestones": [], "waves": [], "edges": [], "milestone_edges": [], "findings": [],
352///     "issues": [{"id": "I1", "milestone": "m", "status": "READY", "wave": 1,
353///         "priority": "p1", "profile": "implementation", "parallel_safe": true,
354///         "title": "t", "slug": "", "depends_on": [], "blocked_by": [], "dependents": [],
355///         "scope": ["src"], "serves": ["x#works"], "objective": "", "evidence_have": 0,
356///         "evidence_need": 1, "started_at": "", "verified_at": "", "completed_at": ""}]
357/// })).unwrap();
358/// let x = IntentOutline { id: "x".into(), criteria: vec!["works".into()],
359///                         milestones: vec!["m".into()], ..Default::default() };
360/// let c = coverage(&[x], &plan);
361/// assert_eq!(c.failures(), 0);
362/// assert_eq!(c.criterion("x", "works").unwrap().strength, CoverageStrength::Covered);
363/// assert_eq!(c.purpose("I1").unwrap().origin, IssueOrigin::Intent);
364/// ```
365pub fn coverage(intents: &[IntentOutline], plan: &Plan) -> IntentCoverage {
366    let mut f = Findings(Vec::new());
367    let by_id: BTreeMap<&str, &IntentOutline> =
368        intents.iter().map(|i| (i.id.as_str(), i)).collect();
369
370    // milestone -> the live intents that name it
371    let mut realises: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
372    for i in intents.iter().filter(|i| !i.retired) {
373        for m in &i.milestones {
374            realises
375                .entry(m.as_str())
376                .or_default()
377                .insert(i.id.as_str());
378        }
379    }
380
381    // --- every issue: its purpose, and the links it declares
382    let mut serving: BTreeMap<(String, String), Vec<&PlanIssue>> = BTreeMap::new();
383    let mut purposes = Vec::with_capacity(plan.issues.len());
384    for issue in &plan.issues {
385        let under: Vec<String> = realises
386            .get(issue.milestone.as_str())
387            .map(|s| s.iter().map(|x| (*x).to_string()).collect())
388            .unwrap_or_default();
389        let mut serves_live = false;
390        let mut seen: BTreeSet<&str> = BTreeSet::new();
391        for token in &issue.serves {
392            // the one place a token is split: a token that is not `<intent>#<criterion>` is
393            // refused here, so no later step can read a half of it as the whole
394            let Some((iid, cid)) = token.split_once('#').filter(|_| serves_token(token)) else {
395                f.push(
396                    FAIL,
397                    "malformed_serves",
398                    &issue.id,
399                    format!("serves {token}, which is not <intent-id>#<criterion-id>"),
400                );
401                continue;
402            };
403            if !seen.insert(token.as_str()) {
404                f.push(
405                    WARN,
406                    "duplicate_serves",
407                    &issue.id,
408                    format!("serves {token} more than once"),
409                );
410                continue;
411            }
412            let Some(intent) = by_id.get(iid) else {
413                f.push(
414                    FAIL,
415                    "serves_unknown_intent",
416                    &issue.id,
417                    format!("serves {token}, but no intent is called {iid}"),
418                );
419                continue;
420            };
421            if !intent.criteria.iter().any(|c| c == cid) {
422                f.push(
423                    FAIL,
424                    "serves_unknown_criterion",
425                    &issue.id,
426                    format!("serves {token}, but intent {iid} declares no criterion {cid}"),
427                );
428                continue;
429            }
430            if intent.retired {
431                continue; // a retired intent owes nothing and is owed nothing
432            }
433            if !intent.milestones.contains(&issue.milestone) {
434                f.push(
435                    FAIL,
436                    "serves_outside_milestone",
437                    &issue.id,
438                    format!(
439                        "serves {token} from milestone {}, which intent {iid} does not name",
440                        issue.milestone
441                    ),
442                );
443                continue;
444            }
445            if live(issue) {
446                serves_live = true;
447                serving
448                    .entry((iid.to_string(), cid.to_string()))
449                    .or_default()
450                    .push(issue);
451            }
452        }
453        let origin = if serves_live {
454            IssueOrigin::Intent
455        } else if under.is_empty() {
456            IssueOrigin::Maintenance
457        } else {
458            IssueOrigin::Unexplained
459        };
460        if origin == IssueOrigin::Unexplained && live(issue) && issue.serves.is_empty() {
461            f.push(
462                FAIL,
463                "issue_without_purpose",
464                &issue.id,
465                format!(
466                    "is under milestone {}, which realises {}, but serves none of its criteria",
467                    issue.milestone,
468                    under.join(", ")
469                ),
470            );
471        }
472        purposes.push(IssuePurpose {
473            issue: issue.id.clone(),
474            milestone: issue.milestone.clone(),
475            origin,
476            serves: issue.serves.clone(),
477            intents: under,
478        });
479    }
480    canonical(&mut purposes);
481
482    // --- every criterion of every live intent
483    let mut criteria = Vec::new();
484    for intent in intents.iter().filter(|i| !i.retired) {
485        // An intent nobody has planned yet is a different state from a plan that misses a
486        // criterion: the first is where every intent starts, the second is the defect this
487        // exists to catch. So an intent whose milestones carry no live issue is reported
488        // once, as a warning, and its criteria are not each refused for want of work.
489        let planned = plan
490            .issues
491            .iter()
492            .filter(|i| live(i))
493            .any(|i| intent.milestones.contains(&i.milestone));
494        if !planned {
495            f.push(
496                WARN,
497                "intent_not_planned",
498                &intent.id,
499                "no live issue sits under any milestone it names; nothing is planned for it yet"
500                    .into(),
501            );
502        }
503        for cid in &intent.criteria {
504            let key = (intent.id.clone(), cid.clone());
505            let mut issues: Vec<&PlanIssue> = serving.get(&key).cloned().unwrap_or_default();
506            canonical(&mut issues);
507            issues.dedup_by(|a, b| a.id == b.id);
508            let subject = format!("{}#{cid}", intent.id);
509            let strength = if issues.is_empty() && intent.observed_satisfied.contains(cid) {
510                CoverageStrength::Observed
511            } else if issues.is_empty() {
512                CoverageStrength::Uncovered
513            } else if issues.iter().all(|i| i.evidence_need == 0) {
514                CoverageStrength::Weak
515            } else {
516                CoverageStrength::Covered
517            };
518            match strength {
519                // Only once work exists under the intent: see `intent_not_planned` above.
520                CoverageStrength::Uncovered if planned => f.push(
521                    FAIL,
522                    "criterion_uncovered",
523                    &subject,
524                    "no live issue serves it; nothing in the plan will make it true".into(),
525                ),
526                CoverageStrength::Uncovered => {}
527                CoverageStrength::Weak => f.push(
528                    WARN,
529                    "criterion_weakly_covered",
530                    &subject,
531                    "every issue serving it requires no evidence, so nothing gates it".into(),
532                ),
533                CoverageStrength::Covered | CoverageStrength::Observed => {}
534            }
535            for (n, a) in issues.iter().enumerate() {
536                for b in &issues[n + 1..] {
537                    if a.scope
538                        .iter()
539                        .any(|x| b.scope.iter().any(|y| overlap(x, y)))
540                    {
541                        f.push(
542                            WARN,
543                            "duplicate_work",
544                            &subject,
545                            format!("{} and {} both serve it over overlapping scope", a.id, b.id),
546                        );
547                    }
548                }
549            }
550            let milestones: BTreeSet<String> = issues.iter().map(|i| i.milestone.clone()).collect();
551            criteria.push(CriterionCoverage {
552                intent: intent.id.clone(),
553                criterion: cid.clone(),
554                strength,
555                issues: issues
556                    .iter()
557                    .map(|i| CoveringIssue {
558                        id: i.id.clone(),
559                        milestone: i.milestone.clone(),
560                        status: i.status.clone(),
561                        evidence_need: i.evidence_need,
562                    })
563                    .collect(),
564                milestones: milestones.into_iter().collect(),
565            });
566        }
567        // --- milestones the intent names that carry none of its criteria
568        for m in &intent.milestones {
569            let contributes = serving
570                .iter()
571                .any(|((iid, _), is)| iid == &intent.id && is.iter().any(|i| &i.milestone == m));
572            if !contributes {
573                f.push(
574                    WARN,
575                    "milestone_contributes_nothing",
576                    m,
577                    format!("is named by intent {}, but no live issue under it serves any of its criteria", intent.id),
578                );
579            }
580        }
581    }
582
583    IntentCoverage {
584        criteria,
585        issues: purposes,
586        findings: f.0,
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::plan::{PlanProject, PlanVocabulary};
594
595    #[test]
596    fn a_serves_token_is_two_slugs_that_may_start_with_a_digit_but_never_a_capital() {
597        for (token, ok) in [
598            ("intent-lifecycle#stage-is-derived", true),
599            ("0intent#1-criterion", true),
600            ("Intent#x", false),
601            ("x#Criterion", false),
602            ("-x#y", false),
603            ("x#", false),
604            ("#y", false),
605            ("x_y#z", false),
606            ("a#b#c", false),
607            ("no-hash", false),
608        ] {
609            assert_eq!(serves_token(token), ok, "{token}");
610        }
611    }
612
613    fn issue(id: &str, milestone: &str, serves: &[&str], scope: &[&str], need: u32) -> PlanIssue {
614        PlanIssue {
615            id: id.into(),
616            milestone: milestone.into(),
617            status: "READY".into(),
618            wave: 1,
619            priority: "p1".into(),
620            profile: "implementation".into(),
621            parallel_safe: true,
622            title: id.into(),
623            slug: String::new(),
624            depends_on: Vec::new(),
625            blocked_by: Vec::new(),
626            dependents: Vec::new(),
627            scope: scope.iter().map(|s| (*s).into()).collect(),
628            serves: serves.iter().map(|s| (*s).into()).collect(),
629            objective: String::new(),
630            evidence_have: 0,
631            evidence_need: need,
632            started_at: String::new(),
633            verified_at: String::new(),
634            completed_at: String::new(),
635        }
636    }
637
638    fn plan(issues: Vec<PlanIssue>) -> Plan {
639        Plan {
640            project: PlanProject {
641                name: "p".into(),
642                repository: "o/p".into(),
643                default_branch: "master".into(),
644                active_milestone: String::new(),
645            },
646            statuses: PlanVocabulary {
647                issue: Vec::new(),
648                milestone: Vec::new(),
649            },
650            milestones: Vec::new(),
651            issues,
652            waves: Vec::new(),
653            edges: Vec::new(),
654            milestone_edges: Vec::new(),
655            findings: Vec::new(),
656        }
657    }
658
659    fn intent(id: &str, criteria: &[&str], milestones: &[&str]) -> IntentOutline {
660        IntentOutline {
661            id: id.into(),
662            criteria: criteria.iter().map(|s| (*s).into()).collect(),
663            milestones: milestones.iter().map(|s| (*s).into()).collect(),
664            ..Default::default()
665        }
666    }
667
668    fn codes(c: &IntentCoverage) -> Vec<(&str, &str)> {
669        c.findings
670            .iter()
671            .map(|f| (f.code.as_str(), f.subject.as_str()))
672            .collect()
673    }
674
675    #[test]
676    fn every_criterion_carried_by_evidence_gated_work_is_clean() {
677        let c = coverage(
678            &[intent("x", &["a", "b"], &["m1", "m2"])],
679            &plan(vec![
680                issue("I1", "m1", &["x#a"], &["src/a"], 1),
681                issue("I2", "m2", &["x#b"], &["src/b"], 2),
682            ]),
683        );
684        assert!(c.findings.is_empty(), "{:?}", c.findings);
685        assert_eq!(c.criterion("x", "b").unwrap().milestones, ["m2"]);
686    }
687
688    #[test]
689    fn an_intent_nobody_has_planned_is_a_warning_not_a_refusal() {
690        let c = coverage(&[intent("x", &["a"], &["m1"])], &plan(vec![]));
691        assert_eq!(
692            codes(&c),
693            [
694                ("intent_not_planned", "x"),
695                ("milestone_contributes_nothing", "m1")
696            ]
697        );
698        assert_eq!(c.failures(), 0);
699        assert_eq!(
700            c.criterion("x", "a").unwrap().strength,
701            CoverageStrength::Uncovered
702        );
703    }
704
705    #[test]
706    fn a_criterion_nothing_serves_is_uncovered_and_refused() {
707        let c = coverage(
708            &[intent("x", &["a", "b"], &["m1"])],
709            &plan(vec![issue("I1", "m1", &["x#a"], &["src"], 1)]),
710        );
711        assert_eq!(codes(&c), [("criterion_uncovered", "x#b")]);
712        assert_eq!(c.failures(), 1);
713        assert_eq!(
714            c.criterion("x", "b").unwrap().strength,
715            CoverageStrength::Uncovered
716        );
717    }
718
719    #[test]
720    fn a_criterion_the_gap_observed_true_asks_for_no_work() {
721        let mut x = intent("x", &["a", "b"], &["m1"]);
722        x.observed_satisfied = vec!["b".into()];
723        let c = coverage(&[x], &plan(vec![issue("I1", "m1", &["x#a"], &["src"], 1)]));
724        assert!(c.findings.is_empty(), "{:?}", c.findings);
725        assert_eq!(
726            c.criterion("x", "b").unwrap().strength,
727            CoverageStrength::Observed
728        );
729    }
730
731    #[test]
732    fn a_cancelled_issue_covers_nothing() {
733        let mut gone = issue("I1", "m1", &["x#a"], &["src"], 1);
734        gone.status = "CANCELLED".into();
735        let c = coverage(&[intent("x", &["a"], &["m1"])], &plan(vec![gone]));
736        // the only issue is cancelled, so nothing is planned: a warning, not a refusal
737        assert!(codes(&c).contains(&("intent_not_planned", "x")));
738        assert_eq!(c.failures(), 0);
739        // a cancelled issue is not asked for a purpose either
740        assert!(!codes(&c).iter().any(|(k, _)| *k == "issue_without_purpose"));
741    }
742
743    #[test]
744    fn work_under_an_intent_that_serves_nothing_has_no_purpose() {
745        let c = coverage(
746            &[intent("x", &["a"], &["m1"])],
747            &plan(vec![
748                issue("I1", "m1", &["x#a"], &["src/a"], 1),
749                issue("I2", "m1", &[], &["src/b"], 1),
750            ]),
751        );
752        assert_eq!(codes(&c), [("issue_without_purpose", "I2")]);
753        assert_eq!(c.purpose("I2").unwrap().origin, IssueOrigin::Unexplained);
754        assert_eq!(c.purpose("I2").unwrap().intents, ["x"]);
755    }
756
757    #[test]
758    fn work_under_no_intent_is_maintenance_and_not_a_finding() {
759        let c = coverage(
760            &[intent("x", &["a"], &["m1"])],
761            &plan(vec![
762                issue("I1", "m1", &["x#a"], &["src"], 1),
763                issue("I9", "ops", &[], &["ops"], 0),
764            ]),
765        );
766        assert!(c.findings.is_empty(), "{:?}", c.findings);
767        assert_eq!(c.purpose("I9").unwrap().origin, IssueOrigin::Maintenance);
768    }
769
770    #[test]
771    fn a_malformed_or_repeated_reference_is_named() {
772        let c = coverage(
773            &[intent("x", &["a"], &["m1"])],
774            &plan(vec![issue("I1", "m1", &["x#a", "x#a", "x"], &["src"], 1)]),
775        );
776        assert_eq!(
777            codes(&c),
778            [("duplicate_serves", "I1"), ("malformed_serves", "I1")]
779        );
780        assert_eq!(c.criterion("x", "a").unwrap().issues.len(), 1);
781    }
782
783    #[test]
784    fn a_dangling_reference_is_refused_by_name() {
785        let c = coverage(
786            &[intent("x", &["a"], &["m1"])],
787            &plan(vec![
788                issue("I1", "m1", &["x#a", "x#nope"], &["src"], 1),
789                issue("I2", "m1", &["y#a"], &["src2"], 1),
790            ]),
791        );
792        let got = codes(&c);
793        assert!(got.contains(&("serves_unknown_criterion", "I1")), "{got:?}");
794        assert!(got.contains(&("serves_unknown_intent", "I2")), "{got:?}");
795    }
796
797    #[test]
798    fn serving_from_a_milestone_the_intent_does_not_name_is_refused() {
799        let c = coverage(
800            &[intent("x", &["a"], &["m1"])],
801            &plan(vec![issue("I1", "elsewhere", &["x#a"], &["src"], 1)]),
802        );
803        let got = codes(&c);
804        assert!(got.contains(&("serves_outside_milestone", "I1")), "{got:?}");
805        assert!(got.contains(&("intent_not_planned", "x")), "{got:?}");
806        assert!(
807            got.contains(&("milestone_contributes_nothing", "m1")),
808            "{got:?}"
809        );
810    }
811
812    #[test]
813    fn coverage_without_required_evidence_is_weak() {
814        let c = coverage(
815            &[intent("x", &["a"], &["m1"])],
816            &plan(vec![issue("I1", "m1", &["x#a"], &["src"], 0)]),
817        );
818        assert_eq!(codes(&c), [("criterion_weakly_covered", "x#a")]);
819        assert_eq!(c.failures(), 0);
820    }
821
822    #[test]
823    fn two_issues_on_one_criterion_over_one_scope_are_duplicate_work() {
824        let c = coverage(
825            &[intent("x", &["a"], &["m1"])],
826            &plan(vec![
827                issue("I1", "m1", &["x#a"], &["src/lib"], 1),
828                issue("I2", "m1", &["x#a"], &["src"], 1),
829                issue("I3", "m1", &["x#a"], &["docs"], 1),
830            ]),
831        );
832        assert_eq!(codes(&c), [("duplicate_work", "x#a")]);
833        assert_eq!(c.criterion("x", "a").unwrap().issues.len(), 3);
834    }
835
836    #[test]
837    fn a_retired_intent_owes_no_coverage() {
838        let mut old = intent("x", &["a"], &["m1"]);
839        old.retired = true;
840        let c = coverage(
841            &[old],
842            &plan(vec![issue("I1", "m1", &["x#a"], &["src"], 1)]),
843        );
844        assert!(c.findings.is_empty(), "{:?}", c.findings);
845        assert!(c.criteria.is_empty());
846        assert_eq!(c.purpose("I1").unwrap().origin, IssueOrigin::Maintenance);
847    }
848
849    #[test]
850    fn the_derivation_does_not_depend_on_the_order_records_are_read() {
851        let intents = [
852            intent("x", &["a", "b"], &["m1"]),
853            intent("y", &["c"], &["m2"]),
854        ];
855        let issues = vec![
856            issue("I1", "m1", &["x#a"], &["a"], 1),
857            issue("I2", "m1", &["x#b"], &["b"], 0),
858            issue("I3", "m2", &[], &["c"], 1),
859        ];
860        let forward = coverage(&intents, &plan(issues.clone()));
861        let mut rev = issues;
862        rev.reverse();
863        let backward = coverage(&intents, &plan(rev));
864        assert_eq!(forward.criteria, backward.criteria);
865        assert_eq!(forward.issues, backward.issues);
866        let sorted = |c: &IntentCoverage| {
867            let mut v = c.findings.clone();
868            v.sort_by(|a, b| (&a.code, &a.subject).cmp(&(&b.code, &b.subject)));
869            v
870        };
871        assert_eq!(sorted(&forward), sorted(&backward));
872    }
873}