Skip to content

Commit a61124e

Browse files
Arthur742RamosCopilotclaude
authored
feat: add bootcamp metrics for standalone codebase metrics (#58)
`bootcamp health` already exposes the HEALTH.md engine as a standalone, LLM-free command — but the parallel METRICS.md engine had no equivalent. This closes that gap. `bootcamp metrics <repo-url>` (local path or remote URL) scans the repo and reports the deterministic codebase metrics that power METRICS.md: language breakdown, source/test/doc/config composition, average/median file size, test-to-source ratio, size classification, top-level directory distribution, largest-file hotspots, and an approachability score (0-100 + A-F grade) with human-readable drivers. - Human-readable report by default (language bars, distribution, hotspots) - `--json` for scripting (full CodebaseMetrics payload) - `--check`/`--min-score` CI gate on the approachability score - `--branch`, `--max-files` (routed past root-flag collisions like `health`), `--keep-temp`, `--verbose` Mirrors `health-command.ts` in structure and reuses `computeCodebaseMetrics` verbatim, so the command can never drift from METRICS.md. Tests: 9 unit (DI-mocked: report/JSON/check/local/keep-temp/scan-failure/ resolve-failure), 4 E2E spawning the real CLI (human report, JSON, --check gate both directions, --max-files routing). Co-authored-by: Arthur742Ramos <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e3da5bd commit a61124e

6 files changed

Lines changed: 521 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `bootcamp metrics <repo-url>` command: a standalone, deterministic codebase-metrics report for any repository (local path or remote URL) without invoking the LLM. Surfaces language breakdown, source/test/doc/config composition, average/median file size, test-to-source ratio, size classification, top-level directory distribution, largest-file hotspots, and an **approachability score (0-100 + A–F grade)** with human-readable drivers. Prints a human-readable report by default, supports `--json` for scripting, and offers a CI gate via `--check`/`--min-score` (exits non-zero when approachability is below the threshold). Reuses the same `computeCodebaseMetrics` engine that powers `METRICS.md` — mirroring the existing `bootcamp health` command.
1213
- Web demo file viewer now has **Copy** and **Download** buttons: copy a generated doc's contents to the clipboard (async Clipboard API with a `document.execCommand` fallback and a timeout guard) or download it as a file, directly from the preview modal.
1314
- `bootcamp completion <bash|zsh|fish>` command to print a shell completion script for tab-completing subcommands, their aliases, and option flags. The completion data is derived from the live CLI definition, so it can never drift from the actual command surface; pipe it to your shell's completion directory (or `source <(bootcamp completion bash)`).
1415
- `--quiet`/`-q` flag for the main `bootcamp <repo-url>` command: suppresses the banner, run header, detected-stack table, progress spinners, score summary, and file-tree listing, printing only the output directory path on stdout so the command composes cleanly in scripts and CI (`OUT=$(bootcamp <url> --quiet)`). Failures and warnings are still written to stderr. Mutually exclusive with `--verbose`.

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ Onboarding Risk: 18/100 (A) 🟢
145145
| GitHub stars | 29 |
146146
| Generated files | 14+ |
147147
| Test suite | 1,000+ tests |
148-
| Source files | 50 TypeScript modules |
149-
| Test files | 74 Vitest files |
148+
| Source files | 51 TypeScript modules |
149+
| Test files | 76 Vitest files |
150150
| Lines of code | 13,381 TypeScript LOC (src/) |
151151
| Languages supported | 10+ |
152152
| Generation time | < 60 seconds |
@@ -492,6 +492,22 @@ bootcamp health ./my-repo --json
492492
bootcamp health ./my-repo --check --min-score 80
493493
```
494494

495+
### Codebase Metrics
496+
497+
```bash
498+
# Report languages, size, hotspots, and an approachability score
499+
bootcamp metrics https://github.com/owner/repo
500+
501+
# Works on local paths too
502+
bootcamp metrics ./my-repo
503+
504+
# Machine-readable output
505+
bootcamp metrics ./my-repo --json
506+
507+
# CI gate: exit non-zero when approachability is below the minimum (default 70)
508+
bootcamp metrics ./my-repo --check --min-score 75
509+
```
510+
495511
### Auto-Create GitHub Issues
496512

497513
```bash
@@ -583,6 +599,7 @@ npm install -g @mermaid-js/mermaid-cli
583599
| `bootcamp web` | Start local web demo server |
584600
| `bootcamp docs <url>` | Analyze documentation drift (`--check`, `--fix`) |
585601
| `bootcamp health <url>` | Score onboarding-readiness (`--json`, `--check`, `--min-score`) |
602+
| `bootcamp metrics <url>` | Report codebase metrics & approachability (`--json`, `--check`, `--min-score`) |
586603
| `bootcamp init` | Scaffold a `.bootcamprc.json` config (`--force`, `--print`, `--style`) |
587604
| `bootcamp styles` | List the built-in style packs and the sections each enables (`--json`) |
588605
| `bootcamp doctor` | Diagnose your environment (`--json`) |

src/cli.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { runDoctor } from "./commands/doctor-command.js";
3131
import { runHealthCommand } from "./commands/health-command.js";
3232
import { runInitCommand } from "./commands/init-command.js";
3333
import { runMainCommand } from "./commands/main-command.js";
34+
import { runMetricsCommand } from "./commands/metrics-command.js";
3435
import { runStylesCommand } from "./commands/styles-command.js";
3536
import { STYLE_PACK_NAMES } from "./plugins.js";
3637
import { resolveOutputFormat } from "./services/config-resolution.js";
@@ -129,6 +130,17 @@ interface HealthActionOptions {
129130
verbose?: boolean;
130131
}
131132

133+
interface MetricsActionOptions {
134+
[key: string]: unknown;
135+
branch?: string;
136+
check?: boolean;
137+
minScore?: string;
138+
json?: boolean;
139+
maxFiles?: string;
140+
keepTemp?: boolean;
141+
verbose?: boolean;
142+
}
143+
132144
interface InitActionOptions {
133145
[key: string]: unknown;
134146
force?: boolean;
@@ -376,6 +388,34 @@ program
376388
});
377389
});
378390

