---
title: "Formatted Reports"
description: "Move the print logic out of the CLI into a small reporter module. Sort findings by severity, group AI vs test results, and skip ANSI colors when running in CI so the logs stay clean."
canonical_url: "https://vercel.com/academy/vercel-sandbox/formatted-reports"
md_url: "https://vercel.com/academy/vercel-sandbox/formatted-reports.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-17T23:50:29.567Z"
content_type: "lesson"
course: "vercel-sandbox"
course_title: "Vercel Sandbox"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Formatted Reports

# 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

1. Create `src/reporter.ts` exporting a `printReview` function.
2. Sort findings by severity (critical → high → medium → low).
3. Skip color codes when `process.env.CI === 'true'`.
4. Replace the print loop in `src/cli.ts` with a single `printReview(combined)` call.

## Hands-on exercise

Create `src/reporter.ts`:

```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:

```ts
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.

\*\*Warning: Troubleshooting: colors in CI logs\*\*

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.

\*\*Note: Troubleshooting: want JSON output instead\*\*

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

```bash
pnpm review https://github.com/vercel/examples
```

Expected output:

```txt
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: 16600ms
```

And in CI mode:

```bash
CI=true pnpm review https://github.com/vercel/examples
```

Same content, no color escapes. Easy to read in either context.

## Commit

```bash
git add src/reporter.ts src/cli.ts
git commit -m "feat(reporter): severity-sorted, ci-aware review output"
```

## Done-When

- [ ] `src/reporter.ts` exports `printReview` and `CombinedReview`
- [ ] AI findings sort critical → high → medium → low
- [ ] `CI=true` suppresses ANSI escape codes
- [ ] CLI calls `printReview(combined)` instead of inline loops
- [ ] Empty findings produce a "no findings" confirmation message

## Solution

```ts title="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';
  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('');
  }
}
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
