Skip to main content

majordomus_cli/commit/
mod.rs

1//! The commit as a value: one grammar, one policy, one verdict, one plan.
2//!
3//! # What was here before
4//!
5//! Two halves of this subject already existed and did not know about each other. The
6//! changelog parsed conventional commits out of the history to render a release; the rule
7//! `project.conventional-commits` stated what a commit must look like and was marked
8//! *advisory*, with the failure behaviour "no command decides this rule; a reviewer does".
9//! So the repository had a parser and a rule, and no verdict — the exact shape of governance
10//! the rest of this repository exists to refuse.
11//!
12//! The other half was outside the repository altogether. The procedure for writing a commit
13//! — how to derive a scope, how to link work, when a fix needs a test, which subjects are
14//! exempt — lived in prose that an AI client read and executed by hand, one file per client,
15//! with a path-to-scope table maintained by a person. That is a workflow whose semantics
16//! live in a prompt, which makes it unversioned, unenforceable, untestable and true for
17//! exactly one tool.
18//!
19//! # What this is
20//!
21//! One typed subject with four parts, each the only place its question is answered:
22//!
23//! - [`CommitHeader`] and [`CommitMessage`] — the grammar. The changelog and the validator read this
24//!   parse; there is no second regular expression anywhere.
25//! - [`CommitPolicy`] — what this repository holds a commit to, as data in `policy.yaml`.
26//! - [`judge`] — the verdict, as typed findings over the canonical [`crate::model::Diagnostic`].
27//! - [`plan`] and [`scopes`] — what is in the working tree, which scope the *history* gives
28//!   it, and how it divides into commits, fingerprinted against the tree it came from.
29//!
30//! Every surface is a projection of these: the capabilities in
31//! [`crate::capability::builtin::commit`] carry them to MCP and HTTP, the command line
32//! renders them, the `commit-msg` hook asks [`judge`] the same question CI asks of the
33//! history, and the documentation is generated from the declarations. Nothing restates them.
34//!
35//! ```
36//! use majordomus_cli::commit::{judge, CommitPolicy, CommitHeader, CommitMessage, CommitSubject};
37//!
38//! // one grammar
39//! assert!(CommitHeader::parse("feat(commit): the subject is a value").is_conventional());
40//! // one verdict, from the same parse
41//! assert!(judge(&CommitSubject::of("feat(commit): the subject is a value"), &CommitPolicy::default()).passed);
42//! // and a message is a value that renders, not a string that is parsed back
43//! assert_eq!(CommitMessage::parse("docs: one line").render(), "docs: one line\n");
44//! ```
45
46pub mod header;
47pub mod message;
48pub mod plan;
49pub mod policy;
50pub mod scopes;
51pub mod verdict;
52
53pub use header::CommitHeader;
54pub use message::{CommitMessage, CommitTrailer};
55pub use plan::{ChangeStage, CommitGroup, CommitPlan, PlanFingerprint, WorkingTreeState};
56pub use policy::{CommitPolicy, FindingLevel};
57pub use scopes::{ScopeSuggestion, ScopeVocabulary};
58pub use verdict::{judge, CommitExemption, CommitRule, CommitSubject, CommitVerdict};
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    /// The four parts compose into one answer, which is the only thing this module is
65    /// responsible for: everything else is asserted beside the code that does it.
66    ///
67    /// The property under test is that a message *authored* as a value, rendered, parsed
68    /// back and judged is the same commit throughout — because that is what lets the planner
69    /// propose a message, the hook judge the rendered text, and the changelog read it back
70    /// months later without any of the three holding its own idea of what a commit is.
71    #[test]
72    fn a_message_authored_as_a_value_survives_being_rendered_parsed_and_judged() {
73        let authored = CommitMessage {
74            header: CommitHeader {
75                word: "feat".into(),
76                kind: crate::release::ChangeKind::Feat,
77                scope: Some("commit".into()),
78                breaking: false,
79                subject: "the value is the same at every step".into(),
80            },
81            body: "Why it was written this way.".into(),
82            trailers: vec![CommitTrailer {
83                key: "Co-Authored-By".into(),
84                value: "Someone <s@example.org>".into(),
85            }],
86        };
87
88        let rendered = authored.render();
89        let parsed = CommitMessage::parse(&rendered);
90        assert_eq!(parsed, authored, "the round trip is exact");
91
92        let verdict = judge(&CommitSubject::of(&rendered), &CommitPolicy::default());
93        assert!(verdict.passed, "{:?}", verdict.findings);
94        assert_eq!(
95            verdict.message.header.subject, authored.header.subject,
96            "the judge read the same commit the author wrote"
97        );
98
99        // and the changelog's reader, which is a different caller of the same grammar,
100        // agrees about every field it carries
101        let change = crate::release::commits::parse("abc123456", &authored.header.render(), "");
102        assert_eq!(change.kind, authored.header.kind);
103        assert_eq!(change.scope, authored.header.scope);
104        assert_eq!(change.subject, authored.header.subject);
105        assert_eq!(change.breaking, authored.header.breaking);
106    }
107
108    /// A subject nobody spelled conventionally reaches both readers, and they disagree only
109    /// about what to do with it. That disagreement is the design; a test that did not state
110    /// it would leave the next person free to "fix" one of the two.
111    #[test]
112    fn the_changelog_carries_what_the_judge_refuses() {
113        let subject = "update stuff";
114        let change = crate::release::commits::parse("abc123456", subject, "");
115        assert_eq!(
116            change.subject, subject,
117            "the changelog keeps it whole: dropping it would lie about what the release holds"
118        );
119        let verdict = judge(&CommitSubject::of(subject), &CommitPolicy::default());
120        assert!(!verdict.passed, "the judge reports it");
121        assert_eq!(verdict.findings.len(), 1, "and reports it once");
122    }
123}