UNPKG

eslint-plugin-sonarjs

Version:
526 lines (525 loc) • 21.2 kB
"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; }