/** * Fast local CI check script */ import { readdir } from 'node:fs/promises'; import { extname } from 'node:path'; import { Command } from 'commander'; import { getCoverageForDir, getCoverageForFiles, loadCoverage, } from './coverage.ts'; import { runChecksParallel, runCommand } from './execution.ts'; import { ANSI, formatCompletedLine, formatDuration, formatWaitingLine, isTTY, printCoverageReport, printFailedOutputs, renderFinalState, renderProgress, startRenderLoop, stopRenderLoop, } from './tui.ts'; import type { CheckResult, CheckResultWithCoverage, CliArgs, CoverageInfo, ExecutionProgress, ParallelCheck, ProcessManager, } from './types.ts'; // Module-level state for signal handler let currentProgress: ExecutionProgress | null = null; let currentProcessManager: ProcessManager | null = null; function handleSigint(): void { if (currentProcessManager) { currentProcessManager.abortController.abort(); for (const proc of currentProcessManager.subprocesses) { proc.kill('SIGTERM'); } currentProcessManager.subprocesses.clear(); stopRenderLoop(currentProcessManager); } if (currentProgress) { renderFinalState(currentProgress, true); } if (isTTY) { process.stdout.write(ANSI.SHOW_CURSOR); } process.exit(130); } process.on('SIGINT', handleSigint); function pnpmScript(name: string): ParallelCheck { return { name, cmd: 'pnpm', args: [name] }; } const PRETTIER_ENV = { PRETTIER_EXPERIMENTAL_CLI: '1' }; const FIX_CHECKS = ['oxlint-fix', 'biome-fix', 'prettier-fix'].map(pnpmScript); const FIXABLE_CHECKS = ['oxlint', 'biome', 'prettier'].map(pnpmScript); const OTHER_CHECKS = [ 'ls-lint', 'git-check', 'markdown-lint', 'doc-fence-check', 'lint-documentation', 'lint-other', 'test-schema', 'type-check', ].map(pnpmScript); function buildTargetedChecks(targets: string[], fix: boolean): ParallelCheck[] { const suffix = fix ? '-fix' : ''; return [ { name: `oxlint${suffix}`, cmd: 'pnpm', args: [ 'exec', 'oxlint', ...(fix ? ['--fix'] : []), '--no-error-on-unmatched-pattern', '-c', '.oxlintrc.json', ...targets, ], }, { name: `biome${suffix}`, cmd: 'pnpm', args: [ 'exec', 'biome', 'check', ...(fix ? ['--write'] : []), '--no-errors-on-unmatched', ...targets, ], }, { name: `prettier${suffix}`, cmd: 'pnpm', args: [ 'exec', 'prettier', fix ? '--write' : '--check', '--no-error-on-unmatched-pattern', '--cache', ...targets, ], env: PRETTIER_ENV, }, ]; } function toSpecPath(file: string): string { if (file.endsWith('.spec.ts')) { return file; } return file.replace(/\.ts$/, '.spec.ts'); } async function countSpecFiles(dir: string): Promise { try { const files = await readdir(dir, { recursive: true, encoding: 'utf-8' }); return files.filter((f) => f.endsWith('.spec.ts')).length; } catch { return 0; } } function toSourcePath(target: string): string | null { if (target.endsWith('.spec.ts')) { return target.replace('.spec.ts', '.ts'); } if (target.endsWith('.ts')) { return target; } return null; } async function collectCoverage(args: CliArgs): Promise { const coverageData = await loadCoverage('./coverage'); if (!coverageData) { return []; } const targets = args.targets.length > 0 ? args.targets : ['.']; const coverage: CoverageInfo[] = []; const sourceFiles: string[] = []; for (const t of targets) { const source = toSourcePath(t); if (source) { sourceFiles.push(source); } else { coverage.push(...getCoverageForDir(coverageData, t)); } } coverage.push(...getCoverageForFiles(coverageData, sourceFiles)); return coverage; } function parseCliArgs(): CliArgs { let args: CliArgs | undefined; new Command('pnpm check') .description('Fast local CI check script') .argument('[targets...]', 'files or directories to scope checks to') .option('--all', 'run fixers first, then all lint checks and tests') .option('--fix', 'run fixers only (oxlint-fix, biome-fix, prettier-fix)') .option('--no-test', 'skip tests') .action((targets, opts) => { args = { all: opts.all ?? false, fix: opts.fix ?? false, noTest: !opts.test, targets, }; }) .parse(); return args!; } async function buildTestChecks(args: CliArgs): Promise { if (args.noTest || (args.fix && !args.all)) { return []; } if (args.targets.length === 0) { return [{ name: 'test', cmd: 'pnpm', args: ['vitest'] }]; } const patterns = [ ...new Set( args.targets .filter((t) => extname(t) === '' || t.endsWith('.ts')) .map((t) => (t.endsWith('.ts') ? toSpecPath(t) : t)), ), ]; if (patterns.length === 0) { return []; } let fileCount = 0; for (const p of patterns) { fileCount += extname(p) === '' ? await countSpecFiles(p) : 1; } if (fileCount === 0) { return []; } const name = `test (${fileCount} ${fileCount === 1 ? 'file' : 'files'})`; return [ { name, cmd: 'pnpm', args: ['vitest', '--passWithNoTests', ...patterns] }, ]; } async function main(): Promise { const startTime = Date.now(); const args = parseCliArgs(); const testChecks = await buildTestChecks(args); let fixChecks: ParallelCheck[]; let lintChecks: ParallelCheck[]; if (args.all) { fixChecks = args.targets.length > 0 ? buildTargetedChecks(args.targets, true) : [...FIX_CHECKS]; lintChecks = args.targets.length > 0 ? buildTargetedChecks(args.targets, false) : [...FIXABLE_CHECKS, ...OTHER_CHECKS]; } else if (args.fix) { fixChecks = args.targets.length > 0 ? buildTargetedChecks(args.targets, true) : [...FIX_CHECKS]; lintChecks = []; } else if (args.targets.length > 0) { fixChecks = []; lintChecks = buildTargetedChecks(args.targets, false); } else { fixChecks = []; lintChecks = [...FIXABLE_CHECKS, ...OTHER_CHECKS]; } const allChecks = [...fixChecks, ...lintChecks, ...testChecks]; const processManager: ProcessManager = { subprocesses: new Set(), abortController: new AbortController(), renderInterval: null, }; const progress: ExecutionProgress = { startTime, checks: allChecks, results: allChecks.map(() => null), startedIndices: new Set(), spinnerFrame: 0, displayLines: allChecks.length + 2, }; currentProgress = progress; currentProcessManager = processManager; console.log('Checks:'); if (isTTY) { process.stdout.write(ANSI.HIDE_CURSOR); for (const check of allChecks) { console.log(formatWaitingLine(check.name)); } console.log(''); console.log('Total: 0s'); } startRenderLoop(progress, processManager); for (let i = 0; i < fixChecks.length; i++) { const check = fixChecks[i]; progress.startedIndices.add(i); const checkStart = Date.now(); const { success, output } = await runCommand( check.cmd, check.args, processManager, check.env, ); const duration = (Date.now() - checkStart) / 1000; const result: CheckResult = { name: check.name, success, duration, output }; progress.results[i] = { result }; if (!isTTY) { console.log(formatCompletedLine(check.name, success, duration)); } } await runChecksParallel( lintChecks, fixChecks.length, progress, processManager, ); if (testChecks.length > 0) { await runChecksParallel( testChecks, fixChecks.length + lintChecks.length, progress, processManager, ); } stopRenderLoop(processManager); renderProgress(progress); const allResults = progress.results.filter( (r): r is CheckResultWithCoverage => r !== null, ); const hasFailure = allResults.some((r) => !r.result.success); const coverage = testChecks.length > 0 ? await collectCoverage(args) : []; printCoverageReport(coverage); printFailedOutputs(progress.results); if (isTTY) { process.stdout.write(ANSI.SHOW_CURSOR); } else { const totalDuration = (Date.now() - startTime) / 1000; console.log(''); console.log(`Total: ${formatDuration(totalDuration)}`); } if (hasFailure) { process.exit(1); } } main().catch((err) => { if (isTTY) { process.stdout.write(ANSI.SHOW_CURSOR); } console.error(err); process.exit(1); });