diff --git a/.gitignore b/.gitignore index 3f3534c7e6..5e37fcfe85 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ node_modules # transpiled artifacts distribution + +.nyc_output diff --git a/.jsonlintrc b/.jsonlintrc deleted file mode 100644 index e5efb8b1f1..0000000000 --- a/.jsonlintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "validate": "http://json.schemastore.org/package" -} diff --git a/package.json b/package.json index 7101c5e896..b3cf575ac9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "start": "npm run watch", - "build": "babel source --out-dir distribution", + "build": "cross-env BABEL_ENV=production babel source --out-dir distribution", "watch": "npm run build -- --watch", "commit": "git-cz", "commitmsg": "npm run build && node distribution/cli.js --edit", @@ -19,8 +19,8 @@ "push": "git push && git push --tags && hub release create \"v$npm_package_version\" --message=\"v$npm_package_version\n$(conventional-changelog -p angular)\" && npm publish", "prepretest": "npm run lint", "pretest": "npm run deps", - "test": "ava", - "lint": "xo *.js", + "test": "nyc ava -c=4", + "lint": "xo", "deps": "npm run build && dependency-check . --missing && dependency-check . --extra --no-dev -i conventional-changelog-angular -i conventional-changelog-lint-config-angular", "commitlint": "node distribution/cli.js --from=HEAD~1", "preversion": "npm run build && npm test", @@ -30,6 +30,14 @@ "travis:lint:commits": "./scripts/lint:commits.sh" }, "ava": { + "files": [ + "source/**/*.test.js", + "!distribution/**/*" + ], + "source": [ + "source/**/*.js", + "!distribution/**/*" + ], "babel": "inherit", "require": [ "babel-register", @@ -37,6 +45,25 @@ ] }, "babel": { + "env": { + "development": { + "sourceMaps": "inline", + "plugins": [ + "add-module-exports", + "istanbul", + [ + "transform-runtime", + { + "polyfill": false, + "regenerator": true + } + ] + ] + }, + "production": { + "ignore": ["**/*.test.js"] + } + }, "presets": [ [ "env", @@ -59,6 +86,22 @@ ] ] }, + "nyc": { + "all": true, + "sourceMap": false, + "instrument": false, + "include": [ + "source/**/*.js" + ] + }, + "xo": { + "plugins": [ + "flow-check" + ], + "rules": { + "flow-check/check": "error" + } + }, "config": { "commitizen": { "path": "cz-conventional-changelog-lint" @@ -89,9 +132,11 @@ }, "license": "MIT", "devDependencies": { + "ansi-styles": "3.1.0", "ava": "0.18.2", "babel-cli": "6.18.0", "babel-plugin-add-module-exports": "0.2.1", + "babel-plugin-istanbul": "4.1.3", "babel-plugin-transform-runtime": "6.23.0", "babel-polyfill": "6.20.0", "babel-preset-env": "1.2.1", @@ -99,14 +144,20 @@ "babel-register": "6.24.1", "conventional-changelog-cli": "1.2.0", "conventional-recommended-bump": "0.3.0", + "cross-env": "5.0.1", "cz-conventional-changelog-lint": "0.1.3", "denodeify": "1.2.1", "dependency-check": "2.7.0", - "execa": "0.6.0", + "eslint-plugin-flow-check": "1.1.1", + "execa": "0.6.3", + "globby": "6.1.0", + "has-ansi": "3.0.0", + "import-from": "2.1.0", + "nyc": "10.3.2", "path-exists": "3.0.0", + "resolve-from": "3.0.0", "rimraf": "2.6.1", - "unexpected": "10.20.0", - "xo": "0.17.1" + "xo": "0.18.2" }, "dependencies": { "babel-polyfill": "6.20.0", diff --git a/source/cli.js b/source/cli.js index 297223c12c..71437c97b9 100644 --- a/source/cli.js +++ b/source/cli.js @@ -9,8 +9,7 @@ import stdin from 'get-stdin'; import pkg from '../package.json'; // eslint-disable-line import/extensions import help from './help'; -import lint from './'; -import {format, getConfiguration, getPreset, getMessages} from './'; // eslint-disable-line no-duplicate-imports +import lint, {format, getConfiguration, getPreset, getMessages} from './'; /** * Behavioural rules @@ -23,12 +22,8 @@ const rules = { }; const configuration = { - // flags of string type string: ['from', 'to', 'preset', 'extends'], - // flags of array type - // flags of bool type boolean: ['edit', 'help', 'version', 'quiet', 'color'], - // flag aliases alias: { c: 'color', e: 'edit', @@ -41,7 +36,7 @@ const configuration = { x: 'extends' }, description: { - color: 'toggle formatted output', + color: 'toggle colored output', edit: 'read last commit message found in ./git/COMMIT_EDITMSG', extends: 'array of shareable configurations to extend', from: 'lower end of the commit range to lint; applies if edit=false', @@ -49,7 +44,6 @@ const configuration = { to: 'upper end of the commit range to lint; applies if edit=false', quiet: 'toggle console output' }, - // flag defaults default: { color: true, edit: false, @@ -58,7 +52,6 @@ const configuration = { to: null, quiet: false }, - // fail on unknown unknown(arg) { throw new Error(`unknown flags: ${arg}`); } @@ -101,11 +94,7 @@ async function main(options) { ) }); - const formatted = format(report, { - color: flags.color, - signs: [' ', '⚠', '✖'], - colors: ['white', 'yellow', 'red'] - }); + const formatted = format(report, {color: flags.color}); if (!flags.quiet) { console.log(`${fmt.grey('⧗')} input: ${fmt.bold(commit.split('\n')[0])}`); diff --git a/source/index.js b/source/index.js index 607e0046d2..99bfc26c21 100644 --- a/source/index.js +++ b/source/index.js @@ -1,5 +1,3 @@ -import {merge} from 'lodash'; - import ruleFunctions from './rules'; import format from './library/format'; import getConfiguration from './library/get-configuration'; @@ -11,19 +9,16 @@ export {format, getConfiguration, getMessages, getPreset}; export default async (message, options = {}) => { const { - preset: { - parserOpts: parserOptions - }, configuration: { rules, wildcards } } = options; - // parse the commit message + // Parse the commit message const parsed = parse(message); - // wildcard matches skip the linting + // Wildcard matches skip the linting const bails = Object.entries(wildcards) .filter(entry => { const [, pattern] = entry; @@ -36,7 +31,7 @@ export default async (message, options = {}) => { }) .map(entry => entry[0]); - // found a wildcard match, skip + // Found a wildcard match, skip if (bails.length > 0) { return { valid: true, @@ -47,7 +42,7 @@ export default async (message, options = {}) => { }; } - // validate against all rules + // Validate against all rules const results = Object.entries(rules) .filter(entry => { const [, [level]] = entry; diff --git a/source/library/ensure-case.js b/source/library/ensure-case.js index ed77506dae..f20358ed1e 100644 --- a/source/library/ensure-case.js +++ b/source/library/ensure-case.js @@ -1,4 +1,11 @@ -export default (a, stringCase) => { - const method = `to${stringCase[0].toUpperCase()}${stringCase.slice(1)}`; - return typeof a !== 'string' || a[method]() === a; +export default (raw = '', target = 'lowercase') => { + const normalized = String(raw); + + switch (target) { + case 'uppercase': + return normalized.toUpperCase() === normalized; + case 'lowercase': + default: + return normalized.toLowerCase() === normalized; + } }; diff --git a/source/library/ensure-case.test.js b/source/library/ensure-case.test.js new file mode 100644 index 0000000000..506e13716a --- /dev/null +++ b/source/library/ensure-case.test.js @@ -0,0 +1,42 @@ +import test from 'ava'; +import ensure from './ensure-case'; + +test('true for no params', t => { + const actual = ensure(); + t.is(actual, true); +}); + +test('true for empty', t => { + const actual = ensure(''); + t.is(actual, true); +}); + +test('true for lowercase', t => { + const actual = ensure('a'); + t.is(actual, true); +}); + +test('false for uppercase', t => { + const actual = ensure('A'); + t.is(actual, false); +}); + +test('true for lowercase on lowercase', t => { + const actual = ensure('a', 'lowercase'); + t.is(actual, true); +}); + +test('false for uppercase on lowercase', t => { + const actual = ensure('A', 'lowercase'); + t.is(actual, false); +}); + +test('true for uppercase on uppercase', t => { + const actual = ensure('A', 'uppercase'); + t.is(actual, true); +}); + +test('false for lowercase on lowercase', t => { + const actual = ensure('a', 'uppercase'); + t.is(actual, false); +}); diff --git a/source/library/ensure-enum.js b/source/library/ensure-enum.js index ef1371e840..527dd18940 100644 --- a/source/library/ensure-enum.js +++ b/source/library/ensure-enum.js @@ -1,3 +1,9 @@ -export default (value, enums) => { +export default (value, enums = []) => { + if (value === undefined) { + return false; + } + if (!Array.isArray(enums)) { + return false; + } return enums.indexOf(value) > -1; }; diff --git a/source/library/ensure-enum.test.js b/source/library/ensure-enum.test.js new file mode 100644 index 0000000000..7da0fdbe20 --- /dev/null +++ b/source/library/ensure-enum.test.js @@ -0,0 +1,42 @@ +import test from 'ava'; +import ensure from './ensure-enum'; + +test('false for no params', t => { + const actual = ensure(); + t.is(actual, false); +}); + +test('true for a against a', t => { + const actual = ensure('a', ['a']); + t.is(actual, true); +}); + +test('false for a against b', t => { + const actual = ensure('a', ['b']); + t.is(actual, false); +}); + +test('true for a against a, b', t => { + const actual = ensure('a', ['a', 'b']); + t.is(actual, true); +}); + +test('false for b against a', t => { + const actual = ensure('b', ['a']); + t.is(actual, false); +}); + +test('true for b against b', t => { + const actual = ensure('b', ['b']); + t.is(actual, true); +}); + +test('true for b against a, b', t => { + const actual = ensure('b', ['a', 'b']); + t.is(actual, true); +}); + +test('false for c against a, b', t => { + const actual = ensure('c', ['a', 'b']); + t.is(actual, false); +}); diff --git a/source/library/ensure-language.js b/source/library/ensure-language.js index 40bfff478f..80d79d7ad9 100644 --- a/source/library/ensure-language.js +++ b/source/library/ensure-language.js @@ -3,9 +3,10 @@ import franc from 'franc'; export default (input, allowed) => { const detected = franc.all(input) .filter(lang => lang[1] >= 0.45) - .map(lang => lang[0]); + .map(lang => lang[0]) + .slice(0, 5); - // franc spits out ['und'] when unable to + // Library franc spits out ['und'] when unable to // guess any languages, let it through in this case const matches = detected[0] === 'und' || detected.indexOf(allowed) > -1; diff --git a/source/library/ensure-language.test.js b/source/library/ensure-language.test.js new file mode 100644 index 0000000000..7d48c9368c --- /dev/null +++ b/source/library/ensure-language.test.js @@ -0,0 +1,62 @@ +import test from 'ava'; +import ensure from './ensure-language'; + +test('true for no params', t => { + const actual = ensure(); + t.is(actual.matches, true); + t.is(actual.detected.includes('und'), true); +}); + +test.failing('true for chinese on chi', t => { + const actual = ensure('這是一個嚴重的問題', 'chi'); + t.is(actual.matches, true); + t.is(actual.detected.includes('chi'), true); +}); + +test('true for spanish on spa', t => { + const actual = ensure('Este es un asunto serio', 'spa'); + t.is(actual.matches, true); + t.is(actual.detected.includes('spa'), true); +}); + +test('true for english on eng', t => { + const actual = ensure('This is a serious subject', 'eng'); + t.is(actual.matches, true); + t.is(actual.detected.includes('eng'), true); +}); + +test('true for hindi on hin', t => { + const actual = ensure('यह एक गंभीर मुद्दा है', 'hin'); + t.is(actual.matches, true); + t.is(actual.detected.includes('hin'), true); +}); + +test('true for portugese on por', t => { + const actual = ensure('Este é um assunto sério', 'por'); + t.is(actual.matches, true); + t.is(actual.detected.includes('por'), true); +}); + +test.failing('false for chinese on eng', t => { + const actual = ensure('這是一個嚴重的問題', 'eng'); + t.is(actual.matches, false); + t.is(actual.detected.includes('chi'), true); +}); + +test('false for spanish on eng', t => { + const actual = ensure('Este es un asunto serio', 'eng'); + t.is(actual.matches, false); + t.is(actual.detected.includes('spa'), true); +}); + +test('false for hindi on eng', t => { + const actual = ensure('यह एक गंभीर मुद्दा है', 'eng'); + t.is(actual.matches, false); + t.is(actual.detected.includes('hin'), true); +}); + +test('false for portugese on eng', t => { + const actual = ensure('Este é um assunto sério', 'eng'); + t.is(actual.matches, false); + t.is(actual.detected.includes('por'), true); +}); diff --git a/source/library/ensure-max-length.test.js b/source/library/ensure-max-length.test.js new file mode 100644 index 0000000000..d92ee5cba0 --- /dev/null +++ b/source/library/ensure-max-length.test.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import ensure from './ensure-max-length'; + +test('false for no params', t => { + const actual = ensure(); + t.is(actual, false); +}); + +test('true for a against 1', t => { + const actual = ensure('a', 1); + t.is(actual, true); +}); + +test('false for ab against 0', t => { + const actual = ensure('a', 0); + t.is(actual, false); +}); + +test('true for a against 2', t => { + const actual = ensure('a', 2); + t.is(actual, true); +}); + +test('true for ab against 2', t => { + const actual = ensure('ab', 2); + t.is(actual, true); +}); diff --git a/source/library/ensure-min-length.test.js b/source/library/ensure-min-length.test.js new file mode 100644 index 0000000000..fdea95dc57 --- /dev/null +++ b/source/library/ensure-min-length.test.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import ensure from './ensure-min-length'; + +test('false for no params', t => { + const actual = ensure(); + t.is(actual, false); +}); + +test('true for a against 1', t => { + const actual = ensure('a', 1); + t.is(actual, true); +}); + +test('false for ab against 0', t => { + const actual = ensure('a', 0); + t.is(actual, true); +}); + +test('true for a against 2', t => { + const actual = ensure('a', 2); + t.is(actual, false); +}); + +test('true for ab against 2', t => { + const actual = ensure('ab', 2); + t.is(actual, true); +}); diff --git a/source/library/ensure-not-empty.js b/source/library/ensure-not-empty.js index 6d79591950..cb42c6122b 100644 --- a/source/library/ensure-not-empty.js +++ b/source/library/ensure-not-empty.js @@ -1 +1 @@ -export default value => typeof value === 'string' && value.length > 0 +export default value => typeof value === 'string' && value.length > 0; diff --git a/source/library/ensure-not-empty.test.js b/source/library/ensure-not-empty.test.js new file mode 100644 index 0000000000..0f7663b838 --- /dev/null +++ b/source/library/ensure-not-empty.test.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import ensure from './ensure-not-empty'; + +test('false for no params', t => { + const actual = ensure(); + t.is(actual, false); +}); + +test('false for ""', t => { + const actual = ensure(''); + t.is(actual, false); +}); + +test('true for a', t => { + const actual = ensure('a'); + t.is(actual, true); +}); diff --git a/source/library/ensure-tense.js b/source/library/ensure-tense.js index 663fd105ef..872d832a2c 100644 --- a/source/library/ensure-tense.js +++ b/source/library/ensure-tense.js @@ -26,7 +26,7 @@ function getTags(lemmata) { } } -export default (input, allowed) => { +export default (input, allowed, options = {}) => { const lemmata = getLemmata(input); const tagged = getTags(lemmata); const verbs = tagged.filter(tag => tag[1][0] === 'V'); @@ -42,6 +42,10 @@ export default (input, allowed) => { const [, tag] = verb; return tags.length > 0 && tags.indexOf(tag) === -1; }) + .filter(verb => { + const [word] = verb; + return !(options.ignored || []).some(ignored => ignored.indexOf(word) > -1); + }) .filter(Boolean) .map(verb => { const [lemma, tag] = verb; diff --git a/source/library/ensure-tense.test.js b/source/library/ensure-tense.test.js new file mode 100644 index 0000000000..95f86520a1 --- /dev/null +++ b/source/library/ensure-tense.test.js @@ -0,0 +1,59 @@ +import test from 'ava'; +import ensure from './ensure-tense'; + +test('true for empty', t => { + const actual = ensure('', []); + t.is(actual.matches, true); +}); + +test.failing('true for past-tense against past-tense', t => { + const actual = ensure('implemented', ['past-tense']); + t.is(actual.matches, true); +}); + +test('true for present-imperative against present-imperative', t => { + const actual = ensure('implement', ['present-imperative']); + t.is(actual.matches, true); +}); + +test('true for present-participle against present-participle', t => { + const actual = ensure('implementing', ['present-participle']); + t.is(actual.matches, true); +}); + +test('true for present-third-person against present-third-person', t => { + const actual = ensure('implements', ['present-third-person']); + t.is(actual.matches, true); +}); + +test('false for past-tense against present-third-person', t => { + const actual = ensure('implemented', ['present-third-person']); + t.is(actual.matches, false); + t.deepEqual(actual.offending, [ + {lemma: 'implemented', tense: 'present-imperative'} + ]); +}); + +test.failing('false for present-imperative against past-tense', t => { + const actual = ensure('implement', ['past-tense']); + t.is(actual.matches, false); + t.deepEqual(actual.offending, [ + {lemma: 'implement', tense: 'present-imperative'} + ]); +}); + +test('false for present-participle against present-third-person', t => { + const actual = ensure('implementing', ['present-third-person']); + t.is(actual.matches, false); + t.deepEqual(actual.offending, [ + {lemma: 'implementing', tense: 'present-participle'} + ]); +}); + +test.failing('false for present-third-person against past-tense', t => { + const actual = ensure('implements', ['past-tense']); + t.is(actual.matches, false); + t.deepEqual(actual.offending, [ + {lemma: 'implements', tense: 'present-third-person'} + ]); +}); diff --git a/source/library/execute-rule.js b/source/library/execute-rule.js index 72515c56f7..8d041daca1 100644 --- a/source/library/execute-rule.js +++ b/source/library/execute-rule.js @@ -1,4 +1,7 @@ export default async entry => { + if (!Array.isArray(entry)) { + return null; + } const [name, config] = entry; return typeof config === 'function' ? [name, await config()] : diff --git a/source/library/execute-rule.test.js b/source/library/execute-rule.test.js new file mode 100644 index 0000000000..ffcc80012b --- /dev/null +++ b/source/library/execute-rule.test.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import execute from './execute-rule'; + +test('does nothing without params', async t => { + const actual = await execute(); + t.is(actual, null); +}); + +test('returns plain config', async t => { + const actual = await execute(['name', 'config']); + t.deepEqual(actual, ['name', 'config']); +}); + +test('unwraps promised config', async t => { + const actual = await execute(['name', Promise.resolve('config')]); + t.deepEqual(actual, ['name', 'config']); +}); + +test('executes config functions', async t => { + const actual = await execute(['name', () => 'config']); + t.deepEqual(actual, ['name', 'config']); +}); + +test('executes async config functions', async t => { + const actual = await execute(['name', async () => 'config']); + t.deepEqual(actual, ['name', 'config']); +}); diff --git a/source/library/format.js b/source/library/format.js index 742a65951e..c8da5c6a7c 100644 --- a/source/library/format.js +++ b/source/library/format.js @@ -1,31 +1,39 @@ import chalk from 'chalk'; -export default function format(report, options = {}) { - const {signs, colors, color: enabled} = options; - const fmt = new chalk.constructor({enabled}); +const DEFAULT_SIGNS = [' ', '⚠', '✖']; +const DEFAULT_COLORS = ['white', 'yellow', 'red']; - const problems = [...report.errors, ...report.warnings] +export default function format(report = {}, options = {}) { + const {signs = DEFAULT_SIGNS, colors = DEFAULT_COLORS, color: enabled = true} = options; + const {errors = [], warnings = []} = report; + + const problems = [...errors, ...warnings] .map(problem => { - const sign = signs[problem.level]; - const color = colors[problem.level]; - const decoration = fmt[color](sign); + const sign = signs[problem.level] || ''; + const color = colors[problem.level] || 'white'; + const decoration = enabled ? chalk[color](sign) : sign; const name = chalk.grey(`[${problem.name}]`); return `${decoration} ${problem.message} ${name}`; }); - const sign = report.errors.length ? // eslint-disable-line no-nested-ternary - '✖' : - report.warnings.length ? - '⚠' : - '✔'; + const sign = selectSign({errors, warnings}); + const color = selectColor({errors, warnings}); + + const decoration = enabled ? chalk[color](sign) : sign; + const summary = `${decoration} found ${errors.length} problems, ${warnings.length} warnings`; + return [...problems, enabled ? chalk.bold(summary) : summary]; +} - const color = report.errors.length ? // eslint-disable-line no-nested-ternary - 'red' : - report.warnings.length ? - 'yellow' : - 'green'; +function selectSign(report) { + if (report.errors.length > 0) { + return '✖'; + } + return report.warnings.length ? '⚠' : '✔'; +} - const decoration = fmt[color](sign); - const summary = `${decoration} found ${report.errors.length} problems, ${report.warnings.length} warnings`; - return [...problems, chalk.bold(summary)]; +function selectColor(report) { + if (report.errors.length > 0) { + return 'red'; + } + return report.warnings.length ? 'yellow' : 'green'; } diff --git a/source/library/format.test.js b/source/library/format.test.js new file mode 100644 index 0000000000..667b422fa6 --- /dev/null +++ b/source/library/format.test.js @@ -0,0 +1,156 @@ +import test from 'ava'; +import hasAnsi from 'has-ansi'; +import chalk from 'chalk'; +import {yellow, red, magenta, blue} from 'ansi-styles'; +import format from './format'; + +const ok = chalk.bold(`${chalk.green('✔')} found 0 problems, 0 warnings`); + +test('does nothing without arguments', t => { + const actual = format(); + t.deepEqual(actual, [ok]); +}); + +test('does nothing without .errors and .warnings', t => { + const actual = format({}); + t.deepEqual(actual, [ok]); +}); + +test('returns empty summary of problems for empty .errors and .warnings', t => { + const [msg] = format({ + errors: [], + warnings: [] + }); + + t.true(msg.includes('0 problems, 0 warnings')); +}); + +test('returns a correct of empty .errors and .warnings', t => { + const [err, prob, msg] = format({ + errors: [ + { + level: 2, + name: 'error-name', + message: 'There was an error' + } + ], + warnings: [ + { + level: 1, + name: 'warning-name', + message: 'There was a problem' + } + ] + }); + + t.true(err.includes('There was an error')); + t.true(prob.includes('There was a problem')); + t.true(msg.includes('1 problems, 1 warnings')); +}); + +test('colors messages by default', t => { + const [msg] = format({ + errors: [], + warnings: [] + }); + t.true(hasAnsi(msg)); +}); + +test('does not color messages if configured', t => { + const [msg] = format({}, {color: false}); + t.false(hasAnsi(msg)); +}); + +test('uses appropriate signs by default', t => { + const [err, warn] = format({ + errors: [ + { + level: 2, + name: 'error-name', + message: 'There was an error' + } + ], + warnings: [ + { + level: 1, + name: 'warning-name', + message: 'There was a problem' + } + ] + }); + + t.true(err.includes('✖')); + t.true(warn.includes('⚠')); +}); + +test('uses signs as configured', t => { + const [err, warn] = format({ + errors: [ + { + level: 2, + name: 'error-name', + message: 'There was an error' + } + ], + warnings: [ + { + level: 1, + name: 'warning-name', + message: 'There was a problem' + } + ] + }, { + signs: ['HNT', 'WRN', 'ERR'] + }); + + t.true(err.includes('ERR')); + t.true(warn.includes('WRN')); +}); + +test('uses appropriate colors by default', t => { + const [err, warn] = format({ + errors: [ + { + level: 2, + name: 'error-name', + message: 'There was an error' + } + ], + warnings: [ + { + level: 1, + name: 'warning-name', + message: 'There was a problem' + } + ] + }); + + t.true(err.includes(red.open)); + t.true(warn.includes(yellow.open)); +}); + +if (process.platform !== 'win32') { + test('uses colors as configured', t => { + const [err, warn] = format({ + errors: [ + { + level: 2, + name: 'error-name', + message: 'There was an error' + } + ], + warnings: [ + { + level: 1, + name: 'warning-name', + message: 'There was a problem' + } + ] + }, { + colors: ['white', 'magenta', 'blue'] + }); + + t.true(err.includes(blue.open)); + t.true(warn.includes(magenta.open)); + }); +} diff --git a/source/library/get-configuration.js b/source/library/get-configuration.js index 86caa6222d..c31124a07a 100644 --- a/source/library/get-configuration.js +++ b/source/library/get-configuration.js @@ -43,7 +43,7 @@ export default async (name = defaultName, settings = defaultSettings, seed = {}) const [key, value] = item; const executedValue = await Promise.all( Object.entries(value || {}) - .map(async entry => await executeRule(entry)) + .map(entry => executeRule(entry)) ); return [key, executedValue.reduce((registry, item) => { const [key, value] = item; diff --git a/source/library/get-configuration.test.js b/source/library/get-configuration.test.js new file mode 100644 index 0000000000..884a89d6e9 --- /dev/null +++ b/source/library/get-configuration.test.js @@ -0,0 +1,53 @@ +import path from 'path'; +import test from 'ava'; + +import getConfiguration from './get-configuration'; + +const cwd = process.cwd(); + +test.afterEach.always(t => { + t.context.back(); +}); + +test('overridden-type-enums should return the exact type-enum', async t => { + t.context.back = chdir('fixtures/overridden-type-enums'); + const actual = await getConfiguration(); + const expected = ['a', 'b', 'c', 'd']; + t.deepEqual(actual.rules['type-enum'][2], expected); +}); + +test('overridden-extended-type-enums should return the exact type-enum', async t => { + t.context.back = chdir('fixtures/overridden-extended-type-enums'); + const actual = await getConfiguration(); + const expected = ['a', 'b', 'c', 'd']; + t.deepEqual(actual.rules['type-enum'][2], expected); +}); + +test('extends-empty should have no rules', async t => { + t.context.back = chdir('fixtures/extends-empty'); + const actual = await getConfiguration(); + t.deepEqual(actual.rules, {}); +}); + +/* Failing: test('invalid extend should throw', async t => { + t.context.back = chdir('fixtures/extends-invalid'); + t.throws(getConfiguration()); +}); */ + +test('empty file should have no rules', async t => { + t.context.back = chdir('fixtures/empty-object-file'); + const actual = await getConfiguration(); + t.deepEqual(actual.rules, {}); +}); + +test('empty file should extend angular', async t => { + t.context.back = chdir('fixtures/empty-file'); + const actual = await getConfiguration(); + t.deepEqual(actual.extends, ['angular']); +}); + +function chdir(target) { + const to = path.resolve(cwd, target.split('/').join(path.sep)); + process.chdir(to); + return () => process.chdir(cwd); +} diff --git a/source/library/get-messages.js b/source/library/get-messages.js index 04342b8330..c9557aa666 100644 --- a/source/library/get-messages.js +++ b/source/library/get-messages.js @@ -25,7 +25,7 @@ async function getCommitMessages(settings) { throw new Error(SHALLOW_MESSAGE); } - return await getHistoryCommits({from, to}); + return getHistoryCommits({from, to}); } // Get commit messages from history @@ -47,7 +47,7 @@ function getHistoryCommits(options) { async function isShallow() { const top = await gitToplevel(); const shallow = join(top, '.git/shallow'); - return await exists(shallow); + return exists(shallow); } // Get recently edited commit message diff --git a/test/integration/get-messages.js b/source/library/get-messages.test.js similarity index 88% rename from test/integration/get-messages.js rename to source/library/get-messages.test.js index 4dd3471f02..4be06cca50 100644 --- a/test/integration/get-messages.js +++ b/source/library/get-messages.test.js @@ -8,10 +8,9 @@ import execa from 'execa'; import {mkdir, writeFile} from 'mz/fs'; import exists from 'path-exists'; import rimraf from 'rimraf'; -import expect from 'unexpected'; -import getMessages from '../../source/library/get-messages'; import pkg from '../../package'; +import getMessages from './get-messages'; const rm = denodeify(rimraf); @@ -29,19 +28,15 @@ test.afterEach.always(async t => { }); test.serial('get edit commit message from git root', async t => { - const [repo] = t.context.repos; - await writeFile('alpha.txt', 'alpha'); await execa('git', ['add', '.']); await execa('git', ['commit', '-m', 'alpha']); const expected = ['alpha\n\n']; const actual = await getMessages({edit: true}); - expect(actual, 'to equal', expected); + t.deepEqual(actual, expected); }); test.serial('get history commit messages', async t => { - const [repo] = t.context.repos; - await writeFile('alpha.txt', 'alpha'); await execa('git', ['add', 'alpha.txt']); await execa('git', ['commit', '-m', 'alpha']); @@ -50,12 +45,10 @@ test.serial('get history commit messages', async t => { const expected = ['remove alpha\n\n', 'alpha\n\n']; const actual = await getMessages({}); - expect(actual, 'to equal', expected); + t.deepEqual(actual, expected); }); test.serial('get edit commit message from git subdirectory', async t => { - const [repo] = t.context.repos; - await mkdir('beta'); await writeFile('beta/beta.txt', 'beta'); process.chdir('beta'); @@ -64,7 +57,7 @@ test.serial('get edit commit message from git subdirectory', async t => { const expected = ['beta\n\n']; const actual = await getMessages({edit: true}); - expect(actual, 'to equal', expected); + t.deepEqual(actual, expected); }); test.serial('get history commit messages from shallow clone', async t => { @@ -78,7 +71,7 @@ test.serial('get history commit messages from shallow clone', async t => { t.context.repos = [...t.context.repos, clone]; const err = await t.throws(getMessages({from: 'master'})); - expect(err.message, 'to contain', 'Could not get git history from shallow clone'); + t.true(err.message.indexOf('Could not get git history from shallow clone') > -1); }); async function initRepository() { diff --git a/source/library/get-preset.js b/source/library/get-preset.js index a48fc53d54..a356f4ed06 100644 --- a/source/library/get-preset.js +++ b/source/library/get-preset.js @@ -1,3 +1,7 @@ -export default async name => { - return await require(`conventional-changelog-${name}`); +import importFrom from 'import-from'; + +const cwd = importFrom.bind(null, process.cwd()); + +export default (name, require = cwd) => { + return require(`conventional-changelog-${name}`); }; diff --git a/source/library/get-preset.test.js b/source/library/get-preset.test.js new file mode 100644 index 0000000000..fc5463e486 --- /dev/null +++ b/source/library/get-preset.test.js @@ -0,0 +1,22 @@ +import test from 'ava'; +import getPreset from './get-preset'; + +function require(id) { + if (id !== 'conventional-changelog-existing') { + throw new Error(`Module "${id}" not found.`); + } + return true; +} + +test('throws when called without params', t => { + t.throws(() => getPreset(), Error); +}); + +test('throws when called for non-existing module', t => { + t.throws(() => getPreset('non-existing', require), Error); +}); + +test('return module when called for existing module', async t => { + const actual = await getPreset('existing', require); + t.is(actual, true); +}); diff --git a/source/library/parse.js b/source/library/parse.js index 74935d4a7b..af3c48db54 100644 --- a/source/library/parse.js +++ b/source/library/parse.js @@ -2,8 +2,8 @@ import {sync} from 'conventional-commits-parser'; export default parse; -function parse(message, options) { - const parsed = sync(message, options); +function parse(message, options, parser = sync) { + const parsed = parser(message, options); parsed.raw = message; return parsed; } diff --git a/source/library/parse.test.js b/source/library/parse.test.js new file mode 100644 index 0000000000..d312ed1776 --- /dev/null +++ b/source/library/parse.test.js @@ -0,0 +1,35 @@ +import test from 'ava'; +import parse from './parse'; + +test('throws when called without params', t => { + t.throws(() => parse(), /Expected a raw commit/); +}); + +test('throws when called with empty message', t => { + t.throws(() => parse(''), /Expected a raw commit/); +}); + +test('returns object with raw message', t => { + const message = 'type(scope): subject'; + const actual = parse(message); + t.is(actual.raw, message); +}); + +test('calls parser with message and passed options', t => { + const message = 'message'; + const options = {}; + + parse(message, options, (m, o) => { + t.is(message, m); + t.is(options, o); + return {}; + }); +}); + +test('passes object up from parser function', t => { + const message = 'message'; + const options = {}; + const result = {}; + const actual = parse(message, options, () => result); + t.is(actual, result); +}); diff --git a/source/library/resolve-extends.js b/source/library/resolve-extends.js index a10000ded2..bb9d6ce551 100644 --- a/source/library/resolve-extends.js +++ b/source/library/resolve-extends.js @@ -1,16 +1,21 @@ -import {merge} from 'lodash'; +import importFrom from 'import-from'; +import {merge, omit} from 'lodash'; + +const cwd = importFrom.bind(null, process.cwd()); // Resolve extend configs -export default function resolveExtends(config, prefix = '', key = 'extends') { - return Object.values(config[key] || []) - .reduce((merged, extender) => { - const name = [prefix, extender] - .filter(String) - .join('-'); - return merge( - {}, - merged, - resolveExtends(require(name)) - ); - }, config); +export default function resolveExtends(config = {}, prefix = '', key = 'extends', require = cwd) { + const extended = loadExtends(config, prefix, key, require) + .reduceRight((r, c) => merge(r, omit(c, [key])), config[key] ? {[key]: config[key]} : {}); + return merge({}, extended, config); +} + +// (any, string, string, Function) => any[]; +function loadExtends(config = {}, prefix = '', key = 'extends', require = cwd) { + const toExtend = Object.values(config[key] || []); + return toExtend.reduce((configs, raw) => { + const id = [prefix, raw].filter(String).join('-'); + const c = require(id); + return [...configs, c, ...loadExtends(c, prefix, key, require)]; + }, []); } diff --git a/source/library/resolve-extends.test.js b/source/library/resolve-extends.test.js new file mode 100644 index 0000000000..bc7b51bd40 --- /dev/null +++ b/source/library/resolve-extends.test.js @@ -0,0 +1,148 @@ +import test from 'ava'; +import resolveExtends from './resolve-extends'; + +const _ = undefined; + +test('returns empty object when called without params', t => { + const actual = resolveExtends(); + t.deepEqual(actual, {}); +}); + +test('returns an equivalent object as passed in', t => { + const expected = {foo: 'bar'}; + const actual = resolveExtends(expected); + t.deepEqual(actual, expected); +}); + +test('uses empty prefix by default', t => { + const input = {extends: ['extender-name']}; + + resolveExtends(input, _, _, id => { + t.is(id, 'extender-name'); + }); +}); + +test('uses prefix as configured', t => { + const input = {extends: ['extender-name']}; + + resolveExtends(input, 'prefix', _, id => { + t.is(id, 'prefix-extender-name'); + }); +}); + +test('uses extends key as configured', t => { + const input = {inherit: ['extender-name'], extends: ['fails']}; + + resolveExtends(input, _, 'inherit', id => { + t.is(id, 'extender-name'); + }); +}); + +test('propagates return value of require function', t => { + const input = {extends: ['extender-name']}; + const propagated = {foo: 'bar'}; + + const actual = resolveExtends(input, _, _, () => { + return propagated; + }); + + t.is(actual.foo, 'bar'); +}); + +test('resolves extends recursively', t => { + const input = {extends: ['extender-name']}; + const actual = []; + + resolveExtends(input, _, _, id => { + actual.push(id); + if (id === 'extender-name') { + return {extends: ['recursive-extender-name']}; + } + if (id === 'recursive-extender-name') { + return {foo: 'bar'}; + } + }); + + t.deepEqual(actual, ['extender-name', 'recursive-extender-name']); +}); + +test('uses prefix key recursively', t => { + const input = {extends: ['extender-name']}; + const actual = []; + + resolveExtends(input, 'prefix', _, id => { + actual.push(id); + if (id === 'prefix-extender-name') { + return {extends: ['recursive-extender-name']}; + } + if (id === 'prefix-recursive-extender-name') { + return {foo: 'bar'}; + } + }); + + t.deepEqual(actual, ['prefix-extender-name', 'prefix-recursive-extender-name']); +}); + +test('uses extends key recursively', t => { + const input = {inherit: ['extender-name']}; + const actual = []; + + resolveExtends(input, _, 'inherit', id => { + actual.push(id); + if (id === 'extender-name') { + return {inherit: ['recursive-extender-name']}; + } + if (id === 'recursive-extender-name') { + return {foo: 'bar'}; + } + }); + + t.deepEqual(actual, ['extender-name', 'recursive-extender-name']); +}); + +test('propagates contents recursively', t => { + const input = {extends: ['extender-name']}; + + const actual = resolveExtends(input, _, _, id => { + if (id === 'extender-name') { + return {extends: ['recursive-extender-name'], foo: 'bar'}; + } + if (id === 'recursive-extender-name') { + return {baz: 'bar'}; + } + }); + + const expected = { + extends: ['extender-name'], + foo: 'bar', + baz: 'bar' + }; + + t.deepEqual(actual, expected); +}); + +test('extending contents should take precedence', t => { + const input = {extends: ['extender-name'], zero: 'root'}; + + const actual = resolveExtends(input, _, _, id => { + if (id === 'extender-name') { + return {extends: ['recursive-extender-name'], zero: id, one: id}; + } + if (id === 'recursive-extender-name') { + return {extends: ['second-recursive-extender-name'], zero: id, one: id, two: id}; + } + if (id === 'second-recursive-extender-name') { + return {zero: id, one: id, two: id, three: id}; + } + }); + + const expected = { + extends: ['extender-name'], + zero: 'root', + one: 'extender-name', + two: 'recursive-extender-name', + three: 'second-recursive-extender-name' + }; + + t.deepEqual(actual, expected); +}); diff --git a/source/rules/body-case.js b/source/rules/body-case.js index 8d0694c4f4..23f33cbe5c 100644 --- a/source/rules/body-case.js +++ b/source/rules/body-case.js @@ -1,8 +1,15 @@ import ensureCase from '../library/ensure-case'; export default (parsed, when, value) => { + const {body} = parsed; + + if (!body) { + return [true]; + } + const negated = when === 'never'; - const result = ensureCase(parsed.body, value); + + const result = ensureCase(body, value); return [ negated ? !result : result, [ diff --git a/source/rules/body-case.test.js b/source/rules/body-case.test.js new file mode 100644 index 0000000000..30faa6de3a --- /dev/null +++ b/source/rules/body-case.test.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import parse from '../library/parse'; +import bodyCase from './body-case'; + +const messages = { + empty: 'chore: subject', + lowercase: 'chore: subject\nbody', + mixedcase: 'chore: subject\nBody', + uppercase: 'chore: subject\nBODY' +}; + +const parsed = { + empty: parse(messages.empty), + lowercase: parse(messages.lowercase), + mixedcase: parse(messages.mixedcase), + uppercase: parse(messages.uppercase) +}; + +test('with empty body should succeed for "never lowercase"', t => { + const [actual] = bodyCase(parsed.empty, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty body should succeed for "always lowercase"', t => { + const [actual] = bodyCase(parsed.empty, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty body should succeed for "never uppercase"', t => { + const [actual] = bodyCase(parsed.empty, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty body should succeed for "always uppercase"', t => { + const [actual] = bodyCase(parsed.empty, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with lowercase body should fail for "never lowercase"', t => { + const [actual] = bodyCase(parsed.lowercase, 'never', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase body should succeed for "always lowercase"', t => { + const [actual] = bodyCase(parsed.lowercase, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase body should succeed for "never lowercase"', t => { + const [actual] = bodyCase(parsed.mixedcase, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase body should fail for "always lowercase"', t => { + const [actual] = bodyCase(parsed.mixedcase, 'always', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with mixedcase body should succeed for "never uppercase"', t => { + const [actual] = bodyCase(parsed.mixedcase, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase body should fail for "always uppercase"', t => { + const [actual] = bodyCase(parsed.mixedcase, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with uppercase body should fail for "never uppercase"', t => { + const [actual] = bodyCase(parsed.uppercase, 'never', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase body should succeed for "always uppercase"', t => { + const [actual] = bodyCase(parsed.uppercase, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/body-empty.js b/source/rules/body-empty.js index 81d7ed4228..83c51b635a 100644 --- a/source/rules/body-empty.js +++ b/source/rules/body-empty.js @@ -2,8 +2,10 @@ import ensureNotEmpty from '../library/ensure-not-empty'; export default (parsed, when) => { const negated = when === 'never'; + const notEmpty = ensureNotEmpty(parsed.body); + return [ - ensureNotEmpty(parsed.body), + negated ? notEmpty : !notEmpty, [ 'body', negated ? 'may not' : 'must', diff --git a/source/rules/body-empty.test.js b/source/rules/body-empty.test.js new file mode 100644 index 0000000000..d27cd5f1e1 --- /dev/null +++ b/source/rules/body-empty.test.js @@ -0,0 +1,49 @@ +import test from 'ava'; +import parse from '../library/parse'; +import bodyEmpty from './body-empty'; + +const messages = { + empty: 'chore: subject', + filled: 'chore: subject\nbody' +}; + +const parsed = { + empty: parse(messages.empty), + filled: parse(messages.filled) +}; + +test('with empty body should succeed for empty keyword', t => { + const [actual] = bodyEmpty(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('with empty body should fail for "never"', t => { + const [actual] = bodyEmpty(parsed.empty, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('with empty body should succeed for "always"', t => { + const [actual] = bodyEmpty(parsed.empty, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('with body should fail for empty keyword', t => { + const [actual] = bodyEmpty(parsed.filled); + const expected = false; + t.is(actual, expected); +}); + +test('with body should succeed for "never"', t => { + const [actual] = bodyEmpty(parsed.filled, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('with body should fail for "always"', t => { + const [actual] = bodyEmpty(parsed.filled, 'always'); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/body-leading-blank.js b/source/rules/body-leading-blank.js index 46b4ac87bc..a6bded69a7 100644 --- a/source/rules/body-leading-blank.js +++ b/source/rules/body-leading-blank.js @@ -1,14 +1,20 @@ export default (parsed, when) => { + // Flunk if no body is found + if (!parsed.body) { + return [true]; + } + const negated = when === 'never'; - // get complete body split into lines - const lines = (parsed.raw || '').split('\n').slice(1); - // check if the first line of body (if any) is empty - const leadingBlank = - lines.length > 0 ? - lines[0].length === 0 : - true; + + // Get complete body split into lines + const lines = (parsed.raw || '').split(/\r|\n/).slice(1); + const [leading] = lines; + + // Check if the first line of body is empty + const succeeds = leading === ''; + return [ - negated ? !leadingBlank : leadingBlank, + negated ? !succeeds : succeeds, [ 'body', negated ? 'may not' : 'must', diff --git a/source/rules/body-leading-blank.test.js b/source/rules/body-leading-blank.test.js new file mode 100644 index 0000000000..273888999c --- /dev/null +++ b/source/rules/body-leading-blank.test.js @@ -0,0 +1,69 @@ +import test from 'ava'; +import parse from '../library/parse'; +import bodyLeadingBlank from './body-leading-blank'; + +const messages = { + simple: 'chore: subject', + without: 'chore: subject\nbody', + with: 'chore: subject\n\nbody' +}; + +const parsed = { + simple: parse(messages.simple), + without: parse(messages.without), + with: parse(messages.with) +}; + +test('with simple message should succeed for empty keyword', t => { + const [actual] = bodyLeadingBlank(parsed.simple); + const expected = true; + t.is(actual, expected); +}); + +test('with simple message should succeed for "never"', t => { + const [actual] = bodyLeadingBlank(parsed.simple, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('with simple message should succeed for "always"', t => { + const [actual] = bodyLeadingBlank(parsed.simple, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('without blank line before body should fail for empty keyword', t => { + const [actual] = bodyLeadingBlank(parsed.without); + const expected = false; + t.is(actual, expected); +}); + +test('without blank line before body should succeed for "never"', t => { + const [actual] = bodyLeadingBlank(parsed.without, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('without blank line before body should fail for "always"', t => { + const [actual] = bodyLeadingBlank(parsed.without, 'always'); + const expected = false; + t.is(actual, expected); +}); + +test('with blank line before body should succeed for empty keyword', t => { + const [actual] = bodyLeadingBlank(parsed.with); + const expected = true; + t.is(actual, expected); +}); + +test('with blank line before body should fail for "never"', t => { + const [actual] = bodyLeadingBlank(parsed.with, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('with blank line before body should succeed for "always"', t => { + const [actual] = bodyLeadingBlank(parsed.with, 'always'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/body-max-length.js b/source/rules/body-max-length.js index ed6a25b3f1..1ecb06e3e2 100644 --- a/source/rules/body-max-length.js +++ b/source/rules/body-max-length.js @@ -1,8 +1,14 @@ import ensureMaxLength from '../library/ensure-max-length'; export default (parsed, when, value) => { + const input = parsed.body; + + if (!input) { + return [true]; + } + return [ - ensureMaxLength(parsed.body, value), + ensureMaxLength(input, value), `body must not be longer than ${value} characters` ]; }; diff --git a/source/rules/body-max-length.test.js b/source/rules/body-max-length.test.js new file mode 100644 index 0000000000..1d5f319356 --- /dev/null +++ b/source/rules/body-max-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './body-max-length'; + +const short = 'a'; +const long = 'ab'; + +const value = short.length; + +const messages = { + empty: 'chore: subject', + short: `chore: subject\n${short}`, + long: `chore: subject\n${long}` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/body-min-length.js b/source/rules/body-min-length.js index 37bfe56242..55eca2b85e 100644 --- a/source/rules/body-min-length.js +++ b/source/rules/body-min-length.js @@ -1,6 +1,10 @@ import ensureMinLength from '../library/ensure-min-length'; export default (parsed, when, value) => { + if (!parsed.body) { + return [true]; + } + return [ ensureMinLength(parsed.body, value), `body must not be shorter than ${value} characters` diff --git a/source/rules/body-min-length.test.js b/source/rules/body-min-length.test.js new file mode 100644 index 0000000000..63cc7a7b90 --- /dev/null +++ b/source/rules/body-min-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './body-min-length'; + +const short = 'a'; +const long = 'ab'; + +const value = long.length; + +const messages = { + simple: 'chore: subject', + short: `chore: subject\n${short}`, + long: `chore: subject\n${long}` +}; + +const parsed = { + simple: parse(messages.simple), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with simple should succeed', t => { + const [actual] = check(parsed.simple, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/body-tense.js b/source/rules/body-tense.js index aec3faab3d..003cfe33e6 100644 --- a/source/rules/body-tense.js +++ b/source/rules/body-tense.js @@ -1,8 +1,12 @@ import ensureTense from '../library/ensure-tense'; export default (parsed, when, value) => { + const tenses = Array.isArray(value) ? value : value.allowed || []; + const ignoreConfig = Array.isArray(value) ? [] : value.ignored || []; + const negated = when === 'never'; - const {matches, offending} = ensureTense(parsed.body, value); + const ignored = [...ignoreConfig, ...parsed.notes.map(note => note.title)]; + const {matches, offending} = ensureTense(parsed.body, tenses, {ignored}); const offenders = offending .map(item => [item.lemma, item.tense].join(' - ')) .join(','); diff --git a/source/rules/body-tense.test.js b/source/rules/body-tense.test.js new file mode 100644 index 0000000000..e52791198d --- /dev/null +++ b/source/rules/body-tense.test.js @@ -0,0 +1,114 @@ +import test from 'ava'; +import parse from '../library/parse'; +import footerTense from './body-tense'; + +const messages = { + empty: 'chore: \n', + presentImperative: `chore: \nwe implement things`, + presentParticiple: `chore: \nimplementing things`, + presentThirdPerson: `chore: \nimplements things`, + past: `chore: \nwe did implement things`, + mixed: `chore: \nimplement, implementing, implements, implemented` +}; + +const parsed = { + empty: parse(messages.empty), + presentImperative: parse(messages.presentImperative), + presentParticiple: parse(messages.presentParticiple), + presentThirdPerson: parse(messages.presentImperative), + past: parse(messages.past), + mixed: parse(messages.mixed) +}; + +test('empty succeeds', t => { + const [actual] = footerTense(parsed.empty, '', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('present succeeds "always present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'always', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'never', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('present succeeds "always present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'always', ['present-participle']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'never', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('present succeeds "always present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'always', ['present-third-person']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'never', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('past should succedd "always past-tense"', t => { + const [actual] = footerTense(parsed.past, 'always', ['past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('past fails "never past-tense"', t => { + const [actual] = footerTense(parsed.past, 'never', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always present-third-person"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always present-imperative"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('present fails "always present-participle"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed succeeds "always present-third-person, present-imperative, present-participle, past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person', 'present-imperative', 'present-participle', 'past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('mixed succeeds "never allowed: present-third-person" and matching ignored: implements', t => { + const [actual] = footerTense(parsed.mixed, 'never', { + allowed: ['present-third-person'], + ignored: ['implements'] + }); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/footer-empty.js b/source/rules/footer-empty.js index fbb4f76874..ef97473e62 100644 --- a/source/rules/footer-empty.js +++ b/source/rules/footer-empty.js @@ -2,8 +2,10 @@ import ensureNotEmpty from '../library/ensure-not-empty'; export default (parsed, when) => { const negated = when === 'never'; + const notEmpty = ensureNotEmpty(parsed.footer); + return [ - ensureNotEmpty(parsed.footer), + negated ? notEmpty : !notEmpty, [ 'footer', negated ? 'may not' : 'must', diff --git a/source/rules/footer-empty.test.js b/source/rules/footer-empty.test.js new file mode 100644 index 0000000000..033e9c9e1c --- /dev/null +++ b/source/rules/footer-empty.test.js @@ -0,0 +1,69 @@ +import test from 'ava'; +import parse from '../library/parse'; +import footerEmpty from './footer-empty'; + +const messages = { + simple: 'chore: subject', + empty: 'chore: subject\nbody', + filled: 'chore: subject\nBREAKING CHANGE: something important' +}; + +const parsed = { + simple: parse(messages.simple), + empty: parse(messages.empty), + filled: parse(messages.filled) +}; + +test('with simple message should succeed for empty keyword', t => { + const [actual] = footerEmpty(parsed.simple); + const expected = true; + t.is(actual, expected); +}); + +test('with simple message should fail for "never"', t => { + const [actual] = footerEmpty(parsed.simple, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('with simple message should succeed for "always"', t => { + const [actual] = footerEmpty(parsed.simple, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty footer should succeed for empty keyword', t => { + const [actual] = footerEmpty(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('with empty footer should fail for "never"', t => { + const [actual] = footerEmpty(parsed.empty, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('with empty footer should succeed for "always"', t => { + const [actual] = footerEmpty(parsed.empty, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('with footer should fail for empty keyword', t => { + const [actual] = footerEmpty(parsed.filled); + const expected = false; + t.is(actual, expected); +}); + +test('with footer should succeed for "never"', t => { + const [actual] = footerEmpty(parsed.filled, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('with footer should fail for "always"', t => { + const [actual] = footerEmpty(parsed.filled, 'always'); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/footer-leading-blank.js b/source/rules/footer-leading-blank.js index eaec80b41d..2d03ae7c14 100644 --- a/source/rules/footer-leading-blank.js +++ b/source/rules/footer-leading-blank.js @@ -1,16 +1,21 @@ export default (parsed, when) => { - // flunk if no footer is found + // Flunk if no footer is found if (!parsed.footer) { return [true]; } const negated = when === 'never'; - // get complete body split into lines - const lines = (parsed.raw || '').split(/\r|\n/).slice(2); + const count = (parsed.body || '').split(/\r|\n/).length; + + // Get complete message split into lines + const lines = (parsed.raw || '') + .split(/\r|\n/) + .slice(count + 1); + const [leading] = lines; - // check if the first line of body is empty + // Check if the first line of footer is empty const succeeds = leading === ''; return [ diff --git a/test/rules/footer-leading-blank.js b/source/rules/footer-leading-blank.test.js similarity index 52% rename from test/rules/footer-leading-blank.js rename to source/rules/footer-leading-blank.test.js index 731517ff3c..6fa76fb81b 100644 --- a/test/rules/footer-leading-blank.js +++ b/source/rules/footer-leading-blank.test.js @@ -1,13 +1,14 @@ import test from 'ava'; -import parse from '../../source/library/parse'; -import footerLeadingBlank from '../../source/rules/footer-leading-blank'; +import parse from '../library/parse'; +import footerLeadingBlank from './footer-leading-blank'; const messages = { simple: 'chore: subject', body: 'chore: subject\nbody', trailing: 'chore: subject\nbody\n\n', without: 'chore: subject\nbody\nBREAKING CHANGE: something important', - with: 'chore: subject\nbody\n\nBREAKING CHANGE: something important' + with: 'chore: subject\nbody\n\nBREAKING CHANGE: something important', + withMulitLine: 'chore: subject\nmulti\nline\nbody\n\nBREAKING CHANGE: something important' }; const parsed = { @@ -15,95 +16,114 @@ const parsed = { body: parse(messages.body), trailing: parse(messages.trailing), without: parse(messages.without), - with: parse(messages.with) + with: parse(messages.with), + withMulitLine: parse(messages.withMulitLine) }; -test('footer-leading-blank with simple message should succeed for empty keyword', t => { +test('with simple message should succeed for empty keyword', t => { const [actual] = footerLeadingBlank(parsed.simple); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with simple message should succeed for "never"', t => { +test('with simple message should succeed for "never"', t => { const [actual] = footerLeadingBlank(parsed.simple, 'never'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with simple message should succeed for "always"', t => { +test('with simple message should succeed for "always"', t => { const [actual] = footerLeadingBlank(parsed.simple, 'always'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with body message should succeed for empty keyword', t => { +test('with body message should succeed for empty keyword', t => { const [actual] = footerLeadingBlank(parsed.body); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with body message should succeed for "never"', t => { +test('with body message should succeed for "never"', t => { const [actual] = footerLeadingBlank(parsed.body, 'never'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with body message should succeed for "always"', t => { +test('with body message should succeed for "always"', t => { const [actual] = footerLeadingBlank(parsed.body, 'always'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with trailing message should succeed for empty keyword', t => { +test('with trailing message should succeed for empty keyword', t => { const [actual] = footerLeadingBlank(parsed.trailing); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with trailing message should succeed for "never"', t => { +test('with trailing message should succeed for "never"', t => { const [actual] = footerLeadingBlank(parsed.trailing, 'never'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with trailing message should succeed for "always"', t => { +test('with trailing message should succeed for "always"', t => { const [actual] = footerLeadingBlank(parsed.trailing, 'always'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank without blank line before footer should fail for empty keyword', t => { +test('without blank line before footer should fail for empty keyword', t => { const [actual] = footerLeadingBlank(parsed.without); const expected = false; t.is(actual, expected); }); -test('footer-leading-blank without blank line before footer should succeed for "never"', t => { +test('without blank line before footer should succeed for "never"', t => { const [actual] = footerLeadingBlank(parsed.without, 'never'); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank without blank line before footer should fail for "always"', t => { +test('without blank line before footer should fail for "always"', t => { const [actual] = footerLeadingBlank(parsed.without, 'always'); const expected = false; t.is(actual, expected); }); -test('footer-leading-blank with blank line before footer should succeed for empty keyword', t => { +test('with blank line before footer should succeed for empty keyword', t => { const [actual] = footerLeadingBlank(parsed.with); const expected = true; t.is(actual, expected); }); -test('footer-leading-blank with blank line before footer should fail for "never"', t => { +test('with blank line before footer should fail for "never"', t => { const [actual] = footerLeadingBlank(parsed.with, 'never'); const expected = false; t.is(actual, expected); }); -test('footer-leading-blank with blank line before footer should succeed for "always"', t => { +test('with blank line before footer should succeed for "always"', t => { const [actual] = footerLeadingBlank(parsed.with, 'always'); const expected = true; t.is(actual, expected); }); + +test('with blank line before footer and multiline body should succeed for empty keyword', t => { + const [actual] = footerLeadingBlank(parsed.withMulitLine); + const expected = true; + t.is(actual, expected); +}); + +test('with blank line before footer and multiline body should fail for "never"', t => { + const [actual] = footerLeadingBlank(parsed.withMulitLine, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('with blank line before footer and multiline body should succeed for "always"', t => { + const [actual] = footerLeadingBlank(parsed.withMulitLine, 'always'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/footer-max-length.js b/source/rules/footer-max-length.js index 87d35cee6e..c3144041ca 100644 --- a/source/rules/footer-max-length.js +++ b/source/rules/footer-max-length.js @@ -1,8 +1,14 @@ import ensureMaxLength from '../library/ensure-max-length'; export default (parsed, when, value) => { + const input = parsed.footer; + + if (!input) { + return [true]; + } + return [ - ensureMaxLength(parsed.footer, value), + ensureMaxLength(input, value), `footer must not be longer than ${value} characters` ]; }; diff --git a/source/rules/footer-max-length.test.js b/source/rules/footer-max-length.test.js new file mode 100644 index 0000000000..13af836f40 --- /dev/null +++ b/source/rules/footer-max-length.test.js @@ -0,0 +1,46 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './footer-max-length'; + +const short = 'BREAKING CHANGE: a'; +const long = 'BREAKING CHANGE: ab'; + +const value = short.length; + +const messages = { + simple: 'chore: subject', + empty: 'chore: subject\nbody', + short: `chore: subject\n${short}`, + long: `chore: subject\n${long}` +}; + +const parsed = { + simple: parse(messages.simple), + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with simple should succeed', t => { + const [actual] = check(parsed.simple, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/footer-min-length.js b/source/rules/footer-min-length.js index 32341585dd..ed0099205e 100644 --- a/source/rules/footer-min-length.js +++ b/source/rules/footer-min-length.js @@ -1,6 +1,9 @@ import ensureMinLength from '../library/ensure-min-length'; export default (parsed, when, value) => { + if (!parsed.footer) { + return [true]; + } return [ ensureMinLength(parsed.footer, value), `footer must not be shorter than ${value} characters` diff --git a/source/rules/footer-min-length.test.js b/source/rules/footer-min-length.test.js new file mode 100644 index 0000000000..5d20465f2c --- /dev/null +++ b/source/rules/footer-min-length.test.js @@ -0,0 +1,46 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './footer-min-length'; + +const short = 'BREAKING CHANGE: a'; +const long = 'BREAKING CHANGE: ab'; + +const value = long.length; + +const messages = { + simple: 'chore: subject', + empty: 'chore: subject\nbody', + short: `chore: subject\n${short}`, + long: `chore: subject\n${long}` +}; + +const parsed = { + simple: parse(messages.simple), + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with simple should succeed', t => { + const [actual] = check(parsed.simple, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/footer-tense.js b/source/rules/footer-tense.js index f944efd954..1767689fed 100644 --- a/source/rules/footer-tense.js +++ b/source/rules/footer-tense.js @@ -1,8 +1,12 @@ import ensureTense from '../library/ensure-tense'; export default (parsed, when, value) => { + const tenses = Array.isArray(value) ? value : value.allowed || []; + const ignoreConfig = Array.isArray(value) ? [] : value.ignored || []; + const negated = when === 'never'; - const {matches, offending} = ensureTense(parsed.footer, value); + const ignored = [...ignoreConfig, ...parsed.notes.map(note => note.title)]; + const {matches, offending} = ensureTense(parsed.footer, tenses, {ignored}); const offenders = offending .map(item => [item.lemma, item.tense].join(' - ')) .join(','); diff --git a/source/rules/footer-tense.test.js b/source/rules/footer-tense.test.js new file mode 100644 index 0000000000..a96bb8198d --- /dev/null +++ b/source/rules/footer-tense.test.js @@ -0,0 +1,114 @@ +import test from 'ava'; +import parse from '../library/parse'; +import footerTense from './footer-tense'; + +const messages = { + empty: 'chore: subject\nbody', + presentImperative: `chore: subject\nBREAKING CHANGE: we implement things`, + presentParticiple: `chore: subject\nBREAKING CHANGE: implementing things`, + presentThirdPerson: `chore: subject\nBREAKING CHANGE: implements things`, + past: `chore: subject\nBREAKING CHANGE: we did implement things`, + mixed: `chore: subject\nBREAKING CHANGE: implement, implementing, implements, implemented` +}; + +const parsed = { + empty: parse(messages.empty), + presentImperative: parse(messages.presentImperative), + presentParticiple: parse(messages.presentParticiple), + presentThirdPerson: parse(messages.presentImperative), + past: parse(messages.past), + mixed: parse(messages.mixed) +}; + +test('with empty footer should succeed', t => { + const [actual] = footerTense(parsed.empty, '', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('with present footer should succeed for "always present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'always', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('with present footer should fail for "never present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'never', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('with present footer should succeed for "always present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'always', ['present-participle']); + const expected = true; + t.is(actual, expected); +}); + +test('with present footer should fail for "never present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'never', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('with present footer should succeed for "always present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'always', ['present-third-person']); + const expected = true; + t.is(actual, expected); +}); + +test('with present footer should fail for "never present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'never', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('with past footer should succedd for "always past-tense"', t => { + const [actual] = footerTense(parsed.past, 'always', ['past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('with past footer should fail for "never past-tense"', t => { + const [actual] = footerTense(parsed.past, 'never', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('with mixed footer should fail for "always present-third-person"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('with mixed footer should fail for "always present-imperative"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('with present footer should fail for "always present-participle"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('with mixed footer should fail for "always past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('with mixed footer should succeed for "always present-third-person, present-imperative, present-participle, past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person', 'present-imperative', 'present-participle', 'past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('with mixed footer should succeed for "never allowed: present-third-person" and matching ignored: implements', t => { + const [actual] = footerTense(parsed.mixed, 'never', { + allowed: ['present-third-person'], + ignored: ['implements'] + }); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/header-max-length.test.js b/source/rules/header-max-length.test.js new file mode 100644 index 0000000000..856d228290 --- /dev/null +++ b/source/rules/header-max-length.test.js @@ -0,0 +1,30 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './header-max-length'; + +const short = 'chore: a'; +const long = 'chore: ab'; + +const value = short.length; + +const messages = { + short, + long +}; + +const parsed = { + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/header-min-length.test.js b/source/rules/header-min-length.test.js new file mode 100644 index 0000000000..e63777991b --- /dev/null +++ b/source/rules/header-min-length.test.js @@ -0,0 +1,30 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './header-min-length'; + +const short = 'BREAKING CHANGE: a'; +const long = 'BREAKING CHANGE: ab'; + +const value = long.length; + +const messages = { + short, + long +}; + +const parsed = { + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/index.test.js b/source/rules/index.test.js new file mode 100644 index 0000000000..0cac33dbe9 --- /dev/null +++ b/source/rules/index.test.js @@ -0,0 +1,33 @@ +import path from 'path'; +import test from 'ava'; +import globby from 'globby'; +import rules from '.'; + +test('exports all rules', async t => { + const expected = await glob('*.js'); + const actual = Object.keys(rules); + t.deepEqual(actual, expected); +}); + +test('rules export functions', t => { + const actual = Object.values(rules); + t.true(actual.every(rule => typeof rule === 'function')); +}); + +async function glob(pattern) { + const files = await globby([path.join(__dirname, pattern)], { + ignore: ['**/index.js', '**/*.test.js'], + cwd: __dirname + }); + return files + .map(relative) + .map(toExport); +} + +function relative(filePath) { + return path.relative(__dirname, filePath); +} + +function toExport(fileName) { + return path.basename(fileName, path.extname(fileName)); +} diff --git a/source/rules/lang.js b/source/rules/lang.js index 9fafbc2175..4c1ac1f7df 100644 --- a/source/rules/lang.js +++ b/source/rules/lang.js @@ -1,3 +1,4 @@ +// TODO: this should be named subject-lang import ensureLanguage from '../library/ensure-language'; export default (parsed, when, value) => { diff --git a/source/rules/lang.test.js b/source/rules/lang.test.js new file mode 100644 index 0000000000..71cee7eefd --- /dev/null +++ b/source/rules/lang.test.js @@ -0,0 +1,75 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './lang'; + +const messages = { + empty: '(): \n', + eng: '(): this is a serious subject', + deu: '(): Dies ist ein ernstes Subjekt' +}; + +const parsed = { + empty: parse(messages.empty), + eng: parse(messages.eng), + deu: parse(messages.deu) +}; + +test('empty succeeds', t => { + const [actual] = check(parsed.eng, '', 'eng'); + const expected = true; + t.is(actual, expected); +}); + +test('english against "eng" succeeds', t => { + const [actual] = check(parsed.eng, '', 'eng'); + const expected = true; + t.is(actual, expected); +}); + +test('english against "always eng" succeeds', t => { + const [actual] = check(parsed.eng, 'always', 'eng'); + const expected = true; + t.is(actual, expected); +}); + +test('english against "never eng" fails', t => { + const [actual] = check(parsed.eng, 'never', 'eng'); + const expected = false; + t.is(actual, expected); +}); + +test('english against "deu" fails', t => { + const [actual] = check(parsed.eng, '', 'deu+'); + const expected = false; + t.is(actual, expected); +}); + +test('english against "always deu" fails', t => { + const [actual] = check(parsed.eng, 'always', 'deu'); + const expected = false; + t.is(actual, expected); +}); + +test('english against "never deu" succeeds', t => { + const [actual] = check(parsed.eng, 'never', 'deu'); + const expected = true; + t.is(actual, expected); +}); + +test('german against "deu" succeeds', t => { + const [actual] = check(parsed.deu, '', 'deu'); + const expected = true; + t.is(actual, expected); +}); + +test('german against "always deu" succeeds', t => { + const [actual] = check(parsed.deu, 'always', 'deu'); + const expected = true; + t.is(actual, expected); +}); + +test('german against "never deu" fails', t => { + const [actual] = check(parsed.deu, 'never', 'deu'); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/scope-case.js b/source/rules/scope-case.js index 2bb179261c..ecc61bd064 100644 --- a/source/rules/scope-case.js +++ b/source/rules/scope-case.js @@ -1,8 +1,15 @@ import ensureCase from '../library/ensure-case'; export default (parsed, when, value) => { + const {scope} = parsed; + + if (!scope) { + return [true]; + } + const negated = when === 'never'; - const result = ensureCase(parsed.scope, value); + + const result = ensureCase(scope, value); return [ negated ? !result : result, [ diff --git a/source/rules/scope-case.test.js b/source/rules/scope-case.test.js new file mode 100644 index 0000000000..3f643a7833 --- /dev/null +++ b/source/rules/scope-case.test.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import parse from '../library/parse'; +import scopeCase from './scope-case'; + +const messages = { + empty: 'chore: subject', + lowercase: 'chore(scope): subject', + mixedcase: 'chore(sCoPe): subject', + uppercase: 'chore(SCOPE): subject' +}; + +const parsed = { + empty: parse(messages.empty), + lowercase: parse(messages.lowercase), + mixedcase: parse(messages.mixedcase), + uppercase: parse(messages.uppercase) +}; + +test('with empty scope should succeed for "never lowercase"', t => { + const [actual] = scopeCase(parsed.empty, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty scope should succeed for "always lowercase"', t => { + const [actual] = scopeCase(parsed.empty, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty scope should succeed for "never uppercase"', t => { + const [actual] = scopeCase(parsed.empty, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty scope should succeed for "always uppercase"', t => { + const [actual] = scopeCase(parsed.empty, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with lowercase scope should fail for "never lowercase"', t => { + const [actual] = scopeCase(parsed.lowercase, 'never', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase scope should succeed for "always lowercase"', t => { + const [actual] = scopeCase(parsed.lowercase, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase scope should succeed for "never lowercase"', t => { + const [actual] = scopeCase(parsed.mixedcase, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase scope should fail for "always lowercase"', t => { + const [actual] = scopeCase(parsed.mixedcase, 'always', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with mixedcase scope should succeed for "never uppercase"', t => { + const [actual] = scopeCase(parsed.mixedcase, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase scope should fail for "always uppercase"', t => { + const [actual] = scopeCase(parsed.mixedcase, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with uppercase scope should fail for "never uppercase"', t => { + const [actual] = scopeCase(parsed.uppercase, 'never', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase scope should succeed for "always uppercase"', t => { + const [actual] = scopeCase(parsed.uppercase, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/scope-empty.js b/source/rules/scope-empty.js index 4c27642a8f..69fa8954d9 100644 --- a/source/rules/scope-empty.js +++ b/source/rules/scope-empty.js @@ -2,9 +2,9 @@ import ensureNotEmpty from '../library/ensure-not-empty'; export default (parsed, when = 'never') => { const negated = when === 'always'; - const result = ensureNotEmpty(parsed.scope); + const notEmpty = ensureNotEmpty(parsed.scope); return [ - negated ? !result : result, + negated ? !notEmpty : notEmpty, [ 'scope', negated ? 'must' : 'may not', diff --git a/test/rules/scope-empty.js b/source/rules/scope-empty.test.js similarity index 61% rename from test/rules/scope-empty.js rename to source/rules/scope-empty.test.js index 3ac7a42041..8935c7f31f 100644 --- a/test/rules/scope-empty.js +++ b/source/rules/scope-empty.test.js @@ -1,6 +1,6 @@ import test from 'ava'; -import parse from '../../source/library/parse'; -import scopeEmpty from '../../source/rules/scope-empty'; +import parse from '../library/parse'; +import scopeEmpty from './scope-empty'; const messages = { plain: 'foo(bar): baz', @@ -14,55 +14,55 @@ const parsed = { empty: parse(messages.empty) }; -test('scope-empty with plain message it should succeed for empty keyword', t => { +test('with plain message it should succeed for empty keyword', t => { const [actual] = scopeEmpty(parsed.plain); const expected = true; t.deepEqual(actual, expected); }); -test('scope-empty with plain message it should succeed for "never"', t => { +test('with plain message it should succeed for "never"', t => { const [actual] = scopeEmpty(parsed.plain, 'never'); const expected = true; t.deepEqual(actual, expected); }); -test('scope-empty with plain message it should fail for "always"', t => { +test('with plain message it should fail for "always"', t => { const [actual] = scopeEmpty(parsed.plain, 'always'); const expected = false; t.deepEqual(actual, expected); }); -test('scope-empty with superfluous message it should fail for empty keyword', t => { +test('with superfluous message it should fail for empty keyword', t => { const [actual] = scopeEmpty(parsed.superfluous); const expected = false; t.deepEqual(actual, expected); }); -test('scope-empty with superfluous message it should fail for "never"', t => { +test('with superfluous message it should fail for "never"', t => { const [actual] = scopeEmpty(parsed.superfluous, 'never'); const expected = false; t.deepEqual(actual, expected); }); -test('scope-empty with superfluous message it should fail for "always"', t => { +test('with superfluous message it should fail for "always"', t => { const [actual] = scopeEmpty(parsed.superfluous, 'always'); const expected = true; t.deepEqual(actual, expected); }); -test('scope-empty with empty message it should fail for empty keyword', t => { +test('with empty message it should fail for empty keyword', t => { const [actual] = scopeEmpty(parsed.empty); const expected = false; t.deepEqual(actual, expected); }); -test('scope-empty with empty message it should fail for "never"', t => { +test('with empty message it should fail for "never"', t => { const [actual] = scopeEmpty(parsed.empty, 'never'); const expected = false; t.deepEqual(actual, expected); }); -test('scope-empty with empty message it should fail for "always"', t => { +test('with empty message it should fail for "always"', t => { const [actual] = scopeEmpty(parsed.empty, 'always'); const expected = true; t.deepEqual(actual, expected); diff --git a/test/rules/scope-enum.js b/source/rules/scope-enum.test.js similarity index 94% rename from test/rules/scope-enum.js rename to source/rules/scope-enum.test.js index f4a2a26e44..c470bb8f6e 100644 --- a/test/rules/scope-enum.js +++ b/source/rules/scope-enum.test.js @@ -1,6 +1,6 @@ import test from 'ava'; -import parse from '../../source/library/parse'; -import scopeEnum from '../../source/rules/scope-enum'; +import parse from '../library/parse'; +import scopeEnum from './scope-enum'; const messages = { plain: 'foo(bar): baz', @@ -26,7 +26,7 @@ test('scope-enum with plain message and never should error empty enum', t => { t.deepEqual(actual, expected); }); -test('scope-enum with plain message should succeed correct enum', t => { +test('with plain message should succeed correct enum', t => { const [actual] = scopeEnum(parsed.plain, 'always', ['bar']); const expected = true; t.deepEqual(actual, expected); diff --git a/source/rules/scope-max-length.js b/source/rules/scope-max-length.js index 6a297d794d..5161944abe 100644 --- a/source/rules/scope-max-length.js +++ b/source/rules/scope-max-length.js @@ -1,8 +1,14 @@ import ensureMaxLength from '../library/ensure-max-length'; export default (parsed, when, value) => { + const input = parsed.scope; + + if (!input) { + return [true]; + } + return [ - ensureMaxLength(parsed.subject, value), + ensureMaxLength(input, value), `scope must not be longer than ${value} characters` ]; }; diff --git a/source/rules/scope-max-length.test.js b/source/rules/scope-max-length.test.js new file mode 100644 index 0000000000..95222957a0 --- /dev/null +++ b/source/rules/scope-max-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './scope-max-length'; + +const short = 'a'; +const long = 'ab'; + +const value = short.length; + +const messages = { + empty: 'chore: \n', + short: `chore(${short}): \n`, + long: `chore(${long}): \n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/scope-min-length.js b/source/rules/scope-min-length.js index 6affe5be80..583a8028dd 100644 --- a/source/rules/scope-min-length.js +++ b/source/rules/scope-min-length.js @@ -1,8 +1,12 @@ import ensureMinLength from '../library/ensure-min-length'; export default (parsed, when, value) => { + const input = parsed.scope; + if (!input) { + return [true]; + } return [ - ensureMinLength(parsed.scope, value), + ensureMinLength(input, value), `scope must not be shorter than ${value} characters` ]; }; diff --git a/source/rules/scope-min-length.test.js b/source/rules/scope-min-length.test.js new file mode 100644 index 0000000000..c30c9d0399 --- /dev/null +++ b/source/rules/scope-min-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './scope-min-length'; + +const short = 'a'; +const long = 'ab'; + +const value = long.length; + +const messages = { + empty: 'chore:\n', + short: `chore(${short}): \n`, + long: `chore(${long}): \n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/subject-case.js b/source/rules/subject-case.js index a400e251f2..ad6d4aaa55 100644 --- a/source/rules/subject-case.js +++ b/source/rules/subject-case.js @@ -1,12 +1,19 @@ import ensureCase from '../library/ensure-case'; export default (parsed, when, value) => { + const {subject} = parsed; + + if (!subject) { + return [true]; + } + const negated = when === 'never'; - const result = ensureCase(parsed.subject, value); + + const result = ensureCase(subject, value); return [ negated ? !result : result, [ - `message must`, + `subject must`, negated ? `not` : null, `be ${value}` ] diff --git a/source/rules/subject-case.test.js b/source/rules/subject-case.test.js new file mode 100644 index 0000000000..20da9dbe49 --- /dev/null +++ b/source/rules/subject-case.test.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import parse from '../library/parse'; +import subjectCase from './subject-case'; + +const messages = { + empty: 'chore:\n', + lowercase: 'chore: subject', + mixedcase: 'chore: sUbJeCt', + uppercase: 'chore: SUBJECT' +}; + +const parsed = { + empty: parse(messages.empty), + lowercase: parse(messages.lowercase), + mixedcase: parse(messages.mixedcase), + uppercase: parse(messages.uppercase) +}; + +test('with empty subject should succeed for "never lowercase"', t => { + const [actual] = subjectCase(parsed.empty, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty subject should succeed for "always lowercase"', t => { + const [actual] = subjectCase(parsed.empty, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty subject should succeed for "never uppercase"', t => { + const [actual] = subjectCase(parsed.empty, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty subject should succeed for "always uppercase"', t => { + const [actual] = subjectCase(parsed.empty, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with lowercase subject should fail for "never lowercase"', t => { + const [actual] = subjectCase(parsed.lowercase, 'never', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase subject should succeed for "always lowercase"', t => { + const [actual] = subjectCase(parsed.lowercase, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase subject should succeed for "never lowercase"', t => { + const [actual] = subjectCase(parsed.mixedcase, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase subject should fail for "always lowercase"', t => { + const [actual] = subjectCase(parsed.mixedcase, 'always', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with mixedcase subject should succeed for "never uppercase"', t => { + const [actual] = subjectCase(parsed.mixedcase, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase subject should fail for "always uppercase"', t => { + const [actual] = subjectCase(parsed.mixedcase, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with uppercase subject should fail for "never uppercase"', t => { + const [actual] = subjectCase(parsed.uppercase, 'never', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase subject should succeed for "always uppercase"', t => { + const [actual] = subjectCase(parsed.uppercase, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/subject-empty.js b/source/rules/subject-empty.js index a5ec258124..8c0c5c4880 100644 --- a/source/rules/subject-empty.js +++ b/source/rules/subject-empty.js @@ -2,8 +2,10 @@ import ensureNotEmpty from '../library/ensure-not-empty'; export default (parsed, when) => { const negated = when === 'never'; + const notEmpty = ensureNotEmpty(parsed.subject); + return [ - ensureNotEmpty(parsed.subject), + negated ? notEmpty : !notEmpty, [ 'message', negated ? 'may not' : 'must', diff --git a/source/rules/subject-empty.test.js b/source/rules/subject-empty.test.js new file mode 100644 index 0000000000..1994047258 --- /dev/null +++ b/source/rules/subject-empty.test.js @@ -0,0 +1,49 @@ +import test from 'ava'; +import parse from '../library/parse'; +import subjectEmpty from './subject-empty'; + +const messages = { + empty: 'chore: \nbody', + filled: 'chore: subject\nbody' +}; + +const parsed = { + empty: parse(messages.empty), + filled: parse(messages.filled) +}; + +test('without subject should succeed for empty keyword', t => { + const [actual] = subjectEmpty(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('without subject should fail for "never"', t => { + const [actual] = subjectEmpty(parsed.empty, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('without subject should succeed for "always"', t => { + const [actual] = subjectEmpty(parsed.empty, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('with subject fail for empty keyword', t => { + const [actual] = subjectEmpty(parsed.filled); + const expected = false; + t.is(actual, expected); +}); + +test('with subject succeed for "never"', t => { + const [actual] = subjectEmpty(parsed.filled, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('with subject fail for "always"', t => { + const [actual] = subjectEmpty(parsed.filled, 'always'); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/subject-full-stop.js b/source/rules/subject-full-stop.js index c50e76c200..0ab8802e0f 100644 --- a/source/rules/subject-full-stop.js +++ b/source/rules/subject-full-stop.js @@ -1,11 +1,15 @@ export default (parsed, when, value) => { + const input = parsed.subject; + + if (!input) { + return [true]; + } + const negated = when === 'never'; - const closingFullStop = - parsed.subject ? - parsed.subject[parsed.subject.length - 1] === value : - true; + const hasStop = input[input.length - 1] === value; + return [ - negated ? !closingFullStop : closingFullStop, + negated ? !hasStop : hasStop, [ 'message', negated ? 'may not' : 'must', diff --git a/source/rules/subject-full-stop.test.js b/source/rules/subject-full-stop.test.js new file mode 100644 index 0000000000..61b8de3a4f --- /dev/null +++ b/source/rules/subject-full-stop.test.js @@ -0,0 +1,51 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './subject-full-stop'; + +const messages = { + empty: 'chore:\n', + with: `chore: subject.\n`, + without: `chore: subject\n` +}; + +const parsed = { + empty: parse(messages.empty), + with: parse(messages.with), + without: parse(messages.without) +}; + +test('empty against "always" should succeed', t => { + const [actual] = check(parsed.empty, 'always', '.'); + const expected = true; + t.is(actual, expected); +}); + +test('empty against "never ." should succeed', t => { + const [actual] = check(parsed.empty, 'never', '.'); + const expected = true; + t.is(actual, expected); +}); + +test('with against "always ." should succeed', t => { + const [actual] = check(parsed.with, 'always', '.'); + const expected = true; + t.is(actual, expected); +}); + +test('with against "never ." should fail', t => { + const [actual] = check(parsed.with, 'never', '.'); + const expected = false; + t.is(actual, expected); +}); + +test('without against "always ." should fail', t => { + const [actual] = check(parsed.without, 'always', '.'); + const expected = false; + t.is(actual, expected); +}); + +test('without against "never ." should succeed', t => { + const [actual] = check(parsed.without, 'never', '.'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/subject-leading-capital.js b/source/rules/subject-leading-capital.js index 5d3e69287b..1c8444ffee 100644 --- a/source/rules/subject-leading-capital.js +++ b/source/rules/subject-leading-capital.js @@ -1,9 +1,17 @@ +// TODO +// * rename this to "subject-first-character" import ensureCase from '../library/ensure-case'; -export default (parsed, when, value) => { +export default (parsed, when = 'always', value = 'uppercase') => { + const input = parsed.subject; + + if (!input) { + return [true]; + } + const negated = when === 'never'; - const {subject} = parsed; - const result = ensureCase(subject[0], value); + const result = ensureCase(input[0], value); + return [ negated ? !result : result, [ diff --git a/source/rules/subject-leading-capital.test.js b/source/rules/subject-leading-capital.test.js new file mode 100644 index 0000000000..63bc277a14 --- /dev/null +++ b/source/rules/subject-leading-capital.test.js @@ -0,0 +1,69 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './subject-leading-capital'; + +const messages = { + empty: 'chore:\n', + with: `chore: Subject\n`, + without: `chore: subject\n` +}; + +const parsed = { + empty: parse(messages.empty), + with: parse(messages.with), + without: parse(messages.without) +}; + +test('empty should succeed', t => { + const [actual] = check(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('empty against "always" should succeed', t => { + const [actual] = check(parsed.empty, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('empty against "never" should succeed', t => { + const [actual] = check(parsed.empty, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with should succeed', t => { + const [actual] = check(parsed.with); + const expected = true; + t.is(actual, expected); +}); + +test('with against "always" should succeed', t => { + const [actual] = check(parsed.with, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with against "never" should fail', t => { + const [actual] = check(parsed.with, 'never', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('without should fail', t => { + const [actual] = check(parsed.without, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('without against "always" should fail', t => { + const [actual] = check(parsed.without, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('without against "never" should succeed', t => { + const [actual] = check(parsed.without, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/subject-max-length.js b/source/rules/subject-max-length.js index f7f7b9a018..81b582ae0e 100644 --- a/source/rules/subject-max-length.js +++ b/source/rules/subject-max-length.js @@ -1,8 +1,14 @@ import ensureMaxLength from '../library/ensure-max-length'; export default (parsed, when, value) => { + const input = parsed.subject; + + if (!input) { + return [true]; + } + return [ - ensureMaxLength(parsed.subject, value), - `message must not be longer than ${value} characters` + ensureMaxLength(input, value), + `footer must not be longer than ${value} characters` ]; }; diff --git a/source/rules/subject-max-length.test.js b/source/rules/subject-max-length.test.js new file mode 100644 index 0000000000..7a50873d70 --- /dev/null +++ b/source/rules/subject-max-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './subject-max-length'; + +const short = 'a'; +const long = 'ab'; + +const value = short.length; + +const messages = { + empty: 'chore:\n', + short: `chore: ${short}\n`, + long: `chore: ${long}\n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/subject-min-length.js b/source/rules/subject-min-length.js index b76a117f1d..054b4eda12 100644 --- a/source/rules/subject-min-length.js +++ b/source/rules/subject-min-length.js @@ -1,8 +1,12 @@ import ensureMinLength from '../library/ensure-min-length'; export default (parsed, when, value) => { + const input = parsed.subject; + if (!input) { + return [true]; + } return [ - ensureMinLength(parsed.subject, value), - `message must not be shorter than ${value} characters` + ensureMinLength(input, value), + `subject must not be shorter than ${value} characters` ]; }; diff --git a/source/rules/subject-min-length.test.js b/source/rules/subject-min-length.test.js new file mode 100644 index 0000000000..b77ea43a24 --- /dev/null +++ b/source/rules/subject-min-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './subject-min-length'; + +const short = 'a'; +const long = 'ab'; + +const value = long.length; + +const messages = { + empty: 'chore:\n', + short: `chore: ${short}\n`, + long: `chore: ${long}\n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/subject-tense.js b/source/rules/subject-tense.js index d56aec7901..6d872d79fe 100644 --- a/source/rules/subject-tense.js +++ b/source/rules/subject-tense.js @@ -1,8 +1,12 @@ import ensureTense from '../library/ensure-tense'; export default (parsed, when, value) => { + const tenses = Array.isArray(value) ? value : value.allowed || []; + const ignoreConfig = Array.isArray(value) ? [] : value.ignored || []; + const negated = when === 'never'; - const {matches, offending} = ensureTense(parsed.subject, value); + const ignored = [...ignoreConfig, ...parsed.notes.map(note => note.title)]; + const {matches, offending} = ensureTense(parsed.subject, tenses, {ignored}); const offenders = offending .map(item => [item.lemma, item.tense].join(' - ')) .join(','); @@ -10,7 +14,7 @@ export default (parsed, when, value) => { return [ negated ? !matches : matches, [ - `tense of message must`, + `tense of subject must`, negated ? `not` : null, `be ${value}. Verbs in other tenses: ${offenders}` ] diff --git a/source/rules/subject-tense.test.js b/source/rules/subject-tense.test.js new file mode 100644 index 0000000000..0f0ce273c1 --- /dev/null +++ b/source/rules/subject-tense.test.js @@ -0,0 +1,114 @@ +import test from 'ava'; +import parse from '../library/parse'; +import footerTense from './subject-tense'; + +const messages = { + empty: 'chore: \n', + presentImperative: `chore: we implement things`, + presentParticiple: `chore: implementing things`, + presentThirdPerson: `chore: implements things`, + past: `chore: we did implement things`, + mixed: `chore: implement, implementing, implements, implemented` +}; + +const parsed = { + empty: parse(messages.empty), + presentImperative: parse(messages.presentImperative), + presentParticiple: parse(messages.presentParticiple), + presentThirdPerson: parse(messages.presentImperative), + past: parse(messages.past), + mixed: parse(messages.mixed) +}; + +test('empty succeeds', t => { + const [actual] = footerTense(parsed.empty, '', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('present succeeds "always present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'always', ['present-imperative']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-imperative"', t => { + const [actual] = footerTense(parsed.presentImperative, 'never', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('present succeeds "always present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'always', ['present-participle']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-participle"', t => { + const [actual] = footerTense(parsed.presentParticiple, 'never', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('present succeeds "always present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'always', ['present-third-person']); + const expected = true; + t.is(actual, expected); +}); + +test('present fails "never present-third-person"', t => { + const [actual] = footerTense(parsed.presentThirdPerson, 'never', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('past should succedd "always past-tense"', t => { + const [actual] = footerTense(parsed.past, 'always', ['past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('past fails "never past-tense"', t => { + const [actual] = footerTense(parsed.past, 'never', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always present-third-person"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always present-imperative"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-imperative']); + const expected = false; + t.is(actual, expected); +}); + +test('present fails "always present-participle"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-participle']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed fails "always past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['past-tense']); + const expected = false; + t.is(actual, expected); +}); + +test('mixed succeeds "always present-third-person, present-imperative, present-participle, past-tense"', t => { + const [actual] = footerTense(parsed.mixed, 'always', ['present-third-person', 'present-imperative', 'present-participle', 'past-tense']); + const expected = true; + t.is(actual, expected); +}); + +test('mixed succeeds "never allowed: present-third-person" and matching ignored: implements', t => { + const [actual] = footerTense(parsed.mixed, 'never', { + allowed: ['present-third-person'], + ignored: ['implements'] + }); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/type-case.js b/source/rules/type-case.js index 506de75919..fe088bc7a0 100644 --- a/source/rules/type-case.js +++ b/source/rules/type-case.js @@ -1,12 +1,19 @@ import ensureCase from '../library/ensure-case'; export default (parsed, when, value) => { + const {type} = parsed; + + if (!type) { + return [true]; + } + const negated = when === 'never'; - const result = ensureCase(parsed.type, value); + + const result = ensureCase(type, value); return [ negated ? !result : result, [ - `type must`, + `subject must`, negated ? `not` : null, `be ${value}` ] diff --git a/source/rules/type-case.test.js b/source/rules/type-case.test.js new file mode 100644 index 0000000000..673c31525b --- /dev/null +++ b/source/rules/type-case.test.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import parse from '../library/parse'; +import typeCase from './type-case'; + +const messages = { + empty: '(scope): subject', + lowercase: 'type: subject', + mixedcase: 'tYpE: subject', + uppercase: 'TYPE: subject' +}; + +const parsed = { + empty: parse(messages.empty), + lowercase: parse(messages.lowercase), + mixedcase: parse(messages.mixedcase), + uppercase: parse(messages.uppercase) +}; + +test('with empty type should succeed for "never lowercase"', t => { + const [actual] = typeCase(parsed.empty, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty type should succeed for "always lowercase"', t => { + const [actual] = typeCase(parsed.empty, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty type should succeed for "never uppercase"', t => { + const [actual] = typeCase(parsed.empty, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with empty type should succeed for "always uppercase"', t => { + const [actual] = typeCase(parsed.empty, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with lowercase type should fail for "never lowercase"', t => { + const [actual] = typeCase(parsed.lowercase, 'never', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase type should succeed for "always lowercase"', t => { + const [actual] = typeCase(parsed.lowercase, 'always', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase type should succeed for "never lowercase"', t => { + const [actual] = typeCase(parsed.mixedcase, 'never', 'lowercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase type should fail for "always lowercase"', t => { + const [actual] = typeCase(parsed.mixedcase, 'always', 'lowercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with mixedcase type should succeed for "never uppercase"', t => { + const [actual] = typeCase(parsed.mixedcase, 'never', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); + +test('with mixedcase type should fail for "always uppercase"', t => { + const [actual] = typeCase(parsed.mixedcase, 'always', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with uppercase type should fail for "never uppercase"', t => { + const [actual] = typeCase(parsed.uppercase, 'never', 'uppercase'); + const expected = false; + t.is(actual, expected); +}); + +test('with lowercase type should succeed for "always uppercase"', t => { + const [actual] = typeCase(parsed.uppercase, 'always', 'uppercase'); + const expected = true; + t.is(actual, expected); +}); diff --git a/source/rules/type-empty.js b/source/rules/type-empty.js index 39e327eee2..d265b76b24 100644 --- a/source/rules/type-empty.js +++ b/source/rules/type-empty.js @@ -2,8 +2,9 @@ import ensureNotEmpty from '../library/ensure-not-empty'; export default (parsed, when) => { const negated = when === 'never'; + const notEmpty = ensureNotEmpty(parsed.type); return [ - ensureNotEmpty(parsed.type), + negated ? notEmpty : !notEmpty, [ 'type', negated ? 'may not' : 'must', diff --git a/source/rules/type-empty.test.js b/source/rules/type-empty.test.js new file mode 100644 index 0000000000..9036a4586e --- /dev/null +++ b/source/rules/type-empty.test.js @@ -0,0 +1,49 @@ +import test from 'ava'; +import parse from '../library/parse'; +import typeEmpty from './type-empty'; + +const messages = { + empty: '(scope):', + filled: 'type: subject' +}; + +const parsed = { + empty: parse(messages.empty), + filled: parse(messages.filled) +}; + +test('without type should succeed for empty keyword', t => { + const [actual] = typeEmpty(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('without type should fail for "never"', t => { + const [actual] = typeEmpty(parsed.empty, 'never'); + const expected = false; + t.is(actual, expected); +}); + +test('without type should succeed for "always"', t => { + const [actual] = typeEmpty(parsed.empty, 'always'); + const expected = true; + t.is(actual, expected); +}); + +test('with type fail for empty keyword', t => { + const [actual] = typeEmpty(parsed.filled); + const expected = false; + t.is(actual, expected); +}); + +test('with type succeed for "never"', t => { + const [actual] = typeEmpty(parsed.filled, 'never'); + const expected = true; + t.is(actual, expected); +}); + +test('with type fail for "always"', t => { + const [actual] = typeEmpty(parsed.filled, 'always'); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/type-enum.js b/source/rules/type-enum.js index 1f2ae3cd17..30784d0c87 100644 --- a/source/rules/type-enum.js +++ b/source/rules/type-enum.js @@ -1,14 +1,21 @@ import ensureEnum from '../library/ensure-enum'; export default (parsed, when, value) => { + const {type: input} = parsed; + + if (!input) { + return [true]; + } + const negated = when === 'never'; - const result = ensureEnum(parsed.type, value); + const result = ensureEnum(input, value); + return [ negated ? !result : result, [ - `type must`, + `scope must`, negated ? `not` : null, - `be one of [${value.map(e => `"${e}"`).join(', ')}]` + `be one of [${value.join(', ')}]` ] .filter(Boolean) .join(' ') diff --git a/source/rules/type-enum.test.js b/source/rules/type-enum.test.js new file mode 100644 index 0000000000..4d45b9cdf2 --- /dev/null +++ b/source/rules/type-enum.test.js @@ -0,0 +1,123 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './type-enum'; + +const messages = { + empty: '(): \n', + a: 'a(): \n', + b: 'b(): \n' +}; + +const parsed = { + empty: parse(messages.empty), + a: parse(messages.a), + b: parse(messages.b) +}; + +test('empty succeeds', t => { + const [actual] = check(parsed.empty); + const expected = true; + t.is(actual, expected); +}); + +test('empty on "a" succeeds', t => { + const [actual] = check(parsed.empty, '', ['a']); + const expected = true; + t.is(actual, expected); +}); + +test('empty on "always a" succeeds', t => { + const [actual] = check(parsed.empty, 'always', ['a']); + const expected = true; + t.is(actual, expected); +}); + +test('empty on "never a" succeeds', t => { + const [actual] = check(parsed.empty, 'never', ['a']); + const expected = true; + t.is(actual, expected); +}); + +test('empty on "always a, b" succeeds', t => { + const [actual] = check(parsed.empty, 'always', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('empty on "never a, b" succeeds', t => { + const [actual] = check(parsed.empty, 'neber', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('a on "a" succeeds', t => { + const [actual] = check(parsed.a, '', ['a']); + const expected = true; + t.is(actual, expected); +}); + +test('a on "always a" succeeds', t => { + const [actual] = check(parsed.a, 'always', ['a']); + const expected = true; + t.is(actual, expected); +}); + +test('a on "never a" fails', t => { + const [actual] = check(parsed.a, 'never', ['a']); + const expected = false; + t.is(actual, expected); +}); + +test('b on "b" succeeds', t => { + const [actual] = check(parsed.b, '', ['b']); + const expected = true; + t.is(actual, expected); +}); + +test('b on "always b" succeeds', t => { + const [actual] = check(parsed.b, 'always', ['b']); + const expected = true; + t.is(actual, expected); +}); + +test('b on "never b" fails', t => { + const [actual] = check(parsed.b, 'never', ['b']); + const expected = false; + t.is(actual, expected); +}); + +test('a on "a, b" succeeds', t => { + const [actual] = check(parsed.a, '', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('a on "always a, b" succeeds', t => { + const [actual] = check(parsed.a, 'always', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('a on "never a, b" fails', t => { + const [actual] = check(parsed.a, 'never', ['a', 'b']); + const expected = false; + t.is(actual, expected); +}); + +test('b on "a, b" succeeds', t => { + const [actual] = check(parsed.b, '', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('b on "always a, b" succeeds', t => { + const [actual] = check(parsed.b, 'always', ['a', 'b']); + const expected = true; + t.is(actual, expected); +}); + +test('b on "never a, b" fails', t => { + const [actual] = check(parsed.b, 'never', ['a', 'b']); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/type-max-length.js b/source/rules/type-max-length.js index 180d71453c..d6f6fef854 100644 --- a/source/rules/type-max-length.js +++ b/source/rules/type-max-length.js @@ -1,8 +1,14 @@ import ensureMaxLength from '../library/ensure-max-length'; export default (parsed, when, value) => { + const input = parsed.type; + + if (!input) { + return [true]; + } + return [ - ensureMaxLength(parsed.type, value), + ensureMaxLength(input, value), `type must not be longer than ${value} characters` ]; }; diff --git a/source/rules/type-max-length.test.js b/source/rules/type-max-length.test.js new file mode 100644 index 0000000000..1be87bb227 --- /dev/null +++ b/source/rules/type-max-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './type-max-length'; + +const short = 'a'; +const long = 'ab'; + +const value = short.length; + +const messages = { + empty: '():\n', + short: `${short}: \n`, + long: `${long}: \n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should succeed', t => { + const [actual] = check(parsed.short, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with long should fail', t => { + const [actual] = check(parsed.long, '', value); + const expected = false; + t.is(actual, expected); +}); diff --git a/source/rules/type-min-length.js b/source/rules/type-min-length.js index 31881c912d..05cdbcebf7 100644 --- a/source/rules/type-min-length.js +++ b/source/rules/type-min-length.js @@ -1,8 +1,12 @@ import ensureMinLength from '../library/ensure-min-length'; export default (parsed, when, value) => { + const input = parsed.type; + if (!input) { + return [true]; + } return [ - ensureMinLength(parsed.header, value), - `scope must not be shorter than ${value} characters` + ensureMinLength(input, value), + `type must not be shorter than ${value} characters` ]; }; diff --git a/source/rules/type-min-length.test.js b/source/rules/type-min-length.test.js new file mode 100644 index 0000000000..4f3cd59636 --- /dev/null +++ b/source/rules/type-min-length.test.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import parse from '../library/parse'; +import check from './type-min-length'; + +const short = 'a'; +const long = 'ab'; + +const value = long.length; + +const messages = { + empty: '():\n', + short: `${short}: \n`, + long: `${long}: \n` +}; + +const parsed = { + empty: parse(messages.empty), + short: parse(messages.short), + long: parse(messages.long) +}; + +test('with empty should succeed', t => { + const [actual] = check(parsed.empty, '', value); + const expected = true; + t.is(actual, expected); +}); + +test('with short should fail', t => { + const [actual] = check(parsed.short, '', value); + const expected = false; + t.is(actual, expected); +}); + +test('with long should succeed', t => { + const [actual] = check(parsed.long, '', value); + const expected = true; + t.is(actual, expected); +}); diff --git a/test/integration/get-configuration.js b/test/integration/get-configuration.js deleted file mode 100644 index 5b6d3b8b64..0000000000 --- a/test/integration/get-configuration.js +++ /dev/null @@ -1,54 +0,0 @@ -import path from 'path'; -import test from 'ava'; -import expect from 'unexpected'; - -import getConfiguration from '../../source/library/get-configuration'; - -const cwd = process.cwd(); - -test('overridden-type-enums should return the exact type-enum', async t => { - const back = chdir('fixtures/overridden-type-enums'); - const actual = await getConfiguration(); - expect(actual.rules['type-enum'][2], 'to equal', [ "a", "b", "c", "d" ]); - back(); -}); - -test('overridden-extended-type-enums should return the exact type-enum', async t => { - const back = chdir('fixtures/overridden-extended-type-enums'); - const actual = await getConfiguration(); - expect(actual.rules['type-enum'][2], 'to equal', [ "a", "b", "c", "d" ]); - back(); -}); - -test('extends-empty should have no rules', async t => { - const back = chdir('fixtures/extends-empty'); - const actual = await getConfiguration(); - expect(actual.rules, 'to equal', {}); - back(); -}); - -test('invalid extend should throw', async t => { - const back = chdir('fixtures/extends-invalid'); - t.throws(getConfiguration(), Error); - back(); -}); - -test('empty file should have no rules', async t => { - const back = chdir('fixtures/empty-object-file'); - const actual = await getConfiguration(); - expect(actual.rules, 'to equal', {}); - back(); -}); - -test('empty file should extend angular', async t => { - const back = chdir('fixtures/empty-file'); - const actual = await getConfiguration(); - expect(actual.extends, 'to equal', ['angular']); - back(); -}); - -function chdir(target) { - const to = path.resolve(cwd, target.split('/').join(path.sep)); - process.chdir(to); - return () => process.chdir(cwd); -}