Skip to content

Commit 6fe168e

Browse files
authored
Fix/3779: Adjust logic for 'never' scope-enum validation (#3795)
* feat(load): use cosmiconfig-typescript-loader v5 to remove ts-node dependency for @commitlint/load * fix(load): adjust "never" logic for scope enum
1 parent 6bc5063 commit 6fe168e

File tree

3 files changed

+208
-150
lines changed

3 files changed

+208
-150
lines changed
+178-134
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,183 @@
11
import parse from '@commitlint/parse';
22
import {scopeEnum} from './scope-enum';
3-
4-
const messages = {
5-
plain: 'foo(bar): baz',
6-
superfluous: 'foo(): baz',
7-
empty: 'foo: baz',
8-
multiple: 'foo(bar,baz): qux',
9-
multipleCommaSpace: 'foo(bar, baz): qux',
10-
};
11-
12-
const parsed = {
13-
plain: parse(messages.plain),
14-
superfluous: parse(messages.superfluous),
15-
empty: parse(messages.empty),
16-
multiple: parse(messages.multiple),
17-
multipleCommaSpace: parse(messages.multipleCommaSpace),
3+
import {RuleConfigCondition} from '@commitlint/types';
4+
5+
const messagesByScope = {
6+
single: {
7+
plain: 'foo(bar): baz',
8+
},
9+
multiple: {
10+
multiple: 'foo(bar,baz): qux',
11+
multipleCommaSpace: 'foo(bar, baz): qux',
12+
},
13+
none: {
14+
empty: 'foo: baz',
15+
superfluous: 'foo(): baz',
16+
},
1817
};
1918

20-
test('scope-enum with plain message and always should succeed empty enum', async () => {
21-
const [actual] = scopeEnum(await parsed.plain, 'always', []);
22-
const expected = true;
23-
expect(actual).toEqual(expected);
24-
});
25-
26-
test('scope-enum with plain message and never should error empty enum', async () => {
27-
const [actual, message] = scopeEnum(await parsed.plain, 'never', []);
28-
const expected = false;
29-
expect(actual).toEqual(expected);
30-
expect(message).toEqual('scope must not be one of []');
31-
});
32-
33-
test('with plain message should succeed correct enum', async () => {
34-
const [actual] = scopeEnum(await parsed.plain, 'always', ['bar']);
35-
const expected = true;
36-
expect(actual).toEqual(expected);
37-
});
38-
39-
test('scope-enum with plain message should error false enum', async () => {
40-
const [actual, message] = scopeEnum(await parsed.plain, 'always', ['foo']);
41-
const expected = false;
42-
expect(actual).toEqual(expected);
43-
expect(message).toEqual('scope must be one of [foo]');
44-
});
45-
46-
test('scope-enum with plain message should error forbidden enum', async () => {
47-
const [actual, message] = scopeEnum(await parsed.plain, 'never', ['bar']);
48-
const expected = false;
49-
expect(actual).toEqual(expected);
50-
expect(message).toEqual('scope must not be one of [bar]');
51-
});
52-
53-
test('scope-enum with plain message should succeed forbidden enum', async () => {
54-
const [actual] = scopeEnum(await parsed.plain, 'never', ['foo']);
55-
const expected = true;
56-
expect(actual).toEqual(expected);
57-
});
58-
59-
test('scope-enum with superfluous scope should succeed enum', async () => {
60-
const [actual] = scopeEnum(await parsed.superfluous, 'always', ['bar']);
61-
const expected = true;
62-
expect(actual).toEqual(expected);
63-
});
64-
65-
test('scope-enum with superfluous scope and "never" should succeed', async () => {
66-
const [actual] = scopeEnum(await parsed.superfluous, 'never', ['bar']);
67-
const expected = true;
68-
expect(actual).toEqual(expected);
69-
});
70-
71-
test('scope-enum with superfluous scope and always should succeed empty enum', async () => {
72-
const [actual] = scopeEnum(await parsed.superfluous, 'always', []);
73-
const expected = true;
74-
expect(actual).toEqual(expected);
75-
});
76-
77-
test('scope-enum with superfluous scope and never should succeed empty enum', async () => {
78-
const [actual] = scopeEnum(await parsed.superfluous, 'never', []);
79-
const expected = true;
80-
expect(actual).toEqual(expected);
81-
});
82-
83-
test('scope-enum with empty scope and always should succeed empty enum', async () => {
84-
const [actual] = scopeEnum(await parsed.superfluous, 'always', []);
85-
const expected = true;
86-
expect(actual).toEqual(expected);
87-
});
88-
89-
test('scope-enum with empty scope and always should succeed filled enum', async () => {
90-
const [actual] = scopeEnum(await parsed.superfluous, 'always', ['foo']);
91-
const expected = true;
92-
expect(actual).toEqual(expected);
93-
});
94-
95-
test('scope-enum with empty scope and never should succeed empty enum', async () => {
96-
const [actual] = scopeEnum(await parsed.superfluous, 'never', []);
97-
const expected = true;
98-
expect(actual).toEqual(expected);
99-
});
100-
101-
test('scope-enum with multiple scopes should error on message with multiple scopes', async () => {
102-
const [actual, message] = scopeEnum(await parsed.multiple, 'never', [
103-
'bar',
104-
'baz',
105-
]);
106-
const expected = false;
107-
expect(actual).toEqual(expected);
108-
expect(message).toEqual('scope must not be one of [bar, baz]');
109-
});
110-
111-
test('scope-enum with multiple scopes should error on message with forbidden enum', async () => {
112-
const [actual, message] = scopeEnum(await parsed.multiple, 'never', [
113-
'bar',
114-
'qux',
115-
]);
116-
const expected = true;
117-
expect(actual).toEqual(expected);
118-
});
119-
120-
test('scope-enum with multiple scopes should error on message with superfluous scope', async () => {
121-
const [actual] = scopeEnum(await parsed.multiple, 'never', ['bar']);
122-
const expected = true;
123-
expect(actual).toEqual(expected);
124-
});
125-
126-
test('scope-enum with multiple scope should succeed on message with multiple scopes', async () => {
127-
const [actual] = scopeEnum(await parsed.multiple, 'always', ['bar', 'baz']);
128-
const expected = true;
129-
expect(actual).toEqual(expected);
130-
});
131-
132-
test('scope-enum with multiple scope with comma+space should succeed on message with multiple scopes', async () => {
133-
const [actual] = scopeEnum(await parsed.multipleCommaSpace, 'always', [
134-
'bar',
135-
'baz',
136-
]);
137-
const expected = true;
138-
expect(actual).toEqual(expected);
19+
const {single, multiple, none} = messagesByScope;
20+
21+
const messages = Object.values(messagesByScope).reduce(
22+
(acc, curr) => ({...acc, ...curr}),
23+
{}
24+
);
25+
26+
const conditions: RuleConfigCondition[] = ['always', 'never'];
27+
28+
describe('Scope Enum Validation', () => {
29+
conditions.forEach((condition) => {
30+
describe('Enum without Scopes', () => {
31+
Object.keys(messages).forEach((messageType) => {
32+
test(`Succeeds with a '${messageType}' message and '${condition}'`, async () => {
33+
const [actual, message] = scopeEnum(
34+
await parse(messages[messageType]),
35+
condition,
36+
[]
37+
);
38+
const expected = true;
39+
expect(actual).toEqual(expected);
40+
expect(message).toEqual('');
41+
});
42+
});
43+
});
44+
45+
describe('Messages without Scopes', () => {
46+
Object.keys(none).forEach((messageType) => {
47+
const fakeMessage = messages[messageType];
48+
49+
it(`Succeeds with a '${messageType}' message and '${condition}' with single-scope enum`, async () => {
50+
const [actual, message] = scopeEnum(
51+
await parse(fakeMessage),
52+
condition,
53+
['bar']
54+
);
55+
expect(actual).toBeTruthy();
56+
expect(message).toBeFalsy();
57+
});
58+
59+
it(`Succeeds with a '${messageType}' message and '${condition}' with multi-scope enum`, async () => {
60+
const [actual, message] = scopeEnum(
61+
await parse(fakeMessage),
62+
condition,
63+
['bar', 'baz']
64+
);
65+
expect(actual).toBeTruthy();
66+
expect(message).toBeFalsy();
67+
});
68+
});
69+
});
70+
});
71+
72+
describe('Always', () => {
73+
describe('Single-Scope Messages', () => {
74+
Object.keys(single).forEach((messageType) => {
75+
const fakeMessage = messages[messageType];
76+
77+
it(`Succeeds with a '${messageType}' message when all message scopes are included in single-scope enum`, async () => {
78+
const [actual, message] = scopeEnum(
79+
await parse(fakeMessage),
80+
'always',
81+
['bar']
82+
);
83+
expect(actual).toBeTruthy();
84+
expect(message).toEqual('scope must be one of [bar]');
85+
});
86+
87+
test(`Succeeds with a '${messageType}' message when all message scopes are included in multi-scope enum`, async () => {
88+
const [actual, message] = scopeEnum(
89+
await parse(fakeMessage),
90+
'always',
91+
['bar', 'baz']
92+
);
93+
expect(actual).toBeTruthy();
94+
expect(message).toEqual('scope must be one of [bar, baz]');
95+
});
96+
97+
test(`Fails with a '${messageType}' message when any message scope is not included in enum`, async () => {
98+
const [actual, message] = scopeEnum(
99+
await parse(fakeMessage),
100+
'always',
101+
['foo']
102+
);
103+
expect(actual).toBeFalsy();
104+
expect(message).toEqual('scope must be one of [foo]');
105+
});
106+
});
107+
});
108+
109+
describe('Multi-Scope Messages', () => {
110+
Object.keys(multiple).forEach((messageType) => {
111+
const fakeMessage = messages[messageType];
112+
113+
test(`Succeeds with a '${messageType}' message when all message scopes are included in enum`, async () => {
114+
const [actual, message] = scopeEnum(
115+
await parse(fakeMessage),
116+
'always',
117+
['bar', 'baz']
118+
);
119+
expect(actual).toBeTruthy();
120+
expect(message).toEqual('scope must be one of [bar, baz]');
121+
});
122+
123+
test(`Fails with a '${messageType}' message when no message scopes are included in enum`, async () => {
124+
const [actual, message] = scopeEnum(
125+
await parse(fakeMessage),
126+
'always',
127+
['foo']
128+
);
129+
expect(actual).toBeFalsy();
130+
expect(message).toEqual('scope must be one of [foo]');
131+
});
132+
133+
it(`Fails with a '${messageType}' message when only some message scopes are included in enum`, async () => {
134+
const [actual, message] = scopeEnum(
135+
await parse(fakeMessage),
136+
'always',
137+
['bar']
138+
);
139+
expect(actual).toBeFalsy();
140+
expect(message).toEqual('scope must be one of [bar]');
141+
});
142+
});
143+
});
144+
});
145+
146+
describe('Never', () => {
147+
describe('Messages with Scopes', () => {
148+
Object.keys({...single, ...multiple}).forEach((messageType) => {
149+
const fakeMessage = messages[messageType];
150+
151+
test(`Succeeds with a '${messageType}' message when no message scopes are included in enum`, async () => {
152+
const [actual, message] = scopeEnum(
153+
await parse(fakeMessage),
154+
'never',
155+
['foo']
156+
);
157+
expect(actual).toBeTruthy();
158+
expect(message).toEqual('scope must not be one of [foo]');
159+
});
160+
161+
it(`Fails with a '${messageType}' message when any message scope is included in single-scope enum`, async () => {
162+
const [actual, message] = scopeEnum(
163+
await parse(fakeMessage),
164+
'never',
165+
['bar']
166+
);
167+
expect(actual).toBeFalsy();
168+
expect(message).toEqual('scope must not be one of [bar]');
169+
});
170+
171+
test(`Fails with a '${messageType}' message when any message scope is included in multi-scope enum`, async () => {
172+
const [actual, message] = scopeEnum(
173+
await parse(fakeMessage),
174+
'never',
175+
['bar', 'baz']
176+
);
177+
expect(actual).toBeFalsy();
178+
expect(message).toEqual('scope must not be one of [bar, baz]');
179+
});
180+
});
181+
});
182+
});
139183
});

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

+13-15
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,28 @@ import message from '@commitlint/message';
33
import {SyncRule} from '@commitlint/types';
44

55
export const scopeEnum: SyncRule<string[]> = (
6-
parsed,
6+
{scope},
77
when = 'always',
88
value = []
99
) => {
10-
if (!parsed.scope) {
10+
if (!scope || !value.length) {
1111
return [true, ''];
1212
}
1313

1414
// Scopes may contain slash or comma delimiters to separate them and mark them as individual segments.
1515
// This means that each of these segments should be tested separately with `ensure`.
1616
const delimiters = /\/|\\|, ?/g;
17-
const scopeSegments = parsed.scope.split(delimiters);
17+
const messageScopes = scope.split(delimiters);
18+
const errorMessage = ['scope must', `be one of [${value.join(', ')}]`];
19+
const isScopeInEnum = (scope: string) => ensure.enum(scope, value);
20+
let isValid;
1821

19-
const negated = when === 'never';
20-
const result =
21-
value.length === 0 ||
22-
scopeSegments.every((scope) => ensure.enum(scope, value));
22+
if (when === 'never') {
23+
isValid = !messageScopes.some(isScopeInEnum);
24+
errorMessage.splice(1, 0, 'not');
25+
} else {
26+
isValid = messageScopes.every(isScopeInEnum);
27+
}
2328

24-
return [
25-
negated ? !result : result,
26-
message([
27-
`scope must`,
28-
negated ? `not` : null,
29-
`be one of [${value.join(', ')}]`,
30-
]),
31-
];
29+
return [isValid, message(errorMessage)];
3230
};

docs/reference-rules.md

+17-1
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ Infinity
225225
```
226226
[]
227227
```
228+
- Notes:
229+
- This rule always passes if no scopes are provided in the message or the value is an empty array.
230+
- When set to `always`, all message scopes must be found in the value.
231+
- When set to `never`, none of the message scopes can be found in the value.
228232

229233
#### scope-case
230234

@@ -347,7 +351,19 @@ Infinity
347351
- **rule**: `always`
348352
- **value**
349353
```js
350-
['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'];
354+
[
355+
'build',
356+
'chore',
357+
'ci',
358+
'docs',
359+
'feat',
360+
'fix',
361+
'perf',
362+
'refactor',
363+
'revert',
364+
'style',
365+
'test',
366+
];
351367
```
352368

353369
#### type-case

0 commit comments

Comments
 (0)