Skip to main content

release_kit/commands/
message.rs

1//! `rk message`: the content guards over a commit message, a title, or a
2//! request body.
3//!
4//! Two classes of finding are declared in `blocks/message-guards`:
5//! attribution an agent left in the text, and a reference to a path the
6//! target repository ignores — an internal artifact that would leak into
7//! the permanent record. The release bot's request is exempt from the
8//! attribution class alone, recognized by exactly the title shape the
9//! landed title check admits for the bot; the ignored-path class always
10//! runs. The guard file owns the patterns; the matchers here implement
11//! them by hand — the binary carries no regex engine — and a unit test
12//! pins the file to the set the matchers cover, so a pattern edit and its
13//! matcher move together.
14//!
15//! A third class is a shape rather than a forbidden pattern, so it lives
16//! here and not in the guard file: a subject whose scope falls outside
17//! [`landing::SCOPE_SHAPE`]. The landed `conventional-pre-commit` hook
18//! requires a scope but takes no pattern for it, so without this class a
19//! scope the forge's title check rejects passes at the desk.
20//!
21//! A fourth class is the scope's absence, judged only where the target's
22//! record names a release, because the changelog reads the scope. Under
23//! `release.mode = "none"` a scope is preferred and not required, so a
24//! Conventional Commit header with none passes.
25
26use std::io::Read as _;
27use std::io::Write as _;
28
29use camino::Utf8Path;
30use serde::Serialize;
31
32use crate::cli::message::{MessageArgs, MessageKind};
33use crate::diagnostic::{Diagnostic, Reason};
34use crate::error::RkError;
35use crate::landing;
36use crate::output::Output;
37
38/// The guard patterns, verbatim: `blocks/message-guards`.
39static GUARDS: &str = include_str!("../../blocks/message-guards");
40
41/// One finding.
42#[derive(Debug, Serialize)]
43pub(crate) struct Finding {
44    /// `attribution`, `internal-path`, `scope-shape`, or `scope-missing`.
45    pub(crate) class: &'static str,
46    /// The 1-based line the finding sits on.
47    pub(crate) line: usize,
48    /// What matched.
49    pub(crate) detail: String,
50}
51
52/// The machine form of a report.
53#[derive(Debug, Serialize)]
54struct Report {
55    /// The shape version of this document.
56    schema: &'static str,
57    /// What the text was judged as.
58    kind: &'static str,
59    /// Whether the bot exemption applied to the attribution class.
60    exempt: bool,
61    /// Every finding, in line order.
62    findings: Vec<Finding>,
63}
64
65/// Judge the text and report; exit 1 under `--check` when a finding stands.
66///
67/// # Errors
68///
69/// Returns [`RkError::CheckFailed`] under `--check` when any finding
70/// stands, [`RkError::Io`] when the input cannot be read, and an error
71/// when the report cannot serialize.
72pub fn run(args: &MessageArgs) -> Result<(), RkError> {
73    let text = read_input(args.file.as_deref().map(camino::Utf8Path::as_str))?;
74    let out = Output::new(args.json);
75
76    let title = match args.kind {
77        MessageKind::Commit | MessageKind::Title => text.lines().next().unwrap_or(""),
78        MessageKind::Body => args.title.as_deref().unwrap_or(""),
79    };
80    let exempt = bot_title(title);
81    let judgment = Judgment {
82        exempt,
83        scope_required: scope_required(&args.target),
84    };
85
86    let (findings, no_repo) = judge(&text, args.kind, &args.target, title, judgment);
87    if no_repo && !args.json {
88        out.warn(format!(
89            "{} is not a git repository; only the fixed .draft/ pattern was tested",
90            args.target
91        ));
92    }
93
94    if exempt {
95        out.result_line("exempt: the release bot's request, by its title");
96    }
97    for finding in &findings {
98        out.result_line(format!(
99            "{}:{} {}",
100            finding.class, finding.line, finding.detail
101        ));
102    }
103    if findings.is_empty() {
104        out.result_line(format!("clean {}", args.kind.as_str()));
105    }
106    let count = findings.len();
107    out.emit(&Report {
108        schema: "rk.message/3",
109        kind: args.kind.as_str(),
110        exempt,
111        findings,
112    })?;
113
114    if args.check && count > 0 {
115        return Err(RkError::check_failed(
116            Diagnostic::new(
117                Reason::StateDrift,
118                format!(
119                    "the {} carries {count} finding{}",
120                    args.kind.as_str(),
121                    if count == 1 { "" } else { "s" }
122                ),
123            )
124            .expected("no agent attribution, no reference to a git-ignored path, and a scope the title check admits")
125            .action("reword the text; the findings above name each line"),
126        ));
127    }
128    Ok(())
129}
130
131/// What decides the judgment beside the text itself.
132#[derive(Debug, Clone, Copy)]
133pub(crate) struct Judgment {
134    /// Whether the release bot's exemption applies, by the title.
135    pub(crate) exempt: bool,
136    /// Whether the subject must name a scope: wherever the target's record
137    /// names a release.
138    pub(crate) scope_required: bool,
139}
140
141/// Whether the target at `target` requires a scope on every subject: its
142/// record, or its configuration where no record stands, names a release.
143/// A target that states neither is judged as before, by the landed
144/// `conventional-pre-commit` hook alone.
145///
146/// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
147fn scope_required(target: &Utf8Path) -> bool {
148    let mode = crate::landing::manifest::load(target)
149        .ok()
150        .flatten()
151        .map(|record| record.profile.release.mode)
152        .or_else(|| {
153            crate::config::load(target.as_std_path())
154                .ok()
155                .flatten()
156                .and_then(|config| config.profile.release.mode)
157        });
158    mode.is_some_and(|mode| mode != crate::profile::ReleaseMode::None)
159}
160
161/// Every finding one text carries, in line order, beside whether the
162/// ignore judgment had no repository to ask.
163///
164/// The one owner of what `rk message --check` judges, so a second caller
165/// cannot judge a weaker set. `rk integrate` calls it before writing a
166/// trunk commit with `git commit-tree`, which fires no `commit-msg` hook
167/// and would otherwise publish a message the landed hook refuses.
168pub(crate) fn judge(
169    text: &str,
170    kind: MessageKind,
171    target: &Utf8Path,
172    title: &str,
173    judgment: Judgment,
174) -> (Vec<Finding>, bool) {
175    let exempt = judgment.exempt;
176    let mut findings = Vec::new();
177    let mut no_repo = false;
178    for (index, line) in text.lines().enumerate() {
179        if !exempt {
180            findings.extend(attribution_hits(line).into_iter().map(|detail| Finding {
181                class: "attribution",
182                line: index + 1,
183                detail,
184            }));
185        }
186    }
187    // The subject is line 1 of a commit message and of a title; a body's
188    // title is context passed beside it, and its findings would carry no
189    // line the reader can open.
190    if matches!(kind, MessageKind::Commit | MessageKind::Title)
191        && !exempt
192        && let Some(scope) = misshapen_scope(title)
193    {
194        findings.push(Finding {
195            class: "scope-shape",
196            line: 1,
197            detail: format!(
198                "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
199                landing::SCOPE_SHAPE
200            ),
201        });
202    }
203    if matches!(kind, MessageKind::Commit | MessageKind::Title)
204        && !exempt
205        && judgment.scope_required
206        && let Some(kind) = unscoped_type(title)
207    {
208        findings.push(Finding {
209            class: "scope-missing",
210            line: 1,
211            detail: format!(
212                "the subject names no scope after '{kind}', and this target names a release, so every subject reads <type>(<scope>): <description>"
213            ),
214        });
215    }
216    let mut seen: std::collections::BTreeSet<(usize, String)> = std::collections::BTreeSet::new();
217    match ignored_paths(target, text) {
218        IgnoreJudgment::Repo(hits) => {
219            for (line, token) in hits {
220                findings.push(Finding {
221                    class: "internal-path",
222                    line,
223                    detail: format!("{token} is git-ignored in {target}"),
224                });
225                seen.insert((line, token));
226            }
227        }
228        IgnoreJudgment::NoRepo => no_repo = true,
229    }
230    // A fixed-pattern fragment already inside a reported token on the
231    // same line — `nested/.draft/plan.md` carrying `.draft/plan.md` — is
232    // the same reference, not a second finding.
233    findings.extend(
234        fixed_draft_hits(text)
235            .into_iter()
236            .filter(|(line, fragment)| {
237                !seen
238                    .iter()
239                    .any(|(seen_line, token)| seen_line == line && token.contains(fragment))
240            })
241            .map(|(line, fragment)| Finding {
242                class: "internal-path",
243                line,
244                detail: format!("{fragment} references the internal .draft/ tree"),
245            }),
246    );
247    findings.sort_by_key(|finding| finding.line);
248    (findings, no_repo)
249}
250
251/// Whether the release bot's exemption applies to one title.
252pub(crate) fn exempt_title(title: &str) -> bool {
253    bot_title(title)
254}
255
256/// The text: stdin for `-` or no file, the file otherwise.
257fn read_input(file: Option<&str>) -> Result<String, RkError> {
258    match file {
259        None | Some("-") => {
260            let mut text = String::new();
261            std::io::stdin().read_to_string(&mut text)?;
262            Ok(text)
263        }
264        Some(path) => Ok(std::fs::read_to_string(path)?),
265    }
266}
267
268/// Whether the title is the release bot's, by exactly the shape the
269/// landed title check admits for it:
270/// `^chore(\((release|master|main)\))?: (release|v).+$`.
271fn bot_title(title: &str) -> bool {
272    let Some(rest) = title.strip_prefix("chore") else {
273        return false;
274    };
275    let rest = if rest.starts_with('(') {
276        let Some(rest) = ["(release)", "(master)", "(main)"]
277            .iter()
278            .find_map(|scope| rest.strip_prefix(scope))
279        else {
280            return false;
281        };
282        rest
283    } else {
284        rest
285    };
286    let Some(rest) = rest.strip_prefix(": ") else {
287        return false;
288    };
289    ["release", "v"].iter().any(|stem| {
290        rest.strip_suffix('\n')
291            .unwrap_or(rest)
292            .strip_prefix(stem)
293            .is_some_and(|tail| !tail.is_empty())
294    })
295}
296
297/// The subject's scope where it falls outside [`landing::SCOPE_SHAPE`],
298/// or `None`.
299///
300/// The scope is what sits between the parentheses of a `type(scope):`
301/// subject, with the breaking `!` outside them. A subject carrying no
302/// scope at all returns `None`: requiring one is the landed
303/// `conventional-pre-commit` hook's job, and this class judges the shape
304/// of a scope that is there.
305fn misshapen_scope(title: &str) -> Option<String> {
306    let (kind, rest) = title.split_once('(')?;
307    if kind.is_empty() || !kind.chars().all(|c| c.is_ascii_alphabetic()) {
308        return None;
309    }
310    let (scope, rest) = rest.split_once(')')?;
311    if !(rest.starts_with(':') || rest.starts_with("!:")) {
312        return None;
313    }
314    (!landing::scope_is_shaped(scope)).then(|| scope.to_owned())
315}
316
317/// The type of a Conventional Commit subject that names no scope, or
318/// `None`: `fix: drop it` and `feat!: break it` answer, while a scoped
319/// subject and a subject that is no Conventional Commit at all do not,
320/// because the landed `conventional-pre-commit` hook owns that shape.
321fn unscoped_type(title: &str) -> Option<&str> {
322    let (head, _) = title.split_once(':')?;
323    let kind = head.strip_suffix('!').unwrap_or(head);
324    (!kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphabetic())).then_some(kind)
325}
326
327/// Every attribution match on one line, as the matched fragment.
328///
329/// Each matcher implements one pattern of the guard file's attribution
330/// class by hand, exactly — each bracketed case class expands to its
331/// literal variants, never to a broader case-insensitive search — and
332/// [`guard_patterns`] with its test holds the two in step.
333fn attribution_hits(line: &str) -> Vec<String> {
334    let mut hits = Vec::new();
335    // [Gg]enerated with \[?Claude
336    if [
337        "Generated with Claude",
338        "generated with Claude",
339        "Generated with [Claude",
340        "generated with [Claude",
341    ]
342    .iter()
343    .any(|variant| line.contains(variant))
344    {
345        hits.push("generated-with-claude attribution".to_owned());
346    }
347    // 🤖 Generated with
348    if line.contains("🤖 Generated with") {
349        hits.push("robot generated-with attribution".to_owned());
350    }
351    // [Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)
352    let trailer = [
353        "Co-Authored-By:",
354        "Co-Authored-by:",
355        "Co-authored-By:",
356        "Co-authored-by:",
357        "co-Authored-By:",
358        "co-Authored-by:",
359        "co-authored-By:",
360        "co-authored-by:",
361    ]
362    .iter()
363    .filter_map(|variant| line.find(variant))
364    .min();
365    if let Some(at) = trailer {
366        let tail = &line[at..];
367        if ["Claude", "claude", "Copilot", "copilot", "Codex", "ChatGPT"]
368            .iter()
369            .any(|agent| tail.contains(agent))
370        {
371            hits.push("agent co-authored-by trailer".to_owned());
372        }
373    }
374    // noreply@anthropic\.com
375    if line.contains("noreply@anthropic.com") {
376        hits.push("anthropic noreply address".to_owned());
377    }
378    hits
379}
380
381/// What the ignored-path check could determine.
382enum IgnoreJudgment {
383    /// The target is a repository; these `(line, token)` pairs are ignored.
384    Repo(Vec<(usize, String)>),
385    /// No repository at the target; only the fixed pattern judged.
386    NoRepo,
387}
388
389/// The path-shaped tokens of the text the target's ignore rules reject,
390/// degraded to no repository judgment where the target is none. The
391/// fixed `.draft/` pattern is not here: it runs unconditionally in
392/// [`fixed_draft_hits`], so a decorated reference no clean token carries
393/// still answers, repository or not.
394fn ignored_paths(target: &Utf8Path, text: &str) -> IgnoreJudgment {
395    let candidates: Vec<(usize, String)> = text
396        .lines()
397        .enumerate()
398        .flat_map(|(index, line)| {
399            line.split_whitespace()
400                .filter_map(path_token)
401                .map(move |token| (index + 1, token))
402        })
403        .collect();
404    if candidates.is_empty() {
405        return IgnoreJudgment::Repo(Vec::new());
406    }
407    check_ignore(target, &candidates).map_or(IgnoreJudgment::NoRepo, IgnoreJudgment::Repo)
408}
409
410/// Every `(line, fragment)` the fixed internal-path pattern matches:
411/// `(^|[^A-Za-z0-9])\.draft/`, implemented exactly — a `.draft/` whose
412/// preceding character, where one exists, is not ASCII-alphanumeric —
413/// with the fragment read forward to the surrounding whitespace and
414/// trimmed of trailing wrappers, so a decorated reference like
415/// `path=.draft/plan.md` or a markdown link still answers.
416fn fixed_draft_hits(text: &str) -> Vec<(usize, String)> {
417    let mut hits = Vec::new();
418    for (index, line) in text.lines().enumerate() {
419        for (at, _) in line.match_indices(".draft/") {
420            let boundary = line[..at]
421                .chars()
422                .next_back()
423                .is_none_or(|c| !c.is_ascii_alphanumeric());
424            if !boundary {
425                continue;
426            }
427            let tail = &line[at..];
428            let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
429            let fragment = tail[..end].trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
430            hits.push((index + 1, fragment.to_owned()));
431        }
432    }
433    hits
434}
435
436/// A whitespace token reduced to its path candidate: wrapping brackets
437/// and quotes trimmed from both edges, sentence punctuation only from the
438/// end — a leading dot is part of a hidden path — URLs, flags, and
439/// variables skipped, and only a token of two or more segments kept,
440/// because a bare word is prose, not a reference.
441fn path_token(token: &str) -> Option<String> {
442    let token = token
443        .trim_start_matches(|c: char| "()[]<>`'\"".contains(c))
444        .trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
445    if token.contains("://") || token.starts_with('-') || token.contains('$') {
446        return None;
447    }
448    let (head, tail) = token.split_once('/')?;
449    if head.is_empty() || tail.is_empty() {
450        return None;
451    }
452    Some(token.to_owned())
453}
454
455/// The candidates the target's git ignores, or `None` where the target is
456/// not a repository the check could consult.
457///
458/// `-z` on both sides: input and output are NUL-delimited, so a non-ASCII
459/// path comes back verbatim rather than `core.quotePath`-escaped and the
460/// byte comparison holds. The writer is its own thread, because git may
461/// fill its stdout pipe while this process is still writing stdin — the
462/// buffering deadlock its documentation assigns the caller.
463fn check_ignore(target: &Utf8Path, candidates: &[(usize, String)]) -> Option<Vec<(usize, String)>> {
464    let mut command = std::process::Command::new(crate::probes::git_bin());
465    let mut child = command
466        .arg("-C")
467        .arg(target.as_std_path())
468        .args(["check-ignore", "--stdin", "-z"])
469        .stdin(std::process::Stdio::piped())
470        .stdout(std::process::Stdio::piped())
471        .stderr(std::process::Stdio::null())
472        .spawn()
473        .ok()?;
474    let writer = child.stdin.take().map(|mut stdin| {
475        let payload: Vec<u8> = candidates
476            .iter()
477            .flat_map(|(_, token)| token.as_bytes().iter().copied().chain([0]))
478            .collect();
479        std::thread::spawn(move || {
480            let _ = stdin.write_all(&payload);
481        })
482    });
483    let output = child.wait_with_output().ok()?;
484    if let Some(writer) = writer {
485        let _ = writer.join();
486    }
487    // 0: some input is ignored; 1: none is. Anything else — 128 for a
488    // missing repository above all — is a target the check cannot judge.
489    if !matches!(output.status.code(), Some(0 | 1)) {
490        return None;
491    }
492    let ignored: std::collections::BTreeSet<&[u8]> = output
493        .stdout
494        .split(|byte| *byte == 0)
495        .filter(|path| !path.is_empty())
496        .collect();
497    Some(
498        candidates
499            .iter()
500            .filter(|(_, token)| ignored.contains(token.as_bytes()))
501            .cloned()
502            .collect(),
503    )
504}
505
506/// The guard file's `(class, pattern)` lines, in order.
507#[must_use]
508pub fn guard_patterns() -> Vec<(&'static str, &'static str)> {
509    let mut class = "";
510    let mut patterns = Vec::new();
511    for line in GUARDS.lines() {
512        if let Some(named) = line.strip_prefix("# class: ") {
513            class = named;
514        } else if !line.starts_with('#') && !line.is_empty() {
515            patterns.push((class, line));
516        }
517    }
518    patterns
519}
520
521#[cfg(test)]
522mod tests {
523    use super::{
524        Finding, Judgment, Report, attribution_hits, bot_title, fixed_draft_hits, guard_patterns,
525        judge, path_token, unscoped_type,
526    };
527
528    /// The complete `rk.message/3` shape, held by snapshot: the class
529    /// vocabulary is part of the contract, so the third class moved the
530    /// version rather than arriving unannounced, and the fourth,
531    /// `scope-missing`, moved it again.
532    #[test]
533    fn the_message_schema_snapshot_holds() {
534        let report = Report {
535            schema: "rk.message/3",
536            kind: "commit",
537            exempt: false,
538            findings: vec![
539                Finding {
540                    class: "scope-shape",
541                    line: 1,
542                    detail: "the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -".into(),
543                },
544                Finding {
545                    class: "internal-path",
546                    line: 3,
547                    detail: ".draft/plan.md is git-ignored in .".into(),
548                },
549            ],
550        };
551        assert_eq!(
552            serde_json::to_string(&report).expect("a report serializes"),
553            r#"{"schema":"rk.message/3","kind":"commit","exempt":false,"findings":[{"class":"scope-shape","line":1,"detail":"the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -"},{"class":"internal-path","line":3,"detail":".draft/plan.md is git-ignored in ."}]}"#
554        );
555    }
556
557    /// The guard file and the hand matchers move together: this is the
558    /// exact pattern set the matchers above implement, so an edit to
559    /// `blocks/message-guards` fails here until the matcher follows.
560    #[test]
561    fn the_guard_file_holds_the_patterns_the_matchers_implement() {
562        assert_eq!(
563            guard_patterns(),
564            [
565                ("attribution", r"[Gg]enerated with \[?Claude"),
566                ("attribution", "🤖 Generated with"),
567                (
568                    "attribution",
569                    r"[Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)"
570                ),
571                ("attribution", r"noreply@anthropic\.com"),
572                ("internal-path", r"(^|[^A-Za-z0-9])\.draft/"),
573            ]
574        );
575    }
576
577    #[test]
578    fn the_attribution_matchers_cover_the_patterns() {
579        for line in [
580            "Generated with Claude Code",
581            "generated with [Claude Code](https://claude.com/claude-code)",
582            "🤖 Generated with tooling",
583            "Co-Authored-By: Claude <x@y>",
584            "co-authored-by: github-copilot",
585            "Co-authored-by: Codex",
586            "Co-Authored-By: ChatGPT",
587            "Signed noreply@anthropic.com",
588        ] {
589            assert!(!attribution_hits(line).is_empty(), "{line} must match");
590        }
591        for line in [
592            "Generated with release-plz",
593            "Co-authored-by: A Person <person@example.com>",
594            "the claude skill route",
595            "Co-authored-by: Autopilot Team",
596            "Xenerated with Claude",
597            "CO-AUTHORED-BY: Claude",
598        ] {
599            assert!(attribution_hits(line).is_empty(), "{line} must not match");
600        }
601    }
602
603    /// Case transformation never feeds an offset back into the original:
604    /// multibyte text before a trailer must match without panicking.
605    #[test]
606    fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
607        // Enough expanding characters that a lowercased offset would
608        // fall past the original string's end, not merely drift.
609        let line = format!("{} Co-Authored-By: Claude", "İ".repeat(40));
610        assert!(!attribution_hits(&line).is_empty());
611        assert!(attribution_hits(&format!("{} nothing here", "İ".repeat(40))).is_empty());
612    }
613
614    /// The fixed pattern is `(^|[^A-Za-z0-9])\.draft/`, exactly: a
615    /// decorated reference answers, an alphanumeric-adjacent one does not.
616    #[test]
617    fn the_fixed_pattern_matches_decorated_references_only_at_a_boundary() {
618        assert_eq!(
619            fixed_draft_hits(
620                "path=.draft/plan.md
621"
622            ),
623            vec![(1, ".draft/plan.md".to_owned())]
624        );
625        assert_eq!(
626            fixed_draft_hits(
627                "a [plan](.draft/plan.md) link
628"
629            ),
630            vec![(1, ".draft/plan.md".to_owned())]
631        );
632        assert_eq!(
633            fixed_draft_hits(
634                ".draft/x
635"
636            ),
637            vec![(1, ".draft/x".to_owned())]
638        );
639        assert!(
640            fixed_draft_hits(
641                "archived.draft/x
642"
643            )
644            .is_empty()
645        );
646        assert!(
647            fixed_draft_hits(
648                "no reference here
649"
650            )
651            .is_empty()
652        );
653    }
654
655    /// Exactly the landed title check's bot alternative:
656    /// `^chore(\((release|master|main)\))?: (release|v).+$`.
657    #[test]
658    fn the_bot_exemption_is_the_title_checks_bot_alternative() {
659        for title in [
660            "chore: release v0.2.6",
661            "chore(release): v0.3.0",
662            "chore(master): release 1.0.0",
663            "chore(main): v2",
664        ] {
665            assert!(bot_title(title), "{title} is the bot's");
666        }
667        for title in [
668            "chore: bump deps",
669            "chore(deps): release v1",
670            "feat(cli): release v1",
671            "chore(release): ",
672            "chore(release): v",
673            "chore:release v1",
674        ] {
675            assert!(!bot_title(title), "{title} is not the bot's");
676        }
677    }
678
679    #[test]
680    fn a_path_token_is_two_segments_without_url_flag_or_variable() {
681        assert_eq!(
682            path_token("(.draft/plan.md)"),
683            Some(".draft/plan.md".into())
684        );
685        assert_eq!(path_token("`src/main.rs`,"), Some("src/main.rs".into()));
686        assert_eq!(path_token("https://a.b/c"), None);
687        assert_eq!(path_token("--flag/value"), None);
688        assert_eq!(path_token("$HOME/x"), None);
689        assert_eq!(path_token("and/or"), Some("and/or".into()));
690        assert_eq!(path_token("word"), None);
691        assert_eq!(path_token("trailing/"), None);
692    }
693
694    /// A header with no scope passes where the record names no release and
695    /// refuses where it names one, naming the missing scope. The bot's
696    /// request, which names none, stays exempt, and a subject that is no
697    /// Conventional Commit is the landed hook's to judge.
698    ///
699    /// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
700    #[test]
701    fn workflow_only_a_header_with_no_scope_passes_only_without_a_release() {
702        let dir = tempfile::tempdir().expect("a scratch dir exists");
703        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
704        let judged = |text: &str, scope_required: bool| {
705            let title = text.lines().next().unwrap_or("");
706            judge(
707                text,
708                crate::cli::message::MessageKind::Commit,
709                target,
710                title,
711                Judgment {
712                    exempt: bot_title(title),
713                    scope_required,
714                },
715            )
716            .0
717        };
718        assert!(judged("fix: drop the stale path\n", false).is_empty());
719        let refused = judged("fix: drop the stale path\n", true);
720        assert_eq!(refused.len(), 1, "{refused:?}");
721        assert_eq!(refused[0].class, "scope-missing");
722        assert!(refused[0].detail.contains("names no scope"), "{refused:?}");
723        assert!(judged("fix(paths): drop the stale path\n", true).is_empty());
724        assert!(judged("chore: release v1.2.0\n", true).is_empty());
725        assert_eq!(unscoped_type("feat!: break it"), Some("feat"));
726        assert_eq!(unscoped_type("feat(x): add it"), None);
727        assert_eq!(unscoped_type("Merge branch 'x' into master"), None);
728        assert_eq!(unscoped_type("fixup! feat(x): add it"), None);
729        // A misshapen scope is still judged where none is required.
730        assert_eq!(
731            judged("fix(Bad Scope): mend\n", false)[0].class,
732            "scope-shape"
733        );
734    }
735}