/** * S10: Renderers thread - Human renderer * * Responsibilities: * - Render canonical failure output matching golden snapshot exactly * - Render progress/summary for verify/observe/qualify * - Render doctor check results * - Render migrate rewrite reports * - Handle large payload truncation * - Use picocolors for styling * - No spinners in CI * - Color respects --color flag */ import pc from 'picocolors'; import type { Artifact, FailureRecord, HumanFailureSection } from '../core/types.js'; import type { OutputContext } from './shared.js'; import { shouldUseColor, getColors, truncate, indent, formatDuration } from './shared.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface HumanRendererOptions { ctx: OutputContext; profile?: string; seed?: number; } // --------------------------------------------------------------------------- // Color setup // --------------------------------------------------------------------------- /** * Get the colors instance for this render context. */ function getColorizer(ctx: OutputContext) { const enabled = shouldUseColor(ctx); return getColors(enabled); } // --------------------------------------------------------------------------- // Canonical failure output // --------------------------------------------------------------------------- /** * Render canonical failure output matching golden snapshot exactly. * * Golden snapshot format: * Contract violation * POST /users * Profile: quick * Seed: 42 * * Expected * response_code(GET /users/{response_body(this).id}) == 200 * * Observed * GET /users/usr-123 returned 404 * * Why this matters * The resource created by POST /users is not retrievable. * * Replay * apophis replay --artifact reports/apophis/failure-2026-04-28T12-30-22Z.json * * Next * Check the create/read consistency for POST /users and GET /users/{id}. */ export function renderCanonicalFailure( failure: FailureRecord, options: HumanRendererOptions, ): string { const c = getColorizer(options.ctx); const lines: string[] = []; // Title lines.push(c.red('Contract violation')); // Route lines.push(c.bold(failure.route)); // Profile and Seed lines.push(`Profile: ${options.profile || 'default'}`); lines.push(`Seed: ${failure.seed}`); lines.push(''); // Expected lines.push('Expected'); lines.push(indent(failure.contract, 2)); lines.push(''); // Observed lines.push('Observed'); // Truncate observed if very long const observed = failure.observed.length > 500 ? truncate(failure.observed, { maxLength: 500 }) : failure.observed; lines.push(indent(observed, 2)); lines.push(''); // Diff (if available) if (failure.diff) { lines.push('Diff'); for (const line of failure.diff.split('\n')) { lines.push(indent(line, 2)); } lines.push(''); } // Actual value (if available, different from observed) if (failure.actual && failure.actual !== failure.observed) { lines.push('Actual value'); const actual = failure.actual.length > 500 ? truncate(failure.actual, { maxLength: 500 }) : failure.actual; lines.push(indent(actual, 2)); lines.push(''); } // Why this matters lines.push('Why this matters'); lines.push(indent(generateWhyItMatters(failure), 2)); lines.push(''); // Replay lines.push('Replay'); lines.push(indent(failure.replayCommand, 2)); lines.push(''); // Next lines.push('Next'); lines.push(indent(generateNextSteps(failure), 2)); return lines.join('\n'); } /** * Generate "Why this matters" text from failure context. */ function generateWhyItMatters(failure: FailureRecord): string { const route = failure.route; const method = route.split(' ')[0]; const path = route.split(' ')[1] || route; // For POST /users with GET follow-up contract if (method === 'POST' && failure.contract.includes('GET')) { return `The resource created by ${route} is not retrievable.`; } // For GET requests if (method === 'GET') { return `The resource at ${path} does not exist or is inaccessible.`; } // Generic fallback return `The contract for ${route} was violated.`; } /** * Generate "Next" steps text from failure context. */ function generateNextSteps(failure: FailureRecord): string { const route = failure.route; const method = route.split(' ')[0]; const path = route.split(' ')[1] || route; // For POST /users with GET follow-up if (method === 'POST' && failure.contract.includes('GET')) { const getPath = failure.contract.match(/GET\s+([^\s{]+)/)?.[1] || path; // Ensure the path ends with /{id} for the canonical format // Remove trailing slash before adding /{id} to avoid double slashes const basePath = getPath.endsWith('/') ? getPath.slice(0, -1) : getPath; const normalizedPath = basePath.endsWith('/{id}') ? basePath : `${basePath}/{id}`; return `Check the create/read consistency for ${route} and GET ${normalizedPath}.`; } // Generic fallback return `Review the contract and implementation for ${route}.`; } // --------------------------------------------------------------------------- // Progress and summary rendering // --------------------------------------------------------------------------- /** * Render progress for a running command. * Safe for CI (no spinners, just text updates). */ export function renderProgress( current: number, total: number, label: string, ctx: OutputContext, ): string { const c = getColorizer(ctx); const pct = total > 0 ? Math.round((current / total) * 100) : 0; if (ctx.isCI || !ctx.isTTY) { // CI mode: simple text, no spinner return `${label} [${current}/${total}] ${pct}%`; } // TTY mode: with color const bar = renderProgressBar(current, total, 20, ctx); return `${c.dim(label)} ${bar} ${c.bold(`${pct}%`)}`; } /** * Render a simple ASCII progress bar. */ function renderProgressBar( current: number, total: number, width: number, ctx: OutputContext, ): string { const c = getColorizer(ctx); if (total === 0) return c.dim('[' + ' '.repeat(width) + ']'); const filled = Math.round((current / total) * width); const empty = width - filled; const filledChar = '█'; const emptyChar = '░'; return '[' + c.green(filledChar.repeat(filled)) + c.dim(emptyChar.repeat(empty)) + ']'; } /** * Render summary for verify/observe/qualify results. */ export function renderSummary( artifact: Artifact, ctx: OutputContext, ): string { const c = getColorizer(ctx); const lines: string[] = []; const { summary } = artifact; lines.push(''); lines.push(c.bold('Summary')); lines.push(` Total: ${summary.total}`); lines.push(` ${c.green('Passed:')} ${summary.passed}`); if (summary.failed > 0) { lines.push(` ${c.red('Failed:')} ${summary.failed}`); } else { lines.push(` Failed: ${summary.failed}`); } lines.push(` Duration: ${formatDuration(artifact.durationMs)}`); if (artifact.seed !== undefined) { lines.push(` Seed: ${artifact.seed}`); } return lines.join('\n'); } // --------------------------------------------------------------------------- // Doctor check results rendering // --------------------------------------------------------------------------- /** * Render doctor check results. */ export function renderDoctorChecks( checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string; detail?: string }>, ctx: OutputContext, ): string { const c = getColorizer(ctx); const lines: string[] = []; lines.push(c.bold('Doctor Results')); lines.push(''); for (const check of checks) { const icon = check.status === 'pass' ? c.green('✓') : check.status === 'warn' ? c.yellow('⚠') : c.red('✗'); lines.push(` ${icon} ${check.name}: ${check.message}`); if (check.detail) { lines.push(indent(check.detail, 4)); } } // Overall status const failedCount = checks.filter(c => c.status === 'fail').length; const warnCount = checks.filter(c => c.status === 'warn').length; lines.push(''); if (failedCount > 0) { lines.push(c.red(`Failed: ${failedCount} check(s)`)); } else if (warnCount > 0) { lines.push(c.yellow(`Warnings: ${warnCount} check(s)`)); } else { lines.push(c.green('All checks passed.')); } return lines.join('\n'); } // --------------------------------------------------------------------------- // Migrate rewrite report rendering // --------------------------------------------------------------------------- /** * Render migrate rewrite report. */ export function renderMigrateReport( items: Array<{ type: string; file: string; line?: number; legacy: string; replacement: string; guidance?: string; ambiguous?: boolean }>, completed: typeof items, remaining: typeof items, mode: 'check' | 'dry-run' | 'write', ctx: OutputContext, ): string { const c = getColorizer(ctx); const lines: string[] = []; if (mode === 'check') { lines.push(c.bold('Legacy config patterns detected:')); lines.push(''); for (const item of items) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` ${c.dim(location)}`); lines.push(` Legacy: ${c.yellow(item.legacy)}`); lines.push(` Replace: ${c.green(item.replacement)}`); if (item.guidance) { lines.push(` Guidance: ${c.dim(item.guidance)}`); } if (item.ambiguous) { lines.push(` ${c.yellow('⚠ Ambiguous — requires manual choice')}`); } lines.push(''); } lines.push(`Found ${items.length} item(s) to migrate.`); lines.push(''); lines.push('Run "apophis migrate --dry-run" to preview rewrites.'); lines.push('Run "apophis migrate --write" to apply rewrites.'); } else if (mode === 'dry-run') { lines.push(c.bold('Dry run — the following rewrites would be applied:')); lines.push(''); for (const item of items) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` ${c.dim(location)}`); lines.push(` ${c.red('- ' + item.legacy)}`); lines.push(` ${c.green('+ ' + item.replacement)}`); if (item.guidance) { lines.push(` ${c.dim('# ' + item.guidance)}`); } if (item.ambiguous) { lines.push(` ${c.yellow('⚠ Skipped (ambiguous — requires manual choice)')}`); } lines.push(''); } lines.push(`Total: ${items.length} item(s) to migrate.`); lines.push(''); lines.push('Run "apophis migrate --write" to apply these rewrites.'); } else { // write mode lines.push(c.bold('Migration complete:')); lines.push(''); if (completed.length > 0) { lines.push(` ${c.green(`Completed (${completed.length}):`)}`); for (const item of completed) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` ${c.green('✓')} ${location} — ${item.legacy} → ${item.replacement}`); } lines.push(''); } if (remaining.length > 0) { lines.push(` ${c.yellow(`Remaining (${remaining.length}):`)}`); for (const item of remaining) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` - ${location} — ${item.legacy}`); if (item.ambiguous) { lines.push(` ${c.yellow('⚠ Ambiguous — requires manual choice')}`); } else if (item.guidance) { lines.push(` ${c.dim('# ' + item.guidance)}`); } } lines.push(''); } if (remaining.length === 0) { lines.push(c.green('All items migrated successfully.')); } else { lines.push('Run "apophis migrate --check" to review remaining items.'); } } return lines.join('\n'); } // --------------------------------------------------------------------------- // Full artifact rendering (human format) // --------------------------------------------------------------------------- /** * Render a full artifact as human-readable output. * This is the main entry point for --format human. */ export function renderHumanArtifact( artifact: Artifact, ctx: OutputContext, ): string { const c = getColorizer(ctx); const lines: string[] = []; // Header lines.push(c.bold(`apophis ${artifact.command}`)); lines.push(''); // Failures if (artifact.failures.length > 0) { for (const failure of artifact.failures) { lines.push(renderCanonicalFailure(failure, { ctx, profile: artifact.profile, seed: artifact.seed, })); lines.push(''); } } // Warnings if (artifact.warnings.length > 0) { lines.push(c.yellow('Warnings:')); for (const warning of artifact.warnings) { lines.push(` ⚠ ${warning}`); } lines.push(''); } // Summary lines.push(renderSummary(artifact, ctx)); // Expansion path guidance lines.push(''); lines.push(c.bold('Next steps')); if (artifact.command === 'verify') { if (artifact.summary.failed === 0) { lines.push(` ${c.green('✓')} All contracts passed.`); lines.push(` ${c.dim('→ Add more behavioral contracts with')} x-ensures ${c.dim('and')} x-requires ${c.dim('to cover more routes.')}`); lines.push(` ${c.dim('→ Run')} apophis observe ${c.dim('to enable runtime contract monitoring in production.')}`); lines.push(` ${c.dim('→ Run')} apophis qualify --profile standard ${c.dim('for stateful/chaos testing.')}`); } else { lines.push(` ${c.yellow('!')} Fix failing contracts and rerun with:`); lines.push(` ${c.dim('→')} apophis verify --seed ${artifact.seed} ${artifact.profile ? `--profile ${artifact.profile}` : ''}`); lines.push(` ${c.dim('→ Or replay the artifact:')} apophis replay --artifact `); } } return lines.join('\n'); }