Skip to main content

omni_dev/git/
lint.rs

1//! Deterministic, no-AI commit-message lint.
2//!
3//! [`lint_message`] is the pure predicate behind `omni-dev git commit
4//! message lint`. It takes no git handle and does no I/O, so it is
5//! exhaustively unit-testable and shared with
6//! [`crate::git::commit::CommitInfoForAI::run_pre_validation_checks`] /
7//! [`crate::git::commit::refine_message_scope`] via [`parse_subject`] and
8//! the scope-check helpers below — one implementation, multiple consumers
9//! (#1474).
10
11use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::data::check::{CommitIssue, IssueSeverity};
16use crate::data::context::{CommitRules, ScopeDefinition};
17
18/// Matches `type(scope)!: description`, `type(scope): description`,
19/// `type: description`, and the lenient legacy `type!(scope): description`
20/// form (accepted for backward compatibility, though not the documented
21/// form). This is the corrected #1473 pattern from the start: the canonical
22/// breaking-change `!` is placed *after* the closing paren, not before it.
23static SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
24    #[allow(clippy::expect_used)] // compile-time constant pattern
25    Regex::new(
26        r"^(?P<type>[a-z]+)(?P<bang1>!)?(?:\((?P<scope>[^)]*)\))?(?P<bang2>!)?: (?P<desc>.*)$",
27    )
28    .expect("SUBJECT_RE is a valid compile-time constant regex")
29});
30
31/// Matches invalid comma spacing within a (possibly multi-part) scope
32/// segment: any whitespace before a comma, or two-or-more spaces after one.
33/// Mirrors `.omni-dev/commit-guidelines.md`'s "Two or more spaces after a
34/// comma, or any whitespace before a comma, is not permitted."
35static BAD_SCOPE_COMMA_RE: LazyLock<Regex> = LazyLock::new(|| {
36    #[allow(clippy::expect_used)] // compile-time constant pattern
37    Regex::new(r"\s,|,\s{2,}").expect("BAD_SCOPE_COMMA_RE is a valid compile-time constant regex")
38});
39
40/// A parsed conventional-commit subject line.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ParsedSubject<'a> {
43    /// The `type` token, e.g. `feat`.
44    pub commit_type: &'a str,
45    /// The raw `(scope)` contents, un-split on commas, when present and
46    /// non-empty.
47    pub scope: Option<&'a str>,
48    /// Everything after the `: ` separator.
49    pub description: &'a str,
50    /// Whether a breaking-change `!` marker was present, either side of the
51    /// scope parens.
52    pub breaking: bool,
53}
54
55/// Parses a commit subject line (the first line of a commit message) into
56/// its conventional-commit parts. Returns `None` when the subject doesn't
57/// match `type(scope)?!?: description` in any accepted form.
58pub fn parse_subject(first_line: &str) -> Option<ParsedSubject<'_>> {
59    let caps = SUBJECT_RE.captures(first_line)?;
60    let commit_type = caps.name("type")?.as_str();
61    let description = caps.name("desc")?.as_str();
62    let breaking = caps.name("bang1").is_some() || caps.name("bang2").is_some();
63    let scope = caps
64        .name("scope")
65        .map(|m| m.as_str())
66        .filter(|s| !s.is_empty());
67    Some(ParsedSubject {
68        commit_type,
69        scope,
70        description,
71        breaking,
72    })
73}
74
75/// Returns `true` when a multi-scope segment's comma spacing is acceptable
76/// (`,` or `, ` only — no space before a comma, no 2+ spaces after one).
77pub(crate) fn scope_comma_format_ok(scope: &str) -> bool {
78    !BAD_SCOPE_COMMA_RE.is_match(scope)
79}
80
81/// Returns `true` when every comma-separated part of `scope` is a member of
82/// `valid_scopes` (already ecosystem-merged by the caller — see
83/// [`crate::claude::context::load_project_scopes`]). An empty `valid_scopes`
84/// list is treated as "anything goes" (no `scopes.yaml` configured),
85/// matching the pre-existing `run_pre_validation_checks` behaviour.
86pub(crate) fn scope_parts_all_valid(scope: &str, valid_scopes: &[ScopeDefinition]) -> bool {
87    valid_scopes.is_empty() || invalid_scope_parts(scope, valid_scopes).is_empty()
88}
89
90/// Returns the comma-separated parts of `scope` that are not present in
91/// `valid_scopes`, trimmed.
92fn invalid_scope_parts<'a>(scope: &'a str, valid_scopes: &[ScopeDefinition]) -> Vec<&'a str> {
93    scope
94        .split(',')
95        .map(str::trim)
96        .filter(|part| !valid_scopes.iter().any(|s| s.name == *part))
97        .collect()
98}
99
100fn issue(severity: IssueSeverity, section: &str, rule: &str, explanation: String) -> CommitIssue {
101    CommitIssue {
102        severity,
103        section: section.to_string(),
104        rule: rule.to_string(),
105        explanation,
106    }
107}
108
109/// Returns `true` when `line` starts with `footer` (case-insensitive)
110/// immediately followed by `:`, e.g. `line = "Co-Authored-By: x"`,
111/// `footer = "Co-Authored-By"`. Byte-slices via `str::get` rather than
112/// direct indexing so a `footer` longer than `line`, or a split that lands
113/// mid-codepoint, returns `false` instead of panicking.
114fn line_has_forbidden_footer(line: &str, footer: &str) -> bool {
115    let Some(prefix) = line.get(..footer.len()) else {
116        return false;
117    };
118    prefix.eq_ignore_ascii_case(footer) && line[footer.len()..].starts_with(':')
119}
120
121/// Runs every deterministic rule against `message`.
122///
123/// Returns the issues found (empty = passes cleanly). Pure — no git, no
124/// I/O; `rules` and `valid_scopes` are supplied by the caller (already
125/// resolved/merged, e.g. via [`crate::claude::context::load_project_scopes`]
126/// / [`crate::claude::context::load_commit_rules`]).
127pub fn lint_message(
128    message: &str,
129    rules: &CommitRules,
130    valid_scopes: &[ScopeDefinition],
131) -> Vec<CommitIssue> {
132    let mut issues = Vec::new();
133
134    let first_line = message.lines().next().unwrap_or("");
135
136    // Subject length — independent of whether the subject otherwise parses.
137    let len = first_line.chars().count();
138    if len > rules.subject_max_len {
139        issues.push(issue(
140            IssueSeverity::Error,
141            "Subject Line",
142            "subject-length",
143            format!(
144                "Subject is {len} characters, which exceeds the {}-character limit",
145                rules.subject_max_len
146            ),
147        ));
148    }
149
150    // Blank line after subject when a body is present — independent of
151    // whether the subject otherwise parses.
152    if let Some(second_line) = message.lines().nth(1) {
153        if !second_line.is_empty() {
154            issues.push(issue(
155                IssueSeverity::Error,
156                "Commit Format",
157                "blank-line-after-subject",
158                "Line 2 must be blank when the commit message has a body".to_string(),
159            ));
160        }
161    }
162
163    // Forbidden footers — independent of whether the subject otherwise
164    // parses; scanned across every line of the message.
165    for line in message.lines() {
166        for footer in &rules.forbidden_footers {
167            if line_has_forbidden_footer(line, footer) {
168                issues.push(issue(
169                    IssueSeverity::Warning,
170                    "Body Guidelines",
171                    "forbidden-footer",
172                    format!("'{footer}' footer is not permitted"),
173                ));
174            }
175        }
176    }
177
178    let Some(parsed) = parse_subject(first_line) else {
179        issues.push(issue(
180            IssueSeverity::Error,
181            "Commit Format",
182            "format",
183            "Subject must match '<type>(<scope>): <description>'".to_string(),
184        ));
185        return issues;
186    };
187
188    if !rules.types.iter().any(|t| t == parsed.commit_type) {
189        issues.push(issue(
190            IssueSeverity::Error,
191            "Types",
192            "unknown-type",
193            format!(
194                "'{}' is not one of the accepted types: {}",
195                parsed.commit_type,
196                rules.types.join(", ")
197            ),
198        ));
199    }
200
201    match parsed.scope {
202        Some(scope) => {
203            if !scope_comma_format_ok(scope) {
204                issues.push(issue(
205                    IssueSeverity::Error,
206                    "Scopes",
207                    "scope-comma-format",
208                    format!("Scope '{scope}' must separate multiple scopes with ',' or ', ' only"),
209                ));
210            }
211
212            let invalid = invalid_scope_parts(scope, valid_scopes);
213            if !valid_scopes.is_empty() && !invalid.is_empty() {
214                issues.push(issue(
215                    IssueSeverity::Error,
216                    "Scopes",
217                    "unknown-scope",
218                    format!(
219                        "Scope(s) not in the valid scopes list: {}",
220                        invalid.join(", ")
221                    ),
222                ));
223            }
224        }
225        None if rules.require_scope => {
226            issues.push(issue(
227                IssueSeverity::Error,
228                "Scopes",
229                "missing-scope",
230                "A scope is required, e.g. 'type(scope): description'".to_string(),
231            ));
232        }
233        None => {}
234    }
235
236    if parsed
237        .description
238        .chars()
239        .next()
240        .is_some_and(char::is_uppercase)
241    {
242        issues.push(issue(
243            IssueSeverity::Info,
244            "Subject Line Style",
245            "lowercase-description",
246            "Description should start with a lowercase letter".to_string(),
247        ));
248    }
249
250    if first_line.trim_end().ends_with('.') {
251        issues.push(issue(
252            IssueSeverity::Info,
253            "Subject Line Style",
254            "no-trailing-period",
255            "Subject should not end with a period".to_string(),
256        ));
257    }
258
259    issues
260}
261
262/// Returns `true` when `issues` contains no [`IssueSeverity::Error`] entry —
263/// the same pass/fail split [`crate::data::check::CheckReport::exit_code`]
264/// uses.
265#[must_use]
266pub fn passes(issues: &[CommitIssue]) -> bool {
267    !issues.iter().any(|i| i.severity == IssueSeverity::Error)
268}
269
270#[cfg(test)]
271#[allow(clippy::unwrap_used, clippy::expect_used)]
272mod tests {
273    use super::*;
274
275    fn scope(name: &str) -> ScopeDefinition {
276        ScopeDefinition {
277            name: name.to_string(),
278            description: String::new(),
279            examples: vec![],
280            file_patterns: vec![],
281        }
282    }
283
284    fn scopes(names: &[&str]) -> Vec<ScopeDefinition> {
285        names.iter().map(|n| scope(n)).collect()
286    }
287
288    fn issue_rules<'a>(rule: &str, issues: &'a [CommitIssue]) -> Vec<&'a CommitIssue> {
289        issues.iter().filter(|i| i.rule == rule).collect()
290    }
291
292    // ── parse_subject ────────────────────────────────────────────────
293
294    #[test]
295    fn parse_subject_simple() {
296        let p = parse_subject("feat(cli): add twiddle contextual options").unwrap();
297        assert_eq!(p.commit_type, "feat");
298        assert_eq!(p.scope, Some("cli"));
299        assert_eq!(p.description, "add twiddle contextual options");
300        assert!(!p.breaking);
301    }
302
303    #[test]
304    fn parse_subject_scope_less() {
305        let p = parse_subject("docs: clarify dry_run helper scope").unwrap();
306        assert_eq!(p.commit_type, "docs");
307        assert_eq!(p.scope, None);
308    }
309
310    #[test]
311    fn parse_subject_canonical_breaking_change() {
312        // #1473: `!` after the closing paren is the documented form and
313        // must match.
314        let p = parse_subject("feat(cli)!: change commit check output format").unwrap();
315        assert_eq!(p.commit_type, "feat");
316        assert_eq!(p.scope, Some("cli"));
317        assert!(p.breaking);
318    }
319
320    #[test]
321    fn parse_subject_lenient_legacy_breaking_change() {
322        let p = parse_subject("feat!(cli): add thing").unwrap();
323        assert_eq!(p.scope, Some("cli"));
324        assert!(p.breaking);
325    }
326
327    #[test]
328    fn parse_subject_both_bangs_accepted_leniently() {
329        assert!(parse_subject("feat!(cli)!: add thing").is_some());
330    }
331
332    #[test]
333    fn parse_subject_multi_scope() {
334        let p = parse_subject("feat(git,data): integrate branch analysis").unwrap();
335        assert_eq!(p.scope, Some("git,data"));
336    }
337
338    #[test]
339    fn parse_subject_rejects_missing_colon_space() {
340        assert!(parse_subject("feat(cli):no space").is_none());
341    }
342
343    #[test]
344    fn parse_subject_rejects_garbage() {
345        assert!(parse_subject("this is not a conventional commit").is_none());
346    }
347
348    // ── lint_message: Rule 1 — format ───────────────────────────────
349
350    #[test]
351    fn format_valid_passes() {
352        let rules = CommitRules::default();
353        let issues = lint_message("feat(cli): add twiddle contextual options", &rules, &[]);
354        assert!(issue_rules("format", &issues).is_empty());
355        assert!(passes(&issues));
356    }
357
358    #[test]
359    fn format_invalid_flags_error() {
360        let rules = CommitRules::default();
361        let issues = lint_message("not a conventional commit at all", &rules, &[]);
362        let found = issue_rules("format", &issues);
363        assert_eq!(found.len(), 1);
364        assert_eq!(found[0].severity, IssueSeverity::Error);
365        assert_eq!(found[0].section, "Commit Format");
366    }
367
368    // ── lint_message: Rule 2 — types ────────────────────────────────
369
370    #[test]
371    fn known_type_passes() {
372        let rules = CommitRules::default();
373        let issues = lint_message("feat(cli): add thing", &rules, &[]);
374        assert!(issue_rules("unknown-type", &issues).is_empty());
375    }
376
377    #[test]
378    fn unknown_type_flags_error() {
379        let rules = CommitRules::default();
380        let issues = lint_message("feature(cli): add thing", &rules, &[]);
381        let found = issue_rules("unknown-type", &issues);
382        assert_eq!(found.len(), 1);
383        assert_eq!(found[0].severity, IssueSeverity::Error);
384        assert_eq!(found[0].section, "Types");
385    }
386
387    // ── lint_message: Rule 3 — scope comma format ───────────────────
388
389    #[test]
390    fn scope_comma_format_valid_passes() {
391        let rules = CommitRules::default();
392        let valid = scopes(&["cli", "claude"]);
393        let issues = lint_message("feat(cli,claude): add thing", &rules, &valid);
394        assert!(issue_rules("scope-comma-format", &issues).is_empty());
395
396        let issues_spaced = lint_message("feat(cli, claude): add thing", &rules, &valid);
397        assert!(issue_rules("scope-comma-format", &issues_spaced).is_empty());
398    }
399
400    #[test]
401    fn scope_comma_two_spaces_after_flags_error() {
402        let rules = CommitRules::default();
403        let valid = scopes(&["a", "b"]);
404        let issues = lint_message("feat(a,  b): add thing", &rules, &valid);
405        let found = issue_rules("scope-comma-format", &issues);
406        assert_eq!(found.len(), 1);
407        assert_eq!(found[0].severity, IssueSeverity::Error);
408    }
409
410    #[test]
411    fn scope_comma_space_before_flags_error() {
412        let rules = CommitRules::default();
413        let valid = scopes(&["a", "b"]);
414        let issues = lint_message("feat(a ,b): add thing", &rules, &valid);
415        assert_eq!(issue_rules("scope-comma-format", &issues).len(), 1);
416    }
417
418    // ── lint_message: Rule 4 — scope validity ───────────────────────
419
420    #[test]
421    fn valid_scope_passes() {
422        let rules = CommitRules::default();
423        let valid = scopes(&["cli"]);
424        let issues = lint_message("feat(cli): add thing", &rules, &valid);
425        assert!(issue_rules("unknown-scope", &issues).is_empty());
426    }
427
428    #[test]
429    fn undefined_scope_flags_error() {
430        let rules = CommitRules::default();
431        let valid = scopes(&["cli"]);
432        let issues = lint_message("feat(bogus): add thing", &rules, &valid);
433        let found = issue_rules("unknown-scope", &issues);
434        assert_eq!(found.len(), 1);
435        assert_eq!(found[0].severity, IssueSeverity::Error);
436        assert_eq!(found[0].section, "Scopes");
437    }
438
439    #[test]
440    fn empty_valid_scopes_list_accepts_anything() {
441        // No scopes.yaml configured — mirrors run_pre_validation_checks.
442        let rules = CommitRules::default();
443        let issues = lint_message("feat(anything): add thing", &rules, &[]);
444        assert!(issue_rules("unknown-scope", &issues).is_empty());
445    }
446
447    // ── lint_message: Rule 5 — require_scope toggle ─────────────────
448
449    #[test]
450    fn missing_scope_allowed_by_default() {
451        let rules = CommitRules::default();
452        assert!(!rules.require_scope);
453        let issues = lint_message("docs: update issue references", &rules, &[]);
454        assert!(issue_rules("missing-scope", &issues).is_empty());
455    }
456
457    #[test]
458    fn missing_scope_flagged_when_required() {
459        let rules = CommitRules {
460            require_scope: true,
461            ..CommitRules::default()
462        };
463        let issues = lint_message("docs: update issue references", &rules, &[]);
464        let found = issue_rules("missing-scope", &issues);
465        assert_eq!(found.len(), 1);
466        assert_eq!(found[0].severity, IssueSeverity::Error);
467    }
468
469    #[test]
470    fn present_scope_not_flagged_when_required() {
471        let rules = CommitRules {
472            require_scope: true,
473            ..CommitRules::default()
474        };
475        let issues = lint_message("docs(docs): update issue references", &rules, &[]);
476        assert!(issue_rules("missing-scope", &issues).is_empty());
477    }
478
479    // ── lint_message: Rules 6/7 — subject line style ────────────────
480
481    #[test]
482    fn lowercase_description_passes() {
483        let rules = CommitRules::default();
484        let issues = lint_message("feat(cli): add thing", &rules, &[]);
485        assert!(issue_rules("lowercase-description", &issues).is_empty());
486    }
487
488    #[test]
489    fn uppercase_description_flags_info() {
490        let rules = CommitRules::default();
491        let issues = lint_message("feat(cli): Add thing", &rules, &[]);
492        let found = issue_rules("lowercase-description", &issues);
493        assert_eq!(found.len(), 1);
494        assert_eq!(found[0].severity, IssueSeverity::Info);
495        // Info-only issues never fail the commit.
496        assert!(passes(&found_as_issues(&issues, "lowercase-description")));
497    }
498
499    fn found_as_issues(issues: &[CommitIssue], rule: &str) -> Vec<CommitIssue> {
500        issues.iter().filter(|i| i.rule == rule).cloned().collect()
501    }
502
503    #[test]
504    fn no_trailing_period_passes() {
505        let rules = CommitRules::default();
506        let issues = lint_message("feat(cli): add thing", &rules, &[]);
507        assert!(issue_rules("no-trailing-period", &issues).is_empty());
508    }
509
510    #[test]
511    fn trailing_period_flags_info() {
512        let rules = CommitRules::default();
513        let issues = lint_message("feat(cli): add thing.", &rules, &[]);
514        let found = issue_rules("no-trailing-period", &issues);
515        assert_eq!(found.len(), 1);
516        assert_eq!(found[0].severity, IssueSeverity::Info);
517    }
518
519    // ── lint_message: Rule 8 — subject length ───────────────────────
520
521    #[test]
522    fn subject_within_limit_passes() {
523        let rules = CommitRules::default();
524        let issues = lint_message("feat(cli): add thing", &rules, &[]);
525        assert!(issue_rules("subject-length", &issues).is_empty());
526    }
527
528    #[test]
529    fn subject_over_limit_flags_error() {
530        let rules = CommitRules::default();
531        let long_desc = "x".repeat(rules.subject_max_len);
532        let msg = format!("feat(cli): {long_desc}");
533        assert!(msg.chars().count() > rules.subject_max_len);
534        let issues = lint_message(&msg, &rules, &[]);
535        let found = issue_rules("subject-length", &issues);
536        assert_eq!(found.len(), 1);
537        assert_eq!(found[0].severity, IssueSeverity::Error);
538    }
539
540    #[test]
541    fn configured_limit_is_honoured() {
542        // Same message: passes at 80, fails at 72. Built programmatically
543        // (rather than hand-counted) to land reliably between the two.
544        let prefix = "feat(cli): ";
545        let desc = "x".repeat(73 - prefix.len());
546        let msg = format!("{prefix}{desc}");
547        assert_eq!(msg.chars().count(), 73);
548
549        let rules_80 = CommitRules {
550            subject_max_len: 80,
551            ..CommitRules::default()
552        };
553        assert!(issue_rules("subject-length", &lint_message(&msg, &rules_80, &[])).is_empty());
554
555        let rules_72 = CommitRules {
556            subject_max_len: 72,
557            ..CommitRules::default()
558        };
559        assert_eq!(
560            issue_rules("subject-length", &lint_message(&msg, &rules_72, &[])).len(),
561            1
562        );
563    }
564
565    // ── lint_message: Rule 9 — blank line after subject ─────────────
566
567    #[test]
568    fn blank_line_2_with_body_passes() {
569        let rules = CommitRules::default();
570        let issues = lint_message("feat(cli): add thing\n\nMore detail here.", &rules, &[]);
571        assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
572    }
573
574    #[test]
575    fn subject_only_no_body_passes() {
576        let rules = CommitRules::default();
577        let issues = lint_message("feat(cli): add thing", &rules, &[]);
578        assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
579    }
580
581    #[test]
582    fn non_blank_line_2_flags_error() {
583        // The 6413fd73 case: a clean subject, non-blank line 2 folds the
584        // whole first paragraph into git's %s.
585        let rules = CommitRules::default();
586        let issues = lint_message(
587            "fix(git): handle detached HEAD in branch analysis\nThis line should be blank.",
588            &rules,
589            &[],
590        );
591        let found = issue_rules("blank-line-after-subject", &issues);
592        assert_eq!(found.len(), 1);
593        assert_eq!(found[0].severity, IssueSeverity::Error);
594        assert_eq!(found[0].section, "Commit Format");
595    }
596
597    // ── lint_message: Rule 10 — forbidden footers ───────────────────
598
599    #[test]
600    fn no_footer_passes() {
601        let rules = CommitRules::default();
602        let issues = lint_message(
603            "feat(cli): add thing\n\nBody text.\n\nCloses #123",
604            &rules,
605            &[],
606        );
607        assert!(issue_rules("forbidden-footer", &issues).is_empty());
608    }
609
610    #[test]
611    fn co_authored_by_footer_flags_warning() {
612        let rules = CommitRules::default();
613        let issues = lint_message(
614            "feat(cli): add thing\n\nBody text.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
615            &rules,
616            &[],
617        );
618        let found = issue_rules("forbidden-footer", &issues);
619        assert_eq!(found.len(), 1);
620        assert_eq!(found[0].severity, IssueSeverity::Warning);
621        assert_eq!(found[0].section, "Body Guidelines");
622        // A warning alone must not fail the commit.
623        assert!(passes(&found_as_issues(&issues, "forbidden-footer")));
624    }
625
626    #[test]
627    fn co_authored_by_footer_case_insensitive() {
628        let rules = CommitRules::default();
629        let issues = lint_message(
630            "feat(cli): add thing\n\nco-authored-by: someone",
631            &rules,
632            &[],
633        );
634        assert_eq!(issue_rules("forbidden-footer", &issues).len(), 1);
635    }
636
637    // ── ecosystem-default scopes (the case most likely to be got wrong) ──
638
639    #[test]
640    fn ecosystem_default_scopes_pass_even_when_absent_from_scopes_yaml() {
641        // Mirrors merge_ecosystem_scopes' Rust defaults: lib, cargo, core,
642        // test are accepted even though a project's scopes.yaml lists
643        // neither — the caller is responsible for merging them in before
644        // calling lint_message (this test simulates that merged list).
645        let rules = CommitRules::default();
646        let merged = scopes(&["cli", "git", "lib", "cargo", "core", "test"]);
647        for s in ["lib", "cargo", "core", "test"] {
648            let msg = format!("chore({s}): bump dependency");
649            let issues = lint_message(&msg, &rules, &merged);
650            assert!(
651                issue_rules("unknown-scope", &issues).is_empty(),
652                "ecosystem default scope {s} should be accepted"
653            );
654        }
655    }
656
657    // ── real positive/negative fixtures from the issue ──────────────
658
659    #[test]
660    fn positive_fixtures_from_history_pass_cleanly() {
661        let rules = CommitRules::default();
662        let valid = scopes(&["cli", "claude", "docs"]);
663        for msg in [
664            "feat(cli,claude): add twiddle contextual options",
665            "feat(cli)!: change commit check output format",
666            "docs(docs): add architecture overview document",
667        ] {
668            let issues = lint_message(msg, &rules, &valid);
669            assert!(passes(&issues), "{msg:?} should pass cleanly: {issues:?}");
670        }
671    }
672
673    #[test]
674    fn multi_scope_breaking_change_records_both_checks() {
675        let rules = CommitRules::default();
676        let valid = scopes(&["cli", "claude"]);
677        let issues = lint_message("feat(cli,claude)!: add thing", &rules, &valid);
678        assert!(passes(&issues));
679    }
680}