majordomus_cli/release/compat.rs
1//! The compatibility engine: how much the public contract moved, and therefore what the
2//! smallest honest version is.
3//!
4//! # The authority this takes away
5//!
6//! Before this module there were two answers to one question. `scripts/ci/version-matches-surface`
7//! compared the registry of two refs and said what the *surface* required; [`super::version`]
8//! counted `feat:` and `fix:` in the commit subjects and said what the *commits* implied, and
9//! that second answer was the one `release bump` actually wrote. So a capability deleted
10//! under a `refactor:` heading was a patch to the writer and a breaking change to the gate,
11//! and the tree could carry an under-versioned public contract until CI happened to look.
12//!
13//! There is one authority here now, and it is the contract:
14//!
15//! ```text
16//! surface(base) ─┐
17//! ├─ diff ─ implied impact ─ policy ─ required impact ─ required version
18//! surface(head) ─┘ │
19//! ▼
20//! one writer, which may not go under it
21//! ```
22//!
23//! Conventional commits did not go away and are not worthless — they are how a change
24//! explains itself, and they are the whole of the changelog's prose. They are carried here
25//! as [`CommitEvidence`], beside the verdict rather than deciding it, and when they
26//! *understate* what the contract did the plan says so explicitly. That line is the point of
27//! the subsystem: a human label and a measured fact, disagreeing, in the open.
28//!
29//! # Why unknown means major
30//!
31//! `compare_schema` models the schema mutations it can name and refuses to guess at the
32//! rest: a key it does not understand, changed, is [`Impact::Major`] with a diagnostic
33//! saying which pointer and that it is unmodelled. A false major is an argument; a false
34//! patch is a broken caller who finds out by breaking. Prose keys are normalised away first
35//! (see [`super::surface::normalise`]) precisely so that this conservatism costs nothing on
36//! the changes that are not contract changes at all.
37//!
38//! ```
39//! use majordomus_cli::release::compat::{Impact, Mode, Policy};
40//! use majordomus_cli::release::version::Version;
41//!
42//! // Below 1.0.0 a removal costs a minor rather than 1.0.0 — and is still named breaking.
43//! let policy = Policy::for_version(Version::parse("0.5.0").unwrap());
44//! assert_eq!(policy.mode, Mode::Pre1_0Strict);
45//! assert_eq!(policy.required_of(Impact::Major), Impact::Minor);
46//!
47//! // At 1.0.0 the shift ends and the implied impact is the requirement.
48//! let policy = Policy::for_version(Version::parse("1.2.0").unwrap());
49//! assert_eq!(policy.required_of(Impact::Major), Impact::Major);
50//! assert_eq!(policy.required_of(Impact::None), Impact::None, "an unchanged surface owes nothing");
51//! ```
52
53use std::collections::BTreeSet;
54use std::path::Path;
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59
60use super::surface::{PublicCapability, Surface, SurfaceError};
61use super::version::Version;
62use crate::model::Object;
63
64/// The policy these verdicts were reached under, carried into every plan so that a decision
65/// recorded today can still be explained when the rules below change.
66pub const POLICY_SCHEMA: &str = "majordomus/version-policy/v1";
67
68/// How much a change moves the contract.
69///
70/// Ordered, so that "the declared bump covers what is required" is a comparison rather than
71/// a table:
72///
73/// ```
74/// use majordomus_cli::release::compat::Impact;
75/// assert!(Impact::Major > Impact::Minor);
76/// assert!(Impact::Minor > Impact::Patch);
77/// assert!(Impact::Patch > Impact::None);
78/// ```
79#[derive(
80 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
81)]
82#[serde(rename_all = "snake_case")]
83#[schemars(rename = "ReleaseImpact")]
84pub enum Impact {
85 /// The contract did not move.
86 None,
87 /// It moved behind the boundary: a caller cannot tell.
88 Patch,
89 /// Something arrived; every existing caller still works.
90 Minor,
91 /// Something a caller could hold is gone or changed under it.
92 Major,
93}
94
95impl Impact {
96 /// The word every surface renders: the command line's report, the JSON every machine
97 /// surface answers with, the Cockpit's badge and the gate's refusal all print this and
98 /// never a wording of their own.
99 ///
100 /// ```
101 /// use majordomus_cli::release::compat::Impact;
102 /// assert_eq!(Impact::Major.as_str(), "major");
103 /// assert_eq!(Impact::None.as_str(), "none");
104 /// ```
105 pub fn as_str(self) -> &'static str {
106 match self {
107 Impact::None => "none",
108 Impact::Patch => "patch",
109 Impact::Minor => "minor",
110 Impact::Major => "major",
111 }
112 }
113
114 /// The impact a word names.
115 ///
116 /// Every impact round-trips through its word, and nothing else is one:
117 ///
118 /// ```
119 /// use majordomus_cli::release::compat::Impact;
120 /// assert_eq!(Impact::parse("minor"), Some(Impact::Minor));
121 /// assert_eq!(Impact::parse("enormous"), None);
122 /// ```
123 pub fn parse(word: &str) -> Option<Impact> {
124 match word {
125 "none" => Some(Impact::None),
126 "patch" => Some(Impact::Patch),
127 "minor" => Some(Impact::Minor),
128 "major" => Some(Impact::Major),
129 _ => None,
130 }
131 }
132}
133
134/// Whether a change added, removed or altered part of the contract.
135///
136/// ```
137/// use majordomus_cli::release::compat::Direction;
138/// assert_eq!(Direction::Removed.sign(), '-');
139/// ```
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
141#[serde(rename_all = "snake_case")]
142#[schemars(rename = "ReleaseChangeDirection")]
143pub enum Direction {
144 /// It was not there before.
145 Added,
146 /// It was there before and is not now.
147 Removed,
148 /// It is there in both, and differs.
149 Changed,
150}
151
152impl Direction {
153 /// The sign the reports print beside a movement, so that a list of changes reads as a
154 /// diff does: what arrived, what left, and what stayed and differs.
155 ///
156 /// ```
157 /// use majordomus_cli::release::compat::Direction;
158 /// assert_eq!(Direction::Added.sign(), '+');
159 /// assert_eq!(Direction::Changed.sign(), '~');
160 /// ```
161 pub fn sign(self) -> char {
162 match self {
163 Direction::Added => '+',
164 Direction::Removed => '-',
165 Direction::Changed => '~',
166 }
167 }
168}
169
170/// Which projection of the contract a change is in.
171///
172/// Not where it was *found* — everything here is found in the registry — but which promise
173/// it is, so that a reader sees "a route is gone" rather than a JSON pointer.
174///
175/// ```
176/// use majordomus_cli::release::compat::SurfaceKind;
177/// assert_eq!(SurfaceKind::Http.as_str(), "route");
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "snake_case")]
181#[schemars(rename = "ReleaseSurfaceKind")]
182pub enum SurfaceKind {
183 /// The capability itself.
184 Capability,
185 /// Whether calling it changes anything.
186 Kind,
187 /// An MCP tool name.
188 McpTool,
189 /// An MCP resource URI.
190 McpResource,
191 /// An HTTP method and path.
192 Http,
193 /// A command-line path.
194 Cli,
195 /// What a caller may send.
196 Input,
197 /// What a caller is promised back.
198 Output,
199}
200
201impl SurfaceKind {
202 /// The word the reports print.
203 ///
204 /// The word names the promise a reader recognises, not the field it came from:
205 ///
206 /// ```
207 /// use majordomus_cli::release::compat::SurfaceKind;
208 /// assert_eq!(SurfaceKind::Http.as_str(), "route");
209 /// assert_eq!(SurfaceKind::Cli.as_str(), "command");
210 /// assert_eq!(SurfaceKind::McpTool.as_str(), "mcp tool");
211 /// ```
212 pub fn as_str(self) -> &'static str {
213 match self {
214 SurfaceKind::Capability => "capability",
215 SurfaceKind::Kind => "kind",
216 SurfaceKind::McpTool => "mcp tool",
217 SurfaceKind::McpResource => "mcp resource",
218 SurfaceKind::Http => "route",
219 SurfaceKind::Cli => "command",
220 SurfaceKind::Input => "input",
221 SurfaceKind::Output => "output",
222 }
223 }
224}
225
226/// One movement of the public contract, with the reason it counts for what it does.
227///
228/// ```
229/// use majordomus_cli::release::compat::{Direction, Impact, SurfaceChange, SurfaceKind};
230/// let change = SurfaceChange {
231/// impact: Impact::Major,
232/// direction: Direction::Removed,
233/// surface: SurfaceKind::Http,
234/// capability: "alpha.list".into(),
235/// id: "GET /api/v1/alpha".into(),
236/// detail: "the capability survives; this way of reaching it does not".into(),
237/// };
238/// assert_eq!(change.to_string(), "- route GET /api/v1/alpha");
239/// ```
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[schemars(rename = "ReleaseSurfaceChange")]
242pub struct SurfaceChange {
243 /// How much it moves the contract on its own.
244 pub impact: Impact,
245 /// Added, removed, or altered.
246 pub direction: Direction,
247 /// Which promise it is.
248 pub surface: SurfaceKind,
249 /// The capability it belongs to.
250 pub capability: String,
251 /// The thing itself: `GET /api/v1/foo`, `majordomus_release_analysis`, `release.analysis`.
252 pub id: String,
253 /// Why this impact and not a smaller one, in one clause.
254 pub detail: String,
255}
256
257impl std::fmt::Display for SurfaceChange {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 write!(
260 f,
261 "{} {} {}",
262 self.direction.sign(),
263 self.surface.as_str(),
264 self.id
265 )
266 }
267}
268
269// ---------------------------------------------------------------------------- the diff
270
271/// Every way the contract moved between two surfaces.
272///
273/// A capability that was renamed appears as a removal and an addition, and deliberately so:
274/// that *is* what happened to a caller holding the old name, and collapsing the two into one
275/// "rename" would lose the breaking half while looking tidier.
276///
277/// ```
278/// use majordomus_cli::release::compat::{diff, Impact};
279/// use majordomus_cli::release::surface::{public_capabilities, Origin, Surface, SURFACE_SCHEMA};
280/// use serde_json::json;
281///
282/// let surface = |caps| Surface {
283/// schema: SURFACE_SCHEMA.to_string(),
284/// origin: Origin::WorkingTree,
285/// capabilities: public_capabilities(&json!({"capabilities": caps})),
286/// };
287/// let before = surface(json!([{"id": "a.one", "kind": "query", "visibility": "public"}]));
288/// let after = surface(json!([
289/// {"id": "a.one", "kind": "query", "visibility": "public"},
290/// {"id": "a.two", "kind": "query", "visibility": "public"},
291/// ]));
292///
293/// assert!(diff(&before, &before).is_empty(), "a surface diffed with itself is empty");
294/// let found = diff(&before, &after);
295/// assert_eq!(found.len(), 1);
296/// assert_eq!(found[0].impact, Impact::Minor, "an addition breaks nobody");
297/// // and the other way round, the same movement is breaking
298/// assert_eq!(diff(&after, &before)[0].impact, Impact::Major);
299/// ```
300pub fn diff(base: &Surface, head: &Surface) -> Vec<SurfaceChange> {
301 let mut changes = Vec::new();
302 let ids: BTreeSet<&String> = base
303 .capabilities
304 .keys()
305 .chain(head.capabilities.keys())
306 .collect();
307
308 for id in ids {
309 match (base.capabilities.get(id), head.capabilities.get(id)) {
310 (None, Some(_)) => changes.push(SurfaceChange {
311 impact: Impact::Minor,
312 direction: Direction::Added,
313 surface: SurfaceKind::Capability,
314 capability: id.clone(),
315 id: id.clone(),
316 detail: "a capability nothing could call before".into(),
317 // Its bindings are not listed separately: they arrived with it, and a
318 // reader counting "one new capability" should not also count three atoms.
319 }),
320 (Some(_), None) => changes.push(SurfaceChange {
321 impact: Impact::Major,
322 direction: Direction::Removed,
323 surface: SurfaceKind::Capability,
324 capability: id.clone(),
325 id: id.clone(),
326 detail: "a caller that held it has nothing to call".into(),
327 }),
328 (Some(b), Some(h)) => compare_capability(b, h, &mut changes),
329 (None, None) => unreachable!("the id came from one of the two maps"),
330 }
331 }
332 changes.sort_by(|a, b| {
333 // Worst first, then stable by what it is, so two runs print the same report.
334 b.impact
335 .cmp(&a.impact)
336 .then_with(|| a.capability.cmp(&b.capability))
337 .then_with(|| a.id.cmp(&b.id))
338 .then_with(|| a.detail.cmp(&b.detail))
339 });
340 changes
341}
342
343/// One capability present in both: every binding, then both schemas.
344fn compare_capability(
345 base: &PublicCapability,
346 head: &PublicCapability,
347 out: &mut Vec<SurfaceChange>,
348) {
349 let id = &base.id;
350
351 if base.kind != head.kind {
352 out.push(SurfaceChange {
353 impact: Impact::Major,
354 direction: Direction::Changed,
355 surface: SurfaceKind::Kind,
356 capability: id.clone(),
357 id: format!("{} -> {}", base.kind, head.kind),
358 detail: "whether calling it changes anything is part of the contract".into(),
359 });
360 }
361
362 binding(
363 out,
364 id,
365 SurfaceKind::McpTool,
366 base.mcp_tool.as_deref(),
367 head.mcp_tool.as_deref(),
368 );
369 binding(
370 out,
371 id,
372 SurfaceKind::McpResource,
373 base.mcp_resource.as_deref(),
374 head.mcp_resource.as_deref(),
375 );
376 binding(
377 out,
378 id,
379 SurfaceKind::Cli,
380 base.cli.as_deref(),
381 head.cli.as_deref(),
382 );
383 let b_http = base.http.as_ref().map(ToString::to_string);
384 let h_http = head.http.as_ref().map(ToString::to_string);
385 binding(
386 out,
387 id,
388 SurfaceKind::Http,
389 b_http.as_deref(),
390 h_http.as_deref(),
391 );
392
393 schema(
394 out,
395 id,
396 SurfaceKind::Input,
397 base.input.as_ref(),
398 head.input.as_ref(),
399 );
400 schema(
401 out,
402 id,
403 SurfaceKind::Output,
404 base.output.as_ref(),
405 head.output.as_ref(),
406 );
407}
408
409/// One optional name a caller can hold: gone is major, arrived is minor, moved is both.
410fn binding(
411 out: &mut Vec<SurfaceChange>,
412 capability: &str,
413 kind: SurfaceKind,
414 base: Option<&str>,
415 head: Option<&str>,
416) {
417 match (base, head) {
418 (Some(b), Some(h)) if b != h => {
419 // Two entries, not one "changed": a caller holding `b` is broken, and `h` is a
420 // thing that did not exist. Reporting a single move would understate the first.
421 out.push(SurfaceChange {
422 impact: Impact::Major,
423 direction: Direction::Removed,
424 surface: kind,
425 capability: capability.to_string(),
426 id: b.to_string(),
427 detail: format!("moved to `{h}`; a caller holding this one breaks"),
428 });
429 out.push(SurfaceChange {
430 impact: Impact::Minor,
431 direction: Direction::Added,
432 surface: kind,
433 capability: capability.to_string(),
434 id: h.to_string(),
435 detail: format!("where `{b}` used to answer"),
436 });
437 }
438 (Some(b), None) => out.push(SurfaceChange {
439 impact: Impact::Major,
440 direction: Direction::Removed,
441 surface: kind,
442 capability: capability.to_string(),
443 id: b.to_string(),
444 detail: "the capability survives; this way of reaching it does not".into(),
445 }),
446 (None, Some(h)) => out.push(SurfaceChange {
447 impact: Impact::Minor,
448 direction: Direction::Added,
449 surface: kind,
450 capability: capability.to_string(),
451 id: h.to_string(),
452 detail: "a new way to reach a capability that already existed".into(),
453 }),
454 _ => {}
455 }
456}
457
458/// One of the two contracts, compared structurally.
459fn schema(
460 out: &mut Vec<SurfaceChange>,
461 capability: &str,
462 role: SurfaceKind,
463 base: Option<&Value>,
464 head: Option<&Value>,
465) {
466 match (base, head) {
467 (Some(b), Some(h)) => {
468 let mut found = Vec::new();
469 compare_schema(role, b, h, "", &mut found);
470 for (impact, pointer, detail) in found {
471 out.push(SurfaceChange {
472 impact,
473 direction: Direction::Changed,
474 surface: role,
475 capability: capability.to_string(),
476 id: if pointer.is_empty() {
477 "the schema".to_string()
478 } else {
479 pointer
480 },
481 detail,
482 });
483 }
484 }
485 // A contract that appears constrains a caller that had none; one that disappears
486 // stops promising. Neither is a shape this repository produces — every capability
487 // declares both — so both are reported rather than ignored.
488 (None, Some(_)) => out.push(SurfaceChange {
489 impact: if role == SurfaceKind::Input {
490 Impact::Major
491 } else {
492 Impact::Minor
493 },
494 direction: Direction::Added,
495 surface: role,
496 capability: capability.to_string(),
497 id: "the schema".into(),
498 detail: "a contract where there was none".into(),
499 }),
500 (Some(_), None) => out.push(SurfaceChange {
501 impact: Impact::Major,
502 direction: Direction::Removed,
503 surface: role,
504 capability: capability.to_string(),
505 id: "the schema".into(),
506 detail: "the contract it stated is gone".into(),
507 }),
508 (None, None) => {}
509 }
510}
511
512/// The schema comparison, by JSON pointer.
513///
514/// `role` decides the direction compatibility runs in, and it is the whole reason the two
515/// are not one function: an input is what a caller *sends*, so accepting more is compatible
516/// and demanding more is breaking; an output is what a caller *receives*, so promising more
517/// is compatible and promising less is breaking. The same edit is minor in one and major in
518/// the other, which is exactly the mistake a textual diff of two schemas makes.
519fn compare_schema(
520 role: SurfaceKind,
521 base: &Value,
522 head: &Value,
523 at: &str,
524 out: &mut Vec<(Impact, String, String)>,
525) {
526 if base == head {
527 return;
528 }
529 let input = role == SurfaceKind::Input;
530 let here = |suffix: &str| {
531 if suffix.is_empty() {
532 at.to_string()
533 } else if at.is_empty() {
534 suffix.to_string()
535 } else {
536 format!("{at}.{suffix}")
537 }
538 };
539
540 let (Some(b), Some(h)) = (base.as_object(), head.as_object()) else {
541 // `true` / `false` schemas, or one side replaced wholesale.
542 out.push((
543 Impact::Major,
544 here(""),
545 "the schema was replaced by one of a different shape".into(),
546 ));
547 return;
548 };
549
550 // ------------------------------------------------------------------------ type
551 if b.get("type") != h.get("type") {
552 out.push((
553 Impact::Major,
554 here("type"),
555 format!(
556 "the type changed from {} to {}",
557 render(b.get("type")),
558 render(h.get("type"))
559 ),
560 ));
561 }
562
563 // ------------------------------------------------------------------------ enum
564 // A value that was accepted and is not any more breaks the caller that sends it; one
565 // that is accepted now and was not breaks nobody. The same holds for an output read by
566 // a caller that only knows the old values, which is why widening is minor and not none.
567 values(b.get("enum"), h.get("enum"), &mut |gone, new| {
568 for v in gone {
569 out.push((
570 Impact::Major,
571 here("enum"),
572 format!("the value {v} is no longer accepted"),
573 ));
574 }
575 for v in new {
576 out.push((
577 Impact::Minor,
578 here("enum"),
579 format!("the value {v} is accepted as well"),
580 ));
581 }
582 });
583
584 // --------------------------------------------------------- oneOf / anyOf / allOf
585 for key in ["oneOf", "anyOf"] {
586 values(b.get(key), h.get(key), &mut |gone, new| {
587 for v in gone {
588 out.push((Impact::Major, here(key), format!("the variant {v} is gone")));
589 }
590 for v in new {
591 out.push((Impact::Minor, here(key), format!("the variant {v} is new")));
592 }
593 });
594 }
595 if b.get("allOf") != h.get("allOf") {
596 // Every branch of an allOf must hold at once, so adding one narrows and removing
597 // one widens; rather than model that, it is named and called breaking.
598 out.push((
599 Impact::Major,
600 here("allOf"),
601 "the conjunction of constraints changed".into(),
602 ));
603 }
604
605 // -------------------------------------------------------------------- required
606 let b_req = string_set(b.get("required"));
607 let h_req = string_set(h.get("required"));
608 for name in h_req.difference(&b_req) {
609 out.push(if input {
610 (
611 Impact::Major,
612 here(&format!("required.{name}")),
613 format!("`{name}` is now required; a caller that did not send it is refused"),
614 )
615 } else {
616 (
617 Impact::Minor,
618 here(&format!("required.{name}")),
619 format!("`{name}` is now always present; a stronger promise than before"),
620 )
621 });
622 }
623 for name in b_req.difference(&h_req) {
624 out.push(if input {
625 (
626 Impact::Minor,
627 here(&format!("required.{name}")),
628 format!("`{name}` is no longer required; every existing caller still works"),
629 )
630 } else {
631 (
632 Impact::Major,
633 here(&format!("required.{name}")),
634 format!("`{name}` is no longer promised; a caller that reads it may find nothing"),
635 )
636 });
637 }
638
639 // ------------------------------------------------------------------ properties
640 let empty = serde_json::Map::new();
641 let b_props = b
642 .get("properties")
643 .and_then(|p| p.as_object())
644 .unwrap_or(&empty);
645 let h_props = h
646 .get("properties")
647 .and_then(|p| p.as_object())
648 .unwrap_or(&empty);
649 let names: BTreeSet<&String> = b_props.keys().chain(h_props.keys()).collect();
650 for name in names {
651 match (b_props.get(name), h_props.get(name)) {
652 (None, Some(_)) => out.push((
653 Impact::Minor,
654 here(&format!("properties.{name}")),
655 if input {
656 format!("`{name}` may now be sent; nothing that worked stopped working")
657 } else {
658 format!("`{name}` is returned as well; a caller ignoring it is unaffected")
659 },
660 )),
661 (Some(_), None) => out.push((
662 Impact::Major,
663 here(&format!("properties.{name}")),
664 if input {
665 // Every input schema in this repository derives `deny_unknown_fields`,
666 // so a property that is gone is not ignored — it is rejected.
667 format!("`{name}` is refused now; a caller still sending it gets an error")
668 } else {
669 format!("`{name}` is gone; a caller reading it finds nothing")
670 },
671 )),
672 (Some(bp), Some(hp)) => {
673 compare_schema(role, bp, hp, &here(&format!("properties.{name}")), out)
674 }
675 (None, None) => unreachable!("the name came from one of the two maps"),
676 }
677 }
678
679 // ----------------------------------------------------------------------- $defs
680 for key in ["$defs", "definitions"] {
681 let b_defs = b.get(key).and_then(|d| d.as_object()).unwrap_or(&empty);
682 let h_defs = h.get(key).and_then(|d| d.as_object()).unwrap_or(&empty);
683 let names: BTreeSet<&String> = b_defs.keys().chain(h_defs.keys()).collect();
684 for name in names {
685 match (b_defs.get(name), h_defs.get(name)) {
686 (Some(bd), Some(hd)) => {
687 compare_schema(role, bd, hd, &here(&format!("{key}.{name}")), out)
688 }
689 // A definition is only emitted when something refers to it, so one arriving
690 // or leaving is a referenced shape arriving or leaving.
691 (None, Some(_)) => out.push((
692 Impact::Minor,
693 here(&format!("{key}.{name}")),
694 format!("the shape `{name}` is part of the contract now"),
695 )),
696 (Some(_), None) => out.push((
697 Impact::Major,
698 here(&format!("{key}.{name}")),
699 format!("the shape `{name}` is gone"),
700 )),
701 (None, None) => unreachable!("the name came from one of the two maps"),
702 }
703 }
704 }
705
706 // ----------------------------------------------------------------------- items
707 for key in ["items", "additionalItems", "contains"] {
708 match (b.get(key), h.get(key)) {
709 (Some(bi), Some(hi)) => compare_schema(role, bi, hi, &here(key), out),
710 (None, None) => {}
711 _ => out.push((
712 Impact::Major,
713 here(key),
714 format!("`{key}` is stated on one side only"),
715 )),
716 }
717 }
718 if let (Some(Value::Array(ba)), Some(Value::Array(ha))) =
719 (b.get("prefixItems"), h.get("prefixItems"))
720 {
721 // Positional: index i of one is index i of the other, and a length change is a
722 // change to what a caller must supply or may read.
723 if ba.len() != ha.len() {
724 out.push((
725 Impact::Major,
726 here("prefixItems"),
727 format!("the tuple went from {} to {} positions", ba.len(), ha.len()),
728 ));
729 }
730 for (i, (bi, hi)) in ba.iter().zip(ha).enumerate() {
731 compare_schema(role, bi, hi, &here(&format!("prefixItems.{i}")), out);
732 }
733 }
734
735 // ------------------------------------------------------ additionalProperties, $ref
736 if b.get("additionalProperties") != h.get("additionalProperties") {
737 match (b.get("additionalProperties"), h.get("additionalProperties")) {
738 // Closing an open object refuses input that used to be accepted; opening a
739 // closed one accepts more. For an output the reasoning runs the other way, and
740 // neither direction is common enough here to be worth more than naming it.
741 (Some(Value::Bool(true)) | None, Some(Value::Bool(false))) => out.push((
742 Impact::Major,
743 here("additionalProperties"),
744 "unknown properties are refused now".into(),
745 )),
746 (Some(Value::Bool(false)), Some(Value::Bool(true)) | None) => out.push((
747 if input { Impact::Minor } else { Impact::Major },
748 here("additionalProperties"),
749 if input {
750 "unknown properties are accepted now".into()
751 } else {
752 "the shape of what is returned is no longer closed".to_string()
753 },
754 )),
755 _ => out.push((
756 Impact::Major,
757 here("additionalProperties"),
758 "what may appear beyond the declared properties changed".into(),
759 )),
760 }
761 }
762 if b.get("$ref") != h.get("$ref") {
763 out.push((
764 Impact::Major,
765 here("$ref"),
766 format!(
767 "it points at {} instead of {}",
768 render(h.get("$ref")),
769 render(b.get("$ref"))
770 ),
771 ));
772 }
773
774 // ------------------------------------------------------- everything not modelled
775 //
776 // A constraint this comparator has no rule for, that differs, is breaking — because the
777 // alternative is to call an unknown contract change a patch, and a caller finds that out
778 // by breaking. The pointer and the key are named so the answer is arguable rather than
779 // mysterious, and every key above is excluded because it already has a rule.
780 const MODELLED: &[&str] = &[
781 "type",
782 "enum",
783 "oneOf",
784 "anyOf",
785 "allOf",
786 "required",
787 "properties",
788 "$defs",
789 "definitions",
790 "items",
791 "additionalItems",
792 "contains",
793 "prefixItems",
794 "additionalProperties",
795 "$ref",
796 ];
797 let keys: BTreeSet<&String> = b.keys().chain(h.keys()).collect();
798 for key in keys {
799 if MODELLED.contains(&key.as_str()) {
800 continue;
801 }
802 if b.get(key) != h.get(key) {
803 out.push((
804 Impact::Major,
805 here(key),
806 format!(
807 "`{key}` changed from {} to {}, and this comparator does not model what \
808 that does to a caller; it is counted as breaking rather than guessed at",
809 render(b.get(key)),
810 render(h.get(key))
811 ),
812 ));
813 }
814 }
815}
816
817/// Compare two arrays as sets of values, calling back with what went and what arrived.
818fn values(
819 base: Option<&Value>,
820 head: Option<&Value>,
821 f: &mut impl FnMut(Vec<String>, Vec<String>),
822) {
823 let set = |v: Option<&Value>| -> BTreeSet<String> {
824 v.and_then(|v| v.as_array())
825 .map(|a| a.iter().map(compact).collect())
826 .unwrap_or_default()
827 };
828 let (b, h) = (set(base), set(head));
829 if b == h {
830 return;
831 }
832 f(
833 b.difference(&h).cloned().collect(),
834 h.difference(&b).cloned().collect(),
835 )
836}
837
838/// The `required` list as a set.
839fn string_set(v: Option<&Value>) -> BTreeSet<String> {
840 v.and_then(|v| v.as_array())
841 .map(|a| {
842 a.iter()
843 .filter_map(|x| x.as_str())
844 .map(str::to_string)
845 .collect()
846 })
847 .unwrap_or_default()
848}
849
850/// One value, compactly, for a message a person reads.
851fn compact(v: &Value) -> String {
852 match v {
853 Value::String(s) => format!("`{s}`"),
854 other => serde_json::to_string(other).unwrap_or_else(|_| "?".into()),
855 }
856}
857
858/// A value that may be absent, for a message a person reads.
859fn render(v: Option<&Value>) -> String {
860 v.map_or_else(|| "absent".to_string(), compact)
861}
862
863// -------------------------------------------------------------------------- the policy
864
865/// How this project turns a measured impact into the version it owes.
866///
867/// Semantic versioning hands `0.y.z` a blanket exemption — "anything MAY change at any
868/// time" — and Elm refuses the exemption by starting every package at `1.0.0`, because a
869/// rule with an escape hatch is a rule about when to use the hatch. This repository is `0.x`
870/// and has not earned `1.0.0`, so neither answer is available: demanding `major` would
871/// demand `1.0.0` for a single removal, and granting the exemption would let a breaking
872/// change ship as a patch.
873///
874/// So below `1.0.0` the floor is a minor. Any movement of the surface, gone or new, is at
875/// least a minor release; only an unchanged surface owes nothing. That is the strongest
876/// signal `0.x` has, and a removal is still *named* as breaking whatever the verdict, so the
877/// evidence does not quietly become a minor along with the number.
878///
879/// At `1.0.0` the shift ends: [`Mode::Semver`] is the ordinary rule, and the transition is
880/// one line of [`Policy::required_of`].
881///
882/// ```
883/// use majordomus_cli::release::compat::{Mode, Policy};
884/// use majordomus_cli::release::version::Version;
885/// assert_eq!(Policy::for_version(Version::parse("0.9.0").unwrap()).mode, Mode::Pre1_0Strict);
886/// assert_eq!(Policy::for_version(Version::parse("1.0.0").unwrap()).mode, Mode::Semver);
887/// ```
888#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
889#[serde(rename_all = "kebab-case")]
890#[schemars(rename = "ReleasePolicyMode")]
891pub enum Mode {
892 /// Below `1.0.0`: breaking changes are allowed, and cost a minor rather than nothing.
893 Pre1_0Strict,
894 /// At or above `1.0.0`: the impact is the requirement.
895 Semver,
896}
897
898/// The compatibility policy, named and versioned.
899///
900/// ```
901/// use majordomus_cli::release::compat::{Impact, Policy, POLICY_SCHEMA};
902/// use majordomus_cli::release::version::Version;
903/// let policy = Policy::for_version(Version::parse("0.5.0").unwrap());
904/// assert_eq!(policy.schema, POLICY_SCHEMA, "a verdict carries the rules it was reached under");
905/// assert_eq!(policy.required_of(Impact::Major), Impact::Minor);
906/// ```
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
908#[schemars(rename = "ReleasePolicy")]
909pub struct Policy {
910 /// `majordomus/version-policy/v1`.
911 pub schema: String,
912 /// Which rule is in force, decided by the baseline version alone.
913 pub mode: Mode,
914}
915
916impl Policy {
917 /// The policy that governs a project at this version.
918 ///
919 /// ```
920 /// use majordomus_cli::release::compat::{Mode, Policy};
921 /// use majordomus_cli::release::version::Version;
922 /// // the baseline decides the mode, not the version being written
923 /// assert_eq!(Policy::for_version(Version::parse("0.1.0").unwrap()).mode, Mode::Pre1_0Strict);
924 /// assert_eq!(Policy::for_version(Version::parse("2.3.4").unwrap()).mode, Mode::Semver);
925 /// ```
926 pub fn for_version(base: Version) -> Policy {
927 Policy {
928 schema: POLICY_SCHEMA.to_string(),
929 mode: if base.major == 0 {
930 Mode::Pre1_0Strict
931 } else {
932 Mode::Semver
933 },
934 }
935 }
936
937 /// The smallest release this impact is allowed to ship as.
938 ///
939 /// An unchanged surface owes nothing under either mode. This runs over every tree, not
940 /// only over a release, and most commits are behind the boundary: demanding a bump for
941 /// each of them would make the gate a thing to be worked around within a day.
942 ///
943 /// ```
944 /// use majordomus_cli::release::compat::{Impact, Policy};
945 /// use majordomus_cli::release::version::Version;
946 ///
947 /// let pre = Policy::for_version(Version::parse("0.5.0").unwrap());
948 /// assert_eq!(pre.required_of(Impact::None), Impact::None);
949 /// assert_eq!(pre.required_of(Impact::Minor), Impact::Minor);
950 /// assert_eq!(pre.required_of(Impact::Major), Impact::Minor, "0.x floors a removal at a minor");
951 ///
952 /// let one = Policy::for_version(Version::parse("1.0.0").unwrap());
953 /// assert_eq!(one.required_of(Impact::Major), Impact::Major);
954 /// ```
955 pub fn required_of(&self, implied: Impact) -> Impact {
956 match implied {
957 Impact::None | Impact::Patch => Impact::None,
958 Impact::Minor => Impact::Minor,
959 Impact::Major => match self.mode {
960 Mode::Pre1_0Strict => Impact::Minor,
961 Mode::Semver => Impact::Major,
962 },
963 }
964 }
965
966 /// How this policy explains itself, in the plan and in `--explain`.
967 ///
968 /// ```
969 /// use majordomus_cli::release::compat::Policy;
970 /// use majordomus_cli::release::version::Version;
971 /// let policy = Policy::for_version(Version::parse("0.5.0").unwrap());
972 /// assert!(policy.statement().contains("below 1.0.0"));
973 /// ```
974 pub fn statement(&self) -> &'static str {
975 match self.mode {
976 Mode::Pre1_0Strict => {
977 "below 1.0.0: an unchanged surface owes nothing, and any movement of it — \
978 arrived or gone — is at least a minor; a removal is named as breaking but \
979 does not demand 1.0.0"
980 }
981 Mode::Semver => {
982 "at or above 1.0.0: an unchanged surface owes nothing, an addition is a \
983 minor, and anything a caller could hold that is gone or changed under it \
984 is a major"
985 }
986 }
987 }
988}
989
990// ------------------------------------------------------------------------- the baseline
991
992/// The release this tree is measured against, and how it was found.
993///
994/// ```
995/// use majordomus_cli::release::compat::Baseline;
996/// let baseline = Baseline {
997/// version: "0.5.0".into(),
998/// reference: "v0.5.0".into(),
999/// read_at: "3a032c1e033b".into(),
1000/// commit: "3a032c1e033b".into(),
1001/// recorded: true,
1002/// atoms: 300,
1003/// fingerprint: "sha256:…".into(),
1004/// };
1005/// // `recorded` is the half that matters: a baseline taken from a tag alone is reported.
1006/// assert!(baseline.recorded);
1007/// assert_eq!(baseline.reference, "v0.5.0");
1008/// ```
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1010#[schemars(rename = "ReleaseBaseline")]
1011pub struct Baseline {
1012 /// `0.5.0`.
1013 pub version: String,
1014 /// `v0.5.0`: the release, as a reader recognises it.
1015 pub reference: String,
1016 /// The ref the surface was actually read from — the commit the release record names,
1017 /// which a clone always has, rather than a tag it may not.
1018 pub read_at: String,
1019 /// The commit that ref resolved to.
1020 pub commit: String,
1021 /// Whether the layer holds a release record for it, which is what makes it canonical
1022 /// rather than the newest thing that looked like a tag.
1023 pub recorded: bool,
1024 /// How many public atoms it published.
1025 pub atoms: usize,
1026 /// The fingerprint of the surface it published.
1027 pub fingerprint: String,
1028}
1029
1030/// Something about the release state that a person should be told, whatever the verdict.
1031///
1032/// ```
1033/// use majordomus_cli::release::compat::{Diagnostic, Severity};
1034/// let d = Diagnostic {
1035/// id: "tag-without-record".into(),
1036/// severity: Severity::Warning,
1037/// message: "v0.1.0 is tagged and the layer holds no release record for it".into(),
1038/// };
1039/// assert_eq!(d.severity, Severity::Warning, "a warning states the plan still stands");
1040/// ```
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1042#[schemars(rename = "ReleaseDiagnostic")]
1043pub struct Diagnostic {
1044 /// `tag-without-record`, `tag-predates-records`, `record-without-tag`,
1045 /// `tag-commit-mismatch`.
1046 pub id: String,
1047 /// Whether it stops a release, only warns, or only informs.
1048 pub severity: Severity,
1049 /// What is wrong, in one sentence.
1050 pub message: String,
1051}
1052
1053/// How much a diagnostic matters.
1054///
1055/// An error says the plan itself cannot be trusted and blocks the verdict; a warning says
1056/// something about the release state is wrong and the verdict stands anyway; a note says
1057/// something true and not wrong, so that the warnings keep meaning what they say.
1058///
1059/// ```
1060/// use majordomus_cli::release::compat::Severity;
1061/// assert_ne!(Severity::Error, Severity::Warning);
1062/// assert_ne!(Severity::Warning, Severity::Note);
1063/// ```
1064#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1065#[serde(rename_all = "snake_case")]
1066#[schemars(rename = "ReleaseSeverity")]
1067pub enum Severity {
1068 /// The plan cannot be trusted; the verdict is blocked.
1069 Error,
1070 /// The plan stands, and something is still wrong.
1071 Warning,
1072 /// Nothing is wrong; the reader is told something about the release state anyway.
1073 Note,
1074}
1075
1076/// Every version tag this repository carries, newest last.
1077fn tags(root: &Path) -> Vec<String> {
1078 let out = std::process::Command::new("git")
1079 .current_dir(root)
1080 .args(["tag", "--list", "v[0-9]*"])
1081 .output();
1082 let Ok(out) = out else { return Vec::new() };
1083 let mut list: Vec<(Version, String)> = String::from_utf8_lossy(&out.stdout)
1084 .lines()
1085 .map(str::trim)
1086 .filter(|l| !l.is_empty())
1087 .filter_map(|l| Version::parse(l.trim_start_matches('v')).map(|v| (v, l.to_string())))
1088 .collect();
1089 list.sort_by_key(|(v, _)| (v.major, v.minor, v.patch));
1090 list.into_iter().map(|(_, t)| t).collect()
1091}
1092
1093/// The release records the layer holds, newest last by version.
1094fn records(objects: &[Object]) -> Vec<(Version, String, Option<String>, Option<String>)> {
1095 let mut list: Vec<_> = objects
1096 .iter()
1097 .filter(|o| o.kind == super::changelog::RELEASE_KIND)
1098 .filter_map(|o| {
1099 let raw = o.metadata.get("version")?.as_str()?;
1100 let v = Version::parse(raw)?;
1101 let tag = o
1102 .metadata
1103 .get("tag")
1104 .and_then(|t| t.as_str())
1105 .map(str::to_string);
1106 let commit = o
1107 .metadata
1108 .get("commit")
1109 .and_then(|c| c.as_str())
1110 .map(str::to_string);
1111 Some((v, raw.to_string(), tag, commit))
1112 })
1113 .collect();
1114 list.sort_by_key(|(v, _, _, _)| (v.major, v.minor, v.patch));
1115 list
1116}
1117
1118/// The release a version is measured from, named without reading its surface.
1119///
1120/// ```
1121/// use majordomus_cli::release::compat::LastRelease;
1122/// let tagged = LastRelease {
1123/// version: "0.5.0".into(),
1124/// reference: "v0.5.0".into(),
1125/// read_at: "v0.5.0".into(),
1126/// recorded: false,
1127/// };
1128/// // a release known only from its tag is read at the tag itself
1129/// assert_eq!(tagged.read_at, tagged.reference);
1130/// ```
1131#[derive(Debug, Clone, PartialEq, Eq)]
1132pub struct LastRelease {
1133 /// `0.5.0`.
1134 pub version: String,
1135 /// `v0.5.0`: the release, as a reader recognises it.
1136 pub reference: String,
1137 /// The ref its tree is read from: the commit its record names, else its tag.
1138 pub read_at: String,
1139 /// Whether the layer holds a record for it, rather than only a tag.
1140 pub recorded: bool,
1141}
1142
1143/// The newest release by version: the highest the layer records, else the highest version
1144/// tag. The one selection — [`analyze`]'s baseline and the version report's last release
1145/// are both this, so the contract and the commit evidence always measure the same window.
1146fn latest(
1147 records: &[(Version, String, Option<String>, Option<String>)],
1148 tags: &[String],
1149) -> Option<LastRelease> {
1150 match records.last() {
1151 Some((_, raw, tag, commit)) => Some(LastRelease {
1152 version: raw.clone(),
1153 reference: tag.clone().unwrap_or_else(|| format!("v{raw}")),
1154 read_at: commit
1155 .clone()
1156 .or_else(|| tag.clone())
1157 .unwrap_or_else(|| format!("v{raw}")),
1158 recorded: true,
1159 }),
1160 None => tags.last().map(|t| LastRelease {
1161 version: t.trim_start_matches('v').to_string(),
1162 reference: t.clone(),
1163 read_at: t.clone(),
1164 recorded: false,
1165 }),
1166 }
1167}
1168
1169/// The release [`analyze`] measures from when no `--since` is given, named without reading
1170/// its surface — `None` when nothing was published.
1171///
1172/// ```
1173/// use majordomus_cli::release::compat::last_release;
1174/// let dir = tempfile::tempdir().unwrap();
1175/// assert_eq!(last_release(dir.path(), &[]), None);
1176/// ```
1177pub fn last_release(root: &Path, objects: &[Object]) -> Option<LastRelease> {
1178 latest(&records(objects), &tags(root))
1179}
1180
1181/// What to say about a tag the layer holds no release record for.
1182///
1183/// Two different things wear that shape, and calling both a defect is how the warning
1184/// stopped being read. Recording began at the earliest record the layer holds: a tag below
1185/// that version was cut before the practice existed, the release-record baseline
1186/// (`.ai/repo/release-record-baseline.txt`) already carries it as known debt, and no record
1187/// can be written for it now without inventing the digests a record is made of. A tag at or
1188/// above that version with no record is the real defect — something was published and the
1189/// evidence was not kept.
1190///
1191/// A tag whose name is not a version, and a layer with no records at all, both fall to the
1192/// warning: neither can be shown to predate anything.
1193///
1194/// ```
1195/// use majordomus_cli::release::compat::{unrecorded_tag, Severity};
1196/// use majordomus_cli::release::version::Version;
1197///
1198/// let first = Version::parse("0.3.1").unwrap();
1199/// let old = unrecorded_tag("v0.2.0", Some(&first));
1200/// assert_eq!(old.severity, Severity::Note);
1201/// assert_eq!(old.id, "tag-predates-records");
1202///
1203/// let new = unrecorded_tag("v0.4.0", Some(&first));
1204/// assert_eq!(new.severity, Severity::Warning);
1205/// assert_eq!(new.id, "tag-without-record");
1206/// ```
1207pub fn unrecorded_tag(tag: &str, first_recorded: Option<&Version>) -> Diagnostic {
1208 let predates = match (first_recorded, Version::parse(tag.trim_start_matches('v'))) {
1209 (Some(first), Some(v)) => v < *first,
1210 _ => false,
1211 };
1212 match (predates, first_recorded) {
1213 (true, Some(first)) => Diagnostic {
1214 id: "tag-predates-records".into(),
1215 severity: Severity::Note,
1216 message: format!(
1217 "{tag} is tagged and the layer holds no release record for it, because it \
1218 predates v{}.{}.{}, the earliest release this layer records; the changelog \
1219 and every baseline begin there",
1220 first.major, first.minor, first.patch
1221 ),
1222 },
1223 _ => Diagnostic {
1224 id: "tag-without-record".into(),
1225 severity: Severity::Warning,
1226 message: format!(
1227 "{tag} is tagged and the layer holds no release record for it; \
1228 the changelog and every baseline read the records, so this release is \
1229 invisible to them"
1230 ),
1231 },
1232 }
1233}
1234
1235/// Find the release to measure against, and say what is incoherent about the release state
1236/// on the way.
1237///
1238/// The canonical baseline is the newest release the *layer records*, because a record is
1239/// what this repository writes when something is actually published; a tag is git's copy of
1240/// that fact and can be created by anyone. When the two disagree the record still wins and
1241/// the disagreement is reported, so the answer is never silently drawn from whichever source
1242/// happened to be newer.
1243fn resolve_baseline(
1244 root: &Path,
1245 objects: &[Object],
1246 since: Option<&str>,
1247 diagnostics: &mut Vec<Diagnostic>,
1248) -> Result<Baseline, SurfaceError> {
1249 let tags = tags(root);
1250 let records = records(objects);
1251
1252 // Every tag should have a record and every record a tag; neither direction is checked
1253 // anywhere else, and each is how a release goes half-published.
1254 let tagged: BTreeSet<String> = tags.iter().cloned().collect();
1255 let recorded_tags: BTreeSet<String> = records
1256 .iter()
1257 .map(|(_, raw, tag, _)| tag.clone().unwrap_or_else(|| format!("v{raw}")))
1258 .collect();
1259 // A tag with no record is two different things, and calling both a defect is how the
1260 // warning stopped being read. Recording began at the earliest record this layer holds:
1261 // every tag below that version was cut before the practice existed, and no record can be
1262 // written for it now without inventing the digests a record is made of. A tag at or above
1263 // that version with no record is the real defect — something was published and the layer
1264 // did not keep the evidence.
1265 let first_recorded = records.iter().map(|(v, ..)| v).min().cloned();
1266 for t in tagged.difference(&recorded_tags) {
1267 diagnostics.push(unrecorded_tag(t, first_recorded.as_ref()));
1268 }
1269 for t in recorded_tags.difference(&tagged) {
1270 diagnostics.push(Diagnostic {
1271 id: "record-without-tag".into(),
1272 severity: Severity::Warning,
1273 message: format!(
1274 "the layer records {t} and no such tag exists in this clone; \
1275 either it was never pushed or the tags were not fetched"
1276 ),
1277 });
1278 }
1279
1280 // The ref to read the surface from. For a record, that is the *commit* it names rather
1281 // than its tag: a record always carries the commit it published, and a tag is git's copy
1282 // of that fact — absent from a shallow clone, absent from a fork, and movable. The tag
1283 // stays the name the reports print, because that is what a reader recognises.
1284 let (version, reference, read_at) = match since {
1285 Some(r) => (
1286 Version::parse(r.trim_start_matches('v')).map_or_else(String::new, |v| v.to_string()),
1287 r.to_string(),
1288 r.to_string(),
1289 ),
1290 None => match latest(&records, &tags) {
1291 Some(last) => {
1292 if !last.recorded {
1293 diagnostics.push(Diagnostic {
1294 id: "baseline-from-tag".into(),
1295 severity: Severity::Warning,
1296 message: format!(
1297 "the layer records no release, so {} was taken as the baseline from git's tags alone",
1298 last.reference
1299 ),
1300 });
1301 }
1302 (last.version, last.reference, last.read_at)
1303 }
1304 None => return Err(SurfaceError::NothingPublished),
1305 },
1306 };
1307
1308 let surface = Surface::read_ref(root, &read_at)?;
1309 let commit = match &surface.origin {
1310 super::surface::Origin::Ref { commit, .. } => commit.clone(),
1311 super::surface::Origin::WorkingTree => String::new(),
1312 };
1313
1314 // The record's own commit against the one the tag points at: a tag moved after the
1315 // record was written makes every comparison since then measure the wrong tree.
1316 let recorded = records.iter().find(|(_, raw, _, _)| *raw == version);
1317 if let Some((_, _, _, Some(record_commit))) = recorded {
1318 if !commit.is_empty() && record_commit != &commit {
1319 diagnostics.push(Diagnostic {
1320 id: "tag-commit-mismatch".into(),
1321 severity: Severity::Error,
1322 message: format!(
1323 "the release record for {version} names commit {} and {reference} points at {}; \
1324 the surface being compared with is not the one that was published",
1325 &record_commit[..record_commit.len().min(12)],
1326 &commit[..commit.len().min(12)]
1327 ),
1328 });
1329 }
1330 }
1331
1332 Ok(Baseline {
1333 version,
1334 reference,
1335 read_at,
1336 commit,
1337 recorded: recorded.is_some(),
1338 atoms: surface.atoms(),
1339 fingerprint: surface.fingerprint(),
1340 })
1341}
1342
1343// ---------------------------------------------------------------------------- the plan
1344
1345/// What the commit subjects since the baseline said about themselves.
1346///
1347/// Evidence, never authority. It is here so that a person can see the human account and the
1348/// measured one side by side, and so that [`VersionPlan::understated`] can say when the two
1349/// disagree — which is the case this whole subsystem exists to catch.
1350///
1351/// ```
1352/// use majordomus_cli::release::compat::{CommitEvidence, Impact};
1353/// let commits = CommitEvidence {
1354/// commits: 24,
1355/// implied: Impact::Patch,
1356/// breaking: Vec::new(),
1357/// };
1358/// // The contract moved by a minor and the subjects claim a patch: the plan reports that
1359/// // disagreement rather than deferring to the words.
1360/// assert!(commits.implied < Impact::Minor);
1361/// ```
1362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1363#[schemars(rename = "ReleaseCommitEvidence")]
1364pub struct CommitEvidence {
1365 /// How many commits since the baseline.
1366 pub commits: usize,
1367 /// What their conventional-commit types alone would have implied.
1368 pub implied: Impact,
1369 /// The subjects of the ones that claimed to be breaking.
1370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371 pub breaking: Vec<String>,
1372}
1373
1374/// Whether the tree may land or ship as it stands.
1375///
1376/// ```
1377/// use majordomus_cli::release::compat::Status;
1378/// assert_ne!(Status::Ok, Status::Blocked);
1379/// ```
1380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1381#[serde(rename_all = "snake_case")]
1382#[schemars(rename = "ReleaseStatus")]
1383pub enum Status {
1384 /// The declared version covers what the contract did.
1385 Ok,
1386 /// It does not, and the plan says by how much.
1387 Blocked,
1388}
1389
1390/// The whole answer: what moved, what that requires, and what the tree declares.
1391///
1392/// One value, and every surface renders it — the command line, `/api/v1/release/analysis`,
1393/// the MCP tool, the Cockpit panel, the generated document and the CI gate. There is no
1394/// second computation anywhere, which is the property that makes the number mean something.
1395///
1396/// ```
1397/// use majordomus_cli::release::compat::{Status, VersionPlan};
1398/// # use majordomus_cli::release::compat::{Baseline, CommitEvidence, Impact, Policy};
1399/// # use majordomus_cli::release::version::Version;
1400/// let plan = VersionPlan {
1401/// policy: Policy::for_version(Version::parse("0.5.0").unwrap()),
1402/// baseline: Baseline {
1403/// version: "0.5.0".into(), reference: "v0.5.0".into(), read_at: "3a032c1e".into(),
1404/// commit: "3a032c1e".into(), recorded: true, atoms: 300, fingerprint: "sha256:a".into(),
1405/// },
1406/// declared_version: "0.5.0".into(),
1407/// tool_version: "0.5.0".into(),
1408/// writers_agree: true,
1409/// atoms: 333,
1410/// fingerprint: "sha256:b".into(),
1411/// implied: Impact::Minor,
1412/// required: Impact::Minor,
1413/// declared: Impact::None,
1414/// required_version: "0.6.0".into(),
1415/// status: Status::Blocked,
1416/// breaking: false,
1417/// changes: Vec::new(),
1418/// commits: CommitEvidence { commits: 24, implied: Impact::Patch, breaking: Vec::new() },
1419/// understated: true,
1420/// diagnostics: Vec::new(),
1421/// };
1422/// // The contract requires a minor and the version declares none: this tree may not land.
1423/// assert_eq!(plan.status, Status::Blocked);
1424/// assert!(plan.declared < plan.required);
1425/// assert!(plan.understated, "the commit subjects claim less than the contract did");
1426/// ```
1427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1428#[schemars(rename = "ReleaseVersionPlan")]
1429pub struct VersionPlan {
1430 /// The policy the verdict was reached under.
1431 pub policy: Policy,
1432 /// The release this was measured against.
1433 pub baseline: Baseline,
1434 /// The version the crate manifest declares — the one place it is authored.
1435 pub declared_version: String,
1436 /// The version `bin/majordomus` prints: what its projection, `share/version.txt`, states.
1437 pub tool_version: String,
1438 /// Whether that projection is current — states what the manifest declares. The name is
1439 /// kept from when the version had two writers; the manifest is the only one now.
1440 pub writers_agree: bool,
1441 /// How many public atoms this tree carries.
1442 pub atoms: usize,
1443 /// The fingerprint of this tree's surface.
1444 pub fingerprint: String,
1445 /// What the surface movement amounts to on its own.
1446 pub implied: Impact,
1447 /// What the policy requires of it.
1448 pub required: Impact,
1449 /// What the two version numbers already represent.
1450 pub declared: Impact,
1451 /// The smallest version this tree may declare.
1452 pub required_version: String,
1453 /// Whether it does.
1454 pub status: Status,
1455 /// Whether anything a caller could hold is gone.
1456 pub breaking: bool,
1457 /// Every movement of the contract, worst first.
1458 pub changes: Vec<SurfaceChange>,
1459 /// What the commits said about themselves.
1460 pub commits: CommitEvidence,
1461 /// Whether the commit subjects understate what the contract actually did.
1462 pub understated: bool,
1463 /// Anything incoherent about the release state, whatever the verdict.
1464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1465 pub diagnostics: Vec<Diagnostic>,
1466}
1467
1468impl VersionPlan {
1469 /// The version the contract requires, when the analysis could measure one.
1470 ///
1471 /// The analysis still returns a plan when a fact it rests on is untrustworthy — a version
1472 /// that is not three numbers (`baseline-version-malformed`, `declared-version-malformed`,
1473 /// then a guess), a baseline whose record and tag name different commits
1474 /// (`tag-commit-mismatch`), a version stated by hand (`writers-disagree`,
1475 /// `version-stated-by-hand`) — so a caller that takes `required_version` as the
1476 /// contract's answer must ask this instead. It is sound exactly when `release bump` would
1477 /// write from it: any [`Severity::Error`] diagnostic ([`VersionPlan::has_errors`]) refuses,
1478 /// with every error's message, one per line, as `bump` prints them.
1479 ///
1480 /// ```
1481 /// # use majordomus_cli::release::compat::*;
1482 /// # use majordomus_cli::release::version::Version;
1483 /// # fn plan(required_version: &str, diagnostics: Vec<Diagnostic>) -> VersionPlan {
1484 /// # VersionPlan { policy: Policy::for_version(Version::parse("0.5.0").unwrap()),
1485 /// # baseline: Baseline { version: "0.5.0".into(), reference: "v0.5.0".into(),
1486 /// # read_at: "c".into(), commit: "c".into(), recorded: true, atoms: 1,
1487 /// # fingerprint: "sha256:a".into() },
1488 /// # declared_version: required_version.into(), tool_version: "0.5.0".into(),
1489 /// # writers_agree: true, atoms: 1, fingerprint: "sha256:b".into(),
1490 /// # implied: Impact::Minor, required: Impact::Minor, declared: Impact::None,
1491 /// # required_version: required_version.into(), status: Status::Blocked,
1492 /// # breaking: false, changes: Vec::new(),
1493 /// # commits: CommitEvidence { commits: 0, implied: Impact::None, breaking: Vec::new() },
1494 /// # understated: false, diagnostics }
1495 /// # }
1496 /// assert_eq!(plan("0.6.0", Vec::new()).measured_version(), Ok("0.6.0".into()));
1497 /// // no version line in the manifest: the analysis echoes "", which is no answer
1498 /// assert!(plan("", Vec::new()).measured_version().is_err());
1499 /// let guessed = Diagnostic { id: "baseline-version-malformed".into(),
1500 /// severity: Severity::Error, message: "the baseline is not three numbers".into() };
1501 /// assert_eq!(plan("0.6.0", vec![guessed]).measured_version(),
1502 /// Err("the baseline is not three numbers".into()));
1503 /// // any error refuses, not only a malformed version: the plan bump would not write from
1504 /// let moved = Diagnostic { id: "tag-commit-mismatch".into(),
1505 /// severity: Severity::Error, message: "the tag moved".into() };
1506 /// let stale = Diagnostic { id: "projection-stale".into(),
1507 /// severity: Severity::Warning, message: "run scripts/derive".into() };
1508 /// assert_eq!(plan("0.6.0", vec![stale.clone()]).measured_version(), Ok("0.6.0".into()));
1509 /// let unsound = plan("0.6.0", vec![moved.clone(), stale, moved]);
1510 /// assert!(unsound.has_errors());
1511 /// assert_eq!(unsound.measured_version(), Err("the tag moved\nthe tag moved".into()));
1512 /// ```
1513 pub fn measured_version(&self) -> Result<String, String> {
1514 // One soundness test with `release bump`: the plan it refuses to write from is the
1515 // plan the report refuses to decide from, for the same reasons.
1516 if self.has_errors() {
1517 let errors: Vec<&str> = self
1518 .diagnostics
1519 .iter()
1520 .filter(|d| d.severity == Severity::Error)
1521 .map(|d| d.message.as_str())
1522 .collect();
1523 return Err(errors.join("\n"));
1524 }
1525 Version::parse(&self.required_version)
1526 .map(|v| v.to_string())
1527 .ok_or_else(|| {
1528 format!(
1529 "the contract's required version '{}' is not three numbers",
1530 self.required_version
1531 )
1532 })
1533 }
1534
1535 /// How many changes moved the contract in each direction.
1536 ///
1537 /// ```
1538 /// # use majordomus_cli::release::compat::*;
1539 /// # use majordomus_cli::release::version::Version;
1540 /// # fn plan(changes: Vec<SurfaceChange>) -> VersionPlan {
1541 /// # VersionPlan { policy: Policy::for_version(Version::parse("0.5.0").unwrap()),
1542 /// # baseline: Baseline { version: "0.5.0".into(), reference: "v0.5.0".into(),
1543 /// # read_at: "c".into(), commit: "c".into(), recorded: true, atoms: 1,
1544 /// # fingerprint: "sha256:a".into() },
1545 /// # declared_version: "0.5.0".into(), tool_version: "0.5.0".into(), writers_agree: true,
1546 /// # atoms: 1, fingerprint: "sha256:b".into(), implied: Impact::Minor,
1547 /// # required: Impact::Minor, declared: Impact::None, required_version: "0.6.0".into(),
1548 /// # status: Status::Blocked, breaking: false, changes,
1549 /// # commits: CommitEvidence { commits: 0, implied: Impact::None, breaking: Vec::new() },
1550 /// # understated: false, diagnostics: Vec::new() }
1551 /// # }
1552 /// # fn change(direction: Direction) -> SurfaceChange {
1553 /// # SurfaceChange { impact: Impact::Minor, direction, surface: SurfaceKind::Capability,
1554 /// # capability: "a.one".into(), id: "a.one".into(), detail: String::new() }
1555 /// # }
1556 /// let p = plan(vec![change(Direction::Added), change(Direction::Added), change(Direction::Removed)]);
1557 /// assert_eq!(p.counts(), (2, 0, 1), "added, changed, removed");
1558 /// ```
1559 pub fn counts(&self) -> (usize, usize, usize) {
1560 let n = |d: Direction| self.changes.iter().filter(|c| c.direction == d).count();
1561 (
1562 n(Direction::Added),
1563 n(Direction::Changed),
1564 n(Direction::Removed),
1565 )
1566 }
1567
1568 /// Everything that is breaking on its own.
1569 ///
1570 /// ```
1571 /// # use majordomus_cli::release::compat::*;
1572 /// # use majordomus_cli::release::version::Version;
1573 /// # let breaking = SurfaceChange { impact: Impact::Major, direction: Direction::Removed,
1574 /// # surface: SurfaceKind::Http, capability: "a.one".into(), id: "GET /x".into(),
1575 /// # detail: String::new() };
1576 /// # let additive = SurfaceChange { impact: Impact::Minor, ..breaking.clone() };
1577 /// # let plan = VersionPlan { policy: Policy::for_version(Version::parse("1.0.0").unwrap()),
1578 /// # baseline: Baseline { version: "1.0.0".into(), reference: "v1.0.0".into(),
1579 /// # read_at: "c".into(), commit: "c".into(), recorded: true, atoms: 1,
1580 /// # fingerprint: "sha256:a".into() },
1581 /// # declared_version: "1.0.0".into(), tool_version: "1.0.0".into(), writers_agree: true,
1582 /// # atoms: 1, fingerprint: "sha256:b".into(), implied: Impact::Major,
1583 /// # required: Impact::Major, declared: Impact::None, required_version: "2.0.0".into(),
1584 /// # status: Status::Blocked, breaking: true, changes: vec![breaking, additive],
1585 /// # commits: CommitEvidence { commits: 0, implied: Impact::None, breaking: Vec::new() },
1586 /// # understated: false, diagnostics: Vec::new() };
1587 /// assert_eq!(plan.breaking_changes().count(), 1, "only what is breaking on its own");
1588 /// ```
1589 pub fn breaking_changes(&self) -> impl Iterator<Item = &SurfaceChange> {
1590 self.changes.iter().filter(|c| c.impact == Impact::Major)
1591 }
1592
1593 /// Whether anything makes the plan itself untrustworthy.
1594 ///
1595 /// ```
1596 /// # use majordomus_cli::release::compat::*;
1597 /// # use majordomus_cli::release::version::Version;
1598 /// # let mut plan = VersionPlan { policy: Policy::for_version(Version::parse("0.5.0").unwrap()),
1599 /// # baseline: Baseline { version: "0.5.0".into(), reference: "v0.5.0".into(),
1600 /// # read_at: "c".into(), commit: "c".into(), recorded: true, atoms: 1,
1601 /// # fingerprint: "sha256:a".into() },
1602 /// # declared_version: "0.5.0".into(), tool_version: "0.5.0".into(), writers_agree: true,
1603 /// # atoms: 1, fingerprint: "sha256:b".into(), implied: Impact::None,
1604 /// # required: Impact::None, declared: Impact::None, required_version: "0.5.0".into(),
1605 /// # status: Status::Ok, breaking: false, changes: Vec::new(),
1606 /// # commits: CommitEvidence { commits: 0, implied: Impact::None, breaking: Vec::new() },
1607 /// # understated: false, diagnostics: Vec::new() };
1608 /// assert!(!plan.has_errors());
1609 /// // A warning leaves the plan standing; only an error makes it untrustworthy.
1610 /// plan.diagnostics.push(Diagnostic { id: "tag-without-record".into(),
1611 /// severity: Severity::Warning, message: "…".into() });
1612 /// assert!(!plan.has_errors());
1613 /// plan.diagnostics.push(Diagnostic { id: "writers-disagree".into(),
1614 /// severity: Severity::Error, message: "…".into() });
1615 /// assert!(plan.has_errors());
1616 /// ```
1617 pub fn has_errors(&self) -> bool {
1618 self.diagnostics
1619 .iter()
1620 .any(|d| d.severity == Severity::Error)
1621 }
1622}
1623
1624/// The bump two version numbers already represent.
1625///
1626/// A number going *down* is not a bump at all, and is reported as [`Impact::None`] so that
1627/// the comparison with the requirement refuses it rather than ranking it.
1628///
1629/// ```
1630/// use majordomus_cli::release::compat::{declared_impact, Impact};
1631/// use majordomus_cli::release::version::Version;
1632/// let v = |s| Version::parse(s).unwrap();
1633/// assert_eq!(declared_impact(v("0.5.0"), v("0.6.0")), Impact::Minor);
1634/// assert_eq!(declared_impact(v("0.5.0"), v("1.0.0")), Impact::Major);
1635/// // A version going down is no bump at all, so it can never satisfy a requirement.
1636/// assert_eq!(declared_impact(v("0.5.0"), v("0.4.0")), Impact::None);
1637/// ```
1638pub fn declared_impact(from: Version, to: Version) -> Impact {
1639 if to.major > from.major {
1640 Impact::Major
1641 } else if to.major < from.major {
1642 Impact::None
1643 } else if to.minor > from.minor {
1644 Impact::Minor
1645 } else if to.minor < from.minor {
1646 Impact::None
1647 } else if to.patch > from.patch {
1648 Impact::Patch
1649 } else {
1650 Impact::None
1651 }
1652}
1653
1654/// The one analysis. Everything that answers a version question calls this.
1655///
1656/// ```
1657/// use majordomus_cli::capability::registry::CapabilityRegistry;
1658/// use majordomus_cli::release::compat::analyze;
1659///
1660/// let registry = CapabilityRegistry::builder().build().expect("an empty registry composes");
1661/// let dir = tempfile::tempdir().unwrap();
1662/// // A repository that has published nothing has no baseline, and the analysis refuses
1663/// // rather than reporting an empty diff — which would read as "nothing changed".
1664/// assert!(analyze(dir.path(), ®istry, &[], None).is_err());
1665/// ```
1666pub fn analyze(
1667 root: &Path,
1668 registry: &crate::capability::registry::CapabilityRegistry,
1669 objects: &[Object],
1670 since: Option<&str>,
1671) -> Result<VersionPlan, SurfaceError> {
1672 let mut diagnostics = Vec::new();
1673 let baseline = resolve_baseline(root, objects, since, &mut diagnostics)?;
1674 let base = Surface::read_ref(root, &baseline.read_at)?;
1675 let head = Surface::of_registry(registry);
1676 let changes = diff(&base, &head);
1677
1678 let implied = changes
1679 .iter()
1680 .map(|c| c.impact)
1681 .max()
1682 .unwrap_or(Impact::None);
1683 let breaking = implied == Impact::Major;
1684
1685 let declared_version = super::version::declared(root).unwrap_or_default();
1686 let tool_version = super::version::tool(root).unwrap_or_default();
1687
1688 let base_v = Version::parse(&baseline.version);
1689 let policy = Policy::for_version(base_v.unwrap_or(Version {
1690 major: 0,
1691 minor: 0,
1692 patch: 0,
1693 }));
1694 let required = policy.required_of(implied);
1695
1696 if base_v.is_none() {
1697 diagnostics.push(Diagnostic {
1698 id: "baseline-version-malformed".into(),
1699 severity: Severity::Error,
1700 message: format!(
1701 "the baseline version '{}' is not three numbers, so the policy that governs it \
1702 cannot be decided; pre-1.0 rules were assumed",
1703 baseline.version
1704 ),
1705 });
1706 }
1707 let declared_v = Version::parse(&declared_version);
1708 if declared_v.is_none() {
1709 diagnostics.push(Diagnostic {
1710 id: "declared-version-malformed".into(),
1711 severity: Severity::Error,
1712 message: format!(
1713 "the tree declares '{declared_version}', which is not three numbers; \
1714 {} states it",
1715 super::version::MANIFEST
1716 ),
1717 });
1718 }
1719 // Where the version is stated: the projection against the authority, and any version
1720 // written down by hand. A stale projection is a warning — every bump leaves one until the
1721 // derivation runs, `generate --check` refuses it, and an error here would make the writer
1722 // refuse to correct a bump it had just made. The rest are errors.
1723 let writers_agree = super::version::projection_current(root);
1724 diagnostics.extend(super::version::diagnose(root));
1725
1726 let (declared, required_version) = match (base_v, declared_v) {
1727 (Some(b), Some(d)) => {
1728 let owed = b.raised_to(required);
1729 // The smallest acceptable version, which is the baseline raised by the
1730 // requirement — unless the tree already declares something at least that big.
1731 let required_version = if declared_impact(b, d) >= required && d >= owed {
1732 d.to_string()
1733 } else {
1734 owed.to_string()
1735 };
1736 (declared_impact(b, d), required_version)
1737 }
1738 _ => (Impact::None, declared_version.clone()),
1739 };
1740
1741 // The human account of the same window, kept beside the measured one.
1742 let commit_changes =
1743 super::commits::in_range(root, &format!("{}..HEAD", baseline.reference), objects);
1744 let commits = CommitEvidence {
1745 commits: commit_changes.len(),
1746 implied: match super::version::bump_of(&commit_changes) {
1747 super::version::Bump::None => Impact::None,
1748 super::version::Bump::Patch => Impact::Patch,
1749 super::version::Bump::Minor => Impact::Minor,
1750 super::version::Bump::Major => Impact::Major,
1751 },
1752 breaking: commit_changes
1753 .iter()
1754 .filter(|c| c.breaking)
1755 .map(|c| c.subject.clone())
1756 .collect(),
1757 };
1758 let understated = commits.implied < implied;
1759
1760 let status = if declared < required || diagnostics.iter().any(|d| d.severity == Severity::Error)
1761 {
1762 Status::Blocked
1763 } else {
1764 Status::Ok
1765 };
1766
1767 Ok(VersionPlan {
1768 policy,
1769 baseline,
1770 declared_version,
1771 tool_version,
1772 writers_agree,
1773 atoms: head.atoms(),
1774 fingerprint: head.fingerprint(),
1775 implied,
1776 required,
1777 declared,
1778 required_version,
1779 status,
1780 breaking,
1781 changes,
1782 commits,
1783 understated,
1784 diagnostics,
1785 })
1786}
1787
1788#[cfg(test)]
1789mod tests {
1790 //! The compatibility matrix, as tests.
1791 //!
1792 //! Every row of the policy this repository documents is a case here, and both policy
1793 //! modes are exercised separately: a rule that is only written down is a rule nobody is
1794 //! holding to. They live in this file rather than beside it because
1795 //! `project.rust-command-tested-in-file` asks for that, and because the quality scanner
1796 //! counts a module as behaviourally tested only from the tests it can see in the file.
1797 use super::*;
1798 use crate::release::surface::{normalise, Origin, Surface, SURFACE_SCHEMA};
1799 use serde_json::json;
1800
1801 /// A surface from a capability list in the registry's own shape.
1802 fn surface(caps: Value) -> Surface {
1803 let manifest = json!({"schema": "majordomus/capability-registry/v1", "capabilities": caps});
1804 // Reuse the real reader, so these tests exercise the same normalisation the engine does
1805 // rather than a convenient stand-in for it.
1806 serde_json::from_value::<Value>(manifest)
1807 .map(|m| Surface {
1808 schema: SURFACE_SCHEMA.to_string(),
1809 origin: Origin::WorkingTree,
1810 capabilities: crate::release::surface::public_capabilities(&m),
1811 })
1812 .expect("the manifest is a value")
1813 }
1814
1815 /// One public capability, with whatever of the contract a case needs.
1816 fn cap(id: &str) -> Value {
1817 json!({"id": id, "kind": "query", "visibility": "public"})
1818 }
1819
1820 /// The impact a diff between two capability lists amounts to.
1821 fn implied(base: Value, head: Value) -> Impact {
1822 diff(&surface(base), &surface(head))
1823 .iter()
1824 .map(|c| c.impact)
1825 .max()
1826 .unwrap_or(Impact::None)
1827 }
1828
1829 /// The changes, for cases that assert on what was named rather than only how much.
1830 fn changes(base: Value, head: Value) -> Vec<SurfaceChange> {
1831 diff(&surface(base), &surface(head))
1832 }
1833
1834 /// The two shapes of an unrecorded tag, and the two ways the question cannot be asked.
1835 #[test]
1836 fn a_tag_older_than_the_practice_is_a_note_and_a_newer_one_is_a_defect() {
1837 let first = Version::parse("0.3.1").expect("a version");
1838
1839 // cut before the record practice began (.ai/repo/release-record-baseline.txt): a note
1840 for tag in ["v0.1.0", "v0.2.0", "v0.3.0"] {
1841 let d = unrecorded_tag(tag, Some(&first));
1842 assert_eq!(d.severity, Severity::Note, "{tag}");
1843 assert_eq!(d.id, "tag-predates-records", "{tag}");
1844 assert!(d.message.contains("predates v0.3.1"), "{}", d.message);
1845 }
1846
1847 // the same version as the earliest record is not below it, and above it is the defect
1848 for tag in ["v0.3.1", "v0.4.0", "v1.0.0"] {
1849 let d = unrecorded_tag(tag, Some(&first));
1850 assert_eq!(d.severity, Severity::Warning, "{tag}");
1851 assert_eq!(d.id, "tag-without-record", "{tag}");
1852 }
1853
1854 // nothing recorded at all: there is no practice to predate
1855 assert_eq!(unrecorded_tag("v0.1.0", None).severity, Severity::Warning);
1856 // a tag that is not a version cannot be placed either way
1857 assert_eq!(
1858 unrecorded_tag("v0.2.0-ai-documents", Some(&first)).id,
1859 "tag-without-record"
1860 );
1861 }
1862
1863 // ------------------------------------------------------------------- identity and bindings
1864
1865 #[test]
1866 fn an_unchanged_surface_owes_nothing() {
1867 assert_eq!(
1868 implied(json!([cap("a.one")]), json!([cap("a.one")])),
1869 Impact::None
1870 );
1871 }
1872
1873 #[test]
1874 fn reordering_is_not_a_change() {
1875 let a = json!([cap("a.one"), cap("b.two"), cap("c.three")]);
1876 let b = json!([cap("c.three"), cap("a.one"), cap("b.two")]);
1877 assert_eq!(
1878 implied(a, b),
1879 Impact::None,
1880 "the registry's enumeration order is not the contract"
1881 );
1882 }
1883
1884 #[test]
1885 fn diffing_a_surface_with_itself_is_empty() {
1886 let s = surface(json!([cap("a.one"), cap("b.two")]));
1887 assert!(diff(&s, &s).is_empty());
1888 }
1889
1890 #[test]
1891 fn adding_a_capability_is_minor_and_removing_one_is_major() {
1892 assert_eq!(
1893 implied(json!([cap("a.one")]), json!([cap("a.one"), cap("b.two")])),
1894 Impact::Minor
1895 );
1896 assert_eq!(
1897 implied(json!([cap("a.one"), cap("b.two")]), json!([cap("a.one")])),
1898 Impact::Major
1899 );
1900 }
1901
1902 #[test]
1903 fn renaming_a_capability_stays_breaking_rather_than_becoming_a_rename() {
1904 let found = changes(json!([cap("a.old")]), json!([cap("a.new")]));
1905 assert_eq!(found.len(), 2, "a removal and an addition, not one move");
1906 assert_eq!(found[0].impact, Impact::Major, "worst first");
1907 assert_eq!(found[0].direction, Direction::Removed);
1908 assert_eq!(found[0].id, "a.old");
1909 assert_eq!(found[1].direction, Direction::Added);
1910 assert_eq!(
1911 found.iter().map(|c| c.impact).max(),
1912 Some(Impact::Major),
1913 "a rename breaks the caller holding the old name"
1914 );
1915 }
1916
1917 #[test]
1918 fn the_kind_is_part_of_the_contract() {
1919 let q = json!([{"id": "a.one", "kind": "query", "visibility": "public"}]);
1920 let c = json!([{"id": "a.one", "kind": "command", "visibility": "public"}]);
1921 assert_eq!(implied(q, c), Impact::Major);
1922 }
1923
1924 #[test]
1925 fn making_a_capability_internal_is_a_removal() {
1926 let public = json!([cap("a.one")]);
1927 let internal = json!([{"id": "a.one", "kind": "query", "visibility": "internal"}]);
1928 let found = changes(public, internal);
1929 assert_eq!(found[0].impact, Impact::Major);
1930 assert_eq!(
1931 found[0].direction,
1932 Direction::Removed,
1933 "withdrawn from the public surface is gone, whatever the code still holds"
1934 );
1935 }
1936
1937 /// Every binding a caller can hold, added and removed, in one table.
1938 #[test]
1939 fn every_binding_added_is_minor_and_removed_is_major() {
1940 let bare = json!([cap("a.one")]);
1941 let bound = |exposure: Value| json!([{"id": "a.one", "kind": "query", "visibility": "public", "exposure": exposure}]);
1942 let cases: &[(&str, Value)] = &[
1943 ("mcp tool", json!({"mcp": {"tool": "t"}})),
1944 (
1945 "mcp resource",
1946 json!({"mcp": {"resource": {"uri": "majordomus://r"}}}),
1947 ),
1948 (
1949 "route",
1950 json!({"http": {"method": "GET", "path": "/api/v1/x"}}),
1951 ),
1952 ("command", json!({"cli": {"path": ["a", "one"]}})),
1953 ];
1954 for (what, exposure) in cases {
1955 assert_eq!(
1956 implied(bare.clone(), bound(exposure.clone())),
1957 Impact::Minor,
1958 "a new {what} breaks nobody"
1959 );
1960 assert_eq!(
1961 implied(bound(exposure.clone()), bare.clone()),
1962 Impact::Major,
1963 "a {what} that is gone breaks whoever held it"
1964 );
1965 }
1966 }
1967
1968 #[test]
1969 fn a_route_that_changes_method_or_path_is_breaking_both_ways() {
1970 let route = |m: &str, p: &str| {
1971 json!([{"id": "a.one", "kind": "query", "visibility": "public",
1972 "exposure": {"http": {"method": m, "path": p}}}])
1973 };
1974 assert_eq!(
1975 implied(route("GET", "/api/v1/x"), route("POST", "/api/v1/x")),
1976 Impact::Major,
1977 "a caller issuing GET gets a method-not-allowed"
1978 );
1979 assert_eq!(
1980 implied(route("GET", "/api/v1/x"), route("GET", "/api/v1/y")),
1981 Impact::Major,
1982 "a caller holding the old path gets a 404"
1983 );
1984 let moved = changes(route("GET", "/api/v1/x"), route("GET", "/api/v1/y"));
1985 assert_eq!(
1986 moved.len(),
1987 2,
1988 "the old one is gone and the new one arrived"
1989 );
1990 assert!(moved[0].detail.contains("breaks"), "and it says why");
1991 }
1992
1993 // --------------------------------------------------------------------------- input schemas
1994
1995 /// A capability whose input is the given schema.
1996 fn with_input(schema: Value) -> Value {
1997 json!([{"id": "a.one", "kind": "query", "visibility": "public", "input": {"schema": schema}}])
1998 }
1999
2000 /// A capability whose output is the given schema.
2001 fn with_output(schema: Value) -> Value {
2002 json!([{"id": "a.one", "kind": "query", "visibility": "public", "output": {"schema": schema}}])
2003 }
2004
2005 #[test]
2006 fn an_input_that_demands_more_is_breaking_and_one_that_demands_less_is_not() {
2007 let optional = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2008 let required =
2009 json!({"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]});
2010 assert_eq!(
2011 implied(with_input(optional.clone()), with_input(required.clone())),
2012 Impact::Major,
2013 "a caller that did not send x is refused now"
2014 );
2015 assert_eq!(
2016 implied(with_input(required), with_input(optional)),
2017 Impact::Minor,
2018 "dropping a requirement breaks nobody"
2019 );
2020 }
2021
2022 #[test]
2023 fn an_optional_input_field_added_is_minor_and_removed_is_major() {
2024 let one = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2025 let two = json!({"type": "object", "properties": {"x": {"type": "string"}, "y": {"type": "string"}}});
2026 assert_eq!(
2027 implied(with_input(one.clone()), with_input(two.clone())),
2028 Impact::Minor
2029 );
2030 // Every input in this repository denies unknown fields, so a property that is gone is
2031 // refused rather than ignored.
2032 assert_eq!(implied(with_input(two), with_input(one)), Impact::Major);
2033 }
2034
2035 #[test]
2036 fn an_input_type_that_changes_is_breaking() {
2037 let s = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2038 let n = json!({"type": "object", "properties": {"x": {"type": "number"}}});
2039 let found = changes(with_input(s), with_input(n));
2040 assert_eq!(found[0].impact, Impact::Major);
2041 assert!(
2042 found[0].id.contains("properties.x"),
2043 "it names where: {}",
2044 found[0].id
2045 );
2046 }
2047
2048 #[test]
2049 fn closing_an_open_object_is_breaking_and_opening_a_closed_one_is_not() {
2050 let open = json!({"type": "object", "additionalProperties": true});
2051 let closed = json!({"type": "object", "additionalProperties": false});
2052 assert_eq!(
2053 implied(with_input(open.clone()), with_input(closed.clone())),
2054 Impact::Major
2055 );
2056 assert_eq!(implied(with_input(closed), with_input(open)), Impact::Minor);
2057 }
2058
2059 // -------------------------------------------------------------------------- output schemas
2060
2061 #[test]
2062 fn an_output_that_promises_less_is_breaking_and_more_is_not() {
2063 let one = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2064 let two = json!({"type": "object", "properties": {"x": {"type": "string"}, "y": {"type": "string"}}});
2065 assert_eq!(
2066 implied(with_output(two.clone()), with_output(one.clone())),
2067 Impact::Major,
2068 "a caller reading y finds nothing"
2069 );
2070 assert_eq!(
2071 implied(with_output(one), with_output(two)),
2072 Impact::Minor,
2073 "a caller ignoring y is unaffected"
2074 );
2075 }
2076
2077 #[test]
2078 fn the_same_edit_is_minor_in_an_input_and_major_in_an_output() {
2079 // This is the whole reason the comparator takes a role: a textual diff of the two
2080 // schemas is identical, and the compatibility answer is opposite.
2081 let optional = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2082 let required =
2083 json!({"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]});
2084 assert_eq!(
2085 implied(with_output(optional.clone()), with_output(required.clone())),
2086 Impact::Minor,
2087 "an output that is always present is a stronger promise"
2088 );
2089 assert_eq!(
2090 implied(with_input(optional), with_input(required)),
2091 Impact::Major,
2092 "an input that must be present is a stricter demand"
2093 );
2094 }
2095
2096 #[test]
2097 fn an_output_that_stops_promising_a_field_is_breaking() {
2098 let promised =
2099 json!({"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]});
2100 let maybe = json!({"type": "object", "properties": {"x": {"type": "string"}}});
2101 let found = changes(with_output(promised), with_output(maybe));
2102 assert_eq!(found[0].impact, Impact::Major);
2103 assert!(
2104 found[0].detail.contains("no longer promised"),
2105 "{}",
2106 found[0].detail
2107 );
2108 }
2109
2110 // ---------------------------------------------------------------------------------- enums
2111
2112 #[test]
2113 fn narrowing_an_enum_is_breaking_and_widening_it_is_not() {
2114 let wide = json!({"type": "string", "enum": ["a", "b", "c"]});
2115 let narrow = json!({"type": "string", "enum": ["a", "b"]});
2116 assert_eq!(
2117 implied(with_input(wide.clone()), with_input(narrow.clone())),
2118 Impact::Major
2119 );
2120 assert_eq!(implied(with_input(narrow), with_input(wide)), Impact::Minor);
2121 }
2122
2123 #[test]
2124 fn a_variant_removed_from_a_oneof_is_breaking() {
2125 let two = json!({"oneOf": [{"const": "a"}, {"const": "b"}]});
2126 let one = json!({"oneOf": [{"const": "a"}]});
2127 assert_eq!(
2128 implied(with_output(two.clone()), with_output(one.clone())),
2129 Impact::Major
2130 );
2131 assert_eq!(implied(with_output(one), with_output(two)), Impact::Minor);
2132 }
2133
2134 #[test]
2135 fn a_nested_definition_is_compared_and_not_skipped() {
2136 let with = |inner: Value| {
2137 with_output(json!({
2138 "type": "object",
2139 "properties": {"item": {"$ref": "#/$defs/Item"}},
2140 "$defs": {"Item": inner}
2141 }))
2142 };
2143 let before = with(json!({"type": "object", "properties": {"x": {"type": "string"}}}));
2144 let after = with(json!({"type": "object", "properties": {}}));
2145 let found = changes(before, after);
2146 assert_eq!(
2147 found[0].impact,
2148 Impact::Major,
2149 "a field inside a $def is still a promise"
2150 );
2151 assert!(
2152 found[0].id.contains("$defs.Item"),
2153 "it names the path: {}",
2154 found[0].id
2155 );
2156 }
2157
2158 // -------------------------------------------------------------- prose, and what is not one
2159
2160 #[test]
2161 fn rewording_a_description_is_not_a_release() {
2162 let before = with_output(
2163 json!({"type": "object", "description": "one wording", "properties": {"x": {"type": "string", "description": "a"}}}),
2164 );
2165 let after = with_output(
2166 json!({"type": "object", "description": "a different and much longer wording", "properties": {"x": {"type": "string", "description": "b"}}}),
2167 );
2168 assert_eq!(
2169 implied(before, after),
2170 Impact::None,
2171 "a documentation change is not a contract change"
2172 );
2173 }
2174
2175 #[test]
2176 fn an_unmodelled_constraint_change_is_breaking_rather_than_guessed_at() {
2177 let loose = json!({"type": "string"});
2178 let tight = json!({"type": "string", "pattern": "^a+$"});
2179 let found = changes(with_input(loose), with_input(tight));
2180 assert_eq!(
2181 found[0].impact,
2182 Impact::Major,
2183 "a constraint this comparator has no rule for is breaking, never a patch"
2184 );
2185 assert!(
2186 found[0].detail.contains("does not model"),
2187 "and it says that is why: {}",
2188 found[0].detail
2189 );
2190 }
2191
2192 // --------------------------------------------------------------------------------- policy
2193
2194 #[test]
2195 fn the_policy_below_one_zero_floors_a_breaking_change_at_a_minor() {
2196 let p = Policy::for_version(Version::parse("0.5.0").expect("a version"));
2197 assert_eq!(p.mode, Mode::Pre1_0Strict);
2198 assert_eq!(p.required_of(Impact::None), Impact::None);
2199 assert_eq!(
2200 p.required_of(Impact::Patch),
2201 Impact::None,
2202 "an unchanged surface owes nothing"
2203 );
2204 assert_eq!(p.required_of(Impact::Minor), Impact::Minor);
2205 assert_eq!(
2206 p.required_of(Impact::Major),
2207 Impact::Minor,
2208 "a removal below 1.0 costs a minor, not 1.0.0 — and is still named as breaking"
2209 );
2210 }
2211
2212 #[test]
2213 fn the_policy_at_one_zero_and_above_is_semver() {
2214 let p = Policy::for_version(Version::parse("1.4.2").expect("a version"));
2215 assert_eq!(p.mode, Mode::Semver);
2216 assert_eq!(p.required_of(Impact::None), Impact::None);
2217 assert_eq!(p.required_of(Impact::Patch), Impact::None);
2218 assert_eq!(p.required_of(Impact::Minor), Impact::Minor);
2219 assert_eq!(p.required_of(Impact::Major), Impact::Major);
2220 }
2221
2222 #[test]
2223 fn the_policy_names_itself_and_says_what_it_does() {
2224 for v in ["0.5.0", "2.0.0"] {
2225 let p = Policy::for_version(Version::parse(v).expect("a version"));
2226 assert_eq!(
2227 p.schema, POLICY_SCHEMA,
2228 "a verdict carries the rules it was reached under"
2229 );
2230 assert!(!p.statement().is_empty());
2231 }
2232 }
2233
2234 // ------------------------------------------------------------------------ declared impact
2235
2236 #[test]
2237 fn the_declared_bump_is_read_from_the_two_numbers() {
2238 let v = |s: &str| Version::parse(s).expect("a version");
2239 assert_eq!(declared_impact(v("0.5.0"), v("0.5.0")), Impact::None);
2240 assert_eq!(declared_impact(v("0.5.0"), v("0.5.1")), Impact::Patch);
2241 assert_eq!(declared_impact(v("0.5.0"), v("0.6.0")), Impact::Minor);
2242 assert_eq!(declared_impact(v("0.5.0"), v("1.0.0")), Impact::Major);
2243 // A version going down is not a small bump; it is no bump, and ranking it as a patch
2244 // would let a downgrade satisfy a requirement.
2245 assert_eq!(declared_impact(v("0.5.0"), v("0.4.0")), Impact::None);
2246 assert_eq!(declared_impact(v("1.0.0"), v("0.9.9")), Impact::None);
2247 }
2248
2249 #[test]
2250 fn an_impact_ranks_above_the_ones_it_supersedes() {
2251 assert!(Impact::Major > Impact::Minor);
2252 assert!(Impact::Minor > Impact::Patch);
2253 assert!(Impact::Patch > Impact::None);
2254 for i in [Impact::None, Impact::Patch, Impact::Minor, Impact::Major] {
2255 assert_eq!(
2256 Impact::parse(i.as_str()),
2257 Some(i),
2258 "every impact round-trips through its word"
2259 );
2260 }
2261 assert_eq!(Impact::parse("enormous"), None);
2262 }
2263
2264 #[test]
2265 fn a_version_is_raised_by_a_measured_impact() {
2266 let v = Version::parse("0.5.0").expect("a version");
2267 assert_eq!(v.raised_to(Impact::None).to_string(), "0.5.0");
2268 assert_eq!(v.raised_to(Impact::Patch).to_string(), "0.5.1");
2269 assert_eq!(v.raised_to(Impact::Minor).to_string(), "0.6.0");
2270 assert_eq!(v.raised_to(Impact::Major).to_string(), "1.0.0");
2271 }
2272
2273 // ------------------------------------------------------------------------- determinism
2274
2275 #[test]
2276 fn the_same_surface_diffs_the_same_however_it_was_enumerated() {
2277 // Property-shaped without a property-testing dependency: the same logical change,
2278 // presented in every order, must produce the same verdict and the same report.
2279 let base = json!([cap("a.one"), cap("b.two"), cap("c.three")]);
2280 let orders = [
2281 json!([cap("a.one"), cap("b.two"), cap("d.four")]),
2282 json!([cap("d.four"), cap("a.one"), cap("b.two")]),
2283 json!([cap("b.two"), cap("d.four"), cap("a.one")]),
2284 ];
2285 let first = changes(base.clone(), orders[0].clone());
2286 for other in &orders[1..] {
2287 assert_eq!(
2288 changes(base.clone(), other.clone()),
2289 first,
2290 "the report is a function of the surfaces, not of their enumeration"
2291 );
2292 }
2293 // And it is the right verdict: one gone, one arrived.
2294 assert_eq!(first.iter().map(|c| c.impact).max(), Some(Impact::Major));
2295 }
2296
2297 #[test]
2298 fn normalising_twice_changes_nothing_further() {
2299 let v = json!({"b": 1, "a": {"d": 2, "c": [{"f": 3, "e": 4}]}, "description": "x"});
2300 let once = normalise(&v);
2301 assert_eq!(normalise(&once), once);
2302 }
2303
2304 // ------------------------------------------------------------------- the baseline
2305
2306 /// A record as [`records`] reads it: the version, its raw spelling, its tag, its commit.
2307 fn record(
2308 raw: &str,
2309 tag: Option<&str>,
2310 commit: Option<&str>,
2311 ) -> (Version, String, Option<String>, Option<String>) {
2312 (
2313 Version::parse(raw).expect("three numbers"),
2314 raw.to_string(),
2315 tag.map(str::to_string),
2316 commit.map(str::to_string),
2317 )
2318 }
2319
2320 /// The baseline is the newest release the layer records, read at the commit the record
2321 /// names; a tag stands in only when nothing is recorded, and says that it did.
2322 #[test]
2323 fn the_newest_release_is_the_layers_record_and_a_tag_only_stands_in_for_none() {
2324 let tags = vec!["v0.1.0".to_string(), "v0.9.0".to_string()];
2325 // a record wins over a newer tag, and is read at its own commit, named by its tag
2326 let recorded = latest(&[record("0.5.0", Some("v0.5.0"), Some("abc123"))], &tags);
2327 assert_eq!(
2328 recorded,
2329 Some(LastRelease {
2330 version: "0.5.0".into(),
2331 reference: "v0.5.0".into(),
2332 read_at: "abc123".into(),
2333 recorded: true,
2334 })
2335 );
2336 // the newest of several records, by version
2337 let newest = latest(
2338 &[
2339 record("0.2.0", Some("v0.2.0"), Some("aaa")),
2340 record("0.3.0", Some("v0.3.0"), Some("bbb")),
2341 ],
2342 &[],
2343 );
2344 assert_eq!(newest.map(|l| l.read_at), Some("bbb".to_string()));
2345 // a record that names no commit is read at its tag
2346 let at_tag = latest(&[record("0.5.0", Some("v0.5.0"), None)], &[]).unwrap();
2347 assert_eq!(
2348 (at_tag.reference.as_str(), at_tag.read_at.as_str()),
2349 ("v0.5.0", "v0.5.0")
2350 );
2351 // and one that names neither is the tag its version spells
2352 let bare = latest(&[record("0.5.0", None, None)], &[]).unwrap();
2353 assert_eq!(
2354 (bare.reference.as_str(), bare.read_at.as_str()),
2355 ("v0.5.0", "v0.5.0")
2356 );
2357 assert!(bare.recorded);
2358 // nothing recorded: the newest tag, read at itself, and not a record
2359 assert_eq!(
2360 latest(&[], &tags),
2361 Some(LastRelease {
2362 version: "0.9.0".into(),
2363 reference: "v0.9.0".into(),
2364 read_at: "v0.9.0".into(),
2365 recorded: false,
2366 })
2367 );
2368 // nothing published at all
2369 assert_eq!(latest(&[], &[]), None);
2370 }
2371
2372 /// A git repository whose one commit carries `registry` at the registry's path and a
2373 /// manifest declaring `version`, tagged `v<version>`: a release known only from git.
2374 fn tagged(registry: Value, version: &str) -> tempfile::TempDir {
2375 let dir = tempfile::tempdir().expect("a temporary directory");
2376 let root = dir.path();
2377 let git = |args: &[&str]| {
2378 let out = std::process::Command::new("git")
2379 .current_dir(root)
2380 .args(args)
2381 .output()
2382 .expect("git runs");
2383 assert!(out.status.success(), "git {args:?}: {out:?}");
2384 };
2385 git(&["init", "-q"]);
2386 git(&["config", "user.email", "t@example.com"]);
2387 git(&["config", "user.name", "t"]);
2388 let write = |rel: &str, text: &str| {
2389 let p = root.join(rel);
2390 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2391 std::fs::write(p, text).unwrap();
2392 };
2393 write(
2394 crate::release::surface::REGISTRY,
2395 &serde_json::to_string(®istry).unwrap(),
2396 );
2397 write(
2398 crate::release::version::MANIFEST,
2399 &format!("[package]\nname = \"majordomus-cli\"\nversion = \"{version}\"\n"),
2400 );
2401 git(&["add", "-A"]);
2402 git(&["commit", "-q", "-m", "release"]);
2403 git(&["tag", &format!("v{version}")]);
2404 dir
2405 }
2406
2407 /// With no `--since`, and a release git knows and the layer does not record, the
2408 /// baseline is the tag — measured, at the tag's tree — and the answer says it was taken
2409 /// from the tags alone rather than passing a tag off as a record.
2410 #[test]
2411 fn a_release_known_only_from_its_tag_is_the_baseline_and_says_so() {
2412 let empty = json!({"schema": "majordomus/capability-registry/v1", "capabilities": []});
2413 let dir = tagged(empty, "0.1.0");
2414 let mut diagnostics = Vec::new();
2415 let baseline =
2416 resolve_baseline(dir.path(), &[], None, &mut diagnostics).expect("a baseline");
2417 assert_eq!(baseline.version, "0.1.0");
2418 assert_eq!(baseline.reference, "v0.1.0");
2419 assert_eq!(baseline.read_at, "v0.1.0");
2420 assert!(!baseline.recorded);
2421 assert_eq!(
2422 baseline.commit.len(),
2423 40,
2424 "the tag's commit: {}",
2425 baseline.commit
2426 );
2427 let from_tag = diagnostics
2428 .iter()
2429 .find(|d| d.id == "baseline-from-tag")
2430 .expect("the baseline says it came from a tag");
2431 assert_eq!(from_tag.severity, Severity::Warning);
2432 assert!(from_tag.message.contains("v0.1.0"), "{}", from_tag.message);
2433 assert!(
2434 diagnostics.iter().any(|d| d.id == "tag-without-record"),
2435 "{diagnostics:?}"
2436 );
2437 assert!(diagnostics.iter().all(|d| d.severity == Severity::Warning));
2438 }
2439
2440 /// The contract's answer is the plan's required version exactly when the plan is
2441 /// sound: every error refuses, all of them named, and a required version that is not
2442 /// three numbers is no answer either.
2443 #[test]
2444 fn the_measured_version_is_the_plans_answer_only_when_the_plan_is_sound() {
2445 let empty = json!({"schema": "majordomus/capability-registry/v1", "capabilities": []});
2446 let dir = tagged(empty, "0.1.0");
2447 let registry = crate::capability::registry::CapabilityRegistry::builder()
2448 .build()
2449 .expect("an empty registry");
2450 let plan = analyze(dir.path(), ®istry, &[], None).expect("a plan");
2451 // nothing moved since the tag: the contract requires the release already made
2452 assert!(!plan.has_errors(), "{:?}", plan.diagnostics);
2453 assert_eq!(plan.required_version, "0.1.0");
2454 assert_eq!(plan.measured_version(), Ok("0.1.0".to_string()));
2455
2456 let error = |message: &str| Diagnostic {
2457 id: "tag-commit-mismatch".into(),
2458 severity: Severity::Error,
2459 message: message.into(),
2460 };
2461 let mut unsound = plan.clone();
2462 unsound.diagnostics.push(error("the tag moved"));
2463 unsound
2464 .diagnostics
2465 .push(error("the record names another commit"));
2466 assert_eq!(
2467 unsound.measured_version(),
2468 Err("the tag moved\nthe record names another commit".to_string())
2469 );
2470
2471 let mut guessed = plan;
2472 guessed.required_version = "0.1".into();
2473 assert_eq!(
2474 guessed.measured_version(),
2475 Err("the contract's required version '0.1' is not three numbers".to_string())
2476 );
2477 }
2478}