/ scripts / lint-baseline.mjs
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  })