Skip to content

Commit 4a19bc9

Browse files
committed
feat(rules): add scope-delimiter-style
1 parent b751b29 commit 4a19bc9

File tree

9 files changed

+487
-28
lines changed

9 files changed

+487
-28
lines changed

@commitlint/rules/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { headerMinLength } from "./header-min-length.js";
1818
import { headerTrim } from "./header-trim.js";
1919
import { referencesEmpty } from "./references-empty.js";
2020
import { scopeCase } from "./scope-case.js";
21+
import { scopeDelimiterStyle } from "./scope-delimiter-style.js";
2122
import { scopeEmpty } from "./scope-empty.js";
2223
import { scopeEnum } from "./scope-enum.js";
2324
import { scopeMaxLength } from "./scope-max-length.js";
@@ -57,6 +58,7 @@ export default {
5758
"header-trim": headerTrim,
5859
"references-empty": referencesEmpty,
5960
"scope-case": scopeCase,
61+
"scope-delimiter-style": scopeDelimiterStyle,
6062
"scope-empty": scopeEmpty,
6163
"scope-enum": scopeEnum,
6264
"scope-max-length": scopeMaxLength,

@commitlint/rules/src/scope-case.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,64 @@ test('with slash in subject should succeed for "always sentence case"', async ()
322322
const expected = true;
323323
expect(actual).toEqual(expected);
324324
});
325+
326+
test("with object-based configuration should use default delimiters", async () => {
327+
const commit = await parse("feat(scope/my-scope, shared-scope): subject");
328+
const [actual] = scopeCase(commit, "always", {
329+
cases: ["kebab-case"],
330+
});
331+
const expected = true;
332+
expect(actual).toEqual(expected);
333+
});
334+
335+
test("with object-based configuration should support custom single delimiter", async () => {
336+
const commit = await parse("feat(scope|my-scope): subject");
337+
const [actual] = scopeCase(commit, "always", {
338+
cases: ["kebab-case"],
339+
delimiters: ["|"],
340+
});
341+
const expected = true;
342+
expect(actual).toEqual(expected);
343+
});
344+
345+
test("with object-based configuration should support multiple custom delimiters", async () => {
346+
const commit = await parse(
347+
"feat(scope|my-scope/shared-scope,common-scope): subject",
348+
);
349+
const [actual] = scopeCase(commit, "always", {
350+
cases: ["kebab-case"],
351+
delimiters: ["|", "/", ","],
352+
});
353+
const expected = true;
354+
expect(actual).toEqual(expected);
355+
});
356+
357+
test("with object-based configuration should fall back to default delimiters when empty array provided", async () => {
358+
const commit = await parse("feat(scope/my-scope): subject");
359+
const [actual] = scopeCase(commit, "always", {
360+
cases: ["kebab-case"],
361+
delimiters: [],
362+
});
363+
const expected = true;
364+
expect(actual).toEqual(expected);
365+
});
366+
367+
test("with object-based configuration should handle special delimiters", async () => {
368+
const commit = await parse("feat(scope*my-scope): subject");
369+
const [actual] = scopeCase(commit, "always", {
370+
cases: ["kebab-case"],
371+
delimiters: ["*"],
372+
});
373+
const expected = true;
374+
expect(actual).toEqual(expected);
375+
});
376+
377+
test('with object-based configuration should respect "never" when custom delimiter is used', async () => {
378+
const commit = await parse("feat(scope|my-scope): subject");
379+
const [actual] = scopeCase(commit, "never", {
380+
cases: ["kebab-case"],
381+
delimiters: ["|"],
382+
});
383+
const expected = false;
384+
expect(actual).toEqual(expected);
385+
});

@commitlint/rules/src/scope-case.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,29 @@ import { TargetCaseType, SyncRule } from "@commitlint/types";
44

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

