#!/usr/bin/env bun import colors from '@styles/theme/colors'; /** * Seatbelt baseline dashboard: parses oxlint.seatbelt.tsv and emits an HTML report * with aggregated tables and optional git history charts (Chart.js pinned beside the HTML). * * Styling uses the same dark palette as src/styles/theme/themes/dark.ts via @styles/theme/colors. * * After writing, opens the report in your default browser unless --no-open is used or CI is set. * When the history chart is included, Chart.js is downloaded next to the HTML so file:// opens work; * tables work offline without Chart.js. */ import CLI from 'expensify-common/CLI'; import {execSync, spawnSync} from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import {SEATBELT_TSV_RELATIVE} from './lint/processors/Seatbelt'; import Git from './utils/Git'; import TSVUtils from './utils/TSVUtils'; /** Pinned Chart.js for reproducible reports (UMD build). */ const CHART_JS_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js'; /** Written beside the HTML so `file://` loads work (remote scripts from disk are unreliable). */ const CHART_BUNDLE_FILENAME = 'lint-report.chart.umd.min.js'; /** * Semantic colors aligned with src/styles/theme/themes/dark.ts (dark theme). * Font stacks match src/styles/utils/FontUtils/fontFamily/multiFontFamily (web-safe fallbacks). */ const REPORT_BODY_FONT = `'Expensify Neue', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif`; const REPORT_MONO_FONT = `'Expensify Mono', ui-monospace, 'Segoe UI Mono', 'Menlo', monospace`; const REPORT_THEME = { appBG: colors.productDark100, cardBG: colors.productDark200, border: colors.productDark400, borderRow: colors.productDark400, tableHeaderBG: colors.productDark300, tableHeaderHoverBG: colors.productDark400, text: colors.productDark900, textSupporting: colors.productDark800, heading: colors.productDark900, codeBG: colors.productDark300, chartSurface: colors.productDark200, accent: colors.green400, } as const; /** Distinct rule trend lines on dark chart surface (from theme palette). */ const CHART_RULE_PALETTE = [ colors.blue300, colors.tangerine300, colors.ice300, colors.pink300, colors.yellow300, colors.green300, colors.blue200, colors.tangerine200, colors.ice400, colors.pink400, ]; /** Encode #RRGGBB + alpha for Chart.js / CSS. */ const hexToRGBA = (hex: string, alpha: number): string => { const n = hex.replace('#', ''); const r = Number.parseInt(n.slice(0, 2), 16); const g = Number.parseInt(n.slice(2, 4), 16); const b = Number.parseInt(n.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; }; type ChartUiPayload = { totalLine: string; totalFill: string; tick: string; grid: string; legend: string; tooltipBg: string; tooltipBorder: string; tooltipText: string; rulePalette: string[]; }; const buildChartUiPayload = (): ChartUiPayload => ({ totalLine: REPORT_THEME.accent, totalFill: hexToRGBA(REPORT_THEME.accent, 0.12), tick: colors.productDark800, grid: hexToRGBA(colors.productDark700, 0.35), legend: colors.productDark900, tooltipBg: colors.productDark400, tooltipBorder: colors.productDark500, tooltipText: colors.productDark900, rulePalette: [...CHART_RULE_PALETTE], }); /** * Download Chart.js UMD next to the report HTML (same directory as output path). */ const saveChartJsBesideHtml = async (htmlAbsPath: string): Promise<{ok: true} | {ok: false; message: string}> => { const dest = path.join(path.dirname(htmlAbsPath), CHART_BUNDLE_FILENAME); try { const res = await fetch(CHART_JS_CDN); if (!res.ok) { return {ok: false, message: `HTTP ${res.status}`}; } fs.writeFileSync(dest, await res.text(), 'utf8'); return {ok: true}; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return {ok: false, message}; } }; type SeatbeltRow = { rawPath: string; rule: string; count: number; }; type Aggregates = { byRule: Map; byFile: Map; grandTotal: number; }; type GitCommitPoint = { hash: string; isoDate: string; }; type HistorySnapshot = { commit: GitCommitPoint; aggregates: Aggregates; }; const parseSeatbeltTsv = (content: string): SeatbeltRow[] => { const {rows} = TSVUtils.parse(content, { onInvalidRow: (error, line) => { console.warn(`lint-report: skipping malformed line: ${error.message}: ${line.slice(0, 120)}`); }, }); const parsed: SeatbeltRow[] = []; for (const row of rows) { if (row.cells.length !== 3) { console.warn(`lint-report: skipping malformed line (${row.cells.length} columns)`); continue; } const [rawPath, rule, count] = row.cells; if (typeof rawPath !== 'string' || typeof rule !== 'string' || typeof count !== 'number' || !Number.isFinite(count) || count < 0) { console.warn(`lint-report: skipping bad row for ${String(rawPath)}`); continue; } parsed.push({rawPath, rule, count}); } return parsed; }; const normalizeFilePath = (projectRoot: string, seatbeltDir: string, rawPath: string): string => path.relative(projectRoot, path.resolve(seatbeltDir, rawPath)).split(path.sep).join('/'); const aggregate = (rows: SeatbeltRow[], projectRoot: string, seatbeltDir: string): Aggregates => { const byRule = new Map(); const byFile = new Map(); let grandTotal = 0; for (const row of rows) { grandTotal += row.count; const fileKey = normalizeFilePath(projectRoot, seatbeltDir, row.rawPath); byRule.set(row.rule, (byRule.get(row.rule) ?? 0) + row.count); byFile.set(fileKey, (byFile.get(fileKey) ?? 0) + row.count); } return {byRule, byFile, grandTotal}; }; const sortedEntries = (map: Map): Array<[string, number]> => [...map.entries()].sort((a, b) => b[1] - a[1]); /** * Read seatbelt TSV at a commit. Uses Git.show (cwd must be the App repo root). */ const trySeatbeltAtCommit = (hash: string): string | null => { try { return Git.show(hash, SEATBELT_TSV_RELATIVE); } catch { return null; } }; /** Requires cwd = App repo root. Git.ts has no wrapper for git log. */ const listSeatbeltCommits = (limit: number): GitCommitPoint[] => { try { const out = execSync(`git log --reverse --format=%H%x09%cI -n ${limit} -- ${SEATBELT_TSV_RELATIVE}`, { encoding: 'utf8', }).trim(); if (!out) { return []; } const commits: GitCommitPoint[] = []; for (const line of out.split('\n')) { const [hash, isoDate] = line.split('\t'); commits.push({hash, isoDate}); } return commits; } catch { return []; } }; const buildHistory = (projectRoot: string, seatbeltDir: string, gitLimit: number): HistorySnapshot[] => { const commits = listSeatbeltCommits(gitLimit); const snapshots: HistorySnapshot[] = []; for (const commit of commits) { const content = trySeatbeltAtCommit(commit.hash); if (content === null) { continue; } const rows = parseSeatbeltTsv(content); snapshots.push({ commit, aggregates: aggregate(rows, projectRoot, seatbeltDir), }); } return snapshots; }; const escapeHtml = (s: string): string => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); type ChartPayload = { labels: string[]; total: number[]; /** Keys align with topRules; each entry is counts per commit for that rule */ ruleSeries: Array<{rule: string; counts: number[]}>; ui: ChartUiPayload; }; const commitDayLabel = (isoDate: string): string => { const d = new Date(isoDate); return Number.isNaN(d.getTime()) ? isoDate.slice(0, 10) : d.toISOString().slice(0, 10); }; const buildChartPayload = (history: HistorySnapshot[], topRules: string[]): ChartPayload | null => { if (!history.length) { return null; } const labels: string[] = []; const total: number[] = []; for (const h of history) { labels.push(commitDayLabel(h.commit.isoDate)); total.push(h.aggregates.grandTotal); } const ruleSeries: Array<{rule: string; counts: number[]}> = []; for (const rule of topRules) { ruleSeries.push({ rule, counts: history.map((h) => h.aggregates.byRule.get(rule) ?? 0), }); } return {labels, total, ruleSeries, ui: buildChartUiPayload()}; }; const renderHtml = (opts: { generatedIso: string; gitHead: string; grandTotal: number; uniqueFiles: number; uniqueRules: number; ruleRows: Array<[string, number]>; fileRows: Array<[string, number]>; chartPayload: ChartPayload | null; /** Relative script URL (e.g. ./lint-report.chart.umd.min.js) when chart is included; omit Chart otherwise. */ chartScriptSrc: string | null; chartWarning: string | null; }): string => { const chartBundles = opts.chartPayload && opts.chartScriptSrc ? ` ` : ''; const ruleRowsHtml = opts.ruleRows.map(([rule, n]) => `${escapeHtml(rule)}${n}`).join('\n'); const fileRowsHtml = opts.fileRows.map(([file, n]) => `${escapeHtml(file)}${n}`).join('\n'); /** Inline SVG so favicon works when opening the report via file:// (no extra asset files). */ const faviconHref = `data:image/svg+xml,${encodeURIComponent( ``, )}`; return ` Lint seatbelt baseline

