Print a Report Worth Reading
The first version works. Great. It is also slow, noisy, and mildly dramatic when anything goes wrong.
We fixed slow (snapshots) and dramatic (resilient error handling). The last problem is noisy. Findings come out in whatever order the model returned them, with no severity grouping, and the formatting is whatever a series of console.log calls happened to produce. Time to put a reporter in front of it.
Outcome
Create src/reporter.ts with printReview(review) that sorts findings by severity, groups AI vs test findings, and suppresses ANSI color codes when CI=true. Update the CLI to call it.
Fast Track
- Create
src/reporter.tsexporting aprintReviewfunction. - Sort findings by severity (critical → high → medium → low).
- Skip color codes when
process.env.CI === 'true'. - Replace the print loop in
src/cli.tswith a singleprintReview(combined)call.
Hands-on exercise
Create src/reporter.ts:
import type { Finding } from './analyze';
import type { TestFinding } from './test-runner';
export type CombinedReview = {
overallRisk: 'low' | 'medium' | 'high';
aiFindings: Finding[];
testFindings: TestFinding[];
};
const SEVERITY_RANK: Record<Finding['severity'], number> = {
critical: 0,
high: 1,
medium: 2,
low: 3
};
const useColor = process.env.CI !== 'true';
function color(code: string, text: string): string {
if (!useColor) return text;
return `\x1b[${code}m${text}\x1b[0m`;
}
function riskColor(risk: CombinedReview['overallRisk']): string {
if (risk === 'high') return '31'; // red
if (risk === 'medium') return '33'; // yellow
return '32'; // green
}
function severityColor(severity: Finding['severity']): string {
if (severity === 'critical' || severity === 'high') return '31';
if (severity === 'medium') return '33';
return '90'; // gray
}
export function printReview(review: CombinedReview): void {
const totalFindings = review.aiFindings.length + review.testFindings.length;
const riskLabel = review.overallRisk.toUpperCase();
console.log('');
console.log(`Overall risk: ${color(riskColor(review.overallRisk), riskLabel)}`);
console.log(`Total findings: ${totalFindings}`);
console.log('');
if (review.aiFindings.length > 0) {
console.log('AI findings:');
const sorted = [...review.aiFindings].sort(
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
);
for (const finding of sorted) {
const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`);
console.log(` ${tag} ${finding.summary} (${finding.file})`);
console.log(` → ${finding.recommendation}`);
}
console.log('');
}
if (review.testFindings.length > 0) {
console.log('Test findings:');
for (const finding of review.testFindings) {
const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`);
console.log(` ${tag} ${finding.details}`);
}
console.log('');
}
if (totalFindings === 0) {
console.log('No findings. The repo is clean by both AI and test signals.');
console.log('');
}
}A few decisions worth pointing at.
The CI detection (process.env.CI === 'true') is the only thing standing between a developer reading colorful output in their terminal and a CI log full of \x1b[31m garbage. Most CI systems set CI=true; the few that don't will just get colors, which is harmless.
We're sorting AI findings but not test findings. AI findings have four severity levels and benefit from ordering; test findings are all 'high' so sorting them doesn't change anything.
We're printing the recommendation under each AI finding (→ ...) instead of just the summary. The recommendation is the actionable part. A summary without it is just a complaint.
The "no findings" message exists because zero results currently looks like the report just stopped halfway through. A confirmation message tells the reader nothing went wrong.
Now wire it in. Open src/cli.ts and replace the print loop:
import { Command } from 'commander';
import { runSandboxLifecycle } from './sandbox-lifecycle';
import { analyzeRepository } from './analyze';
import { parseTestFailures } from './test-runner';
import { printReview, type CombinedReview } from './reporter';
function isValidGitHubRepoUrl(input: string): boolean {
return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input);
}
async function time<T>(label: string, fn: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
return await fn();
} finally {
console.log(` ⏱ ${label}: ${Date.now() - startedAt}ms`);
}
}
async function safe<T>(label: string, fn: () => Promise<T>, fallback: T): Promise<T> {
try {
return await fn();
} catch (error) {
console.warn(`⚠ ${label} failed: ${error instanceof Error ? error.message : error}`);
return fallback;
}
}
const program = new Command();
program
.name('repo-review')
.description('Clone and review a GitHub repository in a Sandbox')
.version('0.1.0');
program
.command('review <repoUrl>')
.description('Run a Sandbox review against a GitHub repository URL')
.action(async (repoUrl: string) => {
if (!isValidGitHubRepoUrl(repoUrl)) {
console.error(`Invalid GitHub repository URL: ${repoUrl}`);
console.error('Expected format: https://github.com/<owner>/<repo>');
process.exitCode = 2;
return;
}
console.log(`Reviewing ${repoUrl}...`);
const totalStart = Date.now();
try {
const lifecycle = await time('sandbox lifecycle', () => runSandboxLifecycle(repoUrl));
const aiReview = lifecycle.files.length === 0
? { overallRisk: 'low' as const, findings: [] }
: await time('ai analysis', () =>
safe(
'ai analysis',
() => analyzeRepository(lifecycle.files),
{ overallRisk: 'low' as const, findings: [] }
)
);
const testFindings = parseTestFailures(
`${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}`
);
const combined: CombinedReview = {
overallRisk: testFindings.length > 0 ? 'high' : aiReview.overallRisk,
aiFindings: aiReview.findings,
testFindings
};
printReview(combined);
console.log(`Total: ${Date.now() - totalStart}ms`);
} catch (error) {
console.error('Review failed:', error instanceof Error ? error.message : error);
process.exitCode = 1;
}
});
program.parse();The CLI got shorter, which is the right direction. Anything report-shaped now lives in the reporter, and the CLI does only what a CLI should: parse args, orchestrate stages, hand off results.
If you see \x1b[31m escape codes in your CI output, your CI provider isn't setting CI=true. Check the docs or hardcode process.env.CI = 'true' in a CI-specific config.
If you want the report as machine-readable JSON (for piping into another tool), add a --json flag to the CLI and have it call console.log(JSON.stringify(combined, null, 2)) instead of printReview. The reporter and the JSON path can coexist.
Try It
pnpm review https://github.com/vercel/examplesExpected output:
Reviewing https://github.com/vercel/examples...
⏱ sandbox lifecycle: 9420ms
⏱ ai analysis: 7180ms
Overall risk: LOW
Total findings: 3
AI findings:
[HIGH] Unsanitized shell interpolation in build script (scripts/release.ts)
→ Use a templated argument array (execFileSync) instead of string interpolation.
[MEDIUM] Missing timeout on external fetch (src/lib/http.ts)
→ Pass an AbortController with a 10s timeout to fetch().
[LOW] Test assertion uses broad matcher (tests/api.test.ts)
→ Replace toMatch(/.+/) with the specific expected value.
Total: 16600msAnd in CI mode:
CI=true pnpm review https://github.com/vercel/examplesSame content, no color escapes. Easy to read in either context.
Commit
git add src/reporter.ts src/cli.ts
git commit -m "feat(reporter): severity-sorted, ci-aware review output"Done-When
src/reporter.tsexportsprintReviewandCombinedReview- AI findings sort critical → high → medium → low
CI=truesuppresses ANSI escape codes- CLI calls
printReview(combined)instead of inline loops - Empty findings produce a "no findings" confirmation message
Solution
import type { Finding } from './analyze';
import type { TestFinding } from './test-runner';
export type CombinedReview = {
overallRisk: 'low' | 'medium' | 'high';
aiFindings: Finding[];
testFindings: TestFinding[];
};
const SEVERITY_RANK: Record<Finding['severity'], number> = {
critical: 0,
high: 1,
medium: 2,
low: 3
};
const useColor = process.env.CI !== 'true';
function color(code: string, text: string): string {
if (!useColor) return text;
return `\x1b[${code}m${text}\x1b[0m`;
}
function riskColor(risk: CombinedReview['overallRisk']): string {
if (risk === 'high') return '31';
if (risk === 'medium') return '33';
return '32';
}
function severityColor(severity: Finding['severity']): string {
if (severity === 'critical' || severity === 'high') return '31';
if (severity === 'medium') return '33';
return '90';
}
export function printReview(review: CombinedReview): void {
const totalFindings = review.aiFindings.length + review.testFindings.length;
const riskLabel = review.overallRisk.toUpperCase();
console.log('');
console.log(`Overall risk: ${color(riskColor(review.overallRisk), riskLabel)}`);
console.log(`Total findings: ${totalFindings}`);
console.log('');
if (review.aiFindings.length > 0) {
console.log('AI findings:');
const sorted = [...review.aiFindings].sort(
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
);
for (const finding of sorted) {
const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`);
console.log(` ${tag} ${finding.summary} (${finding.file})`);
console.log(` → ${finding.recommendation}`);
}
console.log('');
}
if (review.testFindings.length > 0) {
console.log('Test findings:');
for (const finding of review.testFindings) {
const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`);
console.log(` ${tag} ${finding.details}`);
}
console.log('');
}
if (totalFindings === 0) {
console.log('No findings. The repo is clean by both AI and test signals.');
console.log('');
}
}Was this helpful?