routie dev init since i didn't adhere to any proper guidance up until now

This commit is contained in:
2026-04-29 22:27:29 -06:00
commit e1dabb71e2
15301 changed files with 3562618 additions and 0 deletions
@@ -0,0 +1,118 @@
/**
@typedef {
{
name?: string,
names?: string[],
argumentsLength?: number,
minimumArguments?: number,
maximumArguments?: number,
allowSpreadElement?: boolean,
optional?: boolean,
} | string | string[]
} CallOrNewExpressionCheckOptions
*/
// eslint-disable-next-line complexity
function create(node, options, types) {
if (!types.includes(node?.type)) {
return false;
}
if (typeof options === 'string') {
options = {names: [options]};
}
if (Array.isArray(options)) {
options = {names: options};
}
let {
name,
names,
argumentsLength,
minimumArguments,
maximumArguments,
allowSpreadElement,
optional,
} = {
minimumArguments: 0,
maximumArguments: Number.POSITIVE_INFINITY,
allowSpreadElement: false,
...options,
};
if (name) {
names = [name];
}
if (
(optional === true && (node.optional !== optional))
|| (
optional === false
// `node.optional` can be `undefined` in some parsers
&& node.optional
)
) {
return false;
}
if (typeof argumentsLength === 'number' && node.arguments.length !== argumentsLength) {
return false;
}
if (minimumArguments !== 0 && node.arguments.length < minimumArguments) {
return false;
}
if (Number.isFinite(maximumArguments) && node.arguments.length > maximumArguments) {
return false;
}
if (!allowSpreadElement) {
const maximumArgumentsLength = Number.isFinite(maximumArguments) ? maximumArguments : argumentsLength;
if (
typeof maximumArgumentsLength === 'number'
&& node.arguments.some((node, index) =>
node.type === 'SpreadElement'
&& index < maximumArgumentsLength)
) {
return false;
}
}
if (
Array.isArray(names)
&& names.length > 0
&& (
node.callee.type !== 'Identifier'
|| !names.includes(node.callee.name)
)
) {
return false;
}
return true;
}
/**
@param {CallOrNewExpressionCheckOptions} [options]
@returns {boolean}
*/
export const isCallExpression = (node, options) => create(node, options, ['CallExpression']);
/**
@param {CallOrNewExpressionCheckOptions} [options]
@returns {boolean}
*/
export const isNewExpression = (node, options) => {
if (typeof options?.optional === 'boolean') {
throw new TypeError('Cannot check node.optional in `isNewExpression`.');
}
return create(node, options, ['NewExpression']);
};
/**
@param {CallOrNewExpressionCheckOptions} [options]
@returns {boolean}
*/
export const isCallOrNewExpression = (node, options) => create(node, options, ['CallExpression', 'NewExpression']);
@@ -0,0 +1,7 @@
const functionTypes = [
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
];
export default functionTypes;
+31
View File
@@ -0,0 +1,31 @@
export {
isLiteral,
isStringLiteral,
isNumericLiteral,
isBigIntLiteral,
isNullLiteral,
isRegexLiteral,
isEmptyStringLiteral,
} from './literal.js';
export {
isNewExpression,
isCallExpression,
isCallOrNewExpression,
} from './call-or-new-expression.js';
export {default as isArrowFunctionBody} from './is-arrow-function-body.js';
export {default as isDirective} from './is-directive.js';
export {default as isEmptyNode} from './is-empty-node.js';
export {default as isEmptyArrayExpression} from './is-empty-array-expression.js';
export {default as isEmptyObjectExpression} from './is-empty-object-expression.js';
export {default as isExpressionStatement} from './is-expression-statement.js';
export {default as isFunction} from './is-function.js';
export {default as isMemberExpression} from './is-member-expression.js';
export {default as isMethodCall} from './is-method-call.js';
export {default as isNegativeOne} from './is-negative-one.js';
export {default as isReferenceIdentifier} from './is-reference-identifier.js';
export {default as isStaticRequire} from './is-static-require.js';
export {default as isTaggedTemplateLiteral} from './is-tagged-template-literal.js';
export {default as isUndefined} from './is-undefined.js';
export {default as functionTypes} from './function-types.js';
@@ -0,0 +1,3 @@
export default function isArrowFunctionBody(node) {
return node.parent.type === 'ArrowFunctionExpression' && node.parent.body === node;
}
@@ -0,0 +1,4 @@
const isDirective = node => node.type === 'ExpressionStatement'
&& typeof node.directive === 'string';
export default isDirective;
@@ -0,0 +1,5 @@
const isEmptyArrayExpression = node =>
node.type === 'ArrayExpression'
&& node.elements.length === 0;
export default isEmptyArrayExpression;
+17
View File
@@ -0,0 +1,17 @@
export default function isEmptyNode(node, additionalEmpty) {
const {type} = node;
if (type === 'BlockStatement') {
return node.body.every(currentNode => isEmptyNode(currentNode, additionalEmpty));
}
if (type === 'EmptyStatement') {
return true;
}
if (additionalEmpty?.(node)) {
return true;
}
return false;
}
@@ -0,0 +1,5 @@
const isEmptyArrayExpression = node =>
node.type === 'ObjectExpression'
&& node.properties.length === 0;
export default isEmptyArrayExpression;
@@ -0,0 +1,7 @@
export default function isExpressionStatement(node) {
return node.type === 'ExpressionStatement'
|| (
node.type === 'ChainExpression'
&& node.parent.type === 'ExpressionStatement'
);
}
+5
View File
@@ -0,0 +1,5 @@
import functionTypes from './function-types.js';
export default function isFunction(node) {
return functionTypes.includes(node.type);
}
@@ -0,0 +1,98 @@
/* eslint-disable complexity */
/**
@param {
{
property?: string,
properties?: string[],
object?: string,
objects?: string[],
optional?: boolean,
computed?: boolean
} | string | string[]
} [options]
@returns {string}
*/
export default function isMemberExpression(node, options) {
if (node?.type !== 'MemberExpression') {
return false;
}
if (typeof options === 'string') {
options = {properties: [options]};
}
if (Array.isArray(options)) {
options = {properties: options};
}
let {
property,
properties,
object,
objects,
optional,
computed,
} = {
property: '',
properties: [],
object: '',
...options,
};
if (property) {
properties = [property];
}
if (object) {
objects = [object];
}
if (
(optional === true && (node.optional !== optional))
|| (
optional === false
// `node.optional` can be `undefined` in some parsers
&& node.optional
)
) {
return false;
}
if (
Array.isArray(properties)
&& properties.length > 0
) {
if (
node.property.type !== 'Identifier'
|| !properties.includes(node.property.name)
) {
return false;
}
computed ??= false;
}
if (
(computed === true && (node.computed !== computed))
|| (
computed === false
// `node.computed` can be `undefined` in some parsers
&& node.computed
)
) {
return false;
}
if (
Array.isArray(objects)
&& objects.length > 0
&& (
node.object.type !== 'Identifier'
|| !objects.includes(node.object.name)
)
) {
return false;
}
return true;
}
@@ -0,0 +1,62 @@
import isMemberExpression from './is-member-expression.js';
import {isCallExpression} from './call-or-new-expression.js';
/**
@param {
{
// `isCallExpression` options
argumentsLength?: number,
minimumArguments?: number,
maximumArguments?: number,
optionalCall?: boolean,
allowSpreadElement?: boolean,
// `isMemberExpression` options
method?: string,
methods?: string[],
object?: string,
objects?: string[],
optionalMember?: boolean,
computed?: boolean
} | string | string[]
} [options]
@returns {string}
*/
export default function isMethodCall(node, options) {
if (typeof options === 'string') {
options = {methods: [options]};
}
if (Array.isArray(options)) {
options = {methods: options};
}
const {
optionalCall,
optionalMember,
method,
methods,
} = {
method: '',
methods: [],
...options,
};
return (
isCallExpression(node, {
argumentsLength: options.argumentsLength,
minimumArguments: options.minimumArguments,
maximumArguments: options.maximumArguments,
allowSpreadElement: options.allowSpreadElement,
optional: optionalCall,
})
&& isMemberExpression(node.callee, {
object: options.object,
objects: options.objects,
computed: options.computed,
property: method,
properties: methods,
optional: optionalMember,
})
);
}
@@ -0,0 +1,8 @@
import {isNumericLiteral} from './literal.js';
export default function isNegativeOne(node) {
return node?.type === 'UnaryExpression'
&& node.operator === '-'
&& isNumericLiteral(node.argument)
&& node.argument.value === 1;
}
@@ -0,0 +1,159 @@
// eslint-disable-next-line complexity
function isNotReference(node) {
const {parent} = node;
switch (parent.type) {
// `foo.Identifier`
case 'MemberExpression': {
return !parent.computed && parent.property === node;
}
case 'FunctionDeclaration':
case 'FunctionExpression': {
return (
// `function foo(Identifier) {}`
// `const foo = function(Identifier) {}`
parent.params.includes(node)
// `function Identifier() {}`
// `const foo = function Identifier() {}`
|| parent.id === node
);
}
case 'ArrowFunctionExpression': {
// `const foo = (Identifier) => {}`
return parent.params.includes(node);
}
// `class Identifier() {}`
// `const foo = class Identifier() {}`
// `const Identifier = 1`
case 'ClassDeclaration':
case 'ClassExpression':
case 'VariableDeclarator': {
return parent.id === node;
}
// `class Foo {Identifier = 1}`
// `class Foo {Identifier() {}}`
case 'PropertyDefinition':
case 'MethodDefinition': {
return !parent.computed && parent.key === node;
}
// `const foo = {Identifier: 1}`
// `const {Identifier} = {}`
// `const {Identifier: foo} = {}`
// `const {Identifier} = {}`
// `const {foo: Identifier} = {}`
case 'Property': {
return (
(
!parent.computed
&& parent.key === node
&& (
(parent.parent.type === 'ObjectExpression' || parent.parent.type === 'ObjectPattern')
&& parent.parent.properties.includes(parent)
)
)
|| (
parent.value === node
&& parent.parent.type === 'ObjectPattern'
&& parent.parent.properties.includes(parent)
)
);
}
// `const [Identifier] = []`
case 'ArrayPattern': {
return parent.elements.includes(node);
}
/*
```
Identifier: for (const foo of bar) {
continue Identifier;
break Identifier;
}
```
*/
case 'LabeledStatement':
case 'ContinueStatement':
case 'BreakStatement': {
return parent.label === node;
}
// `import * as Identifier from 'foo'`
// `import Identifier from 'foo'`
case 'ImportDefaultSpecifier':
case 'ImportNamespaceSpecifier': {
return parent.local === node;
}
// `export * as Identifier from 'foo'`
case 'ExportAllDeclaration': {
return parent.exported === node;
}
// `import {foo as Identifier} from 'foo'`
// `import {Identifier as foo} from 'foo'`
case 'ImportSpecifier': {
return (parent.local === node || parent.imported === node);
}
// `export {foo as Identifier}`
// `export {Identifier as foo}`
case 'ExportSpecifier': {
return (parent.local === node || parent.exported === node);
}
// TypeScript
case 'TSDeclareFunction':
case 'TSEnumMember': {
return parent.id === node;
}
// `type Foo = { [Identifier: string]: string }`
case 'TSIndexSignature': {
return parent.parameters.includes(node);
}
// `@typescript-eslint/parse` v7
// `type Foo = { [Identifier in keyof string]: number; };`
case 'TSTypeParameter': {
return parent.name === node;
}
// `@typescript-eslint/parse` v8
// `type Foo = { [Identifier in keyof string]: number; };`
case 'TSMappedType': {
return parent.key === node;
}
// `type Identifier = Foo`
case 'TSTypeAliasDeclaration': {
return parent.id === node;
}
case 'TSPropertySignature': {
return parent.key === node;
}
// No default
}
return false;
}
export default function isReferenceIdentifier(node, nameOrNames = []) {
if (node.type !== 'Identifier') {
return false;
}
const names = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames];
if (names.length > 0 && !names.includes(node.name)) {
return false;
}
return !isNotReference(node);
}
@@ -0,0 +1,10 @@
import {isStringLiteral} from './literal.js';
import {isCallExpression} from './call-or-new-expression.js';
const isStaticRequire = node => isCallExpression(node, {
name: 'require',
argumentsLength: 1,
optional: false,
}) && isStringLiteral(node.arguments[0]);
export default isStaticRequire;
@@ -0,0 +1,24 @@
import {isNodeMatches} from '../utils/is-node-matches.js';
/**
Check if the given node is a tagged template literal.
@param {Node} node - The AST node to check.
@param {string[]} tags - The object name or key paths.
@returns {boolean}
*/
export default function isTaggedTemplateLiteral(node, tags) {
if (
node.type !== 'TemplateLiteral'
|| node.parent.type !== 'TaggedTemplateExpression'
|| node.parent.quasi !== node
) {
return false;
}
if (tags) {
return isNodeMatches(node.parent.tag, tags);
}
return true;
}
@@ -0,0 +1,3 @@
export default function isUndefined(node) {
return node?.type === 'Identifier' && node.name === 'undefined';
}
+19
View File
@@ -0,0 +1,19 @@
export function isLiteral(node, value) {
if (node?.type !== 'Literal') {
return false;
}
return node.value === value;
}
export const isStringLiteral = node => node?.type === 'Literal' && typeof node.value === 'string';
export const isNumericLiteral = node => node.type === 'Literal' && typeof node.value === 'number';
export const isRegexLiteral = node => node.type === 'Literal' && Boolean(node.regex);
export const isNullLiteral = node => node?.type === 'Literal' && node.raw === 'null';
export const isBigIntLiteral = node => node.type === 'Literal' && Boolean(node.bigint);
export const isEmptyStringLiteral = node => isLiteral(node, '');
+155
View File
@@ -0,0 +1,155 @@
import cleanRegexp from 'clean-regexp';
import regexpTree from 'regexp-tree';
import escapeString from './utils/escape-string.js';
import {isStringLiteral, isNewExpression, isRegexLiteral} from './ast/index.js';
const MESSAGE_ID = 'better-regex';
const MESSAGE_ID_PARSE_ERROR = 'better-regex/parse-error';
const messages = {
[MESSAGE_ID]: '{{original}} can be optimized to {{optimized}}.',
[MESSAGE_ID_PARSE_ERROR]: 'Problem parsing {{original}}: {{error}}',
};
// `regexp-tree` can optimize `/|/` into `//`, which is not valid JavaScript syntax.
// Normalize to an explicit empty alternative so the autofix always stays parseable.
const normalizeOptimizedRegexLiteral = optimizedRegexLiteral => (
optimizedRegexLiteral.startsWith('//')
? `/(?:)/${optimizedRegexLiteral.slice(2)}`
: optimizedRegexLiteral
);
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sortCharacterClasses} = context.options[0];
const ignoreList = [];
if (sortCharacterClasses === false) {
ignoreList.push('charClassClassrangesMerge');
}
context.on('Literal', node => {
if (!isRegexLiteral(node)) {
return;
}
const {raw: original, regex} = node;
// Regular Expressions with `u` and `v` flag are not well handled by `regexp-tree`
// https://github.com/DmitrySoshnikov/regexp-tree/issues/162
if (regex.flags.includes('u') || regex.flags.includes('v')) {
return;
}
let optimized = original;
try {
optimized = regexpTree.optimize(original, undefined, {blacklist: ignoreList}).toString();
optimized = normalizeOptimizedRegexLiteral(optimized);
} catch (error) {
return {
node,
messageId: MESSAGE_ID_PARSE_ERROR,
data: {
original,
error: error.message,
},
};
}
if (original === optimized) {
return;
}
const problem = {
node,
messageId: MESSAGE_ID,
data: {
original,
optimized,
},
};
if (
node.parent.type === 'MemberExpression'
&& node.parent.object === node
&& !node.parent.optional
&& !node.parent.computed
&& node.parent.property.type === 'Identifier'
&& (
node.parent.property.name === 'toString'
|| node.parent.property.name === 'source'
)
) {
return problem;
}
return Object.assign(problem, {
fix: fixer => fixer.replaceText(node, optimized),
});
});
context.on('NewExpression', node => {
if (!isNewExpression(node, {name: 'RegExp', minimumArguments: 1})) {
return;
}
const [patternNode, flagsNode] = node.arguments;
if (!isStringLiteral(patternNode)) {
return;
}
const oldPattern = patternNode.value;
const flags = isStringLiteral(flagsNode)
? flagsNode.value
: '';
const newPattern = cleanRegexp(oldPattern, flags);
if (oldPattern !== newPattern) {
return {
node,
messageId: MESSAGE_ID,
data: {
original: oldPattern,
optimized: newPattern,
},
fix: fixer => fixer.replaceText(
patternNode,
escapeString(newPattern, patternNode.raw.charAt(0)),
),
};
}
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
sortCharacterClasses: {
type: 'boolean',
description: 'Whether to sort character classes.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Improve regexes by making them shorter, consistent, and safer.',
recommended: false,
},
fixable: 'code',
schema,
defaultOptions: [{sortCharacterClasses: true}],
messages,
},
};
export default config;
+132
View File
@@ -0,0 +1,132 @@
import {isRegExp} from 'node:util/types';
import {findVariable} from '@eslint-community/eslint-utils';
import {getAvailableVariableName, upperFirst} from './utils/index.js';
import {renameVariable} from './fix/index.js';
import {isMethodCall} from './ast/index.js';
const MESSAGE_ID = 'catch-error-name';
const messages = {
[MESSAGE_ID]: 'The catch parameter `{{originalName}}` should be named `{{fixedName}}`.',
};
// - `promise.then(…, foo => {})`
// - `promise.then(…, function(foo) {})`
// - `promise.catch(foo => {})`
// - `promise.catch(function(foo) {})`
const isPromiseCatchParameter = node =>
(node.parent.type === 'FunctionExpression' || node.parent.type === 'ArrowFunctionExpression')
&& node.parent.params[0] === node
&& (
isMethodCall(node.parent.parent, {
method: 'then',
argumentsLength: 2,
optionalCall: false,
})
|| isMethodCall(node.parent.parent, {
method: 'catch',
argumentsLength: 1,
optionalCall: false,
})
)
&& node.parent.parent.arguments.at(-1) === node.parent;
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const options = context.options[0];
const {name: expectedName} = options;
const ignore = options.ignore.map(pattern => isRegExp(pattern) ? pattern : new RegExp(pattern, 'u'));
const isNameAllowed = name =>
name === expectedName
|| ignore.some(regexp => regexp.test(name))
|| name.endsWith(expectedName)
|| name.endsWith(upperFirst(expectedName));
context.on('Identifier', node => {
if (
!(node.parent.type === 'CatchClause' && node.parent.param === node)
&& !isPromiseCatchParameter(node)
) {
return;
}
const originalName = node.name;
if (
isNameAllowed(originalName)
|| isNameAllowed(originalName.replaceAll(/_+$/g, ''))
) {
return;
}
const scope = context.sourceCode.getScope(node);
const variable = findVariable(scope, node);
// This was reported https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1075#issuecomment-768072967
// But can't reproduce, just ignore this case
/* c8 ignore next 3 */
if (!variable) {
return;
}
if (originalName === '_' && variable.references.length === 0) {
return;
}
const scopes = [
variable.scope,
...variable.references.map(({from}) => from),
];
const fixedName = getAvailableVariableName(expectedName, scopes);
const problem = {
node,
messageId: MESSAGE_ID,
data: {
originalName,
fixedName: fixedName || expectedName,
},
};
if (fixedName) {
problem.fix = fixer => renameVariable(variable, fixedName, context, fixer);
}
return problem;
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
name: {
type: 'string',
description: 'The expected name for the error variable.',
},
ignore: {
type: 'array',
uniqueItems: true,
description: 'Patterns to ignore.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce a specific parameter name in catch clauses.',
recommended: true,
},
fixable: 'code',
schema,
defaultOptions: [{name: 'error', ignore: []}],
messages,
},
};
export default config;
+100
View File
@@ -0,0 +1,100 @@
const MESSAGE_ID_ERROR = 'consistent-assert/error';
const messages = {
[MESSAGE_ID_ERROR]: 'Prefer `{{name}}.ok(…)` over `{{name}}(…)`.',
};
/**
@param {import('estree').ImportSpecifier | import('estree').ImportDefaultSpecifier | import('estree').ImportSpecifier | import('estree').ImportDeclaration} node
*/
const isValueImport = node => !node.importKind || node.importKind === 'value';
/**
Check if a specifier is `assert` function.
@param {import('estree').ImportSpecifier | import('estree').ImportDefaultSpecifier} specifier
@param {string} moduleName
*/
const isAssertFunction = (specifier, moduleName) =>
// `import assert from 'node:assert';`
// `import assert from 'node:assert/strict';`
specifier.type === 'ImportDefaultSpecifier'
// `import {default as assert} from 'node:assert';`
// `import {default as assert} from 'node:assert/strict';`
|| (
specifier.type === 'ImportSpecifier'
&& specifier.imported.type === 'Identifier'
&& specifier.imported.name === 'default'
)
// `import {strict as assert} from 'node:assert';`
|| (
moduleName === 'assert'
&& specifier.type === 'ImportSpecifier'
&& specifier.imported.type === 'Identifier'
&& specifier.imported.name === 'strict'
);
const NODE_PROTOCOL = 'node:';
/** @type {import('eslint').Rule.RuleModule['create']} */
const create = context => {
context.on('ImportDeclaration', function * (importDeclaration) {
if (!isValueImport(importDeclaration)) {
return;
}
let moduleName = importDeclaration.source.value;
if (moduleName.startsWith(NODE_PROTOCOL)) {
moduleName = moduleName.slice(NODE_PROTOCOL.length);
}
if (moduleName !== 'assert' && moduleName !== 'assert/strict') {
return;
}
for (const specifier of importDeclaration.specifiers) {
if (!isValueImport(specifier) || !isAssertFunction(specifier, moduleName)) {
continue;
}
const variables = context.sourceCode.getDeclaredVariables(specifier);
/* c8 ignore next 3 */
if (!Array.isArray(variables) && variables.length === 1) {
continue;
}
const [variable] = variables;
for (const {identifier} of variable.references) {
if (!(identifier.parent.type === 'CallExpression' && identifier.parent.callee === identifier)) {
continue;
}
yield {
node: identifier,
messageId: MESSAGE_ID_ERROR,
data: {name: identifier.name},
/** @param {import('eslint').Rule.RuleFixer} fixer */
fix: fixer => fixer.insertTextAfter(identifier, '.ok'),
};
}
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce consistent assertion style with `node:assert`.',
recommended: true,
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,54 @@
import {isMethodCall, isNewExpression} from './ast/index.js';
import {removeMethodCall} from './fix/index.js';
const MESSAGE_ID_ERROR = 'consistent-date-clone/error';
const messages = {
[MESSAGE_ID_ERROR]: 'Unnecessary `.getTime()` call.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('NewExpression', newExpression => {
if (!isNewExpression(newExpression, {name: 'Date', argumentsLength: 1})) {
return;
}
const [callExpression] = newExpression.arguments;
if (!isMethodCall(callExpression, {
method: 'getTime',
argumentsLength: 0,
optionalCall: false,
optionalMember: false,
})) {
return;
}
const {sourceCode} = context;
return {
node: callExpression,
loc: {
start: sourceCode.getLoc(callExpression.callee.property).start,
end: sourceCode.getLoc(callExpression).end,
},
messageId: MESSAGE_ID_ERROR,
fix: fixer => removeMethodCall(fixer, callExpression, context),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer passing `Date` directly to the constructor when cloning.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,291 @@
import {findVariable} from '@eslint-community/eslint-utils';
import {getAvailableVariableName, isLeftHandSide} from './utils/index.js';
import {isCallOrNewExpression} from './ast/index.js';
const MESSAGE_ID = 'consistentDestructuring';
const MESSAGE_ID_SUGGEST = 'consistentDestructuringSuggest';
const isSimpleExpression = expression => {
while (expression) {
if (expression.computed) {
return false;
}
if (expression.type !== 'MemberExpression') {
break;
}
expression = expression.object;
}
return expression.type === 'Identifier'
|| expression.type === 'ThisExpression';
};
const isChildInParentScope = (child, parent) => {
while (child) {
if (child === parent) {
return true;
}
child = child.upper;
}
return false;
};
const getRootIdentifier = expression => {
while (expression?.type === 'MemberExpression') {
expression = expression.object;
}
return expression?.type === 'Identifier' ? expression : undefined;
};
const hasRestElement = pattern => {
switch (pattern?.type) {
case 'RestElement': {
return true;
}
case 'AssignmentPattern': {
return hasRestElement(pattern.left);
}
case 'ObjectPattern': {
return pattern.properties.some(property =>
hasRestElement(property.type === 'Property' ? property.value : property));
}
case 'ArrayPattern': {
return pattern.elements.some(element =>
hasRestElement(element));
}
default: {
return false;
}
}
};
const isIdentifierProperty = property =>
property.type === 'Property'
&& property.key.type === 'Identifier';
const hasNestedRestElement = pattern => {
switch (pattern?.type) {
case 'AssignmentPattern': {
return hasNestedRestElement(pattern.left);
}
case 'ObjectPattern': {
return pattern.properties.some(property => {
if (property.type === 'RestElement') {
return false;
}
return hasRestElement(property.value);
});
}
case 'ArrayPattern': {
return pattern.elements.some(element => {
if (!element || element.type === 'RestElement') {
return false;
}
return hasRestElement(element);
});
}
default: {
return false;
}
}
};
const isMemberDestructuredInNestedPatternWithRest = (objectPattern, memberName) =>
objectPattern.properties.some(property =>
isIdentifierProperty(property)
&& property.key.name === memberName
&& property.value.type !== 'Identifier'
&& hasNestedRestElement(property.value));
const isRootVariableReassigned = (declaration, memberExpressionNode, memberScope, sourceCode) => {
if (!declaration.rootVariable) {
return false;
}
const [, declarationEnd] = sourceCode.getRange(declaration.object);
const [memberStart] = sourceCode.getRange(memberExpressionNode);
return declaration.rootVariable.references.some(reference => {
if (!reference.isWrite()) {
return false;
}
const [referenceStart] = sourceCode.getRange(reference.identifier);
if (referenceStart < declarationEnd) {
return false;
}
// Be conservative: writes from other variable scopes may run before this read via calls/closures.
if (reference.from.variableScope !== memberScope.variableScope) {
return true;
}
return referenceStart <= memberStart;
});
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
const declarations = new Map();
context.on('VariableDeclarator', node => {
if (!(
node.id.type === 'ObjectPattern'
&& node.init
&& node.init.type !== 'Literal'
// Ignore any complex expressions (e.g. arrays, functions)
&& isSimpleExpression(node.init)
)) {
return;
}
const rootIdentifier = getRootIdentifier(node.init);
declarations.set(sourceCode.getText(node.init), {
scope: sourceCode.getScope(node),
object: node.init,
rootIdentifierName: rootIdentifier?.name,
rootVariable: rootIdentifier && findVariable(sourceCode.getScope(node), rootIdentifier),
objectPattern: node.id,
});
});
context.on('MemberExpression', node => {
if (
node.computed
|| (
isCallOrNewExpression(node.parent)
&& node.parent.callee === node
)
|| isLeftHandSide(node)
) {
return;
}
const declaration = declarations.get(sourceCode.getText(node.object));
if (!declaration) {
return;
}
const memberScope = sourceCode.getScope(node);
const memberRootIdentifier = getRootIdentifier(node.object);
const memberRootVariable = memberRootIdentifier && findVariable(memberScope, memberRootIdentifier);
if (
declaration.rootIdentifierName
&& memberRootIdentifier?.name === declaration.rootIdentifierName
&& memberRootVariable !== declaration.rootVariable
) {
return;
}
if (isRootVariableReassigned(declaration, node, memberScope, sourceCode)) {
return;
}
const {scope, objectPattern} = declaration;
// Property is destructured outside the current scope
if (!isChildInParentScope(memberScope, scope)) {
return;
}
const member = sourceCode.getText(node.property);
const memberDestructuredInNestedPattern = isMemberDestructuredInNestedPatternWithRest(objectPattern, member);
const destructuredProperties = objectPattern.properties.filter(property =>
isIdentifierProperty(property)
&& property.value.type === 'Identifier');
const lastProperty = objectPattern.properties.at(-1);
const hasRest = lastProperty?.type === 'RestElement';
const expression = sourceCode.getText(node);
// Member might already be destructured
const destructuredMember = destructuredProperties.find(property =>
property.key.name === member);
if (!destructuredMember) {
if (memberDestructuredInNestedPattern) {
return;
}
// Don't destructure additional members when rest is used
if (hasRest) {
return;
}
// Destructured member collides with an existing identifier
if (getAvailableVariableName(member, [memberScope]) !== member) {
return;
}
}
// Don't try to fix nested member expressions
if (node.parent.type === 'MemberExpression') {
return {
node,
messageId: MESSAGE_ID,
};
}
const newMember = destructuredMember ? destructuredMember.value.name : member;
return {
node,
messageId: MESSAGE_ID,
suggest: [{
messageId: MESSAGE_ID_SUGGEST,
data: {
expression,
property: newMember,
},
* fix(fixer) {
const {properties} = objectPattern;
const lastProperty = properties.at(-1);
yield fixer.replaceText(node, newMember);
if (!destructuredMember) {
yield lastProperty
? fixer.insertTextAfter(lastProperty, `, ${newMember}`)
: fixer.replaceText(objectPattern, `{${newMember}}`);
}
},
}],
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Use destructured variables over properties.',
recommended: false,
},
hasSuggestions: true,
messages: {
[MESSAGE_ID]: 'Use destructured variables over properties.',
[MESSAGE_ID_SUGGEST]: 'Replace `{{expression}}` with destructured property `{{property}}`.',
},
},
};
export default config;
@@ -0,0 +1,123 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {
isEmptyArrayExpression,
isEmptyStringLiteral,
} from './ast/index.js';
const MESSAGE_ID = 'consistent-empty-array-spread';
const messages = {
[MESSAGE_ID]: 'Prefer using empty {{replacementDescription}} since the {{anotherNodePosition}} is {{anotherNodeDescription}}.',
};
const isString = (node, context) => {
const staticValueResult = getStaticValue(node, context.sourceCode.getScope(node));
return typeof staticValueResult?.value === 'string';
};
const isArray = (node, context) => {
if (node.type === 'ArrayExpression') {
return true;
}
const staticValueResult = getStaticValue(node, context.sourceCode.getScope(node));
return Array.isArray(staticValueResult?.value);
};
const cases = [
{
oneSidePredicate: isEmptyStringLiteral,
anotherSidePredicate: isArray,
anotherNodeDescription: 'an array',
replacementDescription: 'array',
replacementCode: '[]',
},
{
oneSidePredicate: isEmptyArrayExpression,
anotherSidePredicate: isString,
anotherNodeDescription: 'a string',
replacementDescription: 'string',
replacementCode: '\'\'',
},
];
function createProblem({
problemNode,
anotherNodePosition,
anotherNodeDescription,
replacementDescription,
replacementCode,
}) {
return {
node: problemNode,
messageId: MESSAGE_ID,
data: {
replacementDescription,
anotherNodePosition,
anotherNodeDescription,
},
fix: fixer => fixer.replaceText(problemNode, replacementCode),
};
}
function getProblem(conditionalExpression, context) {
const {
consequent,
alternate,
} = conditionalExpression;
for (const problemCase of cases) {
const {
oneSidePredicate,
anotherSidePredicate,
} = problemCase;
if (oneSidePredicate(consequent, context) && anotherSidePredicate(alternate, context)) {
return createProblem({
...problemCase,
problemNode: consequent,
anotherNodePosition: 'alternate',
});
}
if (oneSidePredicate(alternate, context) && anotherSidePredicate(consequent, context)) {
return createProblem({
...problemCase,
problemNode: alternate,
anotherNodePosition: 'consequent',
});
}
}
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ArrayExpression', function * (arrayExpression) {
for (const element of arrayExpression.elements) {
if (
element?.type !== 'SpreadElement'
|| element.argument.type !== 'ConditionalExpression'
) {
continue;
}
yield getProblem(element.argument, context);
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer consistent types when spreading a ternary in an array literal.',
recommended: true,
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,136 @@
import toLocation from './utils/to-location.js';
import {isMethodCall, isNegativeOne, isNumericLiteral} from './ast/index.js';
const MESSAGE_ID = 'consistent-existence-index-check';
const messages = {
[MESSAGE_ID]: 'Prefer `{{replacementOperator}} {{replacementValue}}` over `{{originalOperator}} {{originalValue}}` to check {{existenceOrNonExistence}}.',
};
const isZero = node => isNumericLiteral(node) && node.value === 0;
/**
@param {parent: import('estree').BinaryExpression} binaryExpression
@returns {{
replacementOperator: string,
replacementValue: string,
originalOperator: string,
originalValue: string,
} | undefined}
*/
function getReplacement(binaryExpression) {
const {operator, right} = binaryExpression;
if (operator === '<' && isZero(right)) {
return {
replacementOperator: '===',
replacementValue: '-1',
originalOperator: operator,
originalValue: '0',
};
}
if (operator === '>' && isNegativeOne(right)) {
return {
replacementOperator: '!==',
replacementValue: '-1',
originalOperator: operator,
originalValue: '-1',
};
}
if (operator === '>=' && isZero(right)) {
return {
replacementOperator: '!==',
replacementValue: '-1',
originalOperator: operator,
originalValue: '0',
};
}
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('VariableDeclarator', /** @param {import('estree').VariableDeclarator} variableDeclarator */ function * (variableDeclarator) {
if (!(
variableDeclarator.parent.type === 'VariableDeclaration'
&& variableDeclarator.parent.kind === 'const'
&& variableDeclarator.id.type === 'Identifier'
&& isMethodCall(variableDeclarator.init, {methods: ['indexOf', 'lastIndexOf', 'findIndex', 'findLastIndex']})
)) {
return;
}
const variableIdentifier = variableDeclarator.id;
const variables = context.sourceCode.getDeclaredVariables(variableDeclarator);
const [variable] = variables;
// Just for safety
if (
variables.length !== 1
|| variable.identifiers.length !== 1
|| variable.identifiers[0] !== variableIdentifier
) {
return;
}
for (const {identifier} of variable.references) {
/** @type {{parent: import('estree').BinaryExpression}} */
const binaryExpression = identifier.parent;
if (binaryExpression.type !== 'BinaryExpression' || binaryExpression.left !== identifier) {
continue;
}
const replacement = getReplacement(binaryExpression);
if (!replacement) {
return;
}
const {left, operator, right} = binaryExpression;
const {sourceCode} = context;
const operatorToken = sourceCode.getTokenAfter(
left,
token => token.type === 'Punctuator' && token.value === operator,
);
const [start] = sourceCode.getRange(operatorToken);
const [, end] = sourceCode.getRange(right);
yield {
node: binaryExpression,
loc: toLocation([start, end], context),
messageId: MESSAGE_ID,
data: {
...replacement,
existenceOrNonExistence: `${replacement.replacementOperator === '===' ? 'non-' : ''}existence`,
},
* fix(fixer) {
yield fixer.replaceText(operatorToken, replacement.replacementOperator);
if (replacement.replacementValue !== replacement.originalValue) {
yield fixer.replaceText(right, replacement.replacementValue);
}
},
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description:
'Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,269 @@
import {getFunctionHeadLocation, getFunctionNameWithKind} from '@eslint-community/eslint-utils';
import {getReferences, isNodeContainsLexicalThis, isNodeMatches} from './utils/index.js';
import {functionTypes} from './ast/index.js';
const MESSAGE_ID = 'consistent-function-scoping';
const messages = {
[MESSAGE_ID]: 'Move {{functionNameWithKind}} to the outer scope.',
};
const isSameScope = (scope1, scope2) =>
scope1 && scope2 && (scope1 === scope2 || scope1.block === scope2.block);
function checkReferences(scope, parent, scopeManager) {
const hitReference = references => references.some(reference => {
if (isSameScope(parent, reference.from)) {
return true;
}
const {resolved} = reference;
const [definition] = resolved.defs;
// Skip recursive function name
if (definition?.type === 'FunctionName' && resolved.name === definition.name.name) {
return false;
}
return isSameScope(parent, resolved.scope);
});
const hitDefinitions = definitions => definitions.some(definition => {
const scope = scopeManager.acquire(definition.node);
return isSameScope(parent, scope);
});
// This check looks for neighboring function definitions
const hitIdentifier = identifiers => identifiers.some(identifier => {
// Only look at identifiers that live in a FunctionDeclaration
if (
!identifier.parent
|| identifier.parent.type !== 'FunctionDeclaration'
) {
return false;
}
const identifierScope = scopeManager.acquire(identifier);
// If we have a scope, the earlier checks should have worked so ignore them here
/* c8 ignore next 3 */
if (identifierScope) {
return false;
}
const identifierParentScope = scopeManager.acquire(identifier.parent);
/* c8 ignore next 3 */
if (!identifierParentScope) {
return false;
}
// Ignore identifiers from our own scope
if (isSameScope(scope, identifierParentScope)) {
return false;
}
// Look at the scope above the function definition to see if it lives
// next to the reference being checked
return isSameScope(parent, identifierParentScope.upper);
});
return getReferences(scope)
.map(({resolved}) => resolved)
.filter(Boolean)
.some(variable =>
hitReference(variable.references)
|| hitDefinitions(variable.defs)
|| hitIdentifier(variable.identifiers));
}
// https://reactjs.org/docs/hooks-reference.html
const reactHooks = [
'useState',
'useEffect',
'useContext',
'useReducer',
'useCallback',
'useMemo',
'useRef',
'useImperativeHandle',
'useLayoutEffect',
'useDebugValue',
].flatMap(hookName => [hookName, `React.${hookName}`]);
const isReactHook = scope =>
scope.block?.parent?.callee
&& isNodeMatches(scope.block.parent.callee, reactHooks);
const isArrowFunctionNodeWithThis = (node, visitorKeys) =>
node.type === 'ArrowFunctionExpression'
// We avoid `scope.thisFound` because parser scope metadata differs; AST lexical checks are consistent.
// Include both params and body, because parameter defaults can reference lexical `this`.
&& isNodeContainsLexicalThis(node, visitorKeys);
const iifeFunctionTypes = new Set([
'FunctionExpression',
'ArrowFunctionExpression',
]);
const isIife = node =>
iifeFunctionTypes.has(node.type)
&& node.parent.type === 'CallExpression'
&& node.parent.callee === node;
// Helper to walk up the chain to find the first non-arrow ancestor
function getNonArrowAncestor(node) {
let ancestor = node;
while (ancestor && ancestor.type === 'ArrowFunctionExpression') {
ancestor = ancestor.parent;
}
return ancestor;
}
// Helper to skip over a chain of ArrowFunctionExpression nodes
function skipArrowFunctionChain(node) {
let current = node;
while (current.type === 'ArrowFunctionExpression') {
current = current.parent;
}
return current;
}
function handleNestedArrowFunctions(parentNode, node) {
// Skip over arrow function expressions when they are parents and we came from a ReturnStatement
// This handles nested arrow functions: return next => action => { ... }
// But only when we're in a return statement context
if (parentNode.type === 'ArrowFunctionExpression' && node.type === 'ArrowFunctionExpression') {
const ancestor = getNonArrowAncestor(parentNode);
if (ancestor && ancestor.type === 'ReturnStatement') {
parentNode = skipArrowFunctionChain(parentNode);
if (parentNode.type === 'ReturnStatement') {
parentNode = parentNode.parent;
}
}
}
return parentNode;
}
function checkNode(node, scopeManager, sourceCode) {
const scope = scopeManager.acquire(node);
if (
!scope
|| isArrowFunctionNodeWithThis(node, sourceCode.visitorKeys)
) {
return true;
}
let parentNode = node.parent;
// Skip over junk like the block statement inside of a function declaration
// or the various pieces of an arrow function.
if (parentNode.type === 'VariableDeclarator') {
parentNode = parentNode.parent;
}
if (parentNode.type === 'VariableDeclaration') {
parentNode = parentNode.parent;
}
// Only skip ReturnStatement for arrow functions
// Regular function expressions have different semantics and shouldn't be moved
if (parentNode?.type === 'ReturnStatement' && node.type === 'ArrowFunctionExpression') {
parentNode = parentNode.parent;
}
parentNode = handleNestedArrowFunctions(parentNode, node);
if (parentNode?.type === 'BlockStatement') {
parentNode = parentNode.parent;
}
const parentScope = scopeManager.acquire(parentNode);
if (
!parentScope
|| parentScope.type === 'global'
|| isReactHook(parentScope)
|| isIife(parentNode)
) {
return true;
}
return checkReferences(scope, parentScope, scopeManager);
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {checkArrowFunctions} = context.options[0];
const {sourceCode} = context;
const {scopeManager} = sourceCode;
const functions = [];
context.on(functionTypes, () => {
functions.push(false);
});
context.on('JSXElement', () => {
// Turn off this rule if we see a JSX element because scope
// references does not include JSXElement nodes.
if (functions.length > 0) {
functions[functions.length - 1] = true;
}
});
context.onExit(functionTypes, node => {
const currentFunctionHasJsx = functions.pop();
if (currentFunctionHasJsx) {
return;
}
if (node.type === 'ArrowFunctionExpression' && !checkArrowFunctions) {
return;
}
if (checkNode(node, scopeManager, sourceCode)) {
return;
}
return {
node,
loc: getFunctionHeadLocation(node, sourceCode),
messageId: MESSAGE_ID,
data: {
functionNameWithKind: getFunctionNameWithKind(node, sourceCode),
},
};
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
checkArrowFunctions: {
type: 'boolean',
description: 'Whether to check arrow functions.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Move function definitions to the highest possible scope.',
recommended: true,
},
schema,
defaultOptions: [{checkArrowFunctions: true}],
messages,
},
};
export default config;
@@ -0,0 +1,52 @@
import {replaceTemplateElement} from './fix/index.js';
import {isTaggedTemplateLiteral} from './ast/index.js';
const MESSAGE_ID = 'consistent-template-literal-escape';
const messages = {
[MESSAGE_ID]: 'Use `\\${` instead of `$\\{` to escape in template literals.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('TemplateElement', node => {
if (isTaggedTemplateLiteral(node.parent)) {
return;
}
const {raw} = node.value;
// Match `$\{` or `\$\{` and replace with `\${`.
// The `\\?` makes the leading backslash optional to handle both patterns.
// The lookbehind ensures an even number of preceding backslashes (including zero).
const fixedRaw = raw.replaceAll(
/(?<=(?:^|[^\\])(?:\\\\)*)\\?\$\\{/g,
String.raw`\${`,
);
if (raw !== fixedRaw) {
const problem = {
node,
messageId: MESSAGE_ID,
fix: fixer => replaceTemplateElement(node, fixedRaw, context, fixer),
};
return problem;
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce consistent style for escaping `${` in template literals.',
recommended: true,
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,246 @@
import {
upperFirst,
getParenthesizedText,
isNodeMatchesNameOrPath,
} from './utils/index.js';
const MESSAGE_ID_INVALID_EXPORT = 'invalidExport';
const messages = {
[MESSAGE_ID_INVALID_EXPORT]: 'Exported error name should match error class',
};
const nameRegexp = /^(?:[A-Z][\da-z]*)*Error$/;
const getClassName = name => upperFirst(name).replace(/(?:error|)$/i, 'Error');
const getNameProperty = className => `
name = '${className}';
`;
const getSuperClassName = superClass => {
if (superClass?.type === 'Identifier') {
return superClass.name;
}
if (
superClass?.type === 'MemberExpression'
&& !superClass.computed
&& superClass.property.type === 'Identifier'
) {
return superClass.property.name;
}
};
const hasValidSuperClass = node => {
const superClassName = getSuperClassName(node.superClass);
return Boolean(superClassName) && nameRegexp.test(superClassName);
};
const isSuperExpression = node =>
node.type === 'ExpressionStatement'
&& node.expression.type === 'CallExpression'
&& isNodeMatchesNameOrPath(node.expression.callee, 'super');
const isAssignmentExpression = (node, name) =>
node.type === 'ExpressionStatement'
&& node.expression.type === 'AssignmentExpression'
&& isNodeMatchesNameOrPath(node.expression.left, `this.${name}`);
const createInvalidNameError = (node, name) => ({
node,
message: `The \`name\` property should be set to \`${name}\`.`,
});
const isPropertyDefinition = (node, name) =>
node.type === 'PropertyDefinition'
&& !node.static
&& !node.computed
&& node.key.type === 'Identifier'
&& node.key.name === name;
const isValidNameProperty = (nameProperty, className) =>
nameProperty?.value
&& nameProperty.value.value === className;
function * checkConstructorBody(context, constructor, name, nameProperty) {
const {sourceCode} = context;
const constructorBodyNode = constructor.value.body;
// Verify the constructor has a body (TypeScript)
if (!constructorBodyNode) {
return;
}
const constructorBody = constructorBodyNode.body;
const superExpression = constructorBody.find(bodyNode => isSuperExpression(bodyNode));
const messageExpressionIndex = constructorBody.findIndex(bodyNode => isAssignmentExpression(bodyNode, 'message'));
if (!superExpression) {
yield {
node: constructorBodyNode,
message: 'Missing call to `super()` in constructor.',
};
} else if (messageExpressionIndex !== -1) {
const expression = constructorBody[messageExpressionIndex];
yield {
node: superExpression,
message: 'Pass the error message to `super()` instead of setting `this.message`.',
* fix(fixer) {
if (superExpression.expression.arguments.length === 0) {
const rhs = expression.expression.right;
const [start] = sourceCode.getRange(superExpression);
// This part crashes on ESLint 10, but it's still not correct.
// There can be spaces, comments after `super`
yield fixer.insertTextAfterRange(
[start, start + 6],
getParenthesizedText(rhs, context),
);
}
const start = messageExpressionIndex === 0
? sourceCode.getRange(constructorBodyNode)[0]
: sourceCode.getRange(constructorBody[messageExpressionIndex - 1])[1];
const [, end] = sourceCode.getRange(expression);
yield fixer.removeRange([start, end]);
},
};
}
const nameExpression = constructorBody.find(bodyNode => isAssignmentExpression(bodyNode, 'name'));
if (!nameExpression) {
if (!isValidNameProperty(nameProperty, name)) {
yield createInvalidNameError(nameProperty?.value ?? constructorBodyNode, name);
}
return;
}
if (
nameExpression.expression.right.type !== 'Literal'
|| nameExpression.expression.right.value !== name
) {
yield createInvalidNameError(nameExpression.expression.right ?? constructorBodyNode, name);
}
}
function * customErrorDefinition(context, node) {
if (!hasValidSuperClass(node)) {
return;
}
if (node.id === null) {
return;
}
const {name} = node.id;
const className = getClassName(name);
if (name !== className) {
yield {
node: node.id,
message: `Invalid class name, use \`${className}\`.`,
};
}
const {body} = node.body;
const {sourceCode} = context;
const constructor = body.find(x => x.kind === 'constructor');
const nameProperty = body.find(classNode => isPropertyDefinition(classNode, 'name'));
if (!constructor) {
if (isValidNameProperty(nameProperty, name)) {
return;
}
const range = sourceCode.getRange(node.body);
yield {
...createInvalidNameError(nameProperty?.value ?? node, name),
fix(fixer) {
if (nameProperty?.value) {
return fixer.replaceText(nameProperty.value, `'${name}'`);
}
if (nameProperty) {
return fixer.replaceText(nameProperty, getNameProperty(name).trim());
}
return fixer.insertTextAfterRange([
range[0],
range[0] + 1,
], getNameProperty(name));
},
};
return;
}
yield * checkConstructorBody(context, constructor, name, nameProperty);
}
const customErrorExport = (context, node) => {
const exportsName = node.left.property.name;
const maybeError = node.right;
if (maybeError.type !== 'ClassExpression') {
return;
}
if (!hasValidSuperClass(maybeError)) {
return;
}
if (!maybeError.id) {
return;
}
// Assume rule has already fixed the error name
const errorName = maybeError.id.name;
if (exportsName === errorName) {
return;
}
return {
node: node.left.property,
messageId: MESSAGE_ID_INVALID_EXPORT,
fix: fixer => fixer.replaceText(node.left.property, errorName),
};
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ClassDeclaration', node => customErrorDefinition(context, node));
context.on('AssignmentExpression', node => {
if (node.right.type === 'ClassExpression') {
return customErrorDefinition(context, node.right);
}
});
context.on('AssignmentExpression', node => {
if (
node.left.type === 'MemberExpression'
&& node.left.object.type === 'Identifier'
&& node.left.object.name === 'exports'
) {
return customErrorExport(context, node);
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce correct `Error` subclassing.',
recommended: false,
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,62 @@
import {isOpeningBraceToken} from '@eslint-community/eslint-utils';
const MESSAGE_ID = 'empty-brace-spaces';
const messages = {
[MESSAGE_ID]: 'Do not add spaces between braces.',
};
const getProblem = (node, context) => {
const {sourceCode} = context;
const openingBrace = sourceCode.getFirstToken(node, {filter: isOpeningBraceToken});
const closingBrace = sourceCode.getLastToken(node);
const [, start] = sourceCode.getRange(openingBrace);
const [end] = sourceCode.getRange(closingBrace);
const textBetween = sourceCode.text.slice(start, end);
if (!/^\s+$/.test(textBetween)) {
return;
}
return {
loc: {
start: sourceCode.getLoc(openingBrace).end,
end: sourceCode.getLoc(closingBrace).start,
},
messageId: MESSAGE_ID,
fix: fixer => fixer.removeRange([start, end]),
};
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on([
'BlockStatement',
'ClassBody',
'StaticBlock',
'ObjectExpression',
], node => {
const children = node.type === 'ObjectExpression' ? node.properties : node.body;
if (children.length > 0) {
return;
}
return getProblem(node, context);
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'layout',
docs: {
description: 'Enforce no spaces between braces.',
recommended: true,
},
fixable: 'whitespace',
messages,
},
};
export default config;
+98
View File
@@ -0,0 +1,98 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {isCallOrNewExpression} from './ast/index.js';
import builtinErrors from './shared/builtin-errors.js';
const MESSAGE_ID_MISSING_MESSAGE = 'missing-message';
const MESSAGE_ID_EMPTY_MESSAGE = 'message-is-empty-string';
const MESSAGE_ID_NOT_STRING = 'message-is-not-a-string';
const messages = {
[MESSAGE_ID_MISSING_MESSAGE]: 'Pass a message to the `{{constructorName}}` constructor.',
[MESSAGE_ID_EMPTY_MESSAGE]: 'Error message should not be an empty string.',
[MESSAGE_ID_NOT_STRING]: 'Error message should be a string.',
};
const messageArgumentIndexes = new Map([
['AggregateError', 1],
['SuppressedError', 2],
]);
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on(['CallExpression', 'NewExpression'], expression => {
if (!(
isCallOrNewExpression(expression, {
names: builtinErrors,
optional: false,
})
&& context.sourceCode.isGlobalReference(expression.callee)
)) {
return;
}
const constructorName = expression.callee.name;
const messageArgumentIndex = messageArgumentIndexes.has(constructorName)
? messageArgumentIndexes.get(constructorName)
: 0;
const callArguments = expression.arguments;
// If message is `SpreadElement` or there is `SpreadElement` before message
if (callArguments.some((node, index) => index <= messageArgumentIndex && node.type === 'SpreadElement')) {
return;
}
const node = callArguments[messageArgumentIndex];
if (!node) {
return {
node: expression,
messageId: MESSAGE_ID_MISSING_MESSAGE,
data: {constructorName},
};
}
// These types can't be string, and `getStaticValue` may don't know the value
// Add more types, if issue reported
if (node.type === 'ArrayExpression' || node.type === 'ObjectExpression') {
return {
node,
messageId: MESSAGE_ID_NOT_STRING,
};
}
const staticResult = getStaticValue(node, context.sourceCode.getScope(node));
// We don't know the value of `message`
if (!staticResult) {
return;
}
const {value} = staticResult;
if (typeof value !== 'string') {
return {
node,
messageId: MESSAGE_ID_NOT_STRING,
};
}
if (value === '') {
return {
node,
messageId: MESSAGE_ID_EMPTY_MESSAGE,
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce passing a `message` value when creating a built-in error.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+87
View File
@@ -0,0 +1,87 @@
import {replaceTemplateElement} from './fix/index.js';
import {isRegexLiteral, isStringLiteral, isTaggedTemplateLiteral} from './ast/index.js';
const MESSAGE_ID_UPPERCASE = 'escape-uppercase';
const MESSAGE_ID_LOWERCASE = 'escape-lowercase';
const messages = {
[MESSAGE_ID_UPPERCASE]: 'Use uppercase characters for the value of the escape sequence.',
[MESSAGE_ID_LOWERCASE]: 'Use lowercase characters for the value of the escape sequence.',
};
const escapeCase = /(?<=(?:^|[^\\])(?:\\\\)*\\)(?<data>x[\dA-Fa-f]{2}|u[\dA-Fa-f]{4}|u{[\dA-Fa-f]+})/g;
const escapePatternCase = /(?<=(?:^|[^\\])(?:\\\\)*\\)(?<data>x[\dA-Fa-f]{2}|u[\dA-Fa-f]{4}|u{[\dA-Fa-f]+}|c[A-Za-z])/g;
const getProblem = ({node, original, regex = escapeCase, lowercase, fix}) => {
const fixed = original.replace(regex, data => data[0] + data.slice(1)[lowercase ? 'toLowerCase' : 'toUpperCase']());
if (fixed !== original) {
return {
node,
messageId: lowercase ? MESSAGE_ID_LOWERCASE : MESSAGE_ID_UPPERCASE,
fix: fixer => fix ? fix(fixer, fixed) : fixer.replaceText(node, fixed),
};
}
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const lowercase = context.options[0] === 'lowercase';
context.on('Literal', node => {
if (isStringLiteral(node)) {
return getProblem({
node,
original: node.raw,
lowercase,
});
}
});
context.on('Literal', node => {
if (isRegexLiteral(node)) {
return getProblem({
node,
original: node.raw,
regex: escapePatternCase,
lowercase,
});
}
});
context.on('TemplateElement', node => {
if (isTaggedTemplateLiteral(node.parent, ['String.raw'])) {
return;
}
return getProblem({
node,
original: node.value.raw,
lowercase,
fix: (fixer, fixed) => replaceTemplateElement(node, fixed, context, fixer),
});
});
};
const schema = [
{
enum: ['uppercase', 'lowercase'],
description: 'The case style for escape sequences.',
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Require escape sequences to use uppercase or lowercase values.',
recommended: 'unopinionated',
},
fixable: 'code',
schema,
defaultOptions: ['uppercase'],
messages,
},
};
export default config;
@@ -0,0 +1,577 @@
import path from 'node:path';
import {isRegExp} from 'node:util/types';
import semver from 'semver';
import * as ci from 'ci-info';
import {
isEslintDisableOrEnableDirective,
getBuiltinRule,
} from './utils/index.js';
import {readPackageJson} from './shared/package-json.js';
const baseRule = getBuiltinRule('no-warning-comments');
// `unicorn/` prefix is added to avoid conflicts with core rule
const MESSAGE_ID_AVOID_MULTIPLE_DATES = 'unicorn/avoidMultipleDates';
const MESSAGE_ID_EXPIRED_TODO = 'unicorn/expiredTodo';
const MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS
= 'unicorn/avoidMultiplePackageVersions';
const MESSAGE_ID_REACHED_PACKAGE_VERSION = 'unicorn/reachedPackageVersion';
const MESSAGE_ID_HAVE_PACKAGE = 'unicorn/havePackage';
const MESSAGE_ID_DONT_HAVE_PACKAGE = 'unicorn/dontHavePackage';
const MESSAGE_ID_VERSION_MATCHES = 'unicorn/versionMatches';
const MESSAGE_ID_ENGINE_MATCHES = 'unicorn/engineMatches';
const MESSAGE_ID_REMOVE_WHITESPACE = 'unicorn/removeWhitespaces';
const MESSAGE_ID_MISSING_AT_SYMBOL = 'unicorn/missingAtSymbol';
// Override of core rule message with a more specific one - no prefix
const MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT = 'unexpectedComment';
const messages = {
[MESSAGE_ID_AVOID_MULTIPLE_DATES]:
'Avoid using multiple expiration dates in TODO: {{expirationDates}}. {{message}}',
[MESSAGE_ID_EXPIRED_TODO]:
'There is a TODO that is past due date: {{expirationDate}}. {{message}}',
[MESSAGE_ID_REACHED_PACKAGE_VERSION]:
'There is a TODO that is past due package version: {{comparison}}. {{message}}',
[MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS]:
'Avoid using multiple package versions in TODO: {{versions}}. {{message}}',
[MESSAGE_ID_HAVE_PACKAGE]:
'There is a TODO that is deprecated since you installed: {{package}}. {{message}}',
[MESSAGE_ID_DONT_HAVE_PACKAGE]:
'There is a TODO that is deprecated since you uninstalled: {{package}}. {{message}}',
[MESSAGE_ID_VERSION_MATCHES]:
'There is a TODO match for package version: {{comparison}}. {{message}}',
[MESSAGE_ID_ENGINE_MATCHES]:
'There is a TODO match for Node.js version: {{comparison}}. {{message}}',
[MESSAGE_ID_REMOVE_WHITESPACE]:
'Avoid using whitespace on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
[MESSAGE_ID_MISSING_AT_SYMBOL]:
'Missing \'@\' on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
...baseRule.meta.messages,
[MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT]:
'Unexpected \'{{matchedTerm}}\' comment without any conditions: \'{{comment}}\'.',
};
/** @param {string} dirname */
function getPackageHelpers(dirname) {
const packageJsonResult = readPackageJson(dirname);
const packageJson = packageJsonResult?.packageJson ?? {};
const hasPackage = Boolean(packageJsonResult);
const packageDependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
function parseTodoWithArguments(string, {terms}) {
const lowerCaseString = string.toLowerCase();
const lowerCaseTerms = terms.map(term => term.toLowerCase());
const hasTerm = lowerCaseTerms.some(term => lowerCaseString.includes(term));
if (!hasTerm) {
return false;
}
const TODO_ARGUMENT_RE = /\[(?<rawArguments>[^}]+)]/i;
const result = TODO_ARGUMENT_RE.exec(string);
if (!result) {
return false;
}
const {rawArguments} = result.groups;
const parsedArguments = rawArguments
.split(',')
.map(argument => parseArgument(argument.trim()));
return createArgumentGroup(parsedArguments);
}
function parseArgument(argumentString, dirname) {
const {hasPackage} = getPackageHelpers(dirname);
if (ISO8601_DATE.test(argumentString)) {
return {
type: 'dates',
value: argumentString,
};
}
if (hasPackage && DEPENDENCY_INCLUSION_RE.test(argumentString)) {
const condition = argumentString[0] === '+' ? 'in' : 'out';
const name = argumentString.slice(1).trim();
return {
type: 'dependencies',
value: {
name,
condition,
},
};
}
if (hasPackage && VERSION_COMPARISON_RE.test(argumentString)) {
const {groups} = VERSION_COMPARISON_RE.exec(argumentString);
const name = groups.name.trim();
const condition = groups.condition.trim();
const version = groups.version.trim();
const hasEngineKeyword = name.indexOf('engine:') === 0;
const isNodeEngine = hasEngineKeyword && name === 'engine:node';
if (hasEngineKeyword && isNodeEngine) {
return {
type: 'engines',
value: {
condition,
version,
},
};
}
if (!hasEngineKeyword) {
return {
type: 'dependencies',
value: {
name,
condition,
version,
},
};
}
}
if (hasPackage && PKG_VERSION_RE.test(argumentString)) {
const result = PKG_VERSION_RE.exec(argumentString);
const {condition, version} = result.groups;
return {
type: 'packageVersions',
value: {
condition: condition.trim(),
version: version.trim(),
},
};
}
// Currently being ignored as integration tests pointed
// some TODO comments have `[random data like this]`
return {
type: 'unknowns',
value: argumentString,
};
}
function parseTodoMessage(todoString) {
// @example "TODO [...]: message here"
// @example "TODO [...] message here"
const argumentsEnd = todoString.indexOf(']');
const afterArguments = todoString.slice(argumentsEnd + 1).trim();
// Check if have to skip colon
// @example "TODO [...]: message here"
const dropColon = afterArguments[0] === ':';
if (dropColon) {
return afterArguments.slice(1).trim();
}
return afterArguments;
}
return {
packageResult: packageJsonResult,
hasPackage,
packageJson,
packageDependencies,
parseArgument,
parseTodoMessage,
parseTodoWithArguments,
};
}
const DEPENDENCY_INCLUSION_RE = /^[+-]\s*@?\S+\/?\S+/;
const VERSION_COMPARISON_RE = /^(?<name>@?\S\/?\S+)@(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)/i;
const PKG_VERSION_RE = /^(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)\s*$/;
const ISO8601_DATE = /\d{4}-\d{2}-\d{2}/;
function createArgumentGroup(arguments_) {
const groups = {};
for (const {value, type} of arguments_) {
groups[type] ??= [];
groups[type].push(value);
}
return groups;
}
function reachedDate(past, now) {
return Date.parse(past) < Date.parse(now);
}
function tryToCoerceVersion(rawVersion) {
// `version` in `package.json` and comment can't be empty
/* c8 ignore next 3 */
if (!rawVersion) {
return false;
}
let version = String(rawVersion);
// Remove leading things like `^1.0.0`, `>1.0.0`
const leadingNoises = [
'>=',
'<=',
'>',
'<',
'~',
'^',
];
const foundTrailingNoise = leadingNoises.find(noise => version.startsWith(noise));
if (foundTrailingNoise) {
version = version.slice(foundTrailingNoise.length);
}
// Get only the first member for cases such as `1.0.0 - 2.9999.9999`
const parts = version.split(' ');
// We don't have this `package.json` to test
/* c8 ignore next 3 */
if (parts.length > 1) {
version = parts[0];
}
// We don't have this `package.json` to test
/* c8 ignore next 3 */
if (semver.valid(version)) {
return version;
}
try {
// Try to semver.parse a perfect match while semver.coerce tries to fix errors
// But coerce can't parse pre-releases.
return semver.parse(version) || semver.coerce(version);
} catch {
// We don't have this `package.json` to test
/* c8 ignore next 3 */
return false;
}
}
function satisfiesRange(version, condition, range) {
return semver.satisfies(version, `${condition}${range}`, {includePrerelease: true});
}
const DEFAULT_OPTIONS = {
terms: ['todo', 'fixme', 'xxx'],
ignore: [],
ignoreDates: false,
ignoreDatesOnPullRequests: true,
allowWarningComments: true,
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const options = {
date: new Date().toISOString().slice(0, 10),
...context.options[0],
};
const ignoreRegexes = options.ignore.map(pattern => isRegExp(pattern) ? pattern : new RegExp(pattern, 'u'));
const dirname = path.dirname(context.filename);
const {packageJson, packageDependencies, parseArgument, parseTodoMessage, parseTodoWithArguments} = getPackageHelpers(dirname);
const {sourceCode} = context;
const comments = sourceCode.getAllComments();
const unusedComments = comments
.filter(comment => comment.type !== 'Shebang' && !isEslintDisableOrEnableDirective(context, comment))
// Block comments come as one.
// Split for situations like this:
// /*
// * TODO [2999-01-01]: Validate this
// * TODO [2999-01-01]: And this
// * TODO [2999-01-01]: Also this
// */
.flatMap(comment =>
comment.value.split('\n').map(line => ({
...comment,
value: line,
}))).filter(comment => processComment(comment));
// This is highly dependable on ESLint's `no-warning-comments` implementation.
// What we do is patch the parts we know the rule will use, `getAllComments`.
// Since we have priority, we leave only the comments that we didn't use.
const fakeContext = new Proxy(context, {
get(target, property, receiver) {
if (property === 'sourceCode') {
return {
...sourceCode,
getAllComments: () => options.allowWarningComments ? [] : unusedComments,
};
}
return Reflect.get(target, property, receiver);
},
});
const rules = baseRule.create(fakeContext);
// eslint-disable-next-line complexity
function processComment(comment) {
if (ignoreRegexes.some(ignore => ignore.test(comment.value))) {
return;
}
const parsed = parseTodoWithArguments(comment.value, options);
if (!parsed) {
return true;
}
// Count if there are valid properties.
// Otherwise, it's a useless TODO and falls back to `no-warning-comments`.
let uses = 0;
const {
packageVersions = [],
dates = [],
dependencies = [],
engines = [],
unknowns = [],
} = parsed;
if (dates.length > 1) {
uses++;
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_AVOID_MULTIPLE_DATES,
data: {
expirationDates: dates.join(', '),
message: parseTodoMessage(comment.value),
},
});
} else if (dates.length === 1) {
uses++;
const [expirationDate] = dates;
const shouldIgnore = options.ignoreDates || (options.ignoreDatesOnPullRequests && ci.isPR);
if (!shouldIgnore && reachedDate(expirationDate, options.date)) {
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_EXPIRED_TODO,
data: {
expirationDate,
message: parseTodoMessage(comment.value),
},
});
}
}
if (packageVersions.length > 1) {
uses++;
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS,
data: {
versions: packageVersions
.map(({condition, version}) => `${condition}${version}`)
.join(', '),
message: parseTodoMessage(comment.value),
},
});
} else if (packageVersions.length === 1) {
uses++;
const [{condition, version}] = packageVersions;
const packageVersion = tryToCoerceVersion(packageJson.version);
if (packageVersion && satisfiesRange(packageVersion, condition, version)) {
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_REACHED_PACKAGE_VERSION,
data: {
comparison: `${condition}${version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
// Inclusion: 'in', 'out'
// Comparison: '>', '>='
for (const dependency of dependencies) {
uses++;
const targetPackageRawVersion = packageDependencies[dependency.name];
const hasTargetPackage = Boolean(targetPackageRawVersion);
const isInclusion = ['in', 'out'].includes(dependency.condition);
if (isInclusion) {
const [trigger, messageId]
= dependency.condition === 'in'
? [hasTargetPackage, MESSAGE_ID_HAVE_PACKAGE]
: [!hasTargetPackage, MESSAGE_ID_DONT_HAVE_PACKAGE];
if (trigger) {
context.report({
loc: sourceCode.getLoc(comment),
messageId,
data: {
package: dependency.name,
message: parseTodoMessage(comment.value),
},
});
}
continue;
}
const targetPackageVersion = tryToCoerceVersion(targetPackageRawVersion);
/* c8 ignore start */
if (!hasTargetPackage || !targetPackageVersion) {
// Can't compare `¯\_(ツ)_/¯`
continue;
}
/* c8 ignore end */
if (satisfiesRange(targetPackageVersion, dependency.condition, dependency.version)) {
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_VERSION_MATCHES,
data: {
comparison: `${dependency.name} ${dependency.condition} ${dependency.version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
const packageEngines = packageJson.engines || {};
for (const engine of engines) {
uses++;
const targetPackageRawEngineVersion = packageEngines.node;
const hasTargetEngine = Boolean(targetPackageRawEngineVersion);
/* c8 ignore next 3 */
if (!hasTargetEngine) {
continue;
}
const targetPackageEngineVersion = tryToCoerceVersion(targetPackageRawEngineVersion);
if (targetPackageEngineVersion && satisfiesRange(targetPackageEngineVersion, engine.condition, engine.version)) {
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_ENGINE_MATCHES,
data: {
comparison: `node${engine.condition}${engine.version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
for (const unknown of unknowns) {
// In this case, check if there's just an '@' missing before a '>' or '>='.
const hasAt = unknown.includes('@');
const comparisonIndex = unknown.indexOf('>');
if (!hasAt && comparisonIndex !== -1) {
const testString = `${unknown.slice(
0,
comparisonIndex,
)}@${unknown.slice(comparisonIndex)}`;
if (parseArgument(testString).type !== 'unknowns') {
uses++;
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_MISSING_AT_SYMBOL,
data: {
original: unknown,
fix: testString,
message: parseTodoMessage(comment.value),
},
});
continue;
}
}
const withoutWhitespace = unknown.replaceAll(' ', '');
if (parseArgument(withoutWhitespace).type !== 'unknowns') {
uses++;
context.report({
loc: sourceCode.getLoc(comment),
messageId: MESSAGE_ID_REMOVE_WHITESPACE,
data: {
original: unknown,
fix: withoutWhitespace,
message: parseTodoMessage(comment.value),
},
});
continue;
}
}
return uses === 0;
}
context.on('Program', () => {
rules.Program(); // eslint-disable-line new-cap
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
terms: {
type: 'array',
items: {
type: 'string',
},
description: 'Comment terms to check.',
},
ignore: {
type: 'array',
uniqueItems: true,
description: 'Patterns to ignore.',
},
ignoreDates: {
type: 'boolean',
description: 'Whether to ignore expiration dates.',
},
ignoreDatesOnPullRequests: {
type: 'boolean',
description: 'Whether to ignore expiration dates on pull requests.',
},
allowWarningComments: {
type: 'boolean',
description: 'Whether to allow warning comments.',
},
date: {
type: 'string',
format: 'date',
description: 'The reference date.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Add expiration conditions to TODO comments.',
recommended: 'unopinionated',
},
schema,
defaultOptions: [{...DEFAULT_OPTIONS}],
messages,
},
};
export default config;
@@ -0,0 +1,228 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {
isParenthesized,
checkVueTemplate,
isLogicalExpression,
isBooleanExpression,
isControlFlowTest,
getBooleanAncestor,
} from './utils/index.js';
import {fixSpaceAroundKeyword} from './fix/index.js';
import {isLiteral, isMemberExpression} from './ast/index.js';
const TYPE_NON_ZERO = 'non-zero';
const TYPE_ZERO = 'zero';
const MESSAGE_ID_SUGGESTION = 'suggestion';
const messages = {
[TYPE_NON_ZERO]: 'Use `.{{property}} {{code}}` when checking {{property}} is not zero.',
[TYPE_ZERO]: 'Use `.{{property}} {{code}}` when checking {{property}} is zero.',
[MESSAGE_ID_SUGGESTION]: 'Replace `.{{property}}` with `.{{property}} {{code}}`.',
};
const isCompareRight = (node, operator, value) =>
node.type === 'BinaryExpression'
&& node.operator === operator
&& isLiteral(node.right, value);
const isCompareLeft = (node, operator, value) =>
node.type === 'BinaryExpression'
&& node.operator === operator
&& isLiteral(node.left, value);
const nonZeroStyles = new Map([
[
'greater-than',
{
code: '> 0',
test: node => isCompareRight(node, '>', 0),
},
],
[
'not-equal',
{
code: '!== 0',
test: node => isCompareRight(node, '!==', 0),
},
],
]);
const zeroStyle = {
code: '=== 0',
test: node => isCompareRight(node, '===', 0),
};
function getLengthCheckNode(node) {
node = node.parent;
// Zero length check
if (
// `foo.length === 0`
isCompareRight(node, '===', 0)
// `foo.length == 0`
|| isCompareRight(node, '==', 0)
// `foo.length < 1`
|| isCompareRight(node, '<', 1)
// `0 === foo.length`
|| isCompareLeft(node, '===', 0)
// `0 == foo.length`
|| isCompareLeft(node, '==', 0)
// `1 > foo.length`
|| isCompareLeft(node, '>', 1)
) {
return {isZeroLengthCheck: true, node};
}
// Non-Zero length check
if (
// `foo.length !== 0`
isCompareRight(node, '!==', 0)
// `foo.length != 0`
|| isCompareRight(node, '!=', 0)
// `foo.length > 0`
|| isCompareRight(node, '>', 0)
// `foo.length >= 1`
|| isCompareRight(node, '>=', 1)
// `0 !== foo.length`
|| isCompareLeft(node, '!==', 0)
// `0 !== foo.length`
|| isCompareLeft(node, '!=', 0)
// `0 < foo.length`
|| isCompareLeft(node, '<', 0)
// `1 <= foo.length`
|| isCompareLeft(node, '<=', 1)
) {
return {isZeroLengthCheck: false, node};
}
return {};
}
function create(context) {
const options = context.options[0];
const nonZeroStyle = nonZeroStyles.get(options['non-zero']);
const {sourceCode} = context;
function getProblem({node, isZeroLengthCheck, lengthNode, autoFix, shouldSuggest = true}) {
const {code, test} = isZeroLengthCheck ? zeroStyle : nonZeroStyle;
if (test(node)) {
return;
}
let fixed = `${sourceCode.getText(lengthNode)} ${code}`;
if (
!isParenthesized(node, context)
&& node.type === 'UnaryExpression'
&& (node.parent.type === 'UnaryExpression' || node.parent.type === 'AwaitExpression')
) {
fixed = `(${fixed})`;
}
const fix = function * (fixer) {
yield fixer.replaceText(node, fixed);
yield fixSpaceAroundKeyword(fixer, node, context);
};
const problem = {
node,
messageId: isZeroLengthCheck ? TYPE_ZERO : TYPE_NON_ZERO,
data: {code, property: lengthNode.property.name},
};
if (autoFix) {
problem.fix = fix;
} else if (shouldSuggest) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
fix,
},
];
}
return problem;
}
context.on('MemberExpression', memberExpression => {
if (
!isMemberExpression(memberExpression, {
properties: ['length', 'size'],
optional: false,
})
|| memberExpression.object.type === 'ThisExpression'
) {
return;
}
const lengthNode = memberExpression;
const staticValue = getStaticValue(lengthNode, sourceCode.getScope(lengthNode));
if (staticValue && (!Number.isInteger(staticValue.value) || staticValue.value < 0)) {
// Ignore known, non-positive-integer length properties.
return;
}
let node;
let autoFix = true;
let {isZeroLengthCheck, node: lengthCheckNode} = getLengthCheckNode(lengthNode);
if (lengthCheckNode) {
const {isNegative, node: ancestor} = getBooleanAncestor(lengthCheckNode);
node = ancestor;
if (isNegative) {
isZeroLengthCheck = !isZeroLengthCheck;
}
} else {
const {isNegative, node: ancestor} = getBooleanAncestor(lengthNode);
if (isBooleanExpression(ancestor) || isControlFlowTest(ancestor)) {
isZeroLengthCheck = isNegative;
node = ancestor;
} else if (isLogicalExpression(lengthNode.parent) && lengthNode.parent.operator === '&&') {
isZeroLengthCheck = isNegative;
node = lengthNode;
autoFix = false;
}
}
if (node) {
const isUnsafeNegationInBinaryExpression = node.type === 'UnaryExpression'
&& node.operator === '!'
&& node.parent.type === 'BinaryExpression'
&& node.parent.left === node;
return getProblem({
node,
isZeroLengthCheck,
lengthNode,
autoFix: autoFix && !isUnsafeNegationInBinaryExpression,
shouldSuggest: !isUnsafeNegationInBinaryExpression,
});
}
});
}
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
'non-zero': {
enum: [...nonZeroStyles.keys()],
default: 'greater-than',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create: checkVueTemplate(create),
meta: {
type: 'problem',
docs: {
description: 'Enforce explicitly comparing the `length` or `size` property of a value.',
recommended: true,
},
fixable: 'code',
schema,
defaultOptions: [{'non-zero': 'greater-than'}],
messages,
hasSuggestions: true,
},
};
export default config;
+284
View File
@@ -0,0 +1,284 @@
import path from 'node:path';
import {isRegExp} from 'node:util/types';
import {
camelCase,
kebabCase,
snakeCase,
pascalCase,
} from 'change-case';
import cartesianProductSamples from './utils/cartesian-product-samples.js';
const MESSAGE_ID = 'filename-case';
const MESSAGE_ID_EXTENSION = 'filename-extension';
const messages = {
[MESSAGE_ID]: 'Filename is not in {{chosenCases}}. Rename it to {{renamedFilenames}}.',
[MESSAGE_ID_EXTENSION]: 'File extension `{{extension}}` is not in lowercase. Rename it to `{{filename}}`.',
};
const isIgnoredChar = char => !/^[a-z\d-_]$/i.test(char);
const ignoredByDefault = new Set(['index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.vue']);
const isLowerCase = string => string === string.toLowerCase();
const cases = {
camelCase: {
fn: camelCase,
name: 'camel case',
},
kebabCase: {
fn: kebabCase,
name: 'kebab case',
},
snakeCase: {
fn: snakeCase,
name: 'snake case',
},
pascalCase: {
fn: pascalCase,
name: 'pascal case',
},
};
/**
Get the cases specified by the option.
@param {object} options
@returns {string[]} The chosen cases.
*/
function getChosenCases(options) {
if (options.case) {
return [options.case];
}
if (options.cases) {
const cases = Object.keys(options.cases)
.filter(cases => options.cases[cases]);
return cases.length > 0 ? cases : ['kebabCase'];
}
return ['kebabCase'];
}
function validateFilename(words, caseFunctions) {
return words
.filter(({ignored}) => !ignored)
.every(({word}) => caseFunctions.some(caseFunction => caseFunction(word) === word));
}
function fixFilename(words, caseFunctions, {leading, trailing}) {
const replacements = words
.map(({word, ignored}) => ignored ? [word] : caseFunctions.map(caseFunction => caseFunction(word)));
const {
samples: combinations,
} = cartesianProductSamples(replacements);
return [...new Set(combinations.map(parts => `${leading}${parts.join('')}${trailing}`))];
}
function getFilenameParts(filenameWithExtension, {multipleFileExtensions}) {
const extension = path.extname(filenameWithExtension);
const filename = path.basename(filenameWithExtension, extension);
const basename = filename + extension;
const parts = {
basename,
filename,
middle: '',
extension,
};
if (multipleFileExtensions) {
const [firstPart] = filename.split('.');
Object.assign(parts, {
filename: firstPart,
middle: filename.slice(firstPart.length),
});
}
return parts;
}
const leadingUnderscoresRegex = /^(?<leading>_+)(?<tailing>.*)$/;
function splitFilename(filename) {
const result = leadingUnderscoresRegex.exec(filename) || {groups: {}};
const {leading = '', tailing = filename} = result.groups;
const words = [];
let lastWord;
for (const char of tailing) {
const isIgnored = isIgnoredChar(char);
if (lastWord?.ignored === isIgnored) {
lastWord.word += char;
} else {
lastWord = {
word: char,
ignored: isIgnored,
};
words.push(lastWord);
}
}
return {
leading,
words,
};
}
/**
Turns `[a, b, c]` into `a, b, or c`.
@param {string[]} words
@returns {string}
*/
const englishishJoinWords = words => new Intl.ListFormat('en-US', {type: 'disjunction'}).format(words);
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const options = context.options[0] || {};
const chosenCases = getChosenCases(options);
const ignore = (options.ignore || []).map(item => {
if (isRegExp(item)) {
return item;
}
return new RegExp(item, 'u');
});
const multipleFileExtensions = options.multipleFileExtensions !== false;
const chosenCasesFunctions = chosenCases.map(case_ => cases[case_].fn);
const filenameWithExtension = context.physicalFilename;
if (filenameWithExtension === '<input>' || filenameWithExtension === '<text>') {
return;
}
context.on('Program', () => {
const {
basename,
filename,
middle,
extension,
} = getFilenameParts(filenameWithExtension, {multipleFileExtensions});
if (ignoredByDefault.has(basename) || ignore.some(regexp => regexp.test(basename))) {
return;
}
const {leading, words} = splitFilename(filename);
const isValid = validateFilename(words, chosenCasesFunctions);
if (isValid) {
if (!isLowerCase(extension)) {
return {
loc: {column: 0, line: 1},
messageId: MESSAGE_ID_EXTENSION,
data: {filename: filename + middle + extension.toLowerCase(), extension},
};
}
return;
}
const renamedFilenames = fixFilename(words, chosenCasesFunctions, {
leading,
trailing: middle + extension.toLowerCase(),
});
return {
// Report on first character like `unicode-bom` rule
// https://github.com/eslint/eslint/blob/8a77b661bc921c3408bae01b3aa41579edfc6e58/lib/rules/unicode-bom.js#L46
loc: {column: 0, line: 1},
messageId: MESSAGE_ID,
data: {
chosenCases: englishishJoinWords(chosenCases.map(x => cases[x].name)),
renamedFilenames: englishishJoinWords(renamedFilenames.map(x => `\`${x}\``)),
},
};
});
};
const schema = [
{
oneOf: [
{
properties: {
case: {
enum: [
'camelCase',
'snakeCase',
'kebabCase',
'pascalCase',
],
description: 'The filename case style.',
},
ignore: {
type: 'array',
uniqueItems: true,
description: 'Patterns to ignore.',
},
multipleFileExtensions: {
type: 'boolean',
description: 'Whether to treat additional, dot-separated parts of a filename as file extensions.',
},
},
additionalProperties: false,
},
{
properties: {
cases: {
properties: {
camelCase: {
type: 'boolean',
description: 'Whether to allow camelCase filenames.',
},
snakeCase: {
type: 'boolean',
description: 'Whether to allow snake_case filenames.',
},
kebabCase: {
type: 'boolean',
description: 'Whether to allow kebab-case filenames.',
},
pascalCase: {
type: 'boolean',
description: 'Whether to allow PascalCase filenames.',
},
},
additionalProperties: false,
description: 'The allowed filename case styles.',
},
ignore: {
type: 'array',
uniqueItems: true,
description: 'Patterns to ignore.',
},
multipleFileExtensions: {
type: 'boolean',
description: 'Whether to treat additional, dot-separated parts of a filename as file extensions.',
},
},
additionalProperties: false,
},
],
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce a case style for filenames.',
recommended: true,
},
schema,
// eslint-disable-next-line eslint-plugin/require-meta-default-options
defaultOptions: [],
messages,
},
};
export default config;
@@ -0,0 +1,29 @@
import {isSemicolonToken} from '@eslint-community/eslint-utils';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.ReturnStatement | ESTree.ThrowStatement} node
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function * addParenthesizesToReturnOrThrowExpression(fixer, node, context) {
if (node.type !== 'ReturnStatement' && node.type !== 'ThrowStatement') {
return;
}
const {sourceCode} = context;
const returnOrThrowToken = sourceCode.getFirstToken(node);
yield fixer.insertTextAfter(returnOrThrowToken, ' (');
const lastToken = sourceCode.getLastToken(node);
if (!isSemicolonToken(lastToken)) {
yield fixer.insertTextAfter(node, ')');
return;
}
yield fixer.insertTextBefore(lastToken, ')');
}
@@ -0,0 +1,30 @@
import {isCommaToken} from '@eslint-community/eslint-utils';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.CallExpression} node
@param {string} text
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function appendArgument(fixer, node, text, context) {
// This function should also work for `NewExpression`
// But parentheses of `NewExpression` could be omitted, add this check to prevent accidental use on it
/* c8 ignore next 3 */
if (node.type !== 'CallExpression') {
throw new Error(`Unexpected node "${node.type}".`);
}
const {sourceCode} = context;
const [penultimateToken, lastToken] = sourceCode.getLastTokens(node, 2);
if (node.arguments.length > 0) {
text = isCommaToken(penultimateToken) ? ` ${text},` : `, ${text}`;
}
return fixer.insertTextBefore(lastToken, text);
}
@@ -0,0 +1,11 @@
/**
Extend fix range to prevent changes from other rules.
https://github.com/eslint/eslint/pull/13748/files#diff-c692f3fde09eda7c89f1802c908511a3fb59f5d207fe95eb009cb52e46a99e84R348
@param {ruleFixer} fixer - The fixer to fix.
@param {int[]} range - The extended range node.
*/
export default function * extendFixRange(fixer, range) {
yield fixer.insertTextBeforeRange(range, '');
yield fixer.insertTextAfterRange(range, '');
}
@@ -0,0 +1,43 @@
import {getParenthesizedRange} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
const isProblematicToken = ({type, value}) => (
(type === 'Keyword' && /^[a-z]*$/.test(value))
// ForOfStatement
|| (type === 'Identifier' && value === 'of')
// AwaitExpression
|| (type === 'Identifier' && value === 'await')
);
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.Node} node
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
*/
export default function * fixSpaceAroundKeyword(fixer, node, context) {
const {sourceCode} = context;
const range = getParenthesizedRange(node, context);
const tokenBefore = sourceCode.getTokenBefore({range}, {includeComments: true});
if (
tokenBefore
&& range[0] === sourceCode.getRange(tokenBefore)[1]
&& isProblematicToken(tokenBefore)
) {
yield fixer.insertTextAfter(tokenBefore, ' ');
}
const tokenAfter = sourceCode.getTokenAfter({range}, {includeComments: true});
if (
tokenAfter
&& range[1] === sourceCode.getRange(tokenAfter)[0]
&& isProblematicToken(tokenAfter)
) {
yield fixer.insertTextBefore(tokenAfter, ' ');
}
}
+23
View File
@@ -0,0 +1,23 @@
export {default as extendFixRange} from './extend-fix-range.js';
export {default as removeParentheses} from './remove-parentheses.js';
export {default as appendArgument} from './append-argument.js';
export {default as removeArgument} from './remove-argument.js';
export {default as replaceArgument} from './replace-argument.js';
export {default as switchNewExpressionToCallExpression} from './switch-new-expression-to-call-expression.js';
export {default as switchCallExpressionToNewExpression} from './switch-call-expression-to-new-expression.js';
export {
replaceMemberExpressionProperty,
removeMemberExpressionProperty,
} from './replace-member-expression-property.js';
export {default as removeMethodCall} from './remove-method-call.js';
export {default as removeExpressionStatement} from './remove-expression-statement.js';
export {default as removeSpacesAfter} from './remove-spaces-after.js';
export {default as removeSpecifier} from './remove-specifier.js';
export {default as removeObjectProperty} from './remove-object-property.js';
export {default as renameVariable} from './rename-variable.js';
export {default as replaceTemplateElement} from './replace-template-element.js';
export {default as replaceReferenceIdentifier} from './replace-reference-identifier.js';
export {default as replaceNodeOrTokenAndSpacesBefore} from './replace-node-or-token-and-spaces-before.js';
export {default as fixSpaceAroundKeyword} from './fix-space-around-keywords.js';
export {default as replaceStringRaw} from './replace-string-raw.js';
export {default as addParenthesizesToReturnOrThrowExpression} from './add-parenthesizes-to-return-or-throw-expression.js';
@@ -0,0 +1,40 @@
import {isCommaToken} from '@eslint-community/eslint-utils';
import {getParentheses} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.NewExpression | ESTree.CallExpression} node
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function removeArgument(fixer, node, context) {
const callOrNewExpression = node.parent;
const index = callOrNewExpression.arguments.indexOf(node);
const parentheses = getParentheses(node, context);
const firstToken = parentheses[0] || node;
const lastToken = parentheses.at(-1) || node;
const {sourceCode} = context;
let [start] = sourceCode.getRange(firstToken);
let [, end] = sourceCode.getRange(lastToken);
if (index !== 0) {
const commaToken = sourceCode.getTokenBefore(firstToken);
[start] = sourceCode.getRange(commaToken);
}
// If the removed argument is the only argument, the trailing comma must be removed too
if (callOrNewExpression.arguments.length === 1) {
const tokenAfter = sourceCode.getTokenAfter(lastToken);
if (isCommaToken(tokenAfter)) {
[, end] = sourceCode.getRange(tokenAfter);
}
}
return fixer.removeRange([start, end]);
}
@@ -0,0 +1,35 @@
import {isSemicolonToken} from '@eslint-community/eslint-utils';
const isWhitespaceOnly = text => /^\s*$/.test(text);
function removeExpressionStatement(expressionStatement, context, fixer, preserveSemiColon = false) {
const {sourceCode} = context;
const {lines} = sourceCode;
let endToken = expressionStatement;
if (preserveSemiColon) {
const [penultimateToken, lastToken] = sourceCode.getLastTokens(expressionStatement, 2);
if (isSemicolonToken(lastToken)) {
endToken = penultimateToken;
}
}
const startLocation = sourceCode.getLoc(expressionStatement).start;
const endLocation = sourceCode.getLoc(endToken).end;
const textBefore = lines[startLocation.line - 1].slice(0, startLocation.column);
const textAfter = lines[endLocation.line - 1].slice(endLocation.column);
let [start] = sourceCode.getRange(expressionStatement);
let [, end] = sourceCode.getRange(endToken);
if (isWhitespaceOnly(textBefore) && isWhitespaceOnly(textAfter)) {
start = Math.max(0, start - textBefore.length - 1);
end += textAfter.length;
}
return fixer.removeRange([start, end]);
}
export default removeExpressionStatement;
@@ -0,0 +1,28 @@
import {getParenthesizedRange} from '../utils/index.js';
import {removeMemberExpressionProperty} from './replace-member-expression-property.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.CallExpression} callExpression
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function * removeMethodCall(fixer, callExpression, context) {
const memberExpression = callExpression.callee;
// `(( (( foo )).bar ))()`
// ^^^^
yield removeMemberExpressionProperty(fixer, memberExpression, context);
// `(( (( foo )).bar ))()`
// ^^
const [, start] = getParenthesizedRange(memberExpression, context);
const [, end] = context.sourceCode.getRange(callExpression);
yield fixer.removeRange([start, end]);
}
@@ -0,0 +1,21 @@
import {isCommaToken} from '@eslint-community/eslint-utils';
export default function * removeObjectProperty(fixer, property, context) {
const {sourceCode} = context;
for (const token of sourceCode.getTokens(property)) {
yield fixer.remove(token);
}
const tokenAfter = sourceCode.getTokenAfter(property);
if (isCommaToken(tokenAfter)) {
yield fixer.remove(tokenAfter);
} else {
// If the property is the last one and there is no trailing comma
// remove the previous comma
const {properties} = property.parent;
if (properties.length > 1 && properties.at(-1) === property) {
const commaTokenBefore = sourceCode.getTokenBefore(property);
yield fixer.remove(commaTokenBefore);
}
}
}
@@ -0,0 +1,19 @@
import {getParentheses} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.Node} node
@param {ESLint.Rule.RuleFixer} fixer
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function * removeParentheses(node, fixer, context) {
const parentheses = getParentheses(node, context);
for (const token of parentheses) {
yield fixer.remove(token);
}
}
@@ -0,0 +1,22 @@
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.Node | ESTree.Token | number} indexOrNodeOrToken
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {ESLint.Rule.RuleFixer} fixer
@returns {ESLint.Rule.ReportFixer}
*/
export default function removeSpacesAfter(indexOrNodeOrToken, context, fixer) {
let index = indexOrNodeOrToken;
if (typeof indexOrNodeOrToken === 'object') {
index = context.sourceCode.getRange(indexOrNodeOrToken)[1];
}
const textAfter = context.sourceCode.text.slice(index);
const [leadingSpaces] = textAfter.match(/^\s*/);
return fixer.removeRange([index, index + leadingSpaces.length]);
}
@@ -0,0 +1,59 @@
import {isCommaToken, isOpeningBraceToken} from '@eslint-community/eslint-utils';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.ImportSpecifier | ESTree.ExportSpecifier | ESTree.ImportDefaultSpecifier | ESTree.ImportNamespaceSpecifier} specifier
@param {ESLint.Rule.RuleFixer} fixer
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {boolean} [keepDeclaration = false]
@returns {ESLint.Rule.ReportFixer}
*/
export default function * removeSpecifier(specifier, fixer, context, keepDeclaration = false) {
const declaration = specifier.parent;
const {specifiers} = declaration;
if (specifiers.length === 1 && !keepDeclaration) {
yield fixer.remove(declaration);
return;
}
const {sourceCode} = context;
switch (specifier.type) {
case 'ImportSpecifier': {
const isTheOnlyNamedImport = specifiers.every(node => specifier === node || specifier.type !== node.type);
if (isTheOnlyNamedImport) {
const fromToken = sourceCode.getTokenAfter(specifier, token => token.type === 'Identifier' && token.value === 'from');
const hasDefaultImport = specifiers.some(node => node.type === 'ImportDefaultSpecifier');
const startToken = sourceCode.getTokenBefore(specifier, hasDefaultImport ? isCommaToken : isOpeningBraceToken);
const [start] = sourceCode.getRange(startToken);
const [end] = sourceCode.getRange(fromToken);
const tokenBefore = sourceCode.getTokenBefore(startToken);
const shouldInsertSpace = sourceCode.getRange(tokenBefore)[1] === start;
yield fixer.replaceTextRange([start, end], shouldInsertSpace ? ' ' : '');
return;
}
// Fallthrough
}
case 'ExportSpecifier':
case 'ImportNamespaceSpecifier':
case 'ImportDefaultSpecifier': {
yield fixer.remove(specifier);
const tokenAfter = sourceCode.getTokenAfter(specifier);
if (isCommaToken(tokenAfter)) {
yield fixer.remove(tokenAfter);
}
break;
}
// No default
}
}
@@ -0,0 +1,8 @@
import getVariableIdentifiers from '../utils/get-variable-identifiers.js';
import replaceReferenceIdentifier from './replace-reference-identifier.js';
const renameVariable = (variable, name, context, fixer) =>
getVariableIdentifiers(variable)
.map(identifier => replaceReferenceIdentifier(identifier, name, context, fixer));
export default renameVariable;
@@ -0,0 +1,17 @@
import {getParenthesizedRange} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESLint.Rule.RuleFixer} fixer
@param {ESTree.CallExpression | ESTree.NewExpression} node
@param {string} text
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export default function replaceArgument(fixer, node, text, context) {
return fixer.replaceTextRange(getParenthesizedRange(node, context), text);
}
@@ -0,0 +1,25 @@
import {getParenthesizedRange} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.MemberExpression} memberExpression - The `MemberExpression` to fix.
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {string} text
@returns {ESLint.Rule.ReportFixer}
*/
export function replaceMemberExpressionProperty(fixer, memberExpression, context, text) {
const [, start] = getParenthesizedRange(memberExpression.object, context);
const [, end] = context.sourceCode.getRange(memberExpression);
return fixer.replaceTextRange([start, end], text);
}
/**
@param {ESTree.MemberExpression} memberExpression - The `MemberExpression` to fix.
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@returns {ESLint.Rule.ReportFixer}
*/
export const removeMemberExpressionProperty = (fixer, memberExpression, context) => replaceMemberExpressionProperty(fixer, memberExpression, context, '');
@@ -0,0 +1,31 @@
import {getParentheses} from '../utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.Node | ESTree.Token} nodeOrToken
@param {string} replacement
@param {ESLint.Rule.RuleFixer} fixer
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {ESLint.SourceCode} [tokenStore]
@returns {ESLint.Rule.ReportFixer}
*/
export default function * replaceNodeOrTokenAndSpacesBefore(nodeOrToken, replacement, fixer, context, tokenStore) {
const tokens = getParentheses(nodeOrToken, tokenStore ? {sourceCode: tokenStore} : context);
for (const token of tokens) {
yield replaceNodeOrTokenAndSpacesBefore(token, '', fixer, context, tokenStore);
}
const {sourceCode} = context;
let [start, end] = sourceCode.getRange(nodeOrToken);
const textBefore = sourceCode.text.slice(0, start);
const [trailingSpaces] = textBefore.match(/\s*$/);
const [lineBreak] = trailingSpaces.match(/(?:\r?\n|\r){0,1}/);
start -= trailingSpaces.length;
yield fixer.replaceTextRange([start, end], `${lineBreak}${replacement}`);
}
@@ -0,0 +1,32 @@
import isShorthandPropertyValue from '../utils/is-shorthand-property-value.js';
import isShorthandPropertyAssignmentPatternLeft from '../utils/is-shorthand-property-assignment-pattern-left.js';
import isShorthandImportLocal from '../utils/is-shorthand-import-local.js';
import isShorthandExportLocal from '../utils/is-shorthand-export-local.js';
export default function replaceReferenceIdentifier(identifier, replacement, context, fixer) {
if (
isShorthandPropertyValue(identifier)
|| isShorthandPropertyAssignmentPatternLeft(identifier)
) {
return fixer.replaceText(identifier, `${identifier.name}: ${replacement}`);
}
if (isShorthandImportLocal(identifier, context)) {
return fixer.replaceText(identifier, `${identifier.name} as ${replacement}`);
}
if (isShorthandExportLocal(identifier, context)) {
return fixer.replaceText(identifier, `${replacement} as ${identifier.name}`);
}
// `typeAnnotation`
if (identifier.typeAnnotation) {
const {sourceCode} = context;
return fixer.replaceTextRange(
[sourceCode.getRange(identifier)[0], sourceCode.getRange(identifier.typeAnnotation)[0]],
`${replacement}${identifier.optional ? '?' : ''}`,
);
}
return fixer.replaceText(identifier, replacement);
}
@@ -0,0 +1,12 @@
// Replace `StringLiteral` or `TemplateLiteral` node with raw text
const replaceStringRaw = (node, raw, context, fixer) =>
fixer.replaceTextRange(
// Ignore quotes and backticks
[
context.sourceCode.getRange(node)[0] + 1,
context.sourceCode.getRange(node)[1] - 1,
],
raw,
);
export default replaceStringRaw;
@@ -0,0 +1,10 @@
const replaceTemplateElement = (node, replacement, context, fixer) => {
const {tail} = node;
const [start, end] = context.sourceCode.getRange(node);
return fixer.replaceTextRange(
[start + 1, end - (tail ? 1 : 2)],
replacement,
);
};
export default replaceTemplateElement;
@@ -0,0 +1,31 @@
import {
isParenthesized,
shouldAddParenthesesToNewExpressionCallee,
} from '../utils/index.js';
import fixSpaceAroundKeyword from './fix-space-around-keywords.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.CallExpression} node
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {ESLint.Rule.RuleFixer} fixer
@returns {ESLint.Rule.ReportFixer}
*/
export default function * switchCallExpressionToNewExpression(node, context, fixer) {
yield fixSpaceAroundKeyword(fixer, node, context);
yield fixer.insertTextBefore(node, 'new ');
const {callee} = node;
if (
!isParenthesized(callee, context)
&& shouldAddParenthesesToNewExpressionCallee(callee)
) {
yield fixer.insertTextBefore(callee, '(');
yield fixer.insertTextAfter(callee, ')');
}
}
@@ -0,0 +1,44 @@
import {
isNewExpressionWithParentheses,
isParenthesized,
isOnSameLine,
} from '../utils/index.js';
import addParenthesizesToReturnOrThrowExpression from './add-parenthesizes-to-return-or-throw-expression.js';
import removeSpaceAfter from './remove-spaces-after.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
/**
@param {ESTree.NewExpression} newExpression
@param {ESLint.Rule.RuleContext} context - The ESLint rule context object.
@param {ESLint.Rule.RuleFixer} fixer
@returns {ESLint.Rule.ReportFixer}
*/
export default function * switchNewExpressionToCallExpression(newExpression, context, fixer) {
const newToken = context.sourceCode.getFirstToken(newExpression);
yield fixer.remove(newToken);
yield removeSpaceAfter(newToken, context, fixer);
if (!isNewExpressionWithParentheses(newExpression, context)) {
yield fixer.insertTextAfter(newExpression, '()');
}
/*
Remove `new` from this code will makes the function return `undefined`
```js
() => {
return new // comment
Foo()
}
```
*/
if (!isOnSameLine(newToken, newExpression.callee, context) && !isParenthesized(newExpression, context)) {
// Ideally, we should use first parenthesis of the `callee`, and should check spaces after the `new` token
// But adding extra parentheses is harmless, no need to be too complicated
yield addParenthesizesToReturnOrThrowExpression(fixer, newExpression.parent, context);
}
}
+379
View File
@@ -0,0 +1,379 @@
import {getStringIfConstant} from '@eslint-community/eslint-utils';
import {isCallExpression} from './ast/index.js';
const MESSAGE_ID = 'importStyle';
const messages = {
[MESSAGE_ID]: 'Use {{allowedStyles}} import for module `{{moduleName}}`.',
};
const getActualImportDeclarationStyles = importDeclaration => {
const {specifiers} = importDeclaration;
if (specifiers.length === 0) {
return ['unassigned'];
}
const styles = new Set();
for (const specifier of specifiers) {
if (specifier.type === 'ImportDefaultSpecifier') {
styles.add('default');
continue;
}
if (specifier.type === 'ImportNamespaceSpecifier') {
styles.add('namespace');
continue;
}
if (specifier.type === 'ImportSpecifier') {
if (specifier.imported.type === 'Identifier' && specifier.imported.name === 'default') {
styles.add('default');
continue;
}
styles.add('named');
continue;
}
}
return [...styles];
};
const getActualExportDeclarationStyles = exportDeclaration => {
const {specifiers} = exportDeclaration;
if (specifiers.length === 0) {
return ['unassigned'];
}
const styles = new Set();
for (const specifier of specifiers) {
if (specifier.type === 'ExportSpecifier') {
if (specifier.exported.type === 'Identifier' && specifier.exported.name === 'default') {
styles.add('default');
continue;
}
styles.add('named');
continue;
}
}
return [...styles];
};
const getActualAssignmentTargetImportStyles = assignmentTarget => {
if (assignmentTarget.type === 'Identifier' || assignmentTarget.type === 'ArrayPattern') {
return ['namespace'];
}
if (assignmentTarget.type === 'ObjectPattern') {
if (assignmentTarget.properties.length === 0) {
return ['unassigned'];
}
const styles = new Set();
for (const property of assignmentTarget.properties) {
if (property.type === 'RestElement') {
styles.add('named');
continue;
}
if (property.key.type === 'Identifier') {
if (property.key.name === 'default') {
styles.add('default');
} else {
styles.add('named');
}
}
}
return [...styles];
}
// Next line is not test-coverable until unforceable changes to the language
// like an addition of new AST node types usable in `const __HERE__ = foo;`.
// An exotic custom parser or a bug in one could cover it too.
/* c8 ignore next */
return [];
};
const isAssignedDynamicImport = node =>
node.parent.type === 'AwaitExpression'
&& node.parent.argument === node
&& node.parent.parent.type === 'VariableDeclarator'
&& node.parent.parent.init === node.parent;
// Keep this alphabetically sorted for easier maintenance
const defaultStyles = {
chalk: {
default: true,
},
path: {
default: true,
},
'node:path': {
default: true,
},
util: {
named: true,
},
'node:util': {
named: true,
},
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
let [
{
styles = {},
extendDefaultStyles = true,
checkImport = true,
checkDynamicImport = true,
checkExportFrom = false,
checkRequire = true,
} = {},
] = context.options;
styles = extendDefaultStyles
? Object.fromEntries([...Object.keys(defaultStyles), ...Object.keys(styles)]
.map(name => [name, styles[name] === false ? {} : {...defaultStyles[name], ...styles[name]}]))
: styles;
styles = new Map(Object.entries(styles).map(([moduleName, styles]) =>
[moduleName, new Set(Object.entries(styles).filter(([, isAllowed]) => isAllowed).map(([style]) => style))]));
const {sourceCode} = context;
// eslint-disable-next-line max-params
const report = (node, moduleName, actualImportStyles, allowedImportStyles, isRequire = false) => {
if (!allowedImportStyles || allowedImportStyles.size === 0) {
return;
}
let effectiveAllowedImportStyles = allowedImportStyles;
// For `require`, `'default'` style allows both `x = require('x')` (`'namespace'` style) and
// `{default: x} = require('x')` (`'default'` style) since we don't know in advance
// whether `'x'` is a compiled ES6 module (with `default` key) or a CommonJS module and `require`
// does not provide any automatic interop for this, so the user may have to use either of these.
if (isRequire && allowedImportStyles.has('default') && !allowedImportStyles.has('namespace')) {
effectiveAllowedImportStyles = new Set(allowedImportStyles);
effectiveAllowedImportStyles.add('namespace');
}
if (actualImportStyles.every(style => effectiveAllowedImportStyles.has(style))) {
return;
}
const data = {
allowedStyles: new Intl.ListFormat('en-US', {type: 'disjunction'}).format([...allowedImportStyles.keys()]),
moduleName,
};
context.report({
node,
messageId: MESSAGE_ID,
data,
});
};
if (checkImport) {
context.on('ImportDeclaration', node => {
const moduleName = getStringIfConstant(node.source, sourceCode.getScope(node.source));
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = getActualImportDeclarationStyles(node);
report(node, moduleName, actualImportStyles, allowedImportStyles);
});
}
if (checkDynamicImport) {
context.on('ImportExpression', node => {
if (isAssignedDynamicImport(node)) {
return;
}
const moduleName = getStringIfConstant(node.source, sourceCode.getScope(node.source));
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = ['unassigned'];
report(node, moduleName, actualImportStyles, allowedImportStyles);
});
context.on('VariableDeclarator', node => {
if (!(
node.init?.type === 'AwaitExpression'
&& node.init.argument.type === 'ImportExpression'
)) {
return;
}
const assignmentTargetNode = node.id;
const moduleNameNode = node.init.argument.source;
const moduleName = getStringIfConstant(moduleNameNode, sourceCode.getScope(moduleNameNode));
if (!moduleName) {
return;
}
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = getActualAssignmentTargetImportStyles(assignmentTargetNode);
report(node, moduleName, actualImportStyles, allowedImportStyles);
});
}
if (checkExportFrom) {
context.on('ExportAllDeclaration', node => {
const moduleName = getStringIfConstant(node.source, sourceCode.getScope(node.source));
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = ['namespace'];
report(node, moduleName, actualImportStyles, allowedImportStyles);
});
context.on('ExportNamedDeclaration', node => {
if (!node.source) {
return;
}
const moduleName = getStringIfConstant(node.source, sourceCode.getScope(node.source));
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = getActualExportDeclarationStyles(node);
report(node, moduleName, actualImportStyles, allowedImportStyles);
});
}
if (checkRequire) {
context.on('CallExpression', node => {
if (!(
isCallExpression(node, {
name: 'require',
argumentsLength: 1,
optional: false,
})
&& (node.parent.type === 'ExpressionStatement' && node.parent.expression === node)
)) {
return;
}
const moduleName = getStringIfConstant(node.arguments[0], sourceCode.getScope(node.arguments[0]));
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = ['unassigned'];
report(node, moduleName, actualImportStyles, allowedImportStyles, true);
});
context.on('VariableDeclarator', node => {
if (!(
node.init?.type === 'CallExpression'
&& node.init.callee.type === 'Identifier'
&& node.init.callee.name === 'require'
)) {
return;
}
const assignmentTargetNode = node.id;
const moduleNameNode = node.init.arguments[0];
const moduleName = getStringIfConstant(moduleNameNode, sourceCode.getScope(moduleNameNode));
if (!moduleName) {
return;
}
const allowedImportStyles = styles.get(moduleName);
const actualImportStyles = getActualAssignmentTargetImportStyles(assignmentTargetNode);
report(node, moduleName, actualImportStyles, allowedImportStyles, true);
});
}
};
const schema = {
type: 'array',
additionalItems: false,
items: [
{
type: 'object',
additionalProperties: false,
properties: {
checkImport: {
type: 'boolean',
description: 'Whether to check `import` statements.',
},
checkDynamicImport: {
type: 'boolean',
description: 'Whether to check dynamic `import()` expressions.',
},
checkExportFrom: {
type: 'boolean',
description: 'Whether to check `export … from` statements.',
},
checkRequire: {
type: 'boolean',
description: 'Whether to check `require()` calls.',
},
extendDefaultStyles: {
type: 'boolean',
description: 'Whether to extend the default styles.',
},
styles: {
$ref: '#/definitions/moduleStyles',
description: 'Module import styles.',
},
},
},
],
definitions: {
moduleStyles: {
type: 'object',
additionalProperties: {
$ref: '#/definitions/styles',
},
},
styles: {
anyOf: [
{
enum: [
false,
],
},
{
$ref: '#/definitions/booleanObject',
},
],
},
booleanObject: {
type: 'object',
additionalProperties: {
type: 'boolean',
},
},
},
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce specific import styles per module.',
recommended: 'unopinionated',
},
schema,
defaultOptions: [{}],
messages,
},
};
export default config;
+148
View File
@@ -0,0 +1,148 @@
// Generated file, DO NOT edit
export {default as 'better-regex'} from './better-regex.js';
export {default as 'catch-error-name'} from './catch-error-name.js';
export {default as 'consistent-assert'} from './consistent-assert.js';
export {default as 'consistent-date-clone'} from './consistent-date-clone.js';
export {default as 'consistent-destructuring'} from './consistent-destructuring.js';
export {default as 'consistent-empty-array-spread'} from './consistent-empty-array-spread.js';
export {default as 'consistent-existence-index-check'} from './consistent-existence-index-check.js';
export {default as 'consistent-function-scoping'} from './consistent-function-scoping.js';
export {default as 'consistent-template-literal-escape'} from './consistent-template-literal-escape.js';
export {default as 'custom-error-definition'} from './custom-error-definition.js';
export {default as 'empty-brace-spaces'} from './empty-brace-spaces.js';
export {default as 'error-message'} from './error-message.js';
export {default as 'escape-case'} from './escape-case.js';
export {default as 'expiring-todo-comments'} from './expiring-todo-comments.js';
export {default as 'explicit-length-check'} from './explicit-length-check.js';
export {default as 'filename-case'} from './filename-case.js';
export {default as 'import-style'} from './import-style.js';
export {default as 'isolated-functions'} from './isolated-functions.js';
export {default as 'new-for-builtins'} from './new-for-builtins.js';
export {default as 'no-abusive-eslint-disable'} from './no-abusive-eslint-disable.js';
export {default as 'no-accessor-recursion'} from './no-accessor-recursion.js';
export {default as 'no-anonymous-default-export'} from './no-anonymous-default-export.js';
export {default as 'no-array-callback-reference'} from './no-array-callback-reference.js';
export {default as 'no-array-for-each'} from './no-array-for-each.js';
export {default as 'no-array-method-this-argument'} from './no-array-method-this-argument.js';
export {default as 'no-array-reduce'} from './no-array-reduce.js';
export {default as 'no-array-reverse'} from './no-array-reverse.js';
export {default as 'no-array-sort'} from './no-array-sort.js';
export {default as 'no-await-expression-member'} from './no-await-expression-member.js';
export {default as 'no-await-in-promise-methods'} from './no-await-in-promise-methods.js';
export {default as 'no-console-spaces'} from './no-console-spaces.js';
export {default as 'no-document-cookie'} from './no-document-cookie.js';
export {default as 'no-empty-file'} from './no-empty-file.js';
export {default as 'no-for-loop'} from './no-for-loop.js';
export {default as 'no-hex-escape'} from './no-hex-escape.js';
export {default as 'no-immediate-mutation'} from './no-immediate-mutation.js';
export {default as 'no-instanceof-builtins'} from './no-instanceof-builtins.js';
export {default as 'no-invalid-fetch-options'} from './no-invalid-fetch-options.js';
export {default as 'no-invalid-remove-event-listener'} from './no-invalid-remove-event-listener.js';
export {default as 'no-keyword-prefix'} from './no-keyword-prefix.js';
export {default as 'no-lonely-if'} from './no-lonely-if.js';
export {default as 'no-magic-array-flat-depth'} from './no-magic-array-flat-depth.js';
export {default as 'no-named-default'} from './no-named-default.js';
export {default as 'no-negated-condition'} from './no-negated-condition.js';
export {default as 'no-negation-in-equality-check'} from './no-negation-in-equality-check.js';
export {default as 'no-nested-ternary'} from './no-nested-ternary.js';
export {default as 'no-new-array'} from './no-new-array.js';
export {default as 'no-new-buffer'} from './no-new-buffer.js';
export {default as 'no-null'} from './no-null.js';
export {default as 'no-object-as-default-parameter'} from './no-object-as-default-parameter.js';
export {default as 'no-process-exit'} from './no-process-exit.js';
export {default as 'no-single-promise-in-promise-methods'} from './no-single-promise-in-promise-methods.js';
export {default as 'no-static-only-class'} from './no-static-only-class.js';
export {default as 'no-thenable'} from './no-thenable.js';
export {default as 'no-this-assignment'} from './no-this-assignment.js';
export {default as 'no-typeof-undefined'} from './no-typeof-undefined.js';
export {default as 'no-unnecessary-array-flat-depth'} from './no-unnecessary-array-flat-depth.js';
export {default as 'no-unnecessary-array-splice-count'} from './no-unnecessary-array-splice-count.js';
export {default as 'no-unnecessary-await'} from './no-unnecessary-await.js';
export {default as 'no-unnecessary-polyfills'} from './no-unnecessary-polyfills.js';
export {default as 'no-unnecessary-slice-end'} from './no-unnecessary-slice-end.js';
export {default as 'no-unreadable-array-destructuring'} from './no-unreadable-array-destructuring.js';
export {default as 'no-unreadable-iife'} from './no-unreadable-iife.js';
export {default as 'no-unused-properties'} from './no-unused-properties.js';
export {default as 'no-useless-collection-argument'} from './no-useless-collection-argument.js';
export {default as 'no-useless-error-capture-stack-trace'} from './no-useless-error-capture-stack-trace.js';
export {default as 'no-useless-fallback-in-spread'} from './no-useless-fallback-in-spread.js';
export {default as 'no-useless-iterator-to-array'} from './no-useless-iterator-to-array.js';
export {default as 'no-useless-length-check'} from './no-useless-length-check.js';
export {default as 'no-useless-promise-resolve-reject'} from './no-useless-promise-resolve-reject.js';
export {default as 'no-useless-spread'} from './no-useless-spread.js';
export {default as 'no-useless-switch-case'} from './no-useless-switch-case.js';
export {default as 'no-useless-undefined'} from './no-useless-undefined.js';
export {default as 'no-zero-fractions'} from './no-zero-fractions.js';
export {default as 'number-literal-case'} from './number-literal-case.js';
export {default as 'numeric-separators-style'} from './numeric-separators-style.js';
export {default as 'prefer-add-event-listener'} from './prefer-add-event-listener.js';
export {default as 'prefer-array-find'} from './prefer-array-find.js';
export {default as 'prefer-array-flat-map'} from './prefer-array-flat-map.js';
export {default as 'prefer-array-flat'} from './prefer-array-flat.js';
export {default as 'prefer-array-index-of'} from './prefer-array-index-of.js';
export {default as 'prefer-array-some'} from './prefer-array-some.js';
export {default as 'prefer-at'} from './prefer-at.js';
export {default as 'prefer-bigint-literals'} from './prefer-bigint-literals.js';
export {default as 'prefer-blob-reading-methods'} from './prefer-blob-reading-methods.js';
export {default as 'prefer-class-fields'} from './prefer-class-fields.js';
export {default as 'prefer-classlist-toggle'} from './prefer-classlist-toggle.js';
export {default as 'prefer-code-point'} from './prefer-code-point.js';
export {default as 'prefer-date-now'} from './prefer-date-now.js';
export {default as 'prefer-default-parameters'} from './prefer-default-parameters.js';
export {default as 'prefer-dom-node-append'} from './prefer-dom-node-append.js';
export {default as 'prefer-dom-node-dataset'} from './prefer-dom-node-dataset.js';
export {default as 'prefer-dom-node-remove'} from './prefer-dom-node-remove.js';
export {default as 'prefer-dom-node-text-content'} from './prefer-dom-node-text-content.js';
export {default as 'prefer-event-target'} from './prefer-event-target.js';
export {default as 'prefer-export-from'} from './prefer-export-from.js';
export {default as 'prefer-global-this'} from './prefer-global-this.js';
export {default as 'prefer-import-meta-properties'} from './prefer-import-meta-properties.js';
export {default as 'prefer-includes'} from './prefer-includes.js';
export {default as 'prefer-json-parse-buffer'} from './prefer-json-parse-buffer.js';
export {default as 'prefer-keyboard-event-key'} from './prefer-keyboard-event-key.js';
export {default as 'prefer-logical-operator-over-ternary'} from './prefer-logical-operator-over-ternary.js';
export {default as 'prefer-math-min-max'} from './prefer-math-min-max.js';
export {default as 'prefer-math-trunc'} from './prefer-math-trunc.js';
export {default as 'prefer-modern-dom-apis'} from './prefer-modern-dom-apis.js';
export {default as 'prefer-modern-math-apis'} from './prefer-modern-math-apis.js';
export {default as 'prefer-module'} from './prefer-module.js';
export {default as 'prefer-native-coercion-functions'} from './prefer-native-coercion-functions.js';
export {default as 'prefer-negative-index'} from './prefer-negative-index.js';
export {default as 'prefer-node-protocol'} from './prefer-node-protocol.js';
export {default as 'prefer-number-properties'} from './prefer-number-properties.js';
export {default as 'prefer-object-from-entries'} from './prefer-object-from-entries.js';
export {default as 'prefer-optional-catch-binding'} from './prefer-optional-catch-binding.js';
export {default as 'prefer-prototype-methods'} from './prefer-prototype-methods.js';
export {default as 'prefer-query-selector'} from './prefer-query-selector.js';
export {default as 'prefer-reflect-apply'} from './prefer-reflect-apply.js';
export {default as 'prefer-regexp-test'} from './prefer-regexp-test.js';
export {default as 'prefer-response-static-json'} from './prefer-response-static-json.js';
export {default as 'prefer-set-has'} from './prefer-set-has.js';
export {default as 'prefer-set-size'} from './prefer-set-size.js';
export {default as 'prefer-simple-condition-first'} from './prefer-simple-condition-first.js';
export {default as 'prefer-single-call'} from './prefer-single-call.js';
export {default as 'prefer-spread'} from './prefer-spread.js';
export {default as 'prefer-string-raw'} from './prefer-string-raw.js';
export {default as 'prefer-string-replace-all'} from './prefer-string-replace-all.js';
export {default as 'prefer-string-slice'} from './prefer-string-slice.js';
export {default as 'prefer-string-starts-ends-with'} from './prefer-string-starts-ends-with.js';
export {default as 'prefer-string-trim-start-end'} from './prefer-string-trim-start-end.js';
export {default as 'prefer-structured-clone'} from './prefer-structured-clone.js';
export {default as 'prefer-switch'} from './prefer-switch.js';
export {default as 'prefer-ternary'} from './prefer-ternary.js';
export {default as 'prefer-top-level-await'} from './prefer-top-level-await.js';
export {default as 'prefer-type-error'} from './prefer-type-error.js';
export {default as 'prevent-abbreviations'} from './prevent-abbreviations.js';
export {default as 'relative-url-style'} from './relative-url-style.js';
export {default as 'require-array-join-separator'} from './require-array-join-separator.js';
export {default as 'require-module-attributes'} from './require-module-attributes.js';
export {default as 'require-module-specifiers'} from './require-module-specifiers.js';
export {default as 'require-number-to-fixed-digits-argument'} from './require-number-to-fixed-digits-argument.js';
export {default as 'require-post-message-target-origin'} from './require-post-message-target-origin.js';
export {default as 'string-content'} from './string-content.js';
export {default as 'switch-case-braces'} from './switch-case-braces.js';
export {default as 'switch-case-break-position'} from './switch-case-break-position.js';
export {default as 'template-indent'} from './template-indent.js';
export {default as 'text-encoding-identifier-case'} from './text-encoding-identifier-case.js';
export {default as 'throw-new-error'} from './throw-new-error.js';
+206
View File
@@ -0,0 +1,206 @@
import globals from 'globals';
import {functionTypes} from './ast/index.js';
const MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE = 'externally-scoped-variable';
const messages = {
[MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE]: 'Variable {{name}} not defined in scope of isolated function. Function is isolated because: {{reason}}.',
};
/** @type {{functions: string[], selectors: string[], comments: string[], overrideGlobals?: import('eslint').Linter.Globals}} */
const defaultOptions = {
functions: ['makeSynchronous'],
selectors: [],
comments: ['@isolated'],
overrideGlobals: {},
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
/** @type {typeof defaultOptions} */
const options = {...context.options[0]};
options.comments = options.comments.map(comment => comment.toLowerCase());
const allowedGlobals = {
...(globals[`es${context.languageOptions.ecmaVersion}`] ?? globals.builtins),
...context.languageOptions.globals,
...options.overrideGlobals,
};
const checked = new WeakSet();
/** @param {import('estree').Node} node */
const checkForExternallyScopedVariables = (node, reason) => {
if (checked.has(node) || !functionTypes.includes(node.type)) {
return;
}
checked.add(node);
const nodeScope = sourceCode.getScope(node);
// `through`: "The array of references which could not be resolved in this scope" https://eslint.org/docs/latest/extend/scope-manager-interface#scope-interface
for (const reference of nodeScope.through) {
const {identifier} = reference;
if (identifier.parent.type === 'TSTypeReference' || identifier.parent.type === 'TSTypeQuery') {
continue;
}
if (identifier.name in allowedGlobals && allowedGlobals[identifier.name] !== 'off') {
if (reference.isReadOnly()) {
continue;
}
const globalsValue = allowedGlobals[identifier.name];
const isGlobalWritable = globalsValue === true || globalsValue === 'writable' || globalsValue === 'writeable';
if (isGlobalWritable) {
continue;
}
reason += ' (global variable is not writable)';
}
// Could consider checking for typeof operator here, like in no-undef?
context.report({
node: identifier,
messageId: MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE,
data: {name: identifier.name, reason},
});
}
};
const isComment = token => token?.type === 'Block' || token?.type === 'Line';
/**
Find a comment on this node or its parent, in cases where the node passed is part of a variable or export declaration.
@param {import('estree').Node} node
*/
const findComment = node => {
let previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
let commentableNode = node;
while (
!isComment(previousToken)
&& (commentableNode.parent.type === 'VariableDeclarator'
|| commentableNode.parent.type === 'VariableDeclaration'
|| commentableNode.parent.type === 'ExportNamedDeclaration'
|| commentableNode.parent.type === 'ExportDefaultDeclaration')
) {
commentableNode = commentableNode.parent;
previousToken = sourceCode.getTokenBefore(commentableNode, {includeComments: true});
}
if (isComment(previousToken)) {
return previousToken.value;
}
};
/**
Find the string "reason" that a function (node) should be considered isolated. For passing in to `context.report(...)` when out-of-scope variables are found. Returns undefined if the function should not be considered isolated.
@param {import('estree').Node & {parent?: import('estree').Node}} node
*/
const reasonForBeingIsolatedFunction = node => {
if (options.comments.length > 0) {
let previousComment = findComment(node);
if (previousComment) {
previousComment = previousComment
.replace(/(?:\*\s*)*/, '') // JSDoc comments like `/** @isolated */` are parsed into `* @isolated`. And `/**\n * @isolated */` is parsed into `*\n * @isolated`
.trim()
.toLowerCase();
const match = options.comments.find(comment => previousComment === comment || previousComment.startsWith(`${comment} - `) || previousComment.startsWith(`${comment} -- `));
if (match) {
return `follows comment ${JSON.stringify(match)}`;
}
}
}
if (
options.functions.length > 0
&& node.parent.type === 'CallExpression'
&& node.parent.arguments.includes(node)
&& node.parent.callee.type === 'Identifier'
&& options.functions.includes(node.parent.callee.name)
) {
return `callee of function named ${JSON.stringify(node.parent.callee.name)}`;
}
};
context.onExit(
functionTypes,
node => {
const reason = reasonForBeingIsolatedFunction(node);
if (!reason) {
return;
}
return checkForExternallyScopedVariables(node, reason);
},
);
for (const selector of options.selectors) {
context.onExit(
selector,
node => {
const reason = `matches selector ${JSON.stringify(selector)}`;
return checkForExternallyScopedVariables(node, reason);
},
);
}
};
/** @type {import('json-schema').JSONSchema7[]} */
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
overrideGlobals: {
additionalProperties: {
anyOf: [{type: 'boolean'}, {type: 'string', enum: ['readonly', 'writable', 'writeable', 'off']}],
},
description: 'Override which global variables are allowed inside isolated scopes.',
},
functions: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
description: 'Function names that mark a scope as isolated.',
},
selectors: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
description: 'AST selectors that mark a scope as isolated.',
},
comments: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
description: 'Comment patterns that mark a scope as isolated.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
export default {
create,
meta: {
type: 'problem',
docs: {
description: 'Prevent usage of variables from outside the scope of isolated functions.',
recommended: true,
},
schema,
defaultOptions: [defaultOptions],
messages,
},
};
+112
View File
@@ -0,0 +1,112 @@
import {GlobalReferenceTracker} from './utils/global-reference-tracker.js';
import * as builtins from './utils/builtins.js';
import {
switchCallExpressionToNewExpression,
switchNewExpressionToCallExpression,
fixSpaceAroundKeyword,
} from './fix/index.js';
const MESSAGE_ID_ERROR_DATE = 'error-date';
const MESSAGE_ID_SUGGESTION_DATE = 'suggestion-date';
const messages = {
enforce: 'Use `new {{name}}()` instead of `{{name}}()`.',
disallow: 'Use `{{name}}()` instead of `new {{name}}()`.',
[MESSAGE_ID_ERROR_DATE]: 'Use `String(new Date())` instead of `Date()`.',
[MESSAGE_ID_SUGGESTION_DATE]: 'Switch to `String(new Date())`.',
};
function enforceNewExpression({node, path: [name]}, context) {
if (name === 'Object') {
const {parent} = node;
if (
parent.type === 'BinaryExpression'
&& (parent.operator === '===' || parent.operator === '!==')
&& (parent.left === node || parent.right === node)
) {
return;
}
}
// `Date()` returns a string representation of the current date and time, exactly as `new Date().toString()` does.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#return_value
if (name === 'Date') {
function * fix(fixer) {
yield fixer.replaceText(node, 'String(new Date())');
yield fixSpaceAroundKeyword(fixer, node, context);
}
const problem = {
node,
messageId: MESSAGE_ID_ERROR_DATE,
};
if (context.sourceCode.getCommentsInside(node).length === 0 && node.arguments.length === 0) {
problem.fix = fix;
} else {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_DATE,
fix,
},
];
}
return problem;
}
return {
node,
messageId: 'enforce',
data: {name},
fix: fixer => switchCallExpressionToNewExpression(node, context, fixer),
};
}
function enforceCallExpression({node, path: [name]}, context) {
const problem = {
node,
messageId: 'disallow',
data: {name},
};
if (name !== 'String' && name !== 'Boolean' && name !== 'Number') {
problem.fix = fixer => switchNewExpressionToCallExpression(node, context, fixer);
}
return problem;
}
const newExpressionTracker = new GlobalReferenceTracker({
objects: builtins.disallowNew,
type: GlobalReferenceTracker.CONSTRUCT,
handle: enforceCallExpression,
});
const callExpressionTracker = new GlobalReferenceTracker({
objects: builtins.enforceNew,
type: GlobalReferenceTracker.CALL,
handle: enforceNewExpression,
});
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
newExpressionTracker.listen({context});
callExpressionTracker.listen({context});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,49 @@
import {
getEslintDisableDirectives,
} from './utils/index.js';
const MESSAGE_ID = 'no-abusive-eslint-disable';
const messages = {
[MESSAGE_ID]: 'Specify the rules you want to disable.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('Program', function * () {
for (const directive of getEslintDisableDirectives(context)) {
if (directive.value) {
continue;
}
const {start, end} = context.sourceCode.getLoc(directive.node);
yield {
// Can't set it at the given location as the warning
// will be ignored due to the disable comment
loc: {
start: {
...start,
column: -1,
},
end,
},
messageId: MESSAGE_ID,
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce specifying rules to disable in `eslint-disable` comments.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
@@ -0,0 +1,156 @@
const MESSAGE_ID_ERROR = 'no-accessor-recursion/error';
const messages = {
[MESSAGE_ID_ERROR]: 'Disallow recursive access to `this` within {{kind}}ters.',
};
/**
Get the closest non-arrow function scope.
@param {import('eslint').SourceCode} sourceCode
@param {import('estree').Node} node
@return {import('eslint').Scope.Scope | undefined}
*/
const getClosestFunctionScope = (sourceCode, node) => {
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
if (scope.type === 'class') {
return;
}
if (scope.type === 'function' && scope.block.type !== 'ArrowFunctionExpression') {
return scope;
}
}
};
/** @param {import('estree').Identifier | import('estree').PrivateIdentifier} node */
const isIdentifier = node => node.type === 'Identifier' || node.type === 'PrivateIdentifier';
/** @param {import('estree').ThisExpression} node */
const isDotNotationAccess = node =>
node.parent.type === 'MemberExpression'
&& node.parent.object === node
&& !node.parent.computed
&& isIdentifier(node.parent.property);
/**
Check if a property is a valid getter or setter.
@param {import('estree').Property | import('estree').MethodDefinition} property
*/
const isValidProperty = property =>
['Property', 'MethodDefinition'].includes(property?.type)
&& !property.computed
&& ['set', 'get'].includes(property.kind)
&& isIdentifier(property.key);
/**
Check if two property keys are the same.
@param {import('estree').Property['key']} keyLeft
@param {import('estree').Property['key']} keyRight
*/
const isSameKey = (keyLeft, keyRight) => ['type', 'name'].every(key => keyLeft[key] === keyRight[key]);
/**
Check if `this` is accessed recursively within a getter or setter.
@param {import('estree').ThisExpression} node
@param {import('estree').Property | import('estree').MethodDefinition} property
*/
const isMemberAccess = (node, property) =>
isDotNotationAccess(node)
&& isSameKey(node.parent.property, property.key);
/**
Check if `this` is accessed recursively within a destructuring assignment.
@param {import('estree').ThisExpression} node
@param {import('estree').Property | import('estree').MethodDefinition} property
*/
const isRecursiveDestructuringAccess = (node, property) =>
node.parent.type === 'VariableDeclarator'
&& node.parent.init === node
&& node.parent.id.type === 'ObjectPattern'
&& node.parent.id.properties.some(declaratorProperty =>
declaratorProperty.type === 'Property'
&& !declaratorProperty.computed
&& isSameKey(declaratorProperty.key, property.key));
const isPropertyRead = (thisExpression, property) =>
isMemberAccess(thisExpression, property)
|| isRecursiveDestructuringAccess(thisExpression, property);
const isPropertyWrite = (thisExpression, property) => {
if (!isMemberAccess(thisExpression, property)) {
return false;
}
const memberExpression = thisExpression.parent;
const {parent} = memberExpression;
// This part is similar to `isLeftHandSide`, try to DRY in future
return (
// `this.foo = …`
// `[this.foo = …] = …`
// `({property: this.foo = …] = …)`
(
(parent.type === 'AssignmentExpression' || parent.type === 'AssignmentPattern')
&& parent.left === memberExpression
)
// `++ this.foo`
|| (parent.type === 'UpdateExpression' && parent.argument === memberExpression)
// `[this.foo] = …`
|| (parent.type === 'ArrayPattern' && parent.elements.includes(memberExpression))
// `({property: this.foo} = …)`
|| (
parent.type === 'Property'
&& parent.value === memberExpression
&& parent.parent.type === 'ObjectPattern'
&& parent.parent.properties.includes(memberExpression.parent)
)
);
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
context.on('ThisExpression', /** @param {import('estree').ThisExpression} thisExpression */ thisExpression => {
const scope = getClosestFunctionScope(sourceCode, thisExpression);
if (!scope) {
return;
}
/** @type {import('estree').Property | import('estree').MethodDefinition} */
const property = scope.block.parent;
if (!isValidProperty(property)) {
return;
}
if (property.kind === 'get' && isPropertyRead(thisExpression, property)) {
return {node: thisExpression.parent, messageId: MESSAGE_ID_ERROR, data: {kind: property.kind}};
}
if (property.kind === 'set' && isPropertyWrite(thisExpression, property)) {
return {node: thisExpression.parent, messageId: MESSAGE_ID_ERROR, data: {kind: property.kind}};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow recursive access to `this` within getters and setters.',
recommended: 'unopinionated',
},
defaultOptions: [],
messages,
},
};
export default config;
@@ -0,0 +1,212 @@
import path from 'node:path';
import {getFunctionHeadLocation, getFunctionNameWithKind, isOpeningParenToken} from '@eslint-community/eslint-utils';
import helperValidatorIdentifier from '@babel/helper-validator-identifier';
import {camelCase} from 'change-case';
import {
getClassHeadLocation,
getParenthesizedRange,
getScopes,
getAvailableVariableName,
upperFirst,
} from './utils/index.js';
import {isMemberExpression} from './ast/index.js';
const {isIdentifierName} = helperValidatorIdentifier;
const MESSAGE_ID_ERROR = 'no-anonymous-default-export/error';
const MESSAGE_ID_SUGGESTION = 'no-anonymous-default-export/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'The {{description}} should be named.',
[MESSAGE_ID_SUGGESTION]: 'Name it as `{{name}}`.',
};
const isClassKeywordToken = token => token.type === 'Keyword' && token.value === 'class';
const isAnonymousClassOrFunction = node =>
(
(
node.type === 'FunctionDeclaration'
|| node.type === 'FunctionExpression'
|| node.type === 'ClassDeclaration'
|| node.type === 'ClassExpression'
)
&& !node.id
)
|| node.type === 'ArrowFunctionExpression';
function getSuggestionName(node, filename, sourceCode) {
if (filename === '<input>' || filename === '<text>') {
return;
}
let [name] = path.basename(filename).split('.');
name = camelCase(name);
if (!isIdentifierName(name)) {
return;
}
name = node.type === 'ClassDeclaration' || node.type === 'ClassExpression' ? upperFirst(name) : name;
name = getAvailableVariableName(name, getScopes(sourceCode.getScope(node)));
return name;
}
function addName(fixer, node, name, context) {
const {sourceCode} = context;
switch (node.type) {
case 'ClassDeclaration':
case 'ClassExpression': {
const lastDecorator = node.decorators?.at(-1);
const classToken = lastDecorator
? sourceCode.getTokenAfter(lastDecorator, isClassKeywordToken)
: sourceCode.getFirstToken(node, isClassKeywordToken);
return fixer.insertTextAfter(classToken, ` ${name}`);
}
case 'FunctionDeclaration':
case 'FunctionExpression': {
const openingParenthesisToken = sourceCode.getFirstToken(
node,
isOpeningParenToken,
);
const characterBefore = sourceCode.text.charAt(sourceCode.getRange(openingParenthesisToken)[0] - 1);
return fixer.insertTextBefore(
openingParenthesisToken,
`${characterBefore === ' ' ? '' : ' '}${name} `,
);
}
case 'ArrowFunctionExpression': {
const [exportDeclarationStart, exportDeclarationEnd]
= sourceCode.getRange(node.parent.type === 'ExportDefaultDeclaration'
? node.parent
: node.parent.parent);
const [arrowFunctionStart, arrowFunctionEnd] = getParenthesizedRange(node, context);
let textBefore = sourceCode.text.slice(exportDeclarationStart, arrowFunctionStart);
let textAfter = sourceCode.text.slice(arrowFunctionEnd, exportDeclarationEnd);
textBefore = `\n${textBefore}`;
if (!/\s$/.test(textBefore)) {
textBefore = `${textBefore} `;
}
if (!textAfter.endsWith(';')) {
textAfter = `${textAfter};`;
}
return [
fixer.replaceTextRange(
[exportDeclarationStart, arrowFunctionStart],
`const ${name} = `,
),
fixer.replaceTextRange(
[arrowFunctionEnd, exportDeclarationEnd],
';',
),
fixer.insertTextAfterRange(
[exportDeclarationEnd, exportDeclarationEnd],
`${textBefore}${name}${textAfter}`,
),
];
}
// No default
}
}
function getProblem(node, context) {
const {sourceCode, physicalFilename} = context;
const suggestionName = getSuggestionName(node, physicalFilename, sourceCode);
let loc;
let description;
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
loc = getClassHeadLocation(node, context);
description = 'class';
} else {
loc = getFunctionHeadLocation(node, sourceCode);
// [TODO: @fisker]: Ask `@eslint-community/eslint-utils` to expose `getFunctionKind`
const nameWithKind = getFunctionNameWithKind(node);
description = nameWithKind.replace(/ '.*?'$/, '');
}
const problem = {
node,
loc,
messageId: MESSAGE_ID_ERROR,
data: {
description,
},
};
if (!suggestionName) {
return problem;
}
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {
name: suggestionName,
},
fix: fixer => addName(fixer, node, suggestionName, context),
},
];
return problem;
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ExportDefaultDeclaration', node => {
if (!isAnonymousClassOrFunction(node.declaration)) {
return;
}
return getProblem(node.declaration, context);
});
context.on('AssignmentExpression', node => {
if (
!isAnonymousClassOrFunction(node.right)
|| !(
node.parent.type === 'ExpressionStatement'
&& node.parent.expression === node
)
|| !(
isMemberExpression(node.left, {
object: 'module',
property: 'exports',
computed: false,
optional: false,
})
|| (
node.left.type === 'Identifier'
&& node.left.name === 'exports'
)
)
) {
return;
}
return getProblem(node.right, context);
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow anonymous functions and classes as the default export.',
recommended: 'unopinionated',
},
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,345 @@
import {findVariable} from '@eslint-community/eslint-utils';
import {isMethodCall} from './ast/index.js';
import {
isNodeMatches,
isNodeValueNotFunction,
isParenthesized,
getParenthesizedRange,
getParenthesizedText,
shouldAddParenthesesToCallExpressionCallee,
} from './utils/index.js';
const ERROR_WITH_NAME_MESSAGE_ID = 'error-with-name';
const ERROR_WITHOUT_NAME_MESSAGE_ID = 'error-without-name';
const REPLACE_WITH_NAME_MESSAGE_ID = 'replace-with-name';
const REPLACE_WITHOUT_NAME_MESSAGE_ID = 'replace-without-name';
const messages = {
[ERROR_WITH_NAME_MESSAGE_ID]: 'Do not pass function `{{name}}` directly to `.{{method}}(…)`.',
[ERROR_WITHOUT_NAME_MESSAGE_ID]: 'Do not pass function directly to `.{{method}}(…)`.',
[REPLACE_WITH_NAME_MESSAGE_ID]: 'Replace function `{{name}}` with `… => {{name}}({{parameters}})`.',
[REPLACE_WITHOUT_NAME_MESSAGE_ID]: 'Replace function with `… => …({{parameters}})`.',
};
const isAwaitExpressionArgument = node => node.parent.type === 'AwaitExpression' && node.parent.argument === node;
const iteratorMethods = new Map([
{
method: 'every',
ignore: [
'Boolean',
],
},
{
method: 'filter',
shouldIgnoreCallExpression: node => (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Vue'),
ignore: [
'Boolean',
],
},
{
method: 'find',
ignore: [
'Boolean',
],
},
{
method: 'findLast',
ignore: [
'Boolean',
],
},
{
method: 'findIndex',
ignore: [
'Boolean',
],
},
{
method: 'findLastIndex',
ignore: [
'Boolean',
],
},
{
method: 'flatMap',
},
{
method: 'forEach',
returnsUndefined: true,
},
{
method: 'map',
shouldIgnoreCallExpression: node => (node.callee.object.type === 'Identifier' && node.callee.object.name === 'types'),
ignore: [
'String',
'Number',
'BigInt',
'Boolean',
'Symbol',
],
},
{
method: 'reduce',
parameters: [
'accumulator',
'element',
'index',
'array',
],
minParameters: 2,
},
{
method: 'reduceRight',
parameters: [
'accumulator',
'element',
'index',
'array',
],
minParameters: 2,
},
{
method: 'some',
ignore: [
'Boolean',
],
},
].map(({
method,
parameters = ['element', 'index', 'array'],
ignore = [],
minParameters = 1,
returnsUndefined = false,
shouldIgnoreCallExpression,
}) => [method, {
minParameters,
parameters,
returnsUndefined,
shouldIgnoreCallExpression(callExpression) {
if (
method !== 'reduce'
&& method !== 'reduceRight'
&& isAwaitExpressionArgument(callExpression)
) {
return true;
}
if (isNodeMatches(callExpression.callee.object, ignoredCallee)) {
return true;
}
if (
callExpression.callee.object.type === 'CallExpression'
&& isNodeMatches(callExpression.callee.object.callee, ignoredCallee)
) {
return true;
}
return shouldIgnoreCallExpression?.(callExpression) ?? false;
},
shouldIgnoreCallback(callback) {
if (callback.type === 'Identifier' && ignore.includes(callback.name)) {
return true;
}
return false;
},
}]));
const ignoredCallee = [
// http://bluebirdjs.com/docs/api/promise.map.html
'Promise',
'React.Children',
'Children',
'lodash',
'underscore',
'_',
'Async',
'async',
'this',
'$',
'jQuery',
];
function getProblem(context, node, method, options) {
const {type} = node;
const name = type === 'Identifier' ? node.name : '';
const problem = {
node,
messageId: name ? ERROR_WITH_NAME_MESSAGE_ID : ERROR_WITHOUT_NAME_MESSAGE_ID,
data: {
name,
method,
},
};
if (node.type === 'YieldExpression' || node.type === 'AwaitExpression') {
return problem;
}
problem.suggest = [];
const {parameters, minParameters, returnsUndefined} = options;
for (let parameterLength = minParameters; parameterLength <= parameters.length; parameterLength++) {
const suggestionParameters = parameters.slice(0, parameterLength).join(', ');
const suggest = {
messageId: name ? REPLACE_WITH_NAME_MESSAGE_ID : REPLACE_WITHOUT_NAME_MESSAGE_ID,
data: {
name,
parameters: suggestionParameters,
},
fix(fixer) {
let text = getParenthesizedText(node, context);
if (
!isParenthesized(node, context)
&& shouldAddParenthesesToCallExpressionCallee(node)
) {
text = `(${text})`;
}
return fixer.replaceTextRange(
getParenthesizedRange(node, context),
returnsUndefined
? `(${suggestionParameters}) => { ${text}(${suggestionParameters}); }`
: `(${suggestionParameters}) => ${text}(${suggestionParameters})`,
);
},
};
problem.suggest.push(suggest);
}
return problem;
}
function * getTernaryConsequentAndALternate(node) {
if (node.type === 'ConditionalExpression') {
yield * getTernaryConsequentAndALternate(node.consequent);
yield * getTernaryConsequentAndALternate(node.alternate);
return;
}
yield node;
}
// These methods have dedicated type-predicate overloads in TypeScript's lib files.
// Wrapping a type guard can lose narrowing, so direct references should be allowed here.
const methodsWithTypePredicateOverloads = new Set([
'every',
'filter',
'find',
'findLast',
]);
function hasTypePredicateReturnType(node) {
return node.returnType?.typeAnnotation?.type === 'TSTypePredicate';
}
function hasTypePredicateFunctionType(node) {
return node.typeAnnotation?.typeAnnotation?.returnType?.typeAnnotation?.type === 'TSTypePredicate';
}
function isTypePredicateCallback(callback, context) {
if (callback.type !== 'Identifier') {
return false;
}
// Keep this local and syntax-based. Imported/member expressions need type-aware linting.
const variable = findVariable(context.sourceCode.getScope(callback), callback);
const definition = variable?.defs[0];
if (!definition) {
return false;
}
if (definition.type === 'FunctionName') {
return hasTypePredicateReturnType(definition.node);
}
// Imported callbacks may be type guards, but we can't inspect their predicate return
// type without type-aware linting. Be conservative on methods with predicate overloads.
if (definition.type === 'ImportBinding') {
return true;
}
if (definition.type === 'Parameter') {
return hasTypePredicateFunctionType(definition.name);
}
if (definition.type === 'Variable') {
if (hasTypePredicateFunctionType(definition.node.id)) {
return true;
}
const {init} = definition.node;
return init
&& (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')
&& hasTypePredicateReturnType(init);
}
return false;
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', function * (callExpression) {
if (
!isMethodCall(callExpression, {
minimumArguments: 1,
maximumArguments: 2,
optionalCall: false,
computed: false,
})
|| callExpression.callee.property.type !== 'Identifier'
) {
return;
}
const methodNode = callExpression.callee.property;
const methodName = methodNode.name;
if (!iteratorMethods.has(methodName)) {
return;
}
const options = iteratorMethods.get(methodName);
if (options.shouldIgnoreCallExpression(callExpression)) {
return;
}
for (const callback of getTernaryConsequentAndALternate(callExpression.arguments[0])) {
if (
callback.type === 'FunctionExpression'
|| callback.type === 'ArrowFunctionExpression'
// Ignore all `CallExpression`s, including `function.bind()`
|| callback.type === 'CallExpression'
|| options.shouldIgnoreCallback(callback)
|| isNodeValueNotFunction(callback)
|| (methodsWithTypePredicateOverloads.has(methodName) && isTypePredicateCallback(callback, context))
) {
continue;
}
yield getProblem(context, callback, methodName, options);
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Prevent passing a function reference directly to iterator methods.',
recommended: true,
},
hasSuggestions: true,
messages,
},
};
export default config;
+498
View File
@@ -0,0 +1,498 @@
import {
isCommaToken,
isSemicolonToken,
isClosingParenToken,
findVariable,
hasSideEffect,
} from '@eslint-community/eslint-utils';
import {
extendFixRange,
fixSpaceAroundKeyword,
removeParentheses,
} from './fix/index.js';
import {
isArrowFunctionBody,
isMethodCall,
isReferenceIdentifier,
functionTypes,
} from './ast/index.js';
import {
needsSemicolon,
shouldAddParenthesesToExpressionStatementExpression,
shouldAddParenthesesToMemberExpressionObject,
isParenthesized,
getParentheses,
getParenthesizedRange,
isFunctionSelfUsedInside,
isNodeMatches,
assertToken,
} from './utils/index.js';
const MESSAGE_ID_ERROR = 'no-array-for-each/error';
const MESSAGE_ID_SUGGESTION = 'no-array-for-each/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Use `for…of` instead of `.forEach(…)`.',
[MESSAGE_ID_SUGGESTION]: 'Switch to `for…of`.',
};
const continueAbleNodeTypes = new Set([
'WhileStatement',
'DoWhileStatement',
'ForStatement',
'ForOfStatement',
'ForInStatement',
]);
const stripChainExpression = node =>
(node.parent.type === 'ChainExpression' && node.parent.expression === node)
? node.parent
: node;
function isReturnStatementInContinueAbleNodes(returnStatement, callbackFunction) {
for (let node = returnStatement; node && node !== callbackFunction; node = node.parent) {
if (continueAbleNodeTypes.has(node.type)) {
return true;
}
}
return false;
}
function shouldSwitchReturnStatementToBlockStatement(returnStatement) {
const {parent} = returnStatement;
switch (parent.type) {
case 'IfStatement': {
return parent.consequent === returnStatement || parent.alternate === returnStatement;
}
// These parent's body need switch to `BlockStatement` too, but since they are "continueAble", won't fix
// case 'ForStatement':
// case 'ForInStatement':
// case 'ForOfStatement':
// case 'WhileStatement':
// case 'DoWhileStatement':
case 'WithStatement': {
return parent.body === returnStatement;
}
default: {
return false;
}
}
}
function getFixFunction(callExpression, functionInfo, context) {
const {sourceCode} = context;
const [callback] = callExpression.arguments;
const parameters = callback.params;
const iterableObject = callExpression.callee.object;
const {returnStatements} = functionInfo.get(callback);
const isOptionalObject = callExpression.callee.optional;
const ancestor = stripChainExpression(callExpression).parent;
const objectText = sourceCode.getText(iterableObject);
const getForOfLoopHeadText = () => {
const [elementText, indexText] = parameters.map(parameter => sourceCode.getText(parameter));
const shouldUseEntries = parameters.length === 2;
let text = 'for (';
text += isFunctionParameterVariableReassigned(callback, sourceCode) ? 'let' : 'const';
text += ' ';
text += shouldUseEntries ? `[${indexText}, ${elementText}]` : elementText;
text += ' of ';
const shouldAddParenthesesToObject
= isParenthesized(iterableObject, context)
|| (
// `1?.forEach()` -> `(1).entries()`
isOptionalObject
&& shouldUseEntries
&& shouldAddParenthesesToMemberExpressionObject(iterableObject, context)
);
text += shouldAddParenthesesToObject ? `(${objectText})` : objectText;
if (shouldUseEntries) {
text += '.entries()';
}
text += ') ';
return text;
};
const getForOfLoopHeadRange = () => {
const [start] = sourceCode.getRange(callExpression);
const [end] = getParenthesizedRange(callback.body, context);
return [start, end];
};
function * replaceReturnStatement(returnStatement, fixer) {
const returnToken = sourceCode.getFirstToken(returnStatement);
assertToken(returnToken, {
expected: 'return',
ruleId: 'no-array-for-each',
});
if (!returnStatement.argument) {
yield fixer.replaceText(returnToken, 'continue');
return;
}
// Remove `return`
yield fixer.remove(returnToken);
const previousToken = sourceCode.getTokenBefore(returnToken);
const nextToken = sourceCode.getTokenAfter(returnToken);
let textBefore = '';
let textAfter = '';
const shouldAddParentheses
= !isParenthesized(returnStatement.argument, context)
&& shouldAddParenthesesToExpressionStatementExpression(returnStatement.argument);
if (shouldAddParentheses) {
textBefore = `(${textBefore}`;
textAfter = `${textAfter})`;
}
const insertBraces = shouldSwitchReturnStatementToBlockStatement(returnStatement);
if (insertBraces) {
textBefore = `{ ${textBefore}`;
} else if (needsSemicolon(previousToken, context, shouldAddParentheses ? '(' : nextToken.value)) {
textBefore = `;${textBefore}`;
}
if (textBefore) {
yield fixer.insertTextBefore(nextToken, textBefore);
}
if (textAfter) {
yield fixer.insertTextAfter(returnStatement.argument, textAfter);
}
const returnStatementHasSemicolon = isSemicolonToken(sourceCode.getLastToken(returnStatement));
if (!returnStatementHasSemicolon) {
yield fixer.insertTextAfter(returnStatement, ';');
}
yield fixer.insertTextAfter(returnStatement, ' continue;');
if (insertBraces) {
yield fixer.insertTextAfter(returnStatement, ' }');
}
}
const shouldRemoveExpressionStatementLastToken = token => {
if (!isSemicolonToken(token)) {
return false;
}
if (callback.body.type !== 'BlockStatement') {
return false;
}
return true;
};
function * removeCallbackParentheses(fixer) {
// Opening parenthesis tokens already included in `getForOfLoopHeadRange`
const closingParenthesisTokens = getParentheses(callback, context)
.filter(token => isClosingParenToken(token));
for (const closingParenthesisToken of closingParenthesisTokens) {
yield fixer.remove(closingParenthesisToken);
}
}
return function * (fixer) {
// `(( foo.forEach(bar => bar) ))`
yield removeParentheses(callExpression, fixer, context);
// Replace these with `for (const … of …) `
// foo.forEach(bar => bar)
// ^^^^^^^^^^^^^^^^^^^^^^
// foo.forEach(bar => (bar))
// ^^^^^^^^^^^^^^^^^^^^^^
// foo.forEach(bar => {})
// ^^^^^^^^^^^^^^^^^^^^^^
// foo.forEach(function(bar) {})
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
yield fixer.replaceTextRange(getForOfLoopHeadRange(), getForOfLoopHeadText());
// Parenthesized callback function
// foo.forEach( ((bar => {})) )
// ^^
yield removeCallbackParentheses(fixer);
const [
penultimateToken,
lastToken,
] = sourceCode.getLastTokens(callExpression, 2);
// The possible trailing comma token of `Array#forEach()` CallExpression
// foo.forEach(bar => {},)
// ^
if (isCommaToken(penultimateToken)) {
yield fixer.remove(penultimateToken);
}
// The closing parenthesis token of `Array#forEach()` CallExpression
// foo.forEach(bar => {})
// ^
yield fixer.remove(lastToken);
for (const returnStatement of returnStatements) {
yield replaceReturnStatement(returnStatement, fixer);
}
if (ancestor.type === 'ExpressionStatement') {
const expressionStatementLastToken = sourceCode.getLastToken(ancestor);
// Remove semicolon if it's not needed anymore
// foo.forEach(bar => {});
// ^
if (shouldRemoveExpressionStatementLastToken(expressionStatementLastToken)) {
yield fixer.remove(expressionStatementLastToken, fixer);
}
} else if (ancestor.type === 'ArrowFunctionExpression') {
yield fixer.insertTextBefore(callExpression, '{ ');
yield fixer.insertTextAfter(callExpression, ' }');
}
yield fixSpaceAroundKeyword(fixer, callExpression.parent, context);
if (isOptionalObject) {
yield fixer.insertTextBefore(callExpression, `if (${objectText}) `);
}
// Prevent possible variable conflicts
yield extendFixRange(fixer, sourceCode.getRange(callExpression.parent));
};
}
const isChildScope = (child, parent) => {
for (let scope = child; scope; scope = scope.upper) {
if (scope === parent) {
return true;
}
}
return false;
};
function isFunctionParametersSafeToFix(callbackFunction, {sourceCode, scope, callExpression, allIdentifiers}) {
const variables = sourceCode.getDeclaredVariables(callbackFunction);
for (const variable of variables) {
if (variable.defs.length !== 1) {
return false;
}
const [definition] = variable.defs;
if (definition.type !== 'Parameter') {
continue;
}
const variableName = definition.name.name;
const [callExpressionStart, callExpressionEnd] = sourceCode.getRange(callExpression);
for (const identifier of allIdentifiers) {
const {name} = identifier;
const [start, end] = sourceCode.getRange(identifier);
if (
name !== variableName
|| start < callExpressionStart
|| end > callExpressionEnd
) {
continue;
}
const variable = findVariable(scope, identifier);
if (!variable || variable.scope === scope || isChildScope(scope, variable.scope)) {
return false;
}
}
}
return true;
}
function isFunctionParameterVariableReassigned(callbackFunction, sourceCode) {
return sourceCode.getDeclaredVariables(callbackFunction)
.filter(variable => variable.defs[0].type === 'Parameter')
.some(variable =>
variable.references.some(reference => !reference.init && reference.isWrite()));
}
function isFixable(callExpression, {scope, functionInfo, allIdentifiers, sourceCode}) {
// Check `CallExpression`
if (callExpression.optional || callExpression.arguments.length !== 1) {
return false;
}
// Check ancestors, we only fix `ExpressionStatement`
const callOrChainExpression = stripChainExpression(callExpression);
if (
callOrChainExpression.parent.type !== 'ExpressionStatement'
&& !isArrowFunctionBody(callOrChainExpression)
) {
return false;
}
// Check `CallExpression.arguments[0]`;
const [callback] = callExpression.arguments;
if (
// Leave non-function type to `no-array-callback-reference` rule
(callback.type !== 'FunctionExpression' && callback.type !== 'ArrowFunctionExpression')
|| callback.async
|| callback.generator
) {
return false;
}
// Check `callback.params`
const parameters = callback.params;
if (
!(parameters.length === 1 || parameters.length === 2)
// `array.forEach((element = defaultValue) => {})`
|| (parameters.length === 1 && parameters[0].type === 'AssignmentPattern')
// https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1814
|| (parameters.length === 2 && parameters[1].type !== 'Identifier')
|| parameters.some(({type, typeAnnotation}) => type === 'RestElement' || typeAnnotation)
|| !isFunctionParametersSafeToFix(callback, {
scope,
callExpression,
allIdentifiers,
sourceCode,
})
) {
return false;
}
// Check `ReturnStatement`s in `callback`
const {returnStatements, scope: callbackScope} = functionInfo.get(callback);
if (returnStatements.some(returnStatement => isReturnStatementInContinueAbleNodes(returnStatement, callback))) {
return false;
}
if (isFunctionSelfUsedInside(callback, callbackScope)) {
return false;
}
return true;
}
const ignoredObjects = [
'React.Children',
'Children',
'R',
// https://www.npmjs.com/package/p-iteration
'pIteration',
// https://www.npmjs.com/package/effect
'Effect',
];
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const functionStack = [];
const callExpressions = [];
const allIdentifiers = [];
const functionInfo = new Map();
const {sourceCode} = context;
context.on(functionTypes, node => {
functionStack.push(node);
functionInfo.set(node, {
returnStatements: [],
scope: sourceCode.getScope(node),
});
});
context.onExit(functionTypes, () => {
functionStack.pop();
});
context.on('Identifier', node => {
if (isReferenceIdentifier(node)) {
allIdentifiers.push(node);
}
});
context.on('ReturnStatement', node => {
const currentFunction = functionStack.at(-1);
if (!currentFunction) {
return;
}
const {returnStatements} = functionInfo.get(currentFunction);
returnStatements.push(node);
});
context.on('CallExpression', node => {
if (
!isMethodCall(node, {
method: 'forEach',
})
|| isNodeMatches(node.callee.object, ignoredObjects)
) {
return;
}
callExpressions.push({
node,
scope: sourceCode.getScope(node),
});
});
context.onExit('Program', function * () {
for (const {node, scope} of callExpressions) {
const iterable = node.callee;
const problem = {
node: iterable.property,
messageId: MESSAGE_ID_ERROR,
};
if (!isFixable(node, {
scope,
allIdentifiers,
functionInfo,
sourceCode,
})) {
yield problem;
continue;
}
const shouldUseSuggestion = iterable.optional && hasSideEffect(iterable, sourceCode);
const fix = getFixFunction(node, functionInfo, context);
if (shouldUseSuggestion) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
fix,
},
];
} else {
problem.fix = fix;
}
yield problem;
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer `for…of` over the `forEach` method.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,226 @@
import {hasSideEffect} from '@eslint-community/eslint-utils';
import {removeArgument} from './fix/index.js';
import {
getParentheses,
getParenthesizedText,
shouldAddParenthesesToMemberExpressionObject,
isNodeMatches,
isNodeValueNotFunction,
} from './utils/index.js';
import {isMethodCall} from './ast/index.js';
const ERROR_PROTOTYPE_METHOD = 'error-prototype-method';
const ERROR_STATIC_METHOD = 'error-static-method';
const SUGGESTION_BIND = 'suggestion-bind';
const SUGGESTION_REMOVE = 'suggestion-remove';
const messages = {
[ERROR_PROTOTYPE_METHOD]: 'Do not use the `this` argument in `Array#{{method}}()`.',
[ERROR_STATIC_METHOD]: 'Do not use the `this` argument in `Array.{{method}}()`.',
[SUGGESTION_REMOVE]: 'Remove this argument.',
[SUGGESTION_BIND]: 'Use a bound function.',
};
const ignored = [
'lodash.every',
'_.every',
'underscore.every',
'lodash.filter',
'_.filter',
'underscore.filter',
'Vue.filter',
'R.filter',
'lodash.find',
'_.find',
'underscore.find',
'R.find',
'lodash.findLast',
'_.findLast',
'underscore.findLast',
'R.findLast',
'lodash.findIndex',
'_.findIndex',
'underscore.findIndex',
'R.findIndex',
'lodash.findLastIndex',
'_.findLastIndex',
'underscore.findLastIndex',
'R.findLastIndex',
'lodash.flatMap',
'_.flatMap',
'lodash.forEach',
'_.forEach',
'React.Children.forEach',
'Children.forEach',
'R.forEach',
'lodash.map',
'_.map',
'underscore.map',
'React.Children.map',
'Children.map',
'jQuery.map',
'$.map',
'R.map',
'lodash.some',
'_.some',
'underscore.some',
];
function removeThisArgument(thisArgumentNode, context) {
return fixer => removeArgument(fixer, thisArgumentNode, context);
}
function useBoundFunction(callbackNode, thisArgumentNode, context) {
return function * (fixer) {
yield removeThisArgument(thisArgumentNode, context)(fixer);
const callbackParentheses = getParentheses(callbackNode, context);
const isParenthesized = callbackParentheses.length > 0;
const callbackLastToken = isParenthesized
? callbackParentheses.at(-1)
: callbackNode;
if (
!isParenthesized
&& shouldAddParenthesesToMemberExpressionObject(callbackNode, context)
) {
yield fixer.insertTextBefore(callbackLastToken, '(');
yield fixer.insertTextAfter(callbackLastToken, ')');
}
const thisArgumentText = getParenthesizedText(thisArgumentNode, context);
// `thisArgument` was an argument, no need to add extra parentheses
yield fixer.insertTextAfter(callbackLastToken, `.bind(${thisArgumentText})`);
};
}
function getProblem({
context,
callExpression,
callbackNode,
thisArgumentNode,
messageId,
}) {
const problem = {
node: thisArgumentNode,
messageId,
data: {
method: callExpression.callee.property.name,
},
};
const isArrowCallback = callbackNode.type === 'ArrowFunctionExpression';
if (isArrowCallback) {
const thisArgumentHasSideEffect = hasSideEffect(thisArgumentNode, context.sourceCode);
if (thisArgumentHasSideEffect) {
problem.suggest = [
{
messageId: SUGGESTION_REMOVE,
fix: removeThisArgument(thisArgumentNode, context),
},
];
} else {
problem.fix = removeThisArgument(thisArgumentNode, context);
}
return problem;
}
problem.suggest = [
{
messageId: SUGGESTION_REMOVE,
fix: removeThisArgument(thisArgumentNode, context),
},
{
messageId: SUGGESTION_BIND,
fix: useBoundFunction(callbackNode, thisArgumentNode, context),
},
];
return problem;
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
// Prototype methods
context.on('CallExpression', callExpression => {
if (
!isMethodCall(callExpression, {
methods: [
'every',
'filter',
'find',
'findLast',
'findIndex',
'findLastIndex',
'flatMap',
'forEach',
'map',
'some',
],
argumentsLength: 2,
optionalCall: false,
})
|| isNodeMatches(callExpression.callee, ignored)
|| isNodeValueNotFunction(callExpression.arguments[0])
) {
return;
}
return getProblem({
context,
callExpression,
callbackNode: callExpression.arguments[0],
thisArgumentNode: callExpression.arguments[1],
messageId: ERROR_PROTOTYPE_METHOD,
});
});
// `Array.from()` and `Array.fromAsync()`
context.on('CallExpression', callExpression => {
if (
!isMethodCall(callExpression, {
object: 'Array',
methods: ['from', 'fromAsync'],
argumentsLength: 3,
optionalCall: false,
optionalMember: false,
})
|| isNodeValueNotFunction(callExpression.arguments[1])
) {
return;
}
return getProblem({
context,
callExpression,
callbackNode: callExpression.arguments[1],
thisArgumentNode: callExpression.arguments[2],
messageId: ERROR_STATIC_METHOD,
});
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow using the `this` argument in array methods.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
+127
View File
@@ -0,0 +1,127 @@
import {isMethodCall} from './ast/index.js';
import {isNodeValueNotFunction, isArrayPrototypeProperty} from './utils/index.js';
const MESSAGE_ID_REDUCE = 'reduce';
const MESSAGE_ID_REDUCE_RIGHT = 'reduceRight';
const messages = {
[MESSAGE_ID_REDUCE]: '`Array#reduce()` is not allowed. Prefer other types of loop for readability.',
[MESSAGE_ID_REDUCE_RIGHT]: '`Array#reduceRight()` is not allowed. Prefer other types of loop for readability. You may want to call `Array#toReversed()` before looping it.',
};
const cases = [
// `array.{reduce,reduceRight}()`
{
test: callExpression =>
isMethodCall(callExpression, {
methods: ['reduce', 'reduceRight'],
minimumArguments: 1,
maximumArguments: 2,
optionalCall: false,
})
&& !isNodeValueNotFunction(callExpression.arguments[0]),
getMethodNode: callExpression => callExpression.callee.property,
isSimpleOperation(callExpression) {
const [callback] = callExpression.arguments;
return (
callback
&& (
// `array.reduce((accumulator, element) => accumulator + element)`
(callback.type === 'ArrowFunctionExpression' && callback.body.type === 'BinaryExpression')
// `array.reduce((accumulator, element) => {return accumulator + element;})`
// `array.reduce(function (accumulator, element){return accumulator + element;})`
|| (
(callback.type === 'ArrowFunctionExpression' || callback.type === 'FunctionExpression')
&& callback.body.type === 'BlockStatement'
&& callback.body.body.length === 1
&& callback.body.body[0].type === 'ReturnStatement'
&& callback.body.body[0].argument.type === 'BinaryExpression'
)
)
);
},
},
// `[].{reduce,reduceRight}.call()` and `Array.{reduce,reduceRight}.call()`
{
test: callExpression =>
isMethodCall(callExpression, {
method: 'call',
optionalCall: false,
optionalMember: false,
})
&& isArrayPrototypeProperty(callExpression.callee.object, {
properties: ['reduce', 'reduceRight'],
})
&& (
!callExpression.arguments[1]
|| !isNodeValueNotFunction(callExpression.arguments[1])
),
getMethodNode: callExpression => callExpression.callee.object.property,
},
// `[].{reduce,reduceRight}.apply()` and `Array.{reduce,reduceRight}.apply()`
{
test: callExpression =>
isMethodCall(callExpression, {
method: 'apply',
optionalCall: false,
optionalMember: false,
})
&& isArrayPrototypeProperty(callExpression.callee.object, {
properties: ['reduce', 'reduceRight'],
}),
getMethodNode: callExpression => callExpression.callee.object.property,
},
];
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
allowSimpleOperations: {
type: 'boolean',
description: 'Whether to allow simple reduce operations whose callback body is a single binary expression.',
},
},
},
];
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {allowSimpleOperations} = context.options[0];
context.on('CallExpression', function * (callExpression) {
for (const {test, getMethodNode, isSimpleOperation} of cases) {
if (!test(callExpression)) {
continue;
}
if (allowSimpleOperations && isSimpleOperation?.(callExpression)) {
continue;
}
const methodNode = getMethodNode(callExpression);
yield {
node: methodNode,
messageId: methodNode.name,
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow `Array#reduce()` and `Array#reduceRight()`.',
recommended: true,
},
schema,
defaultOptions: [{allowSimpleOperations: true}],
messages,
},
};
export default config;
@@ -0,0 +1,6 @@
import noArrayMutateRule from './shared/no-array-mutate-rule.js';
/** @type {import('eslint').Rule.RuleModule} */
const config = noArrayMutateRule('reverse');
export default config;
+6
View File
@@ -0,0 +1,6 @@
import noArrayMutateRule from './shared/no-array-mutate-rule.js';
/** @type {import('eslint').Rule.RuleModule} */
const config = noArrayMutateRule('sort');
export default config;
@@ -0,0 +1,85 @@
import {removeParentheses, removeMemberExpressionProperty} from './fix/index.js';
import {isLiteral} from './ast/index.js';
const MESSAGE_ID = 'no-await-expression-member';
const messages = {
[MESSAGE_ID]: 'Do not access a member directly from an await expression.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('MemberExpression', memberExpression => {
if (memberExpression.object.type !== 'AwaitExpression') {
return;
}
const {property} = memberExpression;
const problem = {
node: property,
messageId: MESSAGE_ID,
};
// `const foo = (await bar)[0]`
if (
memberExpression.computed
&& !memberExpression.optional
&& (isLiteral(property, 0) || isLiteral(property, 1))
&& memberExpression.parent.type === 'VariableDeclarator'
&& memberExpression.parent.init === memberExpression
&& memberExpression.parent.id.type === 'Identifier'
&& !memberExpression.parent.id.typeAnnotation
) {
problem.fix = function * (fixer) {
const variable = memberExpression.parent.id;
yield fixer.insertTextBefore(variable, property.value === 0 ? '[' : '[, ');
yield fixer.insertTextAfter(variable, ']');
yield removeMemberExpressionProperty(fixer, memberExpression, context);
yield removeParentheses(memberExpression.object, fixer, context);
};
return problem;
}
// `const foo = (await bar).foo`
if (
!memberExpression.computed
&& !memberExpression.optional
&& property.type === 'Identifier'
&& memberExpression.parent.type === 'VariableDeclarator'
&& memberExpression.parent.init === memberExpression
&& memberExpression.parent.id.type === 'Identifier'
&& memberExpression.parent.id.name === property.name
&& !memberExpression.parent.id.typeAnnotation
) {
problem.fix = function * (fixer) {
const variable = memberExpression.parent.id;
yield fixer.insertTextBefore(variable, '{');
yield fixer.insertTextAfter(variable, '}');
yield removeMemberExpressionProperty(fixer, memberExpression, context);
yield removeParentheses(memberExpression.object, fixer, context);
};
return problem;
}
return problem;
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow member access from await expression.',
recommended: true,
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,69 @@
import {isMethodCall} from './ast/index.js';
import {removeSpacesAfter} from './fix/index.js';
const MESSAGE_ID_ERROR = 'no-await-in-promise-methods/error';
const MESSAGE_ID_SUGGESTION = 'no-await-in-promise-methods/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Promise in `Promise.{{method}}()` should not be awaited.',
[MESSAGE_ID_SUGGESTION]: 'Remove `await`.',
};
const METHODS = ['all', 'allSettled', 'any', 'race'];
const isPromiseMethodCallWithArrayExpression = node =>
isMethodCall(node, {
object: 'Promise',
methods: METHODS,
optionalMember: false,
optionalCall: false,
argumentsLength: 1,
})
&& node.arguments[0].type === 'ArrayExpression';
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', function * (callExpression) {
if (!isPromiseMethodCallWithArrayExpression(callExpression)) {
return;
}
for (const element of callExpression.arguments[0].elements) {
if (element?.type !== 'AwaitExpression') {
continue;
}
yield {
node: element,
messageId: MESSAGE_ID_ERROR,
data: {
method: callExpression.callee.property.name,
},
suggest: [
{
messageId: MESSAGE_ID_SUGGESTION,
* fix(fixer) {
const awaitToken = context.sourceCode.getFirstToken(element);
yield fixer.remove(awaitToken);
yield removeSpacesAfter(awaitToken, context, fixer);
},
},
],
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow using `await` in `Promise` method parameters.',
recommended: 'unopinionated',
},
hasSuggestions: true,
messages,
},
};
export default config;
+87
View File
@@ -0,0 +1,87 @@
import toLocation from './utils/to-location.js';
import {isStringLiteral, isMethodCall} from './ast/index.js';
const MESSAGE_ID = 'no-console-spaces';
const messages = {
[MESSAGE_ID]: 'Do not use {{position}} space between `console.{{method}}` parameters.',
};
// Find exactly one leading space, allow exactly one space
const hasLeadingSpace = value => value.length > 1 && value.charAt(0) === ' ' && value.charAt(1) !== ' ';
// Find exactly one trailing space, allow exactly one space
const hasTrailingSpace = value => value.length > 1 && value.at(-1) === ' ' && value.at(-2) !== ' ';
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
const getProblem = (node, method, position) => {
const [start, end] = sourceCode.getRange(node);
const index = position === 'leading'
? start + 1
: end - 2;
const range = [index, index + 1];
return {
loc: toLocation(range, context),
messageId: MESSAGE_ID,
data: {method, position},
fix: fixer => fixer.removeRange(range),
};
};
context.on('CallExpression', function * (node) {
if (
!isMethodCall(node, {
object: 'console',
methods: [
'log',
'debug',
'info',
'warn',
'error',
],
minimumArguments: 1,
optionalCall: false,
optionalMember: false,
})
) {
return;
}
const method = node.callee.property.name;
const {arguments: messages} = node;
const {length} = messages;
for (const [index, node] of messages.entries()) {
if (!isStringLiteral(node) && node.type !== 'TemplateLiteral') {
continue;
}
const raw = sourceCode.getText(node).slice(1, -1);
if (index !== 0 && hasLeadingSpace(raw)) {
yield getProblem(node, method, 'leading');
}
if (index !== length - 1 && hasTrailingSpace(raw)) {
yield getProblem(node, method, 'trailing');
}
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Do not use leading/trailing space between `console.log` parameters.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,29 @@
import {GlobalReferenceTracker} from './utils/global-reference-tracker.js';
const MESSAGE_ID = 'no-document-cookie';
const messages = {
[MESSAGE_ID]: 'Do not use `document.cookie` directly.',
};
const tracker = new GlobalReferenceTracker({
object: 'document.cookie',
filter: ({node}) => node.parent.type === 'AssignmentExpression' && node.parent.left === node,
handle: ({node}) => ({node, messageId: MESSAGE_ID}),
});
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create(context) {
tracker.listen({context});
},
meta: {
type: 'problem',
docs: {
description: 'Do not use `document.cookie` directly.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+56
View File
@@ -0,0 +1,56 @@
import {isEmptyNode, isDirective} from './ast/index.js';
const MESSAGE_ID = 'no-empty-file';
const messages = {
[MESSAGE_ID]: 'Empty files are not allowed.',
};
const isEmpty = node => isEmptyNode(node, isDirective);
const isTripleSlashDirective = node =>
node.type === 'Line' && node.value.startsWith('/');
const hasTripeSlashDirectives = comments =>
comments.some(currentNode => isTripleSlashDirective(currentNode));
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const filename = context.physicalFilename;
if (!/\.(?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$/i.test(filename)) {
return;
}
context.on('Program', node => {
if (node.body.some(node => !isEmpty(node))) {
return;
}
const {sourceCode} = context;
const comments = sourceCode.getAllComments();
if (hasTripeSlashDirectives(comments)) {
return;
}
return {
node,
messageId: MESSAGE_ID,
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow empty files.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+493
View File
@@ -0,0 +1,493 @@
import {isClosingParenToken, getStaticValue} from '@eslint-community/eslint-utils';
import {
getAvailableVariableName,
getScopes,
singular,
toLocation,
getReferences,
} from './utils/index.js';
import {isLiteral} from './ast/index.js';
const MESSAGE_ID = 'no-for-loop';
const messages = {
[MESSAGE_ID]: 'Use a `for-of` loop instead of this `for` loop.',
};
const defaultElementName = 'element';
const isLiteralZero = node => isLiteral(node, 0);
const isLiteralOne = node => isLiteral(node, 1);
const isIdentifierWithName = (node, name) => node?.type === 'Identifier' && node.name === name;
const getTypeReferenceTypeAnnotation = (typeReferenceName, scope) => {
const typeVariable = scope && resolveIdentifierName(typeReferenceName, scope);
const [definition] = typeVariable?.defs ?? [];
if (!definition || definition.type !== 'Type') {
return;
}
if (definition.node.type === 'TSTypeAliasDeclaration') {
return definition.node.typeAnnotation;
}
if (definition.node.type === 'TSTypeParameter') {
return definition.node.constraint;
}
};
const isArrayTypeReference = (node, scope, visitedTypeReferenceNames) => {
if (node.typeName.type !== 'Identifier') {
return false;
}
const typeReferenceName = node.typeName.name;
if (typeReferenceName === 'Array' || typeReferenceName === 'ReadonlyArray') {
return true;
}
if (visitedTypeReferenceNames.has(typeReferenceName)) {
return false;
}
visitedTypeReferenceNames.add(typeReferenceName);
const typeAnnotation = getTypeReferenceTypeAnnotation(typeReferenceName, scope);
const isArray = isArrayType(typeAnnotation, scope, visitedTypeReferenceNames);
visitedTypeReferenceNames.delete(typeReferenceName);
return isArray;
};
const isArrayType = (node, scope, visitedTypeReferenceNames = new Set()) => {
switch (node?.type) {
case 'TSArrayType':
case 'TSTupleType': {
return true;
}
case 'TSTypeReference': {
return isArrayTypeReference(node, scope, visitedTypeReferenceNames);
}
case 'TSTypeOperator': {
return node.operator === 'readonly' && isArrayType(node.typeAnnotation, scope, visitedTypeReferenceNames);
}
case 'TSUnionType': {
return node.types.every(type => isArrayType(type, scope, visitedTypeReferenceNames));
}
case 'TSIntersectionType': {
return node.types.some(type => isArrayType(type, scope, visitedTypeReferenceNames));
}
default: {
return false;
}
}
};
const getIndexIdentifierName = forStatement => {
const {init: variableDeclaration} = forStatement;
if (
!variableDeclaration
|| variableDeclaration.type !== 'VariableDeclaration'
) {
return;
}
if (variableDeclaration.declarations.length !== 1) {
return;
}
const [variableDeclarator] = variableDeclaration.declarations;
if (!isLiteralZero(variableDeclarator.init)) {
return;
}
if (variableDeclarator.id.type !== 'Identifier') {
return;
}
return variableDeclarator.id.name;
};
const getStrictComparisonOperands = binaryExpression => {
if (binaryExpression.operator === '<') {
return {
lesser: binaryExpression.left,
greater: binaryExpression.right,
};
}
if (binaryExpression.operator === '>') {
return {
lesser: binaryExpression.right,
greater: binaryExpression.left,
};
}
};
const getArrayIdentifierFromBinaryExpression = (binaryExpression, indexIdentifierName) => {
const operands = getStrictComparisonOperands(binaryExpression);
if (!operands) {
return;
}
const {lesser, greater} = operands;
if (!isIdentifierWithName(lesser, indexIdentifierName)) {
return;
}
if (greater.type !== 'MemberExpression') {
return;
}
if (
greater.object.type !== 'Identifier'
|| greater.property.type !== 'Identifier'
) {
return;
}
if (greater.property.name !== 'length') {
return;
}
return greater.object;
};
const getArrayIdentifier = (forStatement, indexIdentifierName) => {
const {test} = forStatement;
if (!test || test.type !== 'BinaryExpression') {
return;
}
return getArrayIdentifierFromBinaryExpression(test, indexIdentifierName);
};
const isLiteralOnePlusIdentifierWithName = (node, identifierName) => {
if (node?.type === 'BinaryExpression' && node.operator === '+') {
return (isIdentifierWithName(node.left, identifierName) && isLiteralOne(node.right))
|| (isIdentifierWithName(node.right, identifierName) && isLiteralOne(node.left));
}
return false;
};
const checkUpdateExpression = (forStatement, indexIdentifierName) => {
const {update} = forStatement;
if (!update) {
return false;
}
if (update.type === 'UpdateExpression') {
return update.operator === '++' && isIdentifierWithName(update.argument, indexIdentifierName);
}
if (
update.type === 'AssignmentExpression'
&& isIdentifierWithName(update.left, indexIdentifierName)
) {
if (update.operator === '+=') {
return isLiteralOne(update.right);
}
if (update.operator === '=') {
return isLiteralOnePlusIdentifierWithName(update.right, indexIdentifierName);
}
}
return false;
};
const isOnlyArrayOfIndexVariableRead = (arrayReferences, indexIdentifierName) => arrayReferences.every(reference => {
const node = reference.identifier.parent;
if (node.type !== 'MemberExpression') {
return false;
}
if (node.property.name !== indexIdentifierName) {
return false;
}
if (
node.parent.type === 'AssignmentExpression'
&& node.parent.left === node
) {
return false;
}
return true;
});
const getRemovalRange = (node, sourceCode) => {
const declarationNode = node.parent;
if (declarationNode.declarations.length === 1) {
const {line} = sourceCode.getLoc(declarationNode).start;
const lineText = sourceCode.lines[line - 1];
const isOnlyNodeOnLine = lineText.trim() === sourceCode.getText(declarationNode);
return isOnlyNodeOnLine
? [
sourceCode.getIndexFromLoc({line, column: 0}),
sourceCode.getIndexFromLoc({line: line + 1, column: 0}),
]
: sourceCode.getRange(declarationNode);
}
const index = declarationNode.declarations.indexOf(node);
if (index === 0) {
return [
sourceCode.getRange(node)[0],
sourceCode.getRange(declarationNode.declarations[1])[0],
];
}
return [
sourceCode.getRange(declarationNode.declarations[index - 1])[1],
sourceCode.getRange(node)[1],
];
};
const resolveIdentifierName = (name, scope) => {
while (scope) {
const variable = scope.set.get(name);
if (variable) {
return variable;
}
scope = scope.upper;
}
};
const scopeContains = (ancestor, descendant) => {
while (descendant) {
if (descendant === ancestor) {
return true;
}
descendant = descendant.upper;
}
return false;
};
const nodeContains = (ancestor, descendant) => {
while (descendant) {
if (descendant === ancestor) {
return true;
}
descendant = descendant.parent;
}
return false;
};
const isIndexVariableUsedElsewhereInTheLoopBody = (indexVariable, bodyScope, arrayIdentifierName) => {
const inBodyReferences = indexVariable.references.filter(reference => scopeContains(bodyScope, reference.from));
const referencesOtherThanArrayAccess = inBodyReferences.filter(reference => {
const node = reference.identifier.parent;
if (node.type !== 'MemberExpression') {
return true;
}
if (node.object.name !== arrayIdentifierName) {
return true;
}
return false;
});
return referencesOtherThanArrayAccess.length > 0;
};
const isIndexVariableAssignedToInTheLoopBody = (indexVariable, bodyScope) =>
indexVariable.references
.filter(reference => scopeContains(bodyScope, reference.from))
.some(inBodyReference => inBodyReference.isWrite());
const someVariablesLeakOutOfTheLoop = (forStatement, variables, forScope) =>
variables.some(variable => !variable.references.every(reference => scopeContains(forScope, reference.from) || nodeContains(forStatement, reference.identifier)));
const getReferencesInChildScopes = (scope, name) =>
getReferences(scope).filter(reference => reference.identifier.name === name);
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
const {scopeManager} = sourceCode;
context.on('ForStatement', node => {
const indexIdentifierName = getIndexIdentifierName(node);
if (!indexIdentifierName) {
return;
}
const arrayIdentifier = getArrayIdentifier(node, indexIdentifierName);
if (!arrayIdentifier) {
return;
}
const arrayIdentifierName = arrayIdentifier.name;
const scope = sourceCode.getScope(node);
const staticResult = getStaticValue(arrayIdentifier, scope);
if (staticResult && !Array.isArray(staticResult.value)) {
// Bail out if we can tell that the array variable has a non-array value (i.e. we're looping through the characters of a string constant).
return;
}
if (!checkUpdateExpression(node, indexIdentifierName)) {
return;
}
if (!node.body || node.body.type !== 'BlockStatement') {
return;
}
const forScope = scopeManager.acquire(node);
const bodyScope = scopeManager.acquire(node.body);
if (!bodyScope) {
return;
}
const indexVariable = resolveIdentifierName(indexIdentifierName, bodyScope);
if (isIndexVariableAssignedToInTheLoopBody(indexVariable, bodyScope)) {
return;
}
const arrayReferences = getReferencesInChildScopes(bodyScope, arrayIdentifierName);
if (arrayReferences.length === 0) {
return;
}
if (!isOnlyArrayOfIndexVariableRead(arrayReferences, indexIdentifierName)) {
return;
}
const [start] = sourceCode.getRange(node);
const closingParenthesisToken = sourceCode.getTokenBefore(node.body, isClosingParenToken);
const [, end] = sourceCode.getRange(closingParenthesisToken);
const problem = {
loc: toLocation([start, end], context),
messageId: MESSAGE_ID,
};
const elementReference = arrayReferences.find(reference => {
const node = reference.identifier.parent;
if (node.parent.type !== 'VariableDeclarator') {
return false;
}
return true;
});
const elementNode = elementReference?.identifier.parent.parent;
const elementIdentifierName = elementNode?.id.name;
const elementVariable = elementIdentifierName && resolveIdentifierName(elementIdentifierName, bodyScope);
const shouldGenerateIndex = isIndexVariableUsedElsewhereInTheLoopBody(indexVariable, bodyScope, arrayIdentifierName);
// When `.entries()` would be generated, only autofix if the type annotation confirms it's an array (or there's no type annotation).
const hasNonArrayTypeAnnotation = resolveIdentifierName(arrayIdentifierName, scope)
?.defs.some(definition => {
const typeAnnotation = definition.name.typeAnnotation?.typeAnnotation;
return typeAnnotation && !isArrayType(typeAnnotation, scope);
});
const shouldFix = !someVariablesLeakOutOfTheLoop(node, [indexVariable, elementVariable].filter(Boolean), forScope)
&& !elementNode?.id.typeAnnotation
&& !(hasNonArrayTypeAnnotation && shouldGenerateIndex);
if (shouldFix) {
problem.fix = function * (fixer) {
const index = indexIdentifierName;
const element = elementIdentifierName
|| getAvailableVariableName(singular(arrayIdentifierName) || defaultElementName, getScopes(bodyScope));
const array = arrayIdentifierName;
let declarationElement = element;
let declarationType = 'const';
let removeDeclaration = true;
if (elementNode) {
if (elementNode.id.type === 'ObjectPattern' || elementNode.id.type === 'ArrayPattern') {
removeDeclaration = arrayReferences.length === 1;
}
if (removeDeclaration) {
declarationType = element.type === 'VariableDeclarator' ? elementNode.kind : elementNode.parent.kind;
declarationElement = sourceCode.getText(elementNode.id);
}
}
const parts = [declarationType];
if (shouldGenerateIndex) {
parts.push(` [${index}, ${declarationElement}] of ${array}.entries()`);
} else {
parts.push(` ${declarationElement} of ${array}`);
}
const replacement = parts.join('');
const [start] = sourceCode.getRange(node.init);
const [, end] = sourceCode.getRange(node.update);
yield fixer.replaceTextRange([start, end], replacement);
for (const reference of arrayReferences) {
if (reference !== elementReference) {
yield fixer.replaceText(reference.identifier.parent, element);
}
}
if (elementNode) {
yield removeDeclaration
? fixer.removeRange(getRemovalRange(elementNode, sourceCode))
: fixer.replaceText(elementNode.init, element);
}
};
}
return problem;
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Do not use a `for` loop that can be replaced with a `for-of` loop.',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
+55
View File
@@ -0,0 +1,55 @@
import {replaceTemplateElement} from './fix/index.js';
import {isStringLiteral, isRegexLiteral, isTaggedTemplateLiteral} from './ast/index.js';
const MESSAGE_ID = 'no-hex-escape';
const messages = {
[MESSAGE_ID]: 'Use Unicode escapes instead of hexadecimal escapes.',
};
function checkEscape(context, node, value) {
const fixedValue = value.replaceAll(/(?<=(?:^|[^\\])(?:\\\\)*\\)x/g, 'u00');
if (value !== fixedValue) {
return {
node,
messageId: MESSAGE_ID,
fix: fixer =>
node.type === 'TemplateElement'
? replaceTemplateElement(node, fixedValue, context, fixer)
: fixer.replaceText(node, fixedValue),
};
}
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('Literal', node => {
if (isStringLiteral(node) || isRegexLiteral(node)) {
return checkEscape(context, node, node.raw);
}
});
context.on('TemplateElement', node => {
if (isTaggedTemplateLiteral(node.parent, ['String.raw'])) {
return;
}
return checkEscape(context, node, node.value.raw);
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce the use of Unicode escapes instead of hexadecimal escapes.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,778 @@
import {
hasSideEffect,
isCommaToken,
isSemicolonToken,
findVariable,
} from '@eslint-community/eslint-utils';
import {
isMethodCall,
isMemberExpression,
isNewExpression,
} from './ast/index.js';
import {
removeExpressionStatement,
removeArgument,
} from './fix/index.js';
import {
getNextNode,
getCallExpressionArgumentsText,
getParenthesizedText,
getVariableIdentifiers,
getNewExpressionTokens,
isNewExpressionWithParentheses,
} from './utils/index.js';
/**
@import {TSESTree as ESTree} from '@typescript-eslint/types';
@import * as ESLint from 'eslint';
*/
const MESSAGE_ID_ERROR = 'error';
const MESSAGE_ID_SUGGESTION_ARRAY = 'suggestion/array';
const MESSAGE_ID_SUGGESTION_OBJECT = 'suggestion/object';
const MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN = 'suggestion/object-assign';
const MESSAGE_ID_SUGGESTION_SET = 'suggestion/set';
const MESSAGE_ID_SUGGESTION_MAP = 'suggestion/map';
const messages = {
[MESSAGE_ID_ERROR]: 'Immediate mutation on {{objectType}} is not allowed.',
[MESSAGE_ID_SUGGESTION_ARRAY]: '{{operation}} the elements to the {{assignType}}.',
[MESSAGE_ID_SUGGESTION_OBJECT]: 'Move this property to the {{assignType}}.',
[MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN]: '{{description}} the {{assignType}}.',
[MESSAGE_ID_SUGGESTION_SET]: 'Move the element to the {{assignType}}.',
[MESSAGE_ID_SUGGESTION_MAP]: 'Move the entry to the {{assignType}}.',
};
const hasVariableInNodes = (variable, nodes, context) => {
const {sourceCode} = context;
const identifiers = getVariableIdentifiers(variable);
return nodes.some(node => {
const range = sourceCode.getRange(node);
return identifiers.some(identifier => {
const [start, end] = sourceCode.getRange(identifier);
return start >= range[0] && end <= range[1];
});
});
};
function isCallExpressionWithOptionalArrayExpression(newExpression, names) {
if (!isNewExpression(
newExpression,
{names, maximumArguments: 1},
)) {
return false;
}
// `new Set();` and `new Set([]);`
const [iterable] = newExpression.arguments;
return (!iterable || iterable.type === 'ArrayExpression');
}
function * removeExpressionStatementAfterAssign(expressionStatement, context, fixer) {
const tokenBefore = context.sourceCode.getTokenBefore(expressionStatement);
const shouldPreserveSemiColon = !isSemicolonToken(tokenBefore);
yield removeExpressionStatement(expressionStatement, context, fixer, shouldPreserveSemiColon);
}
function appendListTextToArrayExpressionOrObjectExpression(
context,
fixer,
arrayOrObjectExpression,
listText,
) {
const {sourceCode} = context;
const [
penultimateToken,
closingBracketToken,
] = sourceCode.getLastTokens(arrayOrObjectExpression, 2);
const list = arrayOrObjectExpression.type === 'ArrayExpression'
? arrayOrObjectExpression.elements
: arrayOrObjectExpression.properties;
const shouldInsertComma = list.length > 0 && !isCommaToken(penultimateToken);
return fixer.insertTextBefore(
closingBracketToken,
`${shouldInsertComma ? ',' : ''} ${listText}`,
);
}
function * appendElementsTextToSetConstructor({
context,
fixer,
newExpression,
elementsText,
nextExpressionStatement,
}) {
if (isNewExpressionWithParentheses(newExpression, context)) {
const [setInitialValue] = newExpression.arguments;
if (setInitialValue) {
yield appendListTextToArrayExpressionOrObjectExpression(context, fixer, setInitialValue, elementsText);
} else {
const {
openingParenthesisToken,
} = getNewExpressionTokens(newExpression, context);
yield fixer.insertTextAfter(openingParenthesisToken, `[${elementsText}]`);
}
} else {
/*
The new expression doesn't have parentheses
```
const set = (( new (( Set )) ));
set.add(1);
```
*/
yield fixer.insertTextAfter(newExpression, `([${elementsText}])`);
}
yield * removeExpressionStatementAfterAssign(nextExpressionStatement, context, fixer);
}
function getObjectExpressionPropertiesText(objectExpression, context) {
const {sourceCode} = context;
const openingBraceToken = sourceCode.getFirstToken(objectExpression);
const [penultimateToken, closingBraceToken] = sourceCode.getLastTokens(objectExpression, 2);
const [, start] = sourceCode.getRange(openingBraceToken);
const [end] = sourceCode.getRange(isCommaToken(penultimateToken) ? penultimateToken : closingBraceToken);
return sourceCode.text.slice(start, end);
}
/**
@typedef {ESTree.VariableDeclarator['init'] | ESTree.AssignmentExpression['right']} ValueNode
@typedef {(information: ViolationCaseInformation, arguments: any)} GetFix
@typedef {Parameters<ESLint.Rule.RuleContext['report']>[0]} Problem
@typedef {(information: ViolationCaseInformation) => ESTree.Node} GetProblematicNode
@typedef {{
context: ESLint.Rule.RuleContext,
variable: ESLint.Scope.Variable,
variableNode: ESTree.Identifier,
valueNode: ValueNode,
statement: ESTree.VariableDeclaration | ESTree.ExpressionStatement,
nextExpressionStatement: ESTree.ExpressionStatement,
assignType: 'assignment' | 'declaration',
getFix: GetFix,
}} ViolationCaseInformation
@typedef {{
testValue: (value: ValueNode) => boolean,
getProblematicNode: GetProblematicNode,
getProblem: (node: ReturnType<GetProblematicNode>, information: ViolationCaseInformation) => Problem,
getFix: GetFix,
}} ViolationCase
*/
// `Array`
/** @type {ViolationCase} */
const arrayMutationSettings = {
testValue: value => value?.type === 'ArrayExpression',
getProblematicNode({
context,
variable,
nextExpressionStatement,
}) {
const callExpression = nextExpressionStatement.expression;
if (!(
isMethodCall(callExpression, {
object: variable.name,
methods: ['push', 'unshift'],
optionalMember: false,
optionalCall: false,
})
&& callExpression.arguments.length > 0
)) {
return;
}
if (hasVariableInNodes(variable, callExpression.arguments, context)) {
return;
}
return callExpression;
},
getProblem(callExpression, information) {
const {
context,
assignType,
getFix,
} = information;
const {sourceCode} = context;
const memberExpression = callExpression.callee;
const method = memberExpression.property;
const problem = {
node: memberExpression,
messageId: MESSAGE_ID_ERROR,
data: {objectType: 'array'},
};
const isPrepend = method.name === 'unshift';
const fix = getFix(information, {
callExpression,
isPrepend,
});
if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_ARRAY,
fix,
data: {operation: isPrepend ? 'Prepend' : 'Append', assignType},
},
];
} else {
problem.fix = fix;
}
return problem;
},
getFix: (
{
context,
valueNode: arrayExpression,
nextExpressionStatement,
},
{
callExpression,
isPrepend,
},
) => function * (fixer) {
const text = getCallExpressionArgumentsText(context, callExpression, /* includeTrailingComma */ false);
yield (
isPrepend
? fixer.insertTextAfter(
context.sourceCode.getFirstToken(arrayExpression),
`${text}, `,
)
: appendListTextToArrayExpressionOrObjectExpression(context, fixer, arrayExpression, text)
);
yield removeExpressionStatementAfterAssign(
nextExpressionStatement,
context,
fixer,
);
},
};
// `Object` + `AssignmentExpression`
/** @type {ViolationCase} */
const objectWithAssignmentExpressionSettings = {
testValue: value => value?.type === 'ObjectExpression',
getProblematicNode({
context,
variable,
nextExpressionStatement,
}) {
const assignmentExpression = nextExpressionStatement.expression;
if (!(
assignmentExpression.type === 'AssignmentExpression'
&& assignmentExpression.operator === '='
&& isMemberExpression(assignmentExpression.left, {object: variable.name, optional: false})
)) {
return;
}
const value = assignmentExpression.right;
const memberExpression = assignmentExpression.left;
const {property} = memberExpression;
if (
hasVariableInNodes(
variable,
memberExpression.computed ? [property, value] : [value],
context,
)
) {
return;
}
return assignmentExpression;
},
getProblem(assignmentExpression, information) {
const {
context,
assignType,
getFix,
} = information;
const {sourceCode} = context;
const {
left: memberExpression,
right: value,
} = assignmentExpression;
const {property} = memberExpression;
const operatorToken = sourceCode.getTokenAfter(memberExpression, token => token.type === 'Punctuator' && token.value === assignmentExpression.operator);
const problem = {
node: assignmentExpression,
loc: {
start: sourceCode.getLoc(assignmentExpression).start,
end: sourceCode.getLoc(operatorToken).end,
},
messageId: MESSAGE_ID_ERROR,
data: {objectType: 'object'},
};
const fix = getFix(information, {
assignmentExpression,
memberExpression,
property,
value,
});
if (
(memberExpression.computed && hasSideEffect(property, sourceCode))
|| hasSideEffect(value, sourceCode)
) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_OBJECT,
data: {assignType},
fix,
},
];
} else {
problem.fix = fix;
}
return problem;
},
getFix: (
{
context,
valueNode: objectExpression,
nextExpressionStatement,
},
{
memberExpression,
property,
value,
},
) => function * (fixer) {
let propertyText = getParenthesizedText(property, context);
if (memberExpression.computed) {
propertyText = `[${propertyText}]`;
}
const valueText = getParenthesizedText(value, context);
const text = `${propertyText}: ${valueText},`;
const [
penultimateToken,
closingBraceToken,
] = context.sourceCode.getLastTokens(objectExpression, 2);
const shouldInsertComma = objectExpression.properties.length > 0 && !isCommaToken(penultimateToken);
yield fixer.insertTextBefore(
closingBraceToken,
`${shouldInsertComma ? ',' : ''} ${text}`,
);
yield removeExpressionStatementAfterAssign(
nextExpressionStatement,
context,
fixer,
);
},
};
// `Object` + `Object.assign()`
/** @type {ViolationCase} */
const objectWithObjectAssignSettings = {
testValue: value => value?.type === 'ObjectExpression',
getProblematicNode({
context,
variable,
nextExpressionStatement,
}) {
const callExpression = nextExpressionStatement.expression;
if (!isMethodCall(callExpression, {
object: 'Object',
method: 'assign',
minimumArguments: 2,
optionalMember: false,
optionalCall: false,
})) {
return;
}
const [object, firstValue] = callExpression.arguments;
if (
!(object.type === 'Identifier' && object.name === variable.name)
|| firstValue.type === 'SpreadElement'
|| hasVariableInNodes(variable, [firstValue], context)
) {
return;
}
return callExpression;
},
getProblem(callExpression, information) {
const {
context,
assignType,
getFix,
} = information;
const {sourceCode} = context;
const [, firstValue] = callExpression.arguments;
const problem = {
node: callExpression.callee,
messageId: MESSAGE_ID_ERROR,
data: {objectType: 'object'},
};
const fix = getFix(information, {
callExpression,
firstValue,
});
if (hasSideEffect(firstValue, sourceCode)) {
const description = firstValue.type === 'ObjectExpression'
? 'Move properties to'
: 'Spread properties in';
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN,
data: {description, assignType},
fix,
},
];
} else {
problem.fix = fix;
}
return problem;
},
getFix: (
{
context,
valueNode: objectExpression,
nextExpressionStatement,
},
{
callExpression,
firstValue,
},
) => function * (fixer) {
let text;
if (firstValue.type === 'ObjectExpression') {
if (firstValue.properties.length > 0) {
text = getObjectExpressionPropertiesText(firstValue, context);
}
} else {
text = `...${getParenthesizedText(firstValue, context)}`;
}
if (text) {
yield appendListTextToArrayExpressionOrObjectExpression(context, fixer, objectExpression, text);
}
if (callExpression.arguments.length !== 2) {
yield removeArgument(fixer, firstValue, context);
return;
}
yield removeExpressionStatementAfterAssign(
nextExpressionStatement,
context,
fixer,
);
},
};
// `Set` and `WeakSet`
/** @type {ViolationCase} */
const setMutationSettings = {
testValue: value => isCallExpressionWithOptionalArrayExpression(value, ['Set', 'WeakSet']),
getProblematicNode({
context,
variable,
nextExpressionStatement,
}) {
let callExpression = nextExpressionStatement.expression;
if (callExpression.type === 'ChainExpression') {
callExpression = callExpression.expression;
}
if (!isMethodCall(callExpression, {
object: variable.name,
method: 'add',
argumentsLength: 1,
optionalMember: false,
optionalCall: false,
})) {
return;
}
if (hasVariableInNodes(variable, callExpression.arguments, context)) {
return;
}
return callExpression;
},
getProblem(callExpression, information) {
const {
context,
assignType,
valueNode: newExpression,
getFix,
} = information;
const {sourceCode} = context;
const memberExpression = callExpression.callee;
const problem = {
node: memberExpression,
messageId: MESSAGE_ID_ERROR,
data: {objectType: `\`${newExpression.callee.name}\``},
};
const fix = getFix(information, {
callExpression,
newExpression,
});
if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_SET,
data: {assignType},
fix,
},
];
} else {
problem.fix = fix;
}
return problem;
},
getFix: (
{
context,
nextExpressionStatement,
},
{
callExpression,
newExpression,
},
) => fixer => {
const elementsText = getCallExpressionArgumentsText(
context,
callExpression,
/* IncludeTrailingComma */ false,
);
return appendElementsTextToSetConstructor({
context,
fixer,
newExpression,
elementsText,
nextExpressionStatement,
});
},
};
// `Map` and `WeakMap`
/** @type {ViolationCase} */
const mapMutationSettings = {
testValue: value => isCallExpressionWithOptionalArrayExpression(value, ['Map', 'WeakMap']),
getProblematicNode({
context,
variable,
nextExpressionStatement,
}) {
const callExpression = nextExpressionStatement.expression;
if (!isMethodCall(callExpression, {
object: variable.name,
method: 'set',
argumentsLength: 2,
optionalCall: false,
})) {
return;
}
if (hasVariableInNodes(variable, callExpression.arguments, context)) {
return;
}
return callExpression;
},
getProblem(callExpression, information) {
const {
context,
assignType,
valueNode: newExpression,
getFix,
} = information;
const {sourceCode} = context;
const memberExpression = callExpression.callee;
const problem = {
node: memberExpression,
messageId: MESSAGE_ID_ERROR,
data: {objectType: `\`${newExpression.callee.name}\``},
};
const fix = getFix(information, {
callExpression,
newExpression,
});
if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_MAP,
data: {assignType},
fix,
},
];
} else {
problem.fix = fix;
}
return problem;
},
getFix: (
{
context,
nextExpressionStatement,
},
{
callExpression,
newExpression,
},
) => fixer => {
const argumentsText = getCallExpressionArgumentsText(
context,
callExpression,
/* IncludeTrailingComma */ false,
);
const entryText = `[${argumentsText}]`;
return appendElementsTextToSetConstructor({
context,
fixer,
newExpression,
elementsText: entryText,
nextExpressionStatement,
});
},
};
const cases = [
arrayMutationSettings,
objectWithAssignmentExpressionSettings,
objectWithObjectAssignSettings,
setMutationSettings,
mapMutationSettings,
];
function isLastDeclarator(variableDeclarator) {
const variableDeclaration = variableDeclarator.parent;
return (
variableDeclaration.type === 'VariableDeclaration'
&& variableDeclaration.declarations.at(-1) === variableDeclarator
);
}
const getVariable = (node, context) => {
if (node.type === 'VariableDeclarator') {
return context.sourceCode.getDeclaredVariables(node)
.find(variable => variable.defs.length === 1 && variable.defs[0].name === node.id);
}
return findVariable(context.sourceCode.getScope(node), node.left.name);
};
function getCaseProblem(
context,
assignNode,
{
testValue,
getProblematicNode,
getProblem,
getFix,
},
) {
const isAssignment = assignNode.type === 'AssignmentExpression';
const [variableNode, valueNode] = (isAssignment ? ['left', 'right'] : ['id', 'init'])
.map(property => assignNode[property]);
// eslint-disable-next-line no-warning-comments
// TODO[@fisker]: `AssignmentExpression` should not limit to `Identifier`
if (!(variableNode.type === 'Identifier' && testValue(valueNode))) {
return;
}
const statement = assignNode.parent;
if (!(
// eslint-disable-next-line no-warning-comments
// TODO[@fisker]: `AssignmentExpression` should support `a = b = c` too
(
isAssignment
&& assignNode.operator === '='
&& statement.type === 'ExpressionStatement'
&& statement.expression === assignNode)
|| (!isAssignment && isLastDeclarator(assignNode))
)) {
return;
}
const nextExpressionStatement = getNextNode(statement, context);
if (nextExpressionStatement?.type !== 'ExpressionStatement') {
return;
}
const variable = getVariable(assignNode, context);
/* c8 ignore next */
if (!variable) {
return;
}
const information = {
context,
variable,
variableNode,
valueNode,
statement,
nextExpressionStatement,
assignType: isAssignment ? 'assignment' : 'declaration',
getFix,
};
const problematicNode = getProblematicNode(information);
if (!problematicNode) {
return;
}
return getProblem(problematicNode, information);
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
for (const caseSettings of cases) {
context.on(
[
'VariableDeclarator',
'AssignmentExpression',
],
assignNode => getCaseProblem(context, assignNode, caseSettings),
);
}
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow immediate mutation after variable assignment.',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,211 @@
import {
checkVueTemplate,
getParenthesizedRange,
getTokenStore,
} from './utils/index.js';
import {replaceNodeOrTokenAndSpacesBefore, fixSpaceAroundKeyword} from './fix/index.js';
import builtinErrors from './shared/builtin-errors.js';
import typedArray from './shared/typed-array.js';
const isInstanceofToken = token => token.value === 'instanceof' && token.type === 'Keyword';
const MESSAGE_ID = 'no-instanceof-builtins';
const MESSAGE_ID_SWITCH_TO_TYPE_OF = 'switch-to-type-of';
const messages = {
[MESSAGE_ID]: 'Avoid using `instanceof` for type checking as it can lead to unreliable results.',
[MESSAGE_ID_SWITCH_TO_TYPE_OF]: 'Switch to `typeof … === \'{{type}}\'`.',
};
const primitiveWrappers = new Set([
'String',
'Number',
'Boolean',
'BigInt',
'Symbol',
]);
const strictStrategyConstructors = [
// Error types
...builtinErrors,
// Collection types
'Map',
'Set',
'WeakMap',
'WeakRef',
'WeakSet',
// Arrays and Typed Arrays
'ArrayBuffer',
...typedArray,
// Data types
'Object',
// Regular Expressions
'RegExp',
// Async and functions
'Promise',
'Proxy',
// Other
'DataView',
'Date',
'SharedArrayBuffer',
'FinalizationRegistry',
];
const replaceWithFunctionCall = (node, context, functionName) => function * (fixer) {
const {left, right} = node;
const tokenStore = getTokenStore(context, node);
const instanceofToken = tokenStore.getTokenAfter(left, isInstanceofToken);
yield fixSpaceAroundKeyword(fixer, node, context);
const range = getParenthesizedRange(left, {sourceCode: tokenStore});
yield fixer.insertTextBeforeRange(range, functionName + '(');
yield fixer.insertTextAfterRange(range, ')');
yield replaceNodeOrTokenAndSpacesBefore(instanceofToken, '', fixer, context, tokenStore);
yield replaceNodeOrTokenAndSpacesBefore(right, '', fixer, context, tokenStore);
};
const replaceWithTypeOfExpression = (node, context) => function * (fixer) {
const {left, right} = node;
const tokenStore = getTokenStore(context, node);
const instanceofToken = tokenStore.getTokenAfter(left, isInstanceofToken);
const {sourceCode} = context;
// Check if the node is in a Vue template expression
const vueExpressionContainer = sourceCode.getAncestors(node).findLast(ancestor => ancestor.type === 'VExpressionContainer');
// Get safe quote
const safeQuote = vueExpressionContainer ? (sourceCode.getText(vueExpressionContainer)[0] === '"' ? '\'' : '"') : '\'';
yield fixSpaceAroundKeyword(fixer, node, context);
const leftRange = getParenthesizedRange(left, {sourceCode: tokenStore});
yield fixer.insertTextBeforeRange(leftRange, 'typeof ');
yield fixer.replaceText(instanceofToken, '===');
const rightRange = getParenthesizedRange(right, {sourceCode: tokenStore});
yield fixer.replaceTextRange(rightRange, safeQuote + sourceCode.getText(right).toLowerCase() + safeQuote);
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {
useErrorIsError = false,
strategy = 'loose',
include = [],
exclude = [],
} = context.options[0] ?? {};
const forbiddenConstructors = new Set(strategy === 'strict'
? [...strictStrategyConstructors, ...include]
: include);
context.on('BinaryExpression', /** @param {import('estree').BinaryExpression} node */ node => {
const {right, operator} = node;
if ((operator !== 'instanceof') || (right.type !== 'Identifier') || exclude.includes(right.name)) {
return;
}
const constructorName = right.name;
/** @type {import('eslint').Rule.ReportDescriptor} */
const problem = {
node,
messageId: MESSAGE_ID,
};
if (
constructorName === 'Array'
|| (constructorName === 'Error' && useErrorIsError)
) {
const functionName = constructorName === 'Array' ? 'Array.isArray' : 'Error.isError';
problem.fix = replaceWithFunctionCall(node, context, functionName);
return problem;
}
if (constructorName === 'Function') {
problem.fix = replaceWithTypeOfExpression(node, context);
return problem;
}
if (primitiveWrappers.has(constructorName)) {
problem.suggest = [
{
messageId: MESSAGE_ID_SWITCH_TO_TYPE_OF,
data: {type: constructorName.toLowerCase()},
fix: replaceWithTypeOfExpression(node, context),
},
];
return problem;
}
if (!forbiddenConstructors.has(constructorName)) {
return;
}
return problem;
});
};
const schema = [
{
type: 'object',
properties: {
useErrorIsError: {
type: 'boolean',
},
strategy: {
enum: [
'loose',
'strict',
],
},
include: {
type: 'array',
items: {
type: 'string',
},
},
exclude: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create: checkVueTemplate(create),
meta: {
type: 'problem',
docs: {
description: 'Disallow `instanceof` with built-in objects',
recommended: 'unopinionated',
},
fixable: 'code',
schema,
defaultOptions: [{
useErrorIsError: false,
strategy: 'loose',
include: [],
exclude: [],
}],
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,112 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {
isCallExpression,
isNewExpression,
isUndefined,
isNullLiteral,
} from './ast/index.js';
const MESSAGE_ID_ERROR = 'no-invalid-fetch-options';
const messages = {
[MESSAGE_ID_ERROR]: '"body" is not allowed when method is "{{method}}".',
};
const isObjectPropertyWithName = (node, name) =>
node.type === 'Property'
&& !node.computed
&& node.key.type === 'Identifier'
&& node.key.name === name;
function checkFetchOptions(context, node) {
if (node.type !== 'ObjectExpression') {
return;
}
const {properties} = node;
const bodyProperty = properties.findLast(property => isObjectPropertyWithName(property, 'body'));
if (!bodyProperty) {
return;
}
const bodyValue = bodyProperty.value;
if (isUndefined(bodyValue) || isNullLiteral(bodyValue)) {
return;
}
const methodProperty = properties.findLast(property => isObjectPropertyWithName(property, 'method'));
// If `method` is omitted but there is a `SpreadElement`, we just ignore the case
if (!methodProperty) {
if (properties.some(node => node.type === 'SpreadElement')) {
return;
}
return {
node: bodyProperty.key,
messageId: MESSAGE_ID_ERROR,
data: {method: 'GET'},
};
}
const methodValue = methodProperty.value;
const scope = context.sourceCode.getScope(methodValue);
let method = getStaticValue(methodValue, scope)?.value;
if (typeof method !== 'string') {
return;
}
method = method.toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
return;
}
return {
node: bodyProperty.key,
messageId: MESSAGE_ID_ERROR,
data: {method},
};
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!isCallExpression(callExpression, {
name: 'fetch',
minimumArguments: 2,
optional: false,
})) {
return;
}
return checkFetchOptions(context, callExpression.arguments[1]);
});
context.on('NewExpression', newExpression => {
if (!isNewExpression(newExpression, {
name: 'Request',
minimumArguments: 2,
})) {
return;
}
return checkFetchOptions(context, newExpression.arguments[1]);
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow invalid options in `fetch()` and `new Request()`.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
@@ -0,0 +1,61 @@
import {getFunctionHeadLocation} from '@eslint-community/eslint-utils';
import {isMethodCall} from './ast/index.js';
const MESSAGE_ID = 'no-invalid-remove-event-listener';
const messages = {
[MESSAGE_ID]: 'The listener argument should be a function reference.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!(
isMethodCall(callExpression, {
method: 'removeEventListener',
minimumArguments: 2,
optionalCall: false,
})
&& callExpression.arguments[0].type !== 'SpreadElement'
&& (
callExpression.arguments[1].type === 'FunctionExpression'
|| callExpression.arguments[1].type === 'ArrowFunctionExpression'
|| isMethodCall(callExpression.arguments[1], {
method: 'bind',
optionalCall: false,
optionalMember: false,
})
)
)) {
return;
}
const [, listener] = callExpression.arguments;
if (['ArrowFunctionExpression', 'FunctionExpression'].includes(listener.type)) {
return {
node: listener,
loc: getFunctionHeadLocation(listener, context.sourceCode),
messageId: MESSAGE_ID,
};
}
return {
node: listener.callee.property,
messageId: MESSAGE_ID,
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Prevent calling `EventTarget#removeEventListener()` with the result of an expression.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+203
View File
@@ -0,0 +1,203 @@
import isShorthandPropertyAssignmentPatternLeft from './utils/is-shorthand-property-assignment-pattern-left.js';
const MESSAGE_ID = 'noKeywordPrefix';
const messages = {
[MESSAGE_ID]: 'Do not prefix identifiers with keyword `{{keyword}}`.',
};
const prepareOptions = ({
disallowedPrefixes,
checkProperties = true,
onlyCamelCase = true,
} = {}) => ({
disallowedPrefixes: (disallowedPrefixes || [
'new',
'class',
]),
checkProperties,
onlyCamelCase,
});
function findKeywordPrefix(name, options) {
return options.disallowedPrefixes.find(keyword => {
const suffix = options.onlyCamelCase ? '[A-Z]' : '.';
const regex = new RegExp(`^${keyword}${suffix}`);
return name.match(regex);
});
}
function checkMemberExpression(report, node, options) {
const {name, parent} = node;
const keyword = findKeywordPrefix(name, options);
const effectiveParent = parent.type === 'MemberExpression' ? parent.parent : parent;
if (!options.checkProperties) {
return;
}
if (parent.object.type === 'Identifier' && parent.object.name === name && Boolean(keyword)) {
report(node, keyword);
} else if (
effectiveParent.type === 'AssignmentExpression'
&& Boolean(keyword)
&& (effectiveParent.right.type !== 'MemberExpression' || effectiveParent.left.type === 'MemberExpression')
&& effectiveParent.left.property.name === name
) {
report(node, keyword);
}
}
function checkObjectPattern(report, node, options) {
const {name, parent} = node;
const keyword = findKeywordPrefix(name, options);
/* c8 ignore next 3 */
if (parent.shorthand && parent.value.left && Boolean(keyword)) {
report(node, keyword);
}
const assignmentKeyEqualsValue = parent.key.name === parent.value.name;
if (Boolean(keyword) && parent.computed) {
report(node, keyword);
}
// Prevent checking right hand side of destructured object
if (parent.key === node && parent.value !== node) {
return true;
}
const valueIsInvalid = parent.value.name && Boolean(keyword);
// Ignore destructuring if the option is set, unless a new identifier is created
if (valueIsInvalid && !assignmentKeyEqualsValue) {
report(node, keyword);
}
return false;
}
// Core logic copied from:
// https://github.com/eslint/eslint/blob/master/lib/rules/camelcase.js
const create = context => {
const options = prepareOptions(context.options[0]);
// Contains reported nodes to avoid reporting twice on destructuring with shorthand notation
const reported = [];
const ALLOWED_PARENT_TYPES = new Set(['CallExpression', 'NewExpression']);
function report(node, keyword) {
if (!reported.includes(node)) {
reported.push(node);
context.report({
node,
messageId: MESSAGE_ID,
data: {
name: node.name,
keyword,
},
});
}
}
context.on('Identifier', node => {
const {name, parent} = node;
const keyword = findKeywordPrefix(name, options);
const effectiveParent = parent.type === 'MemberExpression' ? parent.parent : parent;
if (parent.type === 'MemberExpression') {
checkMemberExpression(report, node, options);
} else if (
parent.type === 'Property'
|| parent.type === 'AssignmentPattern'
) {
if (parent.parent.type === 'ObjectPattern') {
const finished = checkObjectPattern(report, node, options);
if (finished) {
return;
}
}
if (
!options.checkProperties
) {
return;
}
// Don't check right hand side of AssignmentExpression to prevent duplicate warnings
if (
Boolean(keyword)
&& !ALLOWED_PARENT_TYPES.has(effectiveParent.type)
&& !(parent.right === node)
&& !isShorthandPropertyAssignmentPatternLeft(node)
) {
report(node, keyword);
}
// Check if it's an import specifier
} else if (
[
'ImportSpecifier',
'ImportNamespaceSpecifier',
'ImportDefaultSpecifier',
].includes(parent.type)
) {
// Report only if the local imported identifier is invalid
if (Boolean(keyword) && parent.local?.name === name) {
report(node, keyword);
}
// Report anything that is invalid that isn't a CallExpression
} else if (
Boolean(keyword)
&& !ALLOWED_PARENT_TYPES.has(effectiveParent.type)
) {
report(node, keyword);
}
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
disallowedPrefixes: {
type: 'array',
items: [
{
type: 'string',
},
],
minItems: 0,
uniqueItems: true,
description: 'The prefixes to disallow.',
},
checkProperties: {
type: 'boolean',
description: 'Whether to check property names.',
},
onlyCamelCase: {
type: 'boolean',
description: 'Whether to only check camelCase names.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow identifiers starting with `new` or `class`.',
recommended: false,
},
schema,
defaultOptions: [{}],
messages,
},
};
export default config;
+155
View File
@@ -0,0 +1,155 @@
import {isNotSemicolonToken} from '@eslint-community/eslint-utils';
import {isParenthesized, needsSemicolon} from './utils/index.js';
import {removeSpacesAfter} from './fix/index.js';
const MESSAGE_ID = 'no-lonely-if';
const messages = {
[MESSAGE_ID]: 'Unexpected `if` as the only statement in a `if` block without `else`.',
};
const isIfStatementWithoutAlternate = node => node.type === 'IfStatement' && !node.alternate;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table
// Lower precedence than `&&`
const needParenthesis = node => (
(node.type === 'LogicalExpression' && (node.operator === '||' || node.operator === '??'))
|| node.type === 'ConditionalExpression'
|| node.type === 'AssignmentExpression'
|| node.type === 'YieldExpression'
|| node.type === 'SequenceExpression'
);
function getIfStatementTokens(node, sourceCode) {
const tokens = {
ifToken: sourceCode.getFirstToken(node),
openingParenthesisToken: sourceCode.getFirstToken(node, 1),
};
const {consequent} = node;
tokens.closingParenthesisToken = sourceCode.getTokenBefore(consequent);
if (consequent.type === 'BlockStatement') {
tokens.openingBraceToken = sourceCode.getFirstToken(consequent);
tokens.closingBraceToken = sourceCode.getLastToken(consequent);
}
return tokens;
}
function fix(innerIfStatement, context) {
const {sourceCode} = context;
return function * (fixer) {
const outerIfStatement = (
innerIfStatement.parent.type === 'BlockStatement'
? innerIfStatement.parent
: innerIfStatement
).parent;
const outer = {
...outerIfStatement,
...getIfStatementTokens(outerIfStatement, sourceCode),
};
const inner = {
...innerIfStatement,
...getIfStatementTokens(innerIfStatement, sourceCode),
};
// Remove inner `if` token
yield fixer.remove(inner.ifToken);
yield removeSpacesAfter(inner.ifToken, context, fixer);
// Remove outer `{}`
if (outer.openingBraceToken) {
yield fixer.remove(outer.openingBraceToken);
yield removeSpacesAfter(outer.openingBraceToken, context, fixer);
yield fixer.remove(outer.closingBraceToken);
const tokenBefore = sourceCode.getTokenBefore(outer.closingBraceToken, {includeComments: true});
yield removeSpacesAfter(tokenBefore, context, fixer);
}
// Add new `()`
yield fixer.insertTextBefore(outer.openingParenthesisToken, '(');
yield fixer.insertTextAfter(
inner.closingParenthesisToken,
`)${inner.consequent.type === 'EmptyStatement' ? '' : ' '}`,
);
// Add ` && `
yield fixer.insertTextAfter(outer.closingParenthesisToken, ' && ');
// Remove `()` if `test` doesn't need it
for (const {test, openingParenthesisToken, closingParenthesisToken} of [outer, inner]) {
if (
isParenthesized(test, context)
|| !needParenthesis(test)
) {
yield fixer.remove(openingParenthesisToken);
yield fixer.remove(closingParenthesisToken);
}
yield removeSpacesAfter(closingParenthesisToken, context, fixer);
}
// If the `if` statement has no block, and is not followed by a semicolon,
// make sure that fixing the issue would not change semantics due to ASI.
// Similar logic https://github.com/eslint/eslint/blob/2124e1b5dad30a905dc26bde9da472bf622d3f50/lib/rules/no-lonely-if.js#L61-L77
if (inner.consequent.type !== 'BlockStatement') {
const lastToken = sourceCode.getLastToken(inner.consequent);
if (isNotSemicolonToken(lastToken)) {
const nextToken = sourceCode.getTokenAfter(outer);
if (nextToken && needsSemicolon(lastToken, context, nextToken.value)) {
yield fixer.insertTextBefore(nextToken, ';');
}
}
}
};
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('IfStatement', ifStatement => {
if (!(
isIfStatementWithoutAlternate(ifStatement)
&& (
// `if (a) { if (b) {} }`
(
ifStatement.parent.type === 'BlockStatement'
&& ifStatement.parent.body.length === 1
&& ifStatement.parent.body[0] === ifStatement
&& isIfStatementWithoutAlternate(ifStatement.parent.parent)
&& ifStatement.parent.parent.consequent === ifStatement.parent
)
// `if (a) if (b) {}`
|| (
isIfStatementWithoutAlternate(ifStatement.parent)
&& ifStatement.parent.consequent === ifStatement
)
)
)) {
return;
}
return {
node: ifStatement,
messageId: MESSAGE_ID,
fix: fix(ifStatement, context),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow `if` statements as the only statement in `if` blocks without `else`.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,55 @@
import {isMethodCall, isNumericLiteral} from './ast/index.js';
import {getCallExpressionTokens} from './utils/index.js';
const MESSAGE_ID = 'no-magic-array-flat-depth';
const messages = {
[MESSAGE_ID]: 'Magic number as depth is not allowed.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!isMethodCall(callExpression, {
method: 'flat',
argumentsLength: 1,
optionalCall: false,
})) {
return;
}
const [depth] = callExpression.arguments;
if (!isNumericLiteral(depth) || depth.value === 1) {
return;
}
const {sourceCode} = context;
const {
openingParenthesisToken,
closingParenthesisToken,
} = getCallExpressionTokens(callExpression, context);
if (sourceCode.commentsExistBetween(openingParenthesisToken, closingParenthesisToken)) {
return;
}
return {
node: depth,
messageId: MESSAGE_ID,
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow a magic number as the `depth` argument in `Array#flat(…).`',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+102
View File
@@ -0,0 +1,102 @@
import {removeSpecifier} from './fix/index.js';
import assertToken from './utils/assert-token.js';
const MESSAGE_ID = 'no-named-default';
const messages = {
[MESSAGE_ID]: 'Prefer using the default {{type}} over named {{type}}.',
};
const isValueImport = node => !node.importKind || node.importKind === 'value';
const isValueExport = node => !node.exportKind || node.exportKind === 'value';
const fixImportSpecifier = (importSpecifier, context) => function * (fixer) {
const {sourceCode} = context;
const declaration = importSpecifier.parent;
yield removeSpecifier(importSpecifier, fixer, context, /* keepDeclaration */ true);
const nameText = sourceCode.getText(importSpecifier.local);
const hasDefaultImport = declaration.specifiers.some(({type}) => type === 'ImportDefaultSpecifier');
// Insert a new `ImportDeclaration`
if (hasDefaultImport) {
const fromToken = sourceCode.getTokenBefore(declaration.source, token => token.type === 'Identifier' && token.value === 'from');
const [startOfFromToken] = sourceCode.getRange(fromToken);
const [, endOfDeclaration] = sourceCode.getRange(declaration);
const text = `import ${nameText} ${sourceCode.text.slice(startOfFromToken, endOfDeclaration)}`;
yield fixer.insertTextBefore(declaration, `${text}\n`);
return;
}
const importToken = sourceCode.getFirstToken(declaration);
assertToken(importToken, {
expected: {type: 'Keyword', value: 'import'},
ruleId: 'no-named-default',
});
const shouldAddComma = declaration.specifiers.some(specifier => specifier !== importSpecifier && specifier.type === importSpecifier.type);
yield fixer.insertTextAfter(importToken, ` ${nameText}${shouldAddComma ? ',' : ''}`);
};
const fixExportSpecifier = (exportSpecifier, context) => function * (fixer) {
const declaration = exportSpecifier.parent;
yield removeSpecifier(exportSpecifier, fixer, context);
const text = `export default ${context.sourceCode.getText(exportSpecifier.local)};`;
yield fixer.insertTextBefore(declaration, `${text}\n`);
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ImportSpecifier', specifier => {
if (!(
isValueImport(specifier)
&& specifier.imported.name === 'default'
&& isValueImport(specifier.parent)
)) {
return;
}
return {
node: specifier,
messageId: MESSAGE_ID,
data: {type: 'import'},
fix: fixImportSpecifier(specifier, context),
};
});
context.on('ExportSpecifier', specifier => {
if (!(
isValueExport(specifier)
&& specifier.exported.name === 'default'
&& isValueExport(specifier.parent)
&& !specifier.parent.source
)) {
return;
}
return {
node: specifier,
messageId: MESSAGE_ID,
data: {type: 'export'},
fix: fixExportSpecifier(specifier, context),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow named usage of default import and export.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,148 @@
/*
Based on ESLint builtin `no-negated-condition` rule
https://github.com/eslint/eslint/blob/5c39425fc55ecc0b97bbd07ac22654c0eb4f789c/lib/rules/no-negated-condition.js
*/
import {
removeParentheses,
fixSpaceAroundKeyword,
addParenthesizesToReturnOrThrowExpression,
} from './fix/index.js';
import {
getParenthesizedRange,
isParenthesized,
isOnSameLine,
needsSemicolon,
} from './utils/index.js';
const MESSAGE_ID = 'no-negated-condition';
const messages = {
[MESSAGE_ID]: 'Unexpected negated condition.',
};
function * convertNegatedCondition(fixer, node, context) {
const {sourceCode} = context;
const {test} = node;
if (test.type === 'UnaryExpression') {
const token = sourceCode.getFirstToken(test);
if (node.type === 'IfStatement') {
yield removeParentheses(test.argument, fixer, context);
}
yield fixer.remove(token);
return;
}
const token = sourceCode.getTokenAfter(
test.left,
token => token.type === 'Punctuator' && token.value === test.operator,
);
yield fixer.replaceText(token, '=' + token.value.slice(1));
}
function * swapConsequentAndAlternate(fixer, node, context) {
const isIfStatement = node.type === 'IfStatement';
const [consequent, alternate] = [
node.consequent,
node.alternate,
].map(node => {
const range = getParenthesizedRange(node, context);
let text = context.sourceCode.text.slice(...range);
// `if (!a) b(); else c()` can't fix to `if (!a) c() else b();`
if (isIfStatement && node.type !== 'BlockStatement') {
text = `{${text}}`;
}
return {
range,
text,
};
});
if (consequent.text === alternate.text) {
return;
}
const {sourceCode} = context;
yield fixer.replaceTextRange(sourceCode.getRange(consequent), alternate.text);
yield fixer.replaceTextRange(sourceCode.getRange(alternate), consequent.text);
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on(['IfStatement', 'ConditionalExpression'], node => {
if (
node.type === 'IfStatement'
&& (
!node.alternate
|| node.alternate.type === 'IfStatement'
)
) {
return;
}
const {test} = node;
if (!(
(test.type === 'UnaryExpression' && test.operator === '!')
|| (test.type === 'BinaryExpression' && (test.operator === '!=' || test.operator === '!=='))
)) {
return;
}
return {
node: test,
messageId: MESSAGE_ID,
/** @param {import('eslint').Rule.RuleFixer} fixer */
* fix(fixer) {
yield convertNegatedCondition(fixer, node, context);
yield swapConsequentAndAlternate(fixer, node, context);
if (
node.type !== 'ConditionalExpression'
|| test.type !== 'UnaryExpression'
) {
return;
}
yield fixSpaceAroundKeyword(fixer, node, context);
const {sourceCode} = context;
const {parent} = node;
const [firstToken, secondToken] = sourceCode.getFirstTokens(test, 2);
if (
(parent.type === 'ReturnStatement' || parent.type === 'ThrowStatement')
&& parent.argument === node
&& !isOnSameLine(firstToken, secondToken, context)
&& !isParenthesized(node, context)
&& !isParenthesized(test, context)
) {
yield addParenthesizesToReturnOrThrowExpression(fixer, parent, context);
return;
}
const tokenBefore = sourceCode.getTokenBefore(node);
if (needsSemicolon(tokenBefore, context, secondToken.value)) {
yield fixer.insertTextBefore(node, ';');
}
},
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow negated conditions.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,99 @@
import {fixSpaceAroundKeyword, addParenthesizesToReturnOrThrowExpression} from './fix/index.js';
import {needsSemicolon, isParenthesized, isOnSameLine} from './utils/index.js';
const MESSAGE_ID_ERROR = 'no-negation-in-equality-check/error';
const MESSAGE_ID_SUGGESTION = 'no-negation-in-equality-check/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Negated expression is not allowed in equality check.',
[MESSAGE_ID_SUGGESTION]: 'Switch to \'{{operator}}\' check.',
};
const EQUALITY_OPERATORS = new Set([
'===',
'!==',
'==',
'!=',
]);
const isEqualityCheck = node => node.type === 'BinaryExpression' && EQUALITY_OPERATORS.has(node.operator);
const isNegatedExpression = node => node.type === 'UnaryExpression' && node.prefix && node.operator === '!';
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('BinaryExpression', binaryExpression => {
const {operator, left} = binaryExpression;
if (!(
isEqualityCheck(binaryExpression)
&& isNegatedExpression(left)
&& !isNegatedExpression(left.argument)
)) {
return;
}
const {sourceCode} = context;
const bangToken = sourceCode.getFirstToken(left);
const negatedOperator = `${operator.startsWith('!') ? '=' : '!'}${operator.slice(1)}`;
return {
node: bangToken,
messageId: MESSAGE_ID_ERROR,
/** @param {import('eslint').Rule.RuleFixer} fixer */
suggest: [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {
operator: negatedOperator,
},
/** @param {import('eslint').Rule.RuleFixer} fixer */
* fix(fixer) {
yield fixSpaceAroundKeyword(fixer, binaryExpression, context);
const tokenAfterBang = sourceCode.getTokenAfter(bangToken);
const {parent} = binaryExpression;
if (
(parent.type === 'ReturnStatement' || parent.type === 'ThrowStatement')
&& !isParenthesized(binaryExpression, context)
) {
const returnToken = sourceCode.getFirstToken(parent);
if (!isOnSameLine(returnToken, tokenAfterBang, context)) {
yield addParenthesizesToReturnOrThrowExpression(fixer, parent, context);
}
}
yield fixer.remove(bangToken);
const previousToken = sourceCode.getTokenBefore(bangToken);
if (needsSemicolon(previousToken, context, tokenAfterBang.value)) {
yield fixer.insertTextAfter(bangToken, ';');
}
const operatorToken = sourceCode.getTokenAfter(
left,
token => token.type === 'Punctuator' && token.value === operator,
);
yield fixer.replaceText(operatorToken, negatedOperator);
},
},
],
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow negated expression in equality check.',
recommended: 'unopinionated',
},
hasSuggestions: true,
messages,
},
};
export default config;
+60
View File
@@ -0,0 +1,60 @@
import {isParenthesized} from './utils/index.js';
const MESSAGE_ID_TOO_DEEP = 'too-deep';
const MESSAGE_ID_SHOULD_PARENTHESIZED = 'should-parenthesized';
const messages = {
[MESSAGE_ID_TOO_DEEP]: 'Do not nest ternary expressions.',
[MESSAGE_ID_SHOULD_PARENTHESIZED]: 'Nested ternary expression should be parenthesized.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ConditionalExpression', node => {
if ([
node.test,
node.consequent,
node.alternate,
].some(node => node.type === 'ConditionalExpression')) {
return;
}
const {sourceCode} = context;
const ancestors = sourceCode.getAncestors(node).toReversed();
const nestLevel = ancestors.findIndex(node => node.type !== 'ConditionalExpression');
if (nestLevel === 1 && !isParenthesized(node, context)) {
return {
node,
messageId: MESSAGE_ID_SHOULD_PARENTHESIZED,
fix: fixer => [
fixer.insertTextBefore(node, '('),
fixer.insertTextAfter(node, ')'),
],
};
}
// Nesting more than one level not allowed
if (nestLevel > 1) {
return {
node: nestLevel > 2 ? ancestors[nestLevel - 3] : node,
messageId: MESSAGE_ID_TOO_DEEP,
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow nested ternary expressions.',
recommended: true,
},
fixable: 'code',
messages,
},
};
export default config;
+107
View File
@@ -0,0 +1,107 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {
isParenthesized,
needsSemicolon,
isNumber,
} from './utils/index.js';
import {isNewExpression} from './ast/index.js';
const MESSAGE_ID_ERROR = 'error';
const MESSAGE_ID_LENGTH = 'array-length';
const MESSAGE_ID_ONLY_ELEMENT = 'only-element';
const MESSAGE_ID_SPREAD = 'spread';
const messages = {
[MESSAGE_ID_ERROR]: '`new Array()` is unclear in intent; use either `[x]` or `Array.from({length: x})`',
[MESSAGE_ID_LENGTH]: 'The argument is the length of array.',
[MESSAGE_ID_ONLY_ELEMENT]: 'The argument is the only element of array.',
[MESSAGE_ID_SPREAD]: 'Spread the argument.',
};
function getProblem(context, node) {
if (
!isNewExpression(node, {
name: 'Array',
argumentsLength: 1,
allowSpreadElement: true,
})
) {
return;
}
const problem = {
node,
messageId: MESSAGE_ID_ERROR,
};
const [argumentNode] = node.arguments;
const {sourceCode} = context;
let text = sourceCode.getText(argumentNode);
if (isParenthesized(argumentNode, context)) {
text = `(${text})`;
}
const maybeSemiColon = needsSemicolon(sourceCode.getTokenBefore(node), context, '[')
? ';'
: '';
// We are not sure how many `arguments` passed
if (argumentNode.type === 'SpreadElement') {
problem.suggest = [
{
messageId: MESSAGE_ID_SPREAD,
fix: fixer => fixer.replaceText(node, `${maybeSemiColon}[${text}]`),
},
];
return problem;
}
const fromLengthText = `Array.from(${text === 'length' ? '{length}' : `{length: ${text}}`})`;
const scope = sourceCode.getScope(node);
if (isNumber(argumentNode, scope)) {
problem.fix = fixer => fixer.replaceText(node, fromLengthText);
return problem;
}
const onlyElementText = `${maybeSemiColon}[${text}]`;
const result = getStaticValue(argumentNode, scope);
if (result !== null && typeof result.value !== 'number') {
problem.fix = fixer => fixer.replaceText(node, onlyElementText);
return problem;
}
// We don't know the argument is number or not
problem.suggest = [
{
messageId: MESSAGE_ID_LENGTH,
fix: fixer => fixer.replaceText(node, fromLengthText),
},
{
messageId: MESSAGE_ID_ONLY_ELEMENT,
fix: fixer => fixer.replaceText(node, onlyElementText),
},
];
return problem;
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('NewExpression', node => getProblem(context, node));
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow `new Array()`.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
+98
View File
@@ -0,0 +1,98 @@
import {getStaticValue} from '@eslint-community/eslint-utils';
import {switchNewExpressionToCallExpression} from './fix/index.js';
import isNumber from './utils/is-number.js';
import {isNewExpression} from './ast/index.js';
const ERROR = 'error';
const ERROR_UNKNOWN = 'error-unknown';
const SUGGESTION = 'suggestion';
const messages = {
[ERROR]: '`new Buffer()` is deprecated, use `Buffer.{{method}}()` instead.',
[ERROR_UNKNOWN]: '`new Buffer()` is deprecated, use `Buffer.alloc()` or `Buffer.from()` instead.',
[SUGGESTION]: 'Switch to `Buffer.{{replacement}}()`.',
};
const inferMethod = (bufferArguments, scope) => {
if (bufferArguments.length !== 1) {
return 'from';
}
const [firstArgument] = bufferArguments;
if (firstArgument.type === 'SpreadElement') {
return;
}
if (firstArgument.type === 'ArrayExpression' || firstArgument.type === 'TemplateLiteral') {
return 'from';
}
if (isNumber(firstArgument, scope)) {
return 'alloc';
}
const staticResult = getStaticValue(firstArgument, scope);
if (staticResult) {
const {value} = staticResult;
if (
typeof value === 'string'
|| Array.isArray(value)
) {
return 'from';
}
}
};
function fix(node, context, method) {
return function * (fixer) {
yield fixer.insertTextAfter(node.callee, `.${method}`);
yield switchNewExpressionToCallExpression(node, context, fixer);
};
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {sourceCode} = context;
context.on('NewExpression', node => {
if (!isNewExpression(node, {name: 'Buffer'})) {
return;
}
const method = inferMethod(node.arguments, sourceCode.getScope(node));
if (method) {
return {
node,
messageId: ERROR,
data: {method},
fix: fix(node, context, method),
};
}
return {
node,
messageId: ERROR_UNKNOWN,
suggest: ['from', 'alloc'].map(replacement => ({
messageId: SUGGESTION,
data: {replacement},
fix: fix(node, context, replacement),
})),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce the use of `Buffer.from()` and `Buffer.alloc()` instead of the deprecated `new Buffer()`.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
+148
View File
@@ -0,0 +1,148 @@
import {isMethodCall, isCallExpression, isNullLiteral} from './ast/index.js';
const ERROR_MESSAGE_ID = 'error';
const SUGGESTION_REPLACE_MESSAGE_ID = 'replace';
const SUGGESTION_REMOVE_MESSAGE_ID = 'remove';
const messages = {
[ERROR_MESSAGE_ID]: 'Use `undefined` instead of `null`.',
[SUGGESTION_REPLACE_MESSAGE_ID]: 'Replace `null` with `undefined`.',
[SUGGESTION_REMOVE_MESSAGE_ID]: 'Remove `null`.',
};
const isLooseEqual = node => node.type === 'BinaryExpression' && ['==', '!='].includes(node.operator);
const isStrictEqual = node => node.type === 'BinaryExpression' && ['===', '!=='].includes(node.operator);
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {checkStrictEquality} = context.options[0];
context.on('Literal', node => {
if (
!isNullLiteral(node)
|| (!checkStrictEquality && isStrictEqual(node.parent))
// `Object.create(null)`, `Object.create(null, foo)`
|| (
isMethodCall(node.parent, {
object: 'Object',
method: 'create',
minimumArguments: 1,
maximumArguments: 2,
optionalCall: false,
optionalMember: false,
})
&& node.parent.arguments[0] === node
)
// `useRef(null)`
|| (
isCallExpression(node.parent, {
name: 'useRef',
argumentsLength: 1,
optionalCall: false,
optionalMember: false,
})
&& node.parent.arguments[0] === node
)
// `React.useRef(null)`
|| (
isMethodCall(node.parent, {
object: 'React',
method: 'useRef',
argumentsLength: 1,
optionalCall: false,
optionalMember: false,
})
&& node.parent.arguments[0] === node
)
// `foo.insertBefore(bar, null)`
|| (
isMethodCall(node.parent, {
method: 'insertBefore',
argumentsLength: 2,
optionalCall: false,
})
&& node.parent.arguments[1] === node
)
) {
return;
}
const {parent} = node;
const problem = {
node,
messageId: ERROR_MESSAGE_ID,
};
const useUndefinedFix = fixer => fixer.replaceText(node, 'undefined');
if (isLooseEqual(parent)) {
problem.fix = useUndefinedFix;
return problem;
}
const useUndefinedSuggestion = {
messageId: SUGGESTION_REPLACE_MESSAGE_ID,
fix: useUndefinedFix,
};
if (parent.type === 'ReturnStatement' && parent.argument === node) {
problem.suggest = [
{
messageId: SUGGESTION_REMOVE_MESSAGE_ID,
fix: fixer => fixer.remove(node),
},
useUndefinedSuggestion,
];
return problem;
}
if (parent.type === 'VariableDeclarator' && parent.init === node && parent.parent.kind !== 'const') {
const {sourceCode} = context;
const [, start] = sourceCode.getRange(parent.id);
const [, end] = sourceCode.getRange(node);
problem.suggest = [
{
messageId: SUGGESTION_REMOVE_MESSAGE_ID,
fix: fixer => fixer.removeRange([start, end]),
},
useUndefinedSuggestion,
];
return problem;
}
problem.suggest = [useUndefinedSuggestion];
return problem;
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
checkStrictEquality: {
type: 'boolean',
description: 'Whether to check strict equality comparisons against `null`.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow the use of the `null` literal.',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
schema,
defaultOptions: [{checkStrictEquality: false}],
messages,
},
};
export default config;
@@ -0,0 +1,52 @@
import {isFunction} from './ast/index.js';
const MESSAGE_ID_IDENTIFIER = 'identifier';
const MESSAGE_ID_NON_IDENTIFIER = 'non-identifier';
const messages = {
[MESSAGE_ID_IDENTIFIER]: 'Do not use an object literal as default for parameter `{{parameter}}`.',
[MESSAGE_ID_NON_IDENTIFIER]: 'Do not use an object literal as default.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('AssignmentPattern', node => {
if (!(
node.right.type === 'ObjectExpression'
&& node.right.properties.length > 0
&& isFunction(node.parent)
&& node.parent.params.includes(node)
)) {
return;
}
const {left, right} = node;
if (left.type === 'Identifier') {
return {
node: left,
messageId: MESSAGE_ID_IDENTIFIER,
data: {parameter: left.name},
};
}
return {
node: right,
messageId: MESSAGE_ID_NON_IDENTIFIER,
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow the use of objects as default parameters.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
+106
View File
@@ -0,0 +1,106 @@
import {isStaticRequire, isMethodCall, isLiteral} from './ast/index.js';
const MESSAGE_ID = 'no-process-exit';
const messages = {
[MESSAGE_ID]: 'Only use `process.exit()` in CLI apps. Throw an error instead.',
};
const isWorkerThreads = node =>
isLiteral(node, 'node:worker_threads')
|| isLiteral(node, 'worker_threads');
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const startsWithHashBang = context.sourceCode.lines[0].indexOf('#!') === 0;
if (startsWithHashBang) {
return;
}
let processEventHandler;
// Only report if it's outside a worker thread context. See #328.
let requiredWorkerThreadsModule = false;
const problemNodes = [];
// `require('worker_threads')`
context.on('CallExpression', callExpression => {
if (
isStaticRequire(callExpression)
&& isWorkerThreads(callExpression.arguments[0])
) {
requiredWorkerThreadsModule = true;
}
});
// `import workerThreads from 'worker_threads'`
context.on('ImportDeclaration', importDeclaration => {
if (
importDeclaration.source.type === 'Literal'
&& isWorkerThreads(importDeclaration.source)
) {
requiredWorkerThreadsModule = true;
}
});
// Check `process.on` / `process.once` call
context.on('CallExpression', node => {
if (isMethodCall(node, {
object: 'process',
methods: ['on', 'once'],
minimumArguments: 1,
optionalCall: false,
optionalMember: false,
})) {
processEventHandler = node;
}
});
context.onExit('CallExpression', node => {
if (node === processEventHandler) {
processEventHandler = undefined;
}
});
// Check `process.exit` call
context.on('CallExpression', node => {
if (
!processEventHandler
&& isMethodCall(node, {
object: 'process',
method: 'exit',
optionalCall: false,
optionalMember: false,
})
) {
problemNodes.push(node);
}
});
context.onExit('Program', function * () {
if (requiredWorkerThreadsModule) {
return;
}
for (const node of problemNodes) {
yield {
node,
messageId: MESSAGE_ID,
};
}
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow `process.exit()`.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
@@ -0,0 +1,177 @@
import {isCommaToken} from '@eslint-community/eslint-utils';
import {isMethodCall, isExpressionStatement} from './ast/index.js';
import {
getParenthesizedText,
isParenthesized,
needsSemicolon,
shouldAddParenthesesToAwaitExpressionArgument,
} from './utils/index.js';
const MESSAGE_ID_ERROR = 'no-single-promise-in-promise-methods/error';
const MESSAGE_ID_SUGGESTION_UNWRAP = 'no-single-promise-in-promise-methods/unwrap';
const MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE = 'no-single-promise-in-promise-methods/use-promise-resolve';
const messages = {
[MESSAGE_ID_ERROR]: 'Wrapping single-element array with `Promise.{{method}}()` is unnecessary.',
[MESSAGE_ID_SUGGESTION_UNWRAP]: 'Use the value directly.',
[MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE]: 'Switch to `Promise.resolve(…)`.',
};
const METHODS = ['all', 'any', 'race'];
const isPromiseMethodCallWithSingleElementArray = node =>
isMethodCall(node, {
object: 'Promise',
methods: METHODS,
optionalMember: false,
optionalCall: false,
argumentsLength: 1,
})
&& node.arguments[0].type === 'ArrayExpression'
&& node.arguments[0].elements.length === 1
&& node.arguments[0].elements[0]
&& node.arguments[0].elements[0].type !== 'SpreadElement';
const unwrapAwaitedCallExpression = (callExpression, context) => fixer => {
const [promiseNode] = callExpression.arguments[0].elements;
let text = getParenthesizedText(promiseNode, context);
if (
!isParenthesized(promiseNode, context)
&& shouldAddParenthesesToAwaitExpressionArgument(promiseNode)
) {
text = `(${text})`;
}
// The next node is already behind a `CallExpression`, there should be no ASI problem
return fixer.replaceText(callExpression, text);
};
const unwrapNonAwaitedCallExpression = (callExpression, context) => fixer => {
const [promiseNode] = callExpression.arguments[0].elements;
let text = getParenthesizedText(promiseNode, context);
if (
!isParenthesized(promiseNode, context)
// Since the original call expression can be anywhere, it's hard to tell if the promise
// need to be parenthesized, but it's safe to add parentheses
&& !(
// Known cases that not need parentheses
promiseNode.type === 'Identifier'
|| promiseNode.type === 'MemberExpression'
)
) {
text = `(${text})`;
}
const previousToken = context.sourceCode.getTokenBefore(callExpression);
if (needsSemicolon(previousToken, context, text)) {
text = `;${text}`;
}
return fixer.replaceText(callExpression, text);
};
const switchToPromiseResolve = (callExpression, sourceCode) => function * (fixer) {
/*
```
Promise.race([promise,])
// ^^^^ methodNameNode
```
*/
const methodNameNode = callExpression.callee.property;
yield fixer.replaceText(methodNameNode, 'resolve');
const [arrayExpression] = callExpression.arguments;
/*
```
Promise.race([promise,])
// ^ openingBracketToken
```
*/
const openingBracketToken = sourceCode.getFirstToken(arrayExpression);
/*
```
Promise.race([promise,])
// ^ penultimateToken
// ^ closingBracketToken
```
*/
const [
penultimateToken,
closingBracketToken,
] = sourceCode.getLastTokens(arrayExpression, 2);
yield fixer.remove(openingBracketToken);
yield fixer.remove(closingBracketToken);
if (isCommaToken(penultimateToken)) {
yield fixer.remove(penultimateToken);
}
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!isPromiseMethodCallWithSingleElementArray(callExpression)) {
return;
}
const methodName = callExpression.callee.property.name;
const problem = {
node: callExpression.arguments[0],
messageId: MESSAGE_ID_ERROR,
data: {
method: methodName,
},
};
const {sourceCode} = context;
if (
callExpression.parent.type === 'AwaitExpression'
&& callExpression.parent.argument === callExpression
&& (
methodName !== 'all'
|| isExpressionStatement(callExpression.parent.parent)
)
) {
problem.fix = unwrapAwaitedCallExpression(callExpression, context);
return problem;
}
if (methodName === 'all') {
return problem;
}
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION_UNWRAP,
fix: unwrapNonAwaitedCallExpression(callExpression, context),
},
{
messageId: MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE,
fix: switchToPromiseResolve(callExpression, sourceCode),
},
];
return problem;
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow passing single-element arrays to `Promise` methods.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;
@@ -0,0 +1,226 @@
import {isSemicolonToken} from '@eslint-community/eslint-utils';
import getClassHeadLocation from './utils/get-class-head-location.js';
import assertToken from './utils/assert-token.js';
import {removeSpacesAfter} from './fix/index.js';
const MESSAGE_ID = 'no-static-only-class';
const messages = {
[MESSAGE_ID]: 'Use an object instead of a class with only static members.',
};
const isEqualToken = ({type, value}) => type === 'Punctuator' && value === '=';
const isDeclarationOfExportDefaultDeclaration = node =>
node.type === 'ClassDeclaration'
&& node.parent.type === 'ExportDefaultDeclaration'
&& node.parent.declaration === node;
const isPropertyDefinition = node => node.type === 'PropertyDefinition';
const isMethodDefinition = node => node.type === 'MethodDefinition';
function isStaticMember(node) {
const {
private: isPrivate,
static: isStatic,
declare: isDeclare,
readonly: isReadonly,
accessibility,
decorators,
key,
} = node;
// Avoid matching unexpected node. For example: https://github.com/tc39/proposal-class-static-block
if (!isPropertyDefinition(node) && !isMethodDefinition(node)) {
return false;
}
if (!isStatic || isPrivate || key.type === 'PrivateIdentifier') {
return false;
}
// TypeScript class
if (
isDeclare
|| isReadonly
|| accessibility !== undefined
|| (Array.isArray(decorators) && decorators.length > 0)
) {
return false;
}
return true;
}
function * switchClassMemberToObjectProperty(node, context, fixer) {
const {sourceCode} = context;
const staticToken = sourceCode.getFirstToken(node);
assertToken(staticToken, {
expected: {type: 'Keyword', value: 'static'},
ruleId: 'no-static-only-class',
});
yield fixer.remove(staticToken);
yield removeSpacesAfter(staticToken, context, fixer);
const maybeSemicolonToken = isPropertyDefinition(node)
? sourceCode.getLastToken(node)
: sourceCode.getTokenAfter(node);
const hasSemicolonToken = isSemicolonToken(maybeSemicolonToken);
if (isPropertyDefinition(node)) {
const {key, value} = node;
if (value) {
// Computed key may have `]` after `key`
const equalToken = sourceCode.getTokenAfter(key, isEqualToken);
yield fixer.replaceText(equalToken, ':');
} else if (hasSemicolonToken) {
yield fixer.insertTextBefore(maybeSemicolonToken, ': undefined');
} else {
yield fixer.insertTextAfter(node, ': undefined');
}
}
yield (
hasSemicolonToken
? fixer.replaceText(maybeSemicolonToken, ',')
: fixer.insertTextAfter(node, ',')
);
}
function switchClassToObject(node, context) {
const {
type,
id,
body,
declare: isDeclare,
abstract: isAbstract,
implements: classImplements,
parent,
} = node;
if (
isDeclare
|| isAbstract
|| (Array.isArray(classImplements) && classImplements.length > 0)
) {
return;
}
if (type === 'ClassExpression' && id) {
return;
}
const isExportDefault = isDeclarationOfExportDefaultDeclaration(node);
if (isExportDefault && id) {
return;
}
const {sourceCode} = context;
for (const node of body.body) {
if (
isPropertyDefinition(node)
&& (
node.typeAnnotation
// This is a stupid way to check if `value` of `PropertyDefinition` uses `this`
|| (node.value && sourceCode.getText(node.value).includes('this'))
)
) {
return;
}
}
return function * (fixer) {
const classToken = sourceCode.getFirstToken(node);
/* c8 ignore next */
assertToken(classToken, {
expected: {type: 'Keyword', value: 'class'},
ruleId: 'no-static-only-class',
});
if (isExportDefault || type === 'ClassExpression') {
/*
There are comments after return, and `{` is not on same line
```js
function a() {
return class // comment
{
static a() {}
}
}
```
*/
if (
type === 'ClassExpression'
&& parent.type === 'ReturnStatement'
&& sourceCode.getLoc(body).start.line !== sourceCode.getLoc(parent).start.line
&& sourceCode.text.slice(sourceCode.getRange(classToken)[1], sourceCode.getRange(body)[0]).trim()
) {
yield fixer.replaceText(classToken, '{');
const openingBraceToken = sourceCode.getFirstToken(body);
yield fixer.remove(openingBraceToken);
} else {
yield fixer.replaceText(classToken, '');
/*
Avoid breaking case like
```js
return class
{};
```
*/
yield removeSpacesAfter(classToken, context, fixer);
}
// There should not be ASI problem
} else {
yield fixer.replaceText(classToken, 'const');
yield fixer.insertTextBefore(body, '= ');
yield fixer.insertTextAfter(body, ';');
}
for (const node of body.body) {
yield switchClassMemberToObjectProperty(node, context, fixer);
}
};
}
function create(context) {
context.on(['ClassDeclaration', 'ClassExpression'], node => {
if (
node.superClass
|| (node.decorators && node.decorators.length > 0)
|| node.body.type !== 'ClassBody'
|| node.body.body.length === 0
|| node.body.body.some(node => !isStaticMember(node))
) {
return;
}
return {
node,
loc: getClassHeadLocation(node, context),
messageId: MESSAGE_ID,
fix: switchClassToObject(node, context),
};
});
}
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow classes that only have static members.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
+194
View File
@@ -0,0 +1,194 @@
import {getStaticValue, getPropertyName} from '@eslint-community/eslint-utils';
import {isMethodCall} from './ast/index.js';
const MESSAGE_ID_OBJECT = 'no-thenable-object';
const MESSAGE_ID_EXPORT = 'no-thenable-export';
const MESSAGE_ID_CLASS = 'no-thenable-class';
const messages = {
[MESSAGE_ID_OBJECT]: 'Do not add `then` to an object.',
[MESSAGE_ID_EXPORT]: 'Do not export `then`.',
[MESSAGE_ID_CLASS]: 'Do not add `then` to a class.',
};
const isStringThen = (node, context) =>
getStaticValue(node, context.sourceCode.getScope(node))?.value === 'then';
const isPropertyThen = (node, context) =>
getPropertyName(node, context.sourceCode.getScope(node)) === 'then';
const cases = [
// `{then() {}}`,
// `{get then() {}}`,
// `{[computedKey]() {}}`,
// `{get [computedKey]() {}}`,
{
selector: 'ObjectExpression',
* getNodes(node, context) {
for (const property of node.properties) {
if (property.type === 'Property' && isPropertyThen(property, context)) {
yield property.key;
}
}
},
messageId: MESSAGE_ID_OBJECT,
},
// `class Foo {then}`,
// `class Foo {static then}`,
// `class Foo {get then() {}}`,
// `class Foo {static get then() {}}`,
{
selectors: ['PropertyDefinition', 'MethodDefinition'],
* getNodes(node, context) {
if (getPropertyName(node, context.sourceCode.getScope(node)) === 'then') {
yield node.key;
}
},
messageId: MESSAGE_ID_CLASS,
},
// `foo.then = …`
// `foo[computedKey] = …`
{
selector: 'MemberExpression',
* getNodes(node, context) {
if (!(node.parent.type === 'AssignmentExpression' && node.parent.left === node)) {
return;
}
if (getPropertyName(node, context.sourceCode.getScope(node)) === 'then') {
yield node.property;
}
},
messageId: MESSAGE_ID_OBJECT,
},
// `Object.defineProperty(foo, 'then', …)`
// `Reflect.defineProperty(foo, 'then', …)`
{
selector: 'CallExpression',
* getNodes(node, context) {
if (!(
isMethodCall(node, {
objects: ['Object', 'Reflect'],
method: 'defineProperty',
minimumArguments: 3,
optionalCall: false,
optionalMember: false,
})
&& node.arguments[0].type !== 'SpreadElement'
)) {
return;
}
const [, secondArgument] = node.arguments;
if (isStringThen(secondArgument, context)) {
yield secondArgument;
}
},
messageId: MESSAGE_ID_OBJECT,
},
// `Object.fromEntries([['then', …]])`
{
selector: 'CallExpression',
* getNodes(node, context) {
if (!(
isMethodCall(node, {
object: 'Object',
method: 'fromEntries',
argumentsLength: 1,
optionalCall: false,
optionalMember: false,
})
&& node.arguments[0].type === 'ArrayExpression'
)) {
return;
}
for (const pairs of node.arguments[0].elements) {
if (
pairs?.type === 'ArrayExpression'
&& pairs.elements[0]
&& pairs.elements[0].type !== 'SpreadElement'
) {
const [key] = pairs.elements;
if (isStringThen(key, context)) {
yield key;
}
}
}
},
messageId: MESSAGE_ID_OBJECT,
},
// `export {then}`
{
selector: 'Identifier',
* getNodes(node) {
if (
node.name === 'then'
&& node.parent.type === 'ExportSpecifier'
&& node.parent.exported === node
) {
yield node;
}
},
messageId: MESSAGE_ID_EXPORT,
},
// `export function then() {}`,
// `export class then {}`,
{
selector: 'Identifier',
* getNodes(node) {
if (
node.name === 'then'
&& (node.parent.type === 'FunctionDeclaration' || node.parent.type === 'ClassDeclaration')
&& node.parent.id === node
&& node.parent.parent.type === 'ExportNamedDeclaration'
&& node.parent.parent.declaration === node.parent
) {
yield node;
}
},
messageId: MESSAGE_ID_EXPORT,
},
// `export const … = …`;
{
selector: 'VariableDeclaration',
* getNodes(node, context) {
if (!(node.parent.type === 'ExportNamedDeclaration' && node.parent.declaration === node)) {
return;
}
for (const variable of context.sourceCode.getDeclaredVariables(node)) {
if (variable.name === 'then') {
yield * variable.identifiers;
}
}
},
messageId: MESSAGE_ID_EXPORT,
},
];
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
for (const {selector, selectors, messageId, getNodes} of cases) {
context.on(selector ?? selectors, function * (node) {
for (const problematicNode of getNodes(node, context)) {
yield {node: problematicNode, messageId};
}
});
}
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow `then` property.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
@@ -0,0 +1,40 @@
const MESSAGE_ID = 'no-this-assignment';
const messages = {
[MESSAGE_ID]: 'Do not assign `this` to `{{name}}`.',
};
function getProblem(variableNode, valueNode) {
if (
variableNode.type !== 'Identifier'
|| valueNode?.type !== 'ThisExpression'
) {
return;
}
return {
node: valueNode.parent,
data: {name: variableNode.name},
messageId: MESSAGE_ID,
};
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('VariableDeclarator', node => getProblem(node.id, node.init));
context.on('AssignmentExpression', node => getProblem(node.left, node.right));
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow assigning `this` to a variable.',
recommended: 'unopinionated',
},
messages,
},
};
export default config;
@@ -0,0 +1,138 @@
import {isLiteral} from './ast/index.js';
import {
addParenthesizesToReturnOrThrowExpression,
removeSpacesAfter,
} from './fix/index.js';
import {
needsSemicolon,
isParenthesized,
isOnSameLine,
isUnresolvedVariable,
} from './utils/index.js';
const MESSAGE_ID_ERROR = 'no-typeof-undefined/error';
const MESSAGE_ID_SUGGESTION = 'no-typeof-undefined/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Compare with `undefined` directly instead of using `typeof`.',
[MESSAGE_ID_SUGGESTION]: 'Switch to `… {{operator}} undefined`.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {checkGlobalVariables} = context.options[0];
context.on('BinaryExpression', binaryExpression => {
if (!(
(
binaryExpression.operator === '==='
|| binaryExpression.operator === '!=='
|| binaryExpression.operator === '=='
|| binaryExpression.operator === '!='
)
&& binaryExpression.left.type === 'UnaryExpression'
&& binaryExpression.left.operator === 'typeof'
&& binaryExpression.left.prefix
&& isLiteral(binaryExpression.right, 'undefined')
)) {
return;
}
const {left: typeofNode, right: undefinedString, operator} = binaryExpression;
const {sourceCode} = context;
const valueNode = typeofNode.argument;
const isGlobalVariable = valueNode.type === 'Identifier'
&& (sourceCode.isGlobalReference(valueNode) || isUnresolvedVariable(valueNode, context));
if (!checkGlobalVariables && isGlobalVariable) {
return;
}
const [typeofToken, secondToken] = sourceCode.getFirstTokens(typeofNode, 2);
const fix = function * (fixer) {
// Change `==`/`!=` to `===`/`!==`
if (operator === '==' || operator === '!=') {
const operatorToken = sourceCode.getTokenAfter(
typeofNode,
token => token.type === 'Punctuator' && token.value === operator,
);
yield fixer.insertTextAfter(operatorToken, '=');
}
yield fixer.replaceText(undefinedString, 'undefined');
yield fixer.remove(typeofToken);
yield removeSpacesAfter(typeofToken, context, fixer);
const {parent} = binaryExpression;
if (
(parent.type === 'ReturnStatement' || parent.type === 'ThrowStatement')
&& parent.argument === binaryExpression
&& !isOnSameLine(typeofToken, secondToken, context)
&& !isParenthesized(binaryExpression, context)
&& !isParenthesized(typeofNode, context)
) {
yield addParenthesizesToReturnOrThrowExpression(fixer, parent, context);
return;
}
const tokenBefore = sourceCode.getTokenBefore(binaryExpression);
if (needsSemicolon(tokenBefore, context, secondToken.value)) {
yield fixer.insertTextBefore(binaryExpression, ';');
}
};
const problem = {
node: binaryExpression,
loc: sourceCode.getLoc(typeofToken),
messageId: MESSAGE_ID_ERROR,
};
if (isGlobalVariable) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {operator: operator.startsWith('!') ? '!==' : '==='},
fix,
},
];
} else {
problem.fix = fix;
}
return problem;
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
checkGlobalVariables: {
type: 'boolean',
description: 'Whether to also check `typeof` comparisons against global variables.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow comparing `undefined` using `typeof`.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
schema,
defaultOptions: [{checkGlobalVariables: false}],
messages,
},
};
export default config;
@@ -0,0 +1,49 @@
import {isMethodCall, isLiteral} from './ast/index.js';
import {removeArgument} from './fix/index.js';
const MESSAGE_ID = 'no-unnecessary-array-flat-depth';
const messages = {
[MESSAGE_ID]: 'Passing `1` as the `depth` argument is unnecessary.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!(
isMethodCall(callExpression, {
method: 'flat',
argumentsLength: 1,
optionalCall: false,
})
&& isLiteral(callExpression.arguments[0], 1)
)) {
return;
}
const [numberOne] = callExpression.arguments;
return {
node: numberOne,
messageId: MESSAGE_ID,
/** @param {import('eslint').Rule.RuleFixer} fixer */
fix: fixer => removeArgument(fixer, numberOne, context),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow using `1` as the `depth` argument of `Array#flat()`.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,25 @@
import {listen} from './shared/no-unnecessary-length-or-infinity-rule.js';
const MESSAGE_ID = 'no-unnecessary-array-splice-count';
const messages = {
[MESSAGE_ID]: 'Passing `{{description}}` as the `{{argumentName}}` argument is unnecessary.',
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create(context) {
listen(context, {methods: ['splice', 'toSpliced'], messageId: MESSAGE_ID});
},
meta: {
type: 'suggestion',
docs: {
description: 'Disallow using `.length` or `Infinity` as the `deleteCount` or `skipCount` argument of `Array#{splice,toSpliced}()`.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,108 @@
import {addParenthesizesToReturnOrThrowExpression, removeSpacesAfter} from './fix/index.js';
import {isParenthesized, needsSemicolon, isOnSameLine} from './utils/index.js';
const MESSAGE_ID = 'no-unnecessary-await';
const messages = {
[MESSAGE_ID]: 'Do not `await` non-promise value.',
};
function notPromise(node) {
switch (node.type) {
case 'ArrayExpression':
case 'ArrowFunctionExpression':
case 'AwaitExpression':
case 'BinaryExpression':
case 'ClassExpression':
case 'FunctionExpression':
case 'JSXElement':
case 'JSXFragment':
case 'Literal':
case 'TemplateLiteral':
case 'UnaryExpression':
case 'UpdateExpression': {
return true;
}
case 'SequenceExpression': {
return notPromise(node.expressions.at(-1));
}
// No default
}
return false;
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('AwaitExpression', node => {
if (
// F#-style pipeline operator, `Promise.resolve() |> await`
!node.argument
|| !notPromise(node.argument)
) {
return;
}
const {sourceCode} = context;
const awaitToken = sourceCode.getFirstToken(node);
const problem = {
node,
loc: sourceCode.getLoc(awaitToken),
messageId: MESSAGE_ID,
};
const valueNode = node.argument;
if (
// Removing `await` may change them to a declaration, if there is no `id` will cause SyntaxError
valueNode.type === 'FunctionExpression'
|| valueNode.type === 'ClassExpression'
// `+await +1` -> `++1`
|| (
node.parent.type === 'UnaryExpression'
&& valueNode.type === 'UnaryExpression'
&& node.parent.operator === valueNode.operator
)
) {
return problem;
}
return Object.assign(problem, {
/** @param {import('eslint').Rule.RuleFixer} fixer */
* fix(fixer) {
if (
!isOnSameLine(awaitToken, valueNode, context)
&& !isParenthesized(node, context)
) {
yield addParenthesizesToReturnOrThrowExpression(fixer, node.parent, context);
}
yield fixer.remove(awaitToken);
yield removeSpacesAfter(awaitToken, context, fixer);
const nextToken = sourceCode.getTokenAfter(awaitToken);
const tokenBefore = sourceCode.getTokenBefore(awaitToken);
if (needsSemicolon(tokenBefore, context, nextToken.value)) {
yield fixer.insertTextBefore(nextToken, ';');
}
},
});
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow awaiting non-promise values.',
recommended: 'unopinionated',
},
fixable: 'code',
messages,
},
};
export default config;
@@ -0,0 +1,361 @@
import path from 'node:path';
import coreJsCompat from 'core-js-compat';
import {camelCase} from 'change-case';
import isStaticRequire from './ast/is-static-require.js';
import {readPackageJson} from './shared/package-json.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 additionalPolyfillModules = {
'es.promise.finally': ['p-finally'],
'es.object.set-prototype-of': ['setprototypeof'],
'es.string.code-point-at': ['code-point-at'],
};
const additionalPolyfillPatterns = Object.fromEntries(Object.entries(additionalPolyfillModules).map(([feature, modules]) => [feature, `|(${modules.join('|')})`]));
const prefixes = '(mdn-polyfills/|polyfill-)';
const suffixes = '(-polyfill)';
const delimiter = String.raw`(\.|-|\.prototype\.|/)?`;
const moduleDelimiter = /[./-]/u;
const getFirstSegment = value => {
const [firstSegment = ''] = value.split(moduleDelimiter);
return firstSegment;
};
const stripPolyfillPrefix = value => {
if (value.startsWith('polyfill-')) {
return value.slice('polyfill-'.length);
}
if (value.startsWith('mdn-polyfills/')) {
return value.slice('mdn-polyfills/'.length);
}
return value;
};
function addPolyfillToken(tokens, value) {
if (!value) {
return;
}
const lowercaseValue = value.toLowerCase();
tokens.add(lowercaseValue);
tokens.add(getFirstSegment(lowercaseValue));
const camelCasedValue = camelCase(value).toLowerCase();
tokens.add(camelCasedValue);
tokens.add(getFirstSegment(camelCasedValue));
}
const polyfills = Object.keys(compatData).map(feature => {
const [rawEcmaVersion, rawConstructorName, rawMethodName = ''] = feature.split('.');
let ecmaVersion = rawEcmaVersion;
let constructorName = rawConstructorName;
let methodName = rawMethodName;
if (ecmaVersion === 'es') {
ecmaVersion = String.raw`(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'),
tokens: (() => {
const tokens = new Set();
if (rawEcmaVersion === 'es') {
tokens.add('es');
} else {
addPolyfillToken(tokens, rawEcmaVersion);
}
addPolyfillToken(tokens, rawConstructorName);
addPolyfillToken(tokens, rawMethodName);
for (const module of additionalPolyfillModules[feature] || []) {
addPolyfillToken(tokens, module);
}
return tokens;
})(),
};
});
const polyfillsByToken = new Map();
const polyfillTokensByFirstCharacter = new Map();
const esConstructorTokens = new Set();
for (const polyfill of polyfills) {
const [ecmaVersion, constructorName] = polyfill.feature.split('.');
if (ecmaVersion === 'es') {
esConstructorTokens.add(constructorName.toLowerCase());
esConstructorTokens.add(camelCase(constructorName).toLowerCase());
}
for (const token of polyfill.tokens) {
if (!token) {
continue;
}
if (polyfillsByToken.has(token)) {
polyfillsByToken.get(token).push(polyfill);
} else {
polyfillsByToken.set(token, [polyfill]);
}
const firstCharacter = token[0];
if (polyfillTokensByFirstCharacter.has(firstCharacter)) {
polyfillTokensByFirstCharacter.get(firstCharacter).add(token);
} else {
polyfillTokensByFirstCharacter.set(firstCharacter, new Set([token]));
}
}
}
const hasEsConstructorPrefix = value => {
for (const token of esConstructorTokens) {
if (value.startsWith(token)) {
return true;
}
}
return false;
};
const isPotentialEsPrefix = importedModule => {
if (!importedModule.startsWith('es')) {
return false;
}
let constructorIndex = 2;
while (
constructorIndex < importedModule.length
&& importedModule[constructorIndex] >= '0'
&& importedModule[constructorIndex] <= '9'
) {
constructorIndex++;
}
if (importedModule.startsWith('.prototype.', constructorIndex)) {
constructorIndex += '.prototype.'.length;
} else if (['.', '-', '/'].includes(importedModule[constructorIndex])) {
constructorIndex++;
}
return hasEsConstructorPrefix(importedModule.slice(constructorIndex));
};
const getPolyfillCandidates = importedModule => {
const normalizedImportedModule = stripPolyfillPrefix(importedModule);
if (!normalizedImportedModule) {
return;
}
const firstCharacter = normalizedImportedModule[0];
const tokens = polyfillTokensByFirstCharacter.get(firstCharacter);
if (!tokens) {
return;
}
const candidates = new Set();
const firstSegment = getFirstSegment(normalizedImportedModule);
if (firstSegment === normalizedImportedModule) {
for (const token of tokens) {
if (token === 'es') {
if (!isPotentialEsPrefix(normalizedImportedModule)) {
continue;
}
} else if (!normalizedImportedModule.startsWith(token)) {
continue;
}
for (const polyfill of polyfillsByToken.get(token)) {
candidates.add(polyfill);
}
}
} else {
for (const token of tokens) {
if (
token === 'es'
|| !firstSegment.startsWith(token)
) {
continue;
}
for (const polyfill of polyfillsByToken.get(token)) {
candidates.add(polyfill);
}
}
}
if (isPotentialEsPrefix(normalizedImportedModule)) {
for (const polyfill of polyfillsByToken.get('es') || []) {
candidates.add(polyfill);
}
}
if (candidates.size === 0) {
return;
}
return [...candidates];
};
function getTargets(options, dirname) {
if (options?.targets) {
return options.targets;
}
const packageJsonResult = readPackageJson(dirname);
if (!packageJsonResult) {
return;
}
const {browserslist, engines} = packageJsonResult.packageJson;
return browserslist ?? 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 unavailableFeatureSet = new Set(unavailableFeatures);
// When core-js graduates a feature from `esnext` to `es`, the entries list both (e.g. `['es.regexp.escape', 'esnext.regexp.escape']`),
// but `coreJsCompat` only includes the `es` version in its unavailable list, making the `esnext` version appear "available".
// To avoid false positives, treat `esnext.*` features as unavailable when their `es.*` counterpart is already in the list.
const checkFeatures = features => !features.every(feature =>
unavailableFeatureSet.has(feature)
|| (feature.startsWith('esnext.') && features.includes(feature.replace('esnext.', 'es.'))));
context.on('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 (!unavailableFeatureSet.has(coreJsModuleFeatures[0])) {
return {node, messageId: MESSAGE_ID_POLYFILL};
}
return;
}
const polyfillCandidates = getPolyfillCandidates(importedModule.toLowerCase());
if (!polyfillCandidates) {
return;
}
const polyfill = polyfillCandidates.find(({pattern}) => pattern.test(importedModule));
if (polyfill) {
const [, namespace, method = ''] = polyfill.feature.split('.');
const features = coreJsEntries[`core-js/full/${namespace}${method && '/'}${method}`];
if (features && checkFeatures(features)) {
return {node, messageId: MESSAGE_ID_POLYFILL};
}
}
});
}
const schema = [
{
type: 'object',
additionalProperties: false,
required: ['targets'],
properties: {
targets: {
oneOf: [
{
type: 'string',
description: 'A browserslist query string.',
},
{
type: 'array',
description: 'An array of browserslist query strings.',
},
{
type: 'object',
description: 'A browserslist targets object.',
},
],
description: 'The target environments.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce the use of built-in methods instead of unnecessary polyfills.',
recommended: 'unopinionated',
},
schema,
// eslint-disable-next-line eslint-plugin/require-meta-default-options
defaultOptions: [],
messages,
},
};
export default config;

Some files were not shown because too many files have changed in this diff Show More