diff --git a/@commitlint/rules/src/index.ts b/@commitlint/rules/src/index.ts index 95e7a9c86d..af8e9afda0 100644 --- a/@commitlint/rules/src/index.ts +++ b/@commitlint/rules/src/index.ts @@ -18,6 +18,7 @@ import { headerMinLength } from "./header-min-length.js"; import { headerTrim } from "./header-trim.js"; import { referencesEmpty } from "./references-empty.js"; import { scopeCase } from "./scope-case.js"; +import { scopeDelimiterStyle } from "./scope-delimiter-style.js"; import { scopeEmpty } from "./scope-empty.js"; import { scopeEnum } from "./scope-enum.js"; import { scopeMaxLength } from "./scope-max-length.js"; @@ -57,6 +58,7 @@ export default { "header-trim": headerTrim, "references-empty": referencesEmpty, "scope-case": scopeCase, + "scope-delimiter-style": scopeDelimiterStyle, "scope-empty": scopeEmpty, "scope-enum": scopeEnum, "scope-max-length": scopeMaxLength, diff --git a/@commitlint/rules/src/scope-case.test.ts b/@commitlint/rules/src/scope-case.test.ts index 4e352e7017..1d337019b5 100644 --- a/@commitlint/rules/src/scope-case.test.ts +++ b/@commitlint/rules/src/scope-case.test.ts @@ -322,3 +322,64 @@ test('with slash in subject should succeed for "always sentence case"', async () const expected = true; expect(actual).toEqual(expected); }); + +test("with object-based configuration should use default delimiters", async () => { + const commit = await parse("feat(scope/my-scope, shared-scope): subject"); + const [actual] = scopeCase(commit, "always", { + cases: ["kebab-case"], + }); + const expected = true; + expect(actual).toEqual(expected); +}); + +test("with object-based configuration should support custom single delimiter", async () => { + const commit = await parse("feat(scope|my-scope): subject"); + const [actual] = scopeCase(commit, "always", { + cases: ["kebab-case"], + delimiters: ["|"], + }); + const expected = true; + expect(actual).toEqual(expected); +}); + +test("with object-based configuration should support multiple custom delimiters", async () => { + const commit = await parse( + "feat(scope|my-scope/shared-scope,common-scope): subject", + ); + const [actual] = scopeCase(commit, "always", { + cases: ["kebab-case"], + delimiters: ["|", "/", ","], + }); + const expected = true; + expect(actual).toEqual(expected); +}); + +test("with object-based configuration should fall back to default delimiters when empty array provided", async () => { + const commit = await parse("feat(scope/my-scope): subject"); + const [actual] = scopeCase(commit, "always", { + cases: ["kebab-case"], + delimiters: [], + }); + const expected = true; + expect(actual).toEqual(expected); +}); + +test("with object-based configuration should handle special delimiters", async () => { + const commit = await parse("feat(scope*my-scope): subject"); + const [actual] = scopeCase(commit, "always", { + cases: ["kebab-case"], + delimiters: ["*"], + }); + const expected = true; + expect(actual).toEqual(expected); +}); + +test('with object-based configuration should respect "never" when custom delimiter is used', async () => { + const commit = await parse("feat(scope|my-scope): subject"); + const [actual] = scopeCase(commit, "never", { + cases: ["kebab-case"], + delimiters: ["|"], + }); + const expected = false; + expect(actual).toEqual(expected); +}); diff --git a/@commitlint/rules/src/scope-case.ts b/@commitlint/rules/src/scope-case.ts index c620086eff..2cf20bd97f 100644 --- a/@commitlint/rules/src/scope-case.ts +++ b/@commitlint/rules/src/scope-case.ts @@ -4,18 +4,29 @@ import { TargetCaseType, SyncRule } from "@commitlint/types"; const negated = (when?: string) => when === "never"; -export const scopeCase: SyncRule = ( - parsed, - when = "always", - value = [], -) => { +export const scopeCase: SyncRule< + | TargetCaseType + | TargetCaseType[] + | { + cases: TargetCaseType[]; + delimiters?: string[]; + } +> = (parsed, when = "always", value = []) => { const { scope } = parsed; if (!scope) { return [true]; } + const isObjectBasedConfiguration = + !Array.isArray(value) && !(typeof value === "string"); - const checks = (Array.isArray(value) ? value : [value]).map((check) => { + const checks = ( + isObjectBasedConfiguration + ? value.cases + : Array.isArray(value) + ? value + : [value] + ).map((check) => { if (typeof check === "string") { return { when: "always", @@ -25,14 +36,22 @@ export const scopeCase: SyncRule = ( return check; }); - // Scopes may contain slash or comma delimiters to separate them and mark them as individual segments. - // This means that each of these segments should be tested separately with `ensure`. - const delimiters = /\/|\\|, ?/g; - const scopeSegments = scope.split(delimiters); + const delimiters = + isObjectBasedConfiguration && value.delimiters?.length + ? value.delimiters + : ["/", "\\", ","]; + const delimiterPatterns = delimiters.map((delimiter) => { + return delimiter === "," + ? ", ?" + : delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }); + const delimiterRegex = new RegExp(delimiterPatterns.join("|")); + const scopeSegments = scope.split(delimiterRegex); const result = checks.some((check) => { const r = scopeSegments.every( - (segment) => delimiters.test(segment) || ensureCase(segment, check.case), + (segment) => + delimiterRegex.test(segment) || ensureCase(segment, check.case), ); return negated(check.when) ? !r : r; diff --git a/@commitlint/rules/src/scope-delimiter-style.test.ts b/@commitlint/rules/src/scope-delimiter-style.test.ts new file mode 100644 index 0000000000..80a644a109 --- /dev/null +++ b/@commitlint/rules/src/scope-delimiter-style.test.ts @@ -0,0 +1,194 @@ +import { describe, test, expect } from "vitest"; +import parse from "@commitlint/parse"; +import { scopeDelimiterStyle } from "./scope-delimiter-style.js"; + +const messages = { + noScope: "feat: subject", + + kebabScope: "feat(lint-staged): subject", + snakeScope: "feat(my_scope): subject", + + defaultSlash: "feat(core/api): subject", + defaultComma: "feat(core,api): subject", + defaultCommaSpace: "feat(core, api): subject", + defaultBackslash: "feat(core\\api): subject", + + nonDefaultPipe: "feat(core|api): subject", + nonDefaultStar: "feat(core*api): subject", + mixedCustom: "feat(core|api/utils): subject", +} as const; + +describe("Scope Delimiter Validation", () => { + describe("Messages without scopes", () => { + test('Succeeds for "always" when there is no scope', async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.noScope), + "always", + ); + + expect(actual).toEqual(true); + expect(error).toEqual(undefined); + }); + + test('Succeeds for "never" when there is no scope', async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.noScope), + "never", + ); + + expect(actual).toEqual(true); + expect(error).toEqual(undefined); + }); + }); + + describe('"always" with default configuration', () => { + test.each([ + { scenario: "kebab-case scope", commit: messages.kebabScope }, + { scenario: "snake_case scope", commit: messages.snakeScope }, + ] as const)( + "Treats $scenario as part of the scope and not a delimiter", + async ({ commit }) => { + const [actual, error] = scopeDelimiterStyle( + await parse(commit), + "always", + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [/, \\, ,]"); + }, + ); + + test.each([ + { scenario: "comma ',' delimiter", commit: messages.defaultComma }, + { scenario: "slash '/' delimiter", commit: messages.defaultSlash }, + { + scenario: "backslash '\\' delimiter", + commit: messages.defaultBackslash, + }, + ] as const)("Succeeds when only $scenario is used", async ({ commit }) => { + const [actual, error] = scopeDelimiterStyle( + await parse(commit), + "always", + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [/, \\, ,]"); + }); + + test.each([ + { scenario: "comma without space", commit: messages.defaultComma }, + { scenario: "comma with space", commit: messages.defaultCommaSpace }, + ] as const)( + "Normalizes $scenario as the same delimiter ','", + async ({ commit }) => { + const [actual, error] = scopeDelimiterStyle( + await parse(commit), + "always", + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [/, \\, ,]"); + }, + ); + + test("Fails when a non-default delimiter is used", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.nonDefaultStar), + "always", + ); + + expect(actual).toEqual(false); + expect(error).toEqual("scope delimiters must be one of [/, \\, ,]"); + }); + }); + + describe('"never" with default configuration', () => { + test("Fails when scope uses only default delimiters", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.defaultSlash), + "never", + ); + + expect(actual).toEqual(false); + expect(error).toEqual("scope delimiters must not be one of [/, \\, ,]"); + }); + + test("Succeeds when scope uses only non-default delimiter", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.nonDefaultPipe), + "never", + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must not be one of [/, \\, ,]"); + }); + }); + + describe("Custom configuration", () => { + test("Falls back to default delimiters when delimiters is an empty array", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.defaultComma), + "always", + [], + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [/, \\, ,]"); + }); + + test("Succeeds when a custom single allowed delimiter is used", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.nonDefaultStar), + "always", + ["*"], + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [*]"); + }); + + test("Fails when ',' is used but only '/' is allowed", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.defaultComma), + "always", + ["/"], + ); + + expect(actual).toEqual(false); + expect(error).toEqual("scope delimiters must be one of [/]"); + }); + + test("Succeeds when both '/' and '|' are allowed and used in the scope", async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.mixedCustom), + "always", + ["/", "|"], + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must be one of [/, |]"); + }); + + test('In "never" mode fails when explicitly forbidden delimiter is used', async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.nonDefaultPipe), + "never", + ["|"], + ); + + expect(actual).toEqual(false); + expect(error).toEqual("scope delimiters must not be one of [|]"); + }); + + test('In "never" mode succeeds when delimiter is not in the forbidden list', async () => { + const [actual, error] = scopeDelimiterStyle( + await parse(messages.nonDefaultPipe), + "never", + ["/"], + ); + + expect(actual).toEqual(true); + expect(error).toEqual("scope delimiters must not be one of [/]"); + }); + }); +}); diff --git a/@commitlint/rules/src/scope-delimiter-style.ts b/@commitlint/rules/src/scope-delimiter-style.ts new file mode 100644 index 0000000000..f2a073c6e7 --- /dev/null +++ b/@commitlint/rules/src/scope-delimiter-style.ts @@ -0,0 +1,41 @@ +import * as ensure from "@commitlint/ensure"; +import message from "@commitlint/message"; +import { SyncRule } from "@commitlint/types"; + +export const scopeDelimiterStyle: SyncRule = ( + { scope }, + when = "always", + value = [], +) => { + if (!scope) { + return [true]; + } + + const delimiters = value.length ? value : ["/", "\\", ","]; + const scopeRawDelimiters = scope.match(/[^A-Za-z0-9-_]+/g) ?? []; + const scopeDelimiters = [ + ...new Set( + scopeRawDelimiters.map((delimiter) => { + const trimmed = delimiter.trim(); + + if (trimmed === ",") { + return ","; + } + + return delimiter; + }), + ), + ]; + + const isAllDelimitersAllowed = scopeDelimiters.every((delimiter) => { + return ensure.enum(delimiter, delimiters); + }); + const isNever = when === "never"; + + return [ + isNever ? !isAllDelimitersAllowed : isAllDelimitersAllowed, + message([ + `scope delimiters must ${isNever ? "not " : ""}be one of [${delimiters.join(", ")}]`, + ]), + ]; +}; diff --git a/@commitlint/rules/src/scope-enum.test.ts b/@commitlint/rules/src/scope-enum.test.ts index 1e7fb7efa7..bf8679e313 100644 --- a/@commitlint/rules/src/scope-enum.test.ts +++ b/@commitlint/rules/src/scope-enum.test.ts @@ -17,6 +17,11 @@ const messagesByScope = { empty: "foo: baz", superfluous: "foo(): baz", }, + objectBaseConfiguration: { + pipeDelimiter: "foo(bar|baz): qux", + asteriskDelimiter: "foo(bar*baz): qux", + multipleCustomDelimiters: "foo(bar|baz/qux*xyz): qux", + }, }; const { single, multiple, none } = messagesByScope; @@ -204,4 +209,96 @@ describe("Scope Enum Validation", () => { }); }); }); + + describe("Object-based configuration", () => { + test("Supports object value with default delimiters (/, \\ or ,)", async () => { + const [actual, error] = scopeEnum( + await parse(messages["multipleCommaSpace"]), + "always", + { + scopeEnums: ["bar", "baz"], + }, + ); + expect(actual).toBe(true); + expect(error).toEqual("scope must be one of [bar, baz]"); + }); + + test("Supports custom single delimiter", async () => { + const [actual, error] = scopeEnum( + await parse(messages["pipeDelimiter"]), + "always", + { + scopeEnums: ["bar", "baz"], + delimiters: ["|"], + }, + ); + expect(actual).toBe(true); + expect(error).toEqual("scope must be one of [bar, baz]"); + }); + + test("Supports multiple custom delimiters", async () => { + const [actual, error] = scopeEnum( + await parse(messages["multipleCustomDelimiters"]), + "always", + { + scopeEnums: ["bar", "baz", "qux", "xyz"], + delimiters: ["|", "/", "*"], + }, + ); + expect(actual).toBe(true); + expect(error).toEqual("scope must be one of [bar, baz, qux, xyz]"); + }); + + test("Fails when any scope segment is not in enum with custom delimiter", async () => { + const [actual, error] = scopeEnum( + await parse(messages["pipeDelimiter"]), + "always", + { + scopeEnums: ["bar", "qux"], + delimiters: ["|"], + }, + ); + expect(actual).toBe(false); + expect(error).toEqual("scope must be one of [bar, qux]"); + }); + + test("Falls back to default delimiters when delimiters is an empty array", async () => { + const [actual, error] = scopeEnum( + await parse(messages["multipleSlash"]), + "always", + { + scopeEnums: ["bar", "baz"], + delimiters: [], + }, + ); + expect(actual).toBe(true); + expect(error).toEqual("scope must be one of [bar, baz]"); + }); + + test("Uses object value for 'never' with custom delimiter", async () => { + const [actual, error] = scopeEnum( + await parse(messages["pipeDelimiter"]), + "never", + { + scopeEnums: ["bar", "baz"], + delimiters: ["|"], + }, + ); + expect(actual).toBe(false); + expect(error).toEqual("scope must not be one of [bar, baz]"); + }); + + test("Handles special characters in delimiters", async () => { + const [actual, error] = scopeEnum( + await parse(messages["asteriskDelimiter"]), + "always", + { + scopeEnums: ["bar", "baz"], + delimiters: ["*"], + }, + ); + expect(actual).toBe(true); + expect(error).toEqual("scope must be one of [bar, baz]"); + }); + }); }); diff --git a/@commitlint/rules/src/scope-enum.ts b/@commitlint/rules/src/scope-enum.ts index 59cef26df9..72152f7c77 100644 --- a/@commitlint/rules/src/scope-enum.ts +++ b/@commitlint/rules/src/scope-enum.ts @@ -2,21 +2,31 @@ import * as ensure from "@commitlint/ensure"; import message from "@commitlint/message"; import { SyncRule } from "@commitlint/types"; -export const scopeEnum: SyncRule = ( - { scope }, - when = "always", - value = [], -) => { - if (!scope || !value.length) { +export const scopeEnum: SyncRule< + | string[] + | { + scopeEnums: string[]; + delimiters?: string[]; + } +> = ({ scope }, when = "always", value = []) => { + const scopeEnums = Array.isArray(value) ? value : value.scopeEnums; + + if (!scope || !scopeEnums.length) { return [true, ""]; } - // Scopes may contain slash or comma delimiters to separate them and mark them as individual segments. - // This means that each of these segments should be tested separately with `ensure`. - const delimiters = /\/|\\|, ?/g; - const messageScopes = scope.split(delimiters); - const errorMessage = ["scope must", `be one of [${value.join(", ")}]`]; - const isScopeInEnum = (scope: string) => ensure.enum(scope, value); + const delimiters = + Array.isArray(value) || !value.delimiters?.length + ? ["/", "\\", ","] + : value.delimiters; + const delimiterPatterns = delimiters.map((delimiter) => { + return delimiter === "," + ? ", ?" + : delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }); + const messageScopes = scope.split(new RegExp(delimiterPatterns.join("|"))); + const errorMessage = ["scope must", `be one of [${scopeEnums.join(", ")}]`]; + const isScopeInEnum = (scope: string) => ensure.enum(scope, scopeEnums); let isValid; if (when === "never") { diff --git a/docs/concepts/commit-conventions.md b/docs/concepts/commit-conventions.md index a35a4a277c..59c98b3320 100644 --- a/docs/concepts/commit-conventions.md +++ b/docs/concepts/commit-conventions.md @@ -18,9 +18,6 @@ footer? ## Multiple scopes -Commitlint supports multiple scopes. -Current delimiter options are: +Commitlint supports multiple scopes. Segments may be separated using delimiters (default: `/`, `\`, `,`). -- "/" -- "\\" -- "," +The set of allowed delimiters can be customized via the [scope-delimiter-style](/reference/rules.html#scope-delimiter-style) rule. diff --git a/docs/reference/rules.md b/docs/reference/rules.md index 7c76fafcd7..77852a3da2 100644 --- a/docs/reference/rules.md +++ b/docs/reference/rules.md @@ -219,6 +219,32 @@ ]; ``` +- extended value (object based) + + ```js + { + cases: ["kebab-case"], + delimiters: ["/"] + } + ``` + + - `cases` — list of allowed case formats + - `delimiters` — optional list of delimiter strings used to split multi-segment scopes (default: `["/", "\", ","]`) + +## scope-delimiter-style + +- **condition**: all delimiters found in `scope` must match `value` +- **rule**: `always` +- **value** + + ```text + ["/", "\", ","] + ``` + +> [!NOTE] +> +> - When using this rule together with [scope-enum](#scope-enum) or [scope-case](#scope-case), make sure to provide the same `delimiters` configuration in those rules as well. Otherwise scope parsing may become inconsistent. + ## scope-empty - **condition**: `scope` is empty @@ -234,6 +260,18 @@ [] ``` +- extended value (object based) + + ```js + { + scopeEnums: ["foo", "bar"], + delimiters: ["/"] + } + ``` + + - `scopeEnums` — list of allowed scope values + - `delimiters` — optional list of delimiter strings used to split multi-segment scopes (default: `["/", "\", ","]`) + > [!NOTE] > > - This rule always passes if no scopes are provided in the message or the value > is an empty array.