Skip to content

Commit c98497f

Browse files
feat: add bootcamp health command for onboarding-readiness scoring (#48)
A standalone, deterministic CLI command that scores any repository's onboarding-readiness (documentation, community, quality, automation) without invoking the LLM. Reuses the computeRepoHealth engine behind HEALTH.md. - Human-readable report by default; --json for machine consumption - CI gate via --check / --min-score (exits non-zero below threshold) - Works on local paths and remote URLs via resolveRepo - Unit tests (mocked deps) + real-CLI e2e tests against fixture repos - README + CHANGELOG updates Co-authored-by: Arthur742Ramos <223556219+Copilot@users.noreply.github.com>
1 parent ec4f453 commit c98497f

7 files changed

Lines changed: 448 additions & 0 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 health <repo-url>` command: a standalone, deterministic onboarding-readiness score for any repository (local path or remote URL) without invoking the LLM. Prints a human-readable report by default, supports `--json` for scripting, and offers a CI gate via `--check`/`--min-score` (exits non-zero when the score falls below the threshold). Reuses the same `computeRepoHealth` engine that powers `HEALTH.md`.
1213
- `HEALTH.md` repo-health & onboarding-readiness report (flagship): a deterministic, AI-free evaluation of the signals that make a project approachable to new contributors — documentation (README, license, contributing, changelog), community files (code of conduct, security policy, issue/PR templates, CODEOWNERS), quality tooling (tests, linter, formatter, EditorConfig, .gitignore), and automation (CI, dependency bots, git hooks). Produces a weighted **0-100 score + A–F grade** with prioritized, actionable recommendations. Gated by the new `showHealth` style-pack section (on for all packs except `minimal`) and surfaced in the run summary.
1314
- `./health` package subpath export, plus public re-exports from `src/api.ts` (`computeRepoHealth`, `generateHealthDocs`, `getHealthGrade`, and related types).
1415
- `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.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,22 @@ bootcamp doctor
463463
bootcamp doctor --json
464464
```
465465

466+
### Repo Health Check
467+
468+
```bash
469+
# Score a repo's onboarding-readiness (docs, community, quality, automation)
470+
bootcamp health https://github.com/owner/repo
471+
472+
# Works on local paths too
473+
bootcamp health ./my-repo
474+
475+
# Machine-readable output
476+
bootcamp health ./my-repo --json
477+
478+
# CI gate: exit non-zero when the score is below the minimum (default 70)
479+
bootcamp health ./my-repo --check --min-score 80
480+
```
481+
466482
### Auto-Create GitHub Issues
467483

468484
```bash
@@ -550,6 +566,7 @@ npm install -g @mermaid-js/mermaid-cli
550566
| `bootcamp diff <owner/repo#pr>` | Generate onboarding diff for a PR |
551567
| `bootcamp web` | Start local web demo server |
552568
| `bootcamp docs <url>` | Analyze documentation drift (`--check`, `--fix`) |
569+
| `bootcamp health <url>` | Score onboarding-readiness (`--json`, `--check`, `--min-score`) |
553570
| `bootcamp doctor` | Diagnose your environment (`--json`) |
554571
| `bootcamp cache list\|prune\|clear` | Manage the analysis cache |
555572

src/cli.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { runCacheList } from "./commands/cache-list.js";
2727
import { runPullRequestDiff } from "./commands/diff-command.js";
2828
import { runDocsCommand } from "./commands/docs-command.js";
2929
import { runDoctor } from "./commands/doctor-command.js";
30+
import { runHealthCommand } from "./commands/health-command.js";
3031
import { runMainCommand } from "./commands/main-command.js";
3132
import { STYLE_PACK_NAMES } from "./plugins.js";
3233
import { resolveOutputFormat } from "./services/config-resolution.js";
@@ -107,6 +108,17 @@ interface DoctorActionOptions {
107108
json?: boolean;
108109
}
109110

111+
interface HealthActionOptions {
112+
[key: string]: unknown;
113+
branch?: string;
114+
check?: boolean;
115+
minScore?: string;
116+
json?: boolean;
117+
maxFiles?: string;
118+
keepTemp?: boolean;
119+
verbose?: boolean;
120+
}
121+
110122
interface CachePruneActionOptions {
111123
[key: string]: unknown;
112124
maxAge?: string;
@@ -317,6 +329,29 @@ program
317329
await runDocsCommand(repoUrl, opts);
318330
});
319331

