eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
396 lines (334 loc) • 10.6 kB
JavaScript
import globals from 'globals';
import {functionTypes, isMemberExpression, isMethodCall} from './ast/index.js';
const MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE = 'externally-scoped-variable';
const MESSAGE_ID_SUPER = 'super';
const MESSAGE_ID_THIS_EXPRESSION = 'this-expression';
const functionContextReferenceMessageIds = new Map([
['Super', MESSAGE_ID_SUPER],
['ThisExpression', MESSAGE_ID_THIS_EXPRESSION],
]);
const messages = {
[MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE]: 'Variable {{name}} not defined in scope of isolated function. Function is isolated because: {{reason}}.',
[MESSAGE_ID_SUPER]: 'Unexpected `super` in isolated function. Function is isolated because: {{reason}}.',
[MESSAGE_ID_THIS_EXPRESSION]: 'Unexpected `this` in isolated function. Function is isolated because: {{reason}}.',
};
/** @type {{functions: string[], selectors: string[], comments: string[], overrideGlobals?: import('eslint').Linter.Globals}} */
const defaultOptions = {
functions: ['makeSynchronous', 'workerize'],
selectors: [],
comments: ['@isolated'],
overrideGlobals: {},
};
const scriptingObjects = ['browser', 'chrome'];
const getObjectPropertyName = node => {
if (node.computed && node.key.type !== 'Literal') {
return;
}
if (node.key.type === 'Identifier') {
return node.key.name;
}
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
return node.key.value;
}
};
const getDefaultArgumentCallReason = node => {
if (
node.parent.type !== 'CallExpression'
|| node.parent.arguments[0] !== node
) {
return;
}
if (isMethodCall(node.parent, {object: 'browser', method: 'execute'})) {
return 'callee of method named "browser.execute"';
}
if (isMethodCall(node.parent, {object: 'page', method: 'evaluate'})) {
return 'callee of method named "page.evaluate"';
}
};
const getScriptingExecuteScriptObjectName = node => {
if (
!isMethodCall(node, {method: 'executeScript'})
|| !isMemberExpression(node.callee.object, {
objects: scriptingObjects,
property: 'scripting',
})
) {
return;
}
return node.callee.object.object.name;
};
const getExecuteScriptPropertyReason = node => {
if (
node.parent.type !== 'Property'
|| node.parent.kind !== 'init'
|| getObjectPropertyName(node.parent) !== 'func'
|| node.parent.parent.type !== 'ObjectExpression'
|| node.parent.parent.parent.type !== 'CallExpression'
|| node.parent.parent.parent.arguments[0] !== node.parent.parent
) {
return;
}
const scriptingObjectName = getScriptingExecuteScriptObjectName(node.parent.parent.parent);
if (!scriptingObjectName) {
return;
}
return `property "func" passed to "${scriptingObjectName}.scripting.executeScript"`;
};
/** @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 configuredGlobals = {
...(globals[`es${context.languageOptions.ecmaVersion}`] ?? globals.builtins),
...context.languageOptions.globals,
};
const checked = new WeakSet();
const getAllowedGlobalValue = reference => {
const {identifier, resolved} = reference;
const {name} = identifier;
if (Object.hasOwn(options.overrideGlobals, name)) {
const overrideGlobalValue = options.overrideGlobals[name];
if (overrideGlobalValue === 'off') {
return 'off';
}
if (resolved && !sourceCode.isGlobalReference(identifier)) {
return;
}
return overrideGlobalValue;
}
if (
Object.hasOwn(configuredGlobals, name)
&& configuredGlobals[name] === 'off'
) {
return 'off';
}
if (!sourceCode.isGlobalReference(identifier)) {
return;
}
if (
!Object.hasOwn(configuredGlobals, name)
&& !resolved?.eslintExplicitGlobal
) {
return;
}
return resolved?.writeable ? 'writable' : 'readonly';
};
function * getFunctionContextProblems(node, reason, root = node) {
const messageId = functionContextReferenceMessageIds.get(node.type);
if (messageId) {
yield {
node,
messageId,
data: {reason},
};
return;
}
if (node !== root) {
if (functionTypes.includes(node.type) && node.type !== 'ArrowFunctionExpression') {
return;
}
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
if (node.superClass) {
yield * getFunctionContextProblems(node.superClass, reason, root);
}
for (const classElement of node.body.body) {
if (classElement.computed) {
yield * getFunctionContextProblems(classElement.key, reason, root);
}
}
return;
}
}
for (const key of sourceCode.visitorKeys[node.type] ?? []) {
const value = node[key];
if (Array.isArray(value)) {
for (const childNode of value) {
if (childNode?.type) {
yield * getFunctionContextProblems(childNode, reason, root);
}
}
continue;
}
if (value?.type) {
yield * getFunctionContextProblems(value, reason, root);
}
}
}
/** @param {import('estree').Node} node */
const reportIsolatedFunctionProblems = (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;
}
const allowedGlobalValue = getAllowedGlobalValue(reference);
let problemReason = reason;
if (allowedGlobalValue !== undefined && allowedGlobalValue !== 'off') {
if (reference.isReadOnly()) {
continue;
}
const isGlobalWritable = [true, 'writable', 'writeable'].includes(allowedGlobalValue);
if (isGlobalWritable) {
continue;
}
problemReason += ' (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: problemReason},
});
}
for (const problem of getFunctionContextProblems(node, reason)) {
context.report(problem);
}
};
const isComment = token => token?.type === 'Block' || token?.type === 'Line';
const canCommentApplyToParent = (node, parent) =>
parent && (
[
'VariableDeclarator',
'VariableDeclaration',
'ExportNamedDeclaration',
'ExportDefaultDeclaration',
].includes(parent.type)
|| (
(
parent.type === 'Property'
|| parent.type === 'MethodDefinition'
)
&& parent.value === node
)
);
/**
Find a comment on this node or its parent, in cases where the node passed is part of a variable, export, property, or method declaration.
@param {import('estree').Node} node
*/
const findComment = node => {
let previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
let commentableNode = node;
while (
!isComment(previousToken)
&& canCommentApplyToParent(commentableNode, commentableNode.parent)
) {
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)}`;
}
return getDefaultArgumentCallReason(node) ?? getExecuteScriptPropertyReason(node);
};
context.onExit(
functionTypes,
node => {
const reason = reasonForBeingIsolatedFunction(node);
if (!reason) {
return;
}
return reportIsolatedFunctionProblems(node, reason);
},
);
for (const selector of options.selectors) {
context.onExit(
selector,
node => {
const reason = `matches selector ${JSON.stringify(selector)}`;
return reportIsolatedFunctionProblems(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,
languages: [
'js/js',
],
},
};