UNPKG

eslint-plugin-sonarjs

Version:
334 lines (333 loc) • 13.7 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/S9163/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; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.rule = void 0; const ancestor_js_1 = require("../helpers/ancestor.js"); const ast_js_1 = require("../helpers/ast.js"); const generate_meta_js_1 = require("../helpers/generate-meta.js"); const module_js_1 = require("../helpers/module.js"); const vue_js_1 = require("../helpers/vue.js"); const meta = __importStar(require("./generated-meta.js")); const messages = { unconditionalMutation: "Don't unconditionally mutate reactive state in this hook; this can trigger an infinite update loop.", }; const VUE_ON_UPDATED_FQN = 'vue.onUpdated'; const VUE_DEFINE_COMPONENT_FQN = 'vue.defineComponent'; const UPDATED_HOOK_NAME = 'updated'; const REF_VALUE_PROPERTY = 'value'; const FUNCTION_BOUNDARIES = new Set([ 'FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration', ]); const GUARD_ANCESTOR_TYPES = new Set(['IfStatement', 'ConditionalExpression', 'LogicalExpression']); const TERMINATING_STATEMENT_TYPES = new Set([ 'ReturnStatement', 'ThrowStatement', 'BreakStatement', 'ContinueStatement', ]); exports.rule = { meta: (0, generate_meta_js_1.generateMeta)(meta, { messages }), create(context) { const hookFunctions = new Set(); function registerHook(candidate) { const hookFunction = resolveHookFunction(context, candidate); if (hookFunction) { hookFunctions.add(hookFunction); } } return { CallExpression(node) { if ((0, module_js_1.getFullyQualifiedName)(context, node) === VUE_ON_UPDATED_FQN) { registerHook(node.arguments[0]); } }, Property(node) { const isUpdatedKey = (0, ast_js_1.isIdentifier)(node.key, UPDATED_HOOK_NAME) || ((0, ast_js_1.isStringLiteral)(node.key) && node.key.value === UPDATED_HOOK_NAME); const parent = (0, ancestor_js_1.getNodeParent)(node); if (isUpdatedKey && parent?.type === 'ObjectExpression' && isVueComponentOptionsObject(context, parent)) { registerHook(node.value); } }, 'Program:exit'() { const visitorKeys = context.sourceCode.visitorKeys; for (const hookFunction of hookFunctions) { for (const mutation of collectMutations(hookFunction.body, visitorKeys)) { checkMutation(context, mutation, visitorKeys); } } }, }; }, }; /** * Whether `node` is recognizable as a Vue component options object, so an unrelated object that * merely happens to have an `updated` key (a cache, a "last updated" flag, an event handler map, * ...) isn't treated as a lifecycle hook just because of the key's name. Recognized shapes: the * sole argument to `defineComponent(...)`, or a module's default export (`export default {...}`; * `export default defineComponent({...})` is already covered by the first branch, since that's the * object literal's direct parent). Legacy Vue 2 patterns such as `Vue.extend({...})` are * intentionally not covered - never guess when a narrower, well-understood signal is available. */ function isVueComponentOptionsObject(context, node) { const parent = (0, ancestor_js_1.getNodeParent)(node); if (!parent) { return false; } if (parent.type === 'ExportDefaultDeclaration') { return true; } return (parent.type === 'CallExpression' && (0, module_js_1.getFullyQualifiedName)(context, parent) === VUE_DEFINE_COMPONENT_FQN); } /** * Resolves the function backing a hook registration: an inline arrow/function literal, or a named * function reference (function declaration, or a variable initialized with a function/arrow * expression) followed through scope. Anything else (member expressions, calls, ...) is left * unresolved rather than guessed at. */ function resolveHookFunction(context, node) { if (!node) { return undefined; } const resolved = (0, ast_js_1.resolveFunction)(context, node); if (resolved) { return resolved; } if (node.type === 'Identifier') { const variable = (0, ast_js_1.getVariableFromName)(context, node.name, node); const definition = variable?.defs[0]; if (definition?.type === 'Variable') { const init = definition.node.init; if (init?.type === 'FunctionExpression' || init?.type === 'ArrowFunctionExpression') { return init; } } } return undefined; } /** * Collects every assignment/update expression directly inside `node`, stopping recursion at * nested function boundaries so mutations inside a function merely *called from* the hook (or any * other closure written inside it) are never collected in the first place. */ function collectMutations(node, visitorKeys) { const mutations = []; visit(node); return mutations; function visit(current) { if (current.type === 'AssignmentExpression' || current.type === 'UpdateExpression') { mutations.push(current); } if (FUNCTION_BOUNDARIES.has(current.type)) { return; } for (const child of (0, ancestor_js_1.childrenOf)(current, visitorKeys)) { visit(child); } } } function checkMutation(context, mutation, visitorKeys) { const memberExpr = mutation.type === 'UpdateExpression' ? mutation.argument : mutation.left; const target = resolveMutationTarget(context, memberExpr); if (!target) { return; } if (isGuarded(mutation)) { return; } if (!hasRedFlagShape(mutation, target, visitorKeys)) { return; } context.report({ node: mutation, messageId: 'unconditionalMutation' }); } /** * Recognizes `<ref>.value` and `<reactive>.<prop>` mutation targets (single property level only: * `state.a.b = ...` is an intentional false negative, see cb.fixture.vue). */ function resolveMutationTarget(context, node) { if (node.type !== 'MemberExpression' || node.computed) { return undefined; } const { object, property } = node; if (object.type !== 'Identifier' || property.type !== 'Identifier') { return undefined; } const kind = (0, vue_js_1.getVueReactiveBindingKind)(context, object); if (kind === 'ref' && property.name === REF_VALUE_PROPERTY) { return { root: object, propertyName: REF_VALUE_PROPERTY }; } if (kind === 'reactive') { return { root: object, propertyName: property.name }; } return undefined; } /** * Signal 1: a mutation nested inside an `if`/ternary/logical guard, or reached only after an * early-return guard clause earlier in the same block, is never flagged - proving that a guard * actually converges is undecidable in general, so guard presence alone is a hard exemption. */ function isGuarded(mutation) { const node = mutation; if (hasGuardingAncestor(node)) { return true; } return isReachedThroughEarlyReturnGuard(node); } /** * Walks up the local ancestor chain looking for a guard whose conditionally-executed branch * actually contains the mutation. Ancestors are only exempting if the child leading to `node` sits * in a branch that doesn't always execute; e.g. an `IfStatement`'s `test`, a `ConditionalExpression`'s * `test`, or a `LogicalExpression`'s left operand always run regardless of the guard's outcome, so * they don't count as guarding positions. */ function hasGuardingAncestor(node) { let childBelow = node; for (const ancestor of (0, ancestor_js_1.localAncestorsChain)(node)) { if (GUARD_ANCESTOR_TYPES.has(ancestor.type) && !isAlwaysExecutedChild(ancestor, childBelow)) { return true; } childBelow = ancestor; } return false; } function isAlwaysExecutedChild(ancestor, child) { if (ancestor.type === 'IfStatement' || ancestor.type === 'ConditionalExpression') { return ancestor.test === child; } if (ancestor.type === 'LogicalExpression') { return ancestor.left === child; } return false; } function isReachedThroughEarlyReturnGuard(node) { let childBelow = node; for (const ancestor of (0, ancestor_js_1.localAncestorsChain)(node)) { if (ancestor.type === 'BlockStatement') { const index = ancestor.body.indexOf(childBelow); if (index > 0 && ancestor.body.slice(0, index).some(isGuardClause)) { return true; } } childBelow = ancestor; } return false; } function isGuardClause(statement) { return (statement.type === 'IfStatement' && !statement.alternate && terminatesControlFlow(statement.consequent)); } function terminatesControlFlow(statement) { if (TERMINATING_STATEMENT_TYPES.has(statement.type)) { return true; } if (statement.type === 'BlockStatement' && statement.body.length > 0) { const lastStatement = statement.body.at(-1); return lastStatement ? terminatesControlFlow(lastStatement) : false; } return false; } /** * Signal 2: flags shapes that are guaranteed to differ across repeated invocations - `++`/`--`, a * compound assignment, a self-reference to the same binding being mutated, or a call to a known * non-deterministic builtin. A plain assignment of a literal or otherwise-stable value (`count.value * = 3`) converges after one call (Vue's ref/reactive setters bail out via Object.is) and is left * unflagged. A bare identity assignment (`count.value = count.value`, RHS is nothing but the same * target) is exactly as stable as a literal for the same reason, so it's exempted too; a * self-reference embedded in a larger expression (`count.value = count.value + 1`) is still caught * below, since that one *does* change the value. */ function hasRedFlagShape(mutation, target, visitorKeys) { if (mutation.type === 'UpdateExpression') { return true; } if (mutation.operator !== '=') { return true; } if (mutation.right.type === 'MemberExpression' && isSameTarget(mutation.right, target)) { return false; } return containsRedFlag(mutation.right, target, visitorKeys); } function containsRedFlag(node, target, visitorKeys) { if (node.type === 'UpdateExpression') { return true; } if (node.type === 'AssignmentExpression' && node.operator !== '=') { return true; } if (node.type === 'MemberExpression' && isSameTarget(node, target)) { return true; } if (node.type === 'CallExpression' && ((0, ast_js_1.isMemberExpression)(node.callee, 'Date', 'now') || (0, ast_js_1.isMemberExpression)(node.callee, 'Math', 'random') || (0, ast_js_1.isMemberExpression)(node.callee, 'performance', 'now'))) { return true; } if (FUNCTION_BOUNDARIES.has(node.type)) { return false; } return (0, ancestor_js_1.childrenOf)(node, visitorKeys).some(child => containsRedFlag(child, target, visitorKeys)); } function isSameTarget(memberExpr, target) { return (!memberExpr.computed && memberExpr.object.type === 'Identifier' && memberExpr.object.name === target.root.name && memberExpr.property.type === 'Identifier' && memberExpr.property.name === target.propertyName); }