diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ca59a039..9dfbadc1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: - name: Lint run: npm run lint + - name: Lint ratchet (warnings must not grow per-rule) + run: npm run lint:ratchet + - name: Unit tests run: npm run test -- --run diff --git a/.lint-baseline.json b/.lint-baseline.json new file mode 100644 index 000000000..8fb186175 --- /dev/null +++ b/.lint-baseline.json @@ -0,0 +1,13 @@ +{ + "(null)": 2, + "@typescript-eslint/no-explicit-any": 44, + "@typescript-eslint/no-unused-vars": 1, + "helm/no-arbitrary-bg-white": 1302, + "helm/no-arbitrary-radius": 38, + "helm/no-arbitrary-text-px": 165, + "helm/no-banned-color": 260, + "helm/no-raw-button": 104, + "helm/no-raw-input": 458, + "jsx-a11y/anchor-is-valid": 1, + "jsx-a11y/aria-role": 19 +} diff --git a/package.json b/package.json index dc8e3be17..1a5926848 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 6000", + "lint:ratchet": "node scripts/lint-ratchet.mjs", "typecheck": "tsc --noEmit", "db:types": "npx supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/lib/types/database.ts", "db:types:check": "npm run db:types && git diff --exit-code src/lib/types/database.ts || (echo '❌ Types are out of date. Run npm run db:types and commit changes.' && exit 1)", diff --git a/scripts/lint-ratchet.mjs b/scripts/lint-ratchet.mjs new file mode 100644 index 000000000..92d14f943 --- /dev/null +++ b/scripts/lint-ratchet.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * lint-ratchet.mjs + * + * Runs `npx eslint src --format json`, tallies warnings per rule-id, and + * compares them against .lint-baseline.json. + * + * Exit codes: + * 0 — no regression (all rule counts <= baseline) + * 1 — regression detected (at least one rule count > baseline) + * + * Flags: + * --update Rewrite .lint-baseline.json from the current run and exit 0. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const BASELINE_PATH = resolve(ROOT, '.lint-baseline.json'); + +const UPDATE = process.argv.includes('--update'); + +// --------------------------------------------------------------------------- +// 1. Run ESLint and collect per-rule warning counts +// --------------------------------------------------------------------------- +let eslintOutput; +try { + // execFileSync with an explicit argv array — no shell, no injection surface. + // stderr → 'inherit' so deprecation notices print directly and don't + // pollute the JSON stdout buffer we parse below. + eslintOutput = execFileSync( + 'npx', + ['eslint', 'src', '--format', 'json', '--max-warnings', '999999'], + // maxBuffer: 64 MB — the full-repo JSON output is ~10 MB today and will + // grow; 64 MB leaves ample headroom without meaningful memory cost. + { cwd: ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'] } + ); +} catch (err) { + // eslint exits non-zero when warnings/errors are present, but still writes + // valid JSON to stdout. Use that if it looks like JSON. + eslintOutput = (err.stdout || '').trim(); + if (!eslintOutput.startsWith('[')) { + console.error('ESLint failed and did not produce JSON output.'); + console.error(err.message); + process.exit(1); + } +} + +/** @type {Array<{messages: Array<{severity: number, ruleId: string|null}>}>} */ +let files; +try { + files = JSON.parse(eslintOutput); +} catch (parseErr) { + console.error('Could not parse ESLint JSON output:', parseErr.message); + process.exit(1); +} + +/** @type {Record} */ +const current = {}; +for (const file of files) { + for (const msg of file.messages) { + if (msg.severity === 1) { + // severity 1 = warning; severity 2 = error + const rule = msg.ruleId ?? '(null)'; + current[rule] = (current[rule] ?? 0) + 1; + } + } +} + +// Stable sorted copy for writing / printing +const sortedCurrent = Object.fromEntries( + Object.entries(current).sort(([a], [b]) => a.localeCompare(b)) +); + +const totalNow = Object.values(current).reduce((s, n) => s + n, 0); + +// --------------------------------------------------------------------------- +// 2. --update: overwrite baseline and exit +// --------------------------------------------------------------------------- +if (UPDATE) { + writeFileSync(BASELINE_PATH, JSON.stringify(sortedCurrent, null, 2) + '\n', 'utf-8'); + console.log( + `lint-ratchet: baseline updated — ${totalNow} warning${totalNow !== 1 ? 's' : ''} across ${Object.keys(sortedCurrent).length} rule${Object.keys(sortedCurrent).length !== 1 ? 's' : ''} locked in ${BASELINE_PATH}` + ); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// 3. Load baseline +// --------------------------------------------------------------------------- +/** @type {Record} */ +let baseline; +try { + baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')); +} catch { + console.error( + `lint-ratchet: baseline file not found at ${BASELINE_PATH}.\n` + + 'Run `npm run lint:ratchet -- --update` to create it.' + ); + process.exit(1); +} + +const totalBaseline = Object.values(baseline).reduce((s, n) => s + n, 0); + +// --------------------------------------------------------------------------- +// 4. Per-rule comparison +// --------------------------------------------------------------------------- +/** @type {Array<{rule: string, baseline: number, now: number, delta: number}>} */ +const regressions = []; + +// Check every rule that appears in current run +for (const [rule, nowCount] of Object.entries(current)) { + const baseCount = baseline[rule] ?? 0; + if (nowCount > baseCount) { + regressions.push({ rule, baseline: baseCount, now: nowCount, delta: nowCount - baseCount }); + } +} + +// Also check rules in baseline that have gone to 0 (not a regression, just informational) +// No action needed — fewer warnings are always fine. + +// --------------------------------------------------------------------------- +// 5. Report +// --------------------------------------------------------------------------- +if (regressions.length > 0) { + console.error('lint-ratchet: WARNING COUNT REGRESSION DETECTED\n'); + console.error( + 'The following rules have MORE warnings than the baseline.\n' + + 'Fix the new violations, or run `npm run lint:ratchet -- --update` only\n' + + 'after the net warning count has decreased.\n' + ); + + const maxRuleLen = Math.max(...regressions.map((r) => r.rule.length)); + console.error( + ` ${'Rule'.padEnd(maxRuleLen)} ${'Baseline'.padStart(8)} ${'Now'.padStart(8)} ${'Delta'.padStart(6)}` + ); + console.error(` ${'-'.repeat(maxRuleLen + 28)}`); + + for (const { rule, baseline: b, now, delta } of regressions.sort( + (a, b_) => b_.delta - a.delta + )) { + console.error( + ` ${rule.padEnd(maxRuleLen)} ${String(b).padStart(8)} ${String(now).padStart(8)} +${String(delta).padStart(5)}` + ); + } + + console.error(`\n Total: ${totalBaseline} → ${totalNow} (net ${totalNow >= totalBaseline ? '+' : ''}${totalNow - totalBaseline})`); + process.exit(1); +} + +if (totalNow < totalBaseline) { + console.log( + `lint-ratchet: warnings dropped (${totalBaseline} → ${totalNow}) — run \`npm run lint:ratchet -- --update\` to lock in the gains` + ); +} else { + // totalNow === totalBaseline (per-rule no regressions, same total) + console.log(`lint-ratchet: OK — ${totalNow} warning${totalNow !== 1 ? 's' : ''}, no regressions`); +} + +process.exit(0); diff --git a/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx b/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx index a86552c6d..9c6637ade 100644 --- a/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx +++ b/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx @@ -115,7 +115,7 @@ export default function CompleteSignupClient() {
{/* Role Selection */}
- +

I am a...