332+
program
333+
.command("health <repo-url>")
334+
.description("Score a repository's onboarding-readiness: docs, community, quality, and automation (supports local paths)")
335+
.option("-b, --branch <branch>", "Branch to analyze", "")
336+
.option("--check", "Exit with code 1 if the health score is below --min-score (for CI)")
337+
.option("--min-score <score>", "Minimum passing score for --check (0-100)", "70")
338+
.option("--json", "Output the health report as JSON for machine consumption")
339+
.option("-m, --max-files <number>", "Maximum files to scan", "500")
340+
.option("--keep-temp", "Keep temporary clone directory")
341+
.option("-v, --verbose", "Show detailed output")
342+
.action(async (repoUrl: string, rawOpts) => {
343+
const opts = getActionOptions<HealthActionOptions>(rawOpts as Command | HealthActionOptions);
344+
await runHealthCommand(repoUrl, {
345+
branch: opts.branch,
346+
check: opts.check || false,
347+
minScore: parseInt(opts.minScore || "70", 10),
348+
json: opts.json || false,
349+
maxFiles: parseInt(opts.maxFiles || "500", 10),
350+
keepTemp: opts.keepTemp || false,
351+
verbose: opts.verbose || false,
352+
});
353+
});
354+
320355
program
321356
.command("doctor")
322357
.description("Diagnose your environment for running bootcamp (Node, git, gh, auth, cache)")

src/commands/health-command.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import chalk from "chalk";
2+
3+
import { computeRepoHealth, type HealthCategory, type HealthStatus, type RepoHealth } from "../health.js";
4+
import { resolveRepo, type RepoSource } from "../repo-resolver.js";
5+
import { scanRepositoryFiles } from "../services/clone-service.js";
6+
7+
/** Options accepted by the `bootcamp health` command. */
8+
export interface HealthCommandOptions {
9+
branch?: string;
10+
/** Emit the report as JSON for machine consumption. */
11+
json?: boolean;
12+
/** Exit non-zero when the score is below `minScore` (CI gate). */
13+
check?: boolean;
14+
/** Minimum passing score for `--check` (0-100). Defaults to 70. */
15+
minScore?: number;
16+
/** Maximum files to scan. Defaults to 500. */
17+
maxFiles?: number;
18+
/** Keep the temporary clone (remote repos only). */
19+
keepTemp?: boolean;
20+
verbose?: boolean;
21+
}
22+
23+
const STATUS_ICON: Record<HealthStatus, string> = {
24+
pass: "✅",
25+
warn: "⚠️",
26+
fail: "❌",
27+
};
28+
29+
const CATEGORY_ORDER: HealthCategory[] = ["Documentation", "Community", "Quality", "Automation"];
30+
31+
function scoreColor(score: number): typeof chalk.green {
32+
if (score >= 80) return chalk.green;
33+
if (score >= 60) return chalk.yellow;
34+
return chalk.red;
35+
}
36+
37+
function printReport(health: RepoHealth, repoName: string): void {
38+
const emoji = health.score >= 80 ? "🟢" : health.score >= 60 ? "🟡" : "🔴";
39+
const color = scoreColor(health.score);
40+
41+
console.log(chalk.bold("\n🩺 Repo Health"));
42+
console.log(chalk.dim(`Repository: ${repoName}\n`));
43+
44+
console.log(`${emoji} ` + color.bold(`${health.score}/100 (Grade: ${health.grade})`));
45+
console.log(
46+
chalk.dim(
47+
`${health.passCount} passed · ${health.warnCount} warning${health.warnCount === 1 ? "" : "s"} · ${health.failCount} missing\n`
48+
)
49+
);
50+
51+
for (const category of CATEGORY_ORDER) {
52+
const checks = health.checks.filter((check) => check.category === category);
53+
if (checks.length === 0) continue;
54+
console.log(chalk.bold(category));
55+
for (const check of checks) {
56+
console.log(` ${STATUS_ICON[check.status]} ` + chalk.cyan(check.label) + chalk.dim(` — ${check.detail}`));
57+
}
58+
console.log();
59+
}
60+
61+
if (health.recommendations.length > 0) {
62+
console.log(chalk.bold("Recommendations") + chalk.dim(" (highest impact first)"));
63+
health.recommendations.forEach((recommendation, index) => {
64+
console.log(chalk.dim(` ${index + 1}. `) + recommendation);
65+
});
66+
console.log();
67+
} else {
68+
console.log(chalk.green("🎉 No gaps detected — this repository covers the onboarding-readiness checklist.\n"));
69+
}
70+
}
71+
72+
/**
73+
* Run the standalone `bootcamp health` command: clone/resolve the target repo,
74+
* scan it, compute the deterministic onboarding-readiness score, and report it
75+
* (human-readable or JSON). With `--check`, exits non-zero below `--min-score`.
76+
*/
77+
export async function runHealthCommand(repoUrl: string, opts: HealthCommandOptions): Promise<void> {
78+
const minScore = typeof opts.minScore === "number" && Number.isFinite(opts.minScore) ? opts.minScore : 70;
79+
80+
let repoSource: RepoSource;
81+
try {
82+
repoSource = await resolveRepo(repoUrl, process.cwd(), opts.branch || undefined);
83+
} catch (error: unknown) {
84+
console.error(
85+
chalk.red(`Failed to resolve repository: ${error instanceof Error ? error.message : String(error)}`)
86+
);
87+
process.exit(1);
88+
return;
89+
}
90+
91+
let exitCode = 0;
92+
try {
93+
const scan = await scanRepositoryFiles(repoSource.path, opts.maxFiles ?? 500);
94+
const health = computeRepoHealth(scan);
95+
96+
if (opts.json) {
97+
console.log(
98+
JSON.stringify(
99+
{
100+
repo: repoSource.repoInfo.fullName,
101+
score: health.score,
102+
grade: health.grade,
103+
passCount: health.passCount,
104+
warnCount: health.warnCount,
105+
failCount: health.failCount,
106+
earnedWeight: health.earnedWeight,
107+
totalWeight: health.totalWeight,
108+
checks: health.checks,
109+
recommendations: health.recommendations,
110+
},
111+
null,
112+
2
113+
)
114+
);
115+
} else {
116+
printReport(health, repoSource.repoInfo.fullName);
117+
}
118+
119+
if (opts.check && health.score < minScore) {
120+
if (!opts.json) {
121+
console.error(
122+
chalk.red(`❌ Repo health ${health.score}/100 is below the required minimum of ${minScore}.`)
123+
);
124+
}
125+
exitCode = 1;
126+
}
127+
} catch (error: unknown) {
128+
console.error(
129+
chalk.red(`Health analysis failed: ${error instanceof Error ? error.message : String(error)}`)
130+
);
131+
exitCode = 1;
132+
} finally {
133+
if (opts.keepTemp && !repoSource.isLocal) {
134+
console.log(chalk.gray(`Temporary clone kept at: ${repoSource.path}`));
135+
} else {
136+
await repoSource.cleanup();
137+
}
138+
}
139+
140+
if (exitCode !== 0) {
141+
process.exit(exitCode);
142+
}
143+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { execFileSync } from "child_process";
2+
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
5+
6+
import { afterEach, describe, expect, it } from "vitest";
7+
8+
import { runCli } from "./helpers.js";
9+
10+
async function createRepo(
11+
baseDir: string,
12+
files: Record<string, string>
13+
): Promise<string> {
14+
const repoDir = join(baseDir, "fixture-health-repo");
15+
await mkdir(repoDir, { recursive: true });
16+
17+
for (const [relativePath, content] of Object.entries(files)) {
18+
const fullPath = join(repoDir, relativePath);
19+
await mkdir(join(fullPath, ".."), { recursive: true });
20+
await writeFile(fullPath, content, "utf-8");
21+
}
22+
23+
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
24+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir, stdio: "ignore" });
25+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoDir, stdio: "ignore" });
26+
execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "ignore" });
27+
execFileSync("git", ["commit", "-m", "init", "--no-gpg-sign"], { cwd: repoDir, stdio: "ignore" });
28+
29+
return repoDir;
30+
}
31+
32+
const HEALTHY_FILES: Record<string, string> = {
33+
"package.json": JSON.stringify({ name: "fixture-health-repo", version: "1.0.0" }, null, 2),
34+
"README.md": `# Fixture Health Repo\n\n${"Detailed onboarding documentation. ".repeat(60)}`,
35+
LICENSE: "MIT License\n",
36+
"CONTRIBUTING.md": "# Contributing\n\nRun the tests before opening a PR.\n",
37+
"CHANGELOG.md": "# Changelog\n",
38+
"CODE_OF_CONDUCT.md": "# Code of Conduct\n",
39+
"SECURITY.md": "# Security Policy\n",
40+
".github/ISSUE_TEMPLATE/bug.yml": "name: Bug\n",
41+
".github/PULL_REQUEST_TEMPLATE.md": "## Description\n",
42+
".github/CODEOWNERS": "* @owner\n",
43+
".github/workflows/ci.yml": "name: CI\non: [push]\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - run: npm test\n",
44+
".github/dependabot.yml": "version: 2\n",
45+
".eslintrc.json": "{}\n",
46+
".prettierrc": "{}\n",
47+
".editorconfig": "root = true\n",
48+
".gitignore": "node_modules\n",
49+
".husky/pre-commit": "npm test\n",
50+
"src/index.ts": "export const x = 1;\n",
51+
"test/index.test.ts": "import '../src/index';\n",
52+
};
53+
54+
const BARE_FILES: Record<string, string> = {
55+
"src/index.ts": "export const x = 1;\n",
56+
"src/app.ts": "export const y = 2;\n",
57+
};
58+
59+
describe("health command", () => {
60+
const tempDirs: string[] = [];
61+
62+
afterEach(async () => {
63+
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
64+
tempDirs.length = 0;
65+
});
66+
67+
it("prints a human-readable health report for a local repo", async () => {
68+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-health-e2e-"));
69+
tempDirs.push(tempDir);
70+
const repoPath = await createRepo(tempDir, HEALTHY_FILES);
71+
72+
const result = await runCli(["health", repoPath]);
73+
expect(result.exitCode).toBe(0);
74+
expect(result.stdout).toContain("Repo Health");
75+
expect(result.stdout).toContain("/100");
76+
expect(result.stdout).toContain("Documentation");
77+
expect(result.stdout).toContain("Automation");
78+
}, 60_000);
79+
80+
it("emits machine-readable JSON with --json", async () => {
81+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-health-e2e-"));
82+
tempDirs.push(tempDir);
83+
const repoPath = await createRepo(tempDir, HEALTHY_FILES);
84+
85+
const result = await runCli(["health", repoPath, "--json"]);
86+
expect(result.exitCode).toBe(0);
87+
88+
const parsed = JSON.parse(result.stdout);
89+
expect(typeof parsed.score).toBe("number");
90+
expect(parsed.score).toBeGreaterThanOrEqual(90);
91+
expect(parsed.grade).toBe("A");
92+
expect(Array.isArray(parsed.checks)).toBe(true);
93+
expect(parsed.checks.length).toBeGreaterThan(0);
94+
}, 60_000);
95+
96+
it("fails the --check gate for a bare repo and passes it with a low minimum", async () => {
97+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-health-e2e-"));
98+
tempDirs.push(tempDir);
99+
const repoPath = await createRepo(tempDir, BARE_FILES);
100+
101+
const failing = await runCli(["health", repoPath, "--check", "--min-score", "70"]);
102+
expect(failing.exitCode).toBe(1);
103+
expect(`${failing.stdout}\n${failing.stderr}`).toContain("below the required minimum");
104+
105+
const passing = await runCli(["health", repoPath, "--check", "--min-score", "0"]);
106+
expect(passing.exitCode).toBe(0);
107+
}, 60_000);
108+
});

0 commit comments

Comments
 (0)