ratchet-eslint-rules.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. /* eslint-disable turbo/no-undeclared-env-vars */
  2. /**
  3. * Ratchet ESLint violations for selected rules.
  4. *
  5. * Examples:
  6. * # Initialize baselines for two rules
  7. * tsx scripts/ratchet-eslint-rules.ts --init \
  8. * --rule react-hooks/exhaustive-deps --rule no-console
  9. *
  10. * # Compare current counts vs baselines
  11. * tsx scripts/ratchet-eslint-rules.ts \
  12. * --rule react-hooks/exhaustive-deps --rule no-console
  13. *
  14. # Decrease baselines when improvements occur
  15. * tsx scripts/ratchet-eslint-rules.ts \
  16. * --rule react-hooks/exhaustive-deps --rule no-console \
  17. * --decrease-baselines
  18. *
  19. * Flags:
  20. * --metadata <path> Path to baseline file (default .github/eslint-rule-baselines.json)
  21. * --init Write current counts for the provided --rule(s) into metadata and exit 0
  22. * --eslint "<cmd>" ESLint command to run (default "npx eslint"). Do not pass untrusted input.
  23. * --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input.
  24. * --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate. REQUIRED.
  25. * --decrease-baselines When improvements occur, lower stored baselines to match the new counts.
  26. *
  27. * Notes:
  28. * - Counts occurrences regardless of severity (warn/error).
  29. * - Fails if any selected rule has currentCount > baselineCount.
  30. */
  31. import { spawnSync } from 'node:child_process'
  32. import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  33. import path from 'node:path'
  34. import { pathToFileURL } from 'node:url'
  35. interface Args {
  36. metadata: string
  37. init: boolean
  38. eslint: string
  39. eslintArgs: string
  40. decreaseBaselines: boolean
  41. rules: string[]
  42. }
  43. interface ESLintMessage {
  44. ruleId?: string | null
  45. }
  46. interface ESLintResult {
  47. filePath?: string
  48. messages?: ESLintMessage[]
  49. }
  50. interface ESLintExecutionResult {
  51. results: ESLintResult[]
  52. stderr: string
  53. }
  54. interface BaselineData {
  55. rules: Record<string, number>
  56. ruleFiles?: Record<string, Record<string, number>>
  57. }
  58. interface RuleSnapshot {
  59. total: number
  60. files: Record<string, number>
  61. }
  62. function parseArgs(argv: string[]): Args {
  63. const args: Args = {
  64. metadata: '.github/eslint-rule-baselines.json',
  65. init: false,
  66. eslint: 'npx eslint',
  67. eslintArgs: '',
  68. decreaseBaselines: false,
  69. rules: [],
  70. }
  71. for (let i = 2; i < argv.length; i += 1) {
  72. const a = argv[i]
  73. if (a === '--init') {
  74. args.init = true
  75. } else if (a === '--metadata') {
  76. args.metadata = argv[++i]
  77. } else if (a === '--eslint') {
  78. args.eslint = argv[++i]
  79. } else if (a === '--eslint-args') {
  80. args.eslintArgs = argv[++i]
  81. } else if (a === '--rule') {
  82. const val = (argv[++i] ?? '').trim()
  83. if (val) {
  84. args.rules.push(
  85. ...val
  86. .split(',')
  87. .map((s) => s.trim())
  88. .filter(Boolean)
  89. )
  90. }
  91. } else if (a === '--decrease-baselines') {
  92. args.decreaseBaselines = true
  93. } else {
  94. console.warn(`Unknown argument: ${a}`)
  95. }
  96. }
  97. if (args.rules.length === 0) {
  98. console.error('Error: You must provide at least one --rule <rule-id>.')
  99. console.error('Example: --rule exhaustive-deps --rule no-console')
  100. process.exit(2)
  101. }
  102. const dedupedRules = new Set(args.rules)
  103. args.rules = Array.from(dedupedRules)
  104. return args
  105. }
  106. /**
  107. * SECURITY:
  108. * Directly spawns a command from its arguments. Should not be called with
  109. * untrusted input.
  110. */
  111. function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExecutionResult {
  112. const fullCmd = `${eslintCmd} ${eslintArgs || ''} --format json`.trim()
  113. const proc = spawnSync(fullCmd, {
  114. shell: true,
  115. encoding: 'utf8',
  116. stdio: ['ignore', 'pipe', 'pipe'],
  117. env: process.env,
  118. maxBuffer: 32 * 1024 * 1024, // allow large ESLint JSON payloads
  119. })
  120. const stdout = typeof proc.stdout === 'string' ? proc.stdout : ''
  121. const stderr = typeof proc.stderr === 'string' ? proc.stderr : ''
  122. if (!stdout.trim()) {
  123. console.error('ESLint did not produce JSON output. stderr:\n', stderr)
  124. process.exit(2)
  125. }
  126. let results: ESLintResult[]
  127. try {
  128. results = JSON.parse(stdout) as ESLintResult[]
  129. } catch (e) {
  130. console.error('Failed to parse ESLint JSON output:', e)
  131. console.error('Raw output (truncated to 4k):\n', stdout.slice(0, 4096))
  132. process.exit(2)
  133. }
  134. return { results, stderr }
  135. }
  136. function normalizeFilePath(filePath?: string | null): string | null {
  137. if (!filePath) return null
  138. const rel = path.relative(process.cwd(), filePath)
  139. const normalized = rel || path.basename(filePath)
  140. return normalized.split(path.sep).join('/')
  141. }
  142. function collectRuleSnapshots(
  143. results: ESLintResult[],
  144. ruleIds: string[]
  145. ): Record<string, RuleSnapshot> {
  146. const checkedIds = new Set(ruleIds)
  147. const snapshots: Record<string, RuleSnapshot> = {}
  148. for (const id of ruleIds) {
  149. snapshots[id] = { total: 0, files: {} }
  150. }
  151. for (const file of results) {
  152. if (!file || !Array.isArray(file.messages)) continue
  153. const normalizedPath = normalizeFilePath(file.filePath)
  154. for (const msg of file.messages) {
  155. const id = msg?.ruleId ?? ''
  156. if (id && checkedIds.has(id)) {
  157. const snapshot = snapshots[id] ?? { total: 0, files: {} }
  158. snapshot.total += 1
  159. if (normalizedPath) {
  160. snapshot.files[normalizedPath] = (snapshot.files[normalizedPath] ?? 0) + 1
  161. }
  162. snapshots[id] = snapshot
  163. }
  164. }
  165. }
  166. return snapshots
  167. }
  168. function readBaselines(fp: string): BaselineData {
  169. if (!existsSync(fp)) return { rules: {}, ruleFiles: {} }
  170. try {
  171. const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial<BaselineData>
  172. if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') {
  173. return { rules: data.rules, ruleFiles: data.ruleFiles ?? {} }
  174. }
  175. } catch {
  176. // ignore invalid metadata files and fall back to blank baselines
  177. }
  178. return { rules: {}, ruleFiles: {} }
  179. }
  180. function writeBaselines(fp: string, updates: Record<string, RuleSnapshot>, merge = true): void {
  181. const dir = path.dirname(fp)
  182. mkdirSync(dir, { recursive: true })
  183. let current: BaselineData = { rules: {}, ruleFiles: {} }
  184. if (merge && existsSync(fp)) {
  185. current = readBaselines(fp)
  186. }
  187. const nextRules = merge ? { ...current.rules } : {}
  188. const nextRuleFiles = merge ? { ...(current.ruleFiles ?? {}) } : {}
  189. for (const [rule, snapshot] of Object.entries(updates)) {
  190. nextRules[rule] = snapshot.total
  191. nextRuleFiles[rule] = snapshot.files
  192. }
  193. const next: BaselineData = { rules: nextRules, ruleFiles: nextRuleFiles }
  194. writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
  195. }
  196. function writeSummary(markdown: string): void {
  197. const summaryFile = process.env.GITHUB_STEP_SUMMARY
  198. if (summaryFile) {
  199. try {
  200. appendFileSync(summaryFile, `${markdown}\n`, 'utf8')
  201. } catch {
  202. // ignore summary write errors because they shouldn't block the script
  203. }
  204. }
  205. }
  206. export function runRatchet(argv: string[], runEslint = dangerouslyRunEsLint): number {
  207. const args = parseArgs(argv)
  208. // SECURITY:
  209. // Offloaded to user. Must document that they should not pass untrusted input
  210. // via --eslint or --eslint-args.
  211. const { results, stderr } = runEslint(args.eslint, args.eslintArgs)
  212. // Filter out test files.
  213. const filteredResults = results.filter((result) => !result.filePath?.includes('.test.'))
  214. const currentSnapshots = collectRuleSnapshots(filteredResults, args.rules)
  215. const currentCounts: Record<string, number> = {}
  216. for (const rule of args.rules) {
  217. currentCounts[rule] = currentSnapshots[rule]?.total ?? 0
  218. }
  219. if (args.init) {
  220. writeBaselines(args.metadata, currentSnapshots, true)
  221. const rows = Object.entries(currentCounts)
  222. .map(([rule, count]) => `| \`${rule}\` | **${count}** |`)
  223. .join('\n')
  224. writeSummary(
  225. [
  226. `### ESLint rule baselines initialized`,
  227. `Metadata: \`${args.metadata}\``,
  228. ``,
  229. `| Rule | Baseline |`,
  230. `| --- | ---: |`,
  231. rows,
  232. ``,
  233. ].join('\n')
  234. )
  235. console.log(
  236. `Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).`
  237. )
  238. return 0
  239. }
  240. const baselineData = readBaselines(args.metadata)
  241. const baselineRules = baselineData.rules || {}
  242. const baselineRuleFiles = baselineData.ruleFiles || {}
  243. const missing = args.rules.filter((r) => typeof baselineRules[r] !== 'number')
  244. if (missing.length) {
  245. const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.`
  246. console.error(msg)
  247. writeSummary(`### ESLint rule ratchet\n${msg}`)
  248. console.log(`::error title=Missing baselines::${msg}`)
  249. return 2
  250. }
  251. let failed = false
  252. const tableRows: string[] = []
  253. const improvedRules: string[] = []
  254. const decreasedBaselines: Record<string, { from: number; to: number; snapshot: RuleSnapshot }> =
  255. {}
  256. for (const rule of args.rules) {
  257. const baseline = baselineRules[rule] ?? 0
  258. const current = currentCounts[rule] ?? 0
  259. const delta = current - baseline
  260. const currentSnapshot = currentSnapshots[rule] ?? { total: 0, files: {} }
  261. const baselineFiles = baselineRuleFiles[rule] ?? {}
  262. tableRows.push(
  263. `| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |`
  264. )
  265. if (current > baseline) {
  266. failed = true
  267. const delta = current - baseline
  268. const baselineHasFiles = Object.hasOwn(baselineRuleFiles, rule)
  269. const fileSummary = describeFileRegression(
  270. baselineFiles,
  271. currentSnapshot.files,
  272. baselineHasFiles
  273. )
  274. const msgParts = [
  275. `You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`,
  276. ]
  277. if (fileSummary) {
  278. msgParts.push(
  279. `Affected files: ${fileSummary}${baselineHasFiles ? '' : ' (baseline missing file breakdown; rerun with --init to capture it)'}`
  280. )
  281. }
  282. const msg = msgParts.join(' ')
  283. console.error(msg)
  284. console.log(`::error title=New violations::${msg}`)
  285. } else if (current < baseline) {
  286. improvedRules.push(rule)
  287. if (args.decreaseBaselines) {
  288. decreasedBaselines[rule] = { from: baseline, to: current, snapshot: currentSnapshot }
  289. }
  290. }
  291. }
  292. const summaryLines = [
  293. `### ESLint rule ratchet`,
  294. `Metadata: \`${args.metadata}\``,
  295. ``,
  296. `| Rule | Baseline | Current | Δ |`,
  297. `| --- | ---: | ---: | ---: |`,
  298. ...tableRows,
  299. ``,
  300. ]
  301. if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) {
  302. const updates: Record<string, RuleSnapshot> = {}
  303. const details: string[] = []
  304. const logParts: string[] = []
  305. for (const [rule, { from, to, snapshot }] of Object.entries(decreasedBaselines)) {
  306. updates[rule] = snapshot
  307. details.push(`- \`${rule}\`: ${from} -> ${to}`)
  308. logParts.push(`${rule}: ${from} -> ${to}`)
  309. }
  310. writeBaselines(args.metadata, updates, true)
  311. summaryLines.push('', 'Baselines decreased for improved rules:', ...details, '')
  312. console.log(`Baselines decreased for improved rules: ${logParts.join(', ')}`)
  313. }
  314. writeSummary(summaryLines.join('\n'))
  315. if (failed) {
  316. if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr)
  317. return 1
  318. } else {
  319. console.log(
  320. improvedRules.length > 0
  321. ? 'Nice! Some rules improved.'
  322. : 'Stable: No regressions for selected rules.'
  323. )
  324. return 0
  325. }
  326. }
  327. function main(): void {
  328. const exitCode = runRatchet(process.argv, dangerouslyRunEsLint)
  329. process.exit(exitCode)
  330. }
  331. if (process.argv[1]) {
  332. const invokedPath = pathToFileURL(path.resolve(process.argv[1])).href
  333. if (import.meta.url === invokedPath) {
  334. main()
  335. }
  336. }
  337. function describeFileRegression(
  338. baselineFiles: Record<string, number>,
  339. currentFiles: Record<string, number>,
  340. baselineHasFiles: boolean
  341. ): string {
  342. const MAX_FILES = 5
  343. if (baselineHasFiles) {
  344. const entries = Object.entries(currentFiles)
  345. .map(([file, count]) => ({
  346. file,
  347. delta: count - (baselineFiles[file] ?? 0),
  348. }))
  349. .filter(({ delta }) => delta > 0)
  350. .sort((a, b) => b.delta - a.delta || a.file.localeCompare(b.file))
  351. if (!entries.length) return ''
  352. return formatFileList(
  353. entries.map(({ file, delta }) => `${file} (+${delta})`),
  354. MAX_FILES
  355. )
  356. }
  357. const currentEntries = Object.entries(currentFiles)
  358. .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
  359. .map(([file, count]) => `${file} (${count} current)`)
  360. if (!currentEntries.length) return ''
  361. return formatFileList(currentEntries, MAX_FILES)
  362. }
  363. function formatFileList(entries: string[], maxFiles: number): string {
  364. if (entries.length <= maxFiles) {
  365. return entries.join(', ')
  366. }
  367. const remainder = entries.length - maxFiles
  368. const plural = remainder === 1 ? 'file' : 'files'
  369. return `${entries.slice(0, maxFiles).join(', ')}, +${remainder} more ${plural}`
  370. }