Skip to main content

majordomus_cli/commit/
verdict.rs

1//! The judge: one commit message, against the policy, with every finding it earns.
2//!
3//! # Why this exists at all
4//!
5//! `project.conventional-commits` was a rule whose failure behaviour read *"No command
6//! decides this rule; a reviewer does"*. That is a rule this repository states and cannot
7//! check, which is the failure mode the whole governance layer exists to prevent — and the
8//! parser that would have decided it was already here, reading the history for the
9//! changelog. What was missing was not a grammar. It was a verdict.
10//!
11//! # What it judges, and what it refuses to
12//!
13//! The findings below are the ones a machine can decide from evidence that is present at
14//! the moment a commit is written: the grammar, the width, whether a scope is one this
15//! repository uses, whether a record the message names exists, whether a fix carries a test,
16//! whether a breaking change says why. It does not judge whether the subject is a good
17//! sentence, whether the change is atomic, or whether the work was worth doing; a reviewer
18//! decides those, and a validator that pretended to would be a validator nobody trusts.
19//!
20//! ```
21//! use majordomus_cli::commit::{judge, CommitPolicy, CommitSubject};
22//! use majordomus_cli::model::Severity;
23//!
24//! let v = judge(&CommitSubject::of("update stuff"), &CommitPolicy::default());
25//! assert!(!v.passed);
26//! assert_eq!(v.findings[0].code, "commit.not_conventional");
27//!
28//! // and git's own subjects are exempt rather than wrong
29//! let m = judge(&CommitSubject::of("Merge pull request #249 from korczis/fix"), &CommitPolicy::default());
30//! assert!(m.passed);
31//! assert!(m.exempt.is_some());
32//! let _ = Severity::Error;
33//! ```
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use super::policy::{git_authored, CommitPolicy, FindingLevel};
39use super::CommitMessage;
40use crate::model::{Diagnostic, Severity};
41use crate::release::commits::record_candidates;
42use crate::release::ChangeKind;
43
44/// Every code this module can produce, as one closed set.
45///
46/// A judgement is a value here and not a string at the call site. The codes reach a person
47/// through a diagnostic, a machine through JSON and a hook through an exit code, and all
48/// three read the same list — which is what makes a finding something a rule document can
49/// name and a test can assert on.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "snake_case")]
52/// ```
53/// use majordomus_cli::commit::CommitRule;
54/// // every rule has a stable code, and the order is the order a person would fix them in
55/// assert_eq!(CommitRule::NotConventional.code(), "commit.not_conventional");
56/// assert_eq!(CommitRule::ALL.first(), Some(&CommitRule::NotConventional));
57/// assert!(CommitRule::ALL.iter().all(|r| r.code().starts_with("commit.")));
58/// ```
59pub enum CommitRule {
60    /// The header is not `type(scope): subject`.
61    NotConventional,
62    /// The subject is wider than the policy allows.
63    SubjectTooLong,
64    /// The header parsed but said nothing after the colon.
65    SubjectEmpty,
66    /// The subject ends in a full stop; a subject is a title, not a sentence.
67    SubjectTrailingPeriod,
68    /// The scope is not one this repository's history or layout yields.
69    UnknownScope,
70    /// A `fix` whose staged files include no test.
71    FixWithoutTest,
72    /// The message names a record id the layer does not hold.
73    UnresolvedReference,
74    /// The commit declares itself breaking and explains nothing.
75    BreakingUnexplained,
76}
77
78impl CommitRule {
79    /// The stable code a diagnostic carries.
80    ///
81    /// ```
82    /// use majordomus_cli::commit::CommitRule;
83    /// assert_eq!(CommitRule::SubjectTooLong.code(), "commit.subject_too_long");
84    /// ```
85    pub fn code(self) -> &'static str {
86        match self {
87            CommitRule::NotConventional => "commit.not_conventional",
88            CommitRule::SubjectTooLong => "commit.subject_too_long",
89            CommitRule::SubjectEmpty => "commit.subject_empty",
90            CommitRule::SubjectTrailingPeriod => "commit.subject_trailing_period",
91            CommitRule::UnknownScope => "commit.unknown_scope",
92            CommitRule::FixWithoutTest => "commit.fix_without_test",
93            CommitRule::UnresolvedReference => "commit.unresolved_reference",
94            CommitRule::BreakingUnexplained => "commit.breaking_unexplained",
95        }
96    }
97
98    /// Every rule, in the order a verdict reports them. Ordered by how early in the message
99    /// the evidence for them appears, so a person reads findings in the order they would
100    /// fix them.
101    pub const ALL: &'static [CommitRule] = &[
102        CommitRule::NotConventional,
103        CommitRule::SubjectEmpty,
104        CommitRule::SubjectTooLong,
105        CommitRule::SubjectTrailingPeriod,
106        CommitRule::UnknownScope,
107        CommitRule::BreakingUnexplained,
108        CommitRule::UnresolvedReference,
109        CommitRule::FixWithoutTest,
110    ];
111}
112
113/// Why a commit was not judged.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "snake_case")]
116/// ```
117/// use majordomus_cli::commit::{judge, CommitExemption, CommitPolicy, CommitSubject};
118/// let v = judge(&CommitSubject::of("Merge pull request #1 from a/b"), &CommitPolicy::default());
119/// assert_eq!(v.exempt, Some(CommitExemption::GitAuthored));
120/// ```
121pub enum CommitExemption {
122    /// git composed the subject: a merge, a revert, a fixup.
123    GitAuthored,
124}
125
126/// Everything the judge is allowed to look at.
127///
128/// A struct rather than five arguments, because the two halves that are optional are
129/// exactly the two a caller may not have: a hook running on `commit-msg` knows the staged
130/// files, a gate reading history does not, and a judge that demanded both would have to be
131/// two judges.
132#[derive(Debug, Clone, Default)]
133/// ```
134/// use majordomus_cli::commit::{judge, CommitPolicy, CommitSubject};
135/// // the two optional halves are the two a caller may not have: a hook knows the staged
136/// // files, a gate reading history does not
137/// let hook = CommitSubject {
138///     text: "fix(a): a thing".into(),
139///     paths: Some(vec!["src/a.rs".into()]),
140///     ..CommitSubject::default()
141/// };
142/// assert_eq!(judge(&hook, &CommitPolicy::default()).findings.len(), 1);
143/// let gate = CommitSubject::of("fix(a): a thing");
144/// assert!(judge(&gate, &CommitPolicy::default()).findings.is_empty());
145/// ```
146pub struct CommitSubject {
147    /// The message as it will be stored.
148    pub text: String,
149    /// The scope vocabulary this repository yields, when it has been derived.
150    pub scopes: Option<Vec<String>>,
151    /// The paths this commit will contain, when they are known.
152    pub paths: Option<Vec<String>>,
153    /// The record ids the layer holds, for resolving what the message names.
154    pub records: Vec<String>,
155}
156
157impl CommitSubject {
158    /// A subject that is only a message: the judgements that need no other evidence.
159    /// ```
160    /// use majordomus_cli::commit::CommitSubject;
161    /// let s = CommitSubject::of("feat(a): a thing");
162    /// assert!(s.paths.is_none(), "a judgement needing paths is not made rather than guessed");
163    /// assert!(s.scopes.is_none());
164    /// ```
165    pub fn of(text: &str) -> CommitSubject {
166        CommitSubject {
167            text: text.to_string(),
168            ..CommitSubject::default()
169        }
170    }
171}
172
173/// What the judge concluded: the message as it was parsed, whether anything of error
174/// severity was found, the exemption when git composed the subject rather than a person, and
175/// every finding in [`CommitRule::ALL`] order — which is the order the evidence for them
176/// appears in the message, so a person reads them in the order they would fix them.
177///
178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
179/// ```
180/// use majordomus_cli::commit::{judge, CommitPolicy, CommitSubject, CommitVerdict};
181/// let v: CommitVerdict = judge(&CommitSubject::of("update stuff"), &CommitPolicy::default());
182/// assert!(!v.passed);
183/// assert_eq!(v.findings[0].code, "commit.not_conventional");
184/// assert_eq!(v.message.header.subject, "update stuff", "what was judged, not what was typed");
185/// ```
186pub struct CommitVerdict {
187    /// The message as parsed; what was judged, not what was typed.
188    pub message: CommitMessage,
189    /// Whether nothing of error severity was found.
190    pub passed: bool,
191    /// Why the commit was not judged, when it was not.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub exempt: Option<CommitExemption>,
194    /// Every finding, in [`CommitRule::ALL`] order.
195    pub findings: Vec<Diagnostic>,
196}
197
198/// Whether a path is a test of this repository.
199///
200/// Derived from the shapes this repository actually uses rather than from a list a person
201/// maintains: a path under a `test`/`tests` directory, a Rust `#[cfg(test)]` file named
202/// `tests.rs`, or a name that says so.
203fn is_test_path(path: &str) -> bool {
204    let p = path.to_ascii_lowercase();
205    p.split('/')
206        .any(|seg| seg == "test" || seg == "tests" || seg == "spec")
207        || p.contains("_test.")
208        || p.contains(".test.")
209        || p.contains("_spec.")
210        || p.ends_with("/tests.rs")
211}
212
213/// Judge one commit.
214/// Judge one commit against `policy`.
215///
216/// ```
217/// use majordomus_cli::commit::{judge, CommitPolicy, CommitSubject};
218/// let p = CommitPolicy::default();
219/// // a well-formed subject has nothing said about it
220/// assert!(judge(&CommitSubject::of("feat(commit): a subject"), &p).findings.is_empty());
221/// // an id the layer does not hold is an error, because nothing may be invented
222/// let invented = CommitSubject { text: "fix(a): under I9999".into(), ..CommitSubject::default() };
223/// assert!(!judge(&invented, &p).passed);
224/// ```
225pub fn judge(subject: &CommitSubject, policy: &CommitPolicy) -> CommitVerdict {
226    let message = CommitMessage::parse(&subject.text);
227    let head = &message.header;
228
229    if git_authored(&head.subject) || git_authored(subject.text.lines().next().unwrap_or_default())
230    {
231        return CommitVerdict {
232            message,
233            passed: true,
234            exempt: Some(CommitExemption::GitAuthored),
235            findings: Vec::new(),
236        };
237    }
238
239    let mut found: Vec<(CommitRule, Severity, String)> = Vec::new();
240    let mut add = |rule: CommitRule, level: FindingLevel, message: String| {
241        if let Some(severity) = level.severity() {
242            found.push((rule, severity, message));
243        }
244    };
245
246    if !head.is_conventional() {
247        let detail = if head.word.is_empty() {
248            format!(
249                "'{}' is not `type(scope): subject`; the types are {}",
250                head.subject.chars().take(60).collect::<String>(),
251                ChangeKind::WORDS.join(", ")
252            )
253        } else {
254            format!(
255                "'{}' is not a commit type; the types are {}",
256                head.word,
257                ChangeKind::WORDS.join(", ")
258            )
259        };
260        add(CommitRule::NotConventional, FindingLevel::Error, detail);
261        // Everything below reads fields this parse did not produce; reporting them as well
262        // would bury the one finding that matters under consequences of it.
263        return finish(message, found);
264    }
265
266    if head.subject.trim().is_empty() {
267        add(
268            CommitRule::SubjectEmpty,
269            FindingLevel::Error,
270            "the header says nothing after the colon".into(),
271        );
272    }
273    let width = head.render().chars().count();
274    if width > policy.subject_max_chars {
275        add(
276            CommitRule::SubjectTooLong,
277            FindingLevel::Error,
278            format!(
279                "the subject is {width} characters; the policy allows {}",
280                policy.subject_max_chars
281            ),
282        );
283    }
284    if head.subject.ends_with('.') {
285        add(
286            CommitRule::SubjectTrailingPeriod,
287            FindingLevel::Error,
288            "the subject ends in a full stop; a subject is a title, not a sentence".into(),
289        );
290    }
291    if let (Some(scope), Some(known)) = (head.scope.as_deref(), subject.scopes.as_deref()) {
292        if !known.iter().any(|k| k == scope) {
293            add(
294                CommitRule::UnknownScope,
295                policy.scope_unknown,
296                format!(
297                    "'{scope}' is not a scope this repository uses; `majordomus commit scopes` lists them"
298                ),
299            );
300        }
301    }
302    if message.breaking()
303        && message.body.trim().is_empty()
304        && message.trailer("BREAKING CHANGE").is_none()
305    {
306        add(
307            CommitRule::BreakingUnexplained,
308            policy.breaking_unexplained,
309            "the commit is marked breaking and explains nothing; write a body or a BREAKING CHANGE trailer".into(),
310        );
311    }
312    for candidate in record_candidates(&without_code_spans(&subject.text)) {
313        if !subject.records.contains(&candidate) {
314            add(
315                CommitRule::UnresolvedReference,
316                policy.reference_unresolved,
317                format!(
318                    "the message names {candidate}, which this repository's layer does not hold"
319                ),
320            );
321        }
322    }
323    if head.kind == ChangeKind::Fix {
324        if let Some(paths) = subject.paths.as_deref() {
325            if !paths.is_empty() && !paths.iter().any(|p| is_test_path(p)) {
326                add(
327                    CommitRule::FixWithoutTest,
328                    policy.fix_requires_test,
329                    "a fix with no test among its files; a bug that can come back is a bug with no regression test".into(),
330                );
331            }
332        }
333    }
334
335    finish(message, found)
336}
337
338/// Order the findings and decide the verdict.
339fn finish(message: CommitMessage, found: Vec<(CommitRule, Severity, String)>) -> CommitVerdict {
340    let mut ranked: Vec<RankedFinding> = found
341        .into_iter()
342        .map(|(rule, severity, message)| RankedFinding {
343            rule,
344            severity,
345            message,
346        })
347        .collect();
348    crate::order::canonical(&mut ranked);
349    let found: Vec<(CommitRule, Severity, String)> = ranked
350        .into_iter()
351        .map(|f| (f.rule, f.severity, f.message))
352        .collect();
353    let passed = !found.iter().any(|(_, s, _)| *s == Severity::Error);
354    CommitVerdict {
355        message,
356        passed,
357        exempt: None,
358        findings: found
359            .into_iter()
360            .map(|(rule, severity, msg)| Diagnostic {
361                severity,
362                code: rule.code().to_string(),
363                path: None,
364                message: msg,
365            })
366            .collect(),
367    }
368}
369
370/// A finding in the order a person reads them: by the rule's place in `CommitRule::ALL`,
371/// then by what it says, so two findings under one rule never trade places between runs.
372struct RankedFinding {
373    rule: CommitRule,
374    severity: Severity,
375    message: String,
376}
377
378impl crate::order::Ordered for RankedFinding {
379    fn order_key(&self) -> crate::order::OrderKey<'_> {
380        let position = CommitRule::ALL
381            .iter()
382            .position(|r| *r == self.rule)
383            .map_or(i64::MAX, |p| p as i64);
384        crate::order::OrderKey::plain(&self.message, &self.message).ranked(position)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn codes(v: &CommitVerdict) -> Vec<&str> {
393        v.findings.iter().map(|d| d.code.as_str()).collect()
394    }
395
396    #[test]
397    fn a_well_formed_commit_passes_with_nothing_to_say() {
398        let v = judge(
399            &CommitSubject::of("feat(commit): the rule is decided by a command"),
400            &CommitPolicy::default(),
401        );
402        assert!(v.passed, "{:?}", v.findings);
403        assert!(v.findings.is_empty());
404        assert!(v.exempt.is_none());
405    }
406
407    #[test]
408    fn a_subject_that_is_not_conventional_is_one_finding_and_not_five() {
409        // The consequences of an unparsed header are not findings of their own: a person
410        // fixing `update stuff` does not need to be told its scope is unknown as well.
411        let v = judge(&CommitSubject::of("update stuff"), &CommitPolicy::default());
412        assert!(!v.passed);
413        assert_eq!(codes(&v), vec!["commit.not_conventional"]);
414    }
415
416    #[test]
417    fn git_composes_subjects_that_no_convention_applies_to() {
418        for s in [
419            "Merge pull request #249 from korczis/fix/pipelines",
420            "Revert \"feat(x): a thing\"",
421            "fixup! feat(x): a thing",
422        ] {
423            let v = judge(&CommitSubject::of(s), &CommitPolicy::default());
424            assert!(v.passed, "{s}");
425            assert_eq!(v.exempt, Some(CommitExemption::GitAuthored), "{s}");
426            assert!(v.findings.is_empty(), "{s}");
427        }
428    }
429
430    #[test]
431    fn a_wide_subject_is_measured_whole_and_not_only_after_the_colon() {
432        let long = "x".repeat(80);
433        let v = judge(
434            &CommitSubject::of(&format!("feat(a): {long}")),
435            &CommitPolicy::default(),
436        );
437        assert_eq!(codes(&v), vec!["commit.subject_too_long"]);
438        assert!(!v.passed);
439        // the width reported is the whole line, which is what a viewer wraps
440        assert!(v.findings[0].message.contains("89 characters"));
441    }
442
443    #[test]
444    fn a_scope_outside_the_vocabulary_is_a_warning_and_still_passes() {
445        let subject = CommitSubject {
446            text: "feat(unheard-of): a new subsystem".into(),
447            scopes: Some(vec!["commit".into(), "http".into()]),
448            ..CommitSubject::default()
449        };
450        let v = judge(&subject, &CommitPolicy::default());
451        assert_eq!(codes(&v), vec!["commit.unknown_scope"]);
452        // inference must not refuse the first commit of a subsystem it has never seen
453        assert!(v.passed);
454        assert_eq!(v.findings[0].severity, Severity::Warning);
455    }
456
457    #[test]
458    fn a_scope_in_the_vocabulary_says_nothing() {
459        let subject = CommitSubject {
460            text: "feat(commit): a thing".into(),
461            scopes: Some(vec!["commit".into()]),
462            ..CommitSubject::default()
463        };
464        assert!(judge(&subject, &CommitPolicy::default())
465            .findings
466            .is_empty());
467    }
468
469    #[test]
470    fn a_reference_the_layer_does_not_hold_fails_and_one_it_holds_does_not() {
471        let held = CommitSubject {
472            text: "fix(plan): a thing under I1305".into(),
473            records: vec!["I1305".into()],
474            ..CommitSubject::default()
475        };
476        assert!(judge(&held, &CommitPolicy::default()).findings.is_empty());
477
478        let invented = CommitSubject {
479            text: "fix(plan): a thing under I9999".into(),
480            records: vec!["I1305".into()],
481            ..CommitSubject::default()
482        };
483        let v = judge(&invented, &CommitPolicy::default());
484        assert_eq!(codes(&v), vec!["commit.unresolved_reference"]);
485        assert!(!v.passed);
486    }
487
488    #[test]
489    fn a_fix_is_asked_for_a_test_only_when_its_files_are_known() {
490        let with_test = CommitSubject {
491            text: "fix(commit): a thing".into(),
492            paths: Some(vec![
493                "src/commit/verdict.rs".into(),
494                "test/cases/1.sh".into(),
495            ]),
496            ..CommitSubject::default()
497        };
498        assert!(judge(&with_test, &CommitPolicy::default())
499            .findings
500            .is_empty());
501
502        let without = CommitSubject {
503            text: "fix(commit): a thing".into(),
504            paths: Some(vec!["src/commit/verdict.rs".into()]),
505            ..CommitSubject::default()
506        };
507        let v = judge(&without, &CommitPolicy::default());
508        assert_eq!(codes(&v), vec!["commit.fix_without_test"]);
509        assert!(
510            v.passed,
511            "a warning by default, so that a gate over history does not rewrite it"
512        );
513
514        // a gate reading history knows no paths, and must not invent the finding
515        let unknown = CommitSubject::of("fix(commit): a thing");
516        assert!(judge(&unknown, &CommitPolicy::default())
517            .findings
518            .is_empty());
519    }
520
521    #[test]
522    fn a_fix_that_only_touches_tests_is_covered_by_them() {
523        let only_tests = CommitSubject {
524            text: "fix(commit): a thing".into(),
525            paths: Some(vec!["apps/majordomus-cli/src/commit/tests.rs".into()]),
526            ..CommitSubject::default()
527        };
528        assert!(judge(&only_tests, &CommitPolicy::default())
529            .findings
530            .is_empty());
531    }
532
533    #[test]
534    fn a_breaking_change_must_say_why() {
535        let bare = judge(
536            &CommitSubject::of("feat(api)!: the route moved"),
537            &CommitPolicy::default(),
538        );
539        assert_eq!(codes(&bare), vec!["commit.breaking_unexplained"]);
540        assert!(!bare.passed);
541
542        let explained = judge(
543            &CommitSubject::of("feat(api)!: the route moved\n\nIt is now under /api/v2."),
544            &CommitPolicy::default(),
545        );
546        assert!(explained.findings.is_empty());
547
548        let trailer = judge(
549            &CommitSubject::of(
550                "feat(api): the route moved\n\nBREAKING CHANGE: it is under /api/v2",
551            ),
552            &CommitPolicy::default(),
553        );
554        assert!(trailer.findings.is_empty());
555    }
556
557    #[test]
558    fn a_level_of_off_reports_nothing_at_all() {
559        let policy = CommitPolicy {
560            reference_unresolved: FindingLevel::Off,
561            ..CommitPolicy::default()
562        };
563        let invented = CommitSubject {
564            text: "fix(plan): a thing under I9999".into(),
565            ..CommitSubject::default()
566        };
567        let v = judge(&invented, &policy);
568        assert!(v.findings.is_empty());
569        assert!(v.passed);
570    }
571
572    #[test]
573    fn findings_are_ordered_by_where_their_evidence_is() {
574        let subject = CommitSubject {
575            text: format!("fix(nope)!: {}.", "x".repeat(80)),
576            scopes: Some(vec!["commit".into()]),
577            ..CommitSubject::default()
578        };
579        let v = judge(&subject, &CommitPolicy::default());
580        assert_eq!(
581            codes(&v),
582            vec![
583                "commit.subject_too_long",
584                "commit.subject_trailing_period",
585                "commit.unknown_scope",
586                "commit.breaking_unexplained",
587            ]
588        );
589    }
590
591    #[test]
592    fn a_test_path_is_recognised_by_the_shapes_this_repository_uses() {
593        for p in [
594            "test/cases/01_init.sh",
595            "tests/integration.rs",
596            "apps/majordomus-cli/src/commit/tests.rs",
597            "lib/foo_test.exs",
598            "site/a.test.js",
599        ] {
600            assert!(is_test_path(p), "{p}");
601        }
602        for p in ["src/commit/verdict.rs", "docs/COMMIT.md", "latest.rs"] {
603            assert!(!is_test_path(p), "{p}");
604        }
605    }
606}
607
608/// One commit of the history, judged.
609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
610/// ```
611/// use majordomus_cli::commit::verdict::JudgedCommit;
612/// // what a history report lists: only the commits that have something to say
613/// let row = JudgedCommit {
614///     commit: "a1b2c3d4e".into(),
615///     subject: "update stuff".into(),
616///     passed: false,
617///     exempt: None,
618///     findings: Vec::new(),
619/// };
620/// assert!(!row.passed);
621/// ```
622pub struct JudgedCommit {
623    /// The commit name, as git gave it.
624    pub commit: String,
625    /// Its subject line.
626    pub subject: String,
627    /// Whether it passed.
628    pub passed: bool,
629    /// Why it was not judged, when it was not.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub exempt: Option<CommitExemption>,
632    /// Every finding.
633    pub findings: Vec<Diagnostic>,
634}
635
636/// A range of history, judged.
637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
638/// A range of history, judged in one pass.
639///
640/// ```
641/// use majordomus_cli::commit::verdict::HistoryReport;
642/// let r = HistoryReport {
643///     range: "HEAD".into(),
644///     total: 1755,
645///     exempt: 561,
646///     failing: 54,
647///     commits: Vec::new(),
648/// };
649/// // a commit with nothing to say is counted and not listed: a report of a thousand
650/// // passing commits is a report nobody reads
651/// assert!(r.commits.is_empty());
652/// assert_eq!(r.total - r.exempt, 1194);
653/// ```
654pub struct HistoryReport {
655    /// The range as asked for.
656    pub range: String,
657    /// How many commits were read.
658    pub total: usize,
659    /// How many were exempt because git composed their subject.
660    pub exempt: usize,
661    /// How many carry a finding of error severity.
662    pub failing: usize,
663    /// Every commit that carries a finding, newest first. A commit with nothing to say is
664    /// counted and not listed: a report that printed a thousand passing commits would be a
665    /// report nobody reads.
666    pub commits: Vec<JudgedCommit>,
667}
668
669/// A record id inside a backticked code span is being *shown*, not cited: a message that
670/// writes ``I9999 — Gone from the model`` is illustrating the shape of a reference, and the
671/// layer is not expected to hold it. Blanking the spans before the scan keeps a real
672/// citation in prose readable while an example stops being one.
673///
674/// ```
675/// use majordomus_cli::commit::verdict::without_code_spans;
676/// assert_eq!(without_code_spans("names `I9999` here"), "names         here");
677/// assert_eq!(without_code_spans("names I9999 here"), "names I9999 here");
678/// ```
679pub fn without_code_spans(text: &str) -> String {
680    let mut out = String::with_capacity(text.len());
681    let mut inside = false;
682    for ch in text.chars() {
683        if ch == '`' {
684            inside = !inside;
685            out.push(' ');
686        } else if inside && ch != '\n' {
687            out.push(' ');
688        } else {
689            out.push(ch);
690        }
691    }
692    out
693}