Skip to content
Open
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
2 changes: 2 additions & 0 deletions @commitlint/rules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions @commitlint/rules/src/scope-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
41 changes: 30 additions & 11 deletions @commitlint/rules/src/scope-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,29 @@ import { TargetCaseType, SyncRule } from "@commitlint/types";

const negated = (when?: string) => when === "never";

export const scopeCase: SyncRule<TargetCaseType | TargetCaseType[]> = (
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",
Expand All @@ -25,14 +36,22 @@ export const scopeCase: SyncRule<TargetCaseType | TargetCaseType[]> = (
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;
Expand Down
194 changes: 194 additions & 0 deletions @commitlint/rules/src/scope-delimiter-style.test.ts
Original file line number Diff line number Diff line change
@@ -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 [/]");
});
});
});
41 changes: 41 additions & 0 deletions @commitlint/rules/src/scope-delimiter-style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as ensure from "@commitlint/ensure";
import message from "@commitlint/message";
import { SyncRule } from "@commitlint/types";

export const scopeDelimiterStyle: SyncRule<string[]> = (
{ 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(", ")}]`,
]),
];
};
Loading