Skip to content

Commit c200f4a

Browse files
feat(rules): add breaking-change-exclamation-mark
Implements and closes #4547.
1 parent 407be6c commit c200f4a

File tree

4 files changed

+136
-0
lines changed

4 files changed

+136
-0
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { test, expect } from "vitest";
2+
import parse from "@commitlint/parse";
3+
import { breakingChangeExclamationMark } from "./breaking-change-exclamation-mark.js";
4+
5+
const noHeader = "commit message";
6+
const plainHeader = "type: subject";
7+
const breakingHeader = "type!: subject";
8+
const noFooter = "";
9+
const plainFooter = "Some-Other-Trailer: content";
10+
const breakingFooter = "BREAKING CHANGE: reason";
11+
12+
// These are equivalence partitions.
13+
const messages = {
14+
noHeaderNoFooter: `${noHeader}\n\n${noFooter}`,
15+
noHeaderPlainFooter: `${noHeader}\n\n${plainFooter}`,
16+
noHeaderBreakingFooter: `${noHeader}\n\n${breakingFooter}`,
17+
plainHeaderPlainFooter: `${plainHeader}\n\n${plainFooter}`,
18+
plainHeaderBreakingFooter: `${plainHeader}\n\n${breakingFooter}`,
19+
breakingHeaderPlainFooter: `${breakingHeader}\n\n${plainFooter}`,
20+
breakingHeaderBreakingFooter: `${breakingHeader}\n\n${breakingFooter}`,
21+
};
22+
23+
const parsed = {
24+
noHeaderNoFooter: parse(messages.noHeaderNoFooter),
25+
noHeaderPlainFooter: parse(messages.noHeaderPlainFooter),
26+
noHeaderBreakingFooter: parse(messages.noHeaderBreakingFooter),
27+
plainHeaderPlainFooter: parse(messages.plainHeaderPlainFooter),
28+
plainHeaderBreakingFooter: parse(messages.plainHeaderBreakingFooter),
29+
breakingHeaderPlainFooter: parse(messages.breakingHeaderPlainFooter),
30+
breakingHeaderBreakingFooter: parse(messages.breakingHeaderBreakingFooter),
31+
};
32+
33+
test("with noHeaderNoFooter should succeed", async () => {
34+
const [actual] = breakingChangeExclamationMark(await parsed.noHeaderNoFooter);
35+
const expected = true;
36+
expect(actual).toEqual(expected);
37+
});
38+
39+
test("with noHeaderPlainFooter should succeed", async () => {
40+
const [actual] = breakingChangeExclamationMark(
41+
await parsed.noHeaderPlainFooter,
42+
);
43+
const expected = true;
44+
expect(actual).toEqual(expected);
45+
});
46+
47+
test("with noHeaderBreakingFooter should fail", async () => {
48+
const [actual] = breakingChangeExclamationMark(
49+
await parsed.noHeaderBreakingFooter,
50+
);
51+
const expected = false;
52+
expect(actual).toEqual(expected);
53+
});
54+
55+
test("with plainHeaderPlainFooter should succeed", async () => {
56+
const [actual] = breakingChangeExclamationMark(
57+
await parsed.plainHeaderPlainFooter,
58+
);
59+
const expected = true;
60+
expect(actual).toEqual(expected);
61+
});
62+
63+
test("with plainHeaderBreakingFooter should fail", async () => {
64+
const [actual] = breakingChangeExclamationMark(
65+
await parsed.plainHeaderBreakingFooter,
66+
);
67+
const expected = false;
68+
expect(actual).toEqual(expected);
69+
});
70+
71+
test("with breakingHeaderPlainFooter should fail", async () => {
72+
const [actual] = breakingChangeExclamationMark(
73+
await parsed.breakingHeaderPlainFooter,
74+
);
75+
const expected = false;
76+
expect(actual).toEqual(expected);
77+
});
78+
79+
test("with breakingHeaderBreakingFooter should succeed", async () => {
80+
const [actual] = breakingChangeExclamationMark(
81+
await parsed.breakingHeaderBreakingFooter,
82+
);
83+
const expected = true;
84+
expect(actual).toEqual(expected);
85+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import message from "@commitlint/message";
2+
import { SyncRule } from "@commitlint/types";
3+
4+
export const breakingChangeExclamationMark: SyncRule = (
5+
parsed,
6+
when = "always",
7+
) => {
8+
const header = parsed.header;
9+
const footer = parsed.footer;
10+
11+
// It is the correct behavior to return true only when both the header and footer are empty,
12+
// but still run the usual checks if one or neither are empty.
13+
// The reasoning is that if one is empty and the other contains a breaking change marker,
14+
// then the check fails as it is not possible for the empty one to indicate a breaking change.
15+
if (!header && !footer) {
16+
return [true];
17+
}
18+
19+
const hasExclamationMark = !!header && /!:/.test(header);
20+
const hasBreakingChange = !!footer && /BREAKING[ -]CHANGE:/.test(footer);
21+
22+
const negated = when === "never";
23+
const check = hasExclamationMark === hasBreakingChange;
24+
25+
return [
26+
negated ? !check : check,
27+
message([
28+
"breaking changes",
29+
negated ? "must not" : "must",
30+
"have both an exclamation mark in the header",
31+
"and BREAKING CHANGE in the footer",
32+
"to identify a breaking change",
33+
]),
34+
];
35+
};

@commitlint/rules/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { breakingChangeExclamationMark } from "./breaking-change-exclamation-mark.js";
12
import { bodyCase } from "./body-case.js";
23
import { bodyEmpty } from "./body-empty.js";
34
import { bodyFullStop } from "./body-full-stop.js";
@@ -36,6 +37,7 @@ import { typeMaxLength } from "./type-max-length.js";
3637
import { typeMinLength } from "./type-min-length.js";
3738

3839
export default {
40+
"breaking-change-exclamation-mark": breakingChangeExclamationMark,
3941
"body-case": bodyCase,
4042
"body-empty": bodyEmpty,
4143
"body-full-stop": bodyFullStop,

docs/reference/rules.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Rules
22

3+
## breaking-change-exclamation-mark
4+
5+
- **condition**: Either both or neither `header` has an exclamation mark before the `:` marker
6+
and `footer` matches the regular expression `BREAKING[ -]CHANGE:`
7+
- **rule**: `always`
8+
9+
> [!NOTE]
10+
>
11+
> This rule enforces that breaking changes are marked by both a `!` in the header
12+
> and `BREAKING CHANGE` in the footer. The behavior is that of an XNOR operation:
13+
>
14+
> - It passes when either both are present or both are not.
15+
> - It fails when one is present and the other is not.
16+
317
## body-full-stop
418

519
- **condition**: `body` ends with `value`

0 commit comments

Comments
 (0)