Files
renovate/tools/check/index.ts
Jamie Tanna 739f43027c refactor(tools/check): convert to Commander (#45596)
Per #43528, we should use Commander for consistency with our other
tools.

Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
2026-09-01 12:55:03 +00:00

365 lines
8.5 KiB
TypeScript

/**
* 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<number> {
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<CoverageInfo[]> {
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<ParallelCheck[]> {
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<void> {
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);
});