Skip to main content

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(), &registry, &[], 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(&registry).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(), &registry, &[], 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}