cli.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. /**
  2. * @fileoverview Main CLI object.
  3. * @author Nicholas C. Zakas
  4. */
  5. "use strict";
  6. /*
  7. * NOTE: The CLI object should *not* call process.exit() directly. It should only return
  8. * exit codes. This allows other programs to use the CLI object and still control
  9. * when the program exits.
  10. */
  11. //------------------------------------------------------------------------------
  12. // Requirements
  13. //------------------------------------------------------------------------------
  14. const fs = require("node:fs"),
  15. { mkdir, stat, writeFile } = require("node:fs/promises"),
  16. path = require("node:path"),
  17. { pathToFileURL } = require("node:url"),
  18. { LegacyESLint } = require("./eslint"),
  19. {
  20. ESLint,
  21. shouldUseFlatConfig,
  22. locateConfigFileToUse,
  23. } = require("./eslint/eslint"),
  24. createCLIOptions = require("./options"),
  25. log = require("./shared/logging"),
  26. RuntimeInfo = require("./shared/runtime-info"),
  27. translateOptions = require("./shared/translate-cli-options");
  28. const { getCacheFile } = require("./eslint/eslint-helpers");
  29. const { SuppressionsService } = require("./services/suppressions-service");
  30. const debug = require("debug")("eslint:cli");
  31. //------------------------------------------------------------------------------
  32. // Types
  33. //------------------------------------------------------------------------------
  34. /** @typedef {import("./options").ParsedCLIOptions} ParsedCLIOptions */
  35. /** @typedef {import("./types").ESLint.LintResult} LintResult */
  36. /** @typedef {import("./types").ESLint.ResultsMeta} ResultsMeta */
  37. //------------------------------------------------------------------------------
  38. // Helpers
  39. //------------------------------------------------------------------------------
  40. /**
  41. * Count error messages.
  42. * @param {LintResult[]} results The lint results.
  43. * @returns {{errorCount:number;fatalErrorCount:number,warningCount:number}} The number of error messages.
  44. */
  45. function countErrors(results) {
  46. let errorCount = 0;
  47. let fatalErrorCount = 0;
  48. let warningCount = 0;
  49. for (const result of results) {
  50. errorCount += result.errorCount;
  51. fatalErrorCount += result.fatalErrorCount;
  52. warningCount += result.warningCount;
  53. }
  54. return { errorCount, fatalErrorCount, warningCount };
  55. }
  56. /**
  57. * Creates an options module from the provided CLI options and encodes it as a data URL.
  58. * @param {ParsedCLIOptions} options The CLI options.
  59. * @returns {URL} The URL of the options module.
  60. */
  61. function createOptionsModule(options) {
  62. const translateOptionsFileURL = new URL(
  63. "./shared/translate-cli-options.js",
  64. pathToFileURL(__filename),
  65. ).href;
  66. const optionsSrc =
  67. `import translateOptions from ${JSON.stringify(translateOptionsFileURL)};\n` +
  68. `export default await translateOptions(${JSON.stringify(options)}, "flat");\n`;
  69. // Base64 encoding is typically shorter than URL encoding
  70. return new URL(
  71. `data:text/javascript;base64,${Buffer.from(optionsSrc).toString("base64")}`,
  72. );
  73. }
  74. /**
  75. * Check if a given file path is a directory or not.
  76. * @param {string} filePath The path to a file to check.
  77. * @returns {Promise<boolean>} `true` if the given path is a directory.
  78. */
  79. async function isDirectory(filePath) {
  80. try {
  81. return (await stat(filePath)).isDirectory();
  82. } catch (error) {
  83. if (error.code === "ENOENT" || error.code === "ENOTDIR") {
  84. return false;
  85. }
  86. throw error;
  87. }
  88. }
  89. /**
  90. * Outputs the results of the linting.
  91. * @param {ESLint} engine The ESLint instance to use.
  92. * @param {LintResult[]} results The results to print.
  93. * @param {string} format The name of the formatter to use or the path to the formatter.
  94. * @param {string} outputFile The path for the output file.
  95. * @param {ResultsMeta} resultsMeta Warning count and max threshold.
  96. * @returns {Promise<boolean>} True if the printing succeeds, false if not.
  97. * @private
  98. */
  99. async function printResults(engine, results, format, outputFile, resultsMeta) {
  100. let formatter;
  101. try {
  102. formatter = await engine.loadFormatter(format);
  103. } catch (e) {
  104. log.error(e.message);
  105. return false;
  106. }
  107. const output = await formatter.format(results, resultsMeta);
  108. if (outputFile) {
  109. const filePath = path.resolve(process.cwd(), outputFile);
  110. if (await isDirectory(filePath)) {
  111. log.error(
  112. "Cannot write to output file path, it is a directory: %s",
  113. outputFile,
  114. );
  115. return false;
  116. }
  117. try {
  118. await mkdir(path.dirname(filePath), { recursive: true });
  119. await writeFile(filePath, output);
  120. } catch (ex) {
  121. log.error("There was a problem writing the output file:\n%s", ex);
  122. return false;
  123. }
  124. } else if (output) {
  125. log.info(output);
  126. }
  127. return true;
  128. }
  129. /**
  130. * Validates the `--concurrency` flag value.
  131. * @param {string} concurrency The `--concurrency` flag value to validate.
  132. * @returns {void}
  133. * @throws {Error} If the `--concurrency` flag value is invalid.
  134. */
  135. function validateConcurrency(concurrency) {
  136. if (
  137. concurrency === void 0 ||
  138. concurrency === "auto" ||
  139. concurrency === "off"
  140. ) {
  141. return;
  142. }
  143. const concurrencyValue = Number(concurrency);
  144. if (!Number.isInteger(concurrencyValue) || concurrencyValue < 1) {
  145. throw new Error(
  146. `Option concurrency: '${concurrency}' is not a positive integer, 'auto' or 'off'.`,
  147. );
  148. }
  149. }
  150. //------------------------------------------------------------------------------
  151. // Public Interface
  152. //------------------------------------------------------------------------------
  153. /**
  154. * Encapsulates all CLI behavior for eslint. Makes it easier to test as well as
  155. * for other Node.js programs to effectively run the CLI.
  156. */
  157. const cli = {
  158. /**
  159. * Calculates the command string for the --inspect-config operation.
  160. * @param {string} configFile The path to the config file to inspect.
  161. * @returns {Promise<string>} The command string to execute.
  162. */
  163. async calculateInspectConfigFlags(configFile) {
  164. // find the config file
  165. const { configFilePath, basePath } = await locateConfigFileToUse({
  166. cwd: process.cwd(),
  167. configFile,
  168. });
  169. return ["--config", configFilePath, "--basePath", basePath];
  170. },
  171. /**
  172. * Executes the CLI based on an array of arguments that is passed in.
  173. * @param {string|Array|Object} args The arguments to process.
  174. * @param {string} [text] The text to lint (used for TTY).
  175. * @param {boolean} [allowFlatConfig=true] Whether or not to allow flat config.
  176. * @returns {Promise<number>} The exit code for the operation.
  177. */
  178. async execute(args, text, allowFlatConfig = true) {
  179. if (Array.isArray(args)) {
  180. debug("CLI args: %o", args.slice(2));
  181. }
  182. /*
  183. * Before doing anything, we need to see if we are using a
  184. * flat config file. If so, then we need to change the way command
  185. * line args are parsed. This is temporary, and when we fully
  186. * switch to flat config we can remove this logic.
  187. */
  188. const usingFlatConfig =
  189. allowFlatConfig && (await shouldUseFlatConfig());
  190. debug("Using flat config?", usingFlatConfig);
  191. if (allowFlatConfig && !usingFlatConfig) {
  192. const { WarningService } = require("./services/warning-service");
  193. new WarningService().emitESLintRCWarning();
  194. }
  195. const CLIOptions = createCLIOptions(usingFlatConfig);
  196. /** @type {ParsedCLIOptions} */
  197. let options;
  198. try {
  199. options = CLIOptions.parse(args);
  200. validateConcurrency(options.concurrency);
  201. } catch (error) {
  202. debug("Error parsing CLI options:", error.message);
  203. let errorMessage = error.message;
  204. if (usingFlatConfig) {
  205. errorMessage +=
  206. "\nYou're using eslint.config.js, some command line flags are no longer available. Please see https://eslint.org/docs/latest/use/command-line-interface for details.";
  207. }
  208. log.error(errorMessage);
  209. return 2;
  210. }
  211. const files = options._;
  212. const useStdin = typeof text === "string";
  213. if (options.help) {
  214. log.info(CLIOptions.generateHelp());
  215. return 0;
  216. }
  217. if (options.version) {
  218. log.info(RuntimeInfo.version());
  219. return 0;
  220. }
  221. if (options.envInfo) {
  222. try {
  223. log.info(RuntimeInfo.environment());
  224. return 0;
  225. } catch (err) {
  226. debug("Error retrieving environment info");
  227. log.error(err.message);
  228. return 2;
  229. }
  230. }
  231. if (options.printConfig) {
  232. if (files.length) {
  233. log.error(
  234. "The --print-config option must be used with exactly one file name.",
  235. );
  236. return 2;
  237. }
  238. if (useStdin) {
  239. log.error(
  240. "The --print-config option is not available for piped-in code.",
  241. );
  242. return 2;
  243. }
  244. const engine = usingFlatConfig
  245. ? new ESLint(await translateOptions(options, "flat"))
  246. : new LegacyESLint(await translateOptions(options));
  247. const fileConfig = await engine.calculateConfigForFile(
  248. options.printConfig,
  249. );
  250. log.info(JSON.stringify(fileConfig, null, " "));
  251. return 0;
  252. }
  253. if (options.inspectConfig) {
  254. log.info(
  255. "You can also run this command directly using 'npx @eslint/config-inspector@latest' in the same directory as your configuration file.",
  256. );
  257. try {
  258. const flatOptions = await translateOptions(options, "flat");
  259. const spawn = require("cross-spawn");
  260. const flags = await cli.calculateInspectConfigFlags(
  261. flatOptions.overrideConfigFile,
  262. );
  263. spawn.sync(
  264. "npx",
  265. ["@eslint/config-inspector@latest", ...flags],
  266. { encoding: "utf8", stdio: "inherit" },
  267. );
  268. } catch (error) {
  269. log.error(error);
  270. return 2;
  271. }
  272. return 0;
  273. }
  274. debug(`Running on ${useStdin ? "text" : "files"}`);
  275. if (options.fix && options.fixDryRun) {
  276. log.error(
  277. "The --fix option and the --fix-dry-run option cannot be used together.",
  278. );
  279. return 2;
  280. }
  281. if (useStdin && options.fix) {
  282. log.error(
  283. "The --fix option is not available for piped-in code; use --fix-dry-run instead.",
  284. );
  285. return 2;
  286. }
  287. if (options.fixType && !options.fix && !options.fixDryRun) {
  288. log.error(
  289. "The --fix-type option requires either --fix or --fix-dry-run.",
  290. );
  291. return 2;
  292. }
  293. if (
  294. options.reportUnusedDisableDirectives &&
  295. options.reportUnusedDisableDirectivesSeverity !== void 0
  296. ) {
  297. log.error(
  298. "The --report-unused-disable-directives option and the --report-unused-disable-directives-severity option cannot be used together.",
  299. );
  300. return 2;
  301. }
  302. if (usingFlatConfig && options.ext) {
  303. // Passing `--ext ""` results in `options.ext` being an empty array.
  304. if (options.ext.length === 0) {
  305. log.error("The --ext option value cannot be empty.");
  306. return 2;
  307. }
  308. // Passing `--ext ,ts` results in an empty string at index 0. Passing `--ext ts,,tsx` results in an empty string at index 1.
  309. const emptyStringIndex = options.ext.indexOf("");
  310. if (emptyStringIndex >= 0) {
  311. log.error(
  312. `The --ext option arguments cannot be empty strings. Found an empty string at index ${emptyStringIndex}.`,
  313. );
  314. return 2;
  315. }
  316. }
  317. if (options.suppressAll && options.suppressRule) {
  318. log.error(
  319. "The --suppress-all option and the --suppress-rule option cannot be used together.",
  320. );
  321. return 2;
  322. }
  323. if (options.suppressAll && options.pruneSuppressions) {
  324. log.error(
  325. "The --suppress-all option and the --prune-suppressions option cannot be used together.",
  326. );
  327. return 2;
  328. }
  329. if (options.suppressRule && options.pruneSuppressions) {
  330. log.error(
  331. "The --suppress-rule option and the --prune-suppressions option cannot be used together.",
  332. );
  333. return 2;
  334. }
  335. if (
  336. useStdin &&
  337. (options.suppressAll ||
  338. options.suppressRule ||
  339. options.pruneSuppressions)
  340. ) {
  341. log.error(
  342. "The --suppress-all, --suppress-rule, and --prune-suppressions options cannot be used with piped-in code.",
  343. );
  344. return 2;
  345. }
  346. const ActiveESLint = usingFlatConfig ? ESLint : LegacyESLint;
  347. /** @type {ESLint|LegacyESLint} */
  348. let engine;
  349. if (options.concurrency && options.concurrency !== "off") {
  350. const optionsURL = createOptionsModule(options);
  351. engine = await ESLint.fromOptionsModule(optionsURL);
  352. } else {
  353. const eslintOptions = await translateOptions(
  354. options,
  355. usingFlatConfig ? "flat" : "eslintrc",
  356. );
  357. engine = new ActiveESLint(eslintOptions);
  358. }
  359. let results;
  360. if (useStdin) {
  361. results = await engine.lintText(text, {
  362. filePath: options.stdinFilename,
  363. // flatConfig respects CLI flag and constructor warnIgnored, eslintrc forces true for backwards compatibility
  364. warnIgnored: usingFlatConfig ? void 0 : true,
  365. });
  366. } else {
  367. results = await engine.lintFiles(files);
  368. }
  369. if (options.fix) {
  370. debug("Fix mode enabled - applying fixes");
  371. await ActiveESLint.outputFixes(results);
  372. }
  373. let unusedSuppressions = {};
  374. if (!useStdin) {
  375. const suppressionsFileLocation = getCacheFile(
  376. options.suppressionsLocation || "eslint-suppressions.json",
  377. process.cwd(),
  378. {
  379. prefix: "suppressions_",
  380. },
  381. );
  382. if (
  383. options.suppressionsLocation &&
  384. !fs.existsSync(suppressionsFileLocation) &&
  385. !options.suppressAll &&
  386. !options.suppressRule
  387. ) {
  388. log.error(
  389. "The suppressions file does not exist. Please run the command with `--suppress-all` or `--suppress-rule` to create it.",
  390. );
  391. return 2;
  392. }
  393. if (
  394. options.suppressAll ||
  395. options.suppressRule ||
  396. options.pruneSuppressions ||
  397. fs.existsSync(suppressionsFileLocation)
  398. ) {
  399. const suppressions = new SuppressionsService({
  400. filePath: suppressionsFileLocation,
  401. cwd: process.cwd(),
  402. });
  403. if (options.suppressAll || options.suppressRule) {
  404. await suppressions.suppress(results, options.suppressRule);
  405. }
  406. if (options.pruneSuppressions) {
  407. await suppressions.prune(results);
  408. }
  409. const suppressionResults = suppressions.applySuppressions(
  410. results,
  411. await suppressions.load(),
  412. );
  413. results = suppressionResults.results;
  414. unusedSuppressions = suppressionResults.unused;
  415. }
  416. }
  417. let resultsToPrint = results;
  418. if (options.quiet) {
  419. debug("Quiet mode enabled - filtering out warnings");
  420. resultsToPrint = ActiveESLint.getErrorResults(resultsToPrint);
  421. }
  422. const resultCounts = countErrors(results);
  423. const tooManyWarnings =
  424. options.maxWarnings >= 0 &&
  425. resultCounts.warningCount > options.maxWarnings;
  426. const resultsMeta = tooManyWarnings
  427. ? {
  428. maxWarningsExceeded: {
  429. maxWarnings: options.maxWarnings,
  430. foundWarnings: resultCounts.warningCount,
  431. },
  432. }
  433. : {};
  434. if (
  435. await printResults(
  436. engine,
  437. resultsToPrint,
  438. options.format,
  439. options.outputFile,
  440. resultsMeta,
  441. )
  442. ) {
  443. // Errors and warnings from the original unfiltered results should determine the exit code
  444. const shouldExitForFatalErrors =
  445. options.exitOnFatalError && resultCounts.fatalErrorCount > 0;
  446. if (!resultCounts.errorCount && tooManyWarnings) {
  447. log.error(
  448. "ESLint found too many warnings (maximum: %s).",
  449. options.maxWarnings,
  450. );
  451. }
  452. if (!options.passOnUnprunedSuppressions) {
  453. const unusedSuppressionsCount =
  454. Object.keys(unusedSuppressions).length;
  455. if (unusedSuppressionsCount > 0) {
  456. log.error(
  457. "There are suppressions left that do not occur anymore. Consider re-running the command with `--prune-suppressions`.",
  458. );
  459. debug(JSON.stringify(unusedSuppressions, null, 2));
  460. return 2;
  461. }
  462. }
  463. if (shouldExitForFatalErrors) {
  464. return 2;
  465. }
  466. return resultCounts.errorCount || tooManyWarnings ? 1 : 0;
  467. }
  468. return 2;
  469. },
  470. };
  471. module.exports = cli;