444 lines
13 KiB
TypeScript
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');
|
|
}
|