Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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