diff --git a/src/helpers.ts b/src/helpers.ts index 62c1b3f4..71a63f88 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -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, @@ -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); } diff --git a/src/rules/compat.ts b/src/rules/compat.ts index dda9d327..5f4499ef 100644 --- a/src/rules/compat.ts +++ b/src/rules/compat.ts @@ -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'; diff --git a/test/e2e.spec.ts b/test/e2e.spec.ts index b4c9a2f1..fb23b05f 100644 --- a/test/e2e.spec.ts +++ b/test/e2e.spec.ts @@ -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"] }, @@ -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"] }, @@ -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"],