eslint-seatbelt
Version:
Gradually tighten ESLint rules in your codebase
403 lines (361 loc) • 11.4 kB
text/typescript
import * as os from "node:os"
import * as fs from "node:fs"
import path, * as nodePath from "node:path"
import {
formatFilename,
formatRuleId,
SEATBELT_FROZEN,
SEATBELT_KEEP,
SeatbeltArgs,
} from "./SeatbeltConfig"
import { name } from "../package.json"
import { appendErrorContext, isErrno } from "./errorHanding"
export type SourceFileName = string
export type RuleId = string
interface SeatbeltFileLine {
encoded?: string
filename: SourceFileName
ruleId: RuleId
maxErrors: number
}
export type SeatbeltFileJson = {
filename: SourceFileName
data: Record<SourceFileName, Record<RuleId, number>>
}
function encodeLine(line: SeatbeltFileLine): string {
const { filename, ruleId, maxErrors } = line
return `${JSON.stringify(filename)}\t${JSON.stringify(ruleId)}\t${maxErrors}\n`
}
function decodeLine(line: string, index: number): SeatbeltFileLine {
try {
const lineParts = line.split("\t")
if (lineParts.length !== 3) {
throw new Error(
`Expected 3 tab-separated JSON strings, instead have ${lineParts.length}`,
)
}
let filename: string
try {
filename = JSON.parse(lineParts[0])
} catch (e) {
appendErrorContext(e, "at tab-separated column 1 (filename)")
throw e
}
let ruleId: RuleId
try {
ruleId = JSON.parse(lineParts[1])
} catch (e) {
appendErrorContext(e, "at tab-separated column 2 (RuleId)")
throw e
}
let maxErrors: number
try {
maxErrors = JSON.parse(lineParts[2])
} catch (e) {
appendErrorContext(e, "at tab-separated column 3 (maxErrors)")
throw e
}
return {
encoded: line,
filename,
ruleId,
maxErrors,
}
} catch (e) {
appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``)
throw e
}
}
interface SeatbeltStateFileData {
maxErrors?: Map<RuleId, number>
lines: SeatbeltFileLine[]
}
const COMMENT_LINE_REGEX = /^\s*#/
const NON_EMPTY_LINE_REGEX = /\S+/
const DEFAULT_FILE_HEADER = `
# ${name} temporarily allowed errors
# docs: https://github.com/justjake/${name}#readme
`.trim()
/**
* The state file is a Map<filename, Map<ruleId, allowedErrors>>.
* It is stored in "tab separated json" format. This format is chosen over JSON
* or YAML because each line is independent, which makes resolving merge
* conflicts much easier than in a syntactically hierarchical format.
*/
export class SeatbeltFile {
static readSync(filename: string): SeatbeltFile {
const text = fs.readFileSync(filename, "utf8")
try {
return SeatbeltFile.parse(filename, text)
} catch (e) {
appendErrorContext(e, `in seatbelt file \`${filename}\``)
throw e
}
}
/**
* Read `filename` if it exists, otherwise create a new empty seatbelt file object
* that will write to that filename.
*/
static openSync(filename: string): SeatbeltFile {
try {
return SeatbeltFile.readSync(filename)
} catch (e) {
if (isErrno(e, "ENOENT")) {
return new SeatbeltFile(filename, new Map(), DEFAULT_FILE_HEADER)
}
throw e
}
}
static parse(filename: string, text: string): SeatbeltFile {
const data = new Map<SourceFileName, SeatbeltStateFileData>()
const split = text.split(/(?<=\n)/)
const lines = split
.filter(
(line) =>
NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line),
)
.map(decodeLine)
const comments = split
.filter((line) => COMMENT_LINE_REGEX.test(line))
.join("")
lines.forEach((line) => {
let fileState = data.get(line.filename)
if (!fileState) {
fileState = { maxErrors: undefined, lines: [] }
data.set(line.filename, fileState)
}
fileState.lines.push(line)
})
return new SeatbeltFile(filename, data, comments.trim())
}
static fromJSON(json: SeatbeltFileJson): SeatbeltFile {
const data = new Map(
Object.entries(json.data).map(([filename, maxErrors]) => [
filename,
{ maxErrors: new Map(Object.entries(maxErrors)), lines: [] },
]),
)
return new SeatbeltFile(json.filename, data)
}
public changed = false
private readonly dirname: string
private useTempDirForWrites = true
constructor(
public readonly filename: string,
protected data: Map<SourceFileName, SeatbeltStateFileData>,
public readonly comments: string = "",
) {
this.filename = path.resolve(this.filename)
this.dirname = path.dirname(this.filename)
}
*filenames(): IterableIterator<SourceFileName> {
for (const filename of this.data.keys()) {
yield this.toAbsolutePath(filename)
}
}
getMaxErrors(
filename: SourceFileName,
): ReadonlyMap<RuleId, number> | undefined {
const fileState = this.data.get(this.toRelativePath(filename))
if (!fileState) {
return undefined
}
fileState.maxErrors ??= parseMaxErrors(fileState.lines)
return fileState.maxErrors
}
removeFile(filename: SourceFileName, args: SeatbeltArgs) {
const relativeFilename = this.toRelativePath(filename)
if (!this.data.has(relativeFilename)) {
return false
}
SeatbeltArgs.verboseLog(args, () =>
args.frozen
? `${formatFilename(filename)}: ${SEATBELT_FROZEN}: didn't remove max errors`
: `${formatFilename(filename)}: remove max errors`,
)
if (args.frozen) {
return false
}
this.data.delete(relativeFilename)
this.changed = true
return true
}
updateMaxErrors(
filename: SourceFileName,
args: SeatbeltArgs,
ruleToErrorCount: ReadonlyMap<RuleId, number>,
) {
const removedRules = new Set<RuleId>()
let increasedRulesCount = 0
let decreasedRulesCount = 0
this.getMaxErrors(filename)
const relativeFilename = this.toRelativePath(filename)
const maxErrors =
this.data.get(relativeFilename)?.maxErrors ?? new Map<RuleId, number>()
ruleToErrorCount.forEach((errorCount, ruleId) => {
const maxErrorCount = maxErrors.get(ruleId) ?? 0
if (errorCount === maxErrorCount) {
return
}
if (
errorCount < maxErrorCount ||
SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)
) {
SeatbeltArgs.verboseLog(args, () =>
args.frozen
? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}`
: `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}`,
)
maxErrors.set(ruleId, errorCount)
if (errorCount > maxErrorCount) {
increasedRulesCount++
} else {
decreasedRulesCount++
}
}
})
if (args.verbose || args.keepRules !== "all") {
maxErrors.forEach((maxErrorCount, ruleId) => {
const shouldRemove =
maxErrorCount === 0 || !ruleToErrorCount.has(ruleId)
if (!shouldRemove) {
return
}
if (SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) {
SeatbeltArgs.verboseLog(
args,
() =>
`${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}`,
)
return
}
SeatbeltArgs.verboseLog(args, () =>
args.frozen
? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}`
: `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${0}`,
)
maxErrors.delete(ruleId)
removedRules.add(ruleId)
})
}
const changed =
increasedRulesCount > 0 ||
decreasedRulesCount > 0 ||
removedRules.size > 0
if (changed && !args.frozen) {
const file = this.data.get(relativeFilename)
if (file) {
file.maxErrors = maxErrors
} else {
this.data.set(relativeFilename, {
maxErrors,
lines: [],
})
}
this.changed = true
}
return { removedRules, increasedRulesCount, decreasedRulesCount }
}
toDataString(): string {
const lines: string[] = []
this.data.forEach((fileState, filename) => {
if (fileState.maxErrors) {
// Serialize maxErrors map structure if it exists, since it may have changes.
fileState.lines = []
fileState.maxErrors.forEach((maxErrorCount, ruleId) => {
fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount })
})
fileState.lines.sort((a, b) =>
a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1,
)
}
fileState.lines.forEach((line) => {
const encoded = (line.encoded ??= encodeLine(line))
lines.push(encoded)
})
})
lines.sort()
if (this.comments) {
return this.comments + "\n\n" + lines.join("")
} else {
return lines.join("")
}
}
readSync() {
const nextStateFile = SeatbeltFile.openSync(this.filename)
if (nextStateFile) {
this.data = nextStateFile.data
this.changed = false
return true
}
return false
}
flushChanges() {
if (this.changed) {
this.writeSync()
this.changed = false
return { updated: true }
}
return { updated: false }
}
writeSync() {
const dataString = this.toDataString()
const dir = nodePath.dirname(this.filename)
const base = nodePath.basename(this.filename)
const tempFile = nodePath.join(
this.useTempDirForWrites ? os.tmpdir() : dir,
`.${base}.wip${process.pid}.${Date.now()}.tmp`,
)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(tempFile, dataString, "utf8")
try {
fs.renameSync(tempFile, this.filename)
} catch (error) {
// If $TMPDIR is on a different filesystem from the git repo, we won't be
// able to move the tempfile over the existing file.
//
// In such cases copy & remove the tempfile instead, and then prefer to create
// tempfiles in the desination dir.
//
// https://github.com/justjake/eslint-seatbelt/issues/7
if (isErrno(error, 'EXDEV')) {
this.useTempDirForWrites = false
fs.copyFileSync(tempFile, this.filename)
fs.rmSync(tempFile)
return
}
throw error
}
}
toJSON(): SeatbeltFileJson {
const data = Object.fromEntries(
Array.from(this.data.keys()).map((filename) => {
const maxErrors = this.getMaxErrors(filename)
if (!maxErrors) {
throw new Error(`${name} bug: expected errors for existing key`)
}
return [filename, Object.fromEntries(maxErrors)]
}),
)
return { filename: this.filename, data }
}
toRelativePath(filename: string) {
if (!nodePath.isAbsolute(filename)) {
return filename
}
return nodePath.relative(this.dirname, filename)
}
toAbsolutePath(filename: string) {
if (nodePath.isAbsolute(filename)) {
return filename
}
return nodePath.resolve(this.dirname, filename)
}
}
function parseMaxErrors(lines: SeatbeltFileLine[]): Map<RuleId, number> {
const maxErrors = new Map<RuleId, number>()
lines.forEach((line) => {
maxErrors.set(line.ruleId, line.maxErrors)
})
return maxErrors
}