mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(chore): add a lint ratchet for shadcn rules (#51014)
## Problem shadcn lint rules has been soft landed in #50676 and are now on as warnings in every app, but nothing stops a PR from adding new violations linear: FE-4473 ## Solution - moved the ratchet script and its tests from `apps/studio/scripts` to `packages/eslint-config-supabase` so every app runs one copy - added a shared rule list, `packages/eslint-config-supabase/ratchet-rules.json` with the shadcn rules - www, docs, design-system, ui-library and learn get `lint-ratchet.yml` with one job per changed app (triggered by the app, `packages/**` or the lockfile) + a weekly `lint-ratchet-decrease.yml` (as for studio ratchet) - package tests run in `eslint-config-supabase-tests.yml` <!-- ## Preview links If relevant, include links to changed pages for easy review access. Copy the preview base URL from the Vercel bot comment on this PR. Use the following table as an example template. | Site | Live | Preview | Search for | | -------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------- | | WWW | [/blog/your-post](https://supabase.com/blog/your-post) | [/blog/your-post](https://zone-www-dot-com-git-branch-name-supabase.vercel.app/blog/your-post) | unique phrase from the change | | Docs | [/docs/guides/your-page](https://supabase.com/docs/guides/your-page) | [/docs/guides/your-page](https://docs-git-branch-name-supabase.vercel.app/docs/guides/your-page) | unique phrase from the change | | Studio | [/dashboard](https://supabase.com/dashboard) | [/dashboard](https://studio-git-branch-name-supabase.vercel.app/dashboard) | unique phrase from the change | | Design system | [/design-system](https://supabase.com/design-system) | [/design-system](https://design-system-git-branch-name-supabase.vercel.app/design-system) | unique phrase from the change | | UI library | [/library](https://supabase.com/library) | [/library](https://ui-library-git-branch-name-supabase.vercel.app/library) | unique phrase from the change | | Knowledge base | [/kb/guides/your-page](https://supabase.com/kb/guides/your-page) | [/kb/guides/your-page](https://kb-git-branch-name-supabase.vercel.app/kb/guides/your-page) | unique phrase from the change | --> <!-- ## Additional context Optionally add any other context or screenshots. --> ## Review instructions 1. run `pnpm --filter ./apps/www run lint:ratchet` 2. add `p-[13px]` to a `className` in any www component and run it again. it fails with `shadcn/no-arbitrary-values` and the file name with `(+1)` 3. revert change 4. run `pnpm --filter eslint-config-supabase test` and see 6 tests pass 5. in ci, check `Ratchet studio lint checks` and the `ratchet (<app>)` jobs for the apps this pr touches ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs [style guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Developer Improvements** * Expanded automated lint checks to cover additional apps and shared package changes. * Added checks for arbitrary Tailwind values, unknown classes, and raw colors across supported apps. * Added automated baseline updates that can open or update a pull request when lint counts change. * Added tests for the lint configuration and support for combining multiple rule files. * Updated Studio lint notifications to exclude Shadcn rules with zero-baseline counts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
73260dfaba
commit
b13d6c2878
23 files changed
+1021
-20
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { runRatchet } from '../ratchet-eslint-rules'
|
||||
|
||||
const packageRoot = path.resolve(__dirname, '..')
|
||||
const repoRoot = path.resolve(packageRoot, '..', '..')
|
||||
const scriptArgvPlaceholder = path.resolve(packageRoot, 'ratchet-eslint-rules.ts')
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
while (tempDirs.length) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ratchet-eslint-rules integration', () => {
|
||||
it('captures per-file counts when initializing baselines', () => {
|
||||
const tmp = createTempDir()
|
||||
const metadataPath = path.join(tmp, 'baseline.json')
|
||||
|
||||
const eslintResults = buildEslintResults([
|
||||
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1 } },
|
||||
{ filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 2 } },
|
||||
])
|
||||
|
||||
const result = invokeRatchet(
|
||||
['--metadata', metadataPath, '--rule', 'no-console', '--init'],
|
||||
eslintResults
|
||||
)
|
||||
|
||||
expect(result).toBe(0)
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
|
||||
expect(metadata.rules['no-console']).toBe(3)
|
||||
expect(metadata.ruleFiles['no-console']).toEqual({
|
||||
[relativeToCwd('apps/studio/src/a.ts')]: 1,
|
||||
[relativeToCwd('apps/studio/src/b.ts')]: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports offending files when regressions occur and metadata has per-file data', () => {
|
||||
const tmp = createTempDir()
|
||||
const metadataPath = path.join(tmp, 'baseline.json')
|
||||
|
||||
writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
rules: { 'no-console': 2 },
|
||||
ruleFiles: {
|
||||
'no-console': {
|
||||
[relativeToCwd('apps/studio/src/a.ts')]: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const eslintResults = buildEslintResults([
|
||||
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 3 } },
|
||||
{ filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 1 } },
|
||||
])
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = invokeRatchet(
|
||||
['--metadata', metadataPath, '--rule', 'no-console'],
|
||||
eslintResults
|
||||
)
|
||||
|
||||
expect(result).toBe(1)
|
||||
const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
|
||||
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (+1)`)
|
||||
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/b.ts')} (+1)`)
|
||||
})
|
||||
|
||||
it('combines rule ids from repeated --rules-file flags', () => {
|
||||
const tmp = createTempDir()
|
||||
const metadataPath = path.join(tmp, 'baseline.json')
|
||||
const appRulesPath = path.join(tmp, 'app-rules.json')
|
||||
const sharedRulesPath = path.join(tmp, 'shared-rules.json')
|
||||
writeFileSync(appRulesPath, JSON.stringify(['no-console']))
|
||||
writeFileSync(sharedRulesPath, JSON.stringify(['no-debugger']))
|
||||
|
||||
const eslintResults = buildEslintResults([
|
||||
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1, 'no-debugger': 2 } },
|
||||
])
|
||||
|
||||
const result = invokeRatchet(
|
||||
[
|
||||
'--metadata',
|
||||
metadataPath,
|
||||
'--rules-file',
|
||||
appRulesPath,
|
||||
'--rules-file',
|
||||
sharedRulesPath,
|
||||
'--init',
|
||||
],
|
||||
eslintResults
|
||||
)
|
||||
|
||||
expect(result).toBe(0)
|
||||
const baseline = JSON.parse(readFileSync(metadataPath, 'utf8'))
|
||||
expect(baseline.rules).toEqual({ 'no-console': 1, 'no-debugger': 2 })
|
||||
})
|
||||
|
||||
it('reads rule ids from --rules-file', () => {
|
||||
const tmp = createTempDir()
|
||||
const metadataPath = path.join(tmp, 'baseline.json')
|
||||
const rulesFilePath = path.join(tmp, 'rules.json')
|
||||
writeFileSync(rulesFilePath, JSON.stringify(['no-console', 'no-debugger']))
|
||||
|
||||
const eslintResults = buildEslintResults([
|
||||
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1, 'no-debugger': 2 } },
|
||||
])
|
||||
|
||||
const result = invokeRatchet(
|
||||
['--metadata', metadataPath, '--rules-file', rulesFilePath, '--init'],
|
||||
eslintResults
|
||||
)
|
||||
|
||||
expect(result).toBe(0)
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
|
||||
expect(metadata.rules['no-console']).toBe(1)
|
||||
expect(metadata.rules['no-debugger']).toBe(2)
|
||||
})
|
||||
|
||||
it('falls back gracefully when baseline is missing per-file data', () => {
|
||||
const tmp = createTempDir()
|
||||
const metadataPath = path.join(tmp, 'baseline.json')
|
||||
|
||||
writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
rules: { 'no-console': 1 },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const eslintResults = buildEslintResults([
|
||||
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 2 } },
|
||||
])
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = invokeRatchet(
|
||||
['--metadata', metadataPath, '--rule', 'no-console'],
|
||||
eslintResults
|
||||
)
|
||||
|
||||
expect(result).toBe(1)
|
||||
const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
|
||||
expect(combinedErrors).toContain('baseline missing file breakdown')
|
||||
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (2 current)`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ratchet-eslint-rules CLI', () => {
|
||||
it('runs when invoked through a symlink', () => {
|
||||
const tmp = createTempDir()
|
||||
const link = path.join(tmp, 'ratchet-eslint-rules.ts')
|
||||
symlinkSync(scriptArgvPlaceholder, link)
|
||||
|
||||
const tsxCli = createRequire(import.meta.url).resolve('tsx/cli')
|
||||
const proc = spawnSync(process.execPath, [tsxCli, link], { encoding: 'utf8' })
|
||||
|
||||
expect(proc.status).toBe(2)
|
||||
expect(proc.stderr).toContain('You must provide at least one --rule')
|
||||
})
|
||||
})
|
||||
|
||||
function buildEslintResults(
|
||||
files: Array<{ filePath: string; rules: Record<string, number> }>
|
||||
): unknown[] {
|
||||
return files.map(({ filePath, rules }) => ({
|
||||
filePath,
|
||||
messages: Object.entries(rules).flatMap(([ruleId, count]) =>
|
||||
Array.from({ length: count }, () => ({ ruleId }))
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'ratchet-eslint'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function repoPath(relPath: string): string {
|
||||
return path.join(repoRoot, relPath)
|
||||
}
|
||||
|
||||
function invokeRatchet(args: string[], eslintResults: unknown[]): number {
|
||||
const argv = ['node', scriptArgvPlaceholder, ...args]
|
||||
return runRatchet(argv, () => ({
|
||||
results: eslintResults as any,
|
||||
stderr: '',
|
||||
}))
|
||||
}
|
||||
|
||||
function relativeToCwd(relPath: string): string {
|
||||
return path.relative(process.cwd(), repoPath(relPath)).split(path.sep).join('/')
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"clean": "rimraf .turbo tsconfig.tsbuildinfo"
|
||||
"clean": "rimraf .turbo tsconfig.tsbuildinfo",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shadcn/lint": "0.1.5",
|
||||
@@ -15,6 +16,8 @@
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-config-turbo": "^2.5.0",
|
||||
"typescript": "catalog:"
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
/**
|
||||
* Ratchet ESLint violations for selected rules.
|
||||
*
|
||||
* Examples:
|
||||
* # Initialize baselines for two rules
|
||||
* tsx node_modules/eslint-config-supabase/ratchet-eslint-rules.ts --init \
|
||||
* --rule react-hooks/exhaustive-deps --rule no-console
|
||||
*
|
||||
* # Compare current counts vs baselines
|
||||
* tsx node_modules/eslint-config-supabase/ratchet-eslint-rules.ts \
|
||||
* --rule react-hooks/exhaustive-deps --rule no-console
|
||||
*
|
||||
# Decrease baselines when improvements occur
|
||||
* tsx node_modules/eslint-config-supabase/ratchet-eslint-rules.ts \
|
||||
* --rule react-hooks/exhaustive-deps --rule no-console \
|
||||
* --decrease-baselines
|
||||
*
|
||||
* Flags:
|
||||
* --metadata <path> Path to baseline file (default .github/eslint-rule-baselines.json)
|
||||
* --init Write current counts for the provided --rule(s) into metadata and exit 0
|
||||
* --eslint "<cmd>" ESLint command to run (default "npx eslint"). Do not pass untrusted input.
|
||||
* --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input.
|
||||
* --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate.
|
||||
* --rules-file <path> Path to a JSON file containing an array of rule id strings.
|
||||
* Combines with --rule if both are given. One of the two is REQUIRED.
|
||||
* --decrease-baselines When improvements occur, lower stored baselines to match the new counts.
|
||||
*
|
||||
* Notes:
|
||||
* - Counts occurrences regardless of severity (warn/error).
|
||||
* - Fails if any selected rule has currentCount > baselineCount.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
interface Args {
|
||||
metadata: string
|
||||
init: boolean
|
||||
eslint: string
|
||||
eslintArgs: string
|
||||
decreaseBaselines: boolean
|
||||
rules: string[]
|
||||
rulesFiles: string[]
|
||||
}
|
||||
|
||||
interface ESLintMessage {
|
||||
ruleId?: string | null
|
||||
}
|
||||
|
||||
interface ESLintResult {
|
||||
filePath?: string
|
||||
messages?: ESLintMessage[]
|
||||
}
|
||||
|
||||
interface ESLintExecutionResult {
|
||||
results: ESLintResult[]
|
||||
stderr: string
|
||||
}
|
||||
|
||||
interface BaselineData {
|
||||
rules: Record<string, number>
|
||||
ruleFiles?: Record<string, Record<string, number>>
|
||||
}
|
||||
|
||||
interface RuleSnapshot {
|
||||
total: number
|
||||
files: Record<string, number>
|
||||
}
|
||||
|
||||
function readRulesFile(filePath: string): string[] {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8')
|
||||
} catch (e) {
|
||||
console.error(`Error: Could not read --rules-file ${filePath}: ${e}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.error(`Error: Could not parse --rules-file ${filePath} as JSON: ${e}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || !parsed.every((r) => typeof r === 'string')) {
|
||||
console.error(`Error: --rules-file ${filePath} must contain a JSON array of rule id strings.`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
metadata: '.github/eslint-rule-baselines.json',
|
||||
init: false,
|
||||
eslint: 'npx eslint',
|
||||
eslintArgs: '',
|
||||
decreaseBaselines: false,
|
||||
rules: [],
|
||||
rulesFiles: [],
|
||||
}
|
||||
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const a = argv[i]
|
||||
if (a === '--init') {
|
||||
args.init = true
|
||||
} else if (a === '--metadata') {
|
||||
args.metadata = argv[++i]
|
||||
} else if (a === '--eslint') {
|
||||
args.eslint = argv[++i]
|
||||
} else if (a === '--eslint-args') {
|
||||
args.eslintArgs = argv[++i]
|
||||
} else if (a === '--rule') {
|
||||
const val = (argv[++i] ?? '').trim()
|
||||
if (val) {
|
||||
args.rules.push(
|
||||
...val
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
} else if (a === '--rules-file') {
|
||||
args.rulesFiles.push(argv[++i])
|
||||
} else if (a === '--decrease-baselines') {
|
||||
args.decreaseBaselines = true
|
||||
} else {
|
||||
console.warn(`Unknown argument: ${a}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const rulesFile of args.rulesFiles) {
|
||||
args.rules.push(...readRulesFile(rulesFile))
|
||||
}
|
||||
|
||||
if (args.rules.length === 0) {
|
||||
console.error('Error: You must provide at least one --rule <rule-id> or a --rules-file.')
|
||||
console.error('Example: --rule exhaustive-deps --rule no-console')
|
||||
console.error('Example: --rules-file node_modules/eslint-config-supabase/ratchet-rules.json')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const dedupedRules = new Set(args.rules)
|
||||
args.rules = Array.from(dedupedRules)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* SECURITY:
|
||||
* Directly spawns a command from its arguments. Should not be called with
|
||||
* untrusted input.
|
||||
*/
|
||||
function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExecutionResult {
|
||||
const fullCmd = `${eslintCmd} ${eslintArgs || ''} --format json`.trim()
|
||||
const proc = spawnSync(fullCmd, {
|
||||
shell: true,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
maxBuffer: 128 * 1024 * 1024, // allow large ESLint JSON payloads (the studio repo regularly emits ~35MB+)
|
||||
})
|
||||
|
||||
const stdout = typeof proc.stdout === 'string' ? proc.stdout : ''
|
||||
const stderr = typeof proc.stderr === 'string' ? proc.stderr : ''
|
||||
|
||||
if (!stdout.trim()) {
|
||||
console.error('ESLint did not produce JSON output. stderr:\n', stderr)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let results: ESLintResult[]
|
||||
try {
|
||||
results = JSON.parse(stdout) as ESLintResult[]
|
||||
} catch (e) {
|
||||
console.error('Failed to parse ESLint JSON output:', e)
|
||||
console.error('Raw output (truncated to 4k):\n', stdout.slice(0, 4096))
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
return { results, stderr }
|
||||
}
|
||||
|
||||
function normalizeFilePath(filePath?: string | null): string | null {
|
||||
if (!filePath) return null
|
||||
const rel = path.relative(process.cwd(), filePath)
|
||||
const normalized = rel || path.basename(filePath)
|
||||
return normalized.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function collectRuleSnapshots(
|
||||
results: ESLintResult[],
|
||||
ruleIds: string[]
|
||||
): Record<string, RuleSnapshot> {
|
||||
const checkedIds = new Set(ruleIds)
|
||||
const snapshots: Record<string, RuleSnapshot> = {}
|
||||
|
||||
for (const id of ruleIds) {
|
||||
snapshots[id] = { total: 0, files: {} }
|
||||
}
|
||||
|
||||
for (const file of results) {
|
||||
if (!file || !Array.isArray(file.messages)) continue
|
||||
const normalizedPath = normalizeFilePath(file.filePath)
|
||||
for (const msg of file.messages) {
|
||||
const id = msg?.ruleId ?? ''
|
||||
if (id && checkedIds.has(id)) {
|
||||
const snapshot = snapshots[id] ?? { total: 0, files: {} }
|
||||
snapshot.total += 1
|
||||
if (normalizedPath) {
|
||||
snapshot.files[normalizedPath] = (snapshot.files[normalizedPath] ?? 0) + 1
|
||||
}
|
||||
snapshots[id] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function readBaselines(fp: string): BaselineData {
|
||||
if (!existsSync(fp)) return { rules: {}, ruleFiles: {} }
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial<BaselineData>
|
||||
if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') {
|
||||
return { rules: data.rules, ruleFiles: data.ruleFiles ?? {} }
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid metadata files and fall back to blank baselines
|
||||
}
|
||||
return { rules: {}, ruleFiles: {} }
|
||||
}
|
||||
|
||||
function writeBaselines(fp: string, updates: Record<string, RuleSnapshot>, merge = true): void {
|
||||
const dir = path.dirname(fp)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
let current: BaselineData = { rules: {}, ruleFiles: {} }
|
||||
if (merge && existsSync(fp)) {
|
||||
current = readBaselines(fp)
|
||||
}
|
||||
|
||||
const nextRules = merge ? { ...current.rules } : {}
|
||||
const nextRuleFiles = merge ? { ...(current.ruleFiles ?? {}) } : {}
|
||||
|
||||
for (const [rule, snapshot] of Object.entries(updates)) {
|
||||
nextRules[rule] = snapshot.total
|
||||
nextRuleFiles[rule] = snapshot.files
|
||||
}
|
||||
|
||||
const next: BaselineData = { rules: nextRules, ruleFiles: nextRuleFiles }
|
||||
writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function writeSummary(markdown: string): void {
|
||||
const summaryFile = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryFile) {
|
||||
try {
|
||||
appendFileSync(summaryFile, `${markdown}\n`, 'utf8')
|
||||
} catch {
|
||||
// ignore summary write errors because they shouldn't block the script
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runRatchet(argv: string[], runEslint = dangerouslyRunEsLint): number {
|
||||
const args = parseArgs(argv)
|
||||
|
||||
// SECURITY:
|
||||
// Offloaded to user. Must document that they should not pass untrusted input
|
||||
// via --eslint or --eslint-args.
|
||||
const { results, stderr } = runEslint(args.eslint, args.eslintArgs)
|
||||
|
||||
// Filter out test files.
|
||||
const filteredResults = results.filter((result) => !result.filePath?.includes('.test.'))
|
||||
|
||||
const currentSnapshots = collectRuleSnapshots(filteredResults, args.rules)
|
||||
const currentCounts: Record<string, number> = {}
|
||||
for (const rule of args.rules) {
|
||||
currentCounts[rule] = currentSnapshots[rule]?.total ?? 0
|
||||
}
|
||||
|
||||
if (args.init) {
|
||||
writeBaselines(args.metadata, currentSnapshots, true)
|
||||
|
||||
const rows = Object.entries(currentCounts)
|
||||
.map(([rule, count]) => `| \`${rule}\` | **${count}** |`)
|
||||
.join('\n')
|
||||
|
||||
writeSummary(
|
||||
[
|
||||
`### ESLint rule baselines initialized`,
|
||||
`Metadata: \`${args.metadata}\``,
|
||||
``,
|
||||
`| Rule | Baseline |`,
|
||||
`| --- | ---: |`,
|
||||
rows,
|
||||
``,
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
console.log(
|
||||
`Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
const baselineData = readBaselines(args.metadata)
|
||||
const baselineRules = baselineData.rules || {}
|
||||
const baselineRuleFiles = baselineData.ruleFiles || {}
|
||||
|
||||
const missing = args.rules.filter((r) => typeof baselineRules[r] !== 'number')
|
||||
if (missing.length) {
|
||||
const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.`
|
||||
console.error(msg)
|
||||
writeSummary(`### ESLint rule ratchet\n${msg}`)
|
||||
console.log(`::error title=Missing baselines::${msg}`)
|
||||
return 2
|
||||
}
|
||||
|
||||
let failed = false
|
||||
const tableRows: string[] = []
|
||||
const improvedRules: string[] = []
|
||||
const decreasedBaselines: Record<string, { from: number; to: number; snapshot: RuleSnapshot }> =
|
||||
{}
|
||||
for (const rule of args.rules) {
|
||||
const baseline = baselineRules[rule] ?? 0
|
||||
const current = currentCounts[rule] ?? 0
|
||||
const delta = current - baseline
|
||||
const currentSnapshot = currentSnapshots[rule] ?? { total: 0, files: {} }
|
||||
const baselineFiles = baselineRuleFiles[rule] ?? {}
|
||||
|
||||
tableRows.push(
|
||||
`| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |`
|
||||
)
|
||||
|
||||
if (current > baseline) {
|
||||
failed = true
|
||||
const delta = current - baseline
|
||||
const baselineHasFiles = Object.hasOwn(baselineRuleFiles, rule)
|
||||
const fileSummary = describeFileRegression(
|
||||
baselineFiles,
|
||||
currentSnapshot.files,
|
||||
baselineHasFiles
|
||||
)
|
||||
const msgParts = [
|
||||
`You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`,
|
||||
]
|
||||
if (fileSummary) {
|
||||
msgParts.push(
|
||||
`Affected files: ${fileSummary}${baselineHasFiles ? '' : ' (baseline missing file breakdown; rerun with --init to capture it)'}`
|
||||
)
|
||||
}
|
||||
const msg = msgParts.join(' ')
|
||||
console.error(msg)
|
||||
console.log(`::error title=New violations::${msg}`)
|
||||
} else if (current < baseline) {
|
||||
improvedRules.push(rule)
|
||||
if (args.decreaseBaselines) {
|
||||
decreasedBaselines[rule] = { from: baseline, to: current, snapshot: currentSnapshot }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summaryLines = [
|
||||
`### ESLint rule ratchet`,
|
||||
`Metadata: \`${args.metadata}\``,
|
||||
``,
|
||||
`| Rule | Baseline | Current | Δ |`,
|
||||
`| --- | ---: | ---: | ---: |`,
|
||||
...tableRows,
|
||||
``,
|
||||
]
|
||||
|
||||
if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) {
|
||||
const updates: Record<string, RuleSnapshot> = {}
|
||||
const details: string[] = []
|
||||
const logParts: string[] = []
|
||||
for (const [rule, { from, to, snapshot }] of Object.entries(decreasedBaselines)) {
|
||||
updates[rule] = snapshot
|
||||
details.push(`- \`${rule}\`: ${from} -> ${to}`)
|
||||
logParts.push(`${rule}: ${from} -> ${to}`)
|
||||
}
|
||||
writeBaselines(args.metadata, updates, true)
|
||||
summaryLines.push('', 'Baselines decreased for improved rules:', ...details, '')
|
||||
console.log(`Baselines decreased for improved rules: ${logParts.join(', ')}`)
|
||||
}
|
||||
|
||||
writeSummary(summaryLines.join('\n'))
|
||||
|
||||
if (failed) {
|
||||
if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr)
|
||||
return 1
|
||||
} else {
|
||||
console.log(
|
||||
improvedRules.length > 0
|
||||
? 'Nice! Some rules improved.'
|
||||
: 'Stable: No regressions for selected rules.'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const exitCode = runRatchet(process.argv, dangerouslyRunEsLint)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
if (process.argv[1]) {
|
||||
const invokedPath = pathToFileURL(realpathSync(path.resolve(process.argv[1]))).href
|
||||
if (import.meta.url === invokedPath) {
|
||||
main()
|
||||
}
|
||||
}
|
||||
|
||||
function describeFileRegression(
|
||||
baselineFiles: Record<string, number>,
|
||||
currentFiles: Record<string, number>,
|
||||
baselineHasFiles: boolean
|
||||
): string {
|
||||
const MAX_FILES = 5
|
||||
if (baselineHasFiles) {
|
||||
const entries = Object.entries(currentFiles)
|
||||
.map(([file, count]) => ({
|
||||
file,
|
||||
delta: count - (baselineFiles[file] ?? 0),
|
||||
}))
|
||||
.filter(({ delta }) => delta > 0)
|
||||
.sort((a, b) => b.delta - a.delta || a.file.localeCompare(b.file))
|
||||
|
||||
if (!entries.length) return ''
|
||||
|
||||
return formatFileList(
|
||||
entries.map(({ file, delta }) => `${file} (+${delta})`),
|
||||
MAX_FILES
|
||||
)
|
||||
}
|
||||
|
||||
const currentEntries = Object.entries(currentFiles)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([file, count]) => `${file} (${count} current)`)
|
||||
|
||||
if (!currentEntries.length) return ''
|
||||
|
||||
return formatFileList(currentEntries, MAX_FILES)
|
||||
}
|
||||
|
||||
function formatFileList(entries: string[], maxFiles: number): string {
|
||||
if (entries.length <= maxFiles) {
|
||||
return entries.join(', ')
|
||||
}
|
||||
const remainder = entries.length - maxFiles
|
||||
const plural = remainder === 1 ? 'file' : 'files'
|
||||
return `${entries.slice(0, maxFiles).join(', ')}, +${remainder} more ${plural}`
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
["shadcn/no-arbitrary-values", "shadcn/no-unknown-classes", "shadcn/no-raw-colors"]
|
||||
Reference in new issue
Block a user