scripts/frontend/generate_eslint_todo_list.mjs (142 lines of code) (raw):

import path from 'node:path'; import fs from 'node:fs'; import { ESLint } from 'eslint'; import { program } from 'commander'; import * as prettier from 'prettier'; import kebabCase from 'lodash/kebabCase.js'; import camelCase from 'lodash/camelCase.js'; import sortBy from 'lodash/sortBy.js'; import eslintConfig from '../../eslint.config.mjs'; const ROOT_PATH = path.resolve(import.meta.dirname, '../../'); function createESLintInstance(overrideConfig) { return new ESLint({ overrideConfigFile: true, overrideConfig, fix: false }); } function lint(eslint, filePaths) { return eslint.lintFiles(filePaths); } /** * Creates a barebone ESLint config to lint the codebase with. We only keep configs that make use * of the rule we are generating a todo file for. If no config use the rule, this returns `null` and * should cause the script to abort its execution. * * @param {string} rule The rule to generate a todo file for. * @returns {object|null} The config to use for the rule. */ function getConfigForRule(rule) { let configHasRule = false; const newConfig = eslintConfig .map((config) => { // We preserve existing configs for the rule so that we don't add valid files to the todo file. // However, we bypass configs that disabled the rule as those are likely the todo files themselves. const hasRuleDefinition = config.rules?.[rule] && config.rules[rule] !== 'off'; if (hasRuleDefinition) { configHasRule = true; return { ...config, rules: { [rule]: config.rules[rule], }, }; } return { ...config, rules: {}, }; }) .filter((config) => config !== null); if (configHasRule) { return newConfig; } return null; } function getOffendingFiles(results, rule) { return results.reduce((acc, result) => { const hasRuleError = result.messages.some((message) => message.ruleId === rule); if (hasRuleError) { acc.push(result.filePath); } return acc; }, []); } async function prettify(data) { const prettierConfig = await prettier.resolveConfig(path.join(ROOT_PATH, '.prettierrc')); return prettier.format(data, { ...prettierConfig, parser: 'babel', }); } async function writeTodoFile(rule, offendingFiles) { const slugifiedRule = kebabCase(rule); const todoFileName = `${slugifiedRule}.mjs`; const todoFilePath = path.join(ROOT_PATH, '.eslint_todo', todoFileName); const camelCasedRule = camelCase(rule); const relativePaths = sortBy(offendingFiles.map((file) => path.relative(ROOT_PATH, file))) .map((relativePath) => `'${relativePath}'`) .join(',\n'); const indexFilePath = path.join(ROOT_PATH, '.eslint_todo', 'index.mjs'); const indexFileContent = fs.readFileSync(indexFilePath, { encoding: 'utf-8' }); console.log(`Writing todo file to ${todoFilePath}.`); const newConfig = ` /** * Generated by \`scripts/frontend/generate_eslint_todo_list.mjs\`. */ export default { files: [${relativePaths}], rules: { '${rule}': 'off', }, } `; const formattedTodoFileContent = await prettify(newConfig); fs.writeFileSync(todoFilePath, formattedTodoFileContent); if (!indexFileContent.match(camelCasedRule)) { console.log(`Adding export statement to ${indexFilePath}.`); const exportStatement = `export { default as ${camelCasedRule} } from './${todoFileName}';`; const newIndexFileContent = `${indexFileContent}\n${exportStatement}`; const formattedNewIndexFileContent = await prettify(newIndexFileContent); fs.writeFileSync(indexFilePath, formattedNewIndexFileContent); } else { console.log(`Export statement already exists in ${indexFilePath}.`); } } async function main() { program .description( 'Generates a todo file to skip linting on offending files for a specific ESLint rule.', ) .option('--debug-config', 'Prints the ESLint config used to generate the todo file.') .argument('<rule>') .parse(process.argv); const options = program.opts(); const [rule] = program.args; console.log(`Generating todo file for rule \`${rule}\`...`); const overrideConfig = getConfigForRule(rule); if (overrideConfig === null) { console.error( `The rule \`${rule}\` could not be found in the ESLint configuration. It needs to be enabled before generating a todo file.`, ); process.exitCode = 1; return; } if (options.debugConfig) { console.log('Using ESLint configuration:'); console.log(overrideConfig); } const eslint = createESLintInstance(overrideConfig); const results = await lint(eslint, [ './app/assets/javascripts/**/*.{js,mjs,cjs,vue}', './ee/app/assets/javascripts/**/*.{js,mjs,cjs,vue}', './spec/frontend/**/*.js', './ee/spec/frontend/**/*.js', 'scripts/**/*.{js,mjs,cjs}', ]); const offendingFiles = getOffendingFiles(results, rule); if (offendingFiles.length > 0) { console.log(`Found ${offendingFiles.length} offending files.`); await writeTodoFile(rule, offendingFiles); } else { console.error('No offenses found. Delete any existing todo file if it is not needed anymore.'); process.exitCode = 1; } } main();