Spaces:
Sleeping
Sleeping
| /** | |
| * @fileoverview Main CLI object. | |
| * @author Nicholas C. Zakas | |
| */ | |
| ; | |
| /* | |
| * NOTE: The CLI object should *not* call process.exit() directly. It should only return | |
| * exit codes. This allows other programs to use the CLI object and still control | |
| * when the program exits. | |
| */ | |
| //------------------------------------------------------------------------------ | |
| // Requirements | |
| //------------------------------------------------------------------------------ | |
| const fs = require("node:fs"), | |
| { mkdir, stat, writeFile } = require("node:fs/promises"), | |
| path = require("node:path"), | |
| { pathToFileURL } = require("node:url"), | |
| { ESLint, locateConfigFileToUse } = require("./eslint/eslint"), | |
| createCLIOptions = require("./options"), | |
| log = require("./shared/logging"), | |
| RuntimeInfo = require("./shared/runtime-info"), | |
| translateOptions = require("./shared/translate-cli-options"); | |
| const { getCacheFile } = require("./eslint/eslint-helpers"); | |
| const { SuppressionsService } = require("./services/suppressions-service"); | |
| const debug = require("debug")("eslint:cli"); | |
| //------------------------------------------------------------------------------ | |
| // Types | |
| //------------------------------------------------------------------------------ | |
| /** @typedef {import("./options").ParsedCLIOptions} ParsedCLIOptions */ | |
| /** @typedef {import("./types").ESLint.LintResult} LintResult */ | |
| /** @typedef {import("./types").ESLint.ResultsMeta} ResultsMeta */ | |
| //------------------------------------------------------------------------------ | |
| // Helpers | |
| //------------------------------------------------------------------------------ | |
| /** | |
| * Count error messages. | |
| * @param {LintResult[]} results The lint results. | |
| * @returns {{errorCount:number;fatalErrorCount:number,warningCount:number}} The number of error messages. | |
| */ | |
| function countErrors(results) { | |
| let errorCount = 0; | |
| let fatalErrorCount = 0; | |
| let warningCount = 0; | |
| for (const result of results) { | |
| errorCount += result.errorCount; | |
| fatalErrorCount += result.fatalErrorCount; | |
| warningCount += result.warningCount; | |
| } | |
| return { errorCount, fatalErrorCount, warningCount }; | |
| } | |
| /** | |
| * Creates an options module from the provided CLI options and encodes it as a data URL. | |
| * @param {ParsedCLIOptions} options The CLI options. | |
| * @returns {URL} The URL of the options module. | |
| */ | |
| function createOptionsModule(options) { | |
| const translateOptionsFileURL = new URL( | |
| "./shared/translate-cli-options.js", | |
| pathToFileURL(__filename), | |
| ).href; | |
| const optionsSrc = | |
| `import translateOptions from ${JSON.stringify(translateOptionsFileURL)};\n` + | |
| `export default await translateOptions(${JSON.stringify(options)});\n`; | |
| // Base64 encoding is typically shorter than URL encoding | |
| return new URL( | |
| `data:text/javascript;base64,${Buffer.from(optionsSrc).toString("base64")}`, | |
| ); | |
| } | |
| /** | |
| * Check if a given file path is a directory or not. | |
| * @param {string} filePath The path to a file to check. | |
| * @returns {Promise<boolean>} `true` if the given path is a directory. | |
| */ | |
| async function isDirectory(filePath) { | |
| try { | |
| return (await stat(filePath)).isDirectory(); | |
| } catch (error) { | |
| if (error.code === "ENOENT" || error.code === "ENOTDIR") { | |
| return false; | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Outputs the results of the linting. | |
| * @param {ESLint} engine The ESLint instance to use. | |
| * @param {LintResult[]} results The results to print. | |
| * @param {string} format The name of the formatter to use or the path to the formatter. | |
| * @param {string} outputFile The path for the output file. | |
| * @param {ResultsMeta} resultsMeta Warning count and max threshold. | |
| * @returns {Promise<boolean>} True if the printing succeeds, false if not. | |
| * @private | |
| */ | |
| async function printResults(engine, results, format, outputFile, resultsMeta) { | |
| let formatter; | |
| try { | |
| formatter = await engine.loadFormatter(format); | |
| } catch (e) { | |
| log.error(e.message); | |
| return false; | |
| } | |
| const output = await formatter.format(results, resultsMeta); | |
| if (outputFile) { | |
| const filePath = path.resolve(process.cwd(), outputFile); | |
| if (await isDirectory(filePath)) { | |
| log.error( | |
| "Cannot write to output file path, it is a directory: %s", | |
| outputFile, | |
| ); | |
| return false; | |
| } | |
| try { | |
| await mkdir(path.dirname(filePath), { recursive: true }); | |
| await writeFile(filePath, output); | |
| } catch (ex) { | |
| log.error("There was a problem writing the output file:\n%s", ex); | |
| return false; | |
| } | |
| } else if (output) { | |
| log.info(output); | |
| } | |
| return true; | |
| } | |
| /** | |
| * Validates the `--concurrency` flag value. | |
| * @param {string} concurrency The `--concurrency` flag value to validate. | |
| * @returns {void} | |
| * @throws {Error} If the `--concurrency` flag value is invalid. | |
| */ | |
| function validateConcurrency(concurrency) { | |
| if ( | |
| concurrency === void 0 || | |
| concurrency === "auto" || | |
| concurrency === "off" | |
| ) { | |
| return; | |
| } | |
| const concurrencyValue = Number(concurrency); | |
| if (!Number.isInteger(concurrencyValue) || concurrencyValue < 1) { | |
| throw new Error( | |
| `Option concurrency: '${concurrency}' is not a positive integer, 'auto' or 'off'.`, | |
| ); | |
| } | |
| } | |
| //------------------------------------------------------------------------------ | |
| // Public Interface | |
| //------------------------------------------------------------------------------ | |
| /** | |
| * Encapsulates all CLI behavior for eslint. Makes it easier to test as well as | |
| * for other Node.js programs to effectively run the CLI. | |
| */ | |
| const cli = { | |
| /** | |
| * Calculates the command string for the --inspect-config operation. | |
| * @param {string} configFile The path to the config file to inspect. | |
| * @returns {Promise<string>} The command string to execute. | |
| */ | |
| async calculateInspectConfigFlags(configFile) { | |
| // find the config file | |
| const { configFilePath, basePath } = await locateConfigFileToUse({ | |
| cwd: process.cwd(), | |
| configFile, | |
| }); | |
| return ["--config", configFilePath, "--basePath", basePath]; | |
| }, | |
| /** | |
| * Executes the CLI based on an array of arguments that is passed in. | |
| * @param {string|Array|Object} args The arguments to process. | |
| * @param {string} [text] The text to lint (used for TTY). | |
| * @returns {Promise<number>} The exit code for the operation. | |
| */ | |
| async execute(args, text) { | |
| if (Array.isArray(args)) { | |
| debug("CLI args: %o", args.slice(2)); | |
| } | |
| const CLIOptions = createCLIOptions(); | |
| /** @type {ParsedCLIOptions} */ | |
| let options; | |
| try { | |
| options = CLIOptions.parse(args); | |
| validateConcurrency(options.concurrency); | |
| } catch (error) { | |
| log.error(error.message); | |
| return 2; | |
| } | |
| const files = options._; | |
| const useStdin = typeof text === "string"; | |
| if (options.help) { | |
| log.info(CLIOptions.generateHelp()); | |
| return 0; | |
| } | |
| if (options.version) { | |
| log.info(RuntimeInfo.version()); | |
| return 0; | |
| } | |
| if (options.envInfo) { | |
| try { | |
| log.info(RuntimeInfo.environment()); | |
| return 0; | |
| } catch (err) { | |
| debug("Error retrieving environment info"); | |
| log.error(err.message); | |
| return 2; | |
| } | |
| } | |
| if (options.printConfig) { | |
| if (files.length) { | |
| log.error( | |
| "The --print-config option must be used with exactly one file name.", | |
| ); | |
| return 2; | |
| } | |
| if (useStdin) { | |
| log.error( | |
| "The --print-config option is not available for piped-in code.", | |
| ); | |
| return 2; | |
| } | |
| const engine = new ESLint(await translateOptions(options)); | |
| const fileConfig = await engine.calculateConfigForFile( | |
| options.printConfig, | |
| ); | |
| log.info(JSON.stringify(fileConfig, null, " ")); | |
| return 0; | |
| } | |
| if (options.inspectConfig) { | |
| log.info( | |
| "You can also run this command directly using 'npx @eslint/config-inspector@latest' in the same directory as your configuration file.", | |
| ); | |
| try { | |
| const flatOptions = await translateOptions(options); | |
| const spawn = require("cross-spawn"); | |
| const flags = await cli.calculateInspectConfigFlags( | |
| flatOptions.overrideConfigFile, | |
| ); | |
| spawn.sync( | |
| "npx", | |
| ["@eslint/config-inspector@latest", ...flags], | |
| { encoding: "utf8", stdio: "inherit" }, | |
| ); | |
| } catch (error) { | |
| log.error(error); | |
| return 2; | |
| } | |
| return 0; | |
| } | |
| debug(`Running on ${useStdin ? "text" : "files"}`); | |
| if (options.fix && options.fixDryRun) { | |
| log.error( | |
| "The --fix option and the --fix-dry-run option cannot be used together.", | |
| ); | |
| return 2; | |
| } | |
| if (useStdin && options.fix) { | |
| log.error( | |
| "The --fix option is not available for piped-in code; use --fix-dry-run instead.", | |
| ); | |
| return 2; | |
| } | |
| if (options.fixType && !options.fix && !options.fixDryRun) { | |
| log.error( | |
| "The --fix-type option requires either --fix or --fix-dry-run.", | |
| ); | |
| return 2; | |
| } | |
| if ( | |
| options.reportUnusedDisableDirectives && | |
| options.reportUnusedDisableDirectivesSeverity !== void 0 | |
| ) { | |
| log.error( | |
| "The --report-unused-disable-directives option and the --report-unused-disable-directives-severity option cannot be used together.", | |
| ); | |
| return 2; | |
| } | |
| if (options.ext) { | |
| // Passing `--ext ""` results in `options.ext` being an empty array. | |
| if (options.ext.length === 0) { | |
| log.error("The --ext option value cannot be empty."); | |
| return 2; | |
| } | |
| // Passing `--ext ,ts` results in an empty string at index 0. Passing `--ext ts,,tsx` results in an empty string at index 1. | |
| const emptyStringIndex = options.ext.indexOf(""); | |
| if (emptyStringIndex >= 0) { | |
| log.error( | |
| `The --ext option arguments cannot be empty strings. Found an empty string at index ${emptyStringIndex}.`, | |
| ); | |
| return 2; | |
| } | |
| } | |
| if (options.suppressAll && options.suppressRule) { | |
| log.error( | |
| "The --suppress-all option and the --suppress-rule option cannot be used together.", | |
| ); | |
| return 2; | |
| } | |
| if (options.suppressAll && options.pruneSuppressions) { | |
| log.error( | |
| "The --suppress-all option and the --prune-suppressions option cannot be used together.", | |
| ); | |
| return 2; | |
| } | |
| if (options.suppressRule && options.pruneSuppressions) { | |
| log.error( | |
| "The --suppress-rule option and the --prune-suppressions option cannot be used together.", | |
| ); | |
| return 2; | |
| } | |
| if ( | |
| useStdin && | |
| (options.suppressAll || | |
| options.suppressRule || | |
| options.pruneSuppressions) | |
| ) { | |
| log.error( | |
| "The --suppress-all, --suppress-rule, and --prune-suppressions options cannot be used with piped-in code.", | |
| ); | |
| return 2; | |
| } | |
| /** @type {ESLint} */ | |
| let engine; | |
| if (options.concurrency !== "off") { | |
| const optionsURL = createOptionsModule(options); | |
| engine = await ESLint.fromOptionsModule(optionsURL); | |
| } else { | |
| const eslintOptions = await translateOptions(options); | |
| engine = new ESLint(eslintOptions); | |
| } | |
| let results; | |
| if (useStdin) { | |
| results = await engine.lintText(text, { | |
| filePath: options.stdinFilename, | |
| }); | |
| } else { | |
| results = await engine.lintFiles(files); | |
| } | |
| if (options.fix) { | |
| debug("Fix mode enabled - applying fixes"); | |
| await ESLint.outputFixes(results); | |
| } | |
| let unusedSuppressions = {}; | |
| if (!useStdin) { | |
| const suppressionsFileLocation = getCacheFile( | |
| options.suppressionsLocation || | |
| SuppressionsService.DEFAULT_SUPPRESSIONS_FILENAME, | |
| process.cwd(), | |
| { | |
| prefix: "suppressions_", | |
| }, | |
| ); | |
| if ( | |
| options.suppressionsLocation && | |
| !fs.existsSync(suppressionsFileLocation) && | |
| !options.suppressAll && | |
| !options.suppressRule | |
| ) { | |
| log.error( | |
| "The suppressions file does not exist. Please run the command with `--suppress-all` or `--suppress-rule` to create it.", | |
| ); | |
| return 2; | |
| } | |
| if ( | |
| options.suppressAll || | |
| options.suppressRule || | |
| options.pruneSuppressions || | |
| fs.existsSync(suppressionsFileLocation) | |
| ) { | |
| const suppressions = new SuppressionsService({ | |
| filePath: suppressionsFileLocation, | |
| cwd: process.cwd(), | |
| }); | |
| if (options.suppressAll || options.suppressRule) { | |
| await suppressions.suppress(results, options.suppressRule); | |
| } | |
| if (options.pruneSuppressions) { | |
| await suppressions.prune(results); | |
| } | |
| const suppressionResults = suppressions.applySuppressions( | |
| results, | |
| await suppressions.load(), | |
| ); | |
| results = suppressionResults.results; | |
| unusedSuppressions = suppressionResults.unused; | |
| } | |
| } | |
| let resultsToPrint = results; | |
| if (options.quiet) { | |
| debug("Quiet mode enabled - filtering out warnings"); | |
| resultsToPrint = ESLint.getErrorResults(resultsToPrint); | |
| } | |
| const resultCounts = countErrors(results); | |
| const tooManyWarnings = | |
| options.maxWarnings >= 0 && | |
| resultCounts.warningCount > options.maxWarnings; | |
| const resultsMeta = /** @type {ResultsMeta} */ ({}); | |
| /* | |
| * `--color` was set, `options.color` is `true`. | |
| * `--no-color` was set, `options.color` is `false`. | |
| * Neither option was provided, `options.color` is omitted, so `undefined`. | |
| */ | |
| if (options.color !== void 0) { | |
| debug(`Color setting for output: ${options.color}`); | |
| resultsMeta.color = options.color; | |
| } | |
| if (tooManyWarnings) { | |
| resultsMeta.maxWarningsExceeded = { | |
| maxWarnings: options.maxWarnings, | |
| foundWarnings: resultCounts.warningCount, | |
| }; | |
| } | |
| if ( | |
| await printResults( | |
| engine, | |
| resultsToPrint, | |
| options.format, | |
| options.outputFile, | |
| resultsMeta, | |
| ) | |
| ) { | |
| // Errors and warnings from the original unfiltered results should determine the exit code | |
| const shouldExitForFatalErrors = | |
| options.exitOnFatalError && resultCounts.fatalErrorCount > 0; | |
| if (!resultCounts.errorCount && tooManyWarnings) { | |
| log.error( | |
| "ESLint found too many warnings (maximum: %s).", | |
| options.maxWarnings, | |
| ); | |
| } | |
| if (!options.passOnUnprunedSuppressions) { | |
| const unusedSuppressionsCount = | |
| Object.keys(unusedSuppressions).length; | |
| if (unusedSuppressionsCount > 0) { | |
| log.error( | |
| "There are suppressions left that do not occur anymore. To resolve this, re-run the command with `--prune-suppressions` to remove unused suppressions. To ignore unused suppressions, use `--pass-on-unpruned-suppressions`.", | |
| ); | |
| debug(JSON.stringify(unusedSuppressions, null, 2)); | |
| return 2; | |
| } | |
| } | |
| if (shouldExitForFatalErrors) { | |
| return 2; | |
| } | |
| return resultCounts.errorCount || tooManyWarnings ? 1 : 0; | |
| } | |
| return 2; | |
| }, | |
| }; | |
| module.exports = cli; | |