From c200f4abe7fc0a293847316638f9e5c5628725ae Mon Sep 17 00:00:00 2001 From: Adam Christiansen Date: Wed, 24 Sep 2025 00:37:18 -0600 Subject: [PATCH 1/3] feat(rules): add breaking-change-exclamation-mark Implements and closes #4547. --- .../breaking-change-exclamation-mark.test.ts | 85 +++++++++++++++++++ .../src/breaking-change-exclamation-mark.ts | 35 ++++++++ @commitlint/rules/src/index.ts | 2 + docs/reference/rules.md | 14 +++ 4 files changed, 136 insertions(+) create mode 100644 @commitlint/rules/src/breaking-change-exclamation-mark.test.ts create mode 100644 @commitlint/rules/src/breaking-change-exclamation-mark.ts diff --git a/@commitlint/rules/src/breaking-change-exclamation-mark.test.ts b/@commitlint/rules/src/breaking-change-exclamation-mark.test.ts new file mode 100644 index 0000000000..7d7b9c5377 --- /dev/null +++ b/@commitlint/rules/src/breaking-change-exclamation-mark.test.ts @@ -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); +}); diff --git a/@commitlint/rules/src/breaking-change-exclamation-mark.ts b/@commitlint/rules/src/breaking-change-exclamation-mark.ts new file mode 100644 index 0000000000..216e3f4b29 --- /dev/null +++ b/@commitlint/rules/src/breaking-change-exclamation-mark.ts @@ -0,0 +1,35 @@ +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 && /!:/.test(header); + const hasBreakingChange = !!footer && /BREAKING[ -]CHANGE:/.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", + ]), + ]; +}; diff --git a/@commitlint/rules/src/index.ts b/@commitlint/rules/src/index.ts index 0a27f0b81a..13f5d6f90e 100644 --- a/@commitlint/rules/src/index.ts +++ b/@commitlint/rules/src/index.ts @@ -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"; @@ -36,6 +37,7 @@ import { typeMaxLength } from "./type-max-length.js"; import { typeMinLength } from "./type-min-length.js"; export default { + "breaking-change-exclamation-mark": breakingChangeExclamationMark, "body-case": bodyCase, "body-empty": bodyEmpty, "body-full-stop": bodyFullStop, diff --git a/docs/reference/rules.md b/docs/reference/rules.md index 5ab5f77a0f..59e395842e 100644 --- a/docs/reference/rules.md +++ b/docs/reference/rules.md @@ -1,5 +1,19 @@ # Rules +## breaking-change-exclamation-mark + +- **condition**: Either both or neither `header` has an exclamation mark before the `:` marker + and `footer` matches the regular expression `BREAKING[ -]CHANGE:` +- **rule**: `always` + +> [!NOTE] +> +> This rule enforces that breaking changes are marked by both a `!` in the header +> and `BREAKING CHANGE` in the footer. The behavior is that of an XNOR operation: +> +> - It passes when either both are present or both are not. +> - It fails when one is present and the other is not. + ## body-full-stop - **condition**: `body` ends with `value` From a3b691536836304b9771a1129859cf0e6bf356b7 Mon Sep 17 00:00:00 2001 From: Adam Christiansen Date: Wed, 24 Sep 2025 22:51:05 -0600 Subject: [PATCH 2/3] feat(rules): breaking-change-exclamation-mark pull request changes Addresses review feedback from @JounQin about pull request #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. --- @commitlint/rules/src/breaking-change-exclamation-mark.ts | 5 +++-- @commitlint/types/src/rules.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/@commitlint/rules/src/breaking-change-exclamation-mark.ts b/@commitlint/rules/src/breaking-change-exclamation-mark.ts index 216e3f4b29..131d5fac3b 100644 --- a/@commitlint/rules/src/breaking-change-exclamation-mark.ts +++ b/@commitlint/rules/src/breaking-change-exclamation-mark.ts @@ -16,8 +16,9 @@ export const breakingChangeExclamationMark: SyncRule = ( return [true]; } - const hasExclamationMark = !!header && /!:/.test(header); - const hasBreakingChange = !!footer && /BREAKING[ -]CHANGE:/.test(footer); + const hasExclamationMark = + !!header && /^(\w*)(?:\((.*)\))?!: (.*)$/.test(header); + const hasBreakingChange = !!footer && /^BREAKING[ -]CHANGE:/m.test(footer); const negated = when === "never"; const check = hasExclamationMark === hasBreakingChange; diff --git a/@commitlint/types/src/rules.ts b/@commitlint/types/src/rules.ts index 3d8f2bcdb2..533e5416df 100644 --- a/@commitlint/types/src/rules.ts +++ b/@commitlint/types/src/rules.ts @@ -97,6 +97,7 @@ export type RulesConfig = { "body-max-length": LengthRuleConfig; "body-max-line-length": LengthRuleConfig; "body-min-length": LengthRuleConfig; + "breaking-change-exclamation-mark": CaseRuleConfig; "footer-empty": RuleConfig; "footer-leading-blank": RuleConfig; "footer-max-length": LengthRuleConfig; From bfab842166e47eddefd59fa4d48c0550057b1052 Mon Sep 17 00:00:00 2001 From: Adam Christiansen Date: Thu, 25 Sep 2025 21:56:52 -0600 Subject: [PATCH 3/3] 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`. --- @commitlint/rules/src/index.ts | 4 +- .../rules/src/subject-exclamation-mark.ts | 2 +- docs/reference/rules.md | 276 +++++++++--------- 3 files changed, 141 insertions(+), 141 deletions(-) diff --git a/@commitlint/rules/src/index.ts b/@commitlint/rules/src/index.ts index 13f5d6f90e..95e7a9c86d 100644 --- a/@commitlint/rules/src/index.ts +++ b/@commitlint/rules/src/index.ts @@ -37,7 +37,6 @@ import { typeMaxLength } from "./type-max-length.js"; import { typeMinLength } from "./type-min-length.js"; export default { - "breaking-change-exclamation-mark": breakingChangeExclamationMark, "body-case": bodyCase, "body-empty": bodyEmpty, "body-full-stop": bodyFullStop, @@ -45,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, @@ -64,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, diff --git a/@commitlint/rules/src/subject-exclamation-mark.ts b/@commitlint/rules/src/subject-exclamation-mark.ts index 19bf8eacca..886d142f9c 100644 --- a/@commitlint/rules/src/subject-exclamation-mark.ts +++ b/@commitlint/rules/src/subject-exclamation-mark.ts @@ -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, diff --git a/docs/reference/rules.md b/docs/reference/rules.md index 59e395842e..7c76fafcd7 100644 --- a/docs/reference/rules.md +++ b/docs/reference/rules.md @@ -1,18 +1,34 @@ # Rules -## breaking-change-exclamation-mark +## body-case -- **condition**: Either both or neither `header` has an exclamation mark before the `:` marker - and `footer` matches the regular expression `BREAKING[ -]CHANGE:` +- **condition**: `body` is in case `value` - **rule**: `always` +- **value** -> [!NOTE] -> -> This rule enforces that breaking changes are marked by both a `!` in the header -> and `BREAKING CHANGE` in the footer. The behavior is that of an XNOR operation: -> -> - It passes when either both are present or both are not. -> - It fails when one is present and the other is not. + ```text + 'lower-case' + ``` + +- **possible values** + + ```js + [ + "lower-case", // default + "upper-case", // UPPERCASE + "camel-case", // camelCase + "kebab-case", // kebab-case + "pascal-case", // PascalCase + "sentence-case", // Sentence case + "snake-case", // snake_case + "start-case", // Start Case + ]; + ``` + +## body-empty + +- **condition**: `body` is empty +- **rule**: `never` ## body-full-stop @@ -29,11 +45,6 @@ - **condition**: `body` begins with blank line - **rule**: `always` -## body-empty - -- **condition**: `body` is empty -- **rule**: `never` - ## body-max-length - **condition**: `body` has `value` or less characters @@ -64,41 +75,30 @@ 0 ``` -## body-case +## breaking-change-exclamation-mark -- **condition**: `body` is in case `value` +- **condition**: Either both or neither `header` has an exclamation mark before the `:` marker + and a line in `footer` matches the regular expression `^BREAKING[ -]CHANGE:` - **rule**: `always` -- **value** - ```text - 'lower-case' - ``` +> [!NOTE] +> +> This rule enforces that breaking changes are marked by both a `!` in the header +> and `BREAKING CHANGE` in the footer. The behavior is that of an XNOR operation: +> +> - It passes when either both are present or both are not. +> - It fails when one is present and the other is not. -- **possible values** +## footer-empty - ```js - [ - "lower-case", // default - "upper-case", // UPPERCASE - "camel-case", // camelCase - "kebab-case", // kebab-case - "pascal-case", // PascalCase - "sentence-case", // Sentence case - "snake-case", // snake_case - "start-case", // Start Case - ]; - ``` +- **condition**: `footer` is empty +- **rule**: `never` ## footer-leading-blank - **condition**: `footer` begins with blank line - **rule**: `always` -## footer-empty - -- **condition**: `footer` is empty -- **rule**: `never` - ## footer-max-length - **condition**: `footer` has `value` or less characters @@ -186,7 +186,7 @@ ## header-trim -- **condition**: `header` must not have initial and / or trailing whitespaces +- **condition**: `header` must not have initial or trailing whitespaces - **rule**: `always` ## references-empty @@ -194,6 +194,36 @@ - **condition**: `references` has at least one entry - **rule**: `never` +## scope-case + +- **condition**: `scope` is in case `value` +- **rule**: `always` +- **value** + + ```text + 'lower-case' + ``` + +- **possible values** + + ```js + [ + "lower-case", // default + "upper-case", // UPPERCASE + "camel-case", // camelCase + "kebab-case", // kebab-case + "pascal-case", // PascalCase + "sentence-case", // Sentence case + "snake-case", // snake_case + "start-case", // Start Case + ]; + ``` + +## scope-empty + +- **condition**: `scope` is empty +- **rule**: `never` + ## scope-enum - **condition**: `scope` is found in value @@ -210,55 +240,35 @@ > - When set to `always`, all message scopes must be found in the value. > - When set to `never`, none of the message scopes can be found in the value. -## scope-case +## scope-max-length -- **condition**: `scope` is in case `value` +- **condition**: `scope` has `value` or less characters - **rule**: `always` - **value** ```text - 'lower-case' + Infinity ``` -- **possible values** - -```js -[ - "lower-case", // default - "upper-case", // UPPERCASE - "camel-case", // camelCase - "kebab-case", // kebab-case - "pascal-case", // PascalCase - "sentence-case", // Sentence case - "snake-case", // snake_case - "start-case", // Start Case -]; -``` - -## scope-empty - -- **condition**: `scope` is empty -- **rule**: `never` - -## scope-max-length +## scope-min-length -- **condition**: `scope` has `value` or less characters +- **condition**: `scope` has `value` or more characters - **rule**: `always` - **value** -```text -Infinity -``` + ```text + 0 + ``` -## scope-min-length +## signed-off-by -- **condition**: `scope` has `value` or more characters +- **condition**: `message` has `value` - **rule**: `always` - **value** -```text -0 -``` + ```text + 'Signed-off-by:' + ``` ## subject-case @@ -266,39 +276,44 @@ Infinity - **rule**: `always` - **value** -```js -["sentence-case", "start-case", "pascal-case", "upper-case"]; -``` + ```js + ["sentence-case", "start-case", "pascal-case", "upper-case"]; + ``` - **possible values** -```js -[ - "lower-case", // lower case - "upper-case", // UPPERCASE - "camel-case", // camelCase - "kebab-case", // kebab-case - "pascal-case", // PascalCase - "sentence-case", // Sentence case - "snake-case", // snake_case - "start-case", // Start Case -]; -``` + ```js + [ + "lower-case", // lower case + "upper-case", // UPPERCASE + "camel-case", // camelCase + "kebab-case", // kebab-case + "pascal-case", // PascalCase + "sentence-case", // Sentence case + "snake-case", // snake_case + "start-case", // Start Case + ]; + ``` ## subject-empty - **condition**: `subject` is empty - **rule**: `never` +## subject-exclamation-mark + +- **condition**: `subject` has exclamation before the `:` marker +- **rule**: `never` + ## subject-full-stop - **condition**: `subject` ends with `value` - **rule**: `never` - **value** -```text -'.' -``` + ```text + '.' + ``` ## subject-max-length @@ -306,9 +321,9 @@ Infinity - **rule**: `always` - **value** -```text -Infinity -``` + ```text + Infinity + ``` ## subject-min-length @@ -316,35 +331,18 @@ Infinity - **rule**: `always` - **value** -```text -0 -``` - -## subject-exclamation-mark - -- **condition**: `subject` has exclamation before the `:` marker -- **rule**: `never` + ```text + 0 + ``` -## type-enum +## trailer-exists -- **condition**: `type` is found in value +- **condition**: `message` has trailer `value` - **rule**: `always` - **value** - ```js - [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "refactor", - "revert", - "style", - "test", - ]; + ```text + 'Signed-off-by:' ``` ## type-case @@ -377,42 +375,44 @@ Infinity - **condition**: `type` is empty - **rule**: `never` -## type-max-length - -- **condition**: `type` has `value` or less characters -- **rule**: `always` -- **value** - - ```text - Infinity - ``` - -## type-min-length +## type-enum -- **condition**: `type` has `value` or more characters +- **condition**: `type` is found in value - **rule**: `always` - **value** - ```text - 0 + ```js + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + ]; ``` -## signed-off-by +## type-max-length -- **condition**: `message` has `value` +- **condition**: `type` has `value` or less characters - **rule**: `always` - **value** ```text - 'Signed-off-by:' + Infinity ``` -## trailer-exists +## type-min-length -- **condition**: `message` has trailer `value` +- **condition**: `type` has `value` or more characters - **rule**: `always` - **value** ```text - 'Signed-off-by:' + 0 ```