eslint-plugin-sonarjs
Version:
403 lines (402 loc) • 18.2 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/S8968/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 generate_meta_js_1 = require("../helpers/generate-meta.js");
const ast_js_1 = require("../helpers/ast.js");
const mocha_js_1 = require("../helpers/mocha.js");
const mocha_style_test_frameworks_js_1 = require("../helpers/mocha-style-test-frameworks.js");
const module_js_1 = require("../helpers/module.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
mocha: 'Call `this.skip()` instead of returning early.',
vitest: "Call the test context's `skip()` instead of returning early.",
playwright: 'Call `test.skip(condition)` instead of returning early.',
nodeTest: "Call the test context's `skip()` before returning early.",
bun: 'Move the condition to `test.skipIf()` instead of returning early.',
};
const suggestionMessages = {
suggestMochaSkip: 'Replace with `this.skip()`.',
suggestVitestSkip: "Replace with the test context's `skip()`.",
suggestPlaywrightSkip: 'Replace with `test.skip(condition)`.',
suggestNodeTestSkip: "Call the test context's `skip()` before returning.",
suggestBunSkipIf: 'Move the condition to `test.skipIf()`.',
};
const PLAYWRIGHT_MODULES = ['@playwright/test'];
const NODE_TEST_MODULES = ['node:test'];
const BUN_MODULES = ['bun:test'];
const VITEST_MODULES = ['vitest'];
const MOCHA_MODULES = ['mocha'];
const JEST_IMPORTS = ['jest', '@jest/globals'];
const JEST_DEPENDENCIES = ['jest'];
const JASMINE_MODULES = ['jasmine', 'jasmine-core', 'jasmine-node', 'karma-jasmine'];
const AVA_MODULES = ['ava'];
const QUNIT_MODULES = ['qunit'];
/**
* Jest and Jasmine are excluded outright: neither has a `this.skip()`-style
* in-body skip, so the guard-return shape this rule flags isn't actionable there.
* AVA and QUnit both support a bare `test(name, fn)` call identical in shape to
* node:test/Bun/Vitest, so they are excluded too: both already fail a test by
* default when it completes without an assertion, so the misleading "passed"
* outcome this rule targets never occurs there.
*
* A file's own explicit import of a covered framework is checked before the
* Jest/Jasmine/AVA/QUnit exclusion, and takes precedence over it: those four are
* detected via `importsOrDependsOnModule`, which also matches on the project's
* package.json dependencies, not just this file's imports. Checking that
* project-wide signal first would wrongly exclude an unambiguous Playwright/
* node:test/Bun/Vitest/Mocha file whenever the project also happens to depend on
* Jest/Jasmine/AVA/QUnit for unrelated reasons (e.g. a mixed-runner monorepo, or
* a leftover Jest devDependency during a migration). Mocha is included in this
* early check too: although it usually runs off globals, a file can still
* explicitly `import { it } from 'mocha'`, and that signal must win over the
* project-wide exclusion just like the other frameworks.
*
* Absent that explicit import, Mocha has no other positive signal to rely on, so
* it's checked last via `importsOrDependsOnModule` against the project's
* package.json dependencies. Without this dependency check, every file with no
* other framework signal would be assumed to be Mocha, which produces false
* positives on frameworks this rule doesn't otherwise recognize (e.g. Jasmine
* loaded from a vendored copy rather than an npm dependency). If nothing
* indicates Mocha either, the file is excluded rather than guessed at.
*/
function detectFramework(context) {
if ((0, module_js_1.importsModule)(context, PLAYWRIGHT_MODULES)) {
return 'playwright';
}
if ((0, module_js_1.importsModule)(context, NODE_TEST_MODULES)) {
return 'nodeTest';
}
if ((0, module_js_1.importsModule)(context, BUN_MODULES)) {
return 'bun';
}
if ((0, module_js_1.importsModule)(context, VITEST_MODULES)) {
return 'vitest';
}
if ((0, module_js_1.importsModule)(context, MOCHA_MODULES)) {
return 'mocha';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, JEST_IMPORTS, JEST_DEPENDENCIES)) {
return 'excluded';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, JASMINE_MODULES, JASMINE_MODULES)) {
return 'excluded';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, AVA_MODULES, AVA_MODULES)) {
return 'excluded';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, QUNIT_MODULES, QUNIT_MODULES)) {
return 'excluded';
}
// Neither imported directly in this file nor excluded: fall back to the project's
// dependency manifest for Playwright/Vitest, which sometimes run test files that
// don't literally import the framework (e.g. via a shared fixture re-export).
if ((0, module_js_1.importsOrDependsOnModule)(context, PLAYWRIGHT_MODULES, PLAYWRIGHT_MODULES)) {
return 'playwright';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, VITEST_MODULES, VITEST_MODULES)) {
return 'vitest';
}
if ((0, module_js_1.importsOrDependsOnModule)(context, MOCHA_MODULES, MOCHA_MODULES)) {
return 'mocha';
}
return 'excluded';
}
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, {
messages: { ...messages, ...suggestionMessages },
hasSuggestions: true,
}),
create(context) {
const framework = detectFramework(context);
if (framework === 'excluded') {
return {};
}
return {
'CallExpression:exit'(node) {
const call = node;
const callback = extractCallback(context, call, framework);
if (callback?.body.type !== 'BlockStatement') {
return;
}
const statements = callback.body.body;
if (statements.length < 2 || statements[0].type !== 'IfStatement') {
return;
}
const guard = statements[0];
if (guard.alternate) {
return;
}
const returnStatement = getSoleReturnWithoutValue(guard.consequent);
if (returnStatement) {
context.report({
node: returnStatement,
messageId: framework,
suggest: buildSuggestions(context, framework, call, callback, guard),
});
}
},
};
},
};
/**
* A recognized it/test call's callback, or null. The callback is always the last
* argument regardless of how many arguments precede it, to cover the 3-argument
* `test(name, options, fn)` form used by node:test and Playwright.
* Calls already marked `.skip` are ignored: such a test can never be misreported as
* "passed", since it never runs at all.
*/
function extractCallback(context, call, framework) {
if (!isRecognizedTestCall(context, call, framework)) {
return null;
}
if (call.callee.type === 'MemberExpression' &&
!call.callee.computed &&
(0, ast_js_1.isIdentifier)(call.callee.property, 'skip')) {
return null;
}
const lastArgument = call.arguments.at(-1);
return lastArgument && ast_js_1.FUNCTION_NODES.includes(lastArgument.type)
? lastArgument
: null;
}
/**
* Mocha and Vitest share the same `it`/`test` call shape, so aliased imports,
* destructured `require()` bindings, and their modifier chains (`.only`,
* `.concurrent`, Vitest's `.sequential`) are resolved through the same helper
* already relied on by other test-related rules (e.g. S8960). `.skip` is
* deliberately not a recognized modifier there, so a `.skip`-marked call is
* already excluded at this stage.
*
* Playwright has its own `test` export and modifier set, resolved separately via
* `getPlaywrightTestQualifiers`; only its known modifiers (plus `.skip`, filtered
* out below) are accepted so that unrelated member chains aren't misread as tests.
*
* node:test and Bun aren't covered by either shared helper yet, so they keep the
* simpler literal-name matching used before (bare `it`/`test`/`specify`, or
* `.only`/`.skip` on top of one of those names).
*
* None of these helpers resolve namespace-style access (`import * as ns from
* '...'; ns.it(...)` or `const ns = require('...'); ns.it(...)`): that's a
* pre-existing gap in the shared FQN resolution these helpers build on, also
* present in the other rules that already rely on them, not specific to this one.
*/
function isRecognizedTestCall(context, call, framework) {
switch (framework) {
case 'mocha':
case 'vitest':
return (0, mocha_style_test_frameworks_js_1.isMochaTestConstruct)(context, call, mocha_style_test_frameworks_js_1.TEST_FUNCTION_NAMES);
case 'playwright': {
const qualifiers = (0, mocha_style_test_frameworks_js_1.getPlaywrightTestQualifiers)(context, call.callee);
return (qualifiers !== undefined &&
qualifiers.every(qualifier => qualifier === 'skip' || mocha_style_test_frameworks_js_1.PLAYWRIGHT_TEST_MODIFIERS.has(qualifier)));
}
case 'nodeTest':
case 'bun':
return (0, mocha_js_1.isTestCase)(call);
}
}
/**
* Returns the `return;` statement if `statement` is exactly that, either directly
* or as the only statement of a block. A `return` with a value is excluded: it usually
* returns a promise or a result for an unrelated, legitimate reason. A block with
* more than one statement is excluded too, since some of those frameworks expect a
* skip call to be immediately followed by `return;` (e.g. node:test's `t.skip()`).
*/
function getSoleReturnWithoutValue(statement) {
if (statement.type === 'ReturnStatement') {
return statement.argument === null ? statement : null;
}
if (statement.type === 'BlockStatement' && statement.body.length === 1) {
return getSoleReturnWithoutValue(statement.body[0]);
}
return null;
}
/**
* Builds the `suggest` fixes for the diagnostic, or undefined when the callback's shape
* doesn't give the fix a safe place to call the framework's skip mechanism from. Each
* framework's fix mirrors the compliant example documented in the RSPEC:
* - Playwright's `test.skip(condition)` is always safe to insert: it doesn't depend on
* the callback's signature at all.
* - node:test and Vitest's fixes call `skip()` on the callback's own test-context
* parameter; when the callback doesn't already declare one, there's nothing to call
* `skip()` on, and introducing a new parameter is left to the developer.
* - Bun has no in-body skip call at all: the only fix is to move the condition to
* `test.skipIf(condition)`, which is only offered for a bare `test`/`it` call (no
* `.only`-style modifier chain to reconcile) whose callback takes no parameters, so
* the condition can't be referring to state that only exists once the test starts
* running (see the RSPEC's "How to fix it in Bun" section).
* - Mocha's `this.skip()` relies on the Mocha test context, which is only bound when the
* callback is declared with the `function` keyword; an arrow function callback has no
* fix to offer.
*/
function buildSuggestions(context, framework, call, callback, guard) {
const condition = context.sourceCode.getText(guard.test);
switch (framework) {
case 'playwright': {
const { base } = (0, mocha_style_test_frameworks_js_1.collectMemberChain)(call.callee);
const calleeName = context.sourceCode.getText(base);
return [
{
messageId: 'suggestPlaywrightSkip',
fix: fixer => fixer.replaceText(guard, `${calleeName}.skip(${condition});`),
},
];
}
case 'nodeTest': {
const contextParamName = getFirstIdentifierParamName(callback);
if (!contextParamName) {
return undefined;
}
return [
{
messageId: 'suggestNodeTestSkip',
fix: fixer => fixer.replaceText(guard.consequent, `{ ${contextParamName}.skip(); return; }`),
},
];
}
case 'vitest': {
const skipAccessor = getVitestSkipAccessor(callback);
if (!skipAccessor) {
return undefined;
}
return [
{
messageId: 'suggestVitestSkip',
fix: fixer => fixer.replaceText(guard.consequent, `{ ${skipAccessor}(); }`),
},
];
}
case 'bun': {
if (call.callee.type !== 'Identifier' || callback.params.length > 0) {
return undefined;
}
const calleeName = call.callee.name;
return [
{
messageId: 'suggestBunSkipIf',
fix: fixer => [
fixer.replaceText(call.callee, `${calleeName}.skipIf(${condition})`),
removeLines(context, fixer, guard),
],
},
];
}
case 'mocha': {
if (callback.type !== 'FunctionExpression') {
return undefined;
}
return [
{
messageId: 'suggestMochaSkip',
fix: fixer => fixer.replaceText(guard.consequent, `{ this.skip(); }`),
},
];
}
}
}
/**
* Removes the whole lines a node occupies, including its leading indentation and
* trailing newline. A plain `fixer.remove(node)` would only remove the node's own
* range, leaving an empty (whitespace-only) line behind; this only reads correctly
* for a statement that is the sole content of the line(s) it occupies, which is the
* case for the `if` guard this is used on.
*/
function removeLines(context, fixer, node) {
const sourceCode = context.sourceCode;
const lineStart = sourceCode.getIndexFromLoc({
line: sourceCode.getLoc(node).start.line,
column: 0,
});
const nodeEnd = sourceCode.getRange(node)[1];
const trailingNewline = /^\r?\n/.exec(sourceCode.text.slice(nodeEnd));
const end = nodeEnd + (trailingNewline ? trailingNewline[0].length : 0);
return fixer.removeRange([lineStart, end]);
}
/**
* The callback's first parameter name, when it's a plain identifier (e.g. node:test's
* `t` in `test('...', t => { ... })`). Any other shape (no parameter, destructured,
* default value, rest element) has no single name to call `.skip()` on.
*/
function getFirstIdentifierParamName(callback) {
const [firstParam] = callback.params;
return firstParam?.type === 'Identifier' ? firstParam.name : undefined;
}
/**
* An expression that evaluates to Vitest's test-context `skip` function, or undefined
* when the callback's first parameter doesn't already expose one:
* - `({ skip }) => {}` or `({ skip: renamed }) => {}`: the destructured local binding.
* - `(ctx) => {}`: `ctx.skip`.
* A parameter that doesn't grant access to `skip` (no parameter, or a destructuring
* pattern without a `skip` property) is left alone rather than rewriting the callback's
* signature to add one.
*/
function getVitestSkipAccessor(callback) {
const [firstParam] = callback.params;
if (!firstParam) {
return undefined;
}
if (firstParam.type === 'Identifier') {
return `${firstParam.name}.skip`;
}
if (firstParam.type === 'ObjectPattern') {
for (const property of firstParam.properties) {
if (property.type === 'Property' &&
!property.computed &&
(0, ast_js_1.isIdentifier)(property.key, 'skip') &&
property.value.type === 'Identifier') {
return property.value.name;
}
}
}
return undefined;
}