UNPKG

eslint-seatbelt

Version:

Gradually tighten ESLint rules in your codebase

340 lines (298 loc) 9.44 kB
/** * Facilitates caching and passing of state between different parts of the plugin. */ import { FallbackEnv, logStderr, padVarName, SEATBELT_VERBOSE, SeatbeltArgs, SeatbeltConfig, SeatbeltConfigWithPwd, SeatbeltEnv, } from "./SeatbeltConfig" import { SeatbeltFile } from "./SeatbeltFile" import { name, version } from "../package.json" import fs from "node:fs" let ANY_CONFIG_DISABLED = false let LAST_VERBOSE_ARGS: SeatbeltArgs | undefined const VERBOSE_SEATBELT_FILES = new Set<string>() const CLI_ARGS = new Set<SeatbeltArgs>() const EMPTY_CONFIG: SeatbeltConfig = {} const argsCache = new WeakMap<SeatbeltConfig, SeatbeltArgs>() const seatbeltFileCache = new Map<string, SeatbeltFile>() const mergedConfigCache = new WeakMap< /* settings.seatbelt */ SeatbeltConfig, WeakMap</* from rule settings override*/ SeatbeltConfig, SeatbeltConfig> >() let envFallbackConfig: SeatbeltConfig | undefined let envOverrideConfig: SeatbeltConfigWithPwd | undefined let hasAnyEnvVars = false let lastLintedFile: { filename: string; args: SeatbeltArgs } | undefined const temporaryFileArgs = new Map<string, SeatbeltArgs>() function getProcessEnvFallbackConfig(): SeatbeltConfig { if (!envFallbackConfig) { envFallbackConfig = SeatbeltConfig.fromFallbackEnv( process.env as FallbackEnv, ) hasAnyEnvVars = Object.keys(envFallbackConfig).length > 0 } return envFallbackConfig } function getProcessEnvOverrideConfig(): SeatbeltConfigWithPwd { if (!envOverrideConfig) { envOverrideConfig = SeatbeltConfig.fromEnvOverrides( process.env as SeatbeltEnv, ) ANY_CONFIG_DISABLED ||= envOverrideConfig.disable ?? false hasAnyEnvVars = Object.keys(envOverrideConfig).length > 0 } return envOverrideConfig } export function ruleOverrideConfigToArgs( settingsConfig: SeatbeltConfig | undefined, ruleOverrideConfig: SeatbeltConfig | undefined, ): SeatbeltArgs { if (settingsConfig && ruleOverrideConfig) { let settingsConfigMergeMap = mergedConfigCache.get(settingsConfig) if (!settingsConfigMergeMap) { settingsConfigMergeMap = new WeakMap() mergedConfigCache.set(settingsConfig, settingsConfigMergeMap) } let mergedConfig = settingsConfigMergeMap.get(ruleOverrideConfig) if (!mergedConfig) { mergedConfig = { ...settingsConfig, ...ruleOverrideConfig } settingsConfigMergeMap.set(ruleOverrideConfig, mergedConfig) } return configToArgs(mergedConfig) } return configToArgs(ruleOverrideConfig ?? settingsConfig ?? EMPTY_CONFIG) } function configToArgs(config: SeatbeltConfig): SeatbeltArgs { let args = argsCache.get(config) if (!args) { const compiledConfig = { ...getProcessEnvFallbackConfig(), ...config, ...getProcessEnvOverrideConfig(), } args = SeatbeltArgs.fromConfig(compiledConfig) ANY_CONFIG_DISABLED ||= args.disable if (args.verbose) { LAST_VERBOSE_ARGS = args VERBOSE_SEATBELT_FILES.add(args.seatbeltFile) if (!args.disable) { logConfig(args, config) } } argsCache.set(config, args) } return args } const configureRuleName = `${name}/configure` function logRuleSetupHint() { logStderr( ` Make sure you have rule ${configureRuleName} enabled in your ESLint config for all files: rules: { // ... "${configureRuleName}": "error", } Docs: https://github.com/justjake/${name}#setup`, ) } function logConfig(args: SeatbeltArgs, baseConfig: SeatbeltConfig) { const log = SeatbeltArgs.getLogger(args) SeatbeltConfig.fromFallbackEnv(process.env as FallbackEnv, log) for (const [key, value] of Object.entries(baseConfig)) { log(`${padVarName("ESLint settings")} config.${key} =`, value) } SeatbeltConfig.fromEnvOverrides(process.env as SeatbeltEnv, log) } export function pushFileArgs(filename: string, args: SeatbeltArgs) { lastLintedFile = { filename, args } temporaryFileArgs.set(filename, args) if (isEslintCli()) { CLI_ARGS.add(args) } } export function popFileArgs(filename: string): SeatbeltArgs { const args = temporaryFileArgs.get(filename) temporaryFileArgs.delete(filename) if (args) { return args } if (!hasAnyEnvVars) { if (lastLintedFile) { logStderr( `WARNING: last configured by file \`${lastLintedFile.filename}\` but linting file \`${filename}\`. You may have rule ${configureRuleName} enabled for some files, but not this one. `.trim(), ) } else { logStderr( `WARNING: rule ${configureRuleName} not enabled in ESLint config and no SEATBELT environment variables set`, ) } logRuleSetupHint() } return configToArgs(EMPTY_CONFIG) } export function getSeatbeltFile(filename: string): SeatbeltFile { let seatbeltFile = seatbeltFileCache.get(filename) if (!seatbeltFile) { seatbeltFile = SeatbeltFile.openSync(filename) seatbeltFileCache.set(filename, seatbeltFile) } return seatbeltFile } let didRegisterProcessExitHandler = false type RunContext = { runner: "eslint-cli" | "editor" | "unknown" inEditorTerminal?: boolean vscodeLike?: boolean ci?: boolean npmLifecycleScript?: string } function detectRunContext(): RunContext { const isVscodeExtension = Boolean(process.env.VSCODE_IPC_HOOK) const isVscodeShell = process.env.TERM_PROGRAM === "vscode" const isEslintCli = process.argv.some( (arg) => arg.endsWith("bin/eslint.js") || arg.includes("node_modules/eslint/"), ) return { runner: isVscodeExtension ? "editor" : isEslintCli ? "eslint-cli" : "unknown", inEditorTerminal: isVscodeShell, npmLifecycleScript: process.env.npm_lifecycle_script, ci: Boolean(process.env.CI), } } let runContext: RunContext | undefined function getRunContext(): RunContext { if (!runContext) { runContext = detectRunContext() } return runContext } const pluginStats = { processorRuns: 0, ruleRuns: 0, removedFiles: 0, } export function incrementStat(key: keyof typeof pluginStats, value = 1) { pluginStats[key] += value } export function onPreprocess(_filename: string) { incrementStat("processorRuns") } export function onPostprocess(_filename: string) {} export function onConfigureRule(_filename: string) { incrementStat("ruleRuns") } export function registerEslintCliExitHandler() { if (ANY_CONFIG_DISABLED) { return } if (didRegisterProcessExitHandler) { return } didRegisterProcessExitHandler = true const runContext = detectRunContext() if (isEslintCli()) { process.once("exit", () => handleEslintCliExit(runContext)) } } // Detect configuration errors function handleEslintCliExit(_runContext: RunContext) { if (ANY_CONFIG_DISABLED) { return } cleanUpRemovedFiles() if (LAST_VERBOSE_ARGS) { logEslintRunSummary() } } function cleanUpRemovedFiles() { for (const args of CLI_ARGS) { const seatbeltFile = getSeatbeltFile(args.seatbeltFile) // TODO: args.threadsafe seatbeltFile.readSync() for (const filename of seatbeltFile.filenames()) { if (!fs.existsSync(filename)) { seatbeltFile.removeFile(filename, args) incrementStat("removedFiles") } } seatbeltFile.writeSync() } } function logEslintRunSummary() { const log = LAST_VERBOSE_ARGS ? SeatbeltArgs.getLogger(LAST_VERBOSE_ARGS) : logStderr const seatbeltFiles = Array.from(VERBOSE_SEATBELT_FILES).map(getSeatbeltFile) const ruleInfo = new Map<string, { allowed: number; inFiles: number }>() const totalInfo = { allowed: 0, inFiles: 0 } for (const seatbeltFile of seatbeltFiles) { for (const filename of seatbeltFile.filenames()) { const maxErrors = seatbeltFile.getMaxErrors(filename) if (maxErrors) { for (const [ruleId, errorCount] of maxErrors.entries()) { const info = getDefault(ruleInfo, ruleId, () => ({ allowed: 0, inFiles: 0, })) info.allowed += errorCount info.inFiles++ totalInfo.allowed += errorCount totalInfo.inFiles++ } } } } const ruleStatsMessages: string[] = [] ruleStatsMessages.push( `${SEATBELT_VERBOSE}: ${name}@${version} checked ${pluginStats.processorRuns} source files\n`, ) const seatbeltFileCount = seatbeltFiles.length === 1 ? "seatbelt file" : `${seatbeltFiles.length} seatbelt files` if (pluginStats.removedFiles > 0) { ruleStatsMessages.push( `Removed ${pluginStats.removedFiles} non-existent source files from ${seatbeltFileCount}\n`, ) } ruleStatsMessages.push(`Allowed errors in ${seatbeltFileCount}:\n`) for (const ruleId of Array.from(ruleInfo.keys()).sort()) { const info = ruleInfo.get(ruleId)! const sourceFilesCount = info.inFiles === 1 ? "1 source file" : `${info.inFiles} source files` ruleStatsMessages.push( ` ${ruleId}: ${info.allowed} allowed in ${sourceFilesCount}\n`, ) } log(ruleStatsMessages.join("")) } export function hasProcessorRun() { return pluginStats.processorRuns > 0 } export function anyDisabled() { return ANY_CONFIG_DISABLED } function getDefault<K, V>(map: Map<K, V>, key: K, defaultValue: () => V) { if (!map.has(key)) { const value = defaultValue() map.set(key, value) return value } return map.get(key)! } export function isEslintCli() { return getRunContext().runner === "eslint-cli" }