diff --git a/configs/recommended.js b/configs/recommended.js index 3fc176107c..ecbedcd90c 100644 --- a/configs/recommended.js +++ b/configs/recommended.js @@ -54,6 +54,7 @@ module.exports = { 'unicorn/no-this-assignment': 'error', 'unicorn/no-typeof-undefined': 'error', 'unicorn/no-unnecessary-await': 'error', + 'unicorn/no-unnecessary-polyfills': 'error', 'unicorn/no-unreadable-array-destructuring': 'error', 'unicorn/no-unreadable-iife': 'error', '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..c888e32a43 --- /dev/null +++ b/docs/rules/no-unnecessary-polyfills.md @@ -0,0 +1,91 @@ +# Enforce the use of built-in methods instead of unnecessary polyfills + +💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs). + + + + + + +✅ _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` + +Specify the target versions, which could be a Browserlist query or a targets object. See the [core-js-compat `targets` option](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js-compat#targets-option) for more info. + +If the option is not specified, the target versions are defined using the [`browserlist`](https://browsersl.ist) field in package.json, or as a last resort, the `engines` field in package.json. + +```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" + } + } +] +``` diff --git a/package.json b/package.json index 0a12038c04..8c8ff46bc0 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@eslint-community/eslint-utils": "^4.4.0", "ci-info": "^3.8.0", "clean-regexp": "^1.0.0", + "core-js-compat": "^3.33.2", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", diff --git a/readme.md b/readme.md index 449bf0e5d8..307e38bdbe 100644 --- a/readme.md +++ b/readme.md @@ -96,6 +96,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c | [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. | ✅ | | | | [no-typeof-undefined](docs/rules/no-typeof-undefined.md) | Disallow comparing `undefined` using `typeof`. | ✅ | 🔧 | 💡 | | [no-unnecessary-await](docs/rules/no-unnecessary-await.md) | Disallow awaiting non-promise values. | ✅ | 🔧 | | +| [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-unreadable-iife](docs/rules/no-unreadable-iife.md) | Disallow unreadable IIFEs. | ✅ | | | | [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..c85165cfd7 --- /dev/null +++ b/rules/no-unnecessary-polyfills.js @@ -0,0 +1,176 @@ +'use strict'; +const path = require('node:path'); +const readPkgUp = require('read-pkg-up'); +const coreJsCompat = require('core-js-compat'); +const {camelCase} = require('lodash'); +const isStaticRequire = require('./ast/is-static-require.js'); + +const {data: compatData, entries: coreJsEntries} = coreJsCompat; + +const MESSAGE_ID_POLYFILL = 'unnecessaryPolyfill'; +const MESSAGE_ID_CORE_JS = 'unnecessaryCoreJsModule'; +const messages = { + [MESSAGE_ID_POLYFILL]: 'Use built-in instead.', + [MESSAGE_ID_CORE_JS]: + 'All polyfilled features imported from `{{coreJsModule}}` are available as built-ins. Use the built-ins instead.', +}; + +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 = '(es\\d*)'; + } + + constructorName = `(${constructorName}|${camelCase(constructorName)})`; + methodName &&= `(${methodName}|${camelCase(methodName)})`; + + const methodOrConstructor = methodName || constructorName; + + const patterns = [ + `^((${prefixes}?(`, + methodName && `(${ecmaVersion}${delimiter}${constructorName}${delimiter}${methodName})|`, // Ex: es6-array-copy-within + methodName && `(${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] || ''})$`, + ]; + + return { + feature, + pattern: new RegExp(patterns.join(''), 'i'), + }; +}); + +function getTargets(options, dirname) { + if (options?.targets) { + return options.targets; + } + + /** @type {readPkgUp.ReadResult | undefined} */ + let packageResult; + try { + // It can fail if, for example, the package.json file has comments. + packageResult = readPkgUp.sync({normalize: false, cwd: dirname}); + } catch {} + + if (!packageResult) { + return; + } + + const {browserlist, engines} = packageResult.packageJson; + return browserlist ?? engines; +} + +function create(context) { + const targets = getTargets(context.options[0], path.dirname(context.filename)); + if (!targets) { + return {}; + } + + let unavailableFeatures; + try { + unavailableFeatures = coreJsCompat({targets}).list; + } catch { + // This can happen if the targets are invalid or use unsupported syntax like `{node:'*'}`. + return {}; + } + + const checkFeatures = features => !features.every(feature => unavailableFeatures.includes(feature)); + + return { + Literal(node) { + if ( + !( + (['ImportDeclaration', 'ImportExpression'].includes(node.parent.type) && node.parent.source === node) + || (isStaticRequire(node.parent) && node.parent.arguments[0] === node) + ) + ) { + return; + } + + const importedModule = node.value; + if (typeof importedModule !== 'string' || ['.', '/'].includes(importedModule[0])) { + return; + } + + const coreJsModuleFeatures = coreJsEntries[importedModule.replace('core-js-pure', 'core-js')]; + + if (coreJsModuleFeatures) { + if (coreJsModuleFeatures.length > 1) { + if (checkFeatures(coreJsModuleFeatures)) { + return { + node, + messageId: MESSAGE_ID_CORE_JS, + data: { + coreJsModule: importedModule, + }, + }; + } + } else if (!unavailableFeatures.includes(coreJsModuleFeatures[0])) { + return {node, messageId: MESSAGE_ID_POLYFILL}; + } + + return; + } + + const polyfill = polyfills.find(({pattern}) => pattern.test(importedModule)); + if (polyfill) { + const [, namespace, method = ''] = polyfill.feature.split('.'); + const [, features] = Object.entries(coreJsEntries).find( + entry => entry[0] === `core-js/full/${namespace}${method && '/'}${method}`, + ); + if (checkFeatures(features)) { + return {node, messageId: MESSAGE_ID_POLYFILL}; + } + } + }, + }; +} + +const schema = [ + { + type: 'object', + additionalProperties: false, + required: ['targets'], + properties: { + targets: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + }, + { + type: 'object', + }, + ], + }, + }, + }, +]; + +/** @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..6d02ed4f39 --- /dev/null +++ b/test/no-unnecessary-polyfills.mjs @@ -0,0 +1,176 @@ +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'}], + }, + { + code: 'require()', + options: [{targets: 'node >3'}], + }, + { + code: 'import("")', + options: [{targets: 'node >3'}], + }, + { + code: 'import(null)', + options: [{targets: 'node >3'}], + }, + { + code: 'require(null)', + options: [{targets: 'node >3'}], + }, + { + code: 'require("" )', + options: [{targets: 'node >3'}], + }, + ], + invalid: [ + { + code: 'require("setprototypeof")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("core-js/features/array/last-index-of")', + options: [{targets: 'node >6.5'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + 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 available 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 available as built-ins. Use the built-ins instead.'}], + }, + { + code: 'require("core-js/features/typed-array")', + options: [{targets: 'node >16'}], + errors: [{message: 'All polyfilled features imported from `core-js/features/typed-array` are available as built-ins. Use the built-ins instead.'}], + }, + { + code: 'require("es6-symbol")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("code-point-at")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("object.getownpropertydescriptors")', + options: [{targets: 'node >8'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("string.prototype.padstart")', + options: [{targets: 'node >8'}], + errors: [{message: 'Use built-in instead.'}], + + }, + { + code: 'require("p-finally")', + options: [{targets: 'node >10.4'}], + errors: [{message: 'Use built-in instead.'}], + + }, + { + code: 'require("promise-polyfill")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("es6-promise")', + options: [{targets: 'node >15'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node 6'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'import assign from "object-assign"', + options: [{targets: 'node 6'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'import("object-assign")', + options: [{targets: 'node 6'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node >6'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("object-assign")', + options: [{targets: 'node 8'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("array-from")', + options: [{targets: 'node >7'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node >4.0.0'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node >4'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("array-find-index")', + options: [{targets: 'node 4'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("mdn-polyfills/Array.prototype.findIndex")', + options: [{targets: 'node 4'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("weakmap-polyfill")', + options: [{targets: 'node 12'}], + errors: [{message: 'Use built-in instead.'}], + }, + { + code: 'require("typed-array-float64-array-polyfill")', + options: [{targets: 'node 17'}], + errors: [{message: 'Use built-in instead.'}], + }, + ], +});