Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat(rules): add breaking-change-exclamation-mark (conventional-chang…
…elog#4548)

* feat(rules): add breaking-change-exclamation-mark

Implements and closes conventional-changelog#4547.

* feat(rules): breaking-change-exclamation-mark pull request changes

Addresses review feedback from @JounQin about pull request conventional-changelog#4548.

- Use `breakingHeaderPattern` to search for the exclamation mark in the header.
- Correct the regular expression to require that BREAKING CHANGE in the footer be anchored at the
  beginning of a line.
- Updated the `RulesConfig` type.

* feat(rules): breaking-change-exclamation-mark pull request feedback

Address pull request feedback from @JounQin.

- Fixed regex for `subject-exclamation-mark`.
- Fixed sorting for `RulesConfig`.
- Fixed sorting for `rules.md`.
  • Loading branch information
adamchristiansen authored Oct 3, 2025
commit c4d419bc76aab72092f1ebd7c0922729a576b7a5
85 changes: 85 additions & 0 deletions @commitlint/rules/src/breaking-change-exclamation-mark.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { test, expect } from "vitest";
import parse from "@commitlint/parse";
import { breakingChangeExclamationMark } from "./breaking-change-exclamation-mark.js";

const noHeader = "commit message";
const plainHeader = "type: subject";
const breakingHeader = "type!: subject";
const noFooter = "";
const plainFooter = "Some-Other-Trailer: content";
const breakingFooter = "BREAKING CHANGE: reason";

// These are equivalence partitions.
const messages = {
noHeaderNoFooter: `${noHeader}\n\n${noFooter}`,
noHeaderPlainFooter: `${noHeader}\n\n${plainFooter}`,
noHeaderBreakingFooter: `${noHeader}\n\n${breakingFooter}`,
plainHeaderPlainFooter: `${plainHeader}\n\n${plainFooter}`,
plainHeaderBreakingFooter: `${plainHeader}\n\n${breakingFooter}`,
breakingHeaderPlainFooter: `${breakingHeader}\n\n${plainFooter}`,
breakingHeaderBreakingFooter: `${breakingHeader}\n\n${breakingFooter}`,
};

const parsed = {
noHeaderNoFooter: parse(messages.noHeaderNoFooter),
noHeaderPlainFooter: parse(messages.noHeaderPlainFooter),
noHeaderBreakingFooter: parse(messages.noHeaderBreakingFooter),
plainHeaderPlainFooter: parse(messages.plainHeaderPlainFooter),
plainHeaderBreakingFooter: parse(messages.plainHeaderBreakingFooter),
breakingHeaderPlainFooter: parse(messages.breakingHeaderPlainFooter),
breakingHeaderBreakingFooter: parse(messages.breakingHeaderBreakingFooter),
};

test("with noHeaderNoFooter should succeed", async () => {
const [actual] = breakingChangeExclamationMark(await parsed.noHeaderNoFooter);
const expected = true;
expect(actual).toEqual(expected);
});

test("with noHeaderPlainFooter should succeed", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.noHeaderPlainFooter,
);
const expected = true;
expect(actual).toEqual(expected);
});

test("with noHeaderBreakingFooter should fail", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.noHeaderBreakingFooter,
);
const expected = false;
expect(actual).toEqual(expected);
});

test("with plainHeaderPlainFooter should succeed", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.plainHeaderPlainFooter,
);
const expected = true;
expect(actual).toEqual(expected);
});

test("with plainHeaderBreakingFooter should fail", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.plainHeaderBreakingFooter,
);
const expected = false;
expect(actual).toEqual(expected);
});

test("with breakingHeaderPlainFooter should fail", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.breakingHeaderPlainFooter,
);
const expected = false;
expect(actual).toEqual(expected);
});

test("with breakingHeaderBreakingFooter should succeed", async () => {
const [actual] = breakingChangeExclamationMark(
await parsed.breakingHeaderBreakingFooter,
);
const expected = true;
expect(actual).toEqual(expected);
});
36 changes: 36 additions & 0 deletions @commitlint/rules/src/breaking-change-exclamation-mark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import message from "@commitlint/message";
import { SyncRule } from "@commitlint/types";

export const breakingChangeExclamationMark: SyncRule = (
parsed,
when = "always",
) => {
const header = parsed.header;
const footer = parsed.footer;

// It is the correct behavior to return true only when both the header and footer are empty,
// but still run the usual checks if one or neither are empty.
// The reasoning is that if one is empty and the other contains a breaking change marker,
// then the check fails as it is not possible for the empty one to indicate a breaking change.
if (!header && !footer) {
return [true];
}

const hasExclamationMark =
!!header && /^(\w*)(?:\((.*)\))?!: (.*)$/.test(header);
const hasBreakingChange = !!footer && /^BREAKING[ -]CHANGE:/m.test(footer);

const negated = when === "never";
const check = hasExclamationMark === hasBreakingChange;

return [
negated ? !check : check,
message([
"breaking changes",
negated ? "must not" : "must",
"have both an exclamation mark in the header",
"and BREAKING CHANGE in the footer",
"to identify a breaking change",
]),
];
};
4 changes: 3 additions & 1 deletion @commitlint/rules/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { breakingChangeExclamationMark } from "./breaking-change-exclamation-mark.js";
import { bodyCase } from "./body-case.js";
import { bodyEmpty } from "./body-empty.js";
import { bodyFullStop } from "./body-full-stop.js";
Expand Down Expand Up @@ -43,6 +44,7 @@ export default {
"body-max-length": bodyMaxLength,
"body-max-line-length": bodyMaxLineLength,
"body-min-length": bodyMinLength,
"breaking-change-exclamation-mark": breakingChangeExclamationMark,
"footer-empty": footerEmpty,
"footer-leading-blank": footerLeadingBlank,
"footer-max-length": footerMaxLength,
Expand All @@ -62,10 +64,10 @@ export default {
"signed-off-by": signedOffBy,
"subject-case": subjectCase,
"subject-empty": subjectEmpty,
"subject-exclamation-mark": subjectExclamationMark,
"subject-full-stop": subjectFullStop,
"subject-max-length": subjectMaxLength,
"subject-min-length": subjectMinLength,
"subject-exclamation-mark": subjectExclamationMark,
"trailer-exists": trailerExists,
"type-case": typeCase,
"type-empty": typeEmpty,
Expand Down
2 changes: 1 addition & 1 deletion @commitlint/rules/src/subject-exclamation-mark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const subjectExclamationMark: SyncRule = (parsed, when = "always") => {
}

const negated = when === "never";
const hasExclamationMark = /!:/.test(input);
const hasExclamationMark = /^(\w*)(?:\((.*)\))?!: (.*)$/.test(input);

return [
negated ? !hasExclamationMark : hasExclamationMark,
Expand Down
1 change: 1 addition & 0 deletions @commitlint/types/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export type RulesConfig<V = RuleConfigQuality.User> = {
"body-max-length": LengthRuleConfig<V>;
"body-max-line-length": LengthRuleConfig<V>;
"body-min-length": LengthRuleConfig<V>;
"breaking-change-exclamation-mark": CaseRuleConfig<V>;
"footer-empty": RuleConfig<V>;
"footer-leading-blank": RuleConfig<V>;
"footer-max-length": LengthRuleConfig<V>;
Expand Down
Loading