eslint-plugin-sonarjs
Version:
526 lines (525 loc) • 21.2 kB
JavaScript
"use strict";
/*
* SonarQube JavaScript Plugin
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* You can redistribute and/or modify this program under the terms of
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S1848/javascript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const typescript_1 = __importDefault(require("typescript"));
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const module_js_1 = require("../helpers/module.js");
const helpers_js_1 = require("./false-positives/helpers.js");
const trusted_dom_bindings_js_1 = require("./false-positives/trusted-dom-bindings.js");
const ast_js_1 = require("../helpers/ast.js");
const type_js_1 = require("../helpers/type.js");
const parser_services_js_1 = require("../helpers/parser-services.js");
const meta = __importStar(require("./generated-meta.js"));
/**
* Paper.js constructors that auto-register with the active project layer.
* Value objects (Point, Size, Color, Matrix, etc.) are intentionally excluded.
*/
const PAPER_ITEM_CONSTRUCTORS = new Set([
'Layer',
'Group',
'Raster',
'Path',
'CompoundPath',
'PointText',
'SymbolItem',
'Project',
'Path.Line',
'Path.Circle',
'Path.Rectangle',
'Path.Ellipse',
'Path.Arc',
'Path.RegularPolygon',
'Path.Star',
'Shape.Circle',
'Shape.Rectangle',
'Shape.Ellipse',
]);
/** DOM selection method names commonly used for element selection */
const DOM_SELECTION_METHODS = [
'querySelector',
'querySelectorAll',
'getElementById',
'getElementsByClassName',
'getElementsByTagName',
'getElementsByName',
];
/** jQuery/$ function names */
const JQUERY_IDENTIFIERS = ['$', 'jQuery'];
const STATEMENT_ANCESTOR_OFFSET = 1;
const CONTAINER_ANCESTOR_OFFSET = 2;
const CONSTRUCTS_PACKAGE_PATH = /[\\/]node_modules[\\/]constructs[\\/]/;
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, {
messages: {
removeInstantiationOf: 'Either remove this useless object instantiation of "{{constructor}}" or use it.',
removeInstantiation: 'Either remove this useless object instantiation or use it.',
},
}),
create(context) {
const sourceCode = context.sourceCode;
return {
'ExpressionStatement > NewExpression': (node) => {
if (isTryable(node, context)) {
return;
}
// Skip constructors receiving DOM elements - indicates DOM attachment side effect
if (hasDomSelectionArgument(node, context)) {
return;
}
if (isRegExpValidation(node, context)) {
return;
}
const services = context.sourceCode.parserServices;
if ((0, parser_services_js_1.isRequiredParserServices)(services) && isCdkConstructCreation(node, services)) {
return;
}
const { callee } = node;
if (callee.type === 'Identifier' || callee.type === 'MemberExpression') {
const calleeText = sourceCode.getText(callee);
if (isException(context, callee, calleeText)) {
return;
}
const reportLocation = {
start: node.loc.start,
end: callee.loc.end,
};
reportIssue(reportLocation, `${calleeText}`, 'removeInstantiationOf', context);
}
else {
const newToken = sourceCode.getFirstToken(node);
reportIssue(newToken.loc, '', 'removeInstantiation', context);
}
},
};
},
};
/**
* Checks whether a construction registers a construct in its construct-tree parent.
*
* @param node discarded constructor invocation
* @param services TypeScript parser services
* @return whether the constructed value and its first argument derive from `constructs.Construct`
*/
function isCdkConstructCreation(node, services) {
const parentArgument = node.arguments[0];
if (!parentArgument) {
return false;
}
const signature = (0, type_js_1.getSignatureFromCallee)(node, services);
if (!signature) {
return false;
}
const parentType = getConstructParentType(parentArgument, services);
if (!parentType) {
return false;
}
const checker = services.program.getTypeChecker();
return (derivesFromConstruct(signature.getReturnType(), checker) &&
derivesFromConstruct(parentType, checker, true));
}
function getConstructParentType(argument, services) {
if (argument.type !== 'SpreadElement') {
return (0, type_js_1.getTypeFromTreeNode)(argument, services);
}
const checker = services.program.getTypeChecker();
const spreadType = (0, type_js_1.getTypeFromTreeNode)(argument.argument, services);
if (!checker.isTupleType(spreadType)) {
return undefined;
}
return checker.getTypeArguments(spreadType)[0];
}
/**
* Checks whether a type directly or transitively derives from `constructs.Construct`.
*
* @param type TypeScript type to inspect
* @param allowConstructInterface whether to also accept the canonical IConstruct interface
* @return whether the type inherits from the canonical Construct declaration
*/
function derivesFromConstruct(type, checker, allowConstructInterface = false) {
const typesToVisit = [type];
const visitedTypes = new Set();
for (const currentType of typesToVisit) {
if (visitedTypes.has(currentType)) {
continue;
}
visitedTypes.add(currentType);
if (currentType.isUnion()) {
if (currentType.types.every(part => derivesFromConstruct(part, checker, allowConstructInterface))) {
return true;
}
continue;
}
if (currentType.isIntersection()) {
typesToVisit.push(...currentType.types);
continue;
}
if (isConstructType(currentType, allowConstructInterface)) {
return true;
}
enqueueTypeParameterConstraint(currentType, typesToVisit);
typesToVisit.push(...getBaseTypes(currentType, checker));
}
return false;
}
function isConstructType(type, allowConstructInterface) {
const symbol = type.getSymbol();
return ((symbol?.name === 'Construct' || (allowConstructInterface && symbol?.name === 'IConstruct')) &&
symbol.declarations?.some(declaration => CONSTRUCTS_PACKAGE_PATH.test(declaration.getSourceFile().fileName)) === true);
}
function enqueueTypeParameterConstraint(type, typesToVisit) {
if (!type.isTypeParameter()) {
return;
}
const constraint = type.getConstraint();
if (constraint) {
typesToVisit.push(constraint);
}
}
function getBaseTypes(type, checker) {
if (!(type.flags & typescript_1.default.TypeFlags.Object)) {
return [];
}
const objectType = type;
const baseTypeOwner = objectType.objectFlags & typescript_1.default.ObjectFlags.Reference
? type.target
: objectType;
if (!(baseTypeOwner.objectFlags & typescript_1.default.ObjectFlags.ClassOrInterface)) {
return [];
}
return checker.getBaseTypes(baseTypeOwner) ?? [];
}
function isTryable(node, context) {
const ancestors = context.sourceCode.getAncestors(node);
let parent = undefined;
let child = node;
while ((parent = ancestors.pop()) !== undefined) {
if (parent.type === 'TryStatement' && parent.block === child) {
return true;
}
child = parent;
}
return false;
}
/**
* Suppresses false positives for RegExp validation patterns where the constructor is used only
* to validate an input before returning it.
*
* For example, `new RegExp(pattern)` is intentional in:
*
* ```js
* function validatePattern(pattern) {
* new RegExp(pattern);
* return pattern;
* }
* ```
*/
function isRegExpValidation(node, context) {
if (!isBuiltInRegExpConstructor(node, context)) {
return false;
}
const nextStatement = getNextSiblingStatement(node, context);
if (nextStatement?.type !== 'ReturnStatement') {
return false;
}
const { argument: returnArgument } = nextStatement;
if (!returnArgument) {
return false;
}
return node.arguments.some(argument => (0, ast_js_1.isIdentifier)(argument) && containsReturnedIdentifier(returnArgument, argument));
}
function isBuiltInRegExpConstructor(node, context) {
const { callee } = node;
const scope = context.sourceCode.getScope(node);
if ((0, ast_js_1.isIdentifier)(callee, 'RegExp')) {
return !(0, ast_js_1.getVariableFromScope)(scope, callee.name)?.defs.length;
}
return (callee.type === 'MemberExpression' &&
!callee.computed &&
(0, ast_js_1.isIdentifier)(callee.object, 'globalThis') &&
!(0, ast_js_1.getVariableFromScope)(scope, callee.object.name)?.defs.length &&
(0, ast_js_1.isIdentifier)(callee.property, 'RegExp'));
}
function getNextSiblingStatement(node, context) {
const ancestors = context.sourceCode.getAncestors(node);
const statement = ancestors.at(-STATEMENT_ANCESTOR_OFFSET);
const container = ancestors.at(-CONTAINER_ANCESTOR_OFFSET);
if (statement?.type !== 'ExpressionStatement') {
return undefined;
}
const body = getStatementList(container);
if (!body) {
return undefined;
}
const statementIndex = body.indexOf(statement);
return statementIndex === -1 ? undefined : body[statementIndex + 1];
}
function getStatementList(node) {
if (node?.type === 'BlockStatement') {
return node.body;
}
if (node?.type === 'SwitchCase') {
return node.consequent;
}
return undefined;
}
function containsReturnedIdentifier(node, identifier) {
if ((0, ast_js_1.isIdentifier)(node, identifier.name)) {
return true;
}
if (node.type === 'ObjectExpression') {
return node.properties.some(property => {
if (property.type === 'Property') {
return containsReturnedIdentifier(property.value, identifier);
}
return containsReturnedIdentifier(property.argument, identifier);
});
}
if (node.type === 'ArrayExpression') {
return node.elements.some(element => {
if (!element) {
return false;
}
if (element.type === 'SpreadElement') {
return containsReturnedIdentifier(element.argument, identifier);
}
return containsReturnedIdentifier(element, identifier);
});
}
if (node.type === 'ConditionalExpression') {
return (containsReturnedIdentifier(node.consequent, identifier) ||
containsReturnedIdentifier(node.alternate, identifier));
}
const nodeType = node.type;
if (nodeType === 'TSAsExpression' || nodeType === 'TSTypeAssertion') {
return containsReturnedIdentifier(node.expression, identifier);
}
return false;
}
function reportIssue(loc, objectText, messageId, context) {
context.report({
messageId,
data: {
constructor: objectText,
},
loc,
});
}
/**
* These exceptions are based on community requests and Peach.
* Two separate checks: one FQN-backed, one for explicit global/member-expression forms.
*/
function isException(context, node, name) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, node);
if (fqn !== null && isFqnException(fqn)) {
return true;
}
return isGlobalFormException(context, node, name);
}
/** Checks import-resolved FQN against the curated list of known side-effect constructors. */
function isFqnException(fqn) {
const exactExceptions = ['vue', '@ag-grid-community.core.Grid'];
const startsWithExceptions = ['aws-cdk-lib', 'cdk8s', '@pulumi', '@cdktf', 'obsidian'];
return (exactExceptions.includes(fqn) || startsWithExceptions.some(prefix => fqn.startsWith(prefix)));
}
/**
* Checks explicitly approved global/member-expression forms.
* For window.ClipboardJS, paper.*, and paperScope.*, verifies the root identifier
* is not a local variable or import (i.e. it is a true global).
* For paper.* and paperScope.*, only suppresses known scene-graph constructors.
*/
function isGlobalFormException(context, callee, calleeText) {
if (calleeText === 'Notification') {
return true;
}
if (calleeText === 'window.ClipboardJS') {
const variable = (0, ast_js_1.getVariableFromName)(context, 'window', callee);
if (variable == null || variable.defs.length === 0) {
return true;
}
}
for (const rootName of ['paper', 'paperScope']) {
const prefix = `${rootName}.`;
if (!calleeText.startsWith(prefix)) {
continue;
}
const itemName = calleeText.slice(prefix.length);
if (!PAPER_ITEM_CONSTRUCTORS.has(itemName)) {
continue;
}
const variable = (0, ast_js_1.getVariableFromName)(context, rootName, callee);
// Suppress only when rootName is a true global (not locally declared)
if (variable == null || variable.defs.length === 0) {
return true;
}
}
return false;
}
/**
* Checks if any constructor argument contains a DOM selection call.
* Constructors receiving DOM elements typically attach to them on construction.
*/
function hasDomSelectionArgument(node, context) {
const scope = context.sourceCode.getScope(node);
return node.arguments.some(arg => arg.type !== 'SpreadElement' && containsDomSelection(arg, scope));
}
/**
* Recursively checks if a node contains a DOM selection call.
* Also resolves variables to check if they were initialized from DOM selection.
*/
function containsDomSelection(node, scope, visitedVariables = new Set()) {
node = (0, helpers_js_1.unwrapTypeAssertion)(node);
if (node.type === 'CallExpression') {
return (isDomSelectionCall(node, scope) ||
(node.callee.type === 'MemberExpression' &&
containsDomSelection(node.callee.object, scope, visitedVariables)));
}
if (node.type === 'Identifier') {
// Resolve variable to check its initializer
return isVariableFromDomSelection(node, scope, visitedVariables);
}
if (node.type === 'MemberExpression') {
// Check if object is a DOM selection call (e.g., document.querySelector(...).dataset)
return containsDomSelection(node.object, scope, visitedVariables);
}
if (node.type === 'ObjectExpression') {
// Check properties for DOM selection calls (e.g., {element: $('foo')})
return node.properties.some(prop => {
if (prop.type === 'Property') {
return containsDomSelection(prop.value, scope, visitedVariables);
}
return false;
});
}
if (node.type === 'ArrayExpression') {
// Check array elements for DOM selection calls (e.g., [$('foo'), $('bar')])
return node.elements.some(elem => elem !== null && containsDomSelection(elem, scope, visitedVariables));
}
if (node.type === 'ConditionalExpression') {
return (containsDomSelection(node.consequent, scope, visitedVariables) ||
containsDomSelection(node.alternate, scope, visitedVariables));
}
return false;
}
/**
* Checks if a variable was initialized from a DOM selection call.
*/
function isVariableFromDomSelection(node, scope, visitedVariables) {
const variable = (0, ast_js_1.getVariableFromScope)(scope, node.name);
if (!variable || visitedVariables.has(variable)) {
return false;
}
visitedVariables.add(variable);
// A read from a nested function can happen before or after a write in the
// enclosing function, so source order alone cannot prove a reassignable
// variable's value. A single-write binding (const, or a let/var written only
// once) has only one possible value once its write executes; requiring that
// write to also precede the read lexically (below) guarantees any later
// closure execution observes it, so the scope check only needs to require
// that the read stays within the write's function rather than matching it
// exactly.
const singleWrite = (0, helpers_js_1.hasSingleWrite)(variable);
const readFunctionScope = (0, helpers_js_1.getNearestFunctionScope)(scope);
const declFunctionScope = (0, helpers_js_1.getNearestFunctionScope)(variable.scope);
if (singleWrite
? !(0, helpers_js_1.isDescendantOrSameScope)(readFunctionScope, declFunctionScope)
: readFunctionScope !== declFunctionScope) {
return false;
}
const writeReference = (0, helpers_js_1.getUniqueWriteReferenceBefore)(variable, node);
if (!writeReference?.writeExpr) {
return false;
}
const writeFunctionScope = (0, helpers_js_1.getNearestFunctionScope)(writeReference.from);
if (singleWrite
? !(0, helpers_js_1.isDescendantOrSameScope)(readFunctionScope, writeFunctionScope)
: writeFunctionScope !== readFunctionScope) {
return false;
}
// Follow a local initializer only. This permits DOM-derived member/call chains
// without performing interprocedural analysis. The write expression must be
// resolved in the scope where the write happens, not where the read happens,
// because a single-write binding may be read from a nested (possibly shadowing)
// function scope.
return containsDomSelection((0, helpers_js_1.unwrapTypeAssertion)(writeReference.writeExpr), writeReference.from, visitedVariables);
}
/**
* Checks if a call expression is a DOM selection call.
* Matches: document.querySelector, document.getElementById, $(), jQuery(), this.$(),
* and any call to DOM selection methods on any object (e.g., myDocument.querySelector)
*/
function isDomSelectionCall(node, scope) {
const { callee } = node;
// Check for $() or jQuery()
if ((0, ast_js_1.isIdentifier)(callee, ...JQUERY_IDENTIFIERS)) {
return (0, trusted_dom_bindings_js_1.isTrustedJQueryBinding)(scope, callee.name);
}
// Check for *.querySelector, *.getElementById, etc. on any object
// This covers document.querySelector, myDocument.querySelector, this.document.querySelector, etc.
if (callee.type === 'MemberExpression' &&
(0, ast_js_1.isIdentifier)(callee.property, ...DOM_SELECTION_METHODS)) {
if ((0, ast_js_1.isIdentifier)(callee.object, 'document') && !(0, trusted_dom_bindings_js_1.isTrustedDocumentBinding)(scope)) {
return false;
}
return true;
}
// Check for this.$() - common in Backbone/Marionette views
if (callee.type === 'MemberExpression' &&
callee.object.type === 'ThisExpression' &&
(0, ast_js_1.isIdentifier)(callee.property, ...JQUERY_IDENTIFIERS)) {
return true;
}
return false;
}