diff --git a/@commitlint/cli/fixtures/issue-prefixes/commitlint.config.js b/@commitlint/cli/fixtures/issue-prefixes/commitlint.config.js new file mode 100644 index 0000000000..c07ee41eb6 --- /dev/null +++ b/@commitlint/cli/fixtures/issue-prefixes/commitlint.config.js @@ -0,0 +1,10 @@ +module.exports = { + rules: { + 'references-empty': [2, 'never'] + }, + parserPreset: { + parserOpts: { + issuePrefixes: ['REF-'] + } + } +}; diff --git a/@commitlint/cli/fixtures/parser-preset/commitlint.config.js b/@commitlint/cli/fixtures/parser-preset/commitlint.config.js index eebcf63055..c37964c6f5 100644 --- a/@commitlint/cli/fixtures/parser-preset/commitlint.config.js +++ b/@commitlint/cli/fixtures/parser-preset/commitlint.config.js @@ -1,7 +1,5 @@ module.exports = { - parserOpts: { - parserPreset: './parser-preset' - }, + parserPreset: './parser-preset', rules: { 'type-enum': [2, 'always', ['type']], 'scope-enum': [2, 'always', ['scope']], diff --git a/@commitlint/cli/src/cli.js b/@commitlint/cli/src/cli.js index ece9b3cbd9..2b0b9d8c10 100755 --- a/@commitlint/cli/src/cli.js +++ b/@commitlint/cli/src/cli.js @@ -96,17 +96,17 @@ async function main(options) { throw err; } - return Promise.all( - messages.map(async message => { - const loaded = await core.load(getSeed(flags), {cwd: flags.cwd}); - const parserOpts = selectParserOpts(loaded.parserPreset); - const opts = parserOpts ? {parserOpts} : {parserOpts: {}}; + const loaded = await core.load(getSeed(flags), {cwd: flags.cwd}); + const parserOpts = selectParserOpts(loaded.parserPreset); + const opts = parserOpts ? {parserOpts} : {parserOpts: {}}; - // Strip comments if reading from `.git/COMMIT_EDIT_MSG` - if (range.edit) { - opts.parserOpts.commentChar = '#'; - } + // Strip comments if reading from `.git/COMMIT_EDIT_MSG` + if (range.edit) { + opts.parserOpts.commentChar = '#'; + } + return Promise.all( + messages.map(async message => { const report = await core.lint(message, loaded.rules, opts); const formatted = core.format(report, {color: flags.color}); @@ -182,13 +182,11 @@ function selectParserOpts(parserPreset) { return undefined; } - const opts = parserPreset.opts; - - if (typeof opts !== 'object') { + if (typeof parserPreset.parserOpts !== 'object') { return undefined; } - return opts.parserOpts; + return parserPreset.parserOpts; } // Catch unhandled rejections globally diff --git a/@commitlint/cli/src/cli.test.js b/@commitlint/cli/src/cli.test.js index 4567afdf13..9ca1b98ae2 100644 --- a/@commitlint/cli/src/cli.test.js +++ b/@commitlint/cli/src/cli.test.js @@ -182,6 +182,12 @@ test('should handle --amend with signoff', async () => { await execa('git', ['commit', '--amend', '--no-edit'], {cwd}); }); +test('should handle linting with issue prefixes', async t => { + const cwd = await git.bootstrap('fixtures/issue-prefixes'); + const actual = await cli([], {cwd})('foobar REF-1'); + t.is(actual.code, 0); +}); + async function writePkg(payload, options) { const pkgPath = path.join(options.cwd, 'package.json'); const pkg = JSON.parse(await sander.readFile(pkgPath)); diff --git a/@commitlint/core/src/lint.js b/@commitlint/core/src/lint.js index f0d375fe5c..5987f91fff 100644 --- a/@commitlint/core/src/lint.js +++ b/@commitlint/core/src/lint.js @@ -1,3 +1,4 @@ +import util from 'util'; import isIgnored from '@commitlint/is-ignored'; import parse from '@commitlint/parse'; import implementations from '@commitlint/rules'; @@ -16,6 +17,84 @@ export default async (message, rules = {}, opts = {}) => { // Parse the commit message const parsed = await parse(message, undefined, opts.parserOpts); + // Find invalid rules configs + const missing = Object.keys(rules).filter( + name => typeof implementations[name] !== 'function' + ); + + if (missing.length > 0) { + const names = Object.keys(implementations); + throw new RangeError( + `Found missing rule names: ${missing.join( + ', ' + )}. Supported rule names are: ${names.join(', ')}` + ); + } + + const invalid = entries(rules) + .map(([name, config]) => { + if (!Array.isArray(config)) { + return new Error( + `config for rule ${name} must be array, received ${util.inspect( + config + )} of type ${typeof config}` + ); + } + + const [level, when] = config; + + if (typeof level !== 'number' || isNaN(level)) { + return new Error( + `level for rule ${name} must be number, received ${util.inspect( + level + )} of type ${typeof level}` + ); + } + + if (level === 0 && config.length === 1) { + return null; + } + + if (config.length !== 2 && config.length !== 3) { + return new Error( + `config for rule ${name} must be 2 or 3 items long, received ${util.inspect( + config + )} of length ${config.length}` + ); + } + + if (level < 0 || level > 2) { + return new RangeError( + `level for rule ${name} must be between 0 and 2, received ${util.inspect( + level + )}` + ); + } + + if (typeof when !== 'string') { + return new Error( + `condition for rule ${name} must be string, received ${util.inspect( + when + )} of type ${typeof when}` + ); + } + + if (when !== 'never' && when !== 'always') { + return new Error( + `condition for rule ${name} must be "always" or "never", received ${util.inspect( + when + )}` + ); + } + + return null; + }) + .filter(item => item instanceof Error); + + if (invalid.length > 0) { + throw new Error(invalid.map(i => i.message).join('\n')); + } + // Validate against all rules const results = entries(rules) .filter(entry => { @@ -32,6 +111,7 @@ export default async (message, rules = {}, opts = {}) => { } const rule = implementations[name]; + const [valid, message] = rule(parsed, when, value); return { diff --git a/@commitlint/core/src/lint.test.js b/@commitlint/core/src/lint.test.js index dd87c166bd..93cd7a36e6 100644 --- a/@commitlint/core/src/lint.test.js +++ b/@commitlint/core/src/lint.test.js @@ -52,3 +52,133 @@ test('positive on stub message and opts', async t => { ); t.true(actual.valid); }); + +test('throws for invalid rule names', async t => { + const error = await t.throws( + lint('foo', {foo: [2, 'always'], bar: [1, 'never']}) + ); + + t.is(error.message.indexOf('Found missing rule names: foo, bar'), 0); +}); + +test('throws for invalid rule config', async t => { + const error = await t.throws( + lint('type(scope): foo', { + 'type-enum': 1, + 'scope-enum': {0: 2, 1: 'never', 2: ['foo'], length: 3} + }) + ); + + t.true(error.message.indexOf('type-enum must be array') > -1); + t.true(error.message.indexOf('scope-enum must be array') > -1); +}); + +test('allows disable shorthand', async t => { + await t.notThrows(lint('foo', {'type-enum': [0], 'scope-enum': [0]})); +}); + +test('throws for rule with invalid length', async t => { + const error = await t.throws( + lint('type(scope): foo', {'scope-enum': [1, 2, 3, 4]}) + ); + + t.true(error.message.indexOf('scope-enum must be 2 or 3 items long') > -1); +}); + +test('throws for rule with invalid level', async t => { + const error = await t.throws( + lint('type(scope): foo', { + 'type-enum': ['2', 'always'], + 'header-max-length': [{}, 'always'] + }) + ); + + t.true(error.message.indexOf('rule type-enum must be number') > -1); + t.true(error.message.indexOf('rule type-enum must be number') > -1); +}); + +test('throws for rule with out of range level', async t => { + const error = await t.throws( + lint('type(scope): foo', { + 'type-enum': [-1, 'always'], + 'header-max-length': [3, 'always'] + }) + ); + + t.true(error.message.indexOf('rule type-enum must be between 0 and 2') > -1); + t.true(error.message.indexOf('rule type-enum must be between 0 and 2') > -1); +}); + +test('throws for rule with invalid condition', async t => { + const error = await t.throws( + lint('type(scope): foo', { + 'type-enum': [1, 2], + 'header-max-length': [1, {}] + }) + ); + + t.true(error.message.indexOf('type-enum must be string') > -1); + t.true(error.message.indexOf('header-max-length must be string') > -1); +}); + +test('throws for rule with out of range condition', async t => { + const error = await t.throws( + lint('type(scope): foo', { + 'type-enum': [1, 'foo'], + 'header-max-length': [1, 'bar'] + }) + ); + + t.true(error.message.indexOf('type-enum must be "always" or "never"') > -1); + t.true( + error.message.indexOf('header-max-length must be "always" or "never"') > -1 + ); +}); + +test('succeds for issue', async t => { + const report = await lint('somehting #1', { + 'references-empty': [2, 'never'] + }); + + t.true(report.valid); +}); + +test('fails for issue', async t => { + const report = await lint('somehting #1', { + 'references-empty': [2, 'always'] + }); + + t.false(report.valid); +}); + +test('succeds for custom issue prefix', async t => { + const report = await lint( + 'somehting REF-1', + { + 'references-empty': [2, 'never'] + }, + { + parserOpts: { + issuePrefixes: ['REF-'] + } + } + ); + + t.true(report.valid); +}); + +test('fails for custom issue prefix', async t => { + const report = await lint( + 'somehting #1', + { + 'references-empty': [2, 'never'] + }, + { + parserOpts: { + issuePrefixes: ['REF-'] + } + } + ); + + t.false(report.valid); +}); diff --git a/@commitlint/core/src/load.js b/@commitlint/core/src/load.js index 59cee9c6b1..0750b2e8f3 100644 --- a/@commitlint/core/src/load.js +++ b/@commitlint/core/src/load.js @@ -26,7 +26,7 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => { config.parserPreset = { name: config.parserPreset, path: resolvedParserPreset, - opts: require(resolvedParserPreset) + parserOpts: (await require(resolvedParserPreset)).parserOpts }; } @@ -38,13 +38,14 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => { }); const preset = valid(mergeWith(extended, config, w)); - // Await parser-preset if applicable if ( typeof preset.parserPreset === 'object' && - typeof preset.parserPreset.opts === 'object' + typeof preset.parserPreset.parserOpts === 'object' && + typeof preset.parserPreset.parserOpts.then === 'function' ) { - preset.parserPreset.opts = await preset.parserPreset.opts; + preset.parserPreset.parserOpts = (await preset.parserPreset + .parserOpts).parserOpts; } // Execute rule config functions if needed diff --git a/@commitlint/core/src/load.test.js b/@commitlint/core/src/load.test.js index 3fa9da29bc..3d9569aa09 100644 --- a/@commitlint/core/src/load.test.js +++ b/@commitlint/core/src/load.test.js @@ -24,12 +24,9 @@ test('uses seed with parserPreset', async t => { }, {cwd} ); - t.is(actual.name, './conventional-changelog-custom'); - t.deepEqual(actual.opts, { - parserOpts: { - headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/ - } + t.deepEqual(actual.parserOpts, { + headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/ }); }); @@ -134,25 +131,19 @@ test('recursive extends with package.json file', async t => { test('parser preset overwrites completely instead of merging', async t => { const cwd = await git.bootstrap('fixtures/parser-preset-override'); const actual = await load({}, {cwd}); - t.is(actual.parserPreset.name, './custom'); - t.is(typeof actual.parserPreset.opts, 'object'); - t.deepEqual(actual.parserPreset.opts, { - b: 'b', - parserOpts: { - headerPattern: /.*/ - } + t.deepEqual(actual.parserPreset.parserOpts, { + headerPattern: /.*/ }); }); test('recursive extends with parserPreset', async t => { const cwd = await git.bootstrap('fixtures/recursive-parser-preset'); const actual = await load({}, {cwd}); - t.is(actual.parserPreset.name, './conventional-changelog-custom'); - t.is(typeof actual.parserPreset.opts, 'object'); + t.is(typeof actual.parserPreset.parserOpts, 'object'); t.deepEqual( - actual.parserPreset.opts.parserOpts.headerPattern, + actual.parserPreset.parserOpts.headerPattern, /^(\w*)(?:\((.*)\))?-(.*)$/ ); }); diff --git a/@commitlint/parse/README.md b/@commitlint/parse/README.md new file mode 100644 index 0000000000..6914b3be8d --- /dev/null +++ b/@commitlint/parse/README.md @@ -0,0 +1,29 @@ +> Parse commit messages to structured data + +# @commitlint/parse + +## Install + +``` +npm install --save @commitlint/parse +``` + +## Use + +```js +const parse = require('@commitlint/parse'); +``` + +## API + +### parse(message: string, parser: Function, parserOpts: Object) + +* **message**: Commit message to parser +* **parser**: Sync parser function to use. Defaults to `sync` of `conventional-commits-parser` +* **parserOpts**: Options to pass to `parser` + ```js + { + commentChar: null, // character indicating comment lines + issuePrefixes: ['#'] // prefix characters for issue references + } + ``` diff --git a/@commitlint/parse/src/index.test.js b/@commitlint/parse/src/index.test.js index 633898643b..f4afab09b8 100644 --- a/@commitlint/parse/src/index.test.js +++ b/@commitlint/parse/src/index.test.js @@ -140,3 +140,20 @@ test('parses references leading subject', async t => { const {references: [actual]} = await parse(message, undefined, opts); t.is(actual.issue, '1'); }); + +test('parses custom references', async t => { + const message = '#1 some subject PREFIX-2'; + const {references} = await parse(message, undefined, { + issuePrefixes: ['PREFIX-'] + }); + + t.falsy(references.find(ref => ref.issue === '1')); + t.deepEqual(references.find(ref => ref.issue === '2'), { + action: null, + issue: '2', + owner: null, + prefix: 'PREFIX-', + raw: '#1 some subject PREFIX-2', + repository: null + }); +}); diff --git a/@commitlint/resolve-extends/src/index.js b/@commitlint/resolve-extends/src/index.js index 891f134ebb..40bb74bd8f 100644 --- a/@commitlint/resolve-extends/src/index.js +++ b/@commitlint/resolve-extends/src/index.js @@ -48,13 +48,12 @@ function loadExtends(config = {}, context = {}) { typeof c.parserPreset === 'string' ) { const resolvedParserPreset = resolveFrom(cwd, c.parserPreset); - const parserPreset = { name: c.parserPreset, path: `./${path.relative(process.cwd(), resolvedParserPreset)}` .split(path.sep) .join('/'), - opts: require(resolvedParserPreset) + parserOpts: require(resolvedParserPreset) }; ctx.parserPreset = parserPreset; diff --git a/@commitlint/rules/src/references-empty.test.js b/@commitlint/rules/src/references-empty.test.js index a491600b32..a414362999 100644 --- a/@commitlint/rules/src/references-empty.test.js +++ b/@commitlint/rules/src/references-empty.test.js @@ -7,7 +7,8 @@ const messages = { plain: 'foo: bar', comment: 'foo: baz\n#1 Comment', reference: '#comment\nfoo: baz \nCloses #1', - references: '#comment\nfoo: bar \nCloses #1, #2, #3' + references: '#comment\nfoo: bar \nCloses #1, #2, #3', + prefix: 'bar REF-1234' }; const opts = (async () => { @@ -24,7 +25,10 @@ const parsed = { reference: (async () => parse(messages.reference, undefined, (await opts).parserOpts))(), references: (async () => - parse(messages.references, undefined, (await opts).parserOpts))() + parse(messages.references, undefined, (await opts).parserOpts))(), + prefix: parse(messages.prefix, undefined, { + issuePrefixes: ['REF-'] + }) }; test('defaults to never and fails for plain', async t => { @@ -74,3 +78,9 @@ test('fails for references with always', async t => { const expected = false; t.is(actual, expected); }); + +test('succeeds for custom references with always', async t => { + const [actual] = referencesEmpty(await parsed.prefix, 'never'); + const expected = true; + t.is(actual, expected); +});