diff --git a/package-lock.json b/package-lock.json index 9e3b85ae..43c79de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@mdn/browser-compat-data": "^5.3.16", "ast-metadata-inferer": "^0.8.0", "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001534", + "caniuse-lite": "^1.0.30001549", "find-up": "^5.0.0", "lodash.memoize": "^4.1.2", "semver": "^7.5.4" @@ -3608,9 +3608,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001534", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz", - "integrity": "sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==", + "version": "1.0.30001549", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001549.tgz", + "integrity": "sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index a679f692..c7169c23 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "lint": "eslint --ignore-path .gitignore --ext .js,.ts .", "spec": "jest --testPathIgnorePatterns test/e2e-repo.spec.ts /benchmarks-tmp", "spec:e2e": "jest test/e2e-repo.spec.ts", + "spec:watch": "npm run spec -- --watch", "test": "npm run lint && npm run build && npm run spec", "tsc": "tsc", "version": "npm run build" @@ -61,7 +62,13 @@ ], "rules": { "import/extensions": "off", - "import/no-extraneous-dependencies": "off" + "import/no-extraneous-dependencies": "off", + "@typescript-eslint/no-use-before-define": [ + "error", + { + "functions": false + } + ] }, "root": true }, @@ -70,13 +77,14 @@ "testEnvironment": "node", "testPathIgnorePatterns": [ "/benchmarks-tmp/" - ] + ], + "collectCoverage": true }, "dependencies": { "@mdn/browser-compat-data": "^5.3.16", "ast-metadata-inferer": "^0.8.0", "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001534", + "caniuse-lite": "^1.0.30001549", "find-up": "^5.0.0", "lodash.memoize": "^4.1.2", "semver": "^7.5.4" diff --git a/src/constants.ts b/src/constants.ts index ff13313c..701ea91e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,31 +1,6 @@ -export interface TargetNameMappings { - chrome: "Chrome"; - firefox: "Firefox"; - safari: "Safari"; - ios_saf: "iOS Safari"; - ie: "IE"; - ie_mob: "IE Mobile"; - edge: "Edge"; - baidu: "Baidu"; - electron: "Electron"; - blackberry_browser: "Blackberry Browser"; - edge_mobile: "Edge Mobile"; - and_uc: "Android UC Browser"; - and_chrome: "Android Chrome"; - and_firefox: "Android Firefox"; - and_webview: "Android Webview"; - and_samsung: "Samsung Browser"; - and_opera: "Opera Android"; - opera: "Opera"; - opera_mini: "Opera Mini"; - opera_mobile: "Opera Mobile"; - node: "Node.js"; - kaios: "KaiOS"; -} - // Maps an ID to the full name user will see // E.g. during error, user will see full name instead of ID -export const STANDARD_TARGET_NAME_MAPPING: Readonly = { +export const STANDARD_TARGET_NAME_MAPPING = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", @@ -48,10 +23,6 @@ export const STANDARD_TARGET_NAME_MAPPING: Readonly = { opera_mobile: "Opera Mobile", node: "Node.js", kaios: "KaiOS", -}; +} as const; -export enum AstNodeTypes { - MemberExpression = "MemberExpression", - CallExpression = "CallExpression", - NewExpression = "NewExpression", -} +export type TargetNameMappings = typeof STANDARD_TARGET_NAME_MAPPING; diff --git a/src/helpers.ts b/src/helpers.ts index 3f4e9609..efe00877 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,16 +1,31 @@ -/* eslint no-nested-ternary: off */ +import fs from "fs"; + import browserslist from "browserslist"; -import { - AstMetadataApiWithTargetsResolver, - ESLintNode, - BrowserListConfig, - Target, - HandleFailingRule, - Context, - BrowsersListOpts, -} from "./types"; +import type { Rule } from "eslint"; +import type * as ESTree from "estree"; +import findUp from "find-up"; + +import { BrowserListConfig, Target, Context, BrowsersListOpts } from "./types"; import { TargetNameMappings } from "./constants"; +const BABEL_CONFIGS = [ + "babel.config.json", + "babel.config.js", + "babel.config.cjs", + ".babelrc", + ".babelrc.json", + ".babelrc.js", + ".babelrc.cjs", +]; + +export const GLOBALS = ["window", "globalThis"]; + +const enum GuardType { + NONE, + POSITIVE, + NEGATIVE, +} + /* 3) Figures out which browsers user is targeting @@ -20,147 +35,273 @@ import { TargetNameMappings } from "./constants"; - All of the rules have compatibility info attached to them - Each API is given to versioning.ts with compatibility info */ -function isInsideIfStatement(context: Context) { - return context.getAncestors().some((ancestor) => { - return ancestor.type === "IfStatement"; - }); -} -function checkNotInsideIfStatementAndReport( - context: Context, - handleFailingRule: HandleFailingRule, - failingRule: AstMetadataApiWithTargetsResolver, - node: ESLintNode -) { - if (!isInsideIfStatement(context)) { - handleFailingRule(failingRule, node); +export function expressionWouldThrow(node: Rule.Node) { + // If this a bare Identifier, not window / globalThis, and not used + // in a `typeof` expression, then it would throw a `ReferenceError` + if ( + node.type === "Identifier" && + !GLOBALS.includes(node.name) && + !isInsideTypeofCheck(node) + ) { + return true; } -} -export function lintCallExpression( - context: Context, - handleFailingRule: HandleFailingRule, - rules: AstMetadataApiWithTargetsResolver[], - node: ESLintNode -) { - if (!node.callee) return; - const calleeName = node.callee.name; - const failingRule = rules.find((rule) => rule.object === calleeName); - if (failingRule) - checkNotInsideIfStatementAndReport( - context, - handleFailingRule, - failingRule, - node - ); + // These would throw `TypeError`s + if (node.parent.type === "CallExpression") { + return !node.parent.optional; + } + + if (node.parent.type === "NewExpression") { + return true; + } + + // TODO: member access off of the node would also be an error + + return false; } -export function lintNewExpression( - context: Context, - handleFailingRule: HandleFailingRule, - rules: Array, - node: ESLintNode -) { - if (!node.callee) return; - const calleeName = node.callee.name; - const failingRule = rules.find((rule) => rule.object === calleeName); - if (failingRule) - checkNotInsideIfStatementAndReport( - context, - handleFailingRule, - failingRule, - node - ); +export interface IfStatementAndGuardedScope { + /** + * May be true even if guardedScope is undefined, meaning the check exists + * inside an if statement but isn't guarding anything meaningful. + */ + ifStatement: boolean; + guardedScope?: { + scope: Rule.Node; + index: number; + }; } -export function lintExpressionStatement( - context: Context, - handleFailingRule: HandleFailingRule, - rules: AstMetadataApiWithTargetsResolver[], - node: ESLintNode -) { - if (!node?.expression?.name) return; - const failingRule = rules.find( - (rule) => rule.object === node?.expression?.name - ); - if (failingRule) - checkNotInsideIfStatementAndReport( - context, - handleFailingRule, - failingRule, - node - ); +/** + * Checks if the given node is used in an if statement, and if it is, return the + * scope that the guard applies to and after which index it applies. + * Should be called with either a bare Identifier or a MemberExpression. + */ +export function determineIfStatementAndGuardedScope( + node: Rule.Node +): IfStatementAndGuardedScope { + const result = getIfStatementAndGuardType(node); + if (!result) return { ifStatement: false }; + + const [ifStatement, guardType] = result; + if (guardType === GuardType.NONE) return { ifStatement: true }; + + if (guardType === GuardType.POSITIVE) { + // It's okay to use the identifier inside of the if statement + return { + ifStatement: true, + guardedScope: { scope: ifStatement.consequent as Rule.Node, index: 0 }, + }; + } + + if ( + ifStatementHasEarlyReturn(ifStatement) && + isBlockOrProgram(ifStatement.parent) + ) { + // It's okay to use the identifier after the if statement + const scope = ifStatement.parent; + const index = scope.body.indexOf(ifStatement) + 1; + return { ifStatement: true, guardedScope: { scope, index } }; + } + + return { ifStatement: true }; } -function isStringLiteral(node: ESLintNode): boolean { - return node.type === "Literal" && typeof node.value === "string"; +export function isBlockOrProgram( + node: Rule.Node +): node is (ESTree.Program | ESTree.BlockStatement) & Rule.NodeParentExtension { + return node.type === "Program" || node.type === "BlockStatement"; } -function protoChainFromMemberExpression(node: ESLintNode): string[] { - if (!node.object) return [node.name]; - const protoChain = (() => { +function getIfStatementAndGuardType( + node: Rule.Node +): Readonly<[ESTree.IfStatement & Rule.NodeParentExtension, GuardType]> | null { + const ifStatement = findContainingIfStatement(node); + if (!ifStatement) return null; + + let positiveGuard = true; + let expression: Rule.Node = node; + const noGuard = [ifStatement, GuardType.NONE] as const; + + if (isUnaryExpression(node.parent, "typeof")) { + expression = node.parent; + + // unused typeof check + if (expression.parent.type !== "BinaryExpression") return noGuard; + + // figure out which side of the comparison is opposite the typeof check + // typeof fetch === "undefined" // comparee is right + // "undefined" === typeof fetch // comparee is left + const comparee = + expression.parent.left === expression + ? expression.parent.right + : expression.parent.left; + + // unexpected comparison + if (!isStringLiteral(comparee)) return noGuard; + + expression = expression.parent; + + const operatorIsPositive = /^===?$/.test(expression.operator); + const rightIsPositive = comparee.value !== "undefined"; + if ( - node.object.type === "NewExpression" || - node.object.type === "CallExpression" + (operatorIsPositive && rightIsPositive) || + (!operatorIsPositive && !rightIsPositive) ) { - return protoChainFromMemberExpression(node.object.callee!); - } else if (node.object.type === "ArrayExpression") { - return ["Array"]; - } else if (isStringLiteral(node.object)) { - return ["String"]; + // typeof foo === "function" + // typeof foo !== "undefined" + positiveGuard = true; } else { - return protoChainFromMemberExpression(node.object); + // typeof foo !== "function" + // typepf foo === "undefined" + positiveGuard = false; } - })(); - return [...protoChain, node.property!.name]; + } else if (isBinaryExpression(expression.parent, "in")) { + expression = expression.parent; + } else if (isBinaryExpression(expression.parent)) { + // window.fetch == null + // window.fetch === undefined + // window.fetch != null + // window.fetch !== undefined + // null == window.fetch + + const comparee = + expression.parent.left === expression + ? expression.parent.right + : expression.parent.left; + + // unexpected comparee + const compareeValue = nullOrUndefined(comparee as any); + if (!compareeValue) return noGuard; + + expression = expression.parent; + + // unexpected operator + if (!/^[!=]==?$/.test(expression.operator)) return noGuard; + + // you can do == null or == undefined or === undefined, but not === null + const isStrictOperator = expression.operator.length === 3; + const validCompareeNames = isStrictOperator + ? ["undefined"] + : ["null", "undefined"]; + + if (!validCompareeNames.includes(compareeValue)) return noGuard; + + if (expression.operator.startsWith("=")) { + // `window.fetch == null` means we enter the block if the api is + //unsupported, so this is a negative guard + positiveGuard = false; + } + } + + while (expression.parent !== ifStatement) { + expression = expression.parent; + + switch (expression.type) { + case "UnaryExpression": { + if (expression.operator === "!") { + // !window.fetch + // !!window.fetch + // !!!!!!window.fetch + // !(typeof fetch === "undefined") + positiveGuard = !positiveGuard; + } + // else, should we ignore this? + // what about ~window.fetch? + break; + } + + case "LogicalExpression": { + // && is safe for positive guards, + // || is safe for negative guards w/ early returns + if ( + !( + (positiveGuard && expression.operator === "&&") || + (!positiveGuard && expression.operator === "||") + ) + ) { + return noGuard; + } + break; + } + } + } + + return [ + expression.parent, + positiveGuard ? GuardType.POSITIVE : GuardType.NEGATIVE, + ]; +} + +function nullOrUndefined(node: Rule.Node): "null" | "undefined" | null { + if (node.type === "Literal" && node.value === null) { + return "null"; + } + + if (node.type === "Identifier" && node.name === "undefined") { + return "undefined"; + } + + return null; } -export function lintMemberExpression( - context: Context, - handleFailingRule: HandleFailingRule, - rules: Array, - node: ESLintNode +function ifStatementHasEarlyReturn( + node: ESTree.IfStatement & Rule.NodeParentExtension ) { - if (!node.object || !node.property) return; - if ( - !node.object.name || - node.object.name === "window" || - node.object.name === "globalThis" - ) { - const rawProtoChain = protoChainFromMemberExpression(node); - const [firstObj] = rawProtoChain; - const protoChain = - firstObj === "window" || firstObj === "globalThis" - ? rawProtoChain.slice(1) - : rawProtoChain; - const protoChainId = protoChain.join("."); - const failingRule = rules.find( - (rule) => rule.protoChainId === protoChainId - ); - if (failingRule) { - checkNotInsideIfStatementAndReport( - context, - handleFailingRule, - failingRule, - node - ); - } - } else { - const objectName = node.object.name; - const propertyName = node.property.name; - const failingRule = rules.find( - (rule) => - rule.object === objectName && - (rule.property == null || rule.property === propertyName) - ); - if (failingRule) - checkNotInsideIfStatementAndReport( - context, - handleFailingRule, - failingRule, - node - ); + return ( + node.consequent.type === "ReturnStatement" || + node.consequent.type === "ThrowStatement" || + (node.consequent.type === "BlockStatement" && + node.consequent.body.some( + (statement) => + statement.type === "ReturnStatement" || + statement.type === "ThrowStatement" + )) + ); +} + +export function isInsideTypeofCheck(node: Rule.Node) { + return isUnaryExpression(node.parent, "typeof"); +} + +function findContainingIfStatement( + node: Rule.Node +): (ESTree.IfStatement & Rule.NodeParentExtension) | null { + while (node.parent && node.parent.type !== "IfStatement") { + node = node.parent; } + return node.parent; +} + +export function isStringLiteral( + node: ESTree.Node +): node is ESTree.SimpleLiteral & { value: string } { + return node.type === "Literal" && typeof node.value === "string"; +} + +function isBinaryExpression( + node: Rule.Node, + operator?: S +): node is ESTree.BinaryExpression & + Rule.NodeParentExtension & + (S extends string ? { operator: S } : {}) { + return ( + node.type === "BinaryExpression" && + (!operator || node.operator === operator) + ); +} + +function isUnaryExpression( + node: Rule.Node, + operator?: S +): node is ESTree.UnaryExpression & + Rule.NodeParentExtension & + (S extends string ? { operator: S } : {}) { + return ( + node.type === "UnaryExpression" && (!operator || node.operator === operator) + ); } export function reverseTargetMappings( @@ -172,6 +313,139 @@ export function reverseTargetMappings( return Object.fromEntries(reversedEntries); } +interface IdentifierProtoChain { + protoChain: string[]; + expression: Rule.Node; +} + +export function identifierProtoChain( + node: ESTree.Identifier & Rule.NodeParentExtension +): null | IdentifierProtoChain { + const result = identifierProtoChainHelper(node); + if (!result) return null; + + const { expression, protoChain } = result; + + if ( + // TODO: do I need to check the parent? + isBinaryExpression(expression.parent, "in") && + isStringLiteral(expression.parent.left) + ) { + // e.g. `if ("fetch" in window) {}` + // in this case we want "fetch" in the protoChain + protoChain.push(expression.parent.left.value); + } + + return { expression, protoChain }; +} + +/** + * Returns an array of property names from the given identifier, without any leading + * window or globalThis. + */ +function identifierProtoChainHelper( + node: ESTree.Identifier & Rule.NodeParentExtension +): null | IdentifierProtoChain { + let expression: (ESTree.Identifier | ESTree.MemberExpression) & + Rule.NodeParentExtension = node; + + const protoChain: string[] = []; + + function protoChainFromMemberExpressionObject(obj: ESTree.Node) { + switch (obj.type) { + case "Identifier": + protoChain.push(obj.name); + return true; + + case "MemberExpression": + if (obj.property.type !== "Identifier") return false; + if (!protoChainFromMemberExpressionObject(obj.object)) return false; + protoChain.push(obj.property.name); + return true; + + case "NewExpression": + return protoChainFromMemberExpressionObject(obj.callee); + + case "ArrayExpression": + protoChain.push("Array"); + return true; + + case "Literal": + if (typeof obj.value === "string") { + protoChain.push("String"); + return true; + } + return false; + } + + return false; + } + + if ( + expression.parent.type === "MemberExpression" && + expression === expression.parent.property + ) { + if (!protoChainFromMemberExpressionObject(expression.parent.object)) { + return null; + } + } + + protoChain.push(expression.name); + + while ( + expression.parent.type === "MemberExpression" || + (expression.parent.type === "NewExpression" && + expression.parent.parent.type === "MemberExpression") + ) { + // cast is okay here because we're guaranteed to keep looping + // until expression is a MemberExpression + expression = expression.parent as any; + } + + if (GLOBALS.includes(protoChain[0])) { + protoChain.shift(); + } + + return { expression, protoChain }; +} + +/** + * Determine the settings to run this plugin with, including the browserslist targets and + * whether to lint all ES APIs. + */ +export function determineSettings(context: Context) { + const settings = context.settings; + + // Determine lowest targets from browserslist config, which reads user's + // package.json config section. Use config from eslintrc for testing purposes + const browserslistConfig: BrowserListConfig = + settings.browsers || settings.targets || context.options[0]; + + // check for accidental misspellings + if (!settings.browserslistOpts && (settings as any).browsersListOpts) { + console.error( + 'Please ensure you spell `browserslistOpts` with a lowercase "l"!' + ); + } + + const browserslistOpts = settings.browserslistOpts; + + const lintAllEsApis: boolean = + settings.lintAllEsApis === true || + // Attempt to infer polyfilling of ES APIs from babel config + (!settings.polyfills?.includes("es:all") && !isUsingTranspiler(context)); + + const browserslistTargets = parseBrowsersListVersion( + determineTargetsFromConfig( + context.filename, + browserslistConfig, + browserslistOpts + ) + ); + + return { lintAllEsApis, browserslistTargets }; +} + /** * Determine the targets based on the browserslist config object * Get the targets from the eslint config and merge them with targets in browserslist config @@ -238,11 +512,11 @@ export function parseBrowsersListVersion( .map((e: string): Target => { const [target, version] = e.split(" ") as [ keyof TargetNameMappings, - number | string, + string, ]; const parsedVersion: number = (() => { - if (typeof version === "number") return version; + // If any version === 'all', return 0. The only version of op_mini is 'all' if (version === "all") return 0; return version.includes("-") ? parseFloat(version.split("-")[0]) @@ -258,20 +532,33 @@ export function parseBrowsersListVersion( // ex. [a@3, b@3, a@1] => [a@3, a@1, b@3] .sort((a: Target, b: Target): number => { if (b.target === a.target) { - // If any version === 'all', return 0. The only version of op_mini is 'all' - // Otherwise, compare the versions - return typeof b.parsedVersion === "string" || - typeof a.parsedVersion === "string" - ? 0 - : b.parsedVersion - a.parsedVersion; + return b.parsedVersion - a.parsedVersion; } - return b.target > a.target ? 1 : -1; + return a.target.localeCompare(b.target); }) // First last target always has the latest version .filter( (e: Target, i: number, items: Array): boolean => // Check if the current target is the last of its kind. - // If it is, then it's the most recent version. + // If it is, then it's the oldest version. i + 1 === items.length || e.target !== items[i + 1].target ) ); } + +/** + * Determine if a user has a babel config, which we use to infer if the linted code is polyfilled. + */ +function isUsingTranspiler(context: Context): boolean { + const dir = context.filename; + const configPath = findUp.sync(BABEL_CONFIGS, { cwd: dir }); + if (configPath) return true; + + const pkgPath = findUp.sync("package.json", { cwd: dir }); + if (pkgPath) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + // Check if babel property exists + return !!pkg.babel; + } + + return false; +} diff --git a/src/providers/caniuse-provider.ts b/src/providers/caniuse-provider.ts index bc4360a9..2cf94311 100644 --- a/src/providers/caniuse-provider.ts +++ b/src/providers/caniuse-provider.ts @@ -1,5 +1,5 @@ import * as lite from "caniuse-lite"; -import { STANDARD_TARGET_NAME_MAPPING, AstNodeTypes } from "../constants"; +import { STANDARD_TARGET_NAME_MAPPING } from "../constants"; import { AstMetadataApiWithTargetsResolver, Target } from "../types"; /** @@ -71,11 +71,11 @@ function isSupportedByCanIUse( * Return an array of all unsupported targets */ export function getUnsupportedTargets( - node: AstMetadataApiWithTargetsResolver, + this: AstMetadataApiWithTargetsResolver, targets: Target[] ): string[] { return targets - .filter((target) => !isSupportedByCanIUse(node, target)) + .filter((target) => !isSupportedByCanIUse(this, target)) .map(formatTargetNames); } @@ -83,170 +83,156 @@ const CanIUseProvider: Array = [ // new ServiceWorker() { caniuseId: "serviceworkers", - astNodeType: AstNodeTypes.NewExpression, object: "ServiceWorker", }, { caniuseId: "serviceworkers", - astNodeType: AstNodeTypes.MemberExpression, object: "navigator", property: "serviceWorker", }, // document.querySelector() { caniuseId: "queryselector", - astNodeType: AstNodeTypes.MemberExpression, object: "document", property: "querySelector", }, // IntersectionObserver { caniuseId: "intersectionobserver", - astNodeType: AstNodeTypes.NewExpression, object: "IntersectionObserver", }, // ResizeObserver { caniuseId: "resizeobserver", - astNodeType: AstNodeTypes.NewExpression, object: "ResizeObserver", }, // PaymentRequest { caniuseId: "payment-request", - astNodeType: AstNodeTypes.NewExpression, object: "PaymentRequest", }, // Promises { caniuseId: "promises", - astNodeType: AstNodeTypes.NewExpression, object: "Promise", }, { caniuseId: "promises", - astNodeType: AstNodeTypes.MemberExpression, object: "Promise", property: "resolve", }, { caniuseId: "promises", - astNodeType: AstNodeTypes.MemberExpression, object: "Promise", property: "all", }, { caniuseId: "promises", - astNodeType: AstNodeTypes.MemberExpression, object: "Promise", property: "race", }, { caniuseId: "promises", - astNodeType: AstNodeTypes.MemberExpression, object: "Promise", property: "reject", }, // fetch { caniuseId: "fetch", - astNodeType: AstNodeTypes.CallExpression, object: "fetch", }, // document.currentScript() { caniuseId: "document-currentscript", - astNodeType: AstNodeTypes.MemberExpression, object: "document", property: "currentScript", }, // URL { caniuseId: "url", - astNodeType: AstNodeTypes.NewExpression, object: "URL", }, // URLSearchParams { caniuseId: "urlsearchparams", - astNodeType: AstNodeTypes.NewExpression, object: "URLSearchParams", }, // performance.now() { caniuseId: "high-resolution-time", - astNodeType: AstNodeTypes.MemberExpression, object: "performance", property: "now", }, // requestIdleCallback() { caniuseId: "requestidlecallback", - astNodeType: AstNodeTypes.CallExpression, object: "requestIdleCallback", }, // requestAnimationFrame() { caniuseId: "requestanimationframe", - astNodeType: AstNodeTypes.CallExpression, object: "requestAnimationFrame", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "TypedArray", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Int8Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Uint8Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Uint8ClampedArray", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Int16Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Uint16Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Int32Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Uint32Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Float32Array", }, { caniuseId: "typedarrays", - astNodeType: AstNodeTypes.NewExpression, object: "Float64Array", }, + { + caniuseId: "js-regexp-lookbehind", + name: "RegExp Lookbehind Assertions", + regexp: true, + object: "", + }, ].map((rule) => ({ ...rule, getUnsupportedTargets, - id: rule.property ? `${rule.object}.${rule.property}` : rule.object, - protoChainId: rule.property ? `${rule.object}.${rule.property}` : rule.object, - protoChain: rule.property ? [rule.object, rule.property] : [rule.object], + id: rule.property + ? `${rule.object}.${rule.property}` + : rule.object || rule.caniuseId, + protoChainId: rule.property + ? `${rule.object}.${rule.property}` + : rule.object || rule.caniuseId, + protoChain: rule.property + ? [rule.object, rule.property] + : rule.object + ? [rule.object] + : [], })); export default CanIUseProvider; diff --git a/src/providers/mdn-provider.ts b/src/providers/mdn-provider.ts index c47354ee..b6d2eab0 100644 --- a/src/providers/mdn-provider.ts +++ b/src/providers/mdn-provider.ts @@ -12,27 +12,10 @@ const mdnRecords: Map = new Map( apis.map((e) => [e.protoChainId, e]) ); -interface TargetIdMappings { - chrome: "chrome"; - firefox: "firefox"; - opera: "opera"; - safari: "safari"; - safari_ios: "ios_saf"; - ie: "ie"; - edge_mobile: "ie_mob"; - edge: "edge"; - opera_android: "and_opera"; - chrome_android: "and_chrome"; - firefox_android: "and_firefox"; - webview_android: "and_webview"; - samsunginternet_android: "and_samsung"; - nodejs: "node"; -} - /** * Map ids of mdn targets to their "common/friendly" name */ -const targetIdMappings: Readonly = { +const targetIdMappings = { chrome: "chrome", firefox: "firefox", opera: "opera", @@ -47,7 +30,7 @@ const targetIdMappings: Readonly = { webview_android: "and_webview", samsunginternet_android: "and_samsung", nodejs: "node", -}; +} as const; const reversedTargetMappings = reverseTargetMappings(targetIdMappings); @@ -129,11 +112,11 @@ export function isSupportedByMDN( * Return an array of all unsupported targets */ export function getUnsupportedTargets( - node: AstMetadataApiWithTargetsResolver, + this: AstMetadataApiWithTargetsResolver, targets: Target[] ): string[] { return targets - .filter((target) => !isSupportedByMDN(node, target)) + .filter((target) => !isSupportedByMDN(this, target)) .map(formatTargetNames); } @@ -150,12 +133,11 @@ function getMetadataName(metadata: ApiMetadata) { const MdnProvider: Array = apis // Create entries for each ast node type .map((metadata) => - metadata.astNodeTypes.map((astNodeType) => ({ + metadata.astNodeTypes.map(() => ({ ...metadata, name: getMetadataName(metadata), id: metadata.protoChainId, protoChainId: metadata.protoChainId, - astNodeType, object: metadata.protoChain[0], // @TODO Handle cases where 'prototype' is in protoChain property: metadata.protoChain[1], diff --git a/src/rules/compat.ts b/src/rules/compat.ts index 98f8ac28..498cf2eb 100644 --- a/src/rules/compat.ts +++ b/src/rules/compat.ts @@ -5,54 +5,40 @@ * Tells eslint to lint certain nodes (lintCallExpression, lintMemberExpression, lintNewExpression) * Gets protochain for the ESLint nodes the plugin is interested in */ -import fs from "fs"; -import findUp from "find-up"; import memoize from "lodash.memoize"; import { Rule } from "eslint"; +import type * as ESTree from "estree"; import { - lintCallExpression, - lintMemberExpression, - lintNewExpression, - lintExpressionStatement, - parseBrowsersListVersion, - determineTargetsFromConfig, + determineSettings, + determineIfStatementAndGuardedScope, + isBlockOrProgram, + identifierProtoChain, + expressionWouldThrow, + isStringLiteral, } from "../helpers"; // will be deprecated and introduced to this file import { - ESLintNode, AstMetadataApiWithTargetsResolver, - BrowserListConfig, HandleFailingRule, Context, - BrowsersListOpts, } from "../types"; import { nodes } from "../providers"; -type ESLint = { - [astNodeTypeName: string]: (node: ESLintNode) => void; -}; - -function getName(node: ESLintNode): string { +function getName(node: Rule.Node): string | null { switch (node.type) { - case "NewExpression": { - return node.callee!.name; + case "Identifier": { + return node.name; } case "MemberExpression": { - return node.object!.name; - } - case "ExpressionStatement": { - return node.expression!.name; - } - case "CallExpression": { - return node.callee!.name; + return (node.object as ESTree.Identifier).name; } default: - throw new Error("not found"); + return null; } } function generateErrorName(rule: AstMetadataApiWithTargetsResolver): string { if (rule.name) return rule.name; - if (rule.property) return `${rule.object}.${rule.property}()`; + if (rule.property) return `${rule.object}.${rule.property}`; return rule.object; } @@ -65,7 +51,7 @@ function isPolyfilled( context: Context, rule: AstMetadataApiWithTargetsResolver ): boolean { - if (!context.settings?.polyfills) return false; + if (!context.settings.polyfills) return false; const polyfills = getPolyfillSet(JSON.stringify(context.settings.polyfills)); return ( // v2 allowed users to select polyfills based off their caniuseId. This is @@ -75,70 +61,38 @@ function isPolyfilled( ); } -const babelConfigs = [ - "babel.config.json", - "babel.config.js", - "babel.config.cjs", - ".babelrc", - ".babelrc.json", - ".babelrc.js", - ".babelrc.cjs", -]; - -/** - * Determine if a user has a babel config, which we use to infer if the linted code is polyfilled. - */ -function isUsingTranspiler(context: Context): boolean { - const dir = context.getFilename(); - const configPath = findUp.sync(babelConfigs, { - cwd: dir, - }); - if (configPath) return true; - const pkgPath = findUp.sync("package.json", { - cwd: dir, - }); - // Check if babel property exists - if (pkgPath) { - const pkg = JSON.parse(fs.readFileSync(pkgPath).toString()); - return !!pkg.babel; - } - return false; -} - -type RulesFilteredByTargets = { - CallExpression: AstMetadataApiWithTargetsResolver[]; - NewExpression: AstMetadataApiWithTargetsResolver[]; - MemberExpression: AstMetadataApiWithTargetsResolver[]; - ExpressionStatement: AstMetadataApiWithTargetsResolver[]; -}; - /** * A small optimization that only lints APIs that are not supported by targeted browsers. * For example, if the user is targeting chrome 50, which supports the fetch API, it is * wasteful to lint calls to fetch. */ const getRulesForTargets = memoize( - (targetsJSON: string, lintAllEsApis: boolean): RulesFilteredByTargets => { - const result = { - CallExpression: [] as AstMetadataApiWithTargetsResolver[], - NewExpression: [] as AstMetadataApiWithTargetsResolver[], - MemberExpression: [] as AstMetadataApiWithTargetsResolver[], - ExpressionStatement: [] as AstMetadataApiWithTargetsResolver[], - }; + (targetsJSON: string, lintAllEsApis: boolean) => { const targets = JSON.parse(targetsJSON); - nodes - .filter((node) => (lintAllEsApis ? true : node.kind !== "es")) - .forEach((node) => { - if (!node.getUnsupportedTargets(node, targets).length) return; - result[node.astNodeType].push(node); - }); + const result: { + objects: Record; + regexps: Record; + } = { objects: {}, regexps: {} }; + + for (const node of nodes) { + if ( + (lintAllEsApis || node.kind !== "es") && + node.getUnsupportedTargets(targets).length > 0 + ) { + if (node.regexp) { + result.regexps[node.protoChainId] = node; + } else { + result.objects[node.protoChainId] = node; + } + } + } return result; } ); -export default { +const ruleModule: Rule.RuleModule = { meta: { docs: { description: "Ensure cross-browser API compatibility", @@ -149,38 +103,8 @@ export default { type: "problem", schema: [{ type: "string" }], }, - create(context: Context): ESLint { - // Determine lowest targets from browserslist config, which reads user's - // package.json config section. Use config from eslintrc for testing purposes - const browserslistConfig: BrowserListConfig = - context.settings?.browsers || - context.settings?.targets || - context.options[0]; - - if ( - !context.settings?.browserslistOpts && - // @ts-expect-error Checking for accidental misspellings - context.settings.browsersListOpts - ) { - console.error( - 'Please ensure you spell `browserslistOpts` with a lowercase "l"!' - ); - } - const browserslistOpts: BrowsersListOpts | undefined = - context.settings?.browserslistOpts; - - const lintAllEsApis: boolean = - context.settings?.lintAllEsApis === true || - // Attempt to infer polyfilling of ES APIs from babel config - (!context.settings?.polyfills?.includes("es:all") && - !isUsingTranspiler(context)); - const browserslistTargets = parseBrowsersListVersion( - determineTargetsFromConfig( - context.getFilename(), - browserslistConfig, - browserslistOpts - ) - ); + create(context: Context) { + const { lintAllEsApis, browserslistTargets } = determineSettings(context); // Stringify to support memoization; browserslistConfig is always an array of new objects. const targetedRules = getRulesForTargets( @@ -188,83 +112,157 @@ export default { lintAllEsApis ); - type Error = { - message: string; - node: ESLintNode; - }; - - const errors: Error[] = []; + const reportedNodes = new Set(); const handleFailingRule: HandleFailingRule = ( - node: AstMetadataApiWithTargetsResolver, - eslintNode: ESLintNode + rule: AstMetadataApiWithTargetsResolver, + node: Rule.Node ) => { - if (isPolyfilled(context, node)) return; - errors.push({ - node: eslintNode, + if (isPolyfilled(context, rule)) return; + if (!shouldIncludeError(node)) return; + + reportedNodes.add(node); + + // TODO: name should never include leading "window" or "globalThis" + // and location should also not include it, we have to compute location ourselves + + context.report({ + node, message: [ - generateErrorName(node), + generateErrorName(rule), "is not supported in", - node.getUnsupportedTargets(node, browserslistTargets).join(", "), + rule.getUnsupportedTargets(browserslistTargets).join(", "), ].join(" "), }); }; const identifiers = new Set(); + const guardedScopes = new Map(); + + function shouldIncludeError(node: Rule.Node) { + // check if this node has already been reported + // TODO: ideally our rules would be smart enough to only include the + // highest member of the property chain if the members have the exact same + // browser support window, so we don't have the issue of duplicates like + // Promise and Promise.resolve, etc. + if (reportedNodes.has(node)) { + return false; + } + + // This matches a rule but it's defined in the global scope + if (identifiers.has(getName(node))) return false; + + let expression: Rule.Node = node; + while (!isBlockOrProgram(expression.parent)) { + expression = expression.parent; + } + + // expression is a child of a Program or BlockStatement + const scope = expression.parent; + const guardedScope = guardedScopes.get(scope); + if (guardedScope != null) { + const index = scope.body.findIndex((value) => value === expression); + if (index >= guardedScope) { + return false; + } + } + + return true; + } return { - CallExpression: lintCallExpression.bind( - null, - context, - handleFailingRule, - targetedRules.CallExpression - ), - NewExpression: lintNewExpression.bind( - null, - context, - handleFailingRule, - targetedRules.NewExpression - ), - ExpressionStatement: lintExpressionStatement.bind( - null, - context, - handleFailingRule, - [...targetedRules.MemberExpression, ...targetedRules.CallExpression] - ), - MemberExpression: lintMemberExpression.bind( - null, - context, - handleFailingRule, - [ - ...targetedRules.MemberExpression, - ...targetedRules.CallExpression, - ...targetedRules.NewExpression, - ] - ), - // Keep track of all the defined variables. Do not report errors for nodes that are not defined - Identifier(node: ESLintNode) { - if (node.parent) { - const { type } = node.parent; - if ( - type === "Property" || // ex. const { Set } = require('immutable'); - type === "FunctionDeclaration" || // ex. function Set() {} - type === "VariableDeclarator" || // ex. const Set = () => {} - type === "ClassDeclaration" || // ex. class Set {} - type === "ImportDefaultSpecifier" || // ex. import Set from 'set'; - type === "ImportSpecifier" || // ex. import {Set} from 'set'; - type === "ImportDeclaration" // ex. import {Set} from 'set'; - ) { + Identifier(node) { + if (node.name === "undefined") return; + + // Keep track of all the defined variables. Do not report errors for nodes that are not defined + switch (node.parent.type) { + case "Property": // ex. const { Set } = require('immutable'); + case "FunctionDeclaration": // ex. function Set() {} + case "VariableDeclarator": // ex. const Set = () => {} + case "ClassDeclaration": // ex. class Set {} + case "ImportDefaultSpecifier": // ex. import Set from 'set'; + case "ImportSpecifier": // ex. import {Set} from 'set'; + case "ImportDeclaration": // ex. import {Set} from 'set'; identifiers.add(node.name); - } + // These would never be errors so we can return early + return; } + + // Check if identifier is one we care about + const result = identifierProtoChain(node); + if (!result) return; + + // Check if the identifier name matches any of the ones in our list + const protoChainId = result.protoChain.join("."); + const rule = targetedRules.objects[protoChainId]; + if (!rule) return; + + if (expressionWouldThrow(result.expression)) { + handleFailingRule(rule, result.expression); + return; + } + + // If it is an optional function call, then we can bail + if ( + result.expression.parent.type === "CallExpression" && + result.expression.parent.optional + ) { + return; + } + + const { ifStatement, guardedScope } = + determineIfStatementAndGuardedScope(result.expression); + if (guardedScope) { + guardedScopes.set(guardedScope.scope, guardedScope.index); + return; + } + + // This identifier is used inside an if statement and won't cause any + // runtime issues, so don't report anything. + if (ifStatement) return; + + // This node isn't guarding an if statement, so report it as an error. + handleFailingRule(rule, result.expression); }, - "Program:exit": () => { - // Get a map of all the variables defined in the root scope (not the global scope) - // const variablesMap = context.getScope().childScopes.map(e => e.set)[0]; - errors - .filter((error) => !identifiers.has(getName(error.node))) - .forEach((node) => context.report(node as Rule.ReportDescriptor)); - }, + + ...(Object.keys(targetedRules.regexps).length === 0 + ? {} + : { + 'NewExpression[callee.name="RegExp"],Literal[regex]'( + node: Rule.Node + ) { + let regexString: string | undefined; + + if ( + node.type === "NewExpression" && + isStringLiteral(node.arguments[0]) + ) { + regexString = node.arguments[0].value; + } else if (node.type === "Literal") { + regexString = (node as ESTree.RegExpLiteral).regex.pattern; + } + + if (!regexString) return; + + for (const rule of Object.values(targetedRules.regexps)) { + switch (rule.id) { + case "js-regexp-lookbehind": { + if ( + regexString.includes("(?<=") || + regexString.includes("(?; @@ -27,48 +26,34 @@ type AstMetadataApi = { export interface Target { target: keyof TargetNameMappings; parsedVersion: number; - version: number | string | "all"; + version: string | "all"; } export type HandleFailingRule = ( node: AstMetadataApiWithTargetsResolver, - eslintNode: ESLintNode + eslintNode: Rule.Node ) => void; export type TargetNames = Array; -export type ESLintNode = { - name: string; - type: string; - value?: unknown; - object?: ESLintNode; - parent?: ESLintNode; - expression?: ESLintNode; - property?: ESLintNode; - callee?: ESLintNode & { - name: string; - type?: string; - }; -}; - export interface AstMetadataApiWithTargetsResolver extends AstMetadataApi { id: string; caniuseId?: string; kind?: APIKind; - getUnsupportedTargets: ( - node: AstMetadataApiWithTargetsResolver, - targets: Target[] - ) => Array; + regexp?: boolean; + getUnsupportedTargets: (targets: Target[]) => Array; } export interface Context extends Rule.RuleContext { - settings: { - targets?: string[]; - browsers?: Array; - polyfills?: Array; - lintAllEsApis?: boolean; - browserslistOpts?: BrowsersListOpts; - }; + settings: Settings; +} + +export interface Settings { + targets?: Array; + browsers?: Array; + polyfills?: Array; + lintAllEsApis?: boolean; + browserslistOpts?: BrowsersListOpts; } export interface BrowsersListOpts diff --git a/test/__snapshots__/helpers.spec.ts.snap b/test/__snapshots__/helpers.spec.ts.snap index 26591305..dfa48dd3 100644 --- a/test/__snapshots__/helpers.spec.ts.snap +++ b/test/__snapshots__/helpers.spec.ts.snap @@ -3,9 +3,9 @@ exports[`Versioning should get lowest target versions 1`] = ` [ { - "parsedVersion": 7, - "target": "node", - "version": "7", + "parsedVersion": 20, + "target": "chrome", + "version": "20", }, { "parsedVersion": 50.5, @@ -13,9 +13,9 @@ exports[`Versioning should get lowest target versions 1`] = ` "version": "50.5", }, { - "parsedVersion": 20, - "target": "chrome", - "version": "20", + "parsedVersion": 7, + "target": "node", + "version": "7", }, ] `; @@ -23,49 +23,49 @@ exports[`Versioning should get lowest target versions 1`] = ` exports[`Versioning should support multi env config in browserslist package.json 1`] = ` [ { - "parsedVersion": 18, - "target": "samsung", - "version": "18.0", + "parsedVersion": 116, + "target": "and_chr", + "version": "116", }, { - "parsedVersion": 16.2, - "target": "safari", - "version": "16.2", + "parsedVersion": 116, + "target": "and_ff", + "version": "116", }, { - "parsedVersion": 98, - "target": "opera", - "version": "98", + "parsedVersion": 13.1, + "target": "and_qq", + "version": "13.1", }, { - "parsedVersion": 73, - "target": "op_mob", - "version": "73", + "parsedVersion": 15.5, + "target": "and_uc", + "version": "15.5", }, { - "parsedVersion": 0, - "target": "op_mini", - "version": "all", + "parsedVersion": 116, + "target": "android", + "version": "116", }, { - "parsedVersion": 2.5, - "target": "kaios", - "version": "2.5", + "parsedVersion": 13.18, + "target": "baidu", + "version": "13.18", }, { - "parsedVersion": 16.1, - "target": "ios_saf", - "version": "16.1", + "parsedVersion": 7, + "target": "bb", + "version": "7", }, { - "parsedVersion": 10, - "target": "ie_mob", - "version": "10", + "parsedVersion": 109, + "target": "chrome", + "version": "109", }, { - "parsedVersion": 9, - "target": "ie", - "version": "9", + "parsedVersion": 113, + "target": "edge", + "version": "113", }, { "parsedVersion": 102, @@ -73,49 +73,49 @@ exports[`Versioning should support multi env config in browserslist package.json "version": "102", }, { - "parsedVersion": 113, - "target": "edge", - "version": "113", + "parsedVersion": 9, + "target": "ie", + "version": "9", }, { - "parsedVersion": 109, - "target": "chrome", - "version": "109", + "parsedVersion": 10, + "target": "ie_mob", + "version": "10", }, { - "parsedVersion": 7, - "target": "bb", - "version": "7", + "parsedVersion": 15.6, + "target": "ios_saf", + "version": "15.6-15.7", }, { - "parsedVersion": 13.18, - "target": "baidu", - "version": "13.18", + "parsedVersion": 2.5, + "target": "kaios", + "version": "2.5", }, { - "parsedVersion": 116, - "target": "android", - "version": "116", + "parsedVersion": 0, + "target": "op_mini", + "version": "all", }, { - "parsedVersion": 15.5, - "target": "and_uc", - "version": "15.5", + "parsedVersion": 73, + "target": "op_mob", + "version": "73", }, { - "parsedVersion": 13.1, - "target": "and_qq", - "version": "13.1", + "parsedVersion": 99, + "target": "opera", + "version": "99", }, { - "parsedVersion": 116, - "target": "and_ff", - "version": "116", + "parsedVersion": 16.3, + "target": "safari", + "version": "16.3", }, { - "parsedVersion": 116, - "target": "and_chr", - "version": "116", + "parsedVersion": 19, + "target": "samsung", + "version": "19.0", }, ] `; @@ -133,39 +133,39 @@ exports[`Versioning should support resolving browserslist config in subdirectory exports[`Versioning should support resolving browserslist config in subdirectory 2`] = ` [ { - "parsedVersion": 20, - "target": "samsung", - "version": "20", + "parsedVersion": 116, + "target": "and_chr", + "version": "116", }, { - "parsedVersion": 15.6, - "target": "safari", - "version": "15.6", + "parsedVersion": 116, + "target": "and_ff", + "version": "116", }, { - "parsedVersion": 99, - "target": "opera", - "version": "99", + "parsedVersion": 13.1, + "target": "and_qq", + "version": "13.1", }, { - "parsedVersion": 73, - "target": "op_mob", - "version": "73", + "parsedVersion": 15.5, + "target": "and_uc", + "version": "15.5", }, { - "parsedVersion": 0, - "target": "op_mini", - "version": "all", + "parsedVersion": 116, + "target": "android", + "version": "116", }, { - "parsedVersion": 2.5, - "target": "kaios", - "version": "2.5", + "parsedVersion": 109, + "target": "chrome", + "version": "109", }, { - "parsedVersion": 16, - "target": "ios_saf", - "version": "16.0", + "parsedVersion": 114, + "target": "edge", + "version": "114", }, { "parsedVersion": 102, @@ -173,39 +173,39 @@ exports[`Versioning should support resolving browserslist config in subdirectory "version": "102", }, { - "parsedVersion": 114, - "target": "edge", - "version": "114", + "parsedVersion": 15.6, + "target": "ios_saf", + "version": "15.6-15.7", }, { - "parsedVersion": 109, - "target": "chrome", - "version": "109", + "parsedVersion": 2.5, + "target": "kaios", + "version": "2.5", }, { - "parsedVersion": 116, - "target": "android", - "version": "116", + "parsedVersion": 0, + "target": "op_mini", + "version": "all", }, { - "parsedVersion": 15.5, - "target": "and_uc", - "version": "15.5", + "parsedVersion": 73, + "target": "op_mob", + "version": "73", }, { - "parsedVersion": 13.1, - "target": "and_qq", - "version": "13.1", + "parsedVersion": 100, + "target": "opera", + "version": "100", }, { - "parsedVersion": 116, - "target": "and_ff", - "version": "116", + "parsedVersion": 15.6, + "target": "safari", + "version": "15.6", }, { - "parsedVersion": 116, - "target": "and_chr", - "version": "116", + "parsedVersion": 21, + "target": "samsung", + "version": "21", }, ] `; @@ -213,49 +213,49 @@ exports[`Versioning should support resolving browserslist config in subdirectory exports[`Versioning should support single array config in browserslist package.json 1`] = ` [ { - "parsedVersion": 18, - "target": "samsung", - "version": "18.0", + "parsedVersion": 116, + "target": "and_chr", + "version": "116", }, { - "parsedVersion": 16.2, - "target": "safari", - "version": "16.2", + "parsedVersion": 116, + "target": "and_ff", + "version": "116", }, { - "parsedVersion": 98, - "target": "opera", - "version": "98", + "parsedVersion": 13.1, + "target": "and_qq", + "version": "13.1", }, { - "parsedVersion": 73, - "target": "op_mob", - "version": "73", + "parsedVersion": 15.5, + "target": "and_uc", + "version": "15.5", }, { - "parsedVersion": 0, - "target": "op_mini", - "version": "all", + "parsedVersion": 116, + "target": "android", + "version": "116", }, { - "parsedVersion": 2.5, - "target": "kaios", - "version": "2.5", + "parsedVersion": 13.18, + "target": "baidu", + "version": "13.18", }, { - "parsedVersion": 16.1, - "target": "ios_saf", - "version": "16.1", + "parsedVersion": 7, + "target": "bb", + "version": "7", }, { - "parsedVersion": 10, - "target": "ie_mob", - "version": "10", + "parsedVersion": 108, + "target": "chrome", + "version": "108", }, { - "parsedVersion": 9, - "target": "ie", - "version": "9", + "parsedVersion": 113, + "target": "edge", + "version": "113", }, { "parsedVersion": 102, @@ -263,94 +263,109 @@ exports[`Versioning should support single array config in browserslist package.j "version": "102", }, { - "parsedVersion": 113, - "target": "edge", - "version": "113", + "parsedVersion": 9, + "target": "ie", + "version": "9", }, { - "parsedVersion": 107, - "target": "chrome", - "version": "107", + "parsedVersion": 10, + "target": "ie_mob", + "version": "10", }, { - "parsedVersion": 7, - "target": "bb", - "version": "7", + "parsedVersion": 15.6, + "target": "ios_saf", + "version": "15.6-15.7", }, { - "parsedVersion": 13.18, - "target": "baidu", - "version": "13.18", + "parsedVersion": 2.5, + "target": "kaios", + "version": "2.5", }, { - "parsedVersion": 116, - "target": "android", - "version": "116", + "parsedVersion": 0, + "target": "op_mini", + "version": "all", }, { - "parsedVersion": 15.5, - "target": "and_uc", - "version": "15.5", + "parsedVersion": 73, + "target": "op_mob", + "version": "73", }, { - "parsedVersion": 13.1, - "target": "and_qq", - "version": "13.1", + "parsedVersion": 99, + "target": "opera", + "version": "99", }, { - "parsedVersion": 116, - "target": "and_ff", - "version": "116", + "parsedVersion": 16.3, + "target": "safari", + "version": "16.3", }, { - "parsedVersion": 116, - "target": "and_chr", - "version": "116", + "parsedVersion": 19, + "target": "samsung", + "version": "19.0", }, ] `; -exports[`Versioning should support string config in rule option 1`] = ` +exports[`Versioning should support single version config in browserslist package.json 1`] = ` [ + { + "parsedVersion": 32, + "target": "chrome", + "version": "32", + }, { "parsedVersion": 20, - "target": "samsung", + "target": "firefox", "version": "20", }, { - "parsedVersion": 15.6, + "parsedVersion": 9, + "target": "ie", + "version": "9", + }, + { + "parsedVersion": 8, "target": "safari", - "version": "15.6", + "version": "8", }, +] +`; + +exports[`Versioning should support string config in rule option 1`] = ` +[ { - "parsedVersion": 99, - "target": "opera", - "version": "99", + "parsedVersion": 116, + "target": "and_chr", + "version": "116", }, { - "parsedVersion": 73, - "target": "op_mob", - "version": "73", + "parsedVersion": 116, + "target": "and_ff", + "version": "116", }, { - "parsedVersion": 0, - "target": "op_mini", - "version": "all", + "parsedVersion": 13.1, + "target": "and_qq", + "version": "13.1", }, { - "parsedVersion": 2.5, - "target": "kaios", - "version": "2.5", + "parsedVersion": 15.5, + "target": "and_uc", + "version": "15.5", }, { - "parsedVersion": 16, - "target": "ios_saf", - "version": "16.0", + "parsedVersion": 116, + "target": "android", + "version": "116", }, { - "parsedVersion": 102, - "target": "firefox", - "version": "102", + "parsedVersion": 109, + "target": "chrome", + "version": "109", }, { "parsedVersion": 114, @@ -358,34 +373,44 @@ exports[`Versioning should support string config in rule option 1`] = ` "version": "114", }, { - "parsedVersion": 109, - "target": "chrome", - "version": "109", + "parsedVersion": 102, + "target": "firefox", + "version": "102", }, { - "parsedVersion": 116, - "target": "android", - "version": "116", + "parsedVersion": 15.6, + "target": "ios_saf", + "version": "15.6-15.7", }, { - "parsedVersion": 15.5, - "target": "and_uc", - "version": "15.5", + "parsedVersion": 2.5, + "target": "kaios", + "version": "2.5", }, { - "parsedVersion": 13.1, - "target": "and_qq", - "version": "13.1", + "parsedVersion": 0, + "target": "op_mini", + "version": "all", }, { - "parsedVersion": 116, - "target": "and_ff", - "version": "116", + "parsedVersion": 73, + "target": "op_mob", + "version": "73", }, { - "parsedVersion": 116, - "target": "and_chr", - "version": "116", + "parsedVersion": 100, + "target": "opera", + "version": "100", + }, + { + "parsedVersion": 15.6, + "target": "safari", + "version": "15.6", + }, + { + "parsedVersion": 21, + "target": "samsung", + "version": "21", }, ] `; diff --git a/test/caniuse-provider.spec.ts b/test/caniuse-provider.spec.ts index f275653d..80a7c07e 100644 --- a/test/caniuse-provider.spec.ts +++ b/test/caniuse-provider.spec.ts @@ -14,7 +14,7 @@ describe("CanIUseProvider", () => { expectRangeResultJSON.browsers ); const targets = parseBrowsersListVersion(config); - const result = getUnsupportedTargets(node, targets); + const result = getUnsupportedTargets.call(node, targets); expect(result).toMatchSnapshot(); }); }); diff --git a/test/e2e.spec.ts b/test/e2e.spec.ts index 05450290..a4e6098f 100644 --- a/test/e2e.spec.ts +++ b/test/e2e.spec.ts @@ -9,7 +9,6 @@ const ruleTester = new RuleTester({ }, }); -// @ts-ignore ruleTester.run("compat", rule, { valid: [ // Ignore ES APIs if config detected @@ -22,23 +21,23 @@ ruleTester.run("compat", rule, { // Feature detection Cases { code: ` - if (fetch) { - fetch() + if (Array.prototype.flat) { + new Array.flat() } `, settings: { browsers: ["ExplorerMobile 10"] }, }, { code: ` - if (Array.prototype.flat) { - new Array.flat() + if (window.fetch) { + fetch() } `, settings: { browsers: ["ExplorerMobile 10"] }, }, { code: ` - if (fetch && otherConditions) { + if ('fetch' in window) { fetch() } `, @@ -46,7 +45,28 @@ ruleTester.run("compat", rule, { }, { code: ` - if (window.fetch) { + if (!window.fetch) return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if (typeof fetch === 'undefined') return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if (typeof window.fetch === 'undefined') return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if ("undefined" != typeof fetch) { fetch() } `, @@ -54,12 +74,40 @@ ruleTester.run("compat", rule, { }, { code: ` - if ('fetch' in window) { + if (window.fetch == null) return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if (window.fetch != null) { fetch() } `, settings: { browsers: ["ExplorerMobile 10"] }, }, + { + code: ` + if (null == window.fetch) return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if (undefined === window.fetch) throw new Error() + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, + { + code: ` + if (!!!(window.fetch != null)) return + fetch() + `, + settings: { browsers: ["ExplorerMobile 10"] }, + }, { code: "window", settings: { browsers: ["ExplorerMobile 10"] }, @@ -250,17 +298,67 @@ ruleTester.run("compat", rule, { browsers: ["chrome 49", "safari 10.1", "firefox 44"], }, }, + { + code: "window.fetch?.('example.com')", + settings: { browsers: ["ie 9"] }, + }, + { + code: "/(?<=pattern)/", + settings: { browsers: ["safari 16.4"] }, + }, ], invalid: [ { - code: "window?.fetch?.('example.com')", - settings: { browsers: ["ie 9"] }, + settings: { browsers: ["ExplorerMobile 10"] }, + code: ` + if (fetch) { + fetch() + } + `, errors: [ - { - message: "fetch is not supported in IE 9", - }, + // This will report for both fetch identifiers + // TODO: the first one could use a different error that it is not a + // valid way to check for the existence of fetch + { message: "fetch is not supported in IE Mobile 10" }, + { message: "fetch is not supported in IE Mobile 10" }, + ], + }, + { + settings: { browsers: ["ExplorerMobile 10"] }, + code: ` + if (fetch && otherConditions) { + fetch() + } + `, + errors: [ + { message: "fetch is not supported in IE Mobile 10" }, + { message: "fetch is not supported in IE Mobile 10" }, ], }, + { + settings: { browsers: ["ExplorerMobile 10"] }, + code: ` + if (!!!!(window.fetch != null)) return + fetch() + `, + errors: [{ message: "fetch is not supported in IE Mobile 10" }], + }, + { + settings: { browsers: ["ExplorerMobile 10"] }, + code: ` + if (window.fetch === null) return + fetch() + `, + errors: [{ message: "fetch is not supported in IE Mobile 10" }], + }, + { + settings: { browsers: ["ExplorerMobile 10"] }, + code: ` + if (!window.fetch) {} + fetch() + `, + errors: [{ message: "fetch is not supported in IE Mobile 10" }], + }, { settings: { browsers: ["ie 9"], @@ -271,15 +369,9 @@ ruleTester.run("compat", rule, { new SharedWorker(); `, errors: [ - { - message: "navigator.hardwareConcurrency() is not supported in IE 9", - }, - { - message: "navigator.serviceWorker() is not supported in IE 9", - }, - { - message: "SharedWorker is not supported in IE 9", - }, + { message: "navigator.hardwareConcurrency() is not supported in IE 9" }, + { message: "navigator.serviceWorker() is not supported in IE 9" }, + { message: "SharedWorker is not supported in IE 9" }, ], }, { @@ -325,7 +417,7 @@ ruleTester.run("compat", rule, { errors: [ { message: - "Promise.allSettled() is not supported in Safari 12, Chrome 72", + "Promise.allSettled() is not supported in Chrome 72, Safari 12", }, ], }, @@ -344,81 +436,43 @@ ruleTester.run("compat", rule, { new Set() `, settings: { browsers: ["ie 9"] }, - errors: [ - { - message: "Set is not supported in IE 9", - type: "NewExpression", - }, - ], + errors: [{ message: "Set is not supported in IE 9" }], }, { code: "new Set()", settings: { browsers: ["ie 9"] }, - errors: [ - { - message: "Set is not supported in IE 9", - type: "NewExpression", - }, - ], + errors: [{ message: "Set is not supported in IE 9" }], }, { code: "new TypedArray()", settings: { browsers: ["ie 9"] }, - errors: [ - { - message: "TypedArray is not supported in IE 9", - type: "NewExpression", - }, - ], + errors: [{ message: "TypedArray is not supported in IE 9" }], }, { code: "new Int8Array()", settings: { browsers: ["ie 9"] }, - errors: [ - { - message: "Int8Array is not supported in IE 9", - type: "NewExpression", - }, - ], + errors: [{ message: "Int8Array is not supported in IE 9" }], }, { code: "new AnimationEvent", settings: { browsers: ["chrome 40"] }, - errors: [ - { - message: "AnimationEvent is not supported in Chrome 40", - type: "NewExpression", - }, - ], + errors: [{ message: "AnimationEvent is not supported in Chrome 40" }], }, { code: "Object.values({})", settings: { browsers: ["safari 9"] }, - errors: [ - { - message: "Object.values() is not supported in Safari 9", - type: "MemberExpression", - }, - ], + errors: [{ message: "Object.values() is not supported in Safari 9" }], }, { code: "new ServiceWorker()", settings: { browsers: ["chrome 31"] }, - errors: [ - { - message: "ServiceWorker is not supported in Chrome 31", - type: "NewExpression", - }, - ], + errors: [{ message: "ServiceWorker is not supported in Chrome 31" }], }, { code: "new IntersectionObserver(() => {}, {});", settings: { browsers: ["chrome 49"] }, errors: [ - { - message: "IntersectionObserver is not supported in Chrome 49", - type: "NewExpression", - }, + { message: "IntersectionObserver is not supported in Chrome 49" }, ], }, { @@ -442,20 +496,14 @@ ruleTester.run("compat", rule, { errors: [ { message: - "WebAssembly is not supported in Safari 10.1, Opera 12.1, iOS Safari 10.3, IE 10, Edge 14", - type: "MemberExpression", + "WebAssembly is not supported in Edge 14, IE 10, iOS Safari 10.3, Opera 12.1, Safari 10.1", }, ], }, { code: "new PaymentRequest(methodData, details, options)", settings: { browsers: ["chrome 57"] }, - errors: [ - { - message: "PaymentRequest is not supported in Chrome 57", - type: "NewExpression", - }, - ], + errors: [{ message: "PaymentRequest is not supported in Chrome 57" }], }, { code: "navigator.serviceWorker", @@ -463,129 +511,70 @@ ruleTester.run("compat", rule, { errors: [ { message: "navigator.serviceWorker() is not supported in Safari 10.1", - type: "MemberExpression", }, ], }, { code: "window.document.fonts()", settings: { browsers: ["ie 8"] }, - errors: [ - { - message: "document.fonts() is not supported in IE 8", - type: "MemberExpression", - }, - ], + errors: [{ message: "document.fonts() is not supported in IE 8" }], }, { code: "new Map().size", settings: { browsers: ["ie 8"] }, - errors: [ - { - message: "Map.size() is not supported in IE 8", - type: "MemberExpression", - }, - { - message: "Map is not supported in IE 8", - type: "NewExpression", - }, - ], + errors: [{ message: "Map is not supported in IE 8" }], }, { code: "new window.Map().size", settings: { browsers: ["ie 8"] }, - errors: [ - { - message: "Map.size() is not supported in IE 8", - type: "MemberExpression", - }, - { - message: "Map is not supported in IE 8", - type: "MemberExpression", - }, - ], + errors: [{ message: "Map is not supported in IE 8" }], }, { code: "new Array().flat", settings: { browsers: ["ie 8"] }, - errors: [ - { - message: "Array.flat() is not supported in IE 8", - type: "MemberExpression", - }, - ], + errors: [{ message: "Array.flat() is not supported in IE 8" }], }, { code: "globalThis.fetch()", settings: { browsers: ["ie 11"] }, - errors: [ - { - message: "fetch is not supported in IE 11", - type: "MemberExpression", - }, - ], + errors: [{ message: "fetch is not supported in IE 11" }], }, { code: "fetch()", settings: { browsers: ["ie 11"] }, - errors: [ - { - message: "fetch is not supported in IE 11", - type: "CallExpression", - }, - ], + errors: [{ message: "fetch is not supported in IE 11" }], + }, + { + code: "fetch", + settings: { browsers: ["ie 11"] }, + errors: [{ message: "fetch is not supported in IE 11" }], }, { code: "Promise.resolve()", settings: { browsers: ["ie 10"] }, - errors: [ - { - message: "Promise.resolve() is not supported in IE 10", - type: "MemberExpression", - }, - ], + errors: [{ message: "Promise is not supported in IE 10" }], }, { code: "Promise.all()", settings: { browsers: ["ie 10"] }, - errors: [ - { - message: "Promise.all() is not supported in IE 10", - type: "MemberExpression", - }, - ], + errors: [{ message: "Promise is not supported in IE 10" }], }, { code: "Promise.race()", settings: { browsers: ["ie 10"] }, - errors: [ - { - message: "Promise.race() is not supported in IE 10", - type: "MemberExpression", - }, - ], + errors: [{ message: "Promise is not supported in IE 10" }], }, { code: "Promise.reject()", settings: { browsers: ["ie 10"] }, - errors: [ - { - message: "Promise.reject() is not supported in IE 10", - type: "MemberExpression", - }, - ], + errors: [{ message: "Promise is not supported in IE 10" }], }, { code: "new URL('http://example')", settings: { browsers: ["chrome 31", "safari 7", "firefox 25"], }, - errors: [ - { - message: "URL is not supported in Safari 7, Firefox 25, Chrome 31", - type: "NewExpression", - }, - ], + errors: [{ message: "URL is not supported in Chrome 31" }], }, { code: "new URLSearchParams()", @@ -595,20 +584,14 @@ ruleTester.run("compat", rule, { errors: [ { message: - "URLSearchParams is not supported in Safari 10, Firefox 28, Chrome 48", - type: "NewExpression", + "URLSearchParams is not supported in Chrome 48, Firefox 28, Safari 10", }, ], }, { code: "performance.now()", settings: { browsers: ["ie 9"] }, - errors: [ - { - message: "performance.now() is not supported in IE 9", - type: "MemberExpression", - }, - ], + errors: [{ message: "performance.now() is not supported in IE 9" }], }, { code: "new ResizeObserver()", @@ -616,9 +599,7 @@ ruleTester.run("compat", rule, { browsers: ["ie 11", "safari 12"], }, errors: [ - { - message: "ResizeObserver is not supported in Safari 12, IE 11", - }, + { message: "ResizeObserver is not supported in IE 11, Safari 12" }, ], }, { @@ -626,22 +607,25 @@ ruleTester.run("compat", rule, { settings: { browsers: ["ie 11", "safari 12"], }, - errors: [ - { - message: "String.at() is not supported in Safari 12, IE 11", - }, - ], + errors: [{ message: "String.at() is not supported in IE 11, Safari 12" }], }, { code: "[].at(5)", settings: { browsers: ["ie 11", "safari 12"], }, - errors: [ - { - message: "Array.at() is not supported in Safari 12, IE 11", - }, - ], + errors: [{ message: "Array.at() is not supported in IE 11, Safari 12" }], + }, + { + code: ` + if (true) { + [].at(5) + } + `, + settings: { + browsers: ["ie 11"], + }, + errors: [{ message: "Array.at() is not supported in IE 11" }], }, // @TODO: Fix this edge case // { @@ -689,5 +673,23 @@ ruleTester.run("compat", rule, { }, ], }, + { + settings: { browsers: ["safari 14"] }, + code: "/(?<=pattern)/", + errors: [ + { + message: "RegExp Lookbehind Assertions is not supported in Safari 14", + }, + ], + }, + { + settings: { browsers: ["safari 14"] }, + code: "new RegExp('(? { singleVersionEnvPackageJSON.browsers ); const result = parseBrowsersListVersion(config); - expect(result).toMatchInlineSnapshot(` - [ - { - "parsedVersion": 8, - "target": "safari", - "version": "8", - }, - { - "parsedVersion": 9, - "target": "ie", - "version": "9", - }, - { - "parsedVersion": 20, - "target": "firefox", - "version": "20", - }, - { - "parsedVersion": 32, - "target": "chrome", - "version": "32", - }, - ] - `); + expect(result).toMatchSnapshot(); }); it("should get lowest target versions", () => { diff --git a/test/mdn-provider.spec.ts b/test/mdn-provider.spec.ts index 180e4708..a48574ca 100644 --- a/test/mdn-provider.spec.ts +++ b/test/mdn-provider.spec.ts @@ -12,7 +12,7 @@ describe("MdnProvider", () => { } as AstMetadataApiWithTargetsResolver; const config = determineTargetsFromConfig(".", ["safari tp"]); const targets = parseBrowsersListVersion(config); - const result = getUnsupportedTargets(node, targets); + const result = getUnsupportedTargets.call(node, targets); expect(result).toEqual([]); }); });