391+
program
392+
.command("metrics <repo-url>")
393+
.description("Report deterministic codebase metrics: languages, size, hotspots, and an approachability score (supports local paths)")
394+
.option("-b, --branch <branch>", "Branch to analyze", "")
395+
.option("--check", "Exit with code 1 if the approachability score is below --min-score (for CI)")
396+
.option("--min-score <score>", "Minimum passing approachability score for --check (0-100)", "70")
397+
.option("--json", "Output the metrics report as JSON for machine consumption")
398+
.option("-m, --max-files <number>", "Maximum files to scan")
399+
.option("--keep-temp", "Keep temporary clone directory")
400+
.option("-v, --verbose", "Show detailed output")
401+
.action(async (repoUrl: string, rawOpts) => {
402+
const opts = getActionOptions<MetricsActionOptions>(rawOpts as Command | MetricsActionOptions);
403+
// `-b/--branch` and `-m/--max-files` collide with the root command's
404+
// options, which can capture them before the subcommand does. Fall back to
405+
// reading the raw argv (same approach as `health` and `diff --output`).
406+
const branch = opts.branch || getCliFlagValue(["--branch", "-b"]) || "";
407+
const maxFiles = opts.maxFiles || getCliFlagValue(["--max-files", "-m"]) || "500";
408+
await runMetricsCommand(repoUrl, {
409+
branch,
410+
check: opts.check || false,
411+
minScore: parseInt(opts.minScore || "70", 10),
412+
json: opts.json || false,
413+
maxFiles: parseInt(maxFiles, 10),
414+
keepTemp: opts.keepTemp || false,
415+
verbose: opts.verbose || false,
416+
});
417+
});
418+
379419
program
380420
.command("init")
381421
.description("Scaffold a .bootcamprc.json config file in the current directory")

