Skip to content

Commit 9026a24

Browse files
feat: codebase metrics (METRICS.md) and bootcamp doctor command (#46)
* feat: add codebase metrics (METRICS.md) and bootcamp doctor command Add two deterministic features following the existing analyzer pattern: - Codebase Metrics & Hotspots: new src/metrics.ts computes language breakdown, file/dir hotspots, test:source ratio, size class, and a 0-100 approachability score (grade A-F). Emits METRICS.md, gated by a new showMetrics style-pack section (on for all packs except minimal). Threaded through analysis orchestration and the main command summary. - Environment Doctor: new 'bootcamp doctor' command (src/doctor.ts + command) checks Node >=20, git, gh + auth, Copilot token, mermaid-cli, and cache health. Supports --json and exits non-zero on required failures. Pure evaluateDoctor + injectable deps for testability. Exports both modules from api.ts with ./metrics and ./doctor subpaths. Adds 33 tests (15 metrics, 18 doctor); updates README + CHANGELOG. Full suite: 1018 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: classify .env files as config and surface unreadable cache in doctor Address review feedback on PR #46: - metrics: isConfigFile now classifies dotfile env files (.env and .env.*) as config. getExtension('.env') returns '' (leading dot at index 0) and the basename wasn't recognized, so these were counted as 'other', skewing metrics on repos where env files are common. - doctor: gatherEnvironment now probes the cache directory directly with readdir. listCacheEntries() deliberately swallows readdir errors and returns [], so the surrounding try/catch could never observe a real read failure (e.g. EACCES); the probe re-throws non-ENOENT errors so the existing cacheError warning path actually fires. ENOENT (cache not yet created) stays a clean zero-entry result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b9659b2 commit 9026a24

14 files changed

Lines changed: 1468 additions & 2 deletions

CHANGELOG.md

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

1010
### Added
1111

12+
- `METRICS.md` codebase metrics & hotspots report (flagship): a deterministic, AI-free analysis of language breakdown, source/test/doc/config counts, largest-file hotspots, top-level directory distribution, test-to-source ratio, size classification, and an **Approachability score (0-100 + A–F grade)** with human-readable drivers. Gated by the new `showMetrics` style-pack section (on for all packs except `minimal`) and surfaced in the run summary.
13+
- `bootcamp doctor` command to diagnose the local environment before a run: checks Node.js (>= 20, required), git (required), GitHub CLI + authentication, a Copilot/GitHub token env var, optional `mermaid-cli`, and analysis-cache health. Supports `--json` for scripting and exits non-zero when a required check fails (CI-friendly).
14+
- `./metrics` and `./doctor` package subpath exports, plus public re-exports from `src/api.ts` (`computeCodebaseMetrics`, `generateMetricsDocs`, `evaluateDoctor`, `gatherEnvironment`, and related types).
1215
- `bootcamp cache list` (alias `ls`) subcommand to inspect cached analysis entries, with a human-readable table and `--json` output for scripting. Surfaces repository, phase, SHA, age, size, model, and style, and reports `(legacy)`/`(malformed)` files so users can see disk usage from stray cache files instead of silently hiding them.
1316
- `bootcamp diff <owner/repo#pr>` command for onboarding-focused PR diffing
1417
- Developer workflow scripts: `format`, `format:check`, `typecheck`, and `dev:web`

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Onboarding Risk: 18/100 (A) 🟢
108108
├── SECURITY.md → Security findings
109109
├── RADAR.md → Tech radar & risk score
110110
├── IMPACT.md → Change impact analysis
111+
├── METRICS.md → Codebase metrics & hotspots
111112
├── diagrams.mmd → Mermaid diagrams
112113
└── repo_facts.json → Structured data
113114
@@ -249,6 +250,8 @@ The Copilot SDK transforms what would be a simple template-filler into an intell
249250
- **Phase-level Cache Management** - Reuses deps/security/impact analysis phases and supports `bootcamp cache list|prune|clear` (with `--json` listing for scripts)
250251
- **Tech Radar** - Identify modern, stable, legacy, and risky technologies
251252
- **Change Impact Analysis** - Understand how file changes affect the codebase
253+
- **Codebase Metrics & Hotspots** - Deterministic `METRICS.md` with language breakdown, largest-file hotspots, test-to-source ratio, and an Approachability score (0-100 + grade)
254+
- **Environment Doctor** - Diagnose Node, git, GitHub CLI/auth, mermaid-cli, and cache health with `bootcamp doctor` (`--json` for CI)
252255
- **Version & PR Comparison** - Compare refs with `--compare` or analyze pull requests with `bootcamp diff`
253256
- **Auto-Issue Creator** - Generate GitHub issues from starter tasks
254257
- **Web Demo Server** - Beautiful browser UI for analyzing repositories
@@ -348,6 +351,7 @@ Request → Options Merge → Hooks (before) → Fetch → Retry? → Hooks (aft
348351
| `SECURITY.md` | Security patterns and findings |
349352
| `RADAR.md` | Tech radar and onboarding risk score |
350353
| `IMPACT.md` | Change impact analysis for key files |
354+
| `METRICS.md` | Codebase metrics, hotspots & approachability score |
351355
| `DIFF.md` | Version comparison (with `--compare`) |
352356
| `diagrams.mmd` | Mermaid diagram sources |
353357
| `repo_facts.json` | Structured data for automation |
@@ -446,6 +450,16 @@ bootcamp https://github.com/owner/repo --watch --watch-interval 60
446450
bootcamp https://github.com/owner/repo --watch --watch-force
447451
```
448452

453+
### Environment Doctor
454+
455+
```bash
456+
# Check Node, git, GitHub CLI/auth, mermaid-cli, and cache health
457+
bootcamp doctor
458+
459+
# Machine-readable output (exits non-zero if a required check fails)
460+
bootcamp doctor --json
461+
```
462+
449463
### Auto-Create GitHub Issues
450464

451465
```bash
@@ -532,6 +546,9 @@ npm install -g @mermaid-js/mermaid-cli
532546
| `bootcamp ask <url>` | Interactive Q&A without full generation |
533547
| `bootcamp diff <owner/repo#pr>` | Generate onboarding diff for a PR |
534548
| `bootcamp web` | Start local web demo server |
549+
| `bootcamp docs <url>` | Analyze documentation drift (`--check`, `--fix`) |
550+
| `bootcamp doctor` | Diagnose your environment (`--json`) |
551+
| `bootcamp cache list\|prune\|clear` | Manage the analysis cache |
535552

536553
## Programmatic API
537554

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@
5656
"import": "./dist/deps.js",
5757
"require": "./dist/cjs/deps.js"
5858
},
59+
"./metrics": {
60+
"types": "./dist/metrics.d.ts",
61+
"import": "./dist/metrics.js",
62+
"require": "./dist/cjs/metrics.js"
63+
},
64+
"./doctor": {
65+
"types": "./dist/doctor.d.ts",
66+
"import": "./dist/doctor.js",
67+
"require": "./dist/cjs/doctor.js"
68+
},
5969
"./diagrams": {
6070
"types": "./dist/diagrams.d.ts",
6171
"import": "./dist/diagrams.js",

src/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@ export {
66
type AnalysisStats,
77
} from "./agent.js";
88
export { runParallelAnalysis, type ParallelAnalysisResult } from "./analysis.js";
9+
export {
10+
evaluateDoctor,
11+
formatDoctorReport,
12+
gatherEnvironment,
13+
parseNodeMajor,
14+
MIN_NODE_MAJOR,
15+
TOKEN_ENV_VARS,
16+
type DoctorCheck,
17+
type DoctorReport,
18+
type EnvironmentSnapshot,
19+
type CheckStatus,
20+
type CheckSeverity,
21+
} from "./doctor.js";
22+
export {
23+
computeCodebaseMetrics,
24+
generateMetricsDocs,
25+
getApproachabilityGrade,
26+
formatBytes,
27+
type CodebaseMetrics,
28+
type LanguageMetric,
29+
type FileHotspot,
30+
type DirectoryMetric,
31+
type CodebaseSizeClass,
32+
type Approachability,
33+
} from "./metrics.js";
934
export {
1035
generateBootcamp,
1136
generateOnboarding,

src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { runAskCommand } from "./commands/ask-command.js";
2626
import { runCacheList } from "./commands/cache-list.js";
2727
import { runPullRequestDiff } from "./commands/diff-command.js";
2828
import { runDocsCommand } from "./commands/docs-command.js";
29+
import { runDoctor } from "./commands/doctor-command.js";
2930
import { runMainCommand } from "./commands/main-command.js";
3031
import { STYLE_PACK_NAMES } from "./plugins.js";
3132
import { resolveOutputFormat } from "./services/config-resolution.js";
@@ -101,6 +102,11 @@ interface DocsActionOptions {
101102
verbose?: boolean;
102103
}
103104

105+
interface DoctorActionOptions {
106+
[key: string]: unknown;
107+
json?: boolean;
108+
}
109+
104110
interface CachePruneActionOptions {
105111
[key: string]: unknown;
106112
maxAge?: string;
@@ -311,6 +317,15 @@ program
311317
await runDocsCommand(repoUrl, opts);
312318
});
313319

320+
program
321+
.command("doctor")
322+
.description("Diagnose your environment for running bootcamp (Node, git, gh, auth, cache)")
323+
.option("--json", "Output diagnostics as JSON for machine consumption")
324+
.action(async (rawOpts) => {
325+
const opts = getActionOptions<DoctorActionOptions>(rawOpts as Command | DoctorActionOptions);
326+
await runDoctor({ json: opts.json });
327+
});
328+
314329
const cacheCommand = program
315330
.command("cache")
316331
.description("Manage the analysis cache");

src/commands/doctor-command.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* `bootcamp doctor` command.
3+
*
4+
* Runs environment diagnostics and prints either a colorized human report or a
5+
* stable JSON payload (`--json`). Exits with code 1 when a required check
6+
* fails so it can gate CI. The core logic lives in `src/doctor.ts`; this module
7+
* handles presentation and process wiring, with injectable dependencies so it
8+
* can be unit-tested without spawning real processes.
9+
*/
10+
11+
import chalk from "chalk";
12+
13+
import {
14+
type DoctorCheck,
15+
type DoctorReport,
16+
type EnvironmentSnapshot,
17+
evaluateDoctor,
18+
formatDoctorReport,
19+
gatherEnvironment,
20+
} from "../doctor.js";
21+
22+
export interface DoctorCommandOptions {
23+
json?: boolean;
24+
}
25+
26+
export interface DoctorCommandDeps {
27+
gather?: () => Promise<EnvironmentSnapshot>;
28+
log?: (message: string) => void;
29+
exit?: (code: number) => void;
30+
}
31+
32+
interface DoctorJson {
33+
ok: boolean;
34+
hasWarnings: boolean;
35+
counts: DoctorReport["counts"];
36+
checks: DoctorCheck[];
37+
environment: EnvironmentSnapshot;
38+
}
39+
40+
/** Build the machine-readable payload for `--json`. */
41+
export function buildDoctorJson(report: DoctorReport, env: EnvironmentSnapshot): DoctorJson {
42+
return {
43+
ok: report.ok,
44+
hasWarnings: report.hasWarnings,
45+
counts: report.counts,
46+
checks: report.checks,
47+
environment: env,
48+
};
49+
}
50+
51+
const STATUS_COLOR: Record<DoctorCheck["status"], (text: string) => string> = {
52+
ok: chalk.green,
53+
warn: chalk.yellow,
54+
fail: chalk.red,
55+
info: chalk.gray,
56+
};
57+
58+
const STATUS_GLYPH: Record<DoctorCheck["status"], string> = {
59+
ok: "✓",
60+
warn: "!",
61+
fail: "✗",
62+
info: "·",
63+
};
64+
65+
/** Render a colorized human report for the terminal. */
66+
export function colorizeReport(report: DoctorReport): string {
67+
const lines: string[] = [];
68+
lines.push(chalk.cyan.bold("repo-bootcamp environment check"));
69+
lines.push("");
70+
for (const check of report.checks) {
71+
const color = STATUS_COLOR[check.status];
72+
lines.push(` ${color(STATUS_GLYPH[check.status])} ${chalk.white(check.label)}: ${chalk.dim(check.detail)}`);
73+
if (check.remedy && (check.status === "fail" || check.status === "warn")) {
74+
lines.push(` ${chalk.dim("→ " + check.remedy)}`);
75+
}
76+
}
77+
lines.push("");
78+
lines.push(
79+
chalk.dim(
80+
`Summary: ${report.counts.ok} ok, ${report.counts.warn} warning(s), ${report.counts.fail} failure(s)`
81+
)
82+
);
83+
lines.push(
84+
report.ok
85+
? chalk.green("All required checks passed — you're ready to run bootcamp.")
86+
: chalk.red("One or more required checks failed — see remedies above.")
87+
);
88+
return lines.join("\n");
89+
}
90+
91+
/**
92+
* Entry point used by the CLI. Returns the evaluated report (useful for tests);
93+
* triggers a non-zero exit when a required check fails.
94+
*/
95+
export async function runDoctor(
96+
options: DoctorCommandOptions = {},
97+
deps: DoctorCommandDeps = {}
98+
): Promise<DoctorReport> {
99+
const gather = deps.gather ?? gatherEnvironment;
100+
const log = deps.log ?? ((msg: string) => console.log(msg));
101+
const exit = deps.exit ?? ((code: number) => process.exit(code));
102+
103+
const env = await gather();
104+
const report = evaluateDoctor(env);
105+
106+
if (options.json) {
107+
log(JSON.stringify(buildDoctorJson(report, env), null, 2));
108+
} else {
109+
log(colorizeReport(report));
110+
}
111+
112+
if (!report.ok) {
113+
exit(1);
114+
}
115+
116+
return report;
117+
}
118+
119+
export { formatDoctorReport };

src/commands/main-command.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface GenerationResult {
3434
security: Awaited<ReturnType<typeof prepareOutputDocuments>>["security"];
3535
radar: Awaited<ReturnType<typeof prepareOutputDocuments>>["radar"];
3636
deps: Awaited<ReturnType<typeof prepareOutputDocuments>>["deps"];
37+
metrics: Awaited<ReturnType<typeof prepareOutputDocuments>>["metrics"];
3738
}
3839

3940
interface GenerateOutputsParams {
@@ -63,7 +64,7 @@ async function generateOutputs({
6364
progress,
6465
allowIssueCreation = true,
6566
}: GenerateOutputsParams): Promise<GenerationResult> {
66-
const { documents, facts: preparedFacts, security, radar, deps, outputTargets } = await prepareOutputDocuments({
67+
const { documents, facts: preparedFacts, security, radar, deps, metrics, outputTargets } = await prepareOutputDocuments({
6768
repoPath,
6869
repoInfo,
6970
scanResult,
@@ -91,6 +92,7 @@ async function generateOutputs({
9192
security,
9293
radar,
9394
deps,
95+
metrics,
9496
};
9597
}
9698

@@ -230,7 +232,7 @@ export async function runMainCommand(repoUrl: string, options: BootcampOptions):
230232
const generateStart = Date.now();
231233
progress.startPhase("generate", options.jsonOnly ? "JSON only" : "12+ files");
232234
try {
233-
const { documentCount, security, radar, deps } = await generateOutputs({
235+
const { documentCount, security, radar, deps, metrics } = await generateOutputs({
234236
repoPath,
235237
repoInfo,
236238
scanResult,
@@ -259,6 +261,18 @@ export async function runMainCommand(repoUrl: string, options: BootcampOptions):
259261
if (deps) {
260262
console.log(chalk.cyan("Dependencies: ") + chalk.white(`${deps.totalCount} total (${deps.runtime.length} runtime, ${deps.dev.length} dev)`));
261263
}
264+
265+
if (styleConfig.sections.showMetrics) {
266+
const appr = metrics.approachability;
267+
const apprColor = appr.score >= 80 ? chalk.green : appr.score >= 60 ? chalk.yellow : chalk.red;
268+
console.log(
269+
chalk.cyan("Codebase: ") +
270+
chalk.white(`${metrics.totalFiles} files, ${metrics.sourceFiles} source`) +
271+
chalk.dim(" · ") +
272+
chalk.cyan("approachability ") +
273+
apprColor(`${appr.score}/100 (${appr.grade})`)
274+
);
275+
}
262276
}
263277
} catch (error: unknown) {
264278
progress.fail(`Document generation failed: ${(error as Error).message}`);
@@ -319,6 +333,9 @@ export async function runMainCommand(repoUrl: string, options: BootcampOptions):
319333
if (styleConfig.sections.showImpact) {
320334
console.log(chalk.white(" ├── ") + chalk.cyan(formatName("IMPACT.md")) + chalk.dim(" → Change impact analysis"));
321335
}
336+
if (styleConfig.sections.showMetrics) {
337+
console.log(chalk.white(" ├── ") + chalk.cyan(formatName("METRICS.md")) + chalk.dim(" → Codebase metrics & hotspots"));
338+
}
322339
if (options.compare) {
323340
console.log(chalk.white(" ├── ") + chalk.cyan(formatName("DIFF.md")) + chalk.dim(" → Version comparison"));
324341
}

0 commit comments

Comments
 (0)