Files
apophis-fastify/src/cli/renderers/human.ts
T

444 lines
13 KiB
TypeScript

/**
* 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 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 <path>`);
}
}
return lines.join('\n');
}