Lint seatbelt baseline

Aggregates come from parsing oxlint.seatbelt.tsv, the source of truth for lint findings in this repo. This tool reads that file instead of running the linter so the report stays fast—refresh or tighten the seatbelt before generating if you want updated counts.

Generated
${escapeHtml(opts.generatedIso)}
Git HEAD
${escapeHtml(opts.gitHead)}
Total violations
${opts.grandTotal}
Files with findings
${opts.uniqueFiles}
Distinct rules
${opts.uniqueRules}

History (${opts.chartPayload?.labels.length ?? 0} commits)

${opts.chartWarning ? `

${escapeHtml(opts.chartWarning)}

` : ''} ${ opts.chartPayload ? `
` : '

No chart data.

' }

By rule

${ruleRowsHtml}
RuleCount

By file

${fileRowsHtml}
FileCount
${chartBundles} `; }; /** * Open an HTML file with the OS default handler (typically your default browser). */ const openHtmlReport = (absPath: string): void => { const absolute = path.resolve(absPath); if (process.platform === 'darwin') { spawnSync('open', [absolute], {stdio: 'ignore'}); } else if (process.platform === 'win32') { // `start "" ` uses the empty window title so paths with spaces work. spawnSync('cmd', ['/c', 'start', '', absolute], { stdio: 'ignore', windowsHide: true, }); } else { spawnSync('xdg-open', [absolute], {stdio: 'ignore'}); } }; const parsePositiveIntArg = (val: string): number => { const n = Number.parseInt(val, 10); if (!Number.isFinite(n) || n < 1) { throw new Error('Must be a positive integer'); } return n; }; const parseNonNegativeIntArg = (val: string): number => { const n = Number.parseInt(val, 10); if (!Number.isFinite(n) || n < 0) { throw new Error('Must be a non-negative integer'); } return n; }; const runReport = async (): Promise => { const projectRoot = path.resolve(__dirname, '..'); const seatbeltPath = path.join(projectRoot, SEATBELT_TSV_RELATIVE); const seatbeltDir = path.dirname(seatbeltPath); /* CLI argv uses kebab-case for flags documented in help */ /* eslint-disable @typescript-eslint/naming-convention */ const cli = new CLI({ namedArgs: { output: { description: 'Output HTML path (relative to App repo root unless absolute)', default: path.join('.lint-reports', 'lint-report.html'), }, 'git-limit': { description: 'Max commits for history chart', default: 200, parse: parsePositiveIntArg, }, 'top-rules': { description: 'Top N rules for optional trend lines', default: 10, parse: parseNonNegativeIntArg, }, }, flags: { 'no-open': { description: 'Do not open the report in the browser after writing (also skipped when CI is set)', }, }, }); /* eslint-enable @typescript-eslint/naming-convention */ const output = cli.namedArgs.output; const gitLimit = cli.namedArgs['git-limit']; const topRules = cli.namedArgs['top-rules']; const openReport = !cli.flags['no-open']; if (!fs.existsSync(seatbeltPath)) { console.error(`lint-report: seatbelt file not found: ${seatbeltPath}`); process.exit(1); } const tsvContent = fs.readFileSync(seatbeltPath, 'utf8'); const rows = parseSeatbeltTsv(tsvContent); const agg = aggregate(rows, projectRoot, seatbeltDir); const ruleRows = sortedEntries(agg.byRule); const fileRows = sortedEntries(agg.byFile); const prevCwd = process.cwd(); process.chdir(projectRoot); let gitHead: string; let history: HistorySnapshot[]; try { gitHead = Git.getHeadShort(); history = buildHistory(projectRoot, seatbeltDir, gitLimit); } finally { process.chdir(prevCwd); } const topRuleNames = ruleRows.slice(0, topRules).map(([name]) => name); let chartPayload = buildChartPayload(history, topRuleNames); let chartWarning: string | null = null; if (!history.length) { chartWarning = 'No git history for oxlint.seatbelt.tsv (not a git repo, or file never committed).'; chartPayload = null; } const outAbs = path.isAbsolute(output) ? output : path.join(projectRoot, output); fs.mkdirSync(path.dirname(outAbs), {recursive: true}); let chartScriptSrc: string | null = null; if (chartPayload) { const saved = await saveChartJsBesideHtml(outAbs); if (!saved.ok) { throw new Error(`lint-report: could not download Chart.js: ${saved.message}`); } chartScriptSrc = `./${CHART_BUNDLE_FILENAME}`; } const html = renderHtml({ generatedIso: new Date().toISOString(), gitHead, grandTotal: agg.grandTotal, uniqueFiles: agg.byFile.size, uniqueRules: agg.byRule.size, ruleRows, fileRows, chartPayload, chartScriptSrc, chartWarning, }); fs.writeFileSync(outAbs, html, 'utf8'); console.log(`lint-report: wrote ${outAbs}`); const shouldOpen = openReport && !process.env.CI; if (shouldOpen) { try { openHtmlReport(outAbs); } catch { console.warn('lint-report: could not open report in browser (display unavailable?)'); } } }; const main = async (): Promise => { try { await runReport(); } catch (error: unknown) { console.error(error instanceof Error ? error.message : error); process.exit(1); } }; main().catch(() => { process.exit(1); });