From 02416ee79481dc8add17bdb2b15b72b5bdf79201 Mon Sep 17 00:00:00 2001 From: Amila Welihinda Date: Thu, 28 Feb 2019 12:35:18 -0800 Subject: [PATCH] use ast-metadata-inferer (#175) * v2.7.0 * initial working commit * testing improvements, bump ast-metadata-inferer * add failing test case * update readme and changelog * handle case when versionAdded === null * bump ast-metadata-inferer --- CHANGELOG.md | 4 + README.md | 5 + package.json | 8 +- src/Lint.js | 8 +- src/LintTypes.js | 8 +- src/Versioning.js | 2 +- src/providers/CanIUseProvider.js | 63 +++++------- src/providers/MdnProvider.js | 167 +++++++++++++++++++++++++++++++ src/providers/index.js | 7 +- src/rules/compat.js | 8 +- test/e2e.spec.js | 26 +++-- yarn.lock | 7 +- 12 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 src/providers/MdnProvider.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bc737b..7d2ccf20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v3.0.0 +### Added +- Support for ~4000 JS API's using [ast-metadata-inferer](https://github.com/amilajack/ast-metadata-inferer) + ## v2.7.0 ### Added - `Object.values()` support diff --git a/README.md b/README.md index 7681f7bb..102f9225 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,8 @@ This project was inspired by a two hour conversation I had with someone on the e ## Demo For a minimal demo, see [amilajack/eslint-plugin-compat-demo](https://github.com/amilajack/eslint-plugin-compat-demo) + +## Related + +* [ast-metadata-inferer](https://github.com/amilajack/ast-metadata-inferer) +* [compat-db](https://github.com/amilajack/compat-db) diff --git a/package.json b/package.json index 4dc9f359..3b9069fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-compat", - "version": "2.6.3", + "version": "3.0.0-0", "description": "Lint browser compatibility of API used", "main": "lib/index.js", "repository": { @@ -26,7 +26,7 @@ ], "homepage": "https://github.com/amilajack/eslint-plugin-compat#readme", "scripts": { - "build": "cross-env NODE_ENV=production rm -rf lib && babel src --out-dir lib", + "build": "cross-env NODE_ENV=production rm -rf lib && babel src --out-dir lib --source-maps inline", "flow": "flow", "flow-typed": "flow-typed install --ignoreDeps peer dev", "lint": "eslint --cache --format=node_modules/eslint-formatter-pretty .", @@ -59,9 +59,11 @@ }, "dependencies": { "@babel/runtime": "^7.3.1", + "ast-metadata-inferer": "^0.1.1-3", "browserslist": "^4.4.1", "caniuse-db": "^1.0.30000935", - "mdn-browser-compat-data": "^0.0.68" + "mdn-browser-compat-data": "^0.0.68", + "semver": "^5.6.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0" diff --git a/src/Lint.js b/src/Lint.js index 24779d7d..59be0695 100644 --- a/src/Lint.js +++ b/src/Lint.js @@ -1,5 +1,5 @@ // @flow -import { rules } from './providers/index'; +import { rules } from './providers'; import type { Node, ESLintNode, Targets, isValidObject } from './LintTypes'; export function generateErrorName(_node: Node): string { @@ -17,13 +17,13 @@ export function generateErrorName(_node: Node): string { export default function Lint( eslintNode: ESLintNode, targets: Targets = ['chrome', 'firefox', 'safari', 'edge'], - polyfills: Set = new Set() + polyfills: Set ): isValidObject { - // Find the corresponding rules for a eslintNode by it's ASTNodeType + // Find the corresponding rules for a eslintNode by it's astNodeType const failingRule = rules .filter( (rule: Node): boolean => - rule.ASTNodeType === eslintNode.type && + rule.astNodeType === eslintNode.type && // Check if polyfill is provided !polyfills.has(rule.id) ) diff --git a/src/LintTypes.js b/src/LintTypes.js index 3c02b9f2..5a9a1490 100644 --- a/src/LintTypes.js +++ b/src/LintTypes.js @@ -1,7 +1,9 @@ // @flow export type node = { - type?: string, - name?: string + type?: 'MemberExpression' | 'NewExpression' | 'CallExpression', + name?: string, + object: string, + property: string | void }; export type Target = { @@ -24,7 +26,7 @@ export type ESLintNode = { } & node; export type Node = { - ASTNodeType: string, + astNodeType: string, id: string, object: string, property?: string, diff --git a/src/Versioning.js b/src/Versioning.js index 0bb0ec33..4b8323fa 100644 --- a/src/Versioning.js +++ b/src/Versioning.js @@ -1,5 +1,5 @@ // @flow -import browserslist from 'browserslist'; // eslint-disable-line +import browserslist from 'browserslist'; import type { BrowserListConfig } from './rules/compat'; type TargetListItem = { diff --git a/src/providers/CanIUseProvider.js b/src/providers/CanIUseProvider.js index 93450964..80fd61e3 100644 --- a/src/providers/CanIUseProvider.js +++ b/src/providers/CanIUseProvider.js @@ -1,6 +1,6 @@ // @flow // $FlowFixMe: Flow import error -import caniuseRecord from 'caniuse-db/fulldata-json/data-2.0.json'; // eslint-disable-line +import caniuseRecords from 'caniuse-db/fulldata-json/data-2.0.json'; import type { Node, ESLintNode, Targets, Target } from '../LintTypes'; type TargetMetadata = { @@ -14,7 +14,7 @@ type CanIUseStats = { } }; -type CanIUseRecord = { +type CanIUseRecords = { data: CanIUseStats }; @@ -71,7 +71,7 @@ function formatTargetNames(target: Target): string { } /** - * Check version for the range format. + * Check if a browser version is in the range format * ex. 10.0-10.2 */ function versionIsRange(version: string): boolean { @@ -88,10 +88,11 @@ function compareRanges(targetVersion: number, statsVersion: string): boolean { /* * Check the CanIUse database to see if targets are supported */ -function canIUseSupported( - stats: CanIUseStats, +function canIUseIsNotSupported( + node: Node, { version, target, parsedVersion }: Target ): boolean { + const { stats } = (caniuseRecords: CanIUseRecords).data[node.id]; const targetStats = stats[target]; return versionIsRange(version) ? Object.keys(targetStats).some( @@ -111,9 +112,8 @@ export function getUnsupportedTargets( node: Node, targets: Targets ): Array { - const { stats } = (caniuseRecord: CanIUseRecord).data[node.id]; return targets - .filter(target => canIUseSupported(stats, target)) + .filter(target => canIUseIsNotSupported(node, target)) .map(formatTargetNames); } @@ -146,119 +146,102 @@ function isValid( return true; } - return getUnsupportedTargets(node, targets).length === 0; + return !getUnsupportedTargets(node, targets).length; } -// -// TODO: Migrate to compat-db -// TODO: Refactor isValid(), remove from rules -// - const CanIUseProvider: Array = [ // new ServiceWorker() { id: 'serviceworkers', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'ServiceWorker' }, { id: 'serviceworkers', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'navigator', property: 'serviceWorker' }, // document.querySelector() { id: 'queryselector', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'document', property: 'querySelector' }, - // WebAssembly - { - id: 'wasm', - ASTNodeType: 'MemberExpression', - object: 'WebAssembly' - }, // IntersectionObserver { id: 'intersectionobserver', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'IntersectionObserver' }, // PaymentRequest { id: 'payment-request', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'PaymentRequest' }, // Promises { id: 'promises', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'Promise' }, { id: 'promises', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'Promise', property: 'resolve' }, { id: 'promises', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'Promise', property: 'all' }, { id: 'promises', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'Promise', property: 'race' }, { id: 'promises', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'Promise', property: 'reject' }, // fetch { id: 'fetch', - ASTNodeType: 'CallExpression', + astNodeType: 'CallExpression', object: 'fetch' }, // document.currentScript() { id: 'document-currentscript', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'document', property: 'currentScript' }, // URL { id: 'url', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'URL' }, // URLSearchParams { id: 'urlsearchparams', - ASTNodeType: 'NewExpression', + astNodeType: 'NewExpression', object: 'URLSearchParams' }, // performance.now() { id: 'high-resolution-time', - ASTNodeType: 'MemberExpression', + astNodeType: 'MemberExpression', object: 'performance', property: 'now' - }, - { - id: 'object-values', - ASTNodeType: 'MemberExpression', - object: 'Object', - property: 'values' } ].map(rule => Object.assign({}, rule, { diff --git a/src/providers/MdnProvider.js b/src/providers/MdnProvider.js new file mode 100644 index 00000000..b2bedb3e --- /dev/null +++ b/src/providers/MdnProvider.js @@ -0,0 +1,167 @@ +import AstMetadata from 'ast-metadata-inferer'; +import semver from 'semver'; +import type { Node, ESLintNode, Targets, Target } from '../LintTypes'; + +type AstMetadataRecordType = { + apiType: 'js-api' | 'css-api', + type: 'js-api' | 'css-api', + protoChain: Array, + protoChainId: string, + astNodeTypes: Array, + isStatic: boolean, + compat: { + support: { + [browserName: string]: { + // If a version is true then it is supported but version is unsure + version_added: string | boolean + } + }, + [x: string]: any + } +}; + +const mdnRecords: Map = new Map( + AstMetadata.map(e => [e.protoChainId, e]) +); + +/** + * Map ids of mdn targets to their "common/friendly" name + */ +const targetNameMappings = { + chrome: 'Chrome', + firefox: 'Firefox', + opera: 'Opera', + safari: 'Safari', + ie: 'IE', + edge: 'Edge', + safari_ios: 'iOS Safari', + opera_android: 'Opera Mobile', + chrome_android: 'Android Chrome', + edge_mobile: 'Edge Mobile', + firefox_android: 'Android Firefox', + webview_android: 'WebView Android', + samsunginternet_android: 'Samsung Browser', + nodes: 'Node.js' +}; + +/** + * Take a target's id and return it's full name by using `targetNameMappings` + * ex. {target: and_ff, version: 40} => 'Android FireFox 40' + */ +function formatTargetNames(target: Target): string { + return `${targetNameMappings[target.target]} ${target.version}`; +} + +/** + * Convert '9' => '9.0.0' + */ +function customCoerce(version: string): string { + return version.length === 1 ? [version, 0, 0].join('.') : version; +} + +/* + * Return if MDN supports the API or not + */ +export function mdnSupported(node: Node, { version, target }: Target): boolean { + // If no record could be found, return false. Rules might not + // be found because they could belong to another provider + if (!mdnRecords.has(node.protoChainId)) return true; + const record = mdnRecords.get(node.protoChainId); + if (!record || !record.compat.support) return true; + const compatRecord = record.compat.support[target]; + if (!compatRecord) return true; + if (!Array.isArray(compatRecord) && !('version_added' in compatRecord)) + return true; + const { version_added: versionAdded } = Array.isArray(compatRecord) + ? compatRecord.find(e => 'version_added' in e) + : compatRecord; + + // If a version is true then it is supported but version is unsure + if (typeof versionAdded === 'boolean') return versionAdded; + if (versionAdded === null) return false; + // A browser supports an API if its version is greater than or equal + // to the first version of the browser that API was added in + return semver.gte( + semver.coerce(customCoerce(version)), + semver.coerce(customCoerce(versionAdded)) + ); +} + +/** + * Return an array of all unsupported targets + */ +export function getUnsupportedTargets( + node: Node, + targets: Targets +): Array { + return targets + .filter(target => !mdnSupported(node, target)) + .map(formatTargetNames); +} + +/** + * Check if the node has matching object or properties + */ +function isValid( + node: Node, + eslintNode: ESLintNode, + targets: Targets +): boolean { + switch (eslintNode.type) { + case 'CallExpression': + case 'NewExpression': + if (!eslintNode.callee) return true; + if (eslintNode.callee.name !== node.object) return true; + break; + case 'MemberExpression': + // Pass tests if non-matching object or property + if (!eslintNode.object || !eslintNode.property) return true; + if (eslintNode.object.name !== node.object) return true; + + // If the property is missing from the rule, it means that only the + // object is required to determine compatibility + if (!node.property) break; + + if (eslintNode.property.name !== node.property) return true; + break; + default: + return true; + } + + return !getUnsupportedTargets(node, targets).length; +} + +function getMetadataName(metadata: Node) { + switch (metadata.protoChain.length) { + case 1: { + return metadata.protoChain[0]; + } + default: + return `${metadata.protoChain.join('.')}()`; + } +} + +const MdnProvider: Array = AstMetadata + // Create entries for each ast node type + .map(metadata => + metadata.astNodeTypes.map(astNodeType => ({ + ...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] + })) + ) + // Flatten the array of arrays + .reduce((p, c) => [...p, ...c]) + // Add rule and target support logic for each entry + .map(rule => ({ + ...rule, + isValid, + getUnsupportedTargets + })); + +export default MdnProvider; diff --git a/src/providers/index.js b/src/providers/index.js index ffa2ac91..96f38846 100644 --- a/src/providers/index.js +++ b/src/providers/index.js @@ -1,8 +1,7 @@ // @flow -import Kangax from './KangaxProvider'; import CanIUse from './CanIUseProvider'; +import Mdn from './MdnProvider'; import type { Node } from '../LintTypes'; -export const rules: Array = [...Kangax, ...CanIUse]; - -export default {}; +// eslint-disable-next-line import/prefer-default-export +export const rules: Array = [...CanIUse, ...Mdn]; diff --git a/src/rules/compat.js b/src/rules/compat.js index 5bb1fb30..cf221a69 100644 --- a/src/rules/compat.js +++ b/src/rules/compat.js @@ -1,10 +1,10 @@ // @flow import Lint, { generateErrorName } from '../Lint'; import DetermineTargetsFromConfig, { Versioning } from '../Versioning'; -import type { ESLintNode, Node } from '../LintTypes'; // eslint-disable-line +import type { ESLintNode } from '../LintTypes'; type ESLint = { - [ASTNodeTypeName: string]: (node: ESLintNode) => void + [astNodeTypeName: string]: (node: ESLintNode) => void }; type Context = { @@ -49,9 +49,7 @@ export default { const { isValid, rule, unsupportedTargets } = Lint( node, browserslistTargets, - context.settings.polyfills - ? new Set(context.settings.polyfills) - : undefined + new Set(context.settings.polyfills || []) ); if (!isValid) { diff --git a/test/e2e.spec.js b/test/e2e.spec.js index 662b96b0..f64ad4c1 100644 --- a/test/e2e.spec.js +++ b/test/e2e.spec.js @@ -27,7 +27,10 @@ ruleTester.run('compat', rule, { }, { code: 'WebAssembly.compile()', - settings: { polyfills: ['wasm'] } + settings: { + browsers: ['chrome 40'], + polyfills: ['WebAssembly', 'WebAssembly.compile'] + } }, { code: 'new IntersectionObserver(() => {}, {});', @@ -51,15 +54,16 @@ ruleTester.run('compat', rule, { } ], invalid: [ - // TODO: Atomcis are not yet supported by caniuse - // - // { - // code: 'Atomics.store()', - // errors: [{ - // message: 'Unsupported API being used', - // type: 'MemberExpression' - // }] - // }, + { + code: 'new AnimationEvent', + settings: { browsers: ['chrome 40'] }, + errors: [ + { + message: 'AnimationEvent is not supported in Chrome 40', + type: 'NewExpression' + } + ] + }, { code: 'Object.values({})', settings: { browsers: ['safari 9'] }, @@ -111,7 +115,7 @@ ruleTester.run('compat', rule, { errors: [ { message: - 'WebAssembly is not supported in Samsung Browser 4, Safari 10.1, Opera 12.1, Opera Mini all, iOS Safari 10.3, IE Mobile 10, IE 10, Edge 14, Blackberry Browser 7, Baidu 7.12, Android UC Browser 11.8, QQ Browser 1.2', + 'WebAssembly is not supported in Safari 10.1, Opera 12.1, IE 10, Edge 14', type: 'MemberExpression' } ] diff --git a/yarn.lock b/yarn.lock index 20137419..f03912fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -849,6 +849,11 @@ assign-symbols@^1.0.0: resolved "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= +ast-metadata-inferer@^0.1.1-3: + version "0.1.1-3" + resolved "https://registry.npmjs.org/ast-metadata-inferer/-/ast-metadata-inferer-0.1.1-3.tgz#04d4c9ae158c27dca2ef957a93bfe9ba70c26ebb" + integrity sha512-z40cBIR8jZ7FL9fWptuDnY5E8DjTeQS2HIzQ4i+Mm3syDm7k57QqPkOcxGEk20Fk3J6jzSCidrsWXps3drmJpQ== + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -4969,7 +4974,7 @@ seek-bzip@^1.0.5: dependencies: commander "~2.8.1" -"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1: +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.6.0" resolved "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==