diff --git a/configs/recommended.js b/configs/recommended.js index 6d7182f2c9..40cbd87530 100644 --- a/configs/recommended.js +++ b/configs/recommended.js @@ -51,6 +51,7 @@ module.exports = { 'unicorn/no-static-only-class': 'error', 'unicorn/no-thenable': 'error', 'unicorn/no-this-assignment': 'error', + 'unicorn/no-unnecessary-polyfills': 'error', 'unicorn/no-unreadable-array-destructuring': 'error', 'unicorn/no-unsafe-regex': 'off', 'unicorn/no-unused-properties': 'off', diff --git a/docs/rules/no-unnecessary-polyfills.md b/docs/rules/no-unnecessary-polyfills.md new file mode 100644 index 0000000000..02718d31a9 --- /dev/null +++ b/docs/rules/no-unnecessary-polyfills.md @@ -0,0 +1,76 @@ +# Enforce the use of built-in methods instead of unnecessary polyfills + + + +✅ *This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.* + + +This rules helps to use existing methods instead of using extra polyfills. + +## Fail + +package.json + +```json +{ + "engines": { + "node": ">=8" + } +} +``` + +```js +const assign = require('object-assign'); +``` + +## Pass + +package.json + +```json +{ + "engines": { + "node": "4" + } +} +``` + +```js +const assign = require('object-assign'); // passes as Object.assign is not supported +``` + +## Options + +Type: `object` + +### targets + +Type: `string | string[] | object` + +The `targets` option allows to specify the target versions. This option could be a Browserlist query or a targets object, see [core-js-compat `targets` option](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js-compat#targets-option) for more informations. It could also be a Node.js target version (SemVer syntax supported) if the [`treatsTargetsAsSemver` option](#treatsTargetsAsSemver) is set to `true`. + +If the option is unspecified, the targets are taken from the `package.json`, from the `engines.node` field (as SemVer Node.js target version), or alternatively, from the `browserlist` field (as Browserlist query). This logic can be reversed by setting the [`useBrowserlistFieldByDefault` option](#useBrowserlistFieldByDefault) to `true`. + +```js +"unicorn/no-unnecessary-polyfills": ["error", { "targets": "node >=12" }] +``` + +```js +"unicorn/no-unnecessary-polyfills": ["error", { "targets": ["node 14.1.0", "chrome 95"] }] +``` + +```js +"unicorn/no-unnecessary-polyfills": ["error", { "targets": { "node": "current", "firefox": "15" } }] +``` + +### treatsTargetsAsSemver + +Type: `boolean` + +By default, the `targets` option is treated as a Browserlist query or a targets object. If you want to treat it as a Node.js target version (SemVer syntax supported), set this option to `true`. + +### useBrowserlistFieldByDefault + +Type: `boolean` + +When the `targets` option is not specified, the Node.js target version is taken from the `package.json` file, in `engines.node` field, and fallback to Browserlist targets, in `browserlist` field. If this option is set to `true`, the logic is reversed: the targets will be taken from the `browserlist` field, and fallback to `engines.node` field. diff --git a/package.json b/package.json index bdfee944ed..da1d88b81f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@babel/helper-validator-identifier": "^7.15.7", "ci-info": "^3.3.0", "clean-regexp": "^1.0.0", + "core-js-compat": "^3.21.0", "eslint-utils": "^3.0.0", "esquery": "^1.4.0", "indent-string": "^4.0.0", diff --git a/readme.md b/readme.md index 75ca706550..9942a8b6b0 100644 --- a/readme.md +++ b/readme.md @@ -92,6 +92,7 @@ Each rule has emojis denoting: | [no-static-only-class](docs/rules/no-static-only-class.md) | Forbid classes that only have static members. | ✅ | 🔧 | | | [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. | ✅ | | | | [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. | ✅ | | | +| [no-unnecessary-polyfills](docs/rules/no-unnecessary-polyfills.md) | Enforce the use of built-in methods instead of unnecessary polyfills. | ✅ | | | | [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) | Disallow unreadable array destructuring. | ✅ | 🔧 | | | [no-unsafe-regex](docs/rules/no-unsafe-regex.md) | Disallow unsafe regular expressions. | | | | | [no-unused-properties](docs/rules/no-unused-properties.md) | Disallow unused object properties. | | | | diff --git a/rules/no-unnecessary-polyfills.js b/rules/no-unnecessary-polyfills.js new file mode 100644 index 0000000000..cf85e7dd68 --- /dev/null +++ b/rules/no-unnecessary-polyfills.js @@ -0,0 +1,251 @@ +'use strict'; +const semver = require('semver'); +const readPkgUp = require('read-pkg-up'); +const coreJsCompat = require('core-js-compat'); +const {camelCase, upperFirst} = require('lodash'); + +const {data: compatData, entries: coreJsEntries} = coreJsCompat; + +const MESSAGE_ID_POLYFILL = 'unnecessaryPolyfill'; +const MESSAGE_ID_CORE_JS = 'unnecessaryCoreJsModule'; +const messages = { + [MESSAGE_ID_POLYFILL]: 'Use the built-in `{{featureName}}`.', + [MESSAGE_ID_CORE_JS]: 'All polyfilled features imported from `{{coreJsModule}}` are disponible as built-ins. Use the built-ins instead.', +}; + +function getTargetsFromPkg(cwd, useBrowserlistFieldByDefault) { + const result = readPkgUp.sync({cwd}); + if (!result || !result.pkg) { + return; + } + + const {browserlist} = result.pkg; + const nodeEngine = result.pkg.engines && result.pkg.engines.node && new SemverNodeVersion(result.pkg.engines.node); + return useBrowserlistFieldByDefault ? browserlist || nodeEngine : nodeEngine || browserlist; +} + +const constructorCaseExceptions = { + regexp: 'RegExp', +}; +function constructorCase(name) { + return constructorCaseExceptions[name] || upperFirst(camelCase(name)); +} + +const additionalPolyfillPatterns = { + 'es.promise.finally': '|(p-finally)', + 'es.object.set-prototype-of': '|(setprototypeof)', + 'es.string.code-point-at': '|(code-point-at)', +}; + +const prefixes = '(mdn-polyfills|polyfill-)'; +const suffixes = '(-polyfill)'; +const delimiter = '(\\.|-|\\.prototype\\.|/)?'; + +const polyfills = Object.keys(compatData).map(feature => { + let [ecmaVersion, constructorName, methodName = ''] = feature.split('.'); + + if (ecmaVersion === 'es') { + ecmaVersion = `(${ecmaVersion}\\d*)`; + } + + constructorName = `(${constructorName}|${camelCase(constructorName)})`; + if (methodName) { + methodName = `(${methodName}|${camelCase(methodName)})`; + } + + const methodOrConstructor = methodName || constructorName; + + return { + feature, + pattern: new RegExp( + `^((${prefixes}?` + + `(${ecmaVersion}${delimiter}${constructorName}${delimiter}${methodName}|` // Ex: es6-array-copy-within + + `${constructorName}${delimiter}${methodName}|` // Ex: array-copy-within + + `${ecmaVersion}${delimiter}${constructorName})` // Ex: es6-array + + `${suffixes}?)|` + + `(${prefixes}${methodOrConstructor}|${methodOrConstructor}${suffixes})` // Ex: polyfill-copy-within / polyfill-promise + + `${additionalPolyfillPatterns[feature] || ''})$`, + 'i', + ), + }; +}); + +function report(context, node, feature) { + let [ecmaVersion, namespace, method = ''] = feature.split('.'); + if (namespace === 'typed-array' && method.endsWith('-array')) { + namespace = method; + method = ''; + } + + const delimiter = method && (ecmaVersion === 'node' ? '.' : '#'); + + context.report({ + node, + messageId: MESSAGE_ID_POLYFILL, + data: { + featureName: `${constructorCase(namespace)}${delimiter}${camelCase(method)}`, + }, + }); +} + +class SemverNodeVersion { + constructor(nodeVersion) { + this.nodeVersion = nodeVersion; + this.validNodeVersion = semver.coerce(nodeVersion); + } + + compare(featureVersion) { + const supportedNodeVersion = semver.coerce(featureVersion); + return this.validNodeVersion + ? semver.lte(supportedNodeVersion, this.validNodeVersion) + : semver.ltr(supportedNodeVersion, this.nodeVersion); + } +} + +function processRule(context, node, moduleName, targets) { + if (!moduleName || typeof moduleName !== 'string') { + return; + } + + const nodeVersion = targets[0] instanceof SemverNodeVersion && targets[0]; + const importedModule = moduleName.replace(/([/\\].+?)\.[^.]+$/, '$1'); + + const unavailableFeatures = coreJsCompat({targets}).list; + const coreJsModuleFeatures = coreJsEntries[importedModule.replace('core-js-pure', 'core-js')]; + + const checkFeatures = features => nodeVersion + ? features.every(feature => compatData[feature].node && nodeVersion.compare(compatData[feature].node)) + : !features.every(feature => unavailableFeatures.includes(feature)); + + if (coreJsModuleFeatures) { + if (coreJsModuleFeatures.length === 1) { + if (nodeVersion ? nodeVersion.compare(compatData[coreJsModuleFeatures[0]].node) : !unavailableFeatures.includes(coreJsModuleFeatures[0])) { + report(context, node, coreJsModuleFeatures[0]); + } + } else if (checkFeatures(coreJsModuleFeatures)) { + context.report({ + node, + messageId: MESSAGE_ID_CORE_JS, + data: { + coreJsModule: moduleName, + }, + }); + } + + return; + } + + const polyfill = polyfills.find(({pattern}) => pattern.test(importedModule)); + if (polyfill) { + const [, namespace, method = ''] = polyfill.feature.split('.'); + const [, features] = Object.entries(coreJsEntries).find(it => it[0] === `core-js/actual/${namespace}${method && '/'}${method}`); + if (checkFeatures(features)) { + report(context, node, polyfill.feature); + } + } +} + +function create(context) { + const options = context.options[0]; + let targets = options && options.targets; + if (!targets) { + getTargetsFromPkg(context.getFilename(), options && options.useBrowserlistFieldByDefault); + } else if (options.treatsTargetsAsSemver) { + targets = new SemverNodeVersion(targets); + } + + if (!targets) { + return {}; + } + + return { + 'CallExpression[callee.name="require"]'(node) { + processRule(context, node, node.arguments[0].value, targets); + }, + 'ImportDeclaration, ImportExpression'(node) { + processRule(context, node, node.source.value, targets); + }, + }; +} + +const schema = [ + { + type: 'object', + additionalProperties: false, + required: ['targets'], + properties: { + useBrowserlistFieldByDefault: {type: 'boolean'}, + treatsTargetsAsSemver: {type: 'boolean'}, + targets: { + oneOf: [ + { + type: 'string', + minLength: 1, + }, + { + type: 'array', + minItems: 1, + items: { + type: 'string', + }, + }, + { + type: 'object', + minProperties: 1, + properties: { + android: {type: 'string'}, + chrome: {type: 'string'}, + deno: {type: 'string'}, + edge: {type: 'string'}, + electron: {type: 'string'}, + firefox: {type: 'string'}, + ie: {type: 'string'}, + ios: {type: 'string'}, + node: {type: 'string'}, + opera: {type: 'string'}, + // eslint-disable-next-line camelcase + opera_mobile: {type: 'string'}, + phantom: {type: 'string'}, + rhino: {type: 'string'}, + safari: {type: 'string'}, + samsung: {type: 'string'}, + esmodules: {type: 'boolean'}, + browsers: { + oneOf: [ + { + type: 'string', + minLength: 1, + }, + { + type: 'array', + minItems: 1, + items: { + type: 'string', + }, + }, + { + type: 'object', + minProperties: 1, + }, + ], + }, + }, + }, + ], + }, + }, + }, +]; + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce the use of built-in methods instead of unnecessary polyfills.', + }, + schema, + messages, + }, +}; diff --git a/test/no-unnecessary-polyfills.mjs b/test/no-unnecessary-polyfills.mjs new file mode 100644 index 0000000000..586a2d1abe --- /dev/null +++ b/test/no-unnecessary-polyfills.mjs @@ -0,0 +1,152 @@ +import {getTester} from './utils/test.mjs'; + +const {test} = getTester(import.meta); + +test({ + valid: [ + { + code: 'require("object-assign")', + options: [{targets: {node: '0.1.0'}}], + }, + { + code: 'require("this-is-not-a-polyfill")', + options: [{targets: {node: '0.1.0'}}], + }, + { + code: 'import assign from "object-assign"', + options: [{targets: {node: '0.1.0'}}], + }, + { + code: 'import("object-assign")', + options: [{targets: {node: '0.1.0'}}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node <4'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node >3'}], + }, + ], + invalid: [ + { + code: 'require("setprototypeof")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use the built-in `Object#setPrototypeOf`.'}], + }, + { + code: 'require("core-js/features/array/last-index-of")', + options: [{targets: 'node >6.5'}], + errors: [{message: 'Use the built-in `Array#lastIndexOf`.'}], + }, + { + code: 'require("core-js-pure/features/array/from")', + options: [{targets: 'node >7'}], + errors: [{message: 'All polyfilled features imported from `core-js-pure/features/array/from` are disponible as built-ins. Use the built-ins instead.'}], + }, + { + code: 'require("core-js/features/array/from")', + options: [{targets: 'node >7'}], + errors: [{message: 'All polyfilled features imported from `core-js/features/array/from` are disponible as built-ins. Use the built-ins instead.'}], + }, + // The feature esnext.typed-array.from-async polyfilled in this modules has not compat data + // { + // code: 'require("core-js/features/typed-array")', + // options: [{targets: 'node >16'}], + // errors: [{message: 'All polyfilled features imported from `core-js/features/typed-array` are disponible as built-ins. Use the built-ins instead.'}], + // }, + { + code: 'require("es6-symbol")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use the built-in `Symbol`.'}], + }, + { + code: 'require("code-point-at")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use the built-in `String#codePointAt`.'}], + }, + { + code: 'require("object.getownpropertydescriptors")', + options: [{targets: 'node >8'}], + errors: [{message: 'Use the built-in `Object#getOwnPropertyDescriptors`.'}], + }, + { + code: 'require("string.prototype.padstart")', + options: [{targets: 'node >8'}], + errors: [{message: 'Use the built-in `String#padStart`.'}], + + }, + { + code: 'require("p-finally")', + options: [{targets: 'node >10.4'}], + errors: [{message: 'Use the built-in `Promise#finally`.'}], + + }, + { + code: 'require("promise-polyfill")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use the built-in `Promise`.'}], + }, + { + code: 'require("es6-promise")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use the built-in `Promise`.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node 6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'import assign from "object-assign"', + options: [{targets: 'node 6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node >6'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node 8'}], + errors: [{message: 'Use the built-in `Object#assign`.'}], + }, + { + code: 'require("array-from")', + options: [{targets: 'node >7'}], + errors: [{message: 'Use the built-in `Array#from`.'}], + }, + { + code: 'require("array.prototype.every")', + options: [{treatsTargetsAsSemver: true, targets: '~4.0.0'}], + errors: [{message: 'Use the built-in `Array#every`.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node >4.0.0'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{treatsTargetsAsSemver: true, targets: '>=4 <5.2 || >6.0.0'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node 4'}], + errors: [{message: 'Use the built-in `Array#findIndex`.'}], + }, + { + code: 'require("weakmap-polyfill")', + options: [{targets: 'node 12'}], + errors: [{message: 'Use the built-in `WeakMap`.'}], + }, + ], +});