7-
export const scopeCase: SyncRule<TargetCaseType | TargetCaseType[]> = (
8-
parsed,
9-
when = "always",
10-
value = [],
11-
) => {
7+
export const scopeCase: SyncRule<
8+
| TargetCaseType
9+
| TargetCaseType[]
10+
| {
11+
cases: TargetCaseType[];
12+
delimiters?: string[];
13+
}
14+
> = (parsed, when = "always", value = []) => {
1215
const { scope } = parsed;
1316

1417
if (!scope) {
1518
return [true];
1619
}
20+
const isObjectBasedConfiguration =
21+
!Array.isArray(value) && !(typeof value === "string");
1722

18-
const checks = (Array.isArray(value) ? value : [value]).map((check) => {
23+
const checks = (
24+
isObjectBasedConfiguration
25+
? value.cases
26+
: Array.isArray(value)
27+
? value
28+
: [value]
29+
).map((check) => {
1930
if (typeof check === "string") {
2031
return {
2132
when: "always",
@@ -25,14 +36,22 @@ export const scopeCase: SyncRule<TargetCaseType | TargetCaseType[]> = (
2536
return check;
2637
});
2738

28-
// Scopes may contain slash or comma delimiters to separate them and mark them as individual segments.
29-
// This means that each of these segments should be tested separately with `ensure`.
30-
const delimiters = /\/|\\|, ?/g;
31-
const scopeSegments = scope.split(delimiters);
39+
const delimiters =
40+
isObjectBasedConfiguration && value.delimiters?.length
41+
? value.delimiters
42+
: ["/", "\\", ","];
43+
const delimiterPatterns = delimiters.map((delimiter) => {
44+
return delimiter === ","
45+
? ", ?"
46+
: delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47+
});
48+
const delimiterRegex = new RegExp(delimiterPatterns.join("|"));
49+
const scopeSegments = scope.split(delimiterRegex);
3250

3351
const result = checks.some((check) => {
3452
const r = scopeSegments.every(
35-
(segment) => delimiters.test(segment) || ensureCase(segment, check.case),
53+
(segment) =>
54+
delimiterRegex.test(segment) || ensureCase(segment, check.case),
3655
);
3756

3857
return negated(check.when) ? !r : r;
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, test, expect } from "vitest";
2+
import parse from "@commitlint/parse";
3+
import { scopeDelimiterStyle } from "./scope-delimiter-style.js";
4+
5+
const messages = {
6+
noScope: "feat: subject",
7+
8+
kebabScope: "feat(lint-staged): subject",
9+
snakeScope: "feat(my_scope): subject",
10+
11+
defaultSlash: "feat(core/api): subject",
12+
defaultComma: "feat(core,api): subject",
13+
defaultCommaSpace: "feat(core, api): subject",
14+
defaultBackslash: "feat(core\\api): subject",
15+
16+
nonDefaultPipe: "feat(core|api): subject",
17+
nonDefaultStar: "feat(core*api): subject",
18+
mixedCustom: "feat(core|api/utils): subject",
19+
} as const;
20+
21+
describe("Scope Delimiter Validation", () => {
22+
describe("Messages without scopes", () => {
23+
test('Succeeds for "always" when there is no scope', async () => {
24+
const [actual, error] = scopeDelimiterStyle(
25+
await parse(messages.noScope),
26+
"always",
27+
);
28+
29+
expect(actual).toEqual(true);
30+
expect(error).toEqual(undefined);
31+
});
32+
33+
test('Succeeds for "never" when there is no scope', async () => {
34+
const [actual, error] = scopeDelimiterStyle(
35+
await parse(messages.noScope),
36+
"never",
37+
);
38+
39+
expect(actual).toEqual(true);
40+
expect(error).toEqual(undefined);
41+
});
42+
});
43+
44+
describe('"always" with default configuration', () => {
45+
test.each([
46+
{ scenario: "kebab-case scope", commit: messages.kebabScope },
47+
{ scenario: "snake_case scope", commit: messages.snakeScope },
48+
] as const)(
49+
"Treats $scenario as part of the scope and not a delimiter",
50+
async ({ commit }) => {
51+
const [actual, error] = scopeDelimiterStyle(
52+
await parse(commit),
53+
"always",
54+
);
55+
56+
expect(actual).toEqual(true);
57+
expect(error).toEqual("scope delimiters must be one of [/, \\, ,]");
58+
},
59+
);
60+
61+
test.each([
62+
{ scenario: "comma ',' delimiter", commit: messages.defaultComma },
63+
{ scenario: "slash '/' delimiter", commit: messages.defaultSlash },
64+
{
65+
scenario: "backslash '\\' delimiter",
66+
commit: messages.defaultBackslash,
67+
},
68+
] as const)("Succeeds when only $scenario is used", async ({ commit }) => {
69+
const [actual, error] = scopeDelimiterStyle(
70+
await parse(commit),
71+
"always",
72+
);
73+
74+
expect(actual).toEqual(true);
75+
expect(error).toEqual("scope delimiters must be one of [/, \\, ,]");
76+
});
77+
78+
test.each([
79+
{ scenario: "comma without space", commit: messages.defaultComma },
80+
{ scenario: "comma with space", commit: messages.defaultCommaSpace },
81+
] as const)(
82+
"Normalizes $scenario as the same delimiter ','",
83+
async ({ commit }) => {
84+
const [actual, error] = scopeDelimiterStyle(
85+
await parse(commit),
86+
"always",
87+
);
88+
89+
expect(actual).toEqual(true);
90+
expect(error).toEqual("scope delimiters must be one of [/, \\, ,]");
91+
},
92+
);
93+
94+
test("Fails when a non-default delimiter is used", async () => {
95+
const [actual, error] = scopeDelimiterStyle(
96+
await parse(messages.nonDefaultStar),
97+
"always",
98+
);
99+
100+
expect(actual).toEqual(false);
101+
expect(error).toEqual("scope delimiters must be one of [/, \\, ,]");
102+
});
103+
});
104+
105+
describe('"never" with default configuration', () => {
106+
test("Fails when scope uses only default delimiters", async () => {
107+
const [actual, error] = scopeDelimiterStyle(
108+
await parse(messages.defaultSlash),
109+
"never",
110+
);
111+
112+
expect(actual).toEqual(false);
113+
expect(error).toEqual("scope delimiters must not be one of [/, \\, ,]");
114+
});
115+
116+
test("Succeeds when scope uses only non-default delimiter", async () => {
117+
const [actual, error] = scopeDelimiterStyle(
118+
await parse(messages.nonDefaultPipe),
119+
"never",
120+
);
121+
122+
expect(actual).toEqual(true);
123+
expect(error).toEqual("scope delimiters must not be one of [/, \\, ,]");
124+
});
125+
});
126+
127+
describe("Custom configuration", () => {
128+
test("Falls back to default delimiters when delimiters is an empty array", async () => {
129+
const [actual, error] = scopeDelimiterStyle(
130+
await parse(messages.defaultComma),
131+
"always",
132+
[],
133+
);
134+
135+
expect(actual).toEqual(true);
136+
expect(error).toEqual("scope delimiters must be one of [/, \\, ,]");
137+
});
138+
139+
test("Succeeds when a custom single allowed delimiter is used", async () => {
140+
const [actual, error] = scopeDelimiterStyle(
141+
await parse(messages.nonDefaultStar),
142+
"always",
143+
["*"],
144+
);
145+
146+
expect(actual).toEqual(true);
147+
expect(error).toEqual("scope delimiters must be one of [*]");
148+
});
149+
150+
test("Fails when ',' is used but only '/' is allowed", async () => {
151+
const [actual, error] = scopeDelimiterStyle(
152+
await parse(messages.defaultComma),
153+
"always",
154+
["/"],
155+
);
156+
157+
expect(actual).toEqual(false);
158+
expect(error).toEqual("scope delimiters must be one of [/]");
159+
});
160+
161+
test("Succeeds when both '/' and '|' are allowed and used in the scope", async () => {
162+
const [actual, error] = scopeDelimiterStyle(
163+
await parse(messages.mixedCustom),
164+
"always",
165+
["/", "|"],
166+
);
167+
168+
expect(actual).toEqual(true);
169+
expect(error).toEqual("scope delimiters must be one of [/, |]");
170+
});
171+
172+
test('In "never" mode fails when explicitly forbidden delimiter is used', async () => {
173+
const [actual, error] = scopeDelimiterStyle(
174+
await parse(messages.nonDefaultPipe),
175+
"never",
176+
["|"],
177+
);
178+
179+
expect(actual).toEqual(false);
180+
expect(error).toEqual("scope delimiters must not be one of [|]");
181+
});
182+
183+
test('In "never" mode succeeds when delimiter is not in the forbidden list', async () => {
184+
const [actual, error] = scopeDelimiterStyle(
185+
await parse(messages.nonDefaultPipe),
186+
"never",
187+
["/"],
188+
);
189+
190+
expect(actual).toEqual(true);
191+
expect(error).toEqual("scope delimiters must not be one of [/]");
192+
});
193+
});
194+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as ensure from "@commitlint/ensure";
2+
import message from "@commitlint/message";
3+
import { SyncRule } from "@commitlint/types";
4+
5+
export const scopeDelimiterStyle: SyncRule<string[]> = (
6+
{ scope },
7+
when = "always",
8+
value = [],
9+
) => {
10+
if (!scope) {
11+
return [true];
12+
}
13+
14+
const delimiters = value.length ? value : ["/", "\\", ","];
15+
const scopeRawDelimiters = scope.match(/[^A-Za-z0-9-_]+/g) ?? [];
16+
const scopeDelimiters = [
17+
...new Set(
18+
scopeRawDelimiters.map((delimiter) => {
19+
const trimmed = delimiter.trim();
20+
21+
if (trimmed === ",") {
22+
return ",";
23+
}
24+
25+
return delimiter;
26+
}),
27+
),
28+
];
29+
30+
const isAllDelimitersAllowed = scopeDelimiters.every((delimiter) => {
31+
return ensure.enum(delimiter, delimiters);
32+
});
33+
const isNever = when === "never";
34+
35+
return [
36+
isNever ? !isAllDelimitersAllowed : isAllDelimitersAllowed,
37+
message([
38+
`scope delimiters must ${isNever ? "not " : ""}be one of [${delimiters.join(", ")}]`,
39+
]),
40+
];
41+
};

0 commit comments

Comments
 (0)