mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-26 23:39:13 +00:00
739f43027c
Per #43528, we should use Commander for consistency with our other tools. Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
365 lines
8.5 KiB
TypeScript
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);
|
|
});
|