diff --git a/@commitlint/cli/src/cli.js b/@commitlint/cli/src/cli.js index 49afd48258..58c499944a 100755 --- a/@commitlint/cli/src/cli.js +++ b/@commitlint/cli/src/cli.js @@ -139,7 +139,16 @@ async function main(options) { const loadOpts = {cwd: flags.cwd, file: flags.config}; const loaded = await load(getSeed(flags), loadOpts); const parserOpts = selectParserOpts(loaded.parserPreset); - const opts = parserOpts ? {parserOpts} : {parserOpts: {}}; + const opts = { + parserOpts: {}, + plugins: {} + }; + if (parserOpts) { + opts.parserOpts = parserOpts; + } + if (loaded.plugins) { + opts.plugins = loaded.plugins; + } const format = loadFormatter(loaded, flags); // Strip comments if reading from `.git/COMMIT_EDIT_MSG` diff --git a/@commitlint/lint/package.json b/@commitlint/lint/package.json index 027fedcb8b..03b95625b2 100644 --- a/@commitlint/lint/package.json +++ b/@commitlint/lint/package.json @@ -67,7 +67,8 @@ "cross-env": "5.1.1", "execa": "0.9.0", "globby": "8.0.1", - "rimraf": "2.6.1" + "rimraf": "2.6.1", + "proxyquire": "2.1.0" }, "dependencies": { "@commitlint/is-ignored": "^7.5.1", diff --git a/@commitlint/lint/src/index.js b/@commitlint/lint/src/index.js index 2d2a0527e0..bb3148aaaa 100644 --- a/@commitlint/lint/src/index.js +++ b/@commitlint/lint/src/index.js @@ -2,7 +2,7 @@ import util from 'util'; import isIgnored from '@commitlint/is-ignored'; import parse from '@commitlint/parse'; import implementations from '@commitlint/rules'; -import {toPairs} from 'lodash'; +import {toPairs, values} from 'lodash'; const buildCommitMesage = ({header, body, footer}) => { let message = header; @@ -27,13 +27,24 @@ export default async (message, rules = {}, opts = {}) => { // Parse the commit message const parsed = await parse(message, undefined, opts.parserOpts); + const mergedImplementations = Object.assign({}, implementations); + if (opts.plugins) { + values(opts.plugins).forEach(plugin => { + if (plugin.rules) { + Object.keys(plugin.rules).forEach(ruleKey => { + mergedImplementations[ruleKey] = plugin.rules[ruleKey]; + }); + } + }); + } + // Find invalid rules configs const missing = Object.keys(rules).filter( - name => typeof implementations[name] !== 'function' + name => typeof mergedImplementations[name] !== 'function' ); if (missing.length > 0) { - const names = Object.keys(implementations); + const names = Object.keys(mergedImplementations); throw new RangeError( `Found invalid rule names: ${missing.join( ', ' @@ -120,7 +131,7 @@ export default async (message, rules = {}, opts = {}) => { return null; } - const rule = implementations[name]; + const rule = mergedImplementations[name]; const [valid, message] = rule(parsed, when, value); diff --git a/@commitlint/lint/src/index.test.js b/@commitlint/lint/src/index.test.js index 9152438b73..9e1cd3bd95 100644 --- a/@commitlint/lint/src/index.test.js +++ b/@commitlint/lint/src/index.test.js @@ -184,6 +184,46 @@ test('fails for custom issue prefix', async t => { t.false(report.valid); }); +test('fails for custom plugin rule', async t => { + const report = await lint( + 'somehting #1', + { + 'plugin-rule': [2, 'never'] + }, + { + plugins: { + 'plugin-example': { + rules: { + 'plugin-rule': () => [false] + } + } + } + } + ); + + t.false(report.valid); +}); + +test('passes for custom plugin rule', async t => { + const report = await lint( + 'somehting #1', + { + 'plugin-rule': [2, 'never'] + }, + { + plugins: { + 'plugin-example': { + rules: { + 'plugin-rule': () => [true] + } + } + } + } + ); + + t.true(report.valid); +}); + test('returns original message only with commit header', async t => { const message = 'foo: bar'; const report = await lint(message); diff --git a/@commitlint/load/fixtures/extends-plugins/commitlint.config.js b/@commitlint/load/fixtures/extends-plugins/commitlint.config.js new file mode 100644 index 0000000000..07fe7273fe --- /dev/null +++ b/@commitlint/load/fixtures/extends-plugins/commitlint.config.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [], + plugins: ['example', '@scope/example'] +}; diff --git a/@commitlint/load/src/index.js b/@commitlint/load/src/index.js index 189f461ab0..9dd20b9388 100644 --- a/@commitlint/load/src/index.js +++ b/@commitlint/load/src/index.js @@ -4,10 +4,11 @@ import resolveExtends from '@commitlint/resolve-extends'; import cosmiconfig from 'cosmiconfig'; import {toPairs, merge, mergeWith, pick} from 'lodash'; import resolveFrom from 'resolve-from'; +import loadPlugin from './utils/loadPlugin'; const w = (a, b) => (Array.isArray(b) ? b : undefined); const valid = input => - pick(input, 'extends', 'rules', 'parserPreset', 'formatter'); + pick(input, 'extends', 'plugins', 'rules', 'parserPreset', 'formatter'); export default async (seed = {}, options = {cwd: process.cwd()}) => { const loaded = await loadConfig(options.cwd, options.file); @@ -16,8 +17,8 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => { // Merge passed config with file based options const config = valid(merge(loaded.config, seed)); const opts = merge( - {extends: [], rules: {}, formatter: '@commitlint/format'}, - pick(config, 'extends') + {extends: [], plugins: [], rules: {}, formatter: '@commitlint/format'}, + pick(config, 'extends', 'plugins') ); // Resolve parserPreset key @@ -55,6 +56,14 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => { resolveFrom.silent(base, config.formatter) || config.formatter; } + // resolve plugins + preset.plugins = {}; + if (config.plugins && config.plugins.length) { + config.plugins.forEach(pluginKey => { + loadPlugin(preset.plugins, pluginKey, process.env.DEBUG === 'true'); + }); + } + // Execute rule config functions if needed const executed = await Promise.all( ['rules'] diff --git a/@commitlint/load/src/index.test.js b/@commitlint/load/src/index.test.js index 854fa0f3b2..e1e97d7456 100644 --- a/@commitlint/load/src/index.test.js +++ b/@commitlint/load/src/index.test.js @@ -5,6 +5,10 @@ import resolveFrom from 'resolve-from'; import load from '.'; +const proxyquire = require('proxyquire') + .noCallThru() + .noPreserveCache(); + test('extends-empty should have no rules', async t => { const cwd = await git.bootstrap('fixtures/extends-empty'); const actual = await load({}, {cwd}); @@ -24,6 +28,41 @@ test('rules should be loaded from specify config file', async t => { t.is(actual.rules.foo, 'bar'); }); +test('plugins should be loaded from seed', async t => { + const plugin = {'@global': true}; + const scopedPlugin = {'@global': true}; + const stubbedLoad = proxyquire('.', { + 'commitlint-plugin-example': plugin, + '@scope/commitlint-plugin-example': scopedPlugin + }); + + const cwd = await git.bootstrap('fixtures/extends-empty'); + const actual = await stubbedLoad( + {plugins: ['example', '@scope/example']}, + {cwd} + ); + t.deepEqual(actual.plugins, { + example: plugin, + '@scope/example': scopedPlugin + }); +}); + +test('plugins should be loaded from config', async t => { + const plugin = {'@global': true}; + const scopedPlugin = {'@global': true}; + const stubbedLoad = proxyquire('.', { + 'commitlint-plugin-example': plugin, + '@scope/commitlint-plugin-example': scopedPlugin + }); + + const cwd = await git.bootstrap('fixtures/extends-plugins'); + const actual = await stubbedLoad({}, {cwd}); + t.deepEqual(actual.plugins, { + example: plugin, + '@scope/example': scopedPlugin + }); +}); + test('uses seed with parserPreset', async t => { const cwd = await git.bootstrap('fixtures/parser-preset'); const {parserPreset: actual} = await load( @@ -61,6 +100,7 @@ test('respects cwd option', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./second-extended'], + plugins: {}, rules: { one: 1, two: 2 @@ -74,6 +114,7 @@ test('recursive extends', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./first-extended'], + plugins: {}, rules: { zero: 0, one: 1, @@ -89,6 +130,7 @@ test('recursive extends with json file', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./first-extended'], + plugins: {}, rules: { zero: 0, one: 1, @@ -104,6 +146,7 @@ test('recursive extends with yaml file', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./first-extended'], + plugins: {}, rules: { zero: 0, one: 1, @@ -119,6 +162,7 @@ test('recursive extends with js file', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./first-extended'], + plugins: {}, rules: { zero: 0, one: 1, @@ -134,6 +178,7 @@ test('recursive extends with package.json file', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./first-extended'], + plugins: {}, rules: { zero: 0, one: 1, @@ -169,6 +214,7 @@ test('ignores unknow keys', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: [], + plugins: {}, rules: { foo: 'bar', baz: 'bar' @@ -183,6 +229,7 @@ test('ignores unknow keys recursively', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: ['./one'], + plugins: {}, rules: { zero: 0, one: 1 @@ -200,6 +247,7 @@ test('find up from given cwd', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: [], + plugins: {}, rules: { child: true, inner: false, @@ -216,6 +264,7 @@ test('find up config from outside current git repo', async t => { t.deepEqual(actual, { formatter: '@commitlint/format', extends: [], + plugins: {}, rules: { child: false, inner: false, @@ -231,6 +280,7 @@ test('respects formatter option', async t => { t.deepEqual(actual, { formatter: 'commitlint-junit', extends: [], + plugins: {}, rules: {} }); }); @@ -242,6 +292,7 @@ test('resolves formatter relative from config directory', async t => { t.deepEqual(actual, { formatter: resolveFrom(cwd, './formatters/custom.js'), extends: [], + plugins: {}, rules: {} }); }); @@ -253,6 +304,7 @@ test('returns formatter name when unable to resolve from config directory', asyn t.deepEqual(actual, { formatter: './doesnt/exists.js', extends: [], + plugins: {}, rules: {} }); }); diff --git a/@commitlint/load/src/utils/loadPlugin.js b/@commitlint/load/src/utils/loadPlugin.js new file mode 100644 index 0000000000..416e0a8784 --- /dev/null +++ b/@commitlint/load/src/utils/loadPlugin.js @@ -0,0 +1,74 @@ +import path from 'path'; +import chalk from 'chalk'; +import {normalizePackageName, getShorthandName} from './pluginNaming'; + +export default function loadPlugin(plugins, pluginName, debug = false) { + const longName = normalizePackageName(pluginName); + const shortName = getShorthandName(longName); + let plugin = null; + + if (pluginName.match(/\s+/u)) { + const whitespaceError = new Error( + `Whitespace found in plugin name '${pluginName}'` + ); + + whitespaceError.messageTemplate = 'whitespace-found'; + whitespaceError.messageData = { + pluginName: longName + }; + throw whitespaceError; + } + + const pluginKey = longName === pluginName ? shortName : pluginName; + + if (!plugins[pluginKey]) { + try { + plugin = require(longName); + } catch (pluginLoadErr) { + try { + // Check whether the plugin exists + require.resolve(longName); + } catch (missingPluginErr) { + // If the plugin can't be resolved, display the missing plugin error (usually a config or install error) + console.error(chalk.red(`Failed to load plugin ${longName}.`)); + missingPluginErr.message = `Failed to load plugin ${pluginName}: ${ + missingPluginErr.message + }`; + missingPluginErr.messageTemplate = 'plugin-missing'; + missingPluginErr.messageData = { + pluginName: longName, + commitlintPath: path.resolve(__dirname, '../..') + }; + throw missingPluginErr; + } + + // Otherwise, the plugin exists and is throwing on module load for some reason, so print the stack trace. + throw pluginLoadErr; + } + + // This step is costly, so skip if debug is disabled + if (debug) { + const resolvedPath = require.resolve(longName); + + let version = null; + + try { + version = require(`${longName}/package.json`).version; + } catch (e) { + // Do nothing + } + + const loadedPluginAndVersion = version + ? `${longName}@${version}` + : `${longName}, version unknown`; + + console.log( + chalk.blue( + `Loaded plugin ${pluginName} (${loadedPluginAndVersion}) (from ${resolvedPath})` + ) + ); + } + + plugins[pluginKey] = plugin; + } +} diff --git a/@commitlint/load/src/utils/loadPlugin.test.js b/@commitlint/load/src/utils/loadPlugin.test.js new file mode 100644 index 0000000000..9af7382f72 --- /dev/null +++ b/@commitlint/load/src/utils/loadPlugin.test.js @@ -0,0 +1,80 @@ +import test from 'ava'; +const proxyquire = require('proxyquire') + .noCallThru() + .noPreserveCache(); + +test.beforeEach(t => { + const plugins = {}; + const plugin = {}; + const scopedPlugin = {}; + const stubbedLoadPlugin = proxyquire('./loadPlugin', { + 'commitlint-plugin-example': plugin, + '@scope/commitlint-plugin-example': scopedPlugin + }); + t.context.data = { + plugins, + plugin, + scopedPlugin, + stubbedLoadPlugin + }; +}); + +test('should load a plugin when referenced by short name', t => { + const {stubbedLoadPlugin, plugins, plugin} = t.context.data; + stubbedLoadPlugin(plugins, 'example'); + t.is(plugins['example'], plugin); +}); + +test('should load a plugin when referenced by long name', t => { + const {stubbedLoadPlugin, plugins, plugin} = t.context.data; + stubbedLoadPlugin(plugins, 'commitlint-plugin-example'); + t.is(plugins['example'], plugin); +}); + +test('should throw an error when a plugin has whitespace', t => { + const {stubbedLoadPlugin, plugins} = t.context.data; + t.throws(() => { + stubbedLoadPlugin(plugins, 'whitespace '); + }, /Whitespace found in plugin name 'whitespace '/u); + t.throws(() => { + stubbedLoadPlugin(plugins, 'whitespace\t'); + }, /Whitespace found in plugin name/u); + t.throws(() => { + stubbedLoadPlugin(plugins, 'whitespace\n'); + }, /Whitespace found in plugin name/u); + t.throws(() => { + stubbedLoadPlugin(plugins, 'whitespace\r'); + }, /Whitespace found in plugin name/u); +}); + +test("should throw an error when a plugin doesn't exist", t => { + const {stubbedLoadPlugin, plugins} = t.context.data; + t.throws(() => { + stubbedLoadPlugin(plugins, 'nonexistentplugin'); + }, /Failed to load plugin/u); +}); + +test('should load a scoped plugin when referenced by short name', t => { + const {stubbedLoadPlugin, plugins, scopedPlugin} = t.context.data; + stubbedLoadPlugin(plugins, '@scope/example'); + t.is(plugins['@scope/example'], scopedPlugin); +}); + +test('should load a scoped plugin when referenced by long name', t => { + const {stubbedLoadPlugin, plugins, scopedPlugin} = t.context.data; + stubbedLoadPlugin(plugins, '@scope/commitlint-plugin-example'); + t.is(plugins['@scope/example'], scopedPlugin); +}); + +/* when referencing a scope plugin and omitting @scope/ */ +test("should load a scoped plugin when referenced by short name, but should not get the plugin if '@scope/' is omitted", t => { + const {stubbedLoadPlugin, plugins} = t.context.data; + stubbedLoadPlugin(plugins, '@scope/example'); + t.is(plugins['example'], undefined); +}); + +test("should load a scoped plugin when referenced by long name, but should not get the plugin if '@scope/' is omitted", t => { + const {stubbedLoadPlugin, plugins} = t.context.data; + stubbedLoadPlugin(plugins, '@scope/commitlint-plugin-example'); + t.is(plugins['example'], undefined); +}); diff --git a/@commitlint/load/src/utils/pluginNaming.js b/@commitlint/load/src/utils/pluginNaming.js new file mode 100644 index 0000000000..84dc2938f1 --- /dev/null +++ b/@commitlint/load/src/utils/pluginNaming.js @@ -0,0 +1,98 @@ +// largely adapted from eslint's plugin system +const NAMESPACE_REGEX = /^@.*\//iu; +// In eslint this is a parameter - we don't need to support the extra options +const prefix = 'commitlint-plugin'; + +// Replace Windows with posix style paths +function convertPathToPosix(filepath) { + const normalizedFilepath = path.normalize(filepath); + const posixFilepath = normalizedFilepath.replace(/\\/gu, '/'); + + return posixFilepath; +} + +/** + * Brings package name to correct format based on prefix + * @param {string} name The name of the package. + * @returns {string} Normalized name of the package + * @private + */ +export function normalizePackageName(name) { + let normalizedName = name; + + /** + * On Windows, name can come in with Windows slashes instead of Unix slashes. + * Normalize to Unix first to avoid errors later on. + * https://github.com/eslint/eslint/issues/5644 + */ + if (normalizedName.indexOf('\\') > -1) { + normalizedName = convertPathToPosix(normalizedName); + } + + if (normalizedName.charAt(0) === '@') { + /** + * it's a scoped package + * package name is the prefix, or just a username + */ + const scopedPackageShortcutRegex = new RegExp( + `^(@[^/]+)(?:/(?:${prefix})?)?$`, + 'u' + ), + scopedPackageNameRegex = new RegExp(`^${prefix}(-|$)`, 'u'); + + if (scopedPackageShortcutRegex.test(normalizedName)) { + normalizedName = normalizedName.replace( + scopedPackageShortcutRegex, + `$1/${prefix}` + ); + } else if (!scopedPackageNameRegex.test(normalizedName.split('/')[1])) { + /** + * for scoped packages, insert the prefix after the first / unless + * the path is already @scope/eslint or @scope/eslint-xxx-yyy + */ + normalizedName = normalizedName.replace( + /^@([^/]+)\/(.*)$/u, + `@$1/${prefix}-$2` + ); + } + } else if (normalizedName.indexOf(`${prefix}-`) !== 0) { + normalizedName = `${prefix}-${normalizedName}`; + } + + return normalizedName; +} + +/** + * Removes the prefix from a fullname. + * @param {string} fullname The term which may have the prefix. + * @returns {string} The term without prefix. + */ +export function getShorthandName(fullname) { + if (fullname[0] === '@') { + let matchResult = new RegExp(`^(@[^/]+)/${prefix}$`, 'u').exec(fullname); + + if (matchResult) { + return matchResult[1]; + } + + matchResult = new RegExp(`^(@[^/]+)/${prefix}-(.+)$`, 'u').exec(fullname); + if (matchResult) { + return `${matchResult[1]}/${matchResult[2]}`; + } + } else if (fullname.startsWith(`${prefix}-`)) { + return fullname.slice(prefix.length + 1); + } + + return fullname; +} + +/** + * Gets the scope (namespace) of a term. + * @param {string} term The term which may have the namespace. + * @returns {string} The namepace of the term if it has one. + */ +export function getNamespaceFromTerm(term) { + const match = term.match(NAMESPACE_REGEX); + + return match ? match[0] : ''; +} diff --git a/docs/reference-plugins.md b/docs/reference-plugins.md new file mode 100644 index 0000000000..3cc83dc139 --- /dev/null +++ b/docs/reference-plugins.md @@ -0,0 +1,48 @@ +# Working with Plugins + +Our plugin implementation is based off of [eslint's plugin implementation](https://github.com/eslint/eslint/blob/5018378131fd5190bbccca902c0cf4276ee1581a/lib/config/plugins.js); +Each plugin is an npm module with a name in the format of `commitlint-plugin-`, such as `commitlint-plugin-jquery`. You can also use scoped packages in the format of `@/commitlint-plugin-` such as `@jquery/commitlint-plugin-jquery`. + +### Rules in Plugins + +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). + +```js +module.exports = { + rules: { + "dollar-sign": function(parsed, when, value) { + // rule implementation ... + } + } +}; +``` + +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}`. + +### Peer Dependency + +To make clear that the plugin requires commitlint to work correctly you have to declare commitlint as a `peerDependency` in your `package.json`. +The plugin support was introduced in commitlint version `7.6.0`. Ensure the `peerDependency` points to @commitlint `7.6.0` or later. + +```json +{ + "peerDependencies": { + "@commitlint/lint": ">=7.6.0" + } +} +``` + +## Share Plugins + +In order to make your plugin available to the community you have to publish it on npm. + +Recommended keywords: + +* `commitlint` +* `commitlintplugin` + +Add these keywords into your `package.json` file to make it easy for others to find. + +## Further Reading + +* [npm Developer Guide](https://docs.npmjs.com/misc/developers)