es-check
Version:
Checks the ECMAScript version of .js glob against a specified version of ECMAScript with a shell command
634 lines (554 loc) • 16 kB
JavaScript
const fs = require('fs');
const winston = require('winston');
const supportsColor = require('supports-color');
const { fastBrakeSync } = require('fast-brake/sync');
const esversionPlugin = require('fast-brake/plugins/esversion');
const fastbrake = fastBrakeSync({ plugins: [esversionPlugin.default] });
/**
* Parse ignore list from options
* @param {Object} options - Options object
* @param {string} [options.ignore] - Comma-separated list of features to ignore
* @param {string} [options.ignoreFile] - Path to JSON file containing features to ignore
* @returns {Set<string>} Set of features to ignore
*/
function parseIgnoreList(options) {
if (!options) {
return new Set();
}
const ignoreList = new Set();
if (options.ignore) {
if (options.ignore.trim() === '') {
return ignoreList;
}
options.ignore.split(',').forEach(feature => {
const trimmed = feature.trim();
if (trimmed) {
ignoreList.add(trimmed);
}
});
}
if (options.allowList) {
if (options.allowList.trim() === '') {
return ignoreList;
}
options.allowList.split(',').forEach(feature => {
const trimmed = feature.trim();
if (trimmed) {
ignoreList.add(trimmed);
}
});
}
const ignoreFilePath = options.ignoreFile || options['ignore-file'];
if (!ignoreFilePath) {
return ignoreList;
}
try {
if (!fs.existsSync(ignoreFilePath)) {
throw new Error(`Ignore file not found: ${ignoreFilePath}`);
}
const fileContent = fs.readFileSync(ignoreFilePath, 'utf8');
if (!fileContent.trim()) {
return ignoreList;
}
const ignoreConfig = JSON.parse(fileContent);
if (!ignoreConfig) {
return ignoreList;
}
if (Array.isArray(ignoreConfig.features)) {
ignoreConfig.features.forEach(feature => {
if (feature && typeof feature === 'string') {
ignoreList.add(feature.trim());
}
});
}
} catch (err) {
throw new Error(`Failed to parse ignore file: ${err.message}`);
}
return ignoreList;
}
function checkVarKindMatch(node, astInfo) {
if (!astInfo.kind) return false;
return node.kind === astInfo.kind;
}
function checkCalleeMatch(node, astInfo) {
if (!astInfo.callee) return false;
if (!node.callee) return false;
if (node.callee.type !== 'Identifier') return false;
return node.callee.name === astInfo.callee;
}
function checkOperatorMatch(node, astInfo) {
if (!astInfo.operator) return false;
return node.operator === astInfo.operator;
}
function checkDefault() {
return true;
}
const checkMap = {
VariableDeclaration: checkVarKindMatch,
LogicalExpression: checkOperatorMatch,
ArrowFunctionExpression: checkDefault,
CallExpression: (node, astInfo) => {
if (node.callee.type === 'MemberExpression') {
if (astInfo.object && astInfo.property) {
return (
node.callee.object.type === 'Identifier' &&
node.callee.object.name === astInfo.object &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === astInfo.property
);
} else if (astInfo.property) {
if (
node.callee.property.type === 'Identifier' &&
node.callee.property.name === astInfo.property
) {
if (astInfo.excludeObjects && node.callee.object.type === 'Identifier') {
const objectName = node.callee.object.name;
if (astInfo.excludeObjects.includes(objectName)) {
return false;
}
}
return true;
}
return false;
}
return false;
} else if (node.callee.type === 'Identifier') {
const { callee } = astInfo;
if (callee && node.callee.name === callee) {
return true;
}
}
return false;
},
NewExpression: (node, astInfo) => {
if (!astInfo.callee) {
return false;
}
if (!node.callee || node.callee.type !== 'Identifier') {
return false;
}
if (node.callee.name !== astInfo.callee) {
return false;
}
if (astInfo.hasOptionsCause) {
if (!node.arguments || node.arguments.length < 2) {
return false;
}
const secondArg = node.arguments[1];
if (secondArg.type !== 'ObjectExpression') {
return false;
}
return secondArg.properties.some(prop =>
prop.key &&
prop.key.type === 'Identifier' &&
prop.key.name === 'cause'
);
}
return true;
},
default: () => false
};
/**
* Create a configured logger instance
* @param {Object} options - Options object
* @param {boolean} [options.noColor] - Disable color output
* @param {boolean} [options['no-color']] - Disable color output (kebab-case alternative)
* @param {boolean} [options.verbose] - Enable verbose logging
* @param {boolean} [options.quiet] - Enable quiet mode (only warnings and errors)
* @param {boolean} [options.silent] - Disable all output
* @returns {winston.Logger} Configured logger instance
*/
function createLogger(options = {}) {
const noColor = options?.noColor || options?.['no-color'] || false;
const level = options?.verbose ? 'debug' : options?.quiet ? 'warn' : 'info';
return winston.createLogger({
transports: [
new winston.transports.Console({
silent: options.silent || false,
level,
stderrLevels: ['error', 'warn'],
format: winston.format.combine(
...(supportsColor.stdout && !noColor ? [winston.format.colorize()] : []),
winston.format.simple(),
),
})
]
});
}
/**
* Generate bash completion script for es-check
* @param {string} cmdName - Command name
* @param {string[]} commands - List of subcommands
* @param {string[]} options - List of options
* @returns {string} Bash completion script
*/
function generateBashCompletion(cmdName, commands, options) {
const cmdsStr = commands.join(' ');
const optsStr = options.map(opt => '--' + opt).join(' ');
return `
# es-check bash completion script
# Install by adding to ~/.bashrc:
# source /path/to/es-check-completion.bash
_${cmdName.replace(/-/g, '_')}_completion() {
local cur prev opts cmds
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
# List of commands
cmds="${cmdsStr}"
# List of options
opts="${optsStr}"
# ES versions
es_versions="es3 es5 es6 es2015 es7 es2016 es8 es2017 es9 es2018 es10 es2019 es11 es2020 es12 es2021 es13 es2022 es14 es2023 es15 es2024 es16 es2025"
# Handle special cases based on previous argument
case "\$prev" in
${cmdName})
# After the main command, suggest ES versions or commands
COMPREPLY=( \$(compgen -W "\$es_versions \$cmds \$opts" -- "\$cur") )
return 0
;;
completion)
# After 'completion' command, suggest shell types
COMPREPLY=( \$(compgen -W "bash zsh" -- "\$cur") )
return 0
;;
*)
# Default case: suggest options or files
if [[ "\$cur" == -* ]]; then
# If current word starts with a dash, suggest options
COMPREPLY=( \$(compgen -W "\$opts" -- "\$cur") )
else
# Otherwise suggest files
COMPREPLY=( \$(compgen -f -- "\$cur") )
fi
return 0
;;
esac
}
complete -F _${cmdName.replace(/-/g, '_')}_completion ${cmdName}
`;
}
/**
* Generate zsh completion script for es-check
* @param {string} cmdName - Command name
* @param {string[]} commands - List of subcommands
* @param {string[]} options - List of options
* @returns {string} Zsh completion script
*/
function generateZshCompletion(cmdName, commands, options) {
const optionsStr = options.map(opt => `"--${opt}[Option description]"`).join('\n ');
const commandsStr = commands.map(cmd => `"${cmd}:Command description"`).join('\n ');
return `
#compdef ${cmdName}
_${cmdName.replace(/-/g, '_')}() {
local -a commands options es_versions
# ES versions
es_versions=(
"es3:ECMAScript 3"
"es5:ECMAScript 5"
"es6:ECMAScript 2015"
"es2015:ECMAScript 2015"
"es7:ECMAScript 2016"
"es2016:ECMAScript 2016"
"es8:ECMAScript 2017"
"es2017:ECMAScript 2017"
"es9:ECMAScript 2018"
"es2018:ECMAScript 2018"
"es10:ECMAScript 2019"
"es2019:ECMAScript 2019"
"es11:ECMAScript 2020"
"es2020:ECMAScript 2020"
"es12:ECMAScript 2021"
"es2021:ECMAScript 2021"
"es13:ECMAScript 2022"
"es2022:ECMAScript 2022"
"es14:ECMAScript 2023"
"es2023:ECMAScript 2023"
"es15:ECMAScript 2024"
"es2024:ECMAScript 2024"
"es16:ECMAScript 2025"
"es2025:ECMAScript 2025"
)
# Commands
commands=(
${commandsStr}
)
# Options
options=(
${optionsStr}
)
# Handle subcommands
if (( CURRENT > 2 )); then
case \${words[2]} in
completion)
_arguments "1:shell:(bash zsh)"
return
;;
esac
fi
# Main completion
_arguments -C \\
"1: :{_describe 'command or ES version' es_versions -- commands}" \\
"*:: :->args"
case \$state in
args)
_arguments -s : \\
\$options \\
"*:file:_files"
;;
esac
}
_${cmdName.replace(/-/g, '_')}
`;
}
/**
* Process files in batches for better performance
* @param {string[]} files - Array of file paths to process
* @param {Function} processor - Async function to process each file
* @param {number} batchSize - Number of files to process concurrently (0 for unlimited)
* @returns {Promise<Array>} Array of results from processing all files
*/
async function processBatchedFiles(files, processor, batchSize = 0) {
if (batchSize <= 0) {
return Promise.all(files.map(processor));
}
const results = [];
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}
const SimpleCache = require('./cache');
const fileCache = new SimpleCache(500, 60000);
/**
* Read file asynchronously with error handling
* @param {string} file - File path to read
* @param {Object} fs - File system module
* @param {boolean} useCache - Whether to use file cache (default: false)
* @returns {Promise<{content: string, error: null} | {content: null, error: Object}>}
*/
async function readFileAsync(file, fs, useCache = false) {
if (useCache) {
const cached = fileCache.get(file);
if (cached !== undefined) {
return cached;
}
}
try {
const content = await fs.promises.readFile(file, 'utf8');
const result = { content, error: null };
if (useCache) {
fileCache.set(file, result);
}
return result;
} catch (err) {
const result = {
content: null,
error: {
err,
file,
stack: err.stack
}
};
if (useCache) {
fileCache.set(file, result);
}
return result;
}
}
/**
* Clear the file cache
*/
function clearFileCache() {
fileCache.clear();
}
/**
* Get file cache statistics
*/
function getFileCacheStats() {
return fileCache.getStats();
}
const ECMA_VERSION_MAP = {
5: 'es5',
6: 'es2015',
7: 'es2016',
8: 'es2017',
9: 'es2018',
10: 'es2019',
11: 'es2020',
12: 'es2021',
13: 'es2022',
14: 'es2023',
15: 'es2024',
16: 'es2025',
2015: 'es2015',
2016: 'es2016',
2017: 'es2017',
2018: 'es2018',
2019: 'es2019',
2020: 'es2020',
2021: 'es2021',
2022: 'es2022',
2023: 'es2023',
2024: 'es2024',
2025: 'es2025',
es5: 'es5',
es6: 'es2015',
es7: 'es2016',
es8: 'es2017',
es9: 'es2018',
es10: 'es2019',
es11: 'es2020',
es12: 'es2021',
es13: 'es2022',
es14: 'es2023',
es15: 'es2024',
es16: 'es2025',
es2015: 'es2015',
es2016: 'es2016',
es2017: 'es2017',
es2018: 'es2018',
es2019: 'es2019',
es2020: 'es2020',
es2021: 'es2021',
es2022: 'es2022',
es2023: 'es2023',
es2024: 'es2024',
es2025: 'es2025'
};
/**
* Convert ecmaVersion to fast-brake target format
* @param {number|string} ecmaVersion - Version from acorn options
* @returns {string} Target version for fast-brake
*/
function getTargetVersion(ecmaVersion) {
return ECMA_VERSION_MAP[ecmaVersion] || 'es5';
}
/**
* Parse code with Acorn and handle errors
* @param {string} code - Code to parse
* @param {Object} acornOpts - Parsing options
* @param {Object} acorn - Acorn parser instance
* @param {string} file - File path for error reporting
* @returns {{ast: Object, error: null} | {ast: null, error: Object}}
*/
function parseCode(code, acornOpts, acorn, file) {
try {
const ast = acorn.parse(code, acornOpts);
return { ast, error: null };
} catch (err) {
return {
ast: null,
error: {
err,
stack: err.stack,
file
}
};
}
}
function determineInvocationType(loggerOrOptions) {
if (!loggerOrOptions) {
return { isNodeAPI: true, logger: null };
}
const hasLoggerMethods = loggerOrOptions.info || loggerOrOptions.error;
if (typeof loggerOrOptions === 'object' && !hasLoggerMethods) {
return { isNodeAPI: true, logger: loggerOrOptions.logger || null };
}
return { isNodeAPI: false, logger: loggerOrOptions };
}
/**
* Determine log levels based on logger capabilities
* @param {Object|null} logger - Logger object
* @returns {Object|null} Object with log level flags or null if no logger
*/
function determineLogLevel(logger) {
if (!logger || !logger.isLevelEnabled) {
return null;
}
return {
isDebug: logger.isLevelEnabled('debug'),
isWarn: logger.isLevelEnabled('warn'),
isInfo: logger.isLevelEnabled('info'),
isError: logger.isLevelEnabled('error')
};
}
/**
* Handle ES version errors uniformly for different error types
* @param {Object} options - Options object
* @param {string} options.errorType - Type of error ('browserslist', 'es3', 'default')
* @param {string} options.errorMessage - Error message to display
* @param {Object|null} options.logger - Logger object
* @param {boolean} options.isNodeAPI - Whether running as Node API
* @param {Array} options.allErrors - Array to collect errors
* @param {string} [options.file='config'] - File reference for error
* @returns {{shouldContinue: boolean, hasErrors: boolean}}
*/
function handleESVersionError(options) {
const { errorType, errorMessage, logger, isNodeAPI, allErrors, file = 'config' } = options;
if (logger) {
logger.error(errorMessage);
}
if (!isNodeAPI) {
process.exit(1);
return { shouldContinue: false, hasErrors: true };
} else {
allErrors.push({
err: new Error(errorMessage),
file
});
return { shouldContinue: true, hasErrors: true };
}
}
function parseLightMode(code, ecmaVersion, isModule, allowHashBang, file) {
const targetVersion = getTargetVersion(ecmaVersion);
const codeToCheck = allowHashBang && code.startsWith('#!')
? code.slice(code.indexOf('\n') + 1)
: code;
try {
const isCompatible = fastbrake.check(codeToCheck, {
target: targetVersion,
sourceType: isModule ? 'module' : 'script'
});
if (!isCompatible) {
return {
error: {
err: new Error(`Code contains features incompatible with ${targetVersion}`),
stack: '',
file
}
};
}
return { error: null };
} catch (err) {
return {
error: {
err: new Error(`Failed to check code in light mode: ${err.message}`),
stack: err.stack || '',
file
}
};
}
}
module.exports = {
parseIgnoreList,
checkVarKindMatch,
checkCalleeMatch,
checkOperatorMatch,
checkDefault,
checkMap,
createLogger,
generateBashCompletion,
generateZshCompletion,
processBatchedFiles,
readFileAsync,
clearFileCache,
getFileCacheStats,
parseCode,
parseLightMode,
determineInvocationType,
determineLogLevel,
handleESVersionError
};