Skip to content

Commit 364cb6c

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

File tree

9 files changed

+460
-28
lines changed

9 files changed

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

0 commit comments

Comments
 (0)