eslint-plugin-sonarjs
Version:
390 lines (389 loc) • 18.8 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/
*/
// https://sonarsource.github.io/rspec/#/rspec/S8785/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 ancestor_js_1 = require("../helpers/ancestor.js");
const assertion_detection_js_1 = require("../helpers/assertion-detection.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const location_js_1 = require("../helpers/location.js");
const module_js_1 = require("../helpers/module.js");
const ast_js_1 = require("../helpers/ast.js");
const mocha_style_test_frameworks_js_1 = require("../helpers/mocha-style-test-frameworks.js");
const parser_services_js_1 = require("../helpers/parser-services.js");
const test_frameworks_js_1 = require("../helpers/test-frameworks.js");
const meta = __importStar(require("./generated-meta.js"));
const async_analysis_js_1 = require("./async-analysis.js");
const call_to_declaration_js_1 = require("../helpers/call-to-declaration.js");
/**
* Frameworks that run a suite's callback synchronously during the discovery phase. Vitest is
* intentionally excluded: it awaits the suite callback, so an async callback is safe there.
* `@jest/globals` is the explicit Jest entry point (`import { describe } from '@jest/globals'`) and
* must be treated like `jest`, mirroring S8780.
*/
const SUPPORTED_FRAMEWORKS = ['jest', '@jest/globals', 'mocha', 'cypress'];
/**
* Suite-defining identifiers whose callback runs during discovery. `xdescribe`/`xcontext` (skip
* variants) are intentionally absent: their tests are excluded from the run, so an async callback
* cannot silently drop them.
*/
const SUITE_BASE_NAMES = new Set(['describe', 'context', 'suite', 'fdescribe']);
/**
* Modifiers allowed on a raising suite form: `describe.only`, `describe.each`,
* `describe.only.each`, etc. `skip` is excluded, which leaves `describe.skip`, `context.skip`,
* `suite.skip`, and `describe.skip.each` out of scope.
*/
const RAISING_MODIFIERS = new Set(['only', 'each']);
/** Test- and suite-registering identifiers, used to detect the tests dropped after an `await`. */
const TEST_AND_SUITE_NAMES = new Set([...mocha_style_test_frameworks_js_1.TEST_FUNCTION_NAMES, ...mocha_style_test_frameworks_js_1.SUITE_FUNCTION_NAMES]);
const messages = {
removeAsync: 'Remove the "async" keyword from this test suite callback; a test suite callback must be synchronous.',
asyncSuiteCallback: 'Move this async setup into a lifecycle hook or a test callback; any test declared after this "await" is silently dropped.',
removeAsyncQuickFix: 'Remove the "async" keyword',
droppedTest: 'This test is declared after the await and is never registered.',
asyncHelperCall: 'This async helper silently drops tests; remove its "async" keyword and move async setup into a lifecycle hook or a test callback.',
};
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages, hasSuggestions: true }),
create(context) {
if (!(0, module_js_1.importsOrDependsOnModule)(context, SUPPORTED_FRAMEWORKS, SUPPORTED_FRAMEWORKS)) {
return {};
}
const parserServices = context.sourceCode.parserServices;
return {
CallExpression(node) {
const raisingSuiteCallback = getRaisingSuiteCallback(context, node, parserServices);
if (!raisingSuiteCallback) {
return;
}
visitRaisingSuiteCallback(context, parserServices, raisingSuiteCallback);
},
};
},
};
function visitRaisingSuiteCallback(context, parserServices, raisingSuiteCallback) {
const { callback, byReference } = raisingSuiteCallback;
const asyncSuiteOutcome = callback.async
? analyzeAsyncSuiteCallback(context, parserServices, callback)
: undefined;
if (asyncSuiteOutcome?.kind === 'noTopLevelAwait') {
// Async callback, no top-level await: remove the misleading async keyword.
reportRemoveAsync(context, callback, !byReference);
}
if ((0, parser_services_js_1.isRequiredParserServices)(parserServices)) {
reportUnawaitedAsyncHelperCalls(context, parserServices, callback, asyncSuiteOutcome?.kind === 'awaitedHelperReported' ? asyncSuiteOutcome.skipAwait : undefined);
}
}
function analyzeAsyncSuiteCallback(context, parserServices, callback) {
const topLevelAwait = (0, async_analysis_js_1.findFirstTopLevelAwait)(context, callback);
if (!topLevelAwait) {
return { kind: 'noTopLevelAwait' };
}
const awaitedHelperOutcome = tryReportAwaitedAsyncHelperCall(context, parserServices, callback, topLevelAwait);
if (awaitedHelperOutcome) {
return awaitedHelperOutcome;
}
reportAsyncSuiteCallback(context, callback, topLevelAwait);
return { kind: 'asyncSuiteReported' };
}
function tryReportAwaitedAsyncHelperCall(context, parserServices, callback, topLevelAwait) {
if (!(0, parser_services_js_1.isRequiredParserServices)(parserServices)) {
return undefined;
}
const awaitedCall = (0, async_analysis_js_1.getAwaitedCall)(topLevelAwait);
if (!awaitedCall) {
return undefined;
}
const nextDescribeAwait = callback.body?.type === 'BlockStatement'
? findNextTopLevelAwaitAfter(context.sourceCode, callback.body, topLevelAwait)
: undefined;
const reported = reportAsyncHelperCall(context, parserServices, awaitedCall, callback, topLevelAwait, nextDescribeAwait);
if (!reported) {
return undefined;
}
return { kind: 'awaitedHelperReported', skipAwait: topLevelAwait };
}
function reportAsyncSuiteCallback(context, callback, topLevelAwait) {
const secondaryLocations = (0, async_analysis_js_1.findTestsRegisteredAfter)(context.sourceCode, callback, topLevelAwait, TEST_AND_SUITE_NAMES).map(test => (0, location_js_1.toSecondaryLocation)(test, messages.droppedTest));
(0, location_js_1.report)(context, {
messageId: 'asyncSuiteCallback',
message: messages.asyncSuiteCallback,
node: topLevelAwait,
}, secondaryLocations);
}
/**
* Returns the function callback of `node` when `node` is a supported suite form that runs its
* callback during discovery; otherwise `undefined`. Handles direct calls (`describe(name, fn)`,
* `describe.only(name, fn)`) and the curried `.each` form (`describe.each(table)(name, fn)`), where
* the callback lives in the outer call's arguments.
*/
function getRaisingSuiteCallback(context, node, parserServices) {
const parts = (0, mocha_style_test_frameworks_js_1.getMochaCalleeParts)(node.callee);
if (parts === undefined || !SUITE_BASE_NAMES.has(parts.base.name)) {
return undefined;
}
if (!parts.modifiers.every(modifier => RAISING_MODIFIERS.has(modifier))) {
return undefined;
}
if (!isSupportedFrameworkConstruct(context, parts.base)) {
return undefined;
}
const inlineCallback = node.arguments.find(isSuiteCallback);
if (inlineCallback) {
return { callback: inlineCallback, byReference: false };
}
if (!(0, parser_services_js_1.isRequiredParserServices)(parserServices)) {
return undefined;
}
for (const argument of node.arguments) {
if (argument.type !== 'Identifier') {
continue;
}
const decl = (0, call_to_declaration_js_1.followReferenceToDeclaration)(argument, parserServices);
if (!decl) {
continue;
}
const esTreeDecl = parserServices.tsNodeToESTreeNodeMap.get(decl);
if (!esTreeDecl || !(0, async_analysis_js_1.isFunctionLike)(esTreeDecl)) {
continue;
}
return { callback: esTreeDecl, byReference: true };
}
return undefined;
}
/** Type guard for inline function callbacks (the describe callback is always an expression). */
function isSuiteCallback(node) {
return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
}
/**
* Whether the suite identifier comes from a supported framework. An imported/required name must
* resolve to `jest`, `@jest/globals`, `mocha`, or `cypress` (this excludes Vitest); a locally-defined
* name is rejected; an unresolved global is accepted, since the file-level dependency gate already
* confirmed a supported framework is in use.
*/
function isSupportedFrameworkConstruct(context, identifier) {
const variable = (0, ast_js_1.getVariableFromScope)(context.sourceCode.getScope(identifier), identifier.name);
if (variable && variable.defs.length > 0) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, identifier);
if (fqn === null) {
return false;
}
// `getFullyQualifiedName` splits the module path on `/`, so a scoped entry point like
// `@jest/globals` yields the prefix `@jest.globals` (not a single first segment). Match the
// whole module prefix rather than `fqn.split('.')[0]`.
return SUPPORTED_FRAMEWORKS.some(module => {
const prefix = module.replaceAll('/', '.');
return fqn === prefix || fqn.startsWith(`${prefix}.`);
});
}
return true;
}
function reportRemoveAsync(context, callback, withQuickFix) {
const asyncToken = context.sourceCode.getFirstToken(callback);
if (asyncToken?.value !== 'async') {
return;
}
const tokenAfterAsync = context.sourceCode.getTokenAfter(asyncToken);
// This rule declares `hasSecondaries`, so the linter decodes every issue message of S8785 as
// an encoded payload. Route this report through the `report` helper too (with no secondary
// locations) so its message is encoded consistently; a raw `context.report` would emit a plain
// string that the decoder fails to `JSON.parse`.
(0, location_js_1.report)(context, {
loc: asyncToken.loc,
messageId: 'removeAsync',
message: messages.removeAsync,
...(withQuickFix
? {
suggest: [
{
messageId: 'removeAsyncQuickFix',
fix: fixer => fixer.removeRange([
asyncToken.range[0],
tokenAfterAsync?.range[0] ?? asyncToken.range[1],
]),
},
],
}
: {}),
});
}
/**
* Walks the top-level statements of `callback`'s body (not crossing function boundaries) and
* reports `asyncHelperCall` for each `CallExpression` statement whose target is an async function
* with at least one test registered after a top-level `await` inside it.
*
* `skipAwait` is an optional `AwaitExpression` node already handled by the caller (the top-level
* await of the describe callback). Any statement whose expression IS that node is skipped.
*
* Returns `true` if at least one issue was reported.
*/
function reportUnawaitedAsyncHelperCalls(context, services, callback, skipAwait) {
const body = callback.body;
if (!body) {
return false;
}
const { sourceCode } = context;
let reported = false;
// Block body: seed the stack with direct statement children.
// Concise-body arrow (`() => expr`): seed with the qexpression itself so it falls through the
// while loop below. The expression is identified inside the loop by `current === body`.
const stack = body.type === 'BlockStatement'
? [...(0, ancestor_js_1.childrenOf)(body, sourceCode.visitorKeys)]
: [body];
while (stack.length > 0) {
const current = stack.pop();
if (!current || (0, ast_js_1.isFunctionNode)(current)) {
continue;
}
let expr = null;
if (current.type === 'ExpressionStatement') {
expr = current.expression;
}
else if (body.type !== 'BlockStatement' && current === body) {
expr = body;
}
if (expr === null) {
stack.push(...(0, ancestor_js_1.childrenOf)(current, sourceCode.visitorKeys));
continue;
}
const nextDescribeAwait = body.type === 'BlockStatement'
? findNextTopLevelAwaitAfter(sourceCode, body, expr)
: undefined;
reported =
reportAsyncHelperExpression(context, services, expr, callback, skipAwait, nextDescribeAwait) || reported;
}
return reported;
}
function reportAsyncHelperExpression(context, services, expr, callback, skipAwait, nextDescribeAwait) {
// Unawaited call: helper() / () => helper()
if (expr.type === 'CallExpression' && !shouldSkipHelperResolution(context, expr)) {
return reportAsyncHelperCall(context, services, expr, callback);
}
// Awaited call: await helper() / async () => await helper()
// Skip if this is the top-level-await already handled by the outer detection branch.
if (expr.type === 'AwaitExpression' &&
expr !== skipAwait &&
expr.argument.type === 'CallExpression' &&
!shouldSkipHelperResolution(context, expr.argument)) {
return reportAsyncHelperCall(context, services, expr.argument, callback, expr, nextDescribeAwait);
}
return false;
}
function findNextTopLevelAwaitAfter(sourceCode, body, afterNode) {
const afterEnd = sourceCode.getRange(afterNode)[1];
for (const stmt of body.body) {
if (stmt.type === 'ExpressionStatement' &&
stmt.expression.type === 'AwaitExpression' &&
sourceCode.getRange(stmt)[0] >= afterEnd) {
return stmt.expression;
}
}
return undefined;
}
function shouldSkipHelperResolution(context, call) {
const calleeParts = (0, mocha_style_test_frameworks_js_1.getMochaCalleeParts)(call.callee);
const isKnownFrameworkRegistration = calleeParts !== undefined &&
test_frameworks_js_1.TEST_FRAMEWORK_STRUCTURE_FUNCTIONS.has(calleeParts.base.name) &&
isSupportedFrameworkConstruct(context, calleeParts.base);
return isKnownFrameworkRegistration || (0, assertion_detection_js_1.isAssertion)(context, call);
}
/**
* Checks whether `callNode` targets an async function that drops tests after its first top-level
* `await`. If so, reports `asyncHelperCall` on `callNode` with the dropped tests as secondaries.
*
* When `awaitNode` is provided, the call is awaited at `awaitNode` inside the describe callback,
* so tests dropped in the describe body after `awaitNode` are also collected as secondaries.
*
* Returns `true` if an issue was reported.
*/
function reportAsyncHelperCall(context, services, callNode, describeCallback, awaitNode, nextDescribeAwait) {
const decl = (0, call_to_declaration_js_1.followCallToDeclaration)(callNode, services);
if (!decl) {
return false;
}
// Only flag async functions.
if (!(typescript_1.default.getCombinedModifierFlags(decl) & typescript_1.default.ModifierFlags.Async)) {
return false;
}
// Get the ESTree equivalent — skip cross-file declarations (no ESTree nodes available for
// secondary locations, and we cannot confirm dropped tests without the body).
const esTreeDecl = services.tsNodeToESTreeNodeMap.get(decl);
if (!esTreeDecl || !(0, async_analysis_js_1.isFunctionLike)(esTreeDecl)) {
return false;
}
// Find the first top-level await inside the helper.
const helperAwait = (0, async_analysis_js_1.findFirstTopLevelAwait)(context, esTreeDecl);
if (!helperAwait) {
return false;
}
// Require at least one test dropped after the await inside the helper.
const droppedInHelper = (0, async_analysis_js_1.findTestsRegisteredAfter)(context.sourceCode, esTreeDecl, helperAwait, TEST_AND_SUITE_NAMES);
if (droppedInHelper.length === 0) {
return false;
}
// If the call is awaited inside the describe callback, also collect tests dropped in the
// describe body after the await expression, up to the next top-level await (if any) to avoid
// overlapping secondaries when multiple sequential awaited helpers are reported.
const droppedInDescribe = awaitNode
? (0, async_analysis_js_1.findTestsRegisteredAfter)(context.sourceCode, describeCallback, awaitNode, TEST_AND_SUITE_NAMES, nextDescribeAwait)
: [];
const secondaryLocations = [...droppedInHelper, ...droppedInDescribe].map(test => (0, location_js_1.toSecondaryLocation)(test, messages.droppedTest));
(0, location_js_1.report)(context, {
messageId: 'asyncHelperCall',
message: messages.asyncHelperCall,
node: callNode,
}, secondaryLocations);
return true;
}