chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* 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 <path>`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user