Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,104 @@ function isInsideIfStatement(
});
}

/**
* Check if a node (IfStatement consequent) contains a return or throw statement,
* indicating an early exit guard.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function containsEarlyExit(node: any): boolean {
if (!node) return false;
if (node.type === "ReturnStatement" || node.type === "ThrowStatement")
return true;
if (node.type === "BlockStatement" && Array.isArray(node.body)) {
return node.body.some(containsEarlyExit);
}
return false;
}

/**
* Recursively check if an expression references the API identified by the rule
* (by object name, property name, or a string literal matching either).
*/
function expressionReferencesApi(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node: any,
rule: AstMetadataApiWithTargetsResolver
): boolean {
if (!node) return false;
if (node.type === "Identifier") {
return node.name === rule.object || node.name === rule.property;
}
if (node.type === "Literal" && typeof node.value === "string") {
return node.value === rule.object || node.value === rule.property;
}
if (node.type === "UnaryExpression") {
return expressionReferencesApi(node.argument, rule);
}
if (node.type === "BinaryExpression" || node.type === "LogicalExpression") {
return (
expressionReferencesApi(node.left, rule) ||
expressionReferencesApi(node.right, rule)
);
}
if (node.type === "MemberExpression") {
return (
expressionReferencesApi(node.object, rule) ||
expressionReferencesApi(node.property, rule)
);
}
if (node.type === "CallExpression") {
return expressionReferencesApi(node.callee, rule);
}
return false;
}

/**
* Detect the early-return guard pattern:
*
* if (!('foo' in window)) { return; }
* window.foo.bar(); // <-- this node is guarded
*
* Walks up from the node to the nearest block body, then checks preceding
* sibling statements for an if-with-early-exit whose test references the
* same API as the failing rule.
*/
function isGuardedByEarlyReturn(
node: ESLintNode,
failingRule: AstMetadataApiWithTargetsResolver
): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = node;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let parent: any = node.parent;

while (parent) {
if (
(parent.type === "BlockStatement" || parent.type === "Program") &&
Array.isArray(parent.body)
) {
const stmtIndex = parent.body.indexOf(current);
if (stmtIndex > 0) {
for (let i = 0; i < stmtIndex; i++) {
const stmt = parent.body[i];
if (
stmt.type === "IfStatement" &&
containsEarlyExit(stmt.consequent) &&
expressionReferencesApi(stmt.test, failingRule)
) {
return true;
}
}
}
break;
}
current = parent;
parent = parent.parent;
}

return false;
}

function checkNotInsideIfStatementAndReport(
context: Context,
handleFailingRule: HandleFailingRule,
Expand All @@ -53,7 +151,8 @@ function checkNotInsideIfStatementAndReport(
) {
if (
context.settings?.ignoreConditionalChecks === true ||
!isInsideIfStatement(node, sourceCode, context)
(!isInsideIfStatement(node, sourceCode, context) &&
!isGuardedByEarlyReturn(node, failingRule))
) {
handleFailingRule(failingRule, node);
}
Expand Down
2 changes: 2 additions & 0 deletions src/rules/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ export default {
if (
type === "Property" || // ex. const { Set } = require('immutable');
type === "FunctionDeclaration" || // ex. function Set() {}
type === "FunctionExpression" || // ex. arr.map(function(Set) {})
type === "ArrowFunctionExpression" || // ex. arr.map(Set => Set.id)
type === "VariableDeclarator" || // ex. const Set = () => {}
type === "ClassDeclaration" || // ex. class Set {}
type === "ImportDefaultSpecifier" || // ex. import Set from 'set';
Expand Down
104 changes: 104 additions & 0 deletions test/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,45 @@ ruleTester.run("compat", rule, {
code: "window",
settings: { browsers: ["ExplorerMobile 10"] },
},
// Early return guard patterns
{
code: `
function setup() {
if (!('serviceWorker' in navigator)) { return; }
navigator.serviceWorker.register('/sw.js');
}
`,
settings: { browsers: ["safari 10.1"] },
},
{
code: `
function setup() {
if (!navigator.serviceWorker) { return; }
navigator.serviceWorker.register('/sw.js');
}
`,
settings: { browsers: ["safari 10.1"] },
},
{
code: `
function init() {
if (!window.fetch) {
throw new Error('fetch not supported');
}
fetch('/api/data');
}
`,
settings: { browsers: ["ie 9"] },
},
{
code: `
function init() {
if (!fetch) return;
fetch('/api/data');
}
`,
settings: { browsers: ["ie 9"] },
},
{
code: "document.fonts()",
settings: { browsers: ["edge 79"] },
Expand Down Expand Up @@ -246,6 +285,36 @@ ruleTester.run("compat", rule, {
`,
settings: { browsers: ["ie 9"] },
},
// Arrow function parameter shadowing
{
code: `
const items = [1, 2, 3];
items.map(fetch => fetch.toString());
`,
settings: { browsers: ["ie 9"] },
},
{
code: `
const schedulers = [{ id: '1', name: 'A' }];
schedulers.map(scheduler => scheduler.name);
`,
settings: { browsers: ["safari 15.6"] },
},
{
code: `
const schedulers = [{ id: '1', name: 'A' }];
schedulers.flatMap(scheduler => scheduler.managedByRoleIds);
`,
settings: { browsers: ["safari 15.6"] },
},
// Function expression parameter shadowing
{
code: `
const items = [1, 2, 3];
items.map(function(fetch) { return fetch.toString(); });
`,
settings: { browsers: ["ie 9"] },
},
{
code: "document.documentElement()",
settings: { browsers: ["Safari 11", "Opera 57", "Edge 17"] },
Expand Down Expand Up @@ -347,6 +416,41 @@ ruleTester.run("compat", rule, {
},
],
},
// Early return with unrelated guard should NOT suppress
{
code: `
function setup() {
if (!someCondition) { return; }
navigator.serviceWorker.register('/sw.js');
}
`,
settings: { browsers: ["safari 10.1"] },
errors: [
{
message:
"navigator.serviceWorker() is not supported in Safari 10.1",
},
],
},
// ignoreConditionalChecks overrides early return guards
{
code: `
function setup() {
if (!('serviceWorker' in navigator)) { return; }
navigator.serviceWorker.register('/sw.js');
}
`,
settings: {
browsers: ["safari 10.1"],
ignoreConditionalChecks: true,
},
errors: [
{
message:
"navigator.serviceWorker() is not supported in Safari 10.1",
},
],
},
{
settings: {
browsers: ["ie 9"],
Expand Down
Loading