lint-baseline.mjs
1 #!/usr/bin/env node 2 3 import fs from 'node:fs' 4 import path from 'node:path' 5 import { ESLint } from 'eslint' 6 7 const BASELINE_FILE = path.resolve( 8 process.cwd(), 9 process.env.ESLINT_BASELINE_FILE || '.eslint-baseline.json', 10 ) 11 const args = process.argv.slice(2) 12 const MODE = args[0] === 'update' ? 'update' : 'check' 13 const ISSUE_SEVERITY = { 14 1: 'warning', 15 2: 'error', 16 } 17 const SOURCE_LOCATION_TAIL_REGEX = /\s(?:[A-Za-z]:)?[\\/][^ ]+:\d+:\d+.*$/u 18 const CWD_PATH = process.cwd().replaceAll('\\', '/') 19 const CWD_REGEX = new RegExp(CWD_PATH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') 20 21 function parseOptions(argv) { 22 const options = { 23 reportJson: '', 24 reportMd: '', 25 maxPreview: 50, 26 } 27 28 for (let index = 1; index < argv.length; index += 1) { 29 const arg = argv[index] 30 const next = argv[index + 1] 31 if (arg === '--report-json' && next) { 32 options.reportJson = next 33 index += 1 34 continue 35 } 36 if (arg === '--report-md' && next) { 37 options.reportMd = next 38 index += 1 39 continue 40 } 41 if (arg === '--max-preview' && next) { 42 const parsed = Number.parseInt(next, 10) 43 if (Number.isFinite(parsed) && parsed > 0) options.maxPreview = parsed 44 index += 1 45 } 46 } 47 48 return options 49 } 50 51 function normalizeMessage(message) { 52 return String(message || '') 53 .replace(/\s+/g, ' ') 54 .replace(SOURCE_LOCATION_TAIL_REGEX, '') 55 .replace(CWD_REGEX, '<cwd>') 56 .trim() 57 } 58 59 function toIssueKey(filePath, ruleId, severity, message) { 60 return JSON.stringify([filePath, ruleId, severity, message]) 61 } 62 63 function fromIssueKey(key) { 64 const [filePath, ruleId, severity, message] = JSON.parse(key) 65 return { filePath, ruleId, severity, message } 66 } 67 68 function compareIssueRows(a, b) { 69 if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath) 70 if (a.severity !== b.severity) return a.severity - b.severity 71 if (a.ruleId !== b.ruleId) return a.ruleId.localeCompare(b.ruleId) 72 return a.message.localeCompare(b.message) 73 } 74 75 function mapToRows(issueCounts) { 76 return Array.from(issueCounts.entries()) 77 .map(([key, count]) => { 78 const parsed = fromIssueKey(key) 79 return { 80 ...parsed, 81 count, 82 } 83 }) 84 .sort(compareIssueRows) 85 } 86 87 function rowsToMap(rows) { 88 const map = new Map() 89 for (const row of rows || []) { 90 const key = toIssueKey( 91 row.filePath, 92 row.ruleId, 93 Number(row.severity), 94 normalizeMessage(row.message), 95 ) 96 map.set(key, Number(row.count) || 0) 97 } 98 return map 99 } 100 101 function escapeMarkdownCell(value) { 102 return String(value).replace(/\|/g, '\\|') 103 } 104 105 function toMarkdownReport({ 106 baselineTotal, 107 currentTotal, 108 regressions, 109 maxPreview, 110 }) { 111 const sorted = [...regressions].sort(compareIssueRows) 112 const preview = sorted.slice(0, maxPreview) 113 const header = regressions.length > 0 114 ? `## Lint Baseline Regression\n\nNet-new lint fingerprints: **${regressions.length}**\n\nBaseline total issues: **${baselineTotal}**\nCurrent total issues: **${currentTotal}**\n` 115 : `## Lint Baseline\n\nNo net-new lint issues detected.\n\nBaseline total issues: **${baselineTotal}**\nCurrent total issues: **${currentTotal}**\n` 116 117 if (preview.length === 0) return `${header}\n` 118 119 const rows = [ 120 '| File | Severity | Rule | Delta | Message |', 121 '|---|---:|---|---:|---|', 122 ] 123 for (const item of preview) { 124 rows.push( 125 `| ${escapeMarkdownCell(item.filePath)} | ${escapeMarkdownCell(ISSUE_SEVERITY[item.severity] || item.severity)} | ${escapeMarkdownCell(item.ruleId)} | +${item.count} | ${escapeMarkdownCell(item.message)} |`, 126 ) 127 } 128 const remainder = regressions.length - preview.length 129 const suffix = remainder > 0 ? `\nShowing first ${preview.length} rows. ${remainder} more not shown.\n` : '\n' 130 return `${header}\n${rows.join('\n')}\n${suffix}` 131 } 132 133 function ensureParentDir(filePath) { 134 const parent = path.dirname(filePath) 135 if (parent && parent !== '.') fs.mkdirSync(parent, { recursive: true }) 136 } 137 138 function writeReports(options, report) { 139 if (options.reportJson) { 140 ensureParentDir(options.reportJson) 141 fs.writeFileSync(options.reportJson, `${JSON.stringify(report, null, 2)}\n`, 'utf8') 142 } 143 if (options.reportMd) { 144 ensureParentDir(options.reportMd) 145 fs.writeFileSync( 146 options.reportMd, 147 toMarkdownReport({ 148 baselineTotal: report.baselineTotal, 149 currentTotal: report.currentTotal, 150 regressions: report.regressions, 151 maxPreview: options.maxPreview, 152 }), 153 'utf8', 154 ) 155 } 156 } 157 158 async function collectIssueCounts() { 159 const eslint = new ESLint() 160 const results = await eslint.lintFiles(['.']) 161 const issueCounts = new Map() 162 163 for (const result of results) { 164 const filePath = path.relative(process.cwd(), result.filePath).replaceAll('\\', '/') 165 for (const message of result.messages) { 166 if (!message || !message.severity) continue 167 const ruleId = message.ruleId || '(eslint)' 168 const severity = Number(message.severity) 169 const normalized = normalizeMessage(message.message) 170 const key = toIssueKey(filePath, ruleId, severity, normalized) 171 issueCounts.set(key, (issueCounts.get(key) || 0) + 1) 172 } 173 } 174 175 return issueCounts 176 } 177 178 function loadBaselineMap() { 179 if (!fs.existsSync(BASELINE_FILE)) return null 180 const parsed = JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf8')) 181 return rowsToMap(parsed.issues) 182 } 183 184 function saveBaseline(issueCounts) { 185 const rows = mapToRows(issueCounts) 186 const payload = { 187 version: 1, 188 generatedAt: new Date().toISOString(), 189 totalIssues: rows.reduce((sum, row) => sum + row.count, 0), 190 uniqueIssues: rows.length, 191 issues: rows, 192 } 193 fs.writeFileSync(BASELINE_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') 194 console.log( 195 `Wrote baseline with ${payload.totalIssues} issues (${payload.uniqueIssues} unique fingerprints) to ${path.basename(BASELINE_FILE)}.`, 196 ) 197 } 198 199 function reportRegressions(regressions, maxPreview) { 200 const sorted = regressions.sort(compareIssueRows) 201 const preview = sorted.slice(0, maxPreview) 202 console.error(`Found ${sorted.length} net-new lint fingerprint(s):`) 203 for (const item of preview) { 204 const severityLabel = ISSUE_SEVERITY[item.severity] || String(item.severity) 205 console.error( 206 `- ${item.filePath} [${severityLabel}] ${item.ruleId} (+${item.count}): ${item.message}`, 207 ) 208 } 209 if (sorted.length > preview.length) { 210 console.error(`...and ${sorted.length - preview.length} more.`) 211 } 212 } 213 214 async function main() { 215 const options = parseOptions(args) 216 const current = await collectIssueCounts() 217 218 if (MODE === 'update') { 219 saveBaseline(current) 220 return 221 } 222 223 const baseline = loadBaselineMap() 224 if (!baseline) { 225 console.error(`Missing ${path.basename(BASELINE_FILE)}. Run: npm run lint:baseline:update`) 226 process.exit(1) 227 } 228 229 const regressions = [] 230 for (const [key, count] of current.entries()) { 231 const allowed = baseline.get(key) || 0 232 if (count > allowed) { 233 const item = fromIssueKey(key) 234 regressions.push({ 235 ...item, 236 count: count - allowed, 237 }) 238 } 239 } 240 241 const baselineTotal = Array.from(baseline.values()).reduce((sum, count) => sum + count, 0) 242 const currentTotal = Array.from(current.values()).reduce((sum, count) => sum + count, 0) 243 const reportPayload = { 244 mode: MODE, 245 baselineFile: path.basename(BASELINE_FILE), 246 baselineTotal, 247 currentTotal, 248 netNewFingerprints: regressions.length, 249 regressions: regressions.sort(compareIssueRows), 250 } 251 writeReports(options, reportPayload) 252 253 if (regressions.length > 0) { 254 reportRegressions(regressions, options.maxPreview) 255 console.error( 256 `Baseline total: ${baselineTotal}. Current total: ${currentTotal}. Fix new issues or refresh baseline intentionally.`, 257 ) 258 process.exit(1) 259 } 260 261 if (currentTotal < baselineTotal) { 262 console.log( 263 `Lint debt improved by ${baselineTotal - currentTotal} issue(s). Run npm run lint:baseline:update to record the lower baseline.`, 264 ) 265 } else { 266 console.log(`No net-new lint issues detected (${currentTotal} current vs ${baselineTotal} baseline).`) 267 } 268 } 269 270 main().catch((error) => { 271 console.error(error instanceof Error ? error.stack || error.message : error) 272 process.exit(1) 273 })