eslint-plugin-sonarjs
Version:
334 lines (333 loc) • 13.7 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/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);
}