eslint-seatbelt
Version:
Gradually tighten ESLint rules in your codebase
340 lines (298 loc) • 9.44 kB
text/typescript
/**
* 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"
}