UNPKG

es-check

Version:

Checks the ECMAScript version of .js glob against a specified version of ECMAScript with a shell command

499 lines (405 loc) 13.2 kB
const acorn = require("acorn"); const glob = require("fast-glob"); const fs = require("fs"); const { detectFeatures, polyfillDetector: polyfillDetectorModule, parseIgnoreList, processBatchedFiles, readFile, parseCode, } = require("../helpers"); const { ECMA_VERSION_MAP, ECMA_VERSION_TO_NUMBER, } = require("../constants/versions"); let polyfillDetector = null; function parseFilePatterns(configFilesValue) { const hasNoValue = !configFilesValue; if (hasNoValue) return []; const isArray = Array.isArray(configFilesValue); if (isArray) { return configFilesValue.map((p) => String(p).trim()).filter(Boolean); } const isString = typeof configFilesValue === "string"; if (isString) { return configFilesValue .split(",") .map((p) => p.trim()) .filter(Boolean); } return []; } function validateConfig(config, options) { const { logger, isNodeAPI, allErrors } = options; const hasEcmaVersion = Boolean(config.ecmaVersion); const hasBrowserCheck = Boolean(config.checkBrowser); const missingVersionSpecification = !hasEcmaVersion && !hasBrowserCheck; const isValid = !missingVersionSpecification; if (isValid) return { isValid: true }; const hasLogger = logger !== null && logger !== undefined; const isExiting = !isNodeAPI; if (hasLogger) { logger.error( "No ecmaScript version or checkBrowser option specified in configuration", ); } if (isExiting) process.exit(1); allErrors.push({ err: new Error( "No ecmaScript version or checkBrowser option specified in configuration", ), file: "config", }); return { isValid: false }; } function handleMissingFiles(pattern, options) { const { logger, isNodeAPI, allErrors } = options; const hasLogger = logger !== null && logger !== undefined; const isExiting = !isNodeAPI; if (hasLogger) { logger.error( `ES-Check: Did not find any files to check for pattern: ${pattern}.`, ); } if (isExiting) process.exit(1); allErrors.push({ err: new Error(`Did not find any files to check for pattern: ${pattern}`), file: "glob", }); } function findFiles(patterns, options) { const { globOpts, looseGlobMatching, logger, isNodeAPI, allErrors } = options; const hasFilePatterns = patterns.length > 0; const shouldEnforceFilePatterns = !hasFilePatterns && !looseGlobMatching; const hasLogger = logger !== null && logger !== undefined; const isExiting = !isNodeAPI; if (shouldEnforceFilePatterns && hasLogger) { logger.error("ES-Check: No file patterns specified to check."); } if (shouldEnforceFilePatterns && isExiting) process.exit(1); if (shouldEnforceFilePatterns) { allErrors.push({ err: new Error("No file patterns specified to check"), file: "config", }); return { files: [], hasError: true }; } let hasPatternWithNoFiles = false; const allMatchedFiles = patterns.flatMap((pattern) => { const globbedFiles = glob.sync(pattern, globOpts); const noFilesFound = globbedFiles.length === 0; const shouldErrorOnNoFiles = noFilesFound && !looseGlobMatching; if (shouldErrorOnNoFiles) { hasPatternWithNoFiles = true; handleMissingFiles(pattern, options); } return globbedFiles; }); const noMatchedFiles = allMatchedFiles.length === 0; const shouldErrorOnNoMatchedFiles = noMatchedFiles && hasFilePatterns && !looseGlobMatching; const shouldWarnOnNoMatchedFiles = noMatchedFiles && looseGlobMatching; if (shouldErrorOnNoMatchedFiles && hasLogger) { logger.error( `ES-Check: Did not find any files to check across all patterns: ${patterns.join(", ")}.`, ); } if (shouldErrorOnNoMatchedFiles && isExiting) process.exit(1); if (shouldErrorOnNoMatchedFiles) { allErrors.push({ err: new Error( `Did not find any files to check across all patterns: ${patterns.join(", ")}`, ), file: "glob", }); return { files: [], hasError: true }; } if (shouldWarnOnNoMatchedFiles && hasLogger) { logger.warn( "ES-Check: No file patterns specified or no files found (running in loose mode).", ); } return { files: allMatchedFiles, hasError: hasPatternWithNoFiles }; } function handleBrowserslistError(browserslistError, options) { const { logger, isNodeAPI, allErrors } = options; const hasLogger = logger !== null && logger !== undefined; const isExiting = !isNodeAPI; if (hasLogger) { logger.error( `Error determining ES version from browserslist: ${browserslistError.message}`, ); } if (isExiting) process.exit(1); allErrors.push({ err: new Error( `Error determining ES version from browserslist: ${browserslistError.message}`, ), file: "browserslist", }); } function handleInvalidVersion(options) { const { logger, isNodeAPI, allErrors } = options; const hasLogger = logger !== null && logger !== undefined; const isExiting = !isNodeAPI; if (hasLogger) { logger.error( "Invalid ecmaScript version, please pass a valid version, use --help for help", ); } if (isExiting) process.exit(1); allErrors.push({ err: new Error("Invalid ecmaScript version"), file: "config", }); } function determineEcmaVersion(config, options) { const { logger, isDebug, isWarn, isNodeAPI, allErrors } = options; const { ecmaVersion: expectedEcmaVersion, checkBrowser } = config; const isBrowserslistCheck = Boolean( expectedEcmaVersion === "checkBrowser" || checkBrowser, ); if (isBrowserslistCheck) { const browserslistQuery = config.browserslistQuery; let browserslistError = null; let ecmaVersion = null; try { const { getESVersionFromBrowserslist } = require("../browserslist"); const esVersionFromBrowserslist = getESVersionFromBrowserslist({ browserslistQuery, browserslistPath: config.browserslistPath, browserslistEnv: config.browserslistEnv, }); ecmaVersion = esVersionFromBrowserslist.toString(); } catch (err) { browserslistError = err; } const hasError = browserslistError !== null; const hasNoError = !hasError; const shouldDebug = hasNoError && isDebug; if (shouldDebug) { logger.debug( `ES-Check: Using ES${ecmaVersion} based on browserslist configuration`, ); } if (hasError) { handleBrowserslistError(browserslistError, { logger, isNodeAPI, allErrors, }); return { ecmaVersion: null, hasError: true }; } return { ecmaVersion, hasError: false }; } const mappedVersion = ECMA_VERSION_MAP[expectedEcmaVersion]; const isInvalidVersion = !mappedVersion; const isLegacyVersion = expectedEcmaVersion === "es3" || expectedEcmaVersion === "es4"; const hasLegacyWarning = isLegacyVersion && isWarn; if (hasLegacyWarning) { logger.warn( `ES-Check: ${expectedEcmaVersion} is not fully supported by the parser. Treating as ES5.`, ); } if (isInvalidVersion) { handleInvalidVersion({ logger, isNodeAPI, allErrors }); return { ecmaVersion: null, hasError: true }; } const ecmaVersion = String(ECMA_VERSION_TO_NUMBER[mappedVersion]); return { ecmaVersion, hasError: false }; } function filterIgnoredFiles(files, pathsToIgnore, globOpts) { const hasNoIgnores = pathsToIgnore.length === 0; if (hasNoIgnores) return files; const expandedPathsToIgnore = pathsToIgnore.flatMap((path) => { const hasWildcard = path.includes("*"); return hasWildcard ? glob.sync(path, globOpts) : [path]; }); const hasNoExpandedIgnores = expandedPathsToIgnore.length === 0; if (hasNoExpandedIgnores) return files; return files.filter((filePath) => { return !expandedPathsToIgnore.some((ignoreValue) => filePath.includes(ignoreValue), ); }); } function processFullAST( code, acornOpts, file, config, ignoreList, ecmaVersion, isDebug, logger, ) { const needsFullAST = config.checkFeatures; const parserOptions = needsFullAST ? acornOpts : { ...acornOpts, locations: false, ranges: false, onComment: null }; const { ast, error: parseError } = parseCode( code, parserOptions, acorn, file, ); const hasParseError = parseError !== null; const shouldDebugError = hasParseError && isDebug; if (shouldDebugError) { logger.debug( `ES-Check: failed to parse file: ${file} \n - error: ${parseError.err}`, ); } if (hasParseError) return parseError; const shouldCheckFeatures = config.checkFeatures; if (!shouldCheckFeatures) return null; const parseSourceType = acornOpts.sourceType || "script"; const esVersion = parseInt(ecmaVersion, 10); let foundFeatures; let unsupportedFeatures; try { const result = detectFeatures( code, esVersion, parseSourceType, ignoreList, { ast, checkForPolyfills: config.checkForPolyfills }, ); foundFeatures = result.foundFeatures; unsupportedFeatures = result.unsupportedFeatures; } catch (err) { const isESCheckFeatureError = err.type === "ES-Check" && err.features; if (isESCheckFeatureError) { return { err, file, stack: err.stack }; } throw err; } if (isDebug) { const stringifiedFeatures = JSON.stringify(foundFeatures, null, 2); logger.debug(`Features found in ${file}: ${stringifiedFeatures}`); } const shouldCheckPolyfills = config.checkForPolyfills && unsupportedFeatures.length > 0; if (!shouldCheckPolyfills) { const isSupported = unsupportedFeatures.length === 0; if (isSupported) return null; const error = new Error( `Unsupported features used: ${unsupportedFeatures.join(", ")} but your target is ES${ecmaVersion}.`, ); return { err: error, file, stack: error.stack }; } if (!polyfillDetector) { polyfillDetector = polyfillDetectorModule; } const polyfills = polyfillDetector.detectPolyfills( code, logger || { debug: () => {}, isLevelEnabled: () => false }, ); const filteredUnsupportedFeatures = polyfillDetector.filterPolyfilled( unsupportedFeatures, polyfills, ); const hasReduction = isDebug && filteredUnsupportedFeatures.length !== unsupportedFeatures.length; if (hasReduction) { logger.debug( `ES-Check: Polyfills reduced unsupported features from ${unsupportedFeatures.length} to ${filteredUnsupportedFeatures.length}`, ); } const isSupported = filteredUnsupportedFeatures.length === 0; if (!isSupported) { const error = new Error( `Unsupported features used: ${filteredUnsupportedFeatures.join(", ")} but your target is ES${ecmaVersion}.`, ); return { err: error, file, stack: error.stack }; } return null; } function createFileProcessor(config, options) { const { acornOpts, ignoreList, logger, isDebug, ecmaVersion } = options; return (file) => { const useCache = config.cache !== false; const { content: code, error: readError } = readFile(file, fs, useCache); const hasReadError = readError !== null; if (hasReadError) return readError; if (isDebug) { logger.debug(`ES-Check: checking ${file}`); } return processFullAST( code, acornOpts, file, config, ignoreList, ecmaVersion, isDebug, logger, ); }; } function logErrors(errors, logger) { const hasLogger = logger !== null && logger !== undefined; if (!hasLogger) return; logger.error( `ES-Check: there were ${errors.length} ES version matching errors.`, ); const { mapErrorPosition } = require("../helpers/sourcemap"); for (const error of errors) { const hasLocation = error.line !== null && error.line !== undefined; const mapped = hasLocation ? mapErrorPosition(error.file, error.line, error.column) : { file: error.file, line: error.line, column: error.column }; const locationInfo = hasLocation ? ` at ${mapped.file}:${mapped.line}:${mapped.column}` : ""; logger.info(` ES-Check Error: ---- · erroring file: ${mapped.file}${locationInfo} · error: ${error.err} · see the printed err.stack below for context ----\n ${error.stack} `); } } function processConfigResult( errors, logger, isNodeAPI, allErrors, ecmaVersion, ) { const hasFileErrors = errors.length > 0; if (hasFileErrors) { logErrors(errors, logger); allErrors.push(...errors); const isExiting = !isNodeAPI; if (isExiting) { process.exit(1); } return { hasErrors: true, shouldContinue: false }; } const hasLogger = logger !== null && logger !== undefined; if (hasLogger) { const versionLabel = ecmaVersion ? `ES${ecmaVersion}` : "the specified ES version"; logger.info(`✓ ES-Check passed! All files are ${versionLabel} compatible.`); } return { hasErrors: false, shouldContinue: true }; } module.exports = { parseFilePatterns, validateConfig, findFiles, determineEcmaVersion, filterIgnoredFiles, createFileProcessor, processConfigResult, parseIgnoreList, processBatchedFiles, };