Skip to content

Commit

Permalink
Enforce rules based on "engines" field in package.json (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
pvdlg authored and sindresorhus committed Jan 20, 2018
1 parent b2e5428 commit 0d18368
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 8 deletions.
7 changes: 4 additions & 3 deletions config/plugins.js
Expand Up @@ -31,8 +31,6 @@ module.exports = {
rules: {
'no-use-extend-native/no-use-extend-native': 'error',
'promise/param-names': 'error',
// Enable this sometime in the future when Node.js has async/await support
// 'promise/prefer-await-to-then': 'error',
'promise/no-return-wrap': ['error', {allowReject: true}],
'promise/no-return-in-finally': 'error',
'import/default': 'error',
Expand Down Expand Up @@ -81,6 +79,9 @@ module.exports = {
// Disabled as the rule doesn't exclude scripts executed with `node` but not referenced in "bin". See https://github.com/mysticatea/eslint-plugin-node/issues/96
// 'node/shebang': 'error',
'node/no-deprecated-api': 'error',
'node/exports-style': ['error', 'module.exports']
'node/exports-style': ['error', 'module.exports'],
// Disabled by default (overrides `plugin:unicorn/recommended`), will be enabled if supported by the Node.js version
'unicorn/prefer-spread': 'off',
'unicorn/no-new-buffer': 'off'
}
};
55 changes: 54 additions & 1 deletion lib/options-manager.js
Expand Up @@ -8,6 +8,7 @@ const pathExists = require('path-exists');
const pkgConf = require('pkg-conf');
const resolveFrom = require('resolve-from');
const prettier = require('prettier');
const semver = require('semver');

const DEFAULT_IGNORE = [
'**/node_modules/**',
Expand Down Expand Up @@ -42,6 +43,46 @@ const DEFAULT_CONFIG = {
}
};

/**
* Define the rules that are enabled only for specific version of Node, based on `engines.node` in package.json or the `node-version` option.
*
* The keys are rule names and the values are an Object with a valid semver (`4.0.0` is valid `4` is not) as keys and the rule configuration as values.
* Each entry define the rule configuration and the minimum Node version for which to set it.
* The entry with the highest version that is compliant with the `engines.node`/`node-version` range will be used.
*
* @type {Object}
*
* @example
* ```javascript
* {
* 'plugin/rule': {
* '6.0.0': ['error', {prop: 'node-6-conf'}],
* '8.0.0': ['error', {prop: 'node-8-conf'}]
* }
* }
*```
* With `engines.node` set to `>=4` the rule `plugin/rule` will not be used.
* With `engines.node` set to `>=6` the rule `plugin/rule` will be used with the config `{prop: 'node-6-conf'}`.
* With `engines.node` set to `>=8` the rule `plugin/rule` will be used with the config `{prop: 'node-8-conf'}`.
*/
const ENGINE_RULES = {
'promise/prefer-await-to-then': {
'7.6.0': 'error'
},
'prefer-rest-params': {
'6.0.0': 'error'
},
'unicorn/prefer-spread': {
'5.0.0': 'error'
},
'prefer-destructuring': {
'6.0.0': ['error', {array: true, object: true}, {enforceForRenamedProperties: true}]
},
'unicorn/no-new-buffer': {
'5.10.0': 'error'
}
};

// Keep the same behaviour in mergeWith as deepAssign
const mergeFn = (prev, val) => {
if (Array.isArray(prev) && Array.isArray(val)) {
Expand Down Expand Up @@ -88,7 +129,8 @@ const mergeWithPkgConf = opts => {
opts = Object.assign({cwd: process.cwd()}, opts);
opts.cwd = path.resolve(opts.cwd);
const conf = pkgConf.sync('xo', {cwd: opts.cwd, skipOnFalse: true});
return Object.assign({}, conf, opts);
const engines = pkgConf.sync('engines', {cwd: opts.cwd});
return Object.assign({}, conf, {engines}, opts);
};

const normalizeSpaces = opts => {
Expand Down Expand Up @@ -129,6 +171,17 @@ const buildConfig = opts => {
);
const spaces = normalizeSpaces(opts);

if (opts.engines && opts.engines.node && semver.validRange(opts.engines.node)) {
for (const rule of Object.keys(ENGINE_RULES)) {
// Use the rule value for the highest version that is lower or equal to the oldest version of Node supported
for (const minVersion of Object.keys(ENGINE_RULES[rule]).sort(semver.compare)) {
if (!semver.intersects(opts.engines.node, `<${minVersion}`)) {
config.rules[rule] = ENGINE_RULES[rule][minVersion];
}
}
}
}

if (opts.space) {
config.rules.indent = ['error', spaces, {SwitchCase: 1}];

Expand Down
16 changes: 16 additions & 0 deletions main.js
Expand Up @@ -4,6 +4,7 @@ const updateNotifier = require('update-notifier');
const getStdin = require('get-stdin');
const meow = require('meow');
const formatterPretty = require('eslint-formatter-pretty');
const semver = require('semver');
const openReport = require('./lib/open-report');
const xo = require('.');

Expand All @@ -21,6 +22,7 @@ const cli = meow(`
--space Use space indent instead of tabs [Default: 2]
--no-semicolon Prevent use of semicolons
--prettier Conform to Prettier code style
--node-version Range of Node.js version to support
--plugin Include third-party plugins [Can be set multiple times]
--extend Extend defaults with a custom config [Can be set multiple times]
--open Open files with issues in your editor
Expand Down Expand Up @@ -76,6 +78,9 @@ const cli = meow(`
prettier: {
type: 'boolean'
},
nodeVersion: {
type: 'string'
},
plugin: {
type: 'string'
},
Expand Down Expand Up @@ -136,6 +141,17 @@ if (input[0] === '-') {
input.shift();
}

if (opts.nodeVersion) {
if (opts.nodeVersion === 'false') {
opts.engines = false;
} else if (semver.validRange(opts.nodeVersion)) {
opts.engines = {node: opts.nodeVersion};
} else {
console.error('The `node-engine` option must be a valid semver range (for example `>=4`)');
process.exit(1);
}
}

if (opts.init) {
require('xo-init')();
} else if (opts.stdin) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -97,6 +97,7 @@
"prettier": "~1.10.2",
"resolve-cwd": "^2.0.0",
"resolve-from": "^4.0.0",
"semver": "^5.4.1",
"slash": "^1.0.0",
"update-notifier": "^2.1.0",
"xo-init": "^0.6.0"
Expand Down
29 changes: 29 additions & 0 deletions readme.md
Expand Up @@ -27,6 +27,7 @@ Uses [ESLint](http://eslint.org) underneath, so issues regarding rules should be
- No need to specify file paths to lint as it lints all JS files except for [commonly ignored paths](#ignores).
- [Config overrides per files/globs.](#config-overrides)
- Includes many useful ESLint plugins, like [`unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`import`](https://github.com/benmosher/eslint-plugin-import), [`ava`](https://github.com/avajs/eslint-plugin-ava), [`node`](https://github.com/mysticatea/eslint-plugin-node) and more.
- Automatically enables rules based on the [`engines`](https://docs.npmjs.com/files/package.json#engines) field in your `package.json`.
- Caches results between runs for much better performance.
- Super simple to add XO to a project with `$ xo --init`.
- Fix many issues automagically with `$ xo --fix`.
Expand Down Expand Up @@ -61,6 +62,7 @@ $ xo --help
--space Use space indent instead of tabs [Default: 2]
--no-semicolon Prevent use of semicolons
--prettier Conform to Prettier code style
--node-version Range of Node.js version to support
--plugin Include third-party plugins [Can be set multiple times]
--extend Extend defaults with a custom config [Can be set multiple times]
--open Open files with issues in your editor
Expand Down Expand Up @@ -206,6 +208,14 @@ Default: `false`

Format code with [Prettier](https://github.com/prettier/prettier). The [Prettier options](https://prettier.io/docs/en/options.html) will be read from the [Prettier config](https://prettier.io/docs/en/configuration.html)

### nodeVersion

Type: `string`, `boolean`<br>
Default: Value of the `engines.node` key in the project `package.json`

Enable rules specific to the Node.js versions within the configured range.
If set to `false`, no rules specific to a Node.js version will be enabled.

### plugins

Type: `Array`
Expand Down Expand Up @@ -303,6 +313,25 @@ If you have a directory structure with nested `package.json` files and you want

Put a `package.json` with your config at the root and add `"xo": false` to the `package.json` in your bundled packages.

### Transpilation

If some files in your project are transpiled in order to support an older Node.js version, you can use the [config overrides](#config-overrides) option to set a specific [`nodeVersion`](#nodeversion) target for these files.

For example, if your project targets Node.js 4 (your `package.json` is configured with `engines.node` set to `>=4`) and you are using [AVA](https://github.com/avajs/ava), then your test files are automatically transpiled. You can override `nodeVersion` for the tests files:

```json
{
"xo": {
"overrides": [
{
"files": "{test,tests,spec,__tests__}/**/*.js",
"nodeVersion": ">=9"
}
]
}
}
```


## FAQ

Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/engines/package.json
@@ -0,0 +1,7 @@
{
"name": "application-name",
"version": "0.0.1",
"engines": {
"node": ">=6"
}
}
6 changes: 6 additions & 0 deletions test/main.js
Expand Up @@ -94,3 +94,9 @@ test('init option', async t => {
const packageJson = fs.readFileSync(filepath, 'utf8');
t.deepEqual(JSON.parse(packageJson).scripts, {test: 'xo'});
});

test('invalid node-engine option', async t => {
const filepath = await tempWrite('console.log()\n', 'x.js');
const err = await t.throws(main(['--node-version', 'v', filepath]));
t.is(err.code, 1);
});
112 changes: 108 additions & 4 deletions test/options-manager.js
Expand Up @@ -4,6 +4,7 @@ import proxyquire from 'proxyquire';
import parentConfig from './fixtures/nested/package';
import childConfig from './fixtures/nested/child/package';
import prettierConfig from './fixtures/prettier/package';
import enginesConfig from './fixtures/engines/package';

process.chdir(__dirname);

Expand Down Expand Up @@ -138,6 +139,88 @@ test('buildConfig: prettier: true, esnext: false', t => {
}]);
});

test('buildConfig: engines: undefined', t => {
const config = manager.buildConfig({});

// Do not include any Node.js version specific rules
t.is(config.rules['prefer-spread'], undefined);
t.is(config.rules['prefer-rest-params'], undefined);
t.is(config.rules['prefer-destructuring'], undefined);
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: false', t => {
const config = manager.buildConfig({engines: false});

// Do not include any Node.js version specific rules
t.is(config.rules['prefer-spread'], undefined);
t.is(config.rules['prefer-rest-params'], undefined);
t.is(config.rules['prefer-destructuring'], undefined);
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: invalid range', t => {
const config = manager.buildConfig({engines: {node: '4'}});

// Do not include any Node.js version specific rules
t.is(config.rules['prefer-spread'], undefined);
t.is(config.rules['prefer-rest-params'], undefined);
t.is(config.rules['prefer-destructuring'], undefined);
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: >=4', t => {
const config = manager.buildConfig({engines: {node: '>=4'}});

// Do not include rules for Node.js 5 and above
t.is(config.rules['unicorn/prefer-spread'], undefined);
// Do not include rules for Node.js 6 and above
t.is(config.rules['prefer-rest-params'], undefined);
t.is(config.rules['prefer-destructuring'], undefined);
// Do not include rules for Node.js 8 and above
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: >=4.1', t => {
const config = manager.buildConfig({engines: {node: '>=5.1'}});

// Do not include rules for Node.js 5 and above
t.is(config.rules['unicorn/prefer-spread'], 'error');
// Do not include rules for Node.js 6 and above
t.is(config.rules['prefer-rest-params'], undefined);
t.is(config.rules['prefer-destructuring'], undefined);
// Do not include rules for Node.js 8 and above
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: >=6', t => {
const config = manager.buildConfig({engines: {node: '>=6'}});

// Include rules for Node.js 5 and above
t.is(config.rules['unicorn/prefer-spread'], 'error');
// Include rules for Node.js 6 and above
t.is(config.rules['prefer-rest-params'], 'error');
t.deepEqual(config.rules['prefer-destructuring'], [
'error', {array: true, object: true}, {enforceForRenamedProperties: true}
]);
// Do not include rules for Node.js 8 and above
t.is(config.rules['promise/prefer-await-to-then'], undefined);
});

test('buildConfig: engines: >=8', t => {
const config = manager.buildConfig({engines: {node: '>=8'}});

// Include rules for Node.js 5 and above
t.is(config.rules['unicorn/prefer-spread'], 'error');
// Include rules for Node.js 6 and above
t.is(config.rules['prefer-rest-params'], 'error');
t.deepEqual(config.rules['prefer-destructuring'], [
'error', {array: true, object: true}, {enforceForRenamedProperties: true}
]);
// Include rules for Node.js 8 and above
t.is(config.rules['promise/prefer-await-to-then'], 'error');
});

test('mergeWithPrettierConf: use `singleQuote`, `trailingComma`, `bracketSpacing` and `jsxBracketSameLine` from `prettier` config if defined', t => {
const cwd = path.resolve('fixtures', 'prettier');
const result = manager.mergeWithPrettierConf({cwd});
Expand Down Expand Up @@ -255,26 +338,47 @@ test('groupConfigs', t => {
test('mergeWithPkgConf: use child if closest', t => {
const cwd = path.resolve('fixtures', 'nested', 'child');
const result = manager.mergeWithPkgConf({cwd});
const expected = Object.assign({}, childConfig.xo, {cwd});
const expected = Object.assign({}, childConfig.xo, {cwd}, {engines: {}});
t.deepEqual(result, expected);
});

test('mergeWithPkgConf: use parent if closest', t => {
const cwd = path.resolve('fixtures', 'nested');
const result = manager.mergeWithPkgConf({cwd});
const expected = Object.assign({}, parentConfig.xo, {cwd});
const expected = Object.assign({}, parentConfig.xo, {cwd}, {engines: {}});
t.deepEqual(result, expected);
});

test('mergeWithPkgConf: use parent if child is ignored', t => {
const cwd = path.resolve('fixtures', 'nested', 'child-ignore');
const result = manager.mergeWithPkgConf({cwd});
const expected = Object.assign({}, parentConfig.xo, {cwd});
const expected = Object.assign({}, parentConfig.xo, {cwd}, {engines: {}});
t.deepEqual(result, expected);
});

test('mergeWithPkgConf: use child if child is empty', t => {
const cwd = path.resolve('fixtures', 'nested', 'child-empty');
const result = manager.mergeWithPkgConf({cwd});
t.deepEqual(result, {cwd});
t.deepEqual(result, {cwd, engines: {}});
});

test('mergeWithPkgConf: read engines from package.json', t => {
const cwd = path.resolve('fixtures', 'engines');
const result = manager.mergeWithPkgConf({cwd});
const expected = Object.assign({}, {engines: enginesConfig.engines}, {cwd});
t.deepEqual(result, expected);
});

test('mergeWithPkgConf: XO engine options supersede package.json\'s', t => {
const cwd = path.resolve('fixtures', 'engines');
const result = manager.mergeWithPkgConf({cwd, engines: {node: '>=8'}});
const expected = Object.assign({}, {engines: {node: '>=8'}}, {cwd});
t.deepEqual(result, expected);
});

test('mergeWithPkgConf: XO engine options false supersede package.json\'s', t => {
const cwd = path.resolve('fixtures', 'engines');
const result = manager.mergeWithPkgConf({cwd, engines: false});
const expected = Object.assign({}, {engines: false}, {cwd});
t.deepEqual(result, expected);
});

0 comments on commit 0d18368

Please sign in to comment.