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}