src/commands/metrics-command.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import chalk from "chalk";
2+
3+
import {
4+
computeCodebaseMetrics,
5+
formatBytes,
6+
type CodebaseMetrics,
7+
} from "../metrics.js";
8+
import { resolveRepo, type RepoSource } from "../repo-resolver.js";
9+
import { scanRepositoryFiles } from "../services/clone-service.js";
10+
11+
/** Options accepted by the `bootcamp metrics` command. */
12+
export interface MetricsCommandOptions {
13+
branch?: string;
14+
/** Emit the report as JSON for machine consumption. */
15+
json?: boolean;
16+
/** Exit non-zero when the approachability score is below `minScore` (CI gate). */
17+
check?: boolean;
18+
/** Minimum passing approachability score for `--check` (0-100). Defaults to 70. */
19+
minScore?: number;
20+
/** Maximum files to scan. Defaults to 500. */
21+
maxFiles?: number;
22+
/** Keep the temporary clone (remote repos only). */
23+
keepTemp?: boolean;
24+
verbose?: boolean;
25+
}
26+
27+
function scoreColor(score: number): typeof chalk.green {
28+
if (score >= 80) return chalk.green;
29+
if (score >= 60) return chalk.yellow;
30+
return chalk.red;
31+
}
32+
33+
/** Render a compact horizontal bar for a 0-100 percentage. */
34+
function bar(percentage: number, width = 20): string {
35+
const filled = Math.round((Math.min(100, Math.max(0, percentage)) / 100) * width);
36+
return "█".repeat(filled) + "░".repeat(width - filled);
37+
}
38+
39+
function printReport(metrics: CodebaseMetrics, repoName: string): void {
40+
const appr = metrics.approachability;
41+
const emoji = appr.score >= 80 ? "🟢" : appr.score >= 60 ? "🟡" : "🔴";
42+
const color = scoreColor(appr.score);
43+
44+
console.log(chalk.bold("\n📊 Codebase Metrics"));
45+
console.log(chalk.dim(`Repository: ${repoName}`));
46+
console.log(
47+
chalk.dim(
48+
`${metrics.totalFiles} files · ${formatBytes(metrics.totalBytes)} · size class: ${metrics.sizeClass}\n`
49+
)
50+
);
51+
52+
console.log(`${emoji} ` + chalk.bold("Approachability ") + color.bold(`${appr.score}/100 (Grade: ${appr.grade})`));
53+
if (appr.factors.length > 0) {
54+
for (const factor of appr.factors) {
55+
console.log(chalk.dim(` • ${factor}`));
56+
}
57+
}
58+
console.log();
59+
60+
console.log(chalk.bold("Composition"));
61+
console.log(
62+
chalk.dim(" ") +
63+
chalk.cyan(`${metrics.sourceFiles} source`) +
64+
chalk.dim(" · ") +
65+
`${metrics.testFiles} test` +
66+
chalk.dim(" · ") +
67+
`${metrics.docFiles} docs` +
68+
chalk.dim(" · ") +
69+
`${metrics.configFiles} config` +
70+
chalk.dim(" · ") +
71+
`${metrics.otherFiles} other`
72+
);
73+
console.log(
74+
chalk.dim(
75+
` avg file ${formatBytes(metrics.averageFileBytes)} · median ${formatBytes(metrics.medianFileBytes)} · test:source ${metrics.testToSourceRatio.toFixed(2)}`
76+
)
77+
);
78+
console.log();
79+
80+
if (metrics.languages.length > 0) {
81+
console.log(chalk.bold("Languages"));
82+
for (const lang of metrics.languages) {
83+
const label = lang.language.padEnd(14);
84+
console.log(
85+
` ${chalk.cyan(label)} ${chalk.dim(bar(lang.percentage))} ${lang.percentage.toFixed(1)}% ` +
86+
chalk.dim(`(${lang.files} file${lang.files === 1 ? "" : "s"})`)
87+
);
88+
}
89+
console.log();
90+
}
91+
92+
if (metrics.directories.length > 0) {
93+
console.log(chalk.bold("Top-level distribution"));
94+
for (const dir of metrics.directories) {
95+
const label = dir.path.padEnd(20);
96+
console.log(
97+
` ${chalk.cyan(label)} ${dir.percentage.toFixed(1)}% ` +
98+
chalk.dim(`(${dir.files} file${dir.files === 1 ? "" : "s"}, ${formatBytes(dir.bytes)})`)
99+
);
100+
}
101+
console.log();
102+
}
103+
104+
if (metrics.hotspots.length > 0) {
105+
console.log(chalk.bold("Largest files") + chalk.dim(" (review hotspots)"));
106+
for (const hotspot of metrics.hotspots) {
107+
console.log(` ${chalk.dim(formatBytes(hotspot.bytes).padStart(9))} ` + chalk.cyan(hotspot.path));
108+
}
109+
console.log();
110+
}
111+
}
112+
113+
/**
114+
* Run the standalone `bootcamp metrics` command: clone/resolve the target repo,
115+
* scan it, compute deterministic codebase metrics, and report them (human or
116+
* JSON). With `--check`, exits non-zero when the approachability score is below
117+
* `--min-score`. Reuses the same `computeCodebaseMetrics` engine that powers
118+
* `METRICS.md`.
119+
*/
120+
export async function runMetricsCommand(repoUrl: string, opts: MetricsCommandOptions): Promise<void> {
121+
const minScore = typeof opts.minScore === "number" && Number.isFinite(opts.minScore) ? opts.minScore : 70;
122+
123+
let repoSource: RepoSource;
124+
try {
125+
repoSource = await resolveRepo(repoUrl, process.cwd(), opts.branch || undefined);
126+
} catch (error: unknown) {
127+
console.error(
128+
chalk.red(`Failed to resolve repository: ${error instanceof Error ? error.message : String(error)}`)
129+
);
130+
process.exit(1);
131+
return;
132+
}
133+
134+
let exitCode = 0;
135+
try {
136+
const scan = await scanRepositoryFiles(repoSource.path, opts.maxFiles ?? 500);
137+
const metrics = computeCodebaseMetrics(scan);
138+
139+
if (opts.json) {
140+
console.log(
141+
JSON.stringify(
142+
{
143+
repo: repoSource.repoInfo.fullName,
144+
filesScanned: scan.files.length,
145+
...metrics,
146+
},
147+
null,
148+
2
149+
)
150+
);
151+
} else {
152+
printReport(metrics, repoSource.repoInfo.fullName);
153+
}
154+
155+
if (opts.check && metrics.approachability.score < minScore) {
156+
if (!opts.json) {
157+
console.error(
158+
chalk.red(
159+
`❌ Approachability ${metrics.approachability.score}/100 is below the required minimum of ${minScore}.`
160+
)
161+
);
162+
}
163+
exitCode = 1;
164+
}
165+
} catch (error: unknown) {
166+
console.error(
167+
chalk.red(`Metrics analysis failed: ${error instanceof Error ? error.message : String(error)}`)
168+
);
169+
exitCode = 1;
170+
} finally {
171+
if (opts.keepTemp && !repoSource.isLocal) {
172+
console.log(chalk.gray(`Temporary clone kept at: ${repoSource.path}`));
173+
} else {
174+
await repoSource.cleanup();
175+
}
176+
}
177+
178+
if (exitCode !== 0) {
179+
process.exit(exitCode);
180+
}
181+
}

0 commit comments

Comments
 (0)