eslint-plugin-sonarjs
Version:
297 lines (296 loc) • 13.6 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/S5845/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 ast_js_1 = require("../helpers/ast.js");
const assertions_js_1 = require("../helpers/assertions.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const parser_services_js_1 = require("../helpers/parser-services.js");
const type_js_1 = require("../helpers/type.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
incompatibleStaticTypes: 'Review this equality assertion: the compared expressions have incompatible static types ("{{actual}}" and "{{expected}}").',
};
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages }),
/*
* High-level idea:
* - only run when the parser provides type information;
* - only analyse strict and deep equality assertions;
* - reduce both assertion operands to broad primitive families instead of comparing exact
* TypeScript types, because the rule is meant to highlight incompatible static typings on
* equality checks rather than to model all runtime equality behavior;
* - stay conservative whenever the type is imprecise (`any`, `unknown`, type parameters, etc.),
* or when a mutable identifier may have been reassigned to a different family.
*/
create(context) {
const services = context.sourceCode.parserServices;
if (!(0, parser_services_js_1.isRequiredParserServices)(services)) {
return {};
}
const checker = services.program.getTypeChecker();
const monkeyPatchedReceivers = new Set();
// Expressions that are not rooted in a mutable local binding can rely directly on the
// expression type at the assertion site:
// expect(getCount()).toBe('1');
// expect(Number(title)).toBe('1');
//
// Bare identifiers and member expressions rooted in an identifier need one extra guard.
// `getTypeAtLocation(value)` can stay narrow even after an imprecise write, so using the
// current type alone would introduce false positives:
// declare function readAny(): any;
// let value: number = 1;
// value = readAny();
// expect(value).toBe('1'); // keep silent: runtime value may be string
// let user: { id: number } = { id: 1 };
// user = readAny();
// expect(user.id).toBe('1'); // keep silent for the same reason
//
// We therefore trust the current type only when the root identifier of the expression has only
// been written with values that are themselves classifiable to primitive families. That still
// lets TypeScript's flow narrowing do the useful work for precise writes:
// let value: number | string;
// if (Math.random() > 0.5) value = 'ready'; else value = 1;
// if (typeof value === 'string') expect(value).toBe(true); // report
//
// This is intentionally coarser than full reaching-definitions. If any write is `any`,
// `unknown`, a type parameter, indexed access, etc., we bail out conservatively.
function checkAssertion(node) {
const assertion = (0, assertions_js_1.extractTestAssertion)(context, node);
if (!isRelevantAssertion(assertion)) {
return;
}
const actualType = checker.getBaseTypeOfLiteralType((0, type_js_1.getTypeFromTreeNode)(assertion.actual, services));
const expectedType = checker.getBaseTypeOfLiteralType((0, type_js_1.getTypeFromTreeNode)(assertion.expected, services));
if (!hasStablePrimitiveType(assertion.actual, actualType) ||
!hasStablePrimitiveType(assertion.expected, expectedType)) {
return;
}
const incompatibility = getIncompatibility(actualType, expectedType, checker);
if (incompatibility) {
context.report({
node: assertion.reportNode,
messageId: 'incompatibleStaticTypes',
data: incompatibility,
});
}
}
function hasStablePrimitiveType(node, nodeType) {
const monkeyPatchedReceiver = getMonkeyPatchedReceiver(node);
if (monkeyPatchedReceiver) {
return false;
}
const allowedCategories = getPrimitiveCategories(nodeType);
if (!allowedCategories) {
return false;
}
const rootIdentifier = getRootIdentifier(node);
if (!rootIdentifier) {
return true;
}
const variable = (0, ast_js_1.getVariableFromName)(context, rootIdentifier.name, node);
if (!variable) {
return true;
}
return variable.references
.filter(ref => ref.isWrite())
.every(ref => isPreciselyTypedWrite(ref.writeExpr, checker, services));
}
function getMonkeyPatchedReceiver(node) {
if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') {
return null;
}
const rootIdentifier = getRootIdentifier(node.callee.object);
if (!rootIdentifier) {
return null;
}
const variable = (0, ast_js_1.getVariableFromName)(context, rootIdentifier.name, node);
return variable && monkeyPatchedReceivers.has(variable) ? variable : null;
}
function collectMonkeyPatchedReceiver(node) {
if (node.type !== 'AssignmentExpression' ||
node.operator !== '=' ||
node.left.type !== 'MemberExpression' ||
!isFunctionLikeExpression(node.right)) {
return;
}
const rootIdentifier = getRootIdentifier(node.left.object);
if (!rootIdentifier) {
return;
}
const variable = (0, ast_js_1.getVariableFromName)(context, rootIdentifier.name, node);
if (variable) {
monkeyPatchedReceivers.add(variable);
}
}
return {
AssignmentExpression(node) {
collectMonkeyPatchedReceiver(node);
},
CallExpression(node) {
checkAssertion(node);
},
};
},
};
// Strict and deep equality assertions are in scope. Loose equality depends on coercion, while
// deep equality is relevant here because the rule only reasons about primitive type families.
function isRelevantAssertion(assertion) {
return (assertion?.kind === 'comparison' &&
(assertion.comparison === 'strict' || assertion.comparison === 'deep'));
}
// The rule reports only when the two operands have no plausible primitive-family overlap.
// If they share a family, or either side falls into a conservative family, we skip reporting.
function getIncompatibility(actualType, expectedType, checker) {
const actualCategories = getPrimitiveCategories(actualType);
const expectedCategories = getPrimitiveCategories(expectedType);
if (!actualCategories || !expectedCategories) {
return null;
}
for (const actualCategory of actualCategories) {
for (const expectedCategory of expectedCategories) {
if (actualCategory === expectedCategory ||
isConservativeCategory(actualCategory) ||
isConservativeCategory(expectedCategory)) {
return null;
}
}
}
return {
actual: checker.typeToString(actualType),
expected: checker.typeToString(expectedType),
};
}
// Objects, null, and undefined are kept conservative because structural typing and JS runtime
// behavior make "always incompatible" claims too risky for this rule.
function isConservativeCategory(category) {
return category === 'object' || category === 'null' || category === 'undefined';
}
// Normalise scalar and union types to a flat list so the rest of the logic can treat both cases
// uniformly.
function getUnionMembers(type) {
return type.isUnion() ? type.types : [type];
}
// Collapse a TypeScript type into primitive families. If any union member is too imprecise to
// classify, return null so callers can stay conservative.
function getPrimitiveCategories(type) {
const categories = getUnionMembers(type).map(getPrimitiveCategory);
return categories.every(isPrimitiveCategory) ? categories : null;
}
// Translate the detailed TypeScript type system into the coarse families this rule reasons about.
// Anything outside those obvious buckets is treated as indeterminate.
function getPrimitiveCategory(type) {
const indeterminateFlags = typescript_1.default.TypeFlags.Any |
typescript_1.default.TypeFlags.Unknown |
typescript_1.default.TypeFlags.TypeParameter |
typescript_1.default.TypeFlags.IndexedAccess;
if ((type.flags & indeterminateFlags) !== 0) {
return null;
}
if ((type.flags & typescript_1.default.TypeFlags.StringLike) !== 0) {
return 'string';
}
if ((type.flags & typescript_1.default.TypeFlags.NumberLike) !== 0) {
return 'number';
}
if ((type.flags & typescript_1.default.TypeFlags.BooleanLike) !== 0) {
return 'boolean';
}
if ((type.flags & typescript_1.default.TypeFlags.BigIntLike) !== 0) {
return 'bigint';
}
if ((type.flags & typescript_1.default.TypeFlags.ESSymbolLike) !== 0) {
return 'symbol';
}
if ((type.flags & typescript_1.default.TypeFlags.Null) !== 0) {
return 'null';
}
if ((type.flags & (typescript_1.default.TypeFlags.Undefined | typescript_1.default.TypeFlags.Void)) !== 0) {
return 'undefined';
}
if ((type.flags & typescript_1.default.TypeFlags.Object) !== 0) {
return 'object';
}
return null;
}
// Narrow the mapped category list back to `PrimitiveCategory[]` once null has been ruled out.
function isPrimitiveCategory(category) {
return category !== null;
}
// A write is trustworthy when TypeScript can still classify the assigned value into primitive
// families at the write site. If not, the identifier's current type is not reliable enough for
// this rule:
// value = readAny(); // bail out
// value = values[index]; // bail out when the indexed access is imprecise
//
// value = 'ready'; // precise
// value = 1; // precise
function isPreciselyTypedWrite(writeExpr, checker, services) {
if (!writeExpr) {
return false;
}
return (getPrimitiveCategories(checker.getBaseTypeOfLiteralType((0, type_js_1.getTypeFromTreeNode)(writeExpr, services))) !== null);
}
function getRootIdentifier(node) {
let current = node;
while (current.type === 'ChainExpression' || current.type === 'MemberExpression') {
current = current.type === 'ChainExpression' ? current.expression : current.object;
}
return current.type === 'Identifier' ? current : null;
}
function isFunctionLikeExpression(node) {
return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
}