eslint-plugin-sonarjs
Version:
411 lines (410 loc) • 15.3 kB
JavaScript
;
/*
* 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/
*/
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.hasSupportedAssertionLibrary = hasSupportedAssertionLibrary;
exports.hasSupportedTestFramework = hasSupportedTestFramework;
exports.isAssertion = isAssertion;
exports.isScriptCapableAssertion = isScriptCapableAssertion;
exports.isTypeLevelAssertion = isTypeLevelAssertion;
exports.isIncompleteShouldAccess = isIncompleteShouldAccess;
exports.isTSAssertion = isTSAssertion;
const typescript_1 = __importDefault(require("typescript"));
const Chai = __importStar(require("./chai.js"));
const Sinon = __importStar(require("./sinon.js"));
const Vitest = __importStar(require("./vitest.js"));
const Supertest = __importStar(require("./supertest.js"));
const Cypress = __importStar(require("./cypress.js"));
const ancestor_js_1 = require("./ancestor.js");
const module_js_1 = require("./module.js");
const module_ts_js_1 = require("./module-ts.js");
const ASSERTION_LIBRARIES = [
'chai',
'sinon',
'vitest',
'supertest',
'@playwright/test',
'assert',
'assert/strict',
'node:assert',
'node:assert/strict',
];
// runners that expose assertion APIs as globals (no import required).
const GLOBAL_ASSERTION_DEPENDENCIES = ['jasmine', 'jest', 'cypress', '@playwright/test'];
const SUPPORTED_TEST_FRAMEWORK_IMPORTS = [
'@jest/globals',
'@playwright/test',
'chai',
'cypress',
'jasmine',
'jest',
'mocha',
'node:test',
'sinon',
'supertest',
'vitest',
];
const SUPPORTED_TEST_FRAMEWORK_DEPENDENCIES = [
'@jest/globals',
'@playwright/test',
'chai',
'cypress',
'jasmine',
'jasmine-core',
'jasmine-node',
'jest',
'karma-jasmine',
'mocha',
'sinon',
'supertest',
'vitest',
];
// Known global `expect*(...)` entry points: the universal `expect`, rxjs marble
// testing's `expectObservable`/`expectSubscriptions`, and vitest's `expectTypeOf`.
// Matched by exact name (not an `expect`-prefix) so unrelated identifiers such as
// `expectation(...)` or `expected(...)` in production code are not treated as
// assertions.
const GLOBAL_EXPECT_NAMES = new Set([
'expect',
'expectObservable',
'expectSubscriptions',
'expectTypeOf',
]);
const CHAI_NON_TERMINAL_PROPERTY_NAMES = new Set([
'all',
'also',
'and',
'any',
'at',
'be',
'been',
'but',
'deep',
'does',
'have',
'has',
'is',
'itself',
'nested',
'not',
'of',
'ordered',
'own',
'same',
'still',
'that',
'to',
'which',
'with',
]);
const CHAI_TERMINAL_PROPERTY_NAMES = new Set([
'Arguments',
'NaN',
'arguments',
'empty',
'exist',
'extensible',
'false',
'finite',
'frozen',
'null',
'ok',
'sealed',
'true',
'undefined',
]);
/**
* Whether the linted file imports or the project depends on a supported
* assertion library / test runner. Rules use this to avoid raising issues in
* files that are not tests.
*/
function hasSupportedAssertionLibrary(context) {
return (0, module_js_1.importsOrDependsOnModule)(context, ASSERTION_LIBRARIES, GLOBAL_ASSERTION_DEPENDENCIES);
}
function hasSupportedTestFramework(context) {
return (0, module_js_1.importsOrDependsOnModule)(context, SUPPORTED_TEST_FRAMEWORK_IMPORTS, SUPPORTED_TEST_FRAMEWORK_DEPENDENCIES);
}
/**
* AST assertion detectors, classified by whether the assertion API can run
* without a test runner. This is the single source of truth for the split:
* {@link isAssertion} matches any detector, {@link isScriptCapableAssertion} only
* the script-capable ones. A new library is one classified entry here, so the two
* predicates can never drift apart.
*
* Script-capable — node `assert`, chai, sinon, supertest — are ordinary libraries
* usable in a plain `node file.js`. Runner-bound — vitest, cypress, global
* `expect*(...)` chains — only exist because a runner executes the file.
*
* Classification is by library, not syntax: a chai `expect(x).to.equal(y)` is also
* matched by the name-based global-`expect` detector (on the outer `.to.equal(...)`
* call), so the script-capable Chai detector (on the inner `chai.expect(...)` call)
* must be able to claim it — which it does, because `isScriptCapableAssertion`
* checks the script-capable detectors directly.
*/
const SCRIPT_CAPABLE_DETECTORS = [
Chai.isAssertion,
Sinon.isAssertion,
Supertest.isAssertion,
isFunctionCallFromNodeAssert,
];
const RUNNER_BOUND_DETECTORS = [
Vitest.isAssertion,
(_context, node) => Cypress.isAssertion(node),
(_context, node) => node.type === 'CallExpression' && isGlobalExpectExpressionJS(node),
];
const ASSERTION_DETECTORS = [
...SCRIPT_CAPABLE_DETECTORS,
...RUNNER_BOUND_DETECTORS,
];
/**
* Whether the given AST node is an assertion call, recognised across chai,
* sinon, vitest, supertest, cypress, global `expect*(...)` chains and node
* `assert`. Pure-AST: does not require type information.
*/
function isAssertion(context, node) {
return ASSERTION_DETECTORS.some(detect => detect(context, node));
}
/**
* Whether `node` is an assertion from a library that runs in a plain script with
* no test runner (node `assert`, chai, sinon, supertest). The complement among
* assertions — vitest, cypress, global `expect` — is "runner-bound". Callers
* deciding "is this runner-bound?" should test
* `isAssertion(...) && !isScriptCapableAssertion(...)`.
*/
function isScriptCapableAssertion(context, node) {
return SCRIPT_CAPABLE_DETECTORS.some(detect => detect(context, node));
}
// All FQN roots whose calls are compile-time-only type checks: Vitest's
// `expectTypeOf`/`assertType` and the standalone `expect-type` package's
// `expectTypeOf` (which Vitest uses internally and may be imported directly).
const TYPE_LEVEL_ASSERTION_ROOTS = [...Vitest.TYPE_LEVEL_ROOTS, 'expect-type.expectTypeOf'];
/**
* Whether `node` is a compile-time-only type check that must not be flagged for
* placement outside a test case.
*/
function isTypeLevelAssertion(context, node) {
if (node.type !== 'CallExpression') {
return false;
}
const fqn = (0, module_js_1.getFullyQualifiedName)(context, node);
return (fqn !== null &&
TYPE_LEVEL_ASSERTION_ROOTS.some(root => fqn === root || fqn.startsWith(`${root}.`)));
}
/**
* Incomplete Chai `foo.should` property chains are not assertions on their own.
* We exclude them so S2699 does not treat an unfinished `should` chain as an assertion
* and miss the "Add at least one assertion to this test case." issue.
*/
function isIncompleteShouldAccess(context, node) {
if (!isShouldMember(node)) {
return false;
}
return !isCompleteESTreeShouldPropertyChain(context, node);
}
/**
* Type-checker-aware counterpart of {@link isAssertion}, operating on TypeScript
* AST nodes. Used when parser services are available to follow resolved types.
*/
function isTSAssertion(services, node) {
return (isGlobalTSAssertion(services, node) ||
isExtendedTSShouldAccess(node) ||
Chai.isTSAssertion(services, node) ||
Sinon.isTSAssertion(services, node) ||
Supertest.isTSAssertion(services, node) ||
Vitest.isTSAssertion(services, node) ||
Cypress.isTSAssertion(node));
}
function isExtendedTSShouldAccess(node) {
return (isTSShouldAccess(node) &&
(0, module_ts_js_1.importsModuleTS)(node.getSourceFile(), ['chai']) &&
isCompleteTSShouldPropertyChain(node));
}
function isTSShouldAccess(node) {
return typescript_1.default.isPropertyAccessExpression(node) && node.name.text === 'should';
}
/**
* Checks if the node matches the pattern expectX(...).method() where:
* - expectX is one of the known global expect entry points ({@link GLOBAL_EXPECT_NAMES})
* - method is a chained property access with a method call (e.g., .toBe(), .toEqual(), .not.toBe())
*
* This mirrors the TypeScript isGlobalExpectExpression function logic.
*/
function isGlobalExpectExpressionJS(node) {
if (node.callee.type !== 'MemberExpression') {
return false;
}
// Walk up the chain of member expressions to find the innermost call expression
// This handles: expect(...).toBe() as well as expect(...).not.toBe()
// Also handles: expectObservable(...).toBe(...), expectSubscriptions(...).toBe(...), etc.
let current = node.callee.object;
while (current.type === 'MemberExpression') {
current = current.object;
}
if (current.type !== 'CallExpression') {
return false;
}
const innerCall = current;
return innerCall.callee.type === 'Identifier' && GLOBAL_EXPECT_NAMES.has(innerCall.callee.name);
}
function isFunctionCallFromNodeAssert(context, node) {
if (node.type !== 'CallExpression') {
return false;
}
const fullyQualifiedName = (0, module_js_1.getFullyQualifiedName)(context, node);
return fullyQualifiedName?.split('.')[0] === 'assert';
}
function isShouldMember(node) {
return (node.type === 'MemberExpression' &&
!node.computed &&
node.property.type === 'Identifier' &&
node.property.name === 'should');
}
function isCompleteESTreeShouldPropertyChain(context, node) {
let current = node;
let parent = (0, ancestor_js_1.getParent)(context, node);
while (isESTreeChaiShouldChainContinuation(parent, current)) {
const grandparent = (0, ancestor_js_1.getParent)(context, parent);
if (isESTreeCallOnNode(grandparent, parent)) {
return true;
}
if (CHAI_TERMINAL_PROPERTY_NAMES.has(parent.property.name)) {
if (!isESTreeChaiShouldChainContinuation(grandparent, parent)) {
return true;
}
current = parent;
parent = grandparent;
continue;
}
if (!CHAI_NON_TERMINAL_PROPERTY_NAMES.has(parent.property.name) &&
!isESTreeChaiShouldChainContinuation(grandparent, parent)) {
return false;
}
current = parent;
parent = grandparent;
}
return false;
}
function isCompleteTSShouldPropertyChain(node) {
let current = node;
let parent = node.parent;
while (isTSChaiShouldChainContinuation(parent, current)) {
const grandparent = parent.parent;
if (isTSCallOnNode(grandparent, parent)) {
return true;
}
if (CHAI_TERMINAL_PROPERTY_NAMES.has(parent.name.text)) {
if (!isTSChaiShouldChainContinuation(grandparent, parent)) {
return true;
}
current = parent;
parent = grandparent;
continue;
}
if (!CHAI_NON_TERMINAL_PROPERTY_NAMES.has(parent.name.text) &&
!isTSChaiShouldChainContinuation(grandparent, parent)) {
return false;
}
current = parent;
parent = grandparent;
}
return false;
}
function isESTreeCallOnNode(parent, node) {
return parent?.type === 'CallExpression' && parent.callee === node;
}
function isTSCallOnNode(parent, node) {
return parent !== undefined && typescript_1.default.isCallExpression(parent) && parent.expression === node;
}
function isESTreeChaiShouldChainContinuation(parent, node) {
return (parent?.type === 'MemberExpression' &&
parent.object === node &&
!parent.computed &&
parent.property.type === 'Identifier');
}
function isTSChaiShouldChainContinuation(parent, node) {
return (parent !== undefined && typescript_1.default.isPropertyAccessExpression(parent) && parent.expression === node);
}
function isGlobalTSAssertion(services, node) {
if (node.kind !== typescript_1.default.SyntaxKind.CallExpression) {
return false;
}
const callExpressionNode = node;
// check for global expect
if (isGlobalExpectExpression(callExpressionNode)) {
return true;
}
return isFunctionCallFromNodeAssertTS(services, node);
}
function isGlobalExpectExpression(node) {
if (node.expression.kind !== typescript_1.default.SyntaxKind.PropertyAccessExpression) {
return false;
}
// Walk up the chain of property accesses to find the innermost call expression
// This handles: expect(...).toHaveBeenCalled() as well as expect(...).not.toHaveBeenCalled()
// Also handles: expectObservable(...).toBe(...), expectSubscriptions(...).toBe(...), etc.
let current = node.expression.expression;
while (current.kind === typescript_1.default.SyntaxKind.PropertyAccessExpression) {
current = current.expression;
}
if (current.kind !== typescript_1.default.SyntaxKind.CallExpression) {
return false;
}
const innerCallExpression = current;
return (innerCallExpression.expression.kind === typescript_1.default.SyntaxKind.Identifier &&
GLOBAL_EXPECT_NAMES.has(innerCallExpression.expression.text));
}
function isFunctionCallFromNodeAssertTS(services, node) {
const fqn = (0, module_ts_js_1.getFullyQualifiedNameTS)(services, node);
const root = fqn?.split('.')[0];
return root === 'assert' || root === 'assert/strict';
}