diff --git a/scripts/eval/README.md b/scripts/eval/README.md index 7e95ad2307fb..b3d481a6e6ce 100644 --- a/scripts/eval/README.md +++ b/scripts/eval/README.md @@ -92,6 +92,22 @@ node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes --claude-effort node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes --claude-efforts max,high node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes --agents codex --codex-effort xhigh +# Restrict to specific projects (works with both agents) +node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes --projects mealdrop,edgy,echarts + +# Fan out across multiple prompts in one batch +node scripts/eval/run-batch.ts --prompts pattern-copy-play,optimized-tests --yes --repetitions 2 + +# Targeted matrix: medium + high effort, 3 projects, 2 reps each (12 Claude trials) +node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes \ + --agents claude --claude-efforts medium,high \ + --projects mealdrop,edgy,echarts --repetitions 2 + +# Same project subset on Codex +node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes \ + --agents codex --codex-effort high \ + --projects mealdrop,edgy,echarts --repetitions 2 + # Different prompt or concurrency node scripts/eval/run-batch.ts --prompt setup --yes node scripts/eval/run-batch.ts --prompt pattern-copy-play --yes --concurrency 4 diff --git a/scripts/eval/lib/publish-trial.test.ts b/scripts/eval/lib/publish-trial.test.ts index b8eb583859aa..276baadb178b 100644 --- a/scripts/eval/lib/publish-trial.test.ts +++ b/scripts/eval/lib/publish-trial.test.ts @@ -93,6 +93,32 @@ describe('buildTrialLabels', () => { 'prompt:setup', ]); }); + + it('truncates labels longer than 50 characters', async () => { + const { buildTrialLabels } = await import('./publish-trial.ts'); + + const longPrompt = 'monorepo-optimized-tests-relaxed-limits-no-story-deletion'; + const labels = buildTrialLabels( + { + name: 'mealdrop', + repo: 'https://github.com/storybook-tmp/mealdrop', + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + { agent: 'claude', model: 'sonnet-4.6', effort: 'high' }, + longPrompt + ); + + expect(labels).toEqual([ + 'eval', + 'project:mealdrop', + 'agent:claude', + 'model:sonnet-4.6', + 'effort:high', + 'prompt:monorepo-optimized-tests-relaxed-limits-no-', + ]); + expect(labels.every((label) => label.length <= 50)).toBe(true); + }); }); describe('publishTrialBranch', () => { diff --git a/scripts/eval/lib/publish-trial.ts b/scripts/eval/lib/publish-trial.ts index 8e604ddd304b..faf75fc6d1c1 100644 --- a/scripts/eval/lib/publish-trial.ts +++ b/scripts/eval/lib/publish-trial.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { x } from 'tinyexec'; + +const GITHUB_LABEL_MAX_LENGTH = 50; import type { TrialWorkspace } from './prepare-trial.ts'; import type { EvalData } from './result-docs.ts'; import { @@ -32,7 +34,11 @@ export function buildTrialLabels( `model:${variant.model}`, `effort:${variant.effort}`, `prompt:${prompt}`, - ]; + ].map(truncateLabel); +} + +function truncateLabel(label: string) { + return label.slice(0, GITHUB_LABEL_MAX_LENGTH); } export async function publishTrialBranch(opts: { diff --git a/scripts/eval/run-batch.test.ts b/scripts/eval/run-batch.test.ts index d1384ca3b509..951f496cfe37 100644 --- a/scripts/eval/run-batch.test.ts +++ b/scripts/eval/run-batch.test.ts @@ -18,6 +18,9 @@ import { BATCH_VARIANTS, buildBatchRunDescriptors, buildBatchVariants, + formatBatchHeader, + formatDuration, + formatPerProjectSummary, main, parseRunBatchArgs, runBatch, @@ -255,6 +258,170 @@ describe('parseRunBatchArgs', () => { claudeEfforts: ['max', 'high'], }); }); + + it('parses a comma-separated --projects list from the CLI', () => { + const [first, second] = BATCH_PROJECT_NAMES; + expect( + parseRunBatchArgs(['--prompt', TEST_PROMPT, '--projects', `${first}, ${second}`]) + ).toEqual({ + prompt: TEST_PROMPT, + projects: [first, second], + }); + }); +}); + +describe('formatDuration', () => { + it('formats sub-minute durations as seconds', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(45_000)).toBe('45s'); + expect(formatDuration(59_499)).toBe('59s'); + }); + + it('formats minutes and seconds for under-an-hour durations', () => { + expect(formatDuration(60_000)).toBe('1m'); + expect(formatDuration(338_759)).toBe('5m 39s'); + expect(formatDuration(1_120_358)).toBe('18m 40s'); + }); + + it('formats hours and minutes for long durations', () => { + expect(formatDuration(3_600_000)).toBe('1h 0m'); + expect(formatDuration(3_900_000)).toBe('1h 5m'); + }); +}); + +describe('formatBatchHeader', () => { + it('summarizes the matrix and lists distinct values', () => { + const descriptors = buildBatchRunDescriptors({ + prompt: TEST_PROMPT, + agents: ['claude'], + claudeEfforts: ['medium', 'high'], + projects: ['mealdrop', 'edgy'], + repetitions: 2, + }); + + const lines = formatBatchHeader({ + batchTimestamp: '2026-05-05T12-09-55-151Z', + descriptors, + concurrency: 8, + logsDir: '/tmp/logs', + }); + + expect(lines[0]).toBe('Eval batch 2026-05-05T12-09-55-151Z'); + expect(lines.join('\n')).toContain( + 'runs: 8 (2 projects × 1 agent(s) × 2 effort(s) × 2 rep(s))' + ); + expect(lines.join('\n')).toContain('prompt: pattern-copy-play'); + expect(lines.join('\n')).toContain('projects: edgy, mealdrop'); + expect(lines.join('\n')).toContain('efforts: high, medium'); + expect(lines.join('\n')).toContain('concurrency: 8'); + expect(lines.join('\n')).toContain('logs: /tmp/logs'); + }); +}); + +describe('formatPerProjectSummary', () => { + it('produces a column-aligned table grouped by project', () => { + const runs = [ + makeRun({ project: 'mealdrop', status: 'success', durationMs: 60_000 }), + makeRun({ project: 'mealdrop', status: 'success', durationMs: 120_000 }), + makeRun({ project: 'mealdrop', status: 'failed', durationMs: 30_000 }), + makeRun({ project: 'edgy', status: 'success', durationMs: 240_000 }), + ]; + const lines = formatPerProjectSummary(runs); + expect(lines[0]).toBe(''); + expect(lines[1]).toBe('Per-project summary:'); + const body = lines.slice(2).join('\n'); + expect(body).toContain('project'); + expect(body).toContain('ok'); + expect(body).toMatch(/edgy\s+1\/1/); + expect(body).toMatch(/mealdrop\s+2\/3/); + expect(body).toContain('30s'); + expect(body).toContain('4m'); + }); + + it('returns an empty array when there are no runs', () => { + expect(formatPerProjectSummary([])).toEqual([]); + }); +}); + +function makeRun(opts: { project: string; status: 'success' | 'failed'; durationMs: number }) { + return { + project: opts.project, + agent: 'claude' as const, + model: 'opus-4.6', + effort: 'high', + prompt: TEST_PROMPT, + repetition: 1, + label: `${opts.project}-r01`, + args: [], + startTimestamp: '2026-05-05T12:00:00.000Z', + endTimestamp: '2026-05-05T12:01:00.000Z', + durationMs: opts.durationMs, + exitCode: opts.status === 'success' ? 0 : 1, + signal: null, + status: opts.status, + logPath: `/tmp/${opts.project}.log`, + } as Parameters[0][number]; +} + +describe('buildBatchRunDescriptors with --projects', () => { + it('restricts the matrix to the requested projects, deduplicating', () => { + const [first, second] = BATCH_PROJECT_NAMES; + const descriptors = buildBatchRunDescriptors({ + prompt: TEST_PROMPT, + agents: ['claude'], + claudeEfforts: ['medium', 'high'], + projects: [first, second, first], + repetitions: 2, + }); + + expect(descriptors).toHaveLength(2 * 2 * 2); // 2 projects × 2 efforts × 2 reps + expect(new Set(descriptors.map((d) => d.project))).toEqual(new Set([first, second])); + expect(new Set(descriptors.map((d) => d.effort))).toEqual(new Set(['medium', 'high'])); + }); + + it('throws when an unknown project is requested', () => { + expect(() => + buildBatchRunDescriptors({ + prompt: TEST_PROMPT, + projects: ['not-a-real-project'], + }) + ).toThrow(/Unknown project/); + }); + + it('fans out across multiple prompts when --prompts is set', () => { + const [first] = BATCH_PROJECT_NAMES; + const descriptors = buildBatchRunDescriptors({ + prompts: ['pattern-copy-play', 'setup'], + agents: ['claude'], + claudeEffort: 'high', + projects: [first], + repetitions: 2, + }); + + expect(descriptors).toHaveLength(2 * 1 * 2); // 2 prompts × 1 project × 2 reps + expect(new Set(descriptors.map((d) => d.prompt))).toEqual( + new Set(['pattern-copy-play', 'setup']) + ); + expect(new Set(descriptors.map((d) => d.label)).size).toBe(descriptors.length); + }); + + it('throws when an unknown prompt is requested', () => { + expect(() => + buildBatchRunDescriptors({ + prompts: ['pattern-copy-play', 'not-a-real-prompt'], + }) + ).toThrow(/Unknown prompt/); + }); + + it('parses --prompts from the CLI', () => { + expect(parseRunBatchArgs(['--prompts', 'pattern-copy-play, setup'])).toMatchObject({ + prompts: ['pattern-copy-play', 'setup'], + }); + }); + + it('rejects CLI invocations with neither --prompt nor --prompts', () => { + expect(() => parseRunBatchArgs(['--repetitions', '1'])).toThrow(/--prompt or --prompts/); + }); }); describe('runBatch', () => { diff --git a/scripts/eval/run-batch.ts b/scripts/eval/run-batch.ts index 323550ab0341..95769cd02ccb 100644 --- a/scripts/eval/run-batch.ts +++ b/scripts/eval/run-batch.ts @@ -88,12 +88,16 @@ export interface RunBatchOptions { batchTimestamp?: string; /** Required when `descriptors` are not provided — prompt variant name from the CLI registry. */ prompt?: string; + /** Optional list of prompts to fan out across in a single batch (in addition to or in place of `prompt`). */ + prompts?: string[]; /** Skip interactive confirmation (large API / token usage). */ yes?: boolean; agents?: (typeof BATCH_AGENT_IDS)[number][]; claudeEfforts?: (typeof CLAUDE_EFFORTS)[number][]; claudeEffort?: (typeof CLAUDE_EFFORTS)[number]; codexEffort?: (typeof CODEX_EFFORTS)[number]; + /** Restrict the batch to a subset of projects. Defaults to BATCH_PROJECT_NAMES. */ + projects?: (typeof BATCH_PROJECT_NAMES)[number][]; /** Repetitions per (project × variant). Defaults to BATCH_REPETITIONS. */ repetitions?: number; log?: (message: string) => void; @@ -151,11 +155,12 @@ export async function runBatch( const descriptors = options.descriptors ?? buildBatchRunDescriptors({ - prompt: requireBatchPrompt(options), + prompts: requireBatchPrompts(options), agents: options.agents, claudeEfforts: options.claudeEfforts, claudeEffort: options.claudeEffort, codexEffort: options.codexEffort, + projects: options.projects, repetitions: options.repetitions, }); @@ -181,16 +186,25 @@ export async function runBatch( const limit = pLimit(concurrency); let started = 0; let finished = 0; + const total = descriptors.length; + const padTotal = String(total).length; + const shortLabel = (descriptor: BatchRunDescriptor) => + `${descriptor.project} r${String(descriptor.repetition).padStart(2, '0')}`; - log( - `Starting eval batch ${batchTimestamp}: ${descriptors.length} runs, concurrency ${concurrency}, logs ${logsDir}` - ); + for (const line of formatBatchHeader({ + batchTimestamp, + descriptors, + concurrency, + logsDir, + })) { + log(line); + } await Promise.all( descriptors.map((descriptor, index) => limit(async () => { started += 1; - log(`[start ${started}/${descriptors.length}] ${descriptor.label}`); + log(`[${String(started).padStart(padTotal)}/${total}] start ${shortLabel(descriptor)}`); const result = await runBatchDescriptor(descriptor, { repoRoot, @@ -202,8 +216,10 @@ export async function runBatch( finished += 1; const reason = result.status === 'failed' ? await readFailureReason(result.logPath) : ''; + const tag = result.status === 'success' ? '✓' : '✗'; + const exitInfo = result.status === 'success' ? '' : ` ${formatExitResult(result)}`; log( - `[finish ${finished}/${descriptors.length}] ${descriptor.label} ${result.status} ${formatExitResult(result)} ${result.durationMs}ms${reason ? ` — ${reason}` : ''}` + `[${String(finished).padStart(padTotal)}/${total}] ${tag} ${shortLabel(descriptor)} ${formatDuration(result.durationMs)}${exitInfo}${reason ? ` — ${reason}` : ''}` ); }) ) @@ -227,11 +243,17 @@ export async function runBatch( await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + log(''); log( - `Finished eval batch ${batchTimestamp}: ${summary.totalRuns} total, ${summary.succeeded} succeeded, ${summary.failed} failed` + `Finished eval batch ${batchTimestamp} in ${formatDuration(summary.durationMs)}: ${summary.succeeded}/${summary.totalRuns} succeeded${summary.failed > 0 ? `, ${summary.failed} failed` : ''}` ); + for (const line of formatPerProjectSummary(summary.runs)) { + log(line); + } + if (summary.failed > 0) { + log(''); log('Failures:'); for (const run of summary.runs) { if (run.status === 'success') continue; @@ -301,11 +323,13 @@ export function buildBatchVariants( } export function buildBatchRunDescriptors(options: { - prompt: string; + prompt?: string; + prompts?: string[]; agents?: RunBatchOptions['agents']; claudeEfforts?: RunBatchOptions['claudeEfforts']; claudeEffort?: RunBatchOptions['claudeEffort']; codexEffort?: RunBatchOptions['codexEffort']; + projects?: RunBatchOptions['projects']; repetitions?: number; }): BatchRunDescriptor[] { const knownProjects = new Set(PROJECTS.map((project) => project.name)); @@ -316,6 +340,9 @@ export function buildBatchRunDescriptors(options: { } } + const projects = resolveBatchProjects(options.projects); + const prompts = resolveBatchPrompts({ prompt: options.prompt, prompts: options.prompts }); + const descriptors: BatchRunDescriptor[] = []; const variants = options.agents == null && @@ -327,9 +354,11 @@ export function buildBatchRunDescriptors(options: { const totalRepetitions = options.repetitions ?? BATCH_REPETITIONS; for (let repetition = 1; repetition <= totalRepetitions; repetition += 1) { - for (const variant of variants) { - for (const project of BATCH_PROJECT_NAMES) { - descriptors.push(createBatchRunDescriptor(project, variant, repetition, options.prompt)); + for (const prompt of prompts) { + for (const variant of variants) { + for (const project of projects) { + descriptors.push(createBatchRunDescriptor(project, variant, repetition, prompt)); + } } } } @@ -337,23 +366,68 @@ export function buildBatchRunDescriptors(options: { return descriptors; } -function requireBatchPrompt(options: RunBatchOptions): string { - if (options.prompt == null || options.prompt.trim() === '') { +function resolveBatchPrompts(options: { prompt?: string; prompts?: string[] }): string[] { + const merged = [...(options.prompts ?? []), ...(options.prompt != null ? [options.prompt] : [])] + .map((value) => value.trim()) + .filter(Boolean); + + if (merged.length === 0) { throw new Error( - 'runBatch: pass `prompt` (prompt template basename) or provide `descriptors` explicitly.' + 'runBatch: pass `prompt` or `prompts` (prompt template basename) or provide `descriptors` explicitly.' ); } - const prompt = options.prompt.trim(); const available = listPrompts(); + const lookup = new Map(available.map((name) => [name.toLowerCase(), name])); + + const seen = new Set(); + const ordered: string[] = []; + const unknown: string[] = []; + for (const value of merged) { + const canonical = lookup.get(value.toLowerCase()); + if (!canonical) { + unknown.push(value); + continue; + } + if (seen.has(canonical)) continue; + seen.add(canonical); + ordered.push(canonical); + } + + if (unknown.length > 0) { + throw new Error( + `Unknown prompt(s): ${unknown.join(', ')}. Available prompts: ${available.join(', ')}` + ); + } - const canonical = available.find((name) => name.toLowerCase() === prompt.toLowerCase()); + return ordered; +} - if (!canonical) { - throw new Error(`Unknown prompt "${prompt}". Available prompts: ${available.join(', ')}`); +function resolveBatchProjects(projects?: RunBatchOptions['projects']) { + if (projects == null || projects.length === 0) { + return [...BATCH_PROJECT_NAMES]; } - return canonical; + const allowed = new Set(BATCH_PROJECT_NAMES); + const unknown = projects.filter((project) => !allowed.has(project)); + if (unknown.length > 0) { + throw new Error( + `Unknown project(s): ${unknown.join(', ')}. Available: ${BATCH_PROJECT_NAMES.join(', ')}` + ); + } + + const seen = new Set(); + const ordered: (typeof BATCH_PROJECT_NAMES)[number][] = []; + for (const project of projects) { + if (seen.has(project)) continue; + seen.add(project); + ordered.push(project); + } + return ordered; +} + +function requireBatchPrompts(options: RunBatchOptions): string[] { + return resolveBatchPrompts({ prompt: options.prompt, prompts: options.prompts }); } async function runBatchDescriptor( @@ -463,23 +537,34 @@ function defaultSpawn(command: string, args: string[], options: SpawnOptions) { return spawnChild(command, args, options); } -const runBatchArgsSchema = z.object({ - concurrency: z.coerce.number().int().positive().optional(), - prompt: z.string().min(1), - yes: z.boolean().optional(), - agents: z.array(z.enum(BATCH_AGENT_IDS)).nonempty().optional(), - claudeEfforts: z.array(z.enum(CLAUDE_EFFORTS)).nonempty().optional(), - claudeEffort: z.enum(CLAUDE_EFFORTS).optional(), - codexEffort: z.enum(CODEX_EFFORTS).optional(), - repetitions: z.coerce.number().int().positive().optional(), -}); +const runBatchArgsSchema = z + .object({ + concurrency: z.coerce.number().int().positive().optional(), + prompt: z.string().min(1).optional(), + prompts: z.array(z.string().min(1)).nonempty().optional(), + yes: z.boolean().optional(), + agents: z.array(z.enum(BATCH_AGENT_IDS)).nonempty().optional(), + claudeEfforts: z.array(z.enum(CLAUDE_EFFORTS)).nonempty().optional(), + claudeEffort: z.enum(CLAUDE_EFFORTS).optional(), + codexEffort: z.enum(CODEX_EFFORTS).optional(), + projects: z.array(z.string().min(1)).nonempty().optional(), + repetitions: z.coerce.number().int().positive().optional(), + }) + .refine((value) => value.prompt != null || value.prompts != null, { + message: 'pass --prompt or --prompts', + path: ['prompt'], + }); const runBatchOptions = { concurrency: { type: 'string' as const, description: 'Max concurrent runs (default: 8)' }, prompt: { type: 'string' as const, description: - 'Prompt variant name (required; registered in code/lib/cli-storybook/src/ai/setup-prompts/)', + 'Prompt variant name (required unless --prompts is set; registered in code/lib/cli-storybook/src/ai/setup-prompts/)', + }, + prompts: { + type: 'string' as const, + description: 'Comma-separated list of prompt variant names to fan out across', }, agents: { type: 'string' as const, @@ -491,6 +576,10 @@ const runBatchOptions = { }, 'claude-effort': { type: 'string' as const, description: 'Single Claude effort level' }, 'codex-effort': { type: 'string' as const, description: 'Single Codex effort level' }, + projects: { + type: 'string' as const, + description: 'Comma-separated project names to run (default: all batch projects)', + }, repetitions: { type: 'string' as const, description: `Repetitions per (project × variant) (default: ${BATCH_REPETITIONS})`, @@ -514,6 +603,8 @@ export function parseRunBatchArgs( | 'codexEffort' | 'concurrency' | 'prompt' + | 'prompts' + | 'projects' | 'yes' | 'repetitions' > @@ -531,11 +622,13 @@ export function parseRunBatchArgs( const parsed = runBatchArgsSchema.safeParse({ concurrency: values.concurrency, prompt: values.prompt, + prompts: parseList(values.prompts), yes: values.yes, agents: parseAgentArgs(values.agents), claudeEfforts: parseClaudeEfforts(values['claude-efforts']), claudeEffort: values['claude-effort'], codexEffort: values['codex-effort'], + projects: parseProjects(values.projects), repetitions: values.repetitions, }); @@ -571,6 +664,21 @@ function parseClaudeEfforts(value?: string) { .filter(Boolean); } +function parseProjects(value?: string) { + return parseList(value); +} + +function parseList(value?: string) { + if (value == null) { + return undefined; + } + const items = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return items.length > 0 ? items : undefined; +} + function resolveBatchAgents(agents?: RunBatchOptions['agents']) { if (agents == null || agents.length === 0) { return [...BATCH_DEFAULT_AGENT_IDS]; @@ -602,6 +710,85 @@ function formatExitResult(result: Pick 0) return `${hours}h ${minutes}m`; + return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`; +} + +function uniqueSorted(values: readonly string[]) { + return [...new Set(values)].sort(); +} + +export function formatBatchHeader(opts: { + batchTimestamp: string; + descriptors: BatchRunDescriptor[]; + concurrency: number; + logsDir: string; +}): string[] { + const { batchTimestamp, descriptors, concurrency, logsDir } = opts; + const projects = uniqueSorted(descriptors.map((d) => d.project)); + const agents = uniqueSorted(descriptors.map((d) => d.agent)); + const models = uniqueSorted(descriptors.map((d) => d.model)); + const efforts = uniqueSorted(descriptors.map((d) => d.effort)); + const prompts = uniqueSorted(descriptors.map((d) => d.prompt)); + const reps = Math.max(...descriptors.map((d) => d.repetition)); + + return [ + `Eval batch ${batchTimestamp}`, + ` runs: ${descriptors.length} (${projects.length} projects × ${agents.length} agent(s) × ${efforts.length} effort(s) × ${reps} rep(s))`, + ` prompt: ${prompts.join(', ')}`, + ` agents: ${agents.join(', ')}`, + ` models: ${models.join(', ')}`, + ` efforts: ${efforts.join(', ')}`, + ` projects: ${projects.join(', ')}`, + ` concurrency: ${concurrency}`, + ` logs: ${logsDir}`, + '', + ]; +} + +export function formatPerProjectSummary(runs: BatchRunSummaryEntry[]): string[] { + if (runs.length === 0) return []; + + const byProject = new Map(); + for (const run of runs) { + const list = byProject.get(run.project) ?? []; + list.push(run); + byProject.set(run.project, list); + } + + const projectCol = Math.max('project'.length, ...[...byProject.keys()].map((p) => p.length)); + const headers = ['project', 'ok', 'min', 'med', 'max']; + const rows = [...byProject.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([project, projectRuns]) => { + const ok = projectRuns.filter((r) => r.status === 'success').length; + const sortedDurations = projectRuns.map((r) => r.durationMs).sort((a, b) => a - b); + const median = sortedDurations[Math.floor(sortedDurations.length / 2)]; + return [ + project, + `${ok}/${projectRuns.length}`, + formatDuration(sortedDurations[0]), + formatDuration(median), + formatDuration(sortedDurations[sortedDurations.length - 1]), + ]; + }); + + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length))); + widths[0] = Math.max(widths[0], projectCol); + + const fmtRow = (cells: string[]) => + ` ${cells.map((cell, i) => (i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i]))).join(' ')}`; + + return ['', 'Per-project summary:', fmtRow(headers), ...rows.map(fmtRow)]; +} + async function waitForChild(child: SpawnedBatchChild) { return new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }>( (resolveResult) => {