Skip to content

Commit 7b39222

Browse files
committed
feat: add report command
1 parent 64196fc commit 7b39222

7 files changed

Lines changed: 303 additions & 1 deletion

File tree

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerCheckCommand } from './commands/check';
88
import { registerDiffCommand } from './commands/diff';
99
import { registerGenerateCommand } from './commands/generate';
1010
import { registerInitCommand } from './commands/init';
11+
import { registerReportCommand } from './commands/report';
1112
import { registerScanCommand } from './commands/scan';
1213

1314
const __filename = fileURLToPath(import.meta.url);
@@ -26,6 +27,7 @@ registerGenerateCommand(program);
2627
registerCheckCommand(program);
2728
registerDiffCommand(program);
2829
registerInitCommand(program);
30+
registerReportCommand(program);
2931
registerScanCommand(program);
3032

3133
program.command('*', { hidden: true }).action(() => {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { DocCov } from '@doccov/sdk';
4+
import type { OpenPkg } from '@openpkg-ts/spec';
5+
import chalk from 'chalk';
6+
import type { Command } from 'commander';
7+
import ora from 'ora';
8+
import { computeStats, renderHtml, renderMarkdown } from '../reports';
9+
import { findEntryPoint, findPackageInMonorepo } from '../utils/package-utils';
10+
11+
type OutputFormat = 'markdown' | 'html' | 'json';
12+
13+
export function registerReportCommand(program: Command): void {
14+
program
15+
.command('report [entry]')
16+
.description('Generate a documentation coverage report')
17+
.option('--cwd <dir>', 'Working directory', process.cwd())
18+
.option('--package <name>', 'Target package name (for monorepos)')
19+
.option('--spec <file>', 'Use existing openpkg.json instead of analyzing')
20+
.option('--output <format>', 'Output format: markdown, html, json', 'markdown')
21+
.option('--out <file>', 'Write to file instead of stdout')
22+
.option('--limit <n>', 'Max exports to show in tables', '20')
23+
.action(async (entry, options) => {
24+
try {
25+
let spec: OpenPkg;
26+
27+
if (options.spec) {
28+
const specPath = path.resolve(options.cwd, options.spec);
29+
spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
30+
} else {
31+
let targetDir = options.cwd;
32+
let entryFile = entry as string | undefined;
33+
34+
if (options.package) {
35+
const packageDir = await findPackageInMonorepo(options.cwd, options.package);
36+
if (!packageDir) throw new Error(`Package "${options.package}" not found`);
37+
targetDir = packageDir;
38+
}
39+
40+
if (!entryFile) {
41+
entryFile = await findEntryPoint(targetDir, true);
42+
} else {
43+
entryFile = path.resolve(targetDir, entryFile);
44+
}
45+
46+
const spinner = ora('Analyzing...').start();
47+
const doccov = new DocCov({ resolveExternalTypes: true });
48+
const result = await doccov.analyzeFileWithDiagnostics(entryFile);
49+
spinner.succeed('Analysis complete');
50+
spec = result.spec;
51+
}
52+
53+
const stats = computeStats(spec);
54+
const format = options.output as OutputFormat;
55+
const limit = parseInt(options.limit, 10) || 20;
56+
57+
let output: string;
58+
if (format === 'json') {
59+
output = JSON.stringify(stats, null, 2);
60+
} else if (format === 'html') {
61+
output = renderHtml(stats, { limit });
62+
} else {
63+
output = renderMarkdown(stats, { limit });
64+
}
65+
66+
if (options.out) {
67+
const outPath = path.resolve(options.cwd, options.out);
68+
fs.writeFileSync(outPath, output);
69+
console.log(chalk.green(`Report written to ${outPath}`));
70+
} else {
71+
console.log(output);
72+
}
73+
} catch (err) {
74+
console.error(chalk.red('Error:'), err instanceof Error ? err.message : err);
75+
process.exitCode = 1;
76+
}
77+
});
78+
}

packages/cli/src/reports/html.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { renderMarkdown } from './markdown';
2+
import type { ReportStats } from './stats';
3+
4+
function escapeHtml(s: string): string {
5+
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6+
}
7+
8+
export function renderHtml(stats: ReportStats, options: { limit?: number } = {}): string {
9+
const md = renderMarkdown(stats, options);
10+
return `<!DOCTYPE html>
11+
<html lang="en">
12+
<head>
13+
<meta charset="UTF-8">
14+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
15+
<title>DocCov Report: ${escapeHtml(stats.packageName)}</title>
16+
<style>
17+
:root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; }
18+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 900px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
19+
h1, h2 { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
20+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
21+
th, td { border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; }
22+
th { background: #161b22; }
23+
code { background: #161b22; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
24+
a { color: var(--accent); }
25+
</style>
26+
</head>
27+
<body>
28+
<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(md)}</pre>
29+
</body>
30+
</html>`;
31+
}

packages/cli/src/reports/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { renderHtml } from './html';
2+
export { renderMarkdown } from './markdown';
3+
export { computeStats, type ReportStats, type SignalStats } from './stats';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { ReportStats } from './stats';
2+
3+
function bar(pct: number, width = 10): string {
4+
const filled = Math.round((pct / 100) * width);
5+
return '█'.repeat(filled) + '░'.repeat(width - filled);
6+
}
7+
8+
export function renderMarkdown(stats: ReportStats, options: { limit?: number } = {}): string {
9+
const limit = options.limit ?? 20;
10+
const lines: string[] = [];
11+
12+
lines.push(`# DocCov Report: ${stats.packageName}@${stats.version}`);
13+
lines.push('');
14+
lines.push(`**Coverage: ${stats.coverageScore}%** \`${bar(stats.coverageScore)}\``);
15+
lines.push('');
16+
lines.push('| Metric | Value |');
17+
lines.push('|--------|-------|');
18+
lines.push(`| Exports | ${stats.totalExports} |`);
19+
lines.push(`| Fully documented | ${stats.fullyDocumented} |`);
20+
lines.push(`| Partially documented | ${stats.partiallyDocumented} |`);
21+
lines.push(`| Undocumented | ${stats.undocumented} |`);
22+
lines.push(`| Drift issues | ${stats.driftCount} |`);
23+
24+
// Signal coverage
25+
lines.push('');
26+
lines.push('## Coverage by Signal');
27+
lines.push('');
28+
lines.push('| Signal | Coverage |');
29+
lines.push('|--------|----------|');
30+
for (const [sig, s] of Object.entries(stats.signalCoverage)) {
31+
lines.push(`| ${sig} | ${s.pct}% \`${bar(s.pct, 8)}\` |`);
32+
}
33+
34+
// By kind
35+
if (stats.byKind.length > 0) {
36+
lines.push('');
37+
lines.push('## Coverage by Kind');
38+
lines.push('');
39+
lines.push('| Kind | Count | Avg Score |');
40+
lines.push('|------|-------|-----------|');
41+
for (const k of stats.byKind) {
42+
lines.push(`| ${k.kind} | ${k.count} | ${k.avgScore}% |`);
43+
}
44+
}
45+
46+
// Lowest coverage exports
47+
const lowExports = stats.exports.filter((e) => e.score < 100).slice(0, limit);
48+
if (lowExports.length > 0) {
49+
lines.push('');
50+
lines.push('## Lowest Coverage Exports');
51+
lines.push('');
52+
lines.push('| Export | Kind | Score | Missing |');
53+
lines.push('|--------|------|-------|---------|');
54+
for (const e of lowExports) {
55+
lines.push(`| \`${e.name}\` | ${e.kind} | ${e.score}% | ${e.missing.join(', ') || '-'} |`);
56+
}
57+
const totalLow = stats.exports.filter((e) => e.score < 100).length;
58+
if (totalLow > limit) {
59+
lines.push(`| ... | | | ${totalLow - limit} more |`);
60+
}
61+
}
62+
63+
// Drift issues
64+
if (stats.driftIssues.length > 0) {
65+
lines.push('');
66+
lines.push('## Drift Issues');
67+
lines.push('');
68+
lines.push('| Export | Type | Issue |');
69+
lines.push('|--------|------|-------|');
70+
for (const d of stats.driftIssues.slice(0, limit)) {
71+
const hint = d.suggestion ? ` → ${d.suggestion}` : '';
72+
lines.push(`| \`${d.exportName}\` | ${d.type} | ${d.issue}${hint} |`);
73+
}
74+
}
75+
76+
lines.push('');
77+
lines.push('---');
78+
lines.push('*Generated by [DocCov](https://doccov.com)*');
79+
80+
return lines.join('\n');
81+
}

packages/cli/src/reports/stats.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { OpenPkg, SpecExportKind } from '@openpkg-ts/spec';
2+
3+
export type SignalStats = { covered: number; total: number; pct: number };
4+
5+
export type ReportStats = {
6+
packageName: string;
7+
version: string;
8+
coverageScore: number;
9+
totalExports: number;
10+
fullyDocumented: number;
11+
partiallyDocumented: number;
12+
undocumented: number;
13+
driftCount: number;
14+
signalCoverage: Record<'description' | 'params' | 'returns' | 'examples', SignalStats>;
15+
byKind: Array<{ kind: SpecExportKind; count: number; avgScore: number }>;
16+
exports: Array<{ name: string; kind: SpecExportKind; score: number; missing: string[] }>;
17+
driftIssues: Array<{ exportName: string; type: string; issue: string; suggestion?: string }>;
18+
};
19+
20+
export function computeStats(spec: OpenPkg): ReportStats {
21+
const exports = spec.exports ?? [];
22+
const signals = {
23+
description: { covered: 0, total: 0 },
24+
params: { covered: 0, total: 0 },
25+
returns: { covered: 0, total: 0 },
26+
examples: { covered: 0, total: 0 },
27+
};
28+
const kindMap = new Map<SpecExportKind, { count: number; totalScore: number }>();
29+
const driftIssues: ReportStats['driftIssues'] = [];
30+
let fullyDocumented = 0;
31+
let partiallyDocumented = 0;
32+
let undocumented = 0;
33+
34+
for (const exp of exports) {
35+
const score = exp.docs?.coverageScore ?? 0;
36+
const missing = exp.docs?.missing ?? [];
37+
38+
// Tally signals
39+
for (const sig of ['description', 'params', 'returns', 'examples'] as const) {
40+
signals[sig].total++;
41+
if (!missing.includes(sig)) signals[sig].covered++;
42+
}
43+
44+
// Tally by kind
45+
const kindEntry = kindMap.get(exp.kind) ?? { count: 0, totalScore: 0 };
46+
kindEntry.count++;
47+
kindEntry.totalScore += score;
48+
kindMap.set(exp.kind, kindEntry);
49+
50+
// Categorize
51+
if (score === 100) fullyDocumented++;
52+
else if (score > 0) partiallyDocumented++;
53+
else undocumented++;
54+
55+
// Collect drift
56+
for (const d of exp.docs?.drift ?? []) {
57+
driftIssues.push({
58+
exportName: exp.name,
59+
type: d.type,
60+
issue: d.issue,
61+
suggestion: d.suggestion,
62+
});
63+
}
64+
}
65+
66+
const signalCoverage = Object.fromEntries(
67+
Object.entries(signals).map(([k, v]) => [
68+
k,
69+
{ ...v, pct: v.total ? Math.round((v.covered / v.total) * 100) : 0 },
70+
]),
71+
) as ReportStats['signalCoverage'];
72+
73+
const byKind = Array.from(kindMap.entries())
74+
.map(([kind, { count, totalScore }]) => ({
75+
kind,
76+
count,
77+
avgScore: Math.round(totalScore / count),
78+
}))
79+
.sort((a, b) => b.count - a.count);
80+
81+
const sortedExports = exports
82+
.map((e) => ({
83+
name: e.name,
84+
kind: e.kind,
85+
score: e.docs?.coverageScore ?? 0,
86+
missing: e.docs?.missing ?? [],
87+
}))
88+
.sort((a, b) => a.score - b.score);
89+
90+
return {
91+
packageName: spec.meta.name ?? 'unknown',
92+
version: spec.meta.version ?? '0.0.0',
93+
coverageScore: spec.docs?.coverageScore ?? 0,
94+
totalExports: exports.length,
95+
fullyDocumented,
96+
partiallyDocumented,
97+
undocumented,
98+
driftCount: driftIssues.length,
99+
signalCoverage,
100+
byKind,
101+
exports: sortedExports,
102+
driftIssues,
103+
};
104+
}

packages/spec/schemas/v0.2.0/openpkg.schema.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@
8787
"deprecated-mismatch",
8888
"visibility-mismatch",
8989
"example-drift",
90-
"broken-link"
90+
"broken-link",
91+
"example-syntax-error",
92+
"async-mismatch",
93+
"property-type-drift"
9194
]
9295
},
9396
"target": {

0 commit comments

Comments
 (0)