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}