Skip to content

Commit cea4564

Browse files
SimeonCmarionebl
authored andcommittedApr 26, 2019
feat: adds support for plugins (#228) (#588)
1 parent 4ee4544 commit cea4564

File tree

11 files changed

+435
-9
lines changed

11 files changed

+435
-9
lines changed
 

‎@commitlint/cli/src/cli.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,16 @@ async function main(options) {
139139
const loadOpts = {cwd: flags.cwd, file: flags.config};
140140
const loaded = await load(getSeed(flags), loadOpts);
141141
const parserOpts = selectParserOpts(loaded.parserPreset);
142-
const opts = parserOpts ? {parserOpts} : {parserOpts: {}};
142+
const opts = {
143+
parserOpts: {},
144+
plugins: {}
145+
};
146+
if (parserOpts) {
147+
opts.parserOpts = parserOpts;
148+
}
149+
if (loaded.plugins) {
150+
opts.plugins = loaded.plugins;
151+
}
143152
const format = loadFormatter(loaded, flags);
144153

145154
// Strip comments if reading from `.git/COMMIT_EDIT_MSG`

‎@commitlint/lint/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
"cross-env": "5.1.1",
6868
"execa": "0.9.0",
6969
"globby": "8.0.1",
70-
"rimraf": "2.6.1"
70+
"rimraf": "2.6.1",
71+
"proxyquire": "2.1.0"
7172
},
7273
"dependencies": {
7374
"@commitlint/is-ignored": "^7.5.1",

‎@commitlint/lint/src/index.js

+15-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import util from 'util';
22
import isIgnored from '@commitlint/is-ignored';
33
import parse from '@commitlint/parse';
44
import implementations from '@commitlint/rules';
5-
import {toPairs} from 'lodash';
5+
import {toPairs, values} from 'lodash';
66

77
const buildCommitMesage = ({header, body, footer}) => {
88
let message = header;
@@ -27,13 +27,24 @@ export default async (message, rules = {}, opts = {}) => {
2727
// Parse the commit message
2828
const parsed = await parse(message, undefined, opts.parserOpts);
2929

30+
const mergedImplementations = Object.assign({}, implementations);
31+
if (opts.plugins) {
32+
values(opts.plugins).forEach(plugin => {
33+
if (plugin.rules) {
34+
Object.keys(plugin.rules).forEach(ruleKey => {
35+
mergedImplementations[ruleKey] = plugin.rules[ruleKey];
36+
});
37+
}
38+
});
39+
}
40+
3041
// Find invalid rules configs
3142
const missing = Object.keys(rules).filter(
32-
name => typeof implementations[name] !== 'function'
43+
name => typeof mergedImplementations[name] !== 'function'
3344
);
3445

3546
if (missing.length > 0) {
36-
const names = Object.keys(implementations);
47+
const names = Object.keys(mergedImplementations);
3748
throw new RangeError(
3849
`Found invalid rule names: ${missing.join(
3950
', '
@@ -120,7 +131,7 @@ export default async (message, rules = {}, opts = {}) => {
120131
return null;
121132
}
122133

123-
const rule = implementations[name];
134+
const rule = mergedImplementations[name];
124135

125136
const [valid, message] = rule(parsed, when, value);
126137

‎@commitlint/lint/src/index.test.js

+40
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,46 @@ test('fails for custom issue prefix', async t => {
184184
t.false(report.valid);
185185
});
186186

187+
test('fails for custom plugin rule', async t => {
188+
const report = await lint(
189+
'somehting #1',
190+
{
191+
'plugin-rule': [2, 'never']
192+
},
193+
{
194+
plugins: {
195+
'plugin-example': {
196+
rules: {
197+
'plugin-rule': () => [false]
198+
}
199+
}
200+
}
201+
}
202+
);
203+
204+
t.false(report.valid);
205+
});
206+
207+
test('passes for custom plugin rule', async t => {
208+
const report = await lint(
209+
'somehting #1',
210+
{
211+
'plugin-rule': [2, 'never']
212+
},
213+
{
214+
plugins: {
215+
'plugin-example': {
216+
rules: {
217+
'plugin-rule': () => [true]
218+
}
219+
}
220+
}
221+
}
222+
);
223+
224+
t.true(report.valid);
225+
});
226+
187227
test('returns original message only with commit header', async t => {
188228
const message = 'foo: bar';
189229
const report = await lint(message);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
extends: [],
3+
plugins: ['example', '@scope/example']
4+
};

‎@commitlint/load/src/index.js

+12-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import resolveExtends from '@commitlint/resolve-extends';
44
import cosmiconfig from 'cosmiconfig';
55
import {toPairs, merge, mergeWith, pick} from 'lodash';
66
import resolveFrom from 'resolve-from';
7+
import loadPlugin from './utils/loadPlugin';
78

89
const w = (a, b) => (Array.isArray(b) ? b : undefined);
910
const valid = input =>
10-
pick(input, 'extends', 'rules', 'parserPreset', 'formatter');
11+
pick(input, 'extends', 'plugins', 'rules', 'parserPreset', 'formatter');
1112

1213
export default async (seed = {}, options = {cwd: process.cwd()}) => {
1314
const loaded = await loadConfig(options.cwd, options.file);
@@ -16,8 +17,8 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => {
1617
// Merge passed config with file based options
1718
const config = valid(merge(loaded.config, seed));
1819
const opts = merge(
19-
{extends: [], rules: {}, formatter: '@commitlint/format'},
20-
pick(config, 'extends')
20+
{extends: [], plugins: [], rules: {}, formatter: '@commitlint/format'},
21+
pick(config, 'extends', 'plugins')
2122
);
2223

2324
// Resolve parserPreset key
@@ -55,6 +56,14 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => {
5556
resolveFrom.silent(base, config.formatter) || config.formatter;
5657
}
5758

59+
// resolve plugins
60+
preset.plugins = {};
61+
if (config.plugins && config.plugins.length) {
62+
config.plugins.forEach(pluginKey => {
63+
loadPlugin(preset.plugins, pluginKey, process.env.DEBUG === 'true');
64+
});
65+
}
66+
5867
// Execute rule config functions if needed
5968
const executed = await Promise.all(
6069
['rules']

‎@commitlint/load/src/index.test.js

+52
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import resolveFrom from 'resolve-from';
55

66
import load from '.';
77

8+
const proxyquire = require('proxyquire')
9+
.noCallThru()
10+
.noPreserveCache();
11+
812
test('extends-empty should have no rules', async t => {
913
const cwd = await git.bootstrap('fixtures/extends-empty');
1014
const actual = await load({}, {cwd});
@@ -24,6 +28,41 @@ test('rules should be loaded from specify config file', async t => {
2428
t.is(actual.rules.foo, 'bar');
2529
});
2630

31+
test('plugins should be loaded from seed', async t => {
32+
const plugin = {'@global': true};
33+
const scopedPlugin = {'@global': true};
34+
const stubbedLoad = proxyquire('.', {
35+
'commitlint-plugin-example': plugin,
36+
'@scope/commitlint-plugin-example': scopedPlugin
37+
});
38+
39+
const cwd = await git.bootstrap('fixtures/extends-empty');
40+
const actual = await stubbedLoad(
41+
{plugins: ['example', '@scope/example']},
42+
{cwd}
43+
);
44+
t.deepEqual(actual.plugins, {
45+
example: plugin,
46+
'@scope/example': scopedPlugin
47+
});
48+
});
49+
50+
test('plugins should be loaded from config', async t => {
51+
const plugin = {'@global': true};
52+
const scopedPlugin = {'@global': true};
53+
const stubbedLoad = proxyquire('.', {
54+
'commitlint-plugin-example': plugin,
55+
'@scope/commitlint-plugin-example': scopedPlugin
56+
});
57+
58+
const cwd = await git.bootstrap('fixtures/extends-plugins');
59+
const actual = await stubbedLoad({}, {cwd});
60+
t.deepEqual(actual.plugins, {
61+
example: plugin,
62+
'@scope/example': scopedPlugin
63+
});
64+
});
65+
2766
test('uses seed with parserPreset', async t => {
2867
const cwd = await git.bootstrap('fixtures/parser-preset');
2968
const {parserPreset: actual} = await load(
@@ -61,6 +100,7 @@ test('respects cwd option', async t => {
61100
t.deepEqual(actual, {
62101
formatter: '@commitlint/format',
63102
extends: ['./second-extended'],
103+
plugins: {},
64104
rules: {
65105
one: 1,
66106
two: 2
@@ -74,6 +114,7 @@ test('recursive extends', async t => {
74114
t.deepEqual(actual, {
75115
formatter: '@commitlint/format',
76116
extends: ['./first-extended'],
117+
plugins: {},
77118
rules: {
78119
zero: 0,
79120
one: 1,
@@ -89,6 +130,7 @@ test('recursive extends with json file', async t => {
89130
t.deepEqual(actual, {
90131
formatter: '@commitlint/format',
91132
extends: ['./first-extended'],
133+
plugins: {},
92134
rules: {
93135
zero: 0,
94136
one: 1,
@@ -104,6 +146,7 @@ test('recursive extends with yaml file', async t => {
104146
t.deepEqual(actual, {
105147
formatter: '@commitlint/format',
106148
extends: ['./first-extended'],
149+
plugins: {},
107150
rules: {
108151
zero: 0,
109152
one: 1,
@@ -119,6 +162,7 @@ test('recursive extends with js file', async t => {
119162
t.deepEqual(actual, {
120163
formatter: '@commitlint/format',
121164
extends: ['./first-extended'],
165+
plugins: {},
122166
rules: {
123167
zero: 0,
124168
one: 1,
@@ -134,6 +178,7 @@ test('recursive extends with package.json file', async t => {
134178
t.deepEqual(actual, {
135179
formatter: '@commitlint/format',
136180
extends: ['./first-extended'],
181+
plugins: {},
137182
rules: {
138183
zero: 0,
139184
one: 1,
@@ -169,6 +214,7 @@ test('ignores unknow keys', async t => {
169214
t.deepEqual(actual, {
170215
formatter: '@commitlint/format',
171216
extends: [],
217+
plugins: {},
172218
rules: {
173219
foo: 'bar',
174220
baz: 'bar'
@@ -183,6 +229,7 @@ test('ignores unknow keys recursively', async t => {
183229
t.deepEqual(actual, {
184230
formatter: '@commitlint/format',
185231
extends: ['./one'],
232+
plugins: {},
186233
rules: {
187234
zero: 0,
188235
one: 1
@@ -200,6 +247,7 @@ test('find up from given cwd', async t => {
200247
t.deepEqual(actual, {
201248
formatter: '@commitlint/format',
202249
extends: [],
250+
plugins: {},
203251
rules: {
204252
child: true,
205253
inner: false,
@@ -216,6 +264,7 @@ test('find up config from outside current git repo', async t => {
216264
t.deepEqual(actual, {
217265
formatter: '@commitlint/format',
218266
extends: [],
267+
plugins: {},
219268
rules: {
220269
child: false,
221270
inner: false,
@@ -231,6 +280,7 @@ test('respects formatter option', async t => {
231280
t.deepEqual(actual, {
232281
formatter: 'commitlint-junit',
233282
extends: [],
283+
plugins: {},
234284
rules: {}
235285
});
236286
});
@@ -242,6 +292,7 @@ test('resolves formatter relative from config directory', async t => {
242292
t.deepEqual(actual, {
243293
formatter: resolveFrom(cwd, './formatters/custom.js'),
244294
extends: [],
295+
plugins: {},
245296
rules: {}
246297
});
247298
});
@@ -253,6 +304,7 @@ test('returns formatter name when unable to resolve from config directory', asyn
253304
t.deepEqual(actual, {
254305
formatter: './doesnt/exists.js',
255306
extends: [],
307+
plugins: {},
256308
rules: {}
257309
});
258310
});
+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import path from 'path';
2+
import chalk from 'chalk';
3+
import {normalizePackageName, getShorthandName} from './pluginNaming';
4+
5+
export default function loadPlugin(plugins, pluginName, debug = false) {
6+
const longName = normalizePackageName(pluginName);
7+
const shortName = getShorthandName(longName);
8+
let plugin = null;
9+
10+
if (pluginName.match(/\s+/u)) {
11+
const whitespaceError = new Error(
12+
`Whitespace found in plugin name '${pluginName}'`
13+
);
14+
15+
whitespaceError.messageTemplate = 'whitespace-found';
16+
whitespaceError.messageData = {
17+
pluginName: longName
18+
};
19+
throw whitespaceError;
20+
}
21+
22+
const pluginKey = longName === pluginName ? shortName : pluginName;
23+
24+
if (!plugins[pluginKey]) {
25+
try {
26+
plugin = require(longName);
27+
} catch (pluginLoadErr) {
28+
try {
29+
// Check whether the plugin exists
30+
require.resolve(longName);
31+
} catch (missingPluginErr) {
32+
// If the plugin can't be resolved, display the missing plugin error (usually a config or install error)
33+
console.error(chalk.red(`Failed to load plugin ${longName}.`));
34+
missingPluginErr.message = `Failed to load plugin ${pluginName}: ${
35+
missingPluginErr.message
36+
}`;
37+
missingPluginErr.messageTemplate = 'plugin-missing';
38+
missingPluginErr.messageData = {
39+
pluginName: longName,
40+
commitlintPath: path.resolve(__dirname, '../..')
41+
};
42+
throw missingPluginErr;
43+
}
44+
45+
// Otherwise, the plugin exists and is throwing on module load for some reason, so print the stack trace.
46+
throw pluginLoadErr;
47+
}
48+
49+
// This step is costly, so skip if debug is disabled
50+
if (debug) {
51+
const resolvedPath = require.resolve(longName);
52+
53+
let version = null;
54+
55+
try {
56+
version = require(`${longName}/package.json`).version;
57+
} catch (e) {
58+
// Do nothing
59+
}
60+
61+
const loadedPluginAndVersion = version
62+
? `${longName}@${version}`
63+
: `${longName}, version unknown`;
64+
65+
console.log(
66+
chalk.blue(
67+
`Loaded plugin ${pluginName} (${loadedPluginAndVersion}) (from ${resolvedPath})`
68+
)
69+
);
70+
}
71+
72+
plugins[pluginKey] = plugin;
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import test from 'ava';
2+
const proxyquire = require('proxyquire')
3+
.noCallThru()
4+
.noPreserveCache();
5+
6+
test.beforeEach(t => {
7+
const plugins = {};
8+
const plugin = {};
9+
const scopedPlugin = {};
10+
const stubbedLoadPlugin = proxyquire('./loadPlugin', {
11+
'commitlint-plugin-example': plugin,
12+
'@scope/commitlint-plugin-example': scopedPlugin
13+
});
14+
t.context.data = {
15+
plugins,
16+
plugin,
17+
scopedPlugin,
18+
stubbedLoadPlugin
19+
};
20+
});
21+
22+
test('should load a plugin when referenced by short name', t => {
23+
const {stubbedLoadPlugin, plugins, plugin} = t.context.data;
24+
stubbedLoadPlugin(plugins, 'example');
25+
t.is(plugins['example'], plugin);
26+
});
27+
28+
test('should load a plugin when referenced by long name', t => {
29+
const {stubbedLoadPlugin, plugins, plugin} = t.context.data;
30+
stubbedLoadPlugin(plugins, 'commitlint-plugin-example');
31+
t.is(plugins['example'], plugin);
32+
});
33+
34+
test('should throw an error when a plugin has whitespace', t => {
35+
const {stubbedLoadPlugin, plugins} = t.context.data;
36+
t.throws(() => {
37+
stubbedLoadPlugin(plugins, 'whitespace ');
38+
}, /Whitespace found in plugin name 'whitespace '/u);
39+
t.throws(() => {
40+
stubbedLoadPlugin(plugins, 'whitespace\t');
41+
}, /Whitespace found in plugin name/u);
42+
t.throws(() => {
43+
stubbedLoadPlugin(plugins, 'whitespace\n');
44+
}, /Whitespace found in plugin name/u);
45+
t.throws(() => {
46+
stubbedLoadPlugin(plugins, 'whitespace\r');
47+
}, /Whitespace found in plugin name/u);
48+
});
49+
50+
test("should throw an error when a plugin doesn't exist", t => {
51+
const {stubbedLoadPlugin, plugins} = t.context.data;
52+
t.throws(() => {
53+
stubbedLoadPlugin(plugins, 'nonexistentplugin');
54+
}, /Failed to load plugin/u);
55+
});
56+
57+
test('should load a scoped plugin when referenced by short name', t => {
58+
const {stubbedLoadPlugin, plugins, scopedPlugin} = t.context.data;
59+
stubbedLoadPlugin(plugins, '@scope/example');
60+
t.is(plugins['@scope/example'], scopedPlugin);
61+
});
62+
63+
test('should load a scoped plugin when referenced by long name', t => {
64+
const {stubbedLoadPlugin, plugins, scopedPlugin} = t.context.data;
65+
stubbedLoadPlugin(plugins, '@scope/commitlint-plugin-example');
66+
t.is(plugins['@scope/example'], scopedPlugin);
67+
});
68+
69+
/* when referencing a scope plugin and omitting @scope/ */
70+
test("should load a scoped plugin when referenced by short name, but should not get the plugin if '@scope/' is omitted", t => {
71+
const {stubbedLoadPlugin, plugins} = t.context.data;
72+
stubbedLoadPlugin(plugins, '@scope/example');
73+
t.is(plugins['example'], undefined);
74+
});
75+
76+
test("should load a scoped plugin when referenced by long name, but should not get the plugin if '@scope/' is omitted", t => {
77+
const {stubbedLoadPlugin, plugins} = t.context.data;
78+
stubbedLoadPlugin(plugins, '@scope/commitlint-plugin-example');
79+
t.is(plugins['example'], undefined);
80+
});
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// largely adapted from eslint's plugin system
2+
const NAMESPACE_REGEX = /^@.*\//iu;
3+
// In eslint this is a parameter - we don't need to support the extra options
4+
const prefix = 'commitlint-plugin';
5+
6+
// Replace Windows with posix style paths
7+
function convertPathToPosix(filepath) {
8+
const normalizedFilepath = path.normalize(filepath);
9+
const posixFilepath = normalizedFilepath.replace(/\\/gu, '/');
10+
11+
return posixFilepath;
12+
}
13+
14+
/**
15+
* Brings package name to correct format based on prefix
16+
* @param {string} name The name of the package.
17+
* @returns {string} Normalized name of the package
18+
* @private
19+
*/
20+
export function normalizePackageName(name) {
21+
let normalizedName = name;
22+
23+
/**
24+
* On Windows, name can come in with Windows slashes instead of Unix slashes.
25+
* Normalize to Unix first to avoid errors later on.
26+
* https://github.com/eslint/eslint/issues/5644
27+
*/
28+
if (normalizedName.indexOf('\\') > -1) {
29+
normalizedName = convertPathToPosix(normalizedName);
30+
}
31+
32+
if (normalizedName.charAt(0) === '@') {
33+
/**
34+
* it's a scoped package
35+
* package name is the prefix, or just a username
36+
*/
37+
const scopedPackageShortcutRegex = new RegExp(
38+
`^(@[^/]+)(?:/(?:${prefix})?)?$`,
39+
'u'
40+
),
41+
scopedPackageNameRegex = new RegExp(`^${prefix}(-|$)`, 'u');
42+
43+
if (scopedPackageShortcutRegex.test(normalizedName)) {
44+
normalizedName = normalizedName.replace(
45+
scopedPackageShortcutRegex,
46+
`$1/${prefix}`
47+
);
48+
} else if (!scopedPackageNameRegex.test(normalizedName.split('/')[1])) {
49+
/**
50+
* for scoped packages, insert the prefix after the first / unless
51+
* the path is already @scope/eslint or @scope/eslint-xxx-yyy
52+
*/
53+
normalizedName = normalizedName.replace(
54+
/^@([^/]+)\/(.*)$/u,
55+
`@$1/${prefix}-$2`
56+
);
57+
}
58+
} else if (normalizedName.indexOf(`${prefix}-`) !== 0) {
59+
normalizedName = `${prefix}-${normalizedName}`;
60+
}
61+
62+
return normalizedName;
63+
}
64+
65+
/**
66+
* Removes the prefix from a fullname.
67+
* @param {string} fullname The term which may have the prefix.
68+
* @returns {string} The term without prefix.
69+
*/
70+
export function getShorthandName(fullname) {
71+
if (fullname[0] === '@') {
72+
let matchResult = new RegExp(`^(@[^/]+)/${prefix}$`, 'u').exec(fullname);
73+
74+
if (matchResult) {
75+
return matchResult[1];
76+
}
77+
78+
matchResult = new RegExp(`^(@[^/]+)/${prefix}-(.+)$`, 'u').exec(fullname);
79+
if (matchResult) {
80+
return `${matchResult[1]}/${matchResult[2]}`;
81+
}
82+
} else if (fullname.startsWith(`${prefix}-`)) {
83+
return fullname.slice(prefix.length + 1);
84+
}
85+
86+
return fullname;
87+
}
88+
89+
/**
90+
* Gets the scope (namespace) of a term.
91+
* @param {string} term The term which may have the namespace.
92+
* @returns {string} The namepace of the term if it has one.
93+
*/
94+
export function getNamespaceFromTerm(term) {
95+
const match = term.match(NAMESPACE_REGEX);
96+
97+
return match ? match[0] : '';
98+
}

‎docs/reference-plugins.md

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Working with Plugins
2+
3+
Our plugin implementation is based off of [eslint's plugin implementation](https://github.com/eslint/eslint/blob/5018378131fd5190bbccca902c0cf4276ee1581a/lib/config/plugins.js);
4+
Each plugin is an npm module with a name in the format of `commitlint-plugin-<plugin-name>`, such as `commitlint-plugin-jquery`. You can also use scoped packages in the format of `@<scope>/commitlint-plugin-<plugin-name>` such as `@jquery/commitlint-plugin-jquery`.
5+
6+
### Rules in Plugins
7+
8+
Plugins can expose additional rules for use in commitlint. To do so, the plugin must export a `rules` object containing a key-value mapping of rule ID to rule. The rule ID does not have to follow any naming convention (so it can just be `dollar-sign`, for instance).
9+
10+
```js
11+
module.exports = {
12+
rules: {
13+
"dollar-sign": function(parsed, when, value) {
14+
// rule implementation ...
15+
}
16+
}
17+
};
18+
```
19+
20+
To use the rule in commitlint, you would use the unprefixed plugin name, followed by a slash, followed by the rule name. So if this plugin were named `commitlint-plugin-myplugin`, then in your configuration you'd refer to the rule by the name `myplugin/dollar-sign`. Example: `"rules": {"myplugin/dollar-sign": 2}`.
21+
22+
### Peer Dependency
23+
24+
To make clear that the plugin requires commitlint to work correctly you have to declare commitlint as a `peerDependency` in your `package.json`.
25+
The plugin support was introduced in commitlint version `7.6.0`. Ensure the `peerDependency` points to @commitlint `7.6.0` or later.
26+
27+
```json
28+
{
29+
"peerDependencies": {
30+
"@commitlint/lint": ">=7.6.0"
31+
}
32+
}
33+
```
34+
35+
## Share Plugins
36+
37+
In order to make your plugin available to the community you have to publish it on npm.
38+
39+
Recommended keywords:
40+
41+
* `commitlint`
42+
* `commitlintplugin`
43+
44+
Add these keywords into your `package.json` file to make it easy for others to find.
45+
46+
## Further Reading
47+
48+
* [npm Developer Guide](https://docs.npmjs.com/misc/developers)

0 commit comments

Comments
 (0)
Please sign in to comment.