import type { RuleId } from "./SeatbeltFile" import { name } from "../package.json" import path from "node:path" import { findRepoRoot } from "./repoIntegration" export const SEATBELT_FILE_NAME = "eslint.seatbelt.tsv" export const SEATBELT_FROZEN = "SEATBELT_FROZEN" export const SEATBELT_INCREASE = "SEATBELT_INCREASE" export const SEATBELT_KEEP = "SEATBELT_KEEP" export const SEATBELT_FILE = "SEATBELT_FILE" export const SEATBELT_PWD = "SEATBELT_PWD" export const SEATBELT_DISABLE = "SEATBELT_DISABLE" export const SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE" export const SEATBELT_VERBOSE = "SEATBELT_VERBOSE" export const SEATBELT_QUIET = "SEATBELT_QUIET" export const SEATBELT_ROOT = "SEATBELT_ROOT" const ENV_VARS = { SEATBELT_FROZEN, SEATBELT_INCREASE, SEATBELT_KEEP, SEATBELT_FILE, SEATBELT_PWD, SEATBELT_DISABLE, SEATBELT_THREADSAFE, SEATBELT_VERBOSE, SEATBELT_QUIET, SEATBELT_ROOT, CI: "CI", JEST_WORKER_ID: "JEST_WORKER_ID", } /** * Configuration for seatbelt can be provided in a few ways: * * 1. Defined in the shared `settings` object in your ESLint config. This * requires also configuring the `eslint-seatbelt/configure` rule. * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * // ... * } * }, * rules: { * "eslint-seatbelt/configure": "error", * } * } * ] * ``` * * 2. Using the `eslint-seatbelt/configure` rule in your ESLint config. * This can be used to override settings for specific files in legacy ESLint configs. * Any configuration provided here will override the shared `settings` object. * * ```js * // in .eslintrc.js * module.exports = { * rules: { * "eslint-seatbelt/configure": "error", * }, * overrides: [ * { * files: ["some/path/*"], * rules: { * "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }] * }, * }, * ], * } * ``` * 3. The settings in config files can be overridden with environment variables when running `eslint` or other tools. * * ```bash * SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint * ``` */ export interface SeatbeltConfig { /** * The seatbelt file stores the max error counts allowed for each file. Should * be an absolute path. * * If not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used. * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * // commonjs * seatbeltFile: `${__dirname}/eslint.seatbelt.tsv` * // esm * seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname * } * } * } * ] * ``` * * You can also set this with environment variable `SEATBELT_FILE`: * * ```bash * SEATBELT_FILE=.config/custom-seatbelt-file eslint * ``` */ seatbeltFile?: string /** * By default whenever a file is linted and a rule has no errors, that rule's * max errors for the file is set to zero. * * However with typescript-eslint, it can be helpful to have two ESLint configs: * * - A default ESLint config with only syntactic rules enabled that don't * require typechecking, that runs on developer machines and in their editor. * - A CI-only ESLint config with only type-aware rules enabled that requires * typechecking. Since these rules require typechecking, they can be too * slow to run in interactive contexts. * * To avoid this, set `keepRules` to the names of *disabled but known rules* * while linting. * * Example: * * ```js * // Default ESLint config * module.exports = [ * { * settings: { * seatbelt: { * keepRules: require('./eslint-typed.config.js').flatMap(config => Object.keys(config.rules ?? {})), * } * }, * rules: { * "no-unused-vars": "error", * }, * } * ] * * // Typechecking-required ESLint config for CI * module.exports = [ * { * settings: { * seatbelt: { * keepRules: require('./eslint.config.js').flatMap(config => Object.keys(config.rules ?? {})), * } * }, * rules: { * // Requires typechecking (slow) * "@typescript-eslint/no-floating-promises": "error", * }, * } * ] * ``` * * You can also set this with environment variable `SEATBELT_KEEP`: * * ```bash * SEATBELT_KEEP="@typescript-eslint/no-floating-promises @typescript-eslint/prefer-reduce-type-parameter" \ * eslint * ``` * * You can set this to `"ALL"` to enable this setting for ALL rules: * * ```bash * SEATBELT_KEEP=ALL eslint * ``` */ keepRules?: RuleId[] | "all" /** * When you enable a rule for the first time, lint with it in this set to set * the initial max error counts. * * Typically this should be enabled for one lint run only via an environment * variable, but it can also be configured via ESLint settings. * * ```bash * SEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint * ``` * * You can set this to `"ALL"` to enable this setting for ALL rules: * * ```bash * SEATBELT_INCREASE=ALL eslint * ``` * * ```js * // in eslint.config.js * // maybe you have a use-case for this * const config = [ * { * settings: { * seatbelt: { * allowIncreaseRules: ["@typescript-eslint/no-floating-promises"], * } * } * } * ] * ``` */ allowIncreaseRules?: RuleId[] | "all" /** * Error if there is any change in the number of errors in the seatbelt file. * This is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors. * * It is enabled by default when environment variable `CI` is set. * * ```bash * CI=1 eslint * ``` * * This can be set with the `SEATBELT_FROZEN` environment variable. * * ```bash * SEATBELT_FROZEN=1 eslint * ``` * * Or in ESLint config: * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * frozen: true, * } * } * } * ] * ``` */ frozen?: boolean /** * Completely disable seatbelt error processing for a lint run while leaving it otherwise configured. * * This can be set with the `SEATBELT_DISABLE` environment variable. * * ```bash * SEATBELT_DISABLE=1 eslint * ``` * * Or in ESLint config: * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * disable: true, * } * } * } * ] * ``` */ disable?: boolean /** * Suppress seatbelt's informational warning messages (e.g. "tend the garden", * "thank you for fixing"). When enabled, seatbelt still downgrades errors to * warnings and updates the seatbelt file, but the warning messages are not * emitted as ESLint results. Over-limit errors and frozen-mode warnings are * always preserved. * * This is useful when seatbelt warnings create noise in CI logs or editor * integrations. * * This can be set with the `SEATBELT_QUIET` environment variable. * * ```bash * SEATBELT_QUIET=1 eslint * ``` * * Or in ESLint config: * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * quiet: true, * } * } * } * ] * ``` */ quiet?: boolean /** * By default seatbelt assumes that only one ESLint process will read and * write to the seatbelt file at a time. * * This should be set to `true` if you use a parallel ESLint runner similar to * jest-runner-eslint to avoid losing updates during parallel writes to the * seatbelt file. * * When enabled, seatbelt creates temporary lock files to serialize updates to * the seatbelt file. This comes at a small performance cost. * * This is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set). * * It can also be set with environment variable `SEATBELT_THREADSAFE`: * * ```bash * SEATBELT_THREADSAFE=1 eslint-parallel * ``` * * Or in ESLint config: * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * threadsafe: true, * } * } * } * ] * ``` */ threadsafe?: boolean /** * Enable verbose logging. * * This can be set with the `SEATBELT_VERBOSE` environment variable. * * ```bash * SEATBELT_VERBOSE=1 eslint * ``` * * Or in ESLint config: * * ```js * // in eslint.config.js * const config = [ * { * settings: { * seatbelt: { * verbose: true, * } * } * } * ] * ``` * * If set to a function (like `console.error`), that function will be called with the log messages. * The default logger when set to `true` is `console.error`. */ verbose?: boolean | "stdout" | "stderr" | ((...message: unknown[]) => void) /** * Repository or project root. * By default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`. * Used for editor integration to disable seatbelt during git actions like rebase or merge. * * This can be set with the `SEATBELT_ROOT` environment variable. */ root?: string } export interface SeatbeltConfigWithPwd extends SeatbeltConfig { pwd: string } export const SeatbeltConfig = { withEnvOverrides( config: SeatbeltConfig, env: SeatbeltEnv & FallbackEnv, ): SeatbeltConfig { return { ...SeatbeltConfig.fromFallbackEnv(env), ...config, ...SeatbeltConfig.fromEnvOverrides(env), } }, fromFallbackEnv( env: FallbackEnv, log?: (...message: unknown[]) => void, ): SeatbeltConfig { const config: SeatbeltConfig = {} const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI) if (isCI) { config.frozen = true log?.(`${padVarName("CI")} config.frozen defaults to`, true) } if (env.JEST_WORKER_ID) { config.threadsafe = true log?.( `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, true, ) } return config }, fromEnvOverrides( env: SeatbeltEnv, log?: (...message: unknown[]) => void, ): SeatbeltConfigWithPwd { const config: SeatbeltConfigWithPwd = { pwd: env[SEATBELT_PWD] || process.cwd(), } const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]) if (verbose !== undefined) { config.verbose = verbose log?.(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose) } const seatbeltFile = env[SEATBELT_FILE] if (seatbeltFile) { const rootRelative = path.isAbsolute(seatbeltFile) ? seatbeltFile : path.join(config.pwd, seatbeltFile) config.seatbeltFile = rootRelative log?.(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative) } const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]) if (disable !== undefined) { config.disable = disable log?.(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable) } const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]) if (frozen !== undefined) { config.frozen = frozen log?.(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen) } const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]) if (increase !== undefined) { config.allowIncreaseRules = increase log?.( `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, increase, ) } const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]) if (keep !== undefined) { config.keepRules = keep log?.(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep) } const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]) if (threadsafe !== undefined) { config.threadsafe = threadsafe log?.( `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, threadsafe, ) } const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]) if (quiet !== undefined) { config.quiet = quiet log?.(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet) } const root = env[SEATBELT_ROOT] if (root) { config.root = root log?.(`${padVarName(SEATBELT_ROOT)} config.root =`, root) } return config }, } as const /** A parsed {@link SeatbeltConfig} with all properties converted to runtime types. */ export type SeatbeltArgs = { [K in keyof SeatbeltConfig]-?: "all" | RuleId[] extends SeatbeltConfig[K] ? "all" | Set : SeatbeltConfig[K] } /** Catalogues the names of environment variables */ export interface SeatbeltEnv { [SEATBELT_INCREASE]?: string [SEATBELT_KEEP]?: string [SEATBELT_FILE]?: string [SEATBELT_PWD]?: string [SEATBELT_THREADSAFE]?: string [SEATBELT_DISABLE]?: string [SEATBELT_FROZEN]?: string [SEATBELT_VERBOSE]?: string [SEATBELT_QUIET]?: string [SEATBELT_ROOT]?: string } export const SeatbeltEnv = { parseRuleSetEnvVar(value: string | undefined): RuleId[] | "all" | undefined { if (value === undefined) { return undefined } if (!value) { return [] } const lower = value.toLowerCase() if (lower === "all" || lower === "1" || lower === "true") { return "all" } return value.split(/[\s,]+/g).filter(Boolean) }, readBooleanEnvVar(value: string | undefined): boolean | undefined { if (value === undefined || value === "") { return undefined } const lower = value.toLowerCase() if (lower === "false" || lower === "0" || lower === "no") { return false } return Boolean(value) }, } as const /** Environment variables we may consider that don't override explicitly set config values. */ export interface FallbackEnv { CI?: string JEST_WORKER_ID?: string } export const logStdout = (...message: unknown[]) => // eslint-disable-next-line no-console console.log(`[${name}]:`, ...message) export const logStderr = (...message: unknown[]) => // eslint-disable-next-line no-console console.error(`[${name}]:`, ...message) export const SeatbeltArgs = { fromConfig(config: SeatbeltConfig & { pwd?: string }): SeatbeltArgs { const cwd = config.pwd ?? process.cwd() const seatbeltFile = config.seatbeltFile ?? SeatbeltArgs.findSeatbeltFile(cwd) const root = config.root ?? findRepoRoot(seatbeltFile) ?? path.dirname(seatbeltFile) return { seatbeltFile, root, keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(config.keepRules ?? []), allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(config.allowIncreaseRules ?? []), frozen: config.frozen ?? false, disable: config.disable ?? false, quiet: config.quiet ?? false, threadsafe: config.threadsafe ?? false, verbose: config.verbose ?? false, } }, getLogger(args: SeatbeltArgs): (...message: unknown[]) => void { if (typeof args.verbose === "function") { return args.verbose } if (args.verbose === "stdout") { return logStdout } return logStderr }, ruleSetHas(ruleSet: "all" | Set, ruleId: RuleId): boolean { return ruleSet === "all" || ruleSet.has(ruleId) }, verboseLog(args: SeatbeltArgs, makeMessage: () => string | unknown[]) { if (args.verbose) { const message = makeMessage() const log = SeatbeltArgs.getLogger(args) if (typeof message === "string") { log(message) } else { log(...message) } } }, findSeatbeltFile(cwd: string): string { // TODO: go up to parent dir w/ .git? return `${cwd}/${SEATBELT_FILE_NAME}` }, } let envVarMaxLength = 0 export function padVarName(name: string) { envVarMaxLength ||= Math.max( ...Object.values(ENV_VARS).map((name) => name.length), ) return `${name}:`.padEnd(envVarMaxLength + 1) } export function formatFilename(filename: string) { const relative = path.relative( process.env[SEATBELT_PWD] ?? process.cwd(), filename, ) return relative ? relative : filename } export function formatRuleId(ruleId: RuleId | null) { if (ruleId === null) { return `unknown rule` } return `rule ${ruleId}` }