diff --git a/.conventional-changelog-lintrc b/.conventional-changelog-lintrc
deleted file mode 100644
index cd28ce9cfc..0000000000
--- a/.conventional-changelog-lintrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "rules": {
- "type-enum": [ 2, "always", [ "Fix", "New", "Breaking", "Docs", "Build", "Upgrade", "Chore", "Update" ] ]
- }
-}
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/.npmrc b/.npmrc
deleted file mode 100644
index 32d2d5e0fd..0000000000
--- a/.npmrc
+++ /dev/null
@@ -1,4 +0,0 @@
-spin = false
-progress = false
-save-exact = true
-cache-min = 99999999
diff --git a/.travis.yml b/.travis.yml
index 43ec172521..8606782c5f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,8 +4,9 @@ node_js:
- '6'
- '4'
before_install:
- - npm i -g npm
+ - git fetch --unshallow
before_script:
- npm run build
script:
+ - npm run travis:lint:commits
- npm test
diff --git a/changelog.md b/changelog.md
index a1a1e2331f..699881a344 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,39 @@
+
+# [2.1.0](https://github.com/marionebl/conventional-changelog-lint/compare/v1.1.9...v2.1.0) (2017-07-07)
+
+
+### Bug Fixes
+
+* prevent false positives for footer-leading-blank ([#33](https://github.com/marionebl/conventional-changelog-lint/issues/33)) ([05b4427](https://github.com/marionebl/conventional-changelog-lint/commit/05b4427))
+* rebuff rules ([#34](https://github.com/marionebl/conventional-changelog-lint/issues/34)) ([702a2f7](https://github.com/marionebl/conventional-changelog-lint/commit/702a2f7))
+* throw when detecting a shallow clone ([8c354c5](https://github.com/marionebl/conventional-changelog-lint/commit/8c354c5)), closes [#7](https://github.com/marionebl/conventional-changelog-lint/issues/7) [#12](https://github.com/marionebl/conventional-changelog-lint/issues/12)
+* update to latest angular config ([b1f3606](https://github.com/marionebl/conventional-changelog-lint/commit/b1f3606))
+
+
+### Features
+
+* ignore fixup and squash commit ([#17](https://github.com/marionebl/conventional-changelog-lint/issues/17)) ([f0b83d8](https://github.com/marionebl/conventional-changelog-lint/commit/f0b83d8))
+
+
+
+
+# 2.0.0 (2017-07-07)
+
+### Documentation
+
+* add recipe for linting of all commits in a PR (#36) ([1e69d54](https://github.com/marionebl/conventional-changelog-lint/commit/1e69d54)), closes [#35](https://github.com/marionebl/conventional-changelog-lint/issues/35)
+
+
+### Bug Fixes
+
+* prevent false positives for footer-leading-blank ([#33](https://github.com/marionebl/conventional-changelog-lint/issues/33)) ([05b4427](https://github.com/marionebl/conventional-changelog-lint/commit/05b4427))
+* rebuff rules ([#34](https://github.com/marionebl/conventional-changelog-lint/issues/34)) ([702a2f7](https://github.com/marionebl/conventional-changelog-lint/commit/702a2f7))
+* throw when detecting a shallow clone ([8c354c5](https://github.com/marionebl/conventional-changelog-lint/commit/8c354c5)), closes [#7](https://github.com/marionebl/conventional-changelog-lint/issues/7) [#12](https://github.com/marionebl/conventional-changelog-lint/issues/12)
+
+### BREAKING CHANGES :warning:
+
+* wildcards config is now ignored entirely
+
## [1.1.9](https://github.com/marionebl/conventional-changelog-lint/compare/v1.1.8...v1.1.9) (2017-04-05)
diff --git a/package.json b/package.json
index 6d0012ca25..f2063396b0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "conventional-changelog-lint",
- "version": "1.1.9",
+ "version": "2.1.0",
"description": "Lint commit messages against a conventional-changelog preset and ruleset",
"main": "distribution/index.js",
"files": [
@@ -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,16 +19,25 @@
"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",
"release": "npm version --no-git-tag-version $(conventional-recommended-bump -p angular)",
"version": "npm run changelog && git add .",
- "postversion": "git commit -m \"chore(release): v$npm_package_version\n$(conventional-changelog -p angular)\" && git tag -a v$npm_package_version -m \"$(conventional-changelog -p angular)\""
+ "postversion": "git commit -m \"chore(release): v$npm_package_version\n$(conventional-changelog -p angular)\" && git tag -a v$npm_package_version -m \"$(conventional-changelog -p angular)\"",
+ "travis:lint:commits": "./scripts/lint:commits.sh"
},
"ava": {
+ "files": [
+ "source/**/*.test.js",
+ "!distribution/**/*"
+ ],
+ "source": [
+ "source/**/*.js",
+ "!distribution/**/*"
+ ],
"babel": "inherit",
"require": [
"babel-register",
@@ -36,6 +45,27 @@
]
},
"babel": {
+ "env": {
+ "development": {
+ "sourceMaps": "inline",
+ "plugins": [
+ "add-module-exports",
+ "istanbul",
+ [
+ "transform-runtime",
+ {
+ "polyfill": false,
+ "regenerator": true
+ }
+ ]
+ ]
+ },
+ "production": {
+ "ignore": [
+ "**/*.test.js"
+ ]
+ }
+ },
"presets": [
[
"env",
@@ -58,6 +88,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"
@@ -68,7 +114,7 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/marionebl/conventional-changelog-lint.git"
+ "url": "https://github.com/marionebl/conventional-changelog-lint.git"
},
"bugs": {
"url": "https://github.com/marionebl/conventional-changelog-lint/issues"
@@ -88,30 +134,39 @@
},
"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",
"babel-preset-stage-0": "6.16.0",
+ "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",
"babel-runtime": "6.23.0",
"chalk": "1.1.3",
"conventional-changelog-angular": "1.3.0",
- "conventional-changelog-lint-config-angular": "0.4.1",
+ "conventional-changelog-lint-config-angular": "^1.0.0",
"conventional-commits-parser": "1.3.0",
"franc": "2.0.0",
"get-stdin": "5.0.1",
@@ -121,6 +176,7 @@
"meow": "3.7.0",
"mz": "2.6.0",
"pos": "0.4.2",
- "rc": "1.1.7"
+ "rc": "1.1.7",
+ "semver": "^5.3.0"
}
}
diff --git a/readme.md b/readme.md
index e1205ebaa5..fc80c6a3f7 100644
--- a/readme.md
+++ b/readme.md
@@ -29,7 +29,7 @@ resolves `extends` configurations.
```shell
❯ conventional-changelog-lint --help
- conventional-changelog-lint@0.1.0 - Lint commit messages against a conventional-changelog preset and ruleset
+ conventional-changelog-lint - Lint commit messages against a conventional-changelog preset and ruleset
[input] reads from stdin if --edit, --from, --to are omitted
--color,-c toggle formatted output, defaults to: true
@@ -42,6 +42,78 @@ resolves `extends` configurations.
```
+### Recipes
+
+#### git hook
+As a `commitmsg` git-hook with ["husky"](https://git.io/JDwyQg)
+
+```json
+ {
+ "scripts": {
+ "commitmsg": "conventional-changelog-lint -e"
+ }
+ }
+```
+
+
+#### Last commit
+As part of `npm test`
+
+```json
+ {
+ "scripts": {
+ "test": "conventional-changelog-lint --from=HEAD~1"
+ }
+ }
+```
+
+#### Lint all commits in Pull Request
+
+You can lint all commits in a PR by passing all commits that
+are present in `SOURCE_BRANCH` but unavailable in `BASE_BRANCH`:
+
+```sh
+conventional-changelog-lint --from=BASE_BRANCH to=SOURCE_BRANCH
+```
+
+Most of the time `BASE_BRANCH` will be `master` for Github Flow.
+
+This assumes `SOURCE_BRANCH` is available on your local checkout.
+This is not true by default for all PRs originating from clones of a repository.
+
+Given you'd like to lint all commits in PR origination from branch `remote-test` on the
+repository `github.com/other-name/test` targeting `master` on `github.com/your-name/test`:
+
+```sh
+cd test # make sure CWD is in your repository
+git remote add other-name https://github.com/other-name/test.git
+git fetch other-name
+
+conventional-changelog-lint --from=master --to=other-name/test
+```
+
+See [scripts/lint:commit.sh](./scripts/lint:commit.sh#6) for an example on how to obtain `SOURCE_BRANCH` from a Github clone automatically on Travis.
+
+#### Travis
+
+Commit Linting on CI has to handle the following cases
+
+* Direct commits
+* Branch Pull Requests
+* Fork Pull Requests
+
+An exemplary implementation is provided as bash script working on Travis CI.
+
+```yml
+# Force full git checkout
+before_install: git fetch --unshallow
+
+script:
+ - ./scripts/lint:commit.sh # [1] scripts/lint:commit.sh
+```
+
+> \[1\]: See [scripts/lint:commit.sh](./scripts/lint:commit.sh) for reference
+
### API
The programming interface does not read configuration by default,
@@ -77,28 +149,6 @@ const report = lint(
);
```
-### Recipes
-
-* As a `commitmsg` git-hook with ["husky"](https://git.io/JDwyQg)
-
-```json
- {
- "scripts": {
- "commitmsg": "conventional-changelog-lint -e"
- }
- }
-```
-
-* As part of `npm test`
-
-```json
- {
- "scripts": {
- "test": "conventional-changelog-lint --from=HEAD~1"
- }
- }
-```
-
## Configuration
`conventional-changelog-lint` is configured via
@@ -186,6 +236,45 @@ wildcards: {
}
```
+## Shallow clones
+
+### TL;DR
+
+Perform `git fetch --shallow` before linting.
+
+Most likely you are reading this because you where presented with an error message:
+
+```
+ 'Could not get git history from shallow clone.
+ Use git fetch --shallow before linting.
+ Original issue: https://git.io/vyKMq\n Refer to https://git.io/vyKMv for details.'
+```
+
+### Explanation
+
+git supports checking out `shallow` clones of a repository to save bandwith in times.
+These limited copies do not contain a full git history. This makes `conventional-changelog-lint`
+fail, especially when running on large commit ranges.
+To ensure linting works every time you should convert a shallow git repo to a complete one.
+Use `git fetch --shallow` to do so.
+
+### Travis
+
+Ensure full git checkouts on TravisCI, add to `.travis.yml`:
+
+```yml
+before_install:
+ - git fetch --unshallow
+```
+
+### Appveyor
+
+Ensure full git checkouts on AppVeyor, add to `appveyor.yml`:
+
+```yml
+shallow_clone: false
+```
+
## Supported Node.js versions
conventional-changelog-lint supports the active Node.js [LTS](https://github.com/nodejs/LTS#lts-schedule) version and higher: `>= 4`
diff --git a/scripts/lint:commits.sh b/scripts/lint:commits.sh
new file mode 100755
index 0000000000..a54d5cefab
--- /dev/null
+++ b/scripts/lint:commits.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+set -e
+set -u
+
+if [[ $TRAVIS_PULL_REQUEST_SLUG != "" && $TRAVIS_PULL_REQUEST_SLUG != $TRAVIS_REPO_SLUG ]]; then
+ # This is a Pull Request from a different slug, hence a forked repository
+ git remote add "$TRAVIS_PULL_REQUEST_SLUG" "https://github.com/$TRAVIS_PULL_REQUEST_SLUG.git"
+ git fetch "$TRAVIS_PULL_REQUEST_SLUG"
+
+ # Use the fetched remote pointing to the source clone for comparison
+ TO="$TRAVIS_PULL_REQUEST_SLUG/$TRAVIS_PULL_REQUEST_BRANCH"
+else
+ # This is a Pull Request from the same remote, no clone repository
+ TO=$TRAVIS_COMMIT
+fi
+
+# Lint all commits in the PR
+# - Covers fork pull requests (when TO=slug/branch)
+# - Covers branch pull requests (when TO=branch)
+conventional-changelog-lint --from="$TRAVIS_BRANCH" --to="$TO"
+
+# Always lint the triggerig commit
+# - Covers direct commits
+conventional-changelog-lint --from="$TRAVIS_COMMIT"
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 4d5b4d22f1..a4af1a0e7f 100644
--- a/source/index.js
+++ b/source/index.js
@@ -1,57 +1,30 @@
-import {sync as parse} from 'conventional-commits-parser';
-import {merge} from 'lodash';
-
import ruleFunctions from './rules';
import format from './library/format';
import getConfiguration from './library/get-configuration';
import getMessages from './library/get-messages';
import getPreset from './library/get-preset';
+import isIgnored from './library/is-ignored';
+import parse from './library/parse';
export {format, getConfiguration, getMessages, getPreset};
export default async (message, options = {}) => {
- const {
- preset: {
- parserOpts: parserOptions
- },
- configuration: {
- rules,
- wildcards
- }
- } = options;
-
- // parse the commit message
- const parsed = merge(
- {raw: message},
- parse(message, parserOptions)
- );
-
- // wildcard matches skip the linting
- const bails = Object.entries(wildcards)
- .filter(entry => {
- const [, pattern] = entry;
- return Array.isArray(pattern);
- })
- .filter(entry => {
- const [, pattern] = entry;
- const expression = new RegExp(...pattern);
- return parsed.header.match(expression);
- })
- .map(entry => entry[0]);
+ const {configuration} = options;
- // found a wildcard match, skip
- if (bails.length > 0) {
+ // Found a wildcard match, skip
+ if (isIgnored(message)) {
return {
valid: true,
- wildcards: bails,
- rules: [],
- warnings: [],
- errors: []
+ errors: [],
+ warnings: []
};
}
- // validate against all rules
- const results = Object.entries(rules)
+ // Parse the commit message
+ const parsed = parse(message);
+
+ // Validate against all rules
+ const results = Object.entries(configuration.rules)
.filter(entry => {
const [, [level]] = entry;
return level > 0;
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 e73e4ff7c6..c9557aa666 100644
--- a/source/library/get-messages.js
+++ b/source/library/get-messages.js
@@ -1,10 +1,17 @@
import {join} from 'path';
+import exists from 'path-exists';
import gitRawCommits from 'git-raw-commits';
import gitToplevel from 'git-toplevel';
import {readFile} from 'mz/fs';
export default getCommitMessages;
+const SHALLOW_MESSAGE = [
+ 'Could not get git history from shallow clone.',
+ 'Use git fetch --shallow before linting.',
+ 'Original issue: https://git.io/vyKMq\n Refer to https://git.io/vyKMv for details.'
+].join('\n');
+
// Get commit messages
// Object => Promise>
async function getCommitMessages(settings) {
@@ -14,7 +21,11 @@ async function getCommitMessages(settings) {
return getEditCommit();
}
- return await getHistoryCommits({from, to});
+ if (await isShallow()) {
+ throw new Error(SHALLOW_MESSAGE);
+ }
+
+ return getHistoryCommits({from, to});
}
// Get commit messages from history
@@ -31,6 +42,14 @@ function getHistoryCommits(options) {
});
}
+// Check if the current repository is shallow
+// () => Promise
+async function isShallow() {
+ const top = await gitToplevel();
+ const shallow = join(top, '.git/shallow');
+ return exists(shallow);
+}
+
// Get recently edited commit message
// () => Promise>
async function getEditCommit() {
diff --git a/test/integration/get-messages.js b/source/library/get-messages.test.js
similarity index 52%
rename from test/integration/get-messages.js
rename to source/library/get-messages.test.js
index 790bcd6a9e..4be06cca50 100644
--- a/test/integration/get-messages.js
+++ b/source/library/get-messages.test.js
@@ -8,29 +8,35 @@ 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);
-test.serial('get edit commit message from git root', async () => {
- const repo = await initRepository();
+test.beforeEach(async t => {
+ t.context.repos = [await initRepository()];
+});
+
+test.afterEach.always(async t => {
+ try {
+ await Promise.all(t.context.repos.map(async repo => cleanRepository(repo)));
+ t.context.repos = [];
+ } catch (err) {
+ console.log({err});
+ }
+});
+test.serial('get edit commit message from git root', async t => {
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);
-
- await cleanRepository(repo);
+ t.deepEqual(actual, expected);
});
-test.serial('get history commit messages', async () => {
- const repo = await initRepository();
-
+test.serial('get history commit messages', async t => {
await writeFile('alpha.txt', 'alpha');
await execa('git', ['add', 'alpha.txt']);
await execa('git', ['commit', '-m', 'alpha']);
@@ -39,14 +45,10 @@ test.serial('get history commit messages', async () => {
const expected = ['remove alpha\n\n', 'alpha\n\n'];
const actual = await getMessages({});
- expect(actual, 'to equal', expected);
-
- await cleanRepository(repo);
+ t.deepEqual(actual, expected);
});
-test.serial('get edit commit message from git subdirectory', async () => {
- const repo = await initRepository();
-
+test.serial('get edit commit message from git subdirectory', async t => {
await mkdir('beta');
await writeFile('beta/beta.txt', 'beta');
process.chdir('beta');
@@ -55,9 +57,21 @@ test.serial('get edit commit message from git subdirectory', async () => {
const expected = ['beta\n\n'];
const actual = await getMessages({edit: true});
- expect(actual, 'to equal', expected);
+ t.deepEqual(actual, expected);
+});
- await cleanRepository(repo);
+test.serial('get history commit messages from shallow clone', async t => {
+ const [repo] = t.context.repos;
+
+ await writeFile('alpha.txt', 'alpha');
+ await execa('git', ['add', 'alpha.txt']);
+ await execa('git', ['commit', '-m', 'alpha']);
+
+ const clone = await cloneRepository(pkg.repository.url, repo, '--depth', '1');
+ t.context.repos = [...t.context.repos, clone];
+
+ const err = await t.throws(getMessages({from: 'master'}));
+ t.true(err.message.indexOf('Could not get git history from shallow clone') > -1);
});
async function initRepository() {
@@ -74,8 +88,21 @@ async function initRepository() {
return {directory, previous};
}
+async function cloneRepository(source, context, ...args) {
+ const directory = join(tmpdir(), rand());
+ await execa('git', ['clone', ...args, source, directory]);
+ process.chdir(directory);
+
+ await execa('git', ['config', 'user.email', 'test@example.com']);
+ await execa('git', ['config', 'user.name', 'ava']);
+
+ return {directory, previous: context.previous};
+}
+
async function cleanRepository(repo) {
- process.chdir(repo.previous);
+ if (repo.previous && repo.previous !== process.cwd()) {
+ process.chdir(repo.previous);
+ }
if (await exists(repo.directory)) {
await rm(repo.directory);
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/is-ignored.js b/source/library/is-ignored.js
new file mode 100644
index 0000000000..97df674b59
--- /dev/null
+++ b/source/library/is-ignored.js
@@ -0,0 +1,12 @@
+import semver from 'semver';
+
+const WILDCARDS = [
+ c => c.match(/^(Merge pull request)|(Merge (.*?) into (.*?)|(Merge branch (.*?))$)/),
+ c => c.match(/^(R|r)evert (.*)/),
+ c => c.match(/^(fixup|squash)!/),
+ c => semver.valid(c)
+];
+
+export default function isIgnored(commit = '') {
+ return WILDCARDS.some(w => w(commit));
+}
diff --git a/source/library/is-ignored.test.js b/source/library/is-ignored.test.js
new file mode 100644
index 0000000000..132bb0263a
--- /dev/null
+++ b/source/library/is-ignored.test.js
@@ -0,0 +1,50 @@
+import test from 'ava';
+import isIgnored from './is-ignored';
+
+test('should return false when called without arguments', t => {
+ t.false(isIgnored());
+});
+
+test('should return false when called with empty string', t => {
+ t.false(isIgnored(''));
+});
+
+test('should return false for normal commit', t => {
+ t.false(isIgnored('initial commit'));
+});
+
+test('should return false for branch merges', t => {
+ t.true(isIgnored('Merge branch \'iss53\''));
+});
+
+test('should return true for merged PRs', t => {
+ t.true(isIgnored('Merge pull request #369'));
+});
+
+test('should return true for revert commits', t => {
+ t.true(isIgnored(`Revert "docs: add recipe for linting of all commits in a PR (#36)"\n\nThis reverts commit 1e69d542c16c2a32acfd139e32efa07a45f19111.`));
+ t.true(isIgnored(`revert "docs: add recipe for linting of all commits in a PR (#36)"\n\nThis reverts commit 1e69d542c16c2a32acfd139e32efa07a45f19111.`));
+});
+
+test('should return true for npm version commits', t => {
+ t.true(isIgnored(`0.0.1`));
+ t.true(isIgnored(`0.1.0`));
+ t.true(isIgnored(`1.0.0`));
+ t.true(isIgnored(`0.0.1-alpha`));
+ t.true(isIgnored(`0.0.1-some-crazy-tag`));
+ t.true(isIgnored(`0.0.1-0`));
+ t.true(isIgnored(`0.0.1-999`));
+ t.true(isIgnored(`0.0.1-alpha.0`));
+ t.true(isIgnored(`0.0.1-alpha.999`));
+ t.true(isIgnored(`0.0.1-some-crazy-tag.0`));
+ t.true(isIgnored(`0.0.1-some-crazy-tag.999`));
+ t.true(isIgnored(`0.0.1-1e69d54`));
+});
+
+test('should return true fixup commits', t => {
+ t.true(isIgnored('fixup! initial commit'));
+});
+
+test('should return true squash commits', t => {
+ t.true(isIgnored('squash! initial commit'));
+});
diff --git a/source/library/parse.js b/source/library/parse.js
new file mode 100644
index 0000000000..af3c48db54
--- /dev/null
+++ b/source/library/parse.js
@@ -0,0 +1,9 @@
+import {sync} from 'conventional-commits-parser';
+
+export default parse;
+
+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..43f8b693ec 100644
--- a/source/library/resolve-extends.js
+++ b/source/library/resolve-extends.js
@@ -1,16 +1,33 @@
-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]} : {});
+
+ // Remove deprecation warning in version 3
+ if (typeof c === 'object' && 'wildcards' in config) {
+ console.warn(`'wildcards' found in top-level configuration ignored. Remove them from your config to silence this warning.`);
+ }
+
+ 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);
+
+ // Remove deprecation warning in version 3
+ if (typeof c === 'object' && 'wildcards' in c) {
+ console.warn(`'wildcards' found in '${id}' ignored. Raise an issue at 'npm repo ${id}' to remove the wildcards and silence this warning.`);
+ }
+
+ 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 1275c3d58f..2d03ae7c14 100644
--- a/source/rules/footer-leading-blank.js
+++ b/source/rules/footer-leading-blank.js
@@ -1,14 +1,25 @@
export default (parsed, when) => {
+ // 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('\n').slice(2);
- // check if the first line of body (if any) is empty
- const leadingBlank =
- lines.length > 0 ?
- lines[0].length === 0 :
- true;
+
+ 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 footer is empty
+ const succeeds = leading === '';
+
return [
- negated ? !leadingBlank : leadingBlank,
+ negated ? !succeeds : succeeds,
[
'footer',
negated ? 'may not' : 'must',
diff --git a/source/rules/footer-leading-blank.test.js b/source/rules/footer-leading-blank.test.js
new file mode 100644
index 0000000000..6fa76fb81b
--- /dev/null
+++ b/source/rules/footer-leading-blank.test.js
@@ -0,0 +1,129 @@
+import test from 'ava';
+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',
+ withMulitLine: 'chore: subject\nmulti\nline\nbody\n\nBREAKING CHANGE: something important'
+};
+
+const parsed = {
+ simple: parse(messages.simple),
+ body: parse(messages.body),
+ trailing: parse(messages.trailing),
+ without: parse(messages.without),
+ with: parse(messages.with),
+ withMulitLine: parse(messages.withMulitLine)
+};
+
+test('with simple message should succeed for empty keyword', t => {
+ const [actual] = footerLeadingBlank(parsed.simple);
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with simple message should succeed for "never"', t => {
+ const [actual] = footerLeadingBlank(parsed.simple, 'never');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with simple message should succeed for "always"', t => {
+ const [actual] = footerLeadingBlank(parsed.simple, 'always');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with body message should succeed for empty keyword', t => {
+ const [actual] = footerLeadingBlank(parsed.body);
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with body message should succeed for "never"', t => {
+ const [actual] = footerLeadingBlank(parsed.body, 'never');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with body message should succeed for "always"', t => {
+ const [actual] = footerLeadingBlank(parsed.body, 'always');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with trailing message should succeed for empty keyword', t => {
+ const [actual] = footerLeadingBlank(parsed.trailing);
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with trailing message should succeed for "never"', t => {
+ const [actual] = footerLeadingBlank(parsed.trailing, 'never');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+test('with trailing message should succeed for "always"', t => {
+ const [actual] = footerLeadingBlank(parsed.trailing, 'always');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+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('without blank line before footer should succeed for "never"', t => {
+ const [actual] = footerLeadingBlank(parsed.without, 'never');
+ const expected = true;
+ t.is(actual, expected);
+});
+
+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('with blank line before footer should succeed for empty keyword', t => {
+ const [actual] = footerLeadingBlank(parsed.with);
+ const expected = true;
+ t.is(actual, expected);
+});
+
+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('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 ba5983151a..8935c7f31f 100644
--- a/test/rules/scope-empty.js
+++ b/source/rules/scope-empty.test.js
@@ -1,6 +1,6 @@
import test from 'ava';
-import {sync as parse} from 'conventional-commits-parser';
-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 93%
rename from test/rules/scope-enum.js
rename to source/rules/scope-enum.test.js
index b1a8f7c4a5..c470bb8f6e 100644
--- a/test/rules/scope-enum.js
+++ b/source/rules/scope-enum.test.js
@@ -1,6 +1,6 @@
import test from 'ava';
-import {sync as parse} from 'conventional-commits-parser';
-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);
-}