@doccov/api 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import { Writable } from 'node:stream';
2
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
3
+ import { Sandbox } from '@vercel/sandbox';
4
+
5
+ export const config = {
6
+ runtime: 'nodejs',
7
+ maxDuration: 60, // Quick detection, 1 minute max
8
+ };
9
+
10
+ interface DetectRequestBody {
11
+ url: string;
12
+ ref?: string;
13
+ }
14
+
15
+ interface PackageInfo {
16
+ name: string;
17
+ path: string;
18
+ description?: string;
19
+ }
20
+
21
+ interface DetectResponse {
22
+ isMonorepo: boolean;
23
+ packageManager: 'npm' | 'pnpm' | 'bun' | 'yarn';
24
+ packages?: PackageInfo[];
25
+ defaultPackage?: string;
26
+ error?: string;
27
+ }
28
+
29
+ // Helper to capture stream output
30
+ function createCaptureStream(): { stream: Writable; getOutput: () => string } {
31
+ let output = '';
32
+ const stream = new Writable({
33
+ write(chunk, _encoding, callback) {
34
+ output += chunk.toString();
35
+ callback();
36
+ },
37
+ });
38
+ return { stream, getOutput: () => output };
39
+ }
40
+
41
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
42
+ // CORS
43
+ res.setHeader('Access-Control-Allow-Origin', '*');
44
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
45
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
46
+
47
+ if (req.method === 'OPTIONS') {
48
+ return res.status(200).end();
49
+ }
50
+
51
+ if (req.method !== 'POST') {
52
+ return res.status(405).json({ error: 'Method not allowed' });
53
+ }
54
+
55
+ const body = req.body as DetectRequestBody;
56
+
57
+ if (!body.url) {
58
+ return res.status(400).json({ error: 'url is required' });
59
+ }
60
+
61
+ try {
62
+ const result = await detectMonorepo(body.url, body.ref ?? 'main');
63
+ return res.status(200).json(result);
64
+ } catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ return res.status(500).json({
67
+ isMonorepo: false,
68
+ packageManager: 'npm',
69
+ error: message,
70
+ } as DetectResponse);
71
+ }
72
+ }
73
+
74
+ async function detectMonorepo(url: string, _ref: string): Promise<DetectResponse> {
75
+ const sandbox = await Sandbox.create({
76
+ source: {
77
+ url,
78
+ type: 'git',
79
+ },
80
+ resources: { vcpus: 2 },
81
+ timeout: 60 * 1000, // 1 minute
82
+ runtime: 'node22',
83
+ });
84
+
85
+ try {
86
+ // List root files
87
+ const lsCapture = createCaptureStream();
88
+ await sandbox.runCommand({
89
+ cmd: 'ls',
90
+ args: ['-1'],
91
+ stdout: lsCapture.stream,
92
+ });
93
+ const files = lsCapture.getOutput();
94
+
95
+ // Detect package manager
96
+ let packageManager: DetectResponse['packageManager'] = 'npm';
97
+ if (files.includes('pnpm-lock.yaml')) {
98
+ packageManager = 'pnpm';
99
+ } else if (files.includes('bun.lock') || files.includes('bun.lockb')) {
100
+ packageManager = 'bun';
101
+ } else if (files.includes('yarn.lock')) {
102
+ packageManager = 'yarn';
103
+ }
104
+
105
+ // Read root package.json
106
+ const pkgCapture = createCaptureStream();
107
+ await sandbox.runCommand({
108
+ cmd: 'cat',
109
+ args: ['package.json'],
110
+ stdout: pkgCapture.stream,
111
+ });
112
+
113
+ let rootPkg: { workspaces?: string[] | { packages?: string[] }; name?: string } = {};
114
+ try {
115
+ rootPkg = JSON.parse(pkgCapture.getOutput());
116
+ } catch {
117
+ // Not a valid package.json
118
+ }
119
+
120
+ // Check for workspaces (npm/yarn/bun) or pnpm-workspace.yaml
121
+ let workspacePatterns: string[] = [];
122
+
123
+ if (rootPkg.workspaces) {
124
+ if (Array.isArray(rootPkg.workspaces)) {
125
+ workspacePatterns = rootPkg.workspaces;
126
+ } else if (rootPkg.workspaces.packages) {
127
+ workspacePatterns = rootPkg.workspaces.packages;
128
+ }
129
+ }
130
+
131
+ // Check pnpm-workspace.yaml
132
+ if (files.includes('pnpm-workspace.yaml')) {
133
+ const wsCapture = createCaptureStream();
134
+ await sandbox.runCommand({
135
+ cmd: 'cat',
136
+ args: ['pnpm-workspace.yaml'],
137
+ stdout: wsCapture.stream,
138
+ });
139
+ const wsContent = wsCapture.getOutput();
140
+ // Simple YAML parsing for packages array
141
+ const packagesMatch = wsContent.match(/packages:\s*\n((?:\s+-\s*.+\n?)+)/);
142
+ if (packagesMatch) {
143
+ const lines = packagesMatch[1].split('\n');
144
+ for (const line of lines) {
145
+ const match = line.match(/^\s+-\s*['"]?([^'"]+)['"]?\s*$/);
146
+ if (match) {
147
+ workspacePatterns.push(match[1]);
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // Not a monorepo
154
+ if (workspacePatterns.length === 0) {
155
+ return {
156
+ isMonorepo: false,
157
+ packageManager,
158
+ };
159
+ }
160
+
161
+ // Find all packages
162
+ const packages: PackageInfo[] = [];
163
+
164
+ // Use find to locate package.json files in workspace dirs
165
+ const findCapture = createCaptureStream();
166
+ await sandbox.runCommand({
167
+ cmd: 'find',
168
+ args: ['.', '-name', 'package.json', '-maxdepth', '3', '-type', 'f'],
169
+ stdout: findCapture.stream,
170
+ });
171
+
172
+ const packagePaths = findCapture
173
+ .getOutput()
174
+ .trim()
175
+ .split('\n')
176
+ .filter((p) => p && p !== './package.json');
177
+
178
+ for (const pkgPath of packagePaths.slice(0, 30)) {
179
+ // Limit to 30 packages
180
+ const catCapture = createCaptureStream();
181
+ await sandbox.runCommand({
182
+ cmd: 'cat',
183
+ args: [pkgPath],
184
+ stdout: catCapture.stream,
185
+ });
186
+
187
+ try {
188
+ const pkg = JSON.parse(catCapture.getOutput()) as {
189
+ name?: string;
190
+ description?: string;
191
+ private?: boolean;
192
+ };
193
+ if (pkg.name && !pkg.private) {
194
+ packages.push({
195
+ name: pkg.name,
196
+ path: pkgPath.replace('./package.json', '.').replace('/package.json', ''),
197
+ description: pkg.description,
198
+ });
199
+ }
200
+ } catch {
201
+ // Skip invalid package.json
202
+ }
203
+ }
204
+
205
+ // Sort by name
206
+ packages.sort((a, b) => a.name.localeCompare(b.name));
207
+
208
+ return {
209
+ isMonorepo: true,
210
+ packageManager,
211
+ packages,
212
+ defaultPackage: packages[0]?.name,
213
+ };
214
+ } finally {
215
+ await sandbox.stop();
216
+ }
217
+ }
@@ -0,0 +1,470 @@
1
+ import { Writable } from 'node:stream';
2
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
3
+ import { Sandbox } from '@vercel/sandbox';
4
+ import {
5
+ SandboxFileSystem,
6
+ detectBuildInfo,
7
+ detectMonorepo,
8
+ detectPackageManager,
9
+ getInstallCommand,
10
+ getPrimaryBuildScript,
11
+ } from '@doccov/sdk';
12
+
13
+ export const config = {
14
+ runtime: 'nodejs',
15
+ maxDuration: 300,
16
+ };
17
+
18
+ interface JobEvent {
19
+ type: 'progress' | 'complete' | 'error';
20
+ stage?: string;
21
+ message?: string;
22
+ progress?: number;
23
+ result?: ScanResult;
24
+ availablePackages?: string[];
25
+ }
26
+
27
+ interface ScanResult {
28
+ owner: string;
29
+ repo: string;
30
+ ref: string;
31
+ packageName?: string;
32
+ coverage: number;
33
+ exportCount: number;
34
+ typeCount: number;
35
+ driftCount: number;
36
+ undocumented: string[];
37
+ drift: Array<{
38
+ export: string;
39
+ type: string;
40
+ issue: string;
41
+ }>;
42
+ }
43
+
44
+ // Helper to capture stream output
45
+ function createCaptureStream(): { stream: Writable; getOutput: () => string } {
46
+ let output = '';
47
+ const stream = new Writable({
48
+ write(chunk, _encoding, callback) {
49
+ output += chunk.toString();
50
+ callback();
51
+ },
52
+ });
53
+ return { stream, getOutput: () => output };
54
+ }
55
+
56
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
57
+ // CORS
58
+ res.setHeader('Access-Control-Allow-Origin', '*');
59
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
60
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
61
+
62
+ if (req.method === 'OPTIONS') {
63
+ return res.status(200).end();
64
+ }
65
+
66
+ if (req.method !== 'GET') {
67
+ return res.status(405).json({ error: 'Method not allowed' });
68
+ }
69
+
70
+ // Get params from query string
71
+ const url = req.query.url as string;
72
+ const ref = (req.query.ref as string) || 'main';
73
+ const owner = req.query.owner as string;
74
+ const repo = req.query.repo as string;
75
+ const pkg = req.query.package as string | undefined;
76
+
77
+ if (!url || !owner || !repo) {
78
+ return res.status(400).json({ error: 'Missing required query params (url, owner, repo)' });
79
+ }
80
+
81
+ // Set SSE headers
82
+ res.setHeader('Content-Type', 'text/event-stream');
83
+ res.setHeader('Cache-Control', 'no-cache');
84
+ res.setHeader('Connection', 'keep-alive');
85
+
86
+ // Send initial comment
87
+ res.write(':ok\n\n');
88
+
89
+ // Helper to send SSE event
90
+ const sendEvent = (event: JobEvent) => {
91
+ const data = JSON.stringify(event);
92
+ res.write(`data: ${data}\n\n`);
93
+ };
94
+
95
+ // Run scan with streaming progress
96
+ await runScanWithProgress({ url, ref, owner, repo, package: pkg }, sendEvent);
97
+
98
+ res.end();
99
+ }
100
+
101
+ interface ScanOptions {
102
+ url: string;
103
+ ref: string;
104
+ owner: string;
105
+ repo: string;
106
+ package?: string;
107
+ }
108
+
109
+ async function runScanWithProgress(
110
+ options: ScanOptions,
111
+ sendEvent: (event: JobEvent) => void,
112
+ ): Promise<void> {
113
+ try {
114
+ sendEvent({
115
+ type: 'progress',
116
+ stage: 'cloning',
117
+ message: `Cloning ${options.owner}/${options.repo}...`,
118
+ progress: 5,
119
+ });
120
+
121
+ const sandbox = await Sandbox.create({
122
+ source: {
123
+ url: options.url,
124
+ type: 'git',
125
+ },
126
+ resources: { vcpus: 4 },
127
+ timeout: 5 * 60 * 1000,
128
+ runtime: 'node22',
129
+ });
130
+
131
+ try {
132
+ // Create filesystem abstraction for SDK detection functions
133
+ const fs = new SandboxFileSystem(sandbox);
134
+
135
+ // Checkout specific ref if not main/master
136
+ if (options.ref && options.ref !== 'main' && options.ref !== 'master') {
137
+ sendEvent({
138
+ type: 'progress',
139
+ stage: 'cloning',
140
+ message: `Checking out ${options.ref}...`,
141
+ progress: 7,
142
+ });
143
+
144
+ const checkoutCapture = createCaptureStream();
145
+ const checkoutResult = await sandbox.runCommand({
146
+ cmd: 'git',
147
+ args: ['checkout', options.ref],
148
+ stdout: checkoutCapture.stream,
149
+ stderr: checkoutCapture.stream,
150
+ });
151
+
152
+ if (checkoutResult.exitCode !== 0) {
153
+ // Try fetching the ref first (might be a tag not fetched by shallow clone)
154
+ await sandbox.runCommand({
155
+ cmd: 'git',
156
+ args: ['fetch', '--depth', '1', 'origin', `refs/tags/${options.ref}:refs/tags/${options.ref}`],
157
+ });
158
+ const retryResult = await sandbox.runCommand({
159
+ cmd: 'git',
160
+ args: ['checkout', options.ref],
161
+ });
162
+ if (retryResult.exitCode !== 0) {
163
+ throw new Error(`Failed to checkout ${options.ref}: ${checkoutCapture.getOutput()}`);
164
+ }
165
+ }
166
+ }
167
+
168
+ sendEvent({
169
+ type: 'progress',
170
+ stage: 'detecting',
171
+ message: 'Detecting project structure...',
172
+ progress: 10,
173
+ });
174
+
175
+ // Detect package manager using SDK
176
+ const pmInfo = await detectPackageManager(fs);
177
+ const pmMessage = pmInfo.lockfile ? `Detected ${pmInfo.name} project` : 'No lockfile detected';
178
+ sendEvent({ type: 'progress', stage: 'detecting', message: pmMessage, progress: 15 });
179
+
180
+ // Early monorepo detection - fail fast if monorepo without package param
181
+ if (!options.package) {
182
+ const mono = await detectMonorepo(fs);
183
+
184
+ if (mono.isMonorepo) {
185
+ sendEvent({
186
+ type: 'progress',
187
+ stage: 'detecting',
188
+ message: 'Monorepo detected, listing packages...',
189
+ progress: 17,
190
+ });
191
+
192
+ const availablePackages = mono.packages
193
+ .filter((p) => !p.private)
194
+ .map((p) => p.name);
195
+
196
+ await sandbox.stop();
197
+ sendEvent({
198
+ type: 'error',
199
+ message: `Monorepo detected. Please specify a package to analyze using the 'package' query parameter.`,
200
+ availablePackages,
201
+ });
202
+ return;
203
+ }
204
+ }
205
+
206
+ // Install package manager if needed (npm and pnpm are pre-installed in node22)
207
+ if (pmInfo.name === 'bun') {
208
+ sendEvent({
209
+ type: 'progress',
210
+ stage: 'installing',
211
+ message: 'Installing bun...',
212
+ progress: 18,
213
+ });
214
+ await sandbox.runCommand({ cmd: 'npm', args: ['install', '-g', 'bun'] });
215
+ } else if (pmInfo.name === 'yarn') {
216
+ sendEvent({
217
+ type: 'progress',
218
+ stage: 'installing',
219
+ message: 'Installing yarn...',
220
+ progress: 18,
221
+ });
222
+ await sandbox.runCommand({ cmd: 'npm', args: ['install', '-g', 'yarn'] });
223
+ }
224
+
225
+ // Install dependencies with fallback chain
226
+ sendEvent({
227
+ type: 'progress',
228
+ stage: 'installing',
229
+ message: 'Installing dependencies...',
230
+ progress: 20,
231
+ });
232
+
233
+ let installed = false;
234
+ let activePm = pmInfo.name;
235
+ const installCapture = createCaptureStream();
236
+
237
+ // Try primary package manager using SDK's getInstallCommand
238
+ const primaryCmd = getInstallCommand(pmInfo);
239
+ const primaryResult = await sandbox.runCommand({
240
+ cmd: primaryCmd[0],
241
+ args: primaryCmd.slice(1),
242
+ stdout: installCapture.stream,
243
+ stderr: installCapture.stream,
244
+ });
245
+
246
+ if (primaryResult.exitCode === 0) {
247
+ installed = true;
248
+ } else {
249
+ const errorOutput = installCapture.getOutput();
250
+
251
+ // Check if it's a workspace:* protocol error - try bun fallback
252
+ if (errorOutput.includes('workspace:') || errorOutput.includes('EUNSUPPORTEDPROTOCOL')) {
253
+ sendEvent({
254
+ type: 'progress',
255
+ stage: 'installing',
256
+ message: 'Trying bun fallback for workspace protocol...',
257
+ progress: 25,
258
+ });
259
+
260
+ // Install bun if not already the primary
261
+ if (pmInfo.name !== 'bun') {
262
+ await sandbox.runCommand({ cmd: 'npm', args: ['install', '-g', 'bun'] });
263
+ }
264
+
265
+ const bunCapture = createCaptureStream();
266
+ const bunResult = await sandbox.runCommand({
267
+ cmd: 'bun',
268
+ args: ['install'],
269
+ stdout: bunCapture.stream,
270
+ stderr: bunCapture.stream,
271
+ });
272
+
273
+ if (bunResult.exitCode === 0) {
274
+ installed = true;
275
+ activePm = 'bun'; // Update pm for build step
276
+ }
277
+ }
278
+ }
279
+
280
+ if (installed) {
281
+ sendEvent({
282
+ type: 'progress',
283
+ stage: 'installing',
284
+ message: 'Dependencies installed',
285
+ progress: 40,
286
+ });
287
+ } else {
288
+ // Graceful degradation - continue with limited analysis
289
+ sendEvent({
290
+ type: 'progress',
291
+ stage: 'installing',
292
+ message: 'Install failed (continuing with limited analysis)',
293
+ progress: 40,
294
+ });
295
+ }
296
+
297
+ // Check for build script using SDK
298
+ const buildInfo = await detectBuildInfo(fs);
299
+ const buildScript = getPrimaryBuildScript(buildInfo);
300
+
301
+ if (buildScript) {
302
+ sendEvent({
303
+ type: 'progress',
304
+ stage: 'building',
305
+ message: 'Running build...',
306
+ progress: 45,
307
+ });
308
+
309
+ const buildCapture = createCaptureStream();
310
+ // Use activePm (may have changed to bun as fallback)
311
+ const buildCmd =
312
+ activePm === 'npm' || activePm === 'yarn'
313
+ ? [activePm, 'run', buildScript]
314
+ : [activePm, buildScript];
315
+
316
+ const buildResult = await sandbox.runCommand({
317
+ cmd: buildCmd[0],
318
+ args: buildCmd.slice(1),
319
+ stdout: buildCapture.stream,
320
+ stderr: buildCapture.stream,
321
+ });
322
+
323
+ const buildMessage =
324
+ buildResult.exitCode === 0 ? 'Build complete' : 'Build failed (continuing)';
325
+ sendEvent({ type: 'progress', stage: 'building', message: buildMessage, progress: 55 });
326
+ }
327
+
328
+ // Install doccov CLI
329
+ sendEvent({
330
+ type: 'progress',
331
+ stage: 'analyzing',
332
+ message: 'Installing DocCov CLI...',
333
+ progress: 60,
334
+ });
335
+
336
+ const cliInstall = await sandbox.runCommand({
337
+ cmd: 'npm',
338
+ args: ['install', '-g', '@doccov/cli'],
339
+ });
340
+
341
+ if (cliInstall.exitCode !== 0) {
342
+ throw new Error('Failed to install @doccov/cli');
343
+ }
344
+
345
+ // Run generate
346
+ const specFile = '/tmp/spec.json';
347
+ const genArgs = ['generate', '--cwd', '.', '-o', specFile];
348
+ const analyzeMessage = options.package
349
+ ? `Analyzing ${options.package}...`
350
+ : 'Generating DocCov spec...';
351
+ if (options.package) {
352
+ genArgs.push('--package', options.package);
353
+ }
354
+
355
+ sendEvent({ type: 'progress', stage: 'analyzing', message: analyzeMessage, progress: 65 });
356
+
357
+ const genCapture = createCaptureStream();
358
+ const genResult = await sandbox.runCommand({
359
+ cmd: 'doccov',
360
+ args: genArgs,
361
+ stdout: genCapture.stream,
362
+ stderr: genCapture.stream,
363
+ });
364
+
365
+ const genOutput = genCapture.getOutput();
366
+ if (genResult.exitCode !== 0) {
367
+ throw new Error(`doccov generate failed: ${genOutput.slice(-300)}`);
368
+ }
369
+
370
+ sendEvent({
371
+ type: 'progress',
372
+ stage: 'extracting',
373
+ message: 'Extracting results...',
374
+ progress: 85,
375
+ });
376
+
377
+ // Check if spec file was created
378
+ const checkFileCapture = createCaptureStream();
379
+ await sandbox.runCommand({
380
+ cmd: 'cat',
381
+ args: [specFile],
382
+ stdout: checkFileCapture.stream,
383
+ stderr: checkFileCapture.stream,
384
+ });
385
+ const specContent = checkFileCapture.getOutput();
386
+
387
+ if (!specContent.trim() || specContent.includes('No such file')) {
388
+ throw new Error(`Spec file not found or empty. Generate output: ${genOutput.slice(-500)}`);
389
+ }
390
+
391
+ // Extract summary with error handling
392
+ const extractScript = `
393
+ const fs = require('fs');
394
+ try {
395
+ if (!fs.existsSync('${specFile}')) {
396
+ console.error('Spec file not found: ${specFile}');
397
+ process.exit(1);
398
+ }
399
+ const content = fs.readFileSync('${specFile}', 'utf-8');
400
+ const spec = JSON.parse(content);
401
+ const undocumented = [];
402
+ const drift = [];
403
+ for (const exp of spec.exports || []) {
404
+ const docs = exp.docs;
405
+ if (!docs) continue;
406
+ if ((docs.missing?.length || 0) > 0 || (docs.coverageScore || 0) < 100) {
407
+ undocumented.push(exp.name);
408
+ }
409
+ for (const d of docs.drift || []) {
410
+ drift.push({ export: exp.name, type: d.type, issue: d.issue });
411
+ }
412
+ }
413
+ console.log(JSON.stringify({
414
+ coverage: spec.docs?.coverageScore || 0,
415
+ exportCount: spec.exports?.length || 0,
416
+ typeCount: spec.types?.length || 0,
417
+ undocumented: undocumented.slice(0, 50),
418
+ drift: drift.slice(0, 20),
419
+ driftCount: drift.length,
420
+ }));
421
+ } catch (e) {
422
+ console.error('Extract error:', e.message);
423
+ process.exit(1);
424
+ }
425
+ `.replace(/\n/g, ' ');
426
+
427
+ const nodeCapture = createCaptureStream();
428
+ const nodeResult = await sandbox.runCommand({
429
+ cmd: 'node',
430
+ args: ['-e', extractScript],
431
+ stdout: nodeCapture.stream,
432
+ stderr: nodeCapture.stream,
433
+ });
434
+
435
+ const summaryJson = nodeCapture.getOutput();
436
+ if (nodeResult.exitCode !== 0 || !summaryJson.trim()) {
437
+ throw new Error(`Failed to extract summary: ${summaryJson.slice(0, 300)}`);
438
+ }
439
+
440
+ const summary = JSON.parse(summaryJson.trim()) as {
441
+ coverage: number;
442
+ exportCount: number;
443
+ typeCount: number;
444
+ undocumented: string[];
445
+ drift: ScanResult['drift'];
446
+ driftCount: number;
447
+ };
448
+
449
+ const result: ScanResult = {
450
+ owner: options.owner,
451
+ repo: options.repo,
452
+ ref: options.ref,
453
+ packageName: options.package,
454
+ coverage: summary.coverage,
455
+ exportCount: summary.exportCount,
456
+ typeCount: summary.typeCount,
457
+ driftCount: summary.driftCount,
458
+ undocumented: summary.undocumented,
459
+ drift: summary.drift,
460
+ };
461
+
462
+ sendEvent({ type: 'complete', result });
463
+ } finally {
464
+ await sandbox.stop();
465
+ }
466
+ } catch (error) {
467
+ const message = error instanceof Error ? error.message : String(error);
468
+ sendEvent({ type: 'error', message });
469
+ }
470
+ }