@doccov/api 0.4.0 → 0.5.0

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.
@@ -1,8 +1,11 @@
1
+ import { fetchSpec } from '@doccov/sdk';
2
+ import { validateSpec } from '@openpkg-ts/spec';
1
3
  import { Hono } from 'hono';
2
- import { fetchSpecFromGitHub } from '../utils/github';
3
4
 
4
5
  export const badgeRoute = new Hono();
5
6
 
7
+ type BadgeStyle = 'flat' | 'flat-square' | 'for-the-badge';
8
+
6
9
  type BadgeColor =
7
10
  | 'brightgreen'
8
11
  | 'green'
@@ -16,6 +19,7 @@ interface BadgeOptions {
16
19
  label: string;
17
20
  message: string;
18
21
  color: BadgeColor;
22
+ style?: BadgeStyle;
19
23
  }
20
24
 
21
25
  function getColorForScore(score: number): BadgeColor {
@@ -27,22 +31,17 @@ function getColorForScore(score: number): BadgeColor {
27
31
  return 'red';
28
32
  }
29
33
 
30
- function generateBadgeSvg(options: BadgeOptions): string {
31
- const { label, message, color } = options;
32
-
33
- const colors: Record<BadgeColor, string> = {
34
- brightgreen: '#4c1',
35
- green: '#97ca00',
36
- yellowgreen: '#a4a61d',
37
- yellow: '#dfb317',
38
- orange: '#fe7d37',
39
- red: '#e05d44',
40
- lightgrey: '#9f9f9f',
41
- };
42
-
43
- const bgColor = colors[color];
44
-
45
- // Simple badge dimensions
34
+ const BADGE_COLORS: Record<BadgeColor, string> = {
35
+ brightgreen: '#4c1',
36
+ green: '#97ca00',
37
+ yellowgreen: '#a4a61d',
38
+ yellow: '#dfb317',
39
+ orange: '#fe7d37',
40
+ red: '#e05d44',
41
+ lightgrey: '#9f9f9f',
42
+ };
43
+
44
+ function generateFlatBadge(label: string, message: string, bgColor: string): string {
46
45
  const labelWidth = label.length * 7 + 10;
47
46
  const messageWidth = message.length * 7 + 10;
48
47
  const totalWidth = labelWidth + messageWidth;
@@ -70,49 +69,139 @@ function generateBadgeSvg(options: BadgeOptions): string {
70
69
  </svg>`;
71
70
  }
72
71
 
72
+ function generateFlatSquareBadge(label: string, message: string, bgColor: string): string {
73
+ const labelWidth = label.length * 7 + 10;
74
+ const messageWidth = message.length * 7 + 10;
75
+ const totalWidth = labelWidth + messageWidth;
76
+
77
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${message}">
78
+ <title>${label}: ${message}</title>
79
+ <g shape-rendering="crispEdges">
80
+ <rect width="${labelWidth}" height="20" fill="#555"/>
81
+ <rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${bgColor}"/>
82
+ </g>
83
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
84
+ <text x="${labelWidth / 2}" y="14">${label}</text>
85
+ <text x="${labelWidth + messageWidth / 2}" y="14">${message}</text>
86
+ </g>
87
+ </svg>`;
88
+ }
89
+
90
+ function generateForTheBadge(label: string, message: string, bgColor: string): string {
91
+ const labelUpper = label.toUpperCase();
92
+ const messageUpper = message.toUpperCase();
93
+ const labelWidth = labelUpper.length * 10 + 20;
94
+ const messageWidth = messageUpper.length * 10 + 20;
95
+ const totalWidth = labelWidth + messageWidth;
96
+
97
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="28" role="img" aria-label="${label}: ${message}">
98
+ <title>${label}: ${message}</title>
99
+ <g shape-rendering="crispEdges">
100
+ <rect width="${labelWidth}" height="28" fill="#555"/>
101
+ <rect x="${labelWidth}" width="${messageWidth}" height="28" fill="${bgColor}"/>
102
+ </g>
103
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="10" font-weight="bold">
104
+ <text x="${labelWidth / 2}" y="18">${labelUpper}</text>
105
+ <text x="${labelWidth + messageWidth / 2}" y="18">${messageUpper}</text>
106
+ </g>
107
+ </svg>`;
108
+ }
109
+
110
+ function generateBadgeSvg(options: BadgeOptions): string {
111
+ const { label, message, color, style = 'flat' } = options;
112
+ const bgColor = BADGE_COLORS[color];
113
+
114
+ switch (style) {
115
+ case 'flat-square':
116
+ return generateFlatSquareBadge(label, message, bgColor);
117
+ case 'for-the-badge':
118
+ return generateForTheBadge(label, message, bgColor);
119
+ default:
120
+ return generateFlatBadge(label, message, bgColor);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Compute coverage score from spec exports if not already present.
126
+ */
127
+ function computeCoverageScore(spec: { exports?: { description?: string }[] }): number {
128
+ const exports = spec.exports ?? [];
129
+ if (exports.length === 0) return 0;
130
+
131
+ const documented = exports.filter((e) => e.description && e.description.trim().length > 0);
132
+ return Math.round((documented.length / exports.length) * 100);
133
+ }
134
+
135
+ // Cache headers: 5min max-age, stale-if-error for resilience
136
+ const CACHE_HEADERS_SUCCESS = {
137
+ 'Content-Type': 'image/svg+xml',
138
+ 'Cache-Control': 'public, max-age=300, stale-if-error=3600',
139
+ };
140
+
141
+ const CACHE_HEADERS_ERROR = {
142
+ 'Content-Type': 'image/svg+xml',
143
+ 'Cache-Control': 'no-cache',
144
+ };
145
+
73
146
  // GET /badge/:owner/:repo
74
147
  badgeRoute.get('/:owner/:repo', async (c) => {
75
148
  const { owner, repo } = c.req.param();
76
- const branch = c.req.query('branch') ?? 'main';
149
+
150
+ // Query params for customization
151
+ const ref = c.req.query('ref') ?? c.req.query('branch') ?? 'main';
152
+ const specPath = c.req.query('path') ?? c.req.query('package') ?? 'openpkg.json';
153
+ const style = (c.req.query('style') ?? 'flat') as BadgeStyle;
77
154
 
78
155
  try {
79
- const spec = await fetchSpecFromGitHub(owner, repo, branch);
156
+ const spec = await fetchSpec(owner, repo, { ref, path: specPath });
80
157
 
81
158
  if (!spec) {
82
159
  const svg = generateBadgeSvg({
83
160
  label: 'docs',
84
161
  message: 'not found',
85
162
  color: 'lightgrey',
163
+ style,
86
164
  });
87
165
 
88
- return c.body(svg, 404, {
89
- 'Content-Type': 'image/svg+xml',
90
- 'Cache-Control': 'no-cache',
166
+ return c.body(svg, 404, CACHE_HEADERS_ERROR);
167
+ }
168
+
169
+ // Validate spec against schema
170
+ const validation = validateSpec(spec);
171
+ if (!validation.ok) {
172
+ const svg = generateBadgeSvg({
173
+ label: 'docs',
174
+ message: 'invalid',
175
+ color: 'lightgrey',
176
+ style,
91
177
  });
178
+
179
+ return c.body(svg, 422, CACHE_HEADERS_ERROR);
92
180
  }
93
181
 
94
- const coverageScore = spec.docs?.coverageScore ?? 0;
182
+ // Use docs.coverageScore if present (enriched spec), otherwise compute from exports
183
+ // Note: The spec type has changed - check for generation.analysis or similar patterns
184
+ const coverageScore =
185
+ (spec as { docs?: { coverageScore?: number } }).docs?.coverageScore ??
186
+ computeCoverageScore(spec);
187
+
95
188
  const svg = generateBadgeSvg({
96
189
  label: 'docs',
97
190
  message: `${coverageScore}%`,
98
191
  color: getColorForScore(coverageScore),
192
+ style,
99
193
  });
100
194
 
101
- return c.body(svg, 200, {
102
- 'Content-Type': 'image/svg+xml',
103
- 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
104
- });
195
+ return c.body(svg, 200, CACHE_HEADERS_SUCCESS);
105
196
  } catch {
106
197
  const svg = generateBadgeSvg({
107
198
  label: 'docs',
108
199
  message: 'error',
109
200
  color: 'red',
201
+ style,
110
202
  });
111
203
 
112
- return c.body(svg, 500, {
113
- 'Content-Type': 'image/svg+xml',
114
- 'Cache-Control': 'no-cache',
115
- });
204
+ return c.body(svg, 500, CACHE_HEADERS_ERROR);
116
205
  }
117
206
  });
118
207
 
@@ -120,5 +209,6 @@ badgeRoute.get('/:owner/:repo', async (c) => {
120
209
  badgeRoute.get('/:owner/:repo.svg', async (c) => {
121
210
  const { owner, repo } = c.req.param();
122
211
  const repoName = repo.replace(/\.svg$/, '');
123
- return c.redirect(`/badge/${owner}/${repoName}`);
212
+ const query = new URL(c.req.url).search;
213
+ return c.redirect(`/badge/${owner}/${repoName}${query}`);
124
214
  });
@@ -1,3 +1,4 @@
1
+ import { getPlanLimits, type Plan } from '@doccov/db';
1
2
  import { CustomerPortal, Webhooks } from '@polar-sh/hono';
2
3
  import { Hono } from 'hono';
3
4
  import { auth } from '../auth/config';
@@ -200,3 +201,67 @@ billingRoute.get('/status', async (c) => {
200
201
  portalUrl: org.polarCustomerId ? `${API_URL}/billing/portal?orgId=${orgId}` : null,
201
202
  });
202
203
  });
204
+
205
+ // ============ Usage Details ============
206
+ billingRoute.get('/usage', async (c) => {
207
+ const orgId = c.req.query('orgId');
208
+ if (!orgId) return c.json({ error: 'orgId required' }, 400);
209
+
210
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
211
+ if (!session) return c.json({ error: 'Unauthorized' }, 401);
212
+
213
+ // Get org with member count
214
+ const org = await db
215
+ .selectFrom('organizations')
216
+ .innerJoin('org_members', 'org_members.orgId', 'organizations.id')
217
+ .where('organizations.id', '=', orgId)
218
+ .where('org_members.userId', '=', session.user.id)
219
+ .select([
220
+ 'organizations.id',
221
+ 'organizations.plan',
222
+ 'organizations.aiCallsUsed',
223
+ 'organizations.aiCallsResetAt',
224
+ ])
225
+ .executeTakeFirst();
226
+
227
+ if (!org) return c.json({ error: 'Organization not found' }, 404);
228
+
229
+ // Get member count
230
+ const memberResult = await db
231
+ .selectFrom('org_members')
232
+ .where('orgId', '=', orgId)
233
+ .select(db.fn.countAll<number>().as('count'))
234
+ .executeTakeFirst();
235
+
236
+ const seats = memberResult?.count ?? 1;
237
+ const limits = getPlanLimits(org.plan as Plan);
238
+
239
+ // Calculate next reset date
240
+ const now = new Date();
241
+ const resetAt = org.aiCallsResetAt || new Date(now.getFullYear(), now.getMonth() + 1, 1);
242
+ const shouldReset = !org.aiCallsResetAt || now >= org.aiCallsResetAt;
243
+ const aiUsed = shouldReset ? 0 : org.aiCallsUsed;
244
+
245
+ // Calculate pricing based on plan
246
+ const pricing: Record<string, number> = { free: 0, team: 15, pro: 49 };
247
+ const monthlyCost = (pricing[org.plan] ?? 0) * seats;
248
+
249
+ return c.json({
250
+ plan: org.plan,
251
+ seats,
252
+ monthlyCost,
253
+ aiCalls: {
254
+ used: aiUsed,
255
+ limit: limits.aiCallsPerMonth === Infinity ? 'unlimited' : limits.aiCallsPerMonth,
256
+ resetAt: resetAt.toISOString(),
257
+ },
258
+ analyses: {
259
+ limit: limits.analysesPerDay === Infinity ? 'unlimited' : limits.analysesPerDay,
260
+ resetAt: 'daily',
261
+ },
262
+ history: {
263
+ days: limits.historyDays,
264
+ },
265
+ privateRepos: limits.privateRepos === Infinity,
266
+ });
267
+ });
@@ -1,3 +1,4 @@
1
+ import { getPlanLimits, type Plan } from '@doccov/db';
1
2
  import { Hono } from 'hono';
2
3
  import { nanoid } from 'nanoid';
3
4
  import { auth } from '../auth/config';
@@ -29,33 +30,50 @@ coverageRoute.get('/projects/:projectId/history', async (c) => {
29
30
  const { projectId } = c.req.param();
30
31
  const { range = '30d', limit = '50' } = c.req.query();
31
32
 
32
- // Verify user has access to project
33
- const project = await db
33
+ // Verify user has access to project and get org plan
34
+ const projectWithOrg = await db
34
35
  .selectFrom('projects')
35
36
  .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
37
+ .innerJoin('organizations', 'organizations.id', 'projects.orgId')
36
38
  .where('projects.id', '=', projectId)
37
39
  .where('org_members.userId', '=', session.user.id)
38
- .select(['projects.id', 'projects.name'])
40
+ .select(['projects.id', 'projects.name', 'organizations.plan'])
39
41
  .executeTakeFirst();
40
42
 
41
- if (!project) {
43
+ if (!projectWithOrg) {
42
44
  return c.json({ error: 'Project not found' }, 404);
43
45
  }
44
46
 
45
- // Calculate date filter based on range
47
+ // Check plan limits for trends access
48
+ const planLimits = getPlanLimits(projectWithOrg.plan as Plan);
49
+ if (planLimits.historyDays === 0) {
50
+ return c.json(
51
+ {
52
+ error: 'Coverage trends require Team plan or higher',
53
+ upgrade: 'https://doccov.com/pricing',
54
+ },
55
+ 403,
56
+ );
57
+ }
58
+
59
+ // Calculate date filter based on range (capped by plan limit)
46
60
  let dateFilter: Date | null = null;
47
61
  const now = new Date();
48
- switch (range) {
49
- case '7d':
50
- dateFilter = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
51
- break;
52
- case '30d':
53
- dateFilter = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
54
- break;
55
- case '90d':
56
- dateFilter = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
57
- break;
58
- // 'all' and 'versions' - no date filter
62
+ const maxDays = planLimits.historyDays;
63
+
64
+ // Map range to days, capped by plan limit
65
+ const rangeDays: Record<string, number> = {
66
+ '7d': Math.min(7, maxDays),
67
+ '30d': Math.min(30, maxDays),
68
+ '90d': Math.min(90, maxDays),
69
+ };
70
+
71
+ if (range in rangeDays) {
72
+ const days = rangeDays[range];
73
+ dateFilter = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
74
+ } else if (range === 'all' || range === 'versions') {
75
+ // Still cap by plan limit
76
+ dateFilter = new Date(now.getTime() - maxDays * 24 * 60 * 60 * 1000);
59
77
  }
60
78
 
61
79
  let query = db
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Demo route - public "Try it Now" endpoint for analyzing npm packages
3
+ * Uses the /plan and /execute-stream endpoints from the Vercel API
4
+ */
5
+
6
+ import { Hono } from 'hono';
7
+ import { streamSSE } from 'hono/streaming';
8
+ import { anonymousRateLimit } from '../middleware/anonymous-rate-limit';
9
+
10
+ export const demoRoute = new Hono();
11
+
12
+ // Vercel API URL (where /plan and /execute-stream live)
13
+ const VERCEL_API_URL = process.env.VERCEL_API_URL || 'https://api-khaki-phi.vercel.app';
14
+
15
+ // Rate limit: 5 analyses per hour per IP
16
+ demoRoute.use(
17
+ '*',
18
+ anonymousRateLimit({
19
+ windowMs: 60 * 60 * 1000, // 1 hour
20
+ max: 5,
21
+ message: 'Demo limit reached. Sign up for unlimited access.',
22
+ upgradeUrl: 'https://doccov.com/pricing',
23
+ }),
24
+ );
25
+
26
+ /**
27
+ * npm package info from registry
28
+ */
29
+ interface NpmPackageInfo {
30
+ name: string;
31
+ version: string;
32
+ description?: string;
33
+ repository?: string;
34
+ }
35
+
36
+ /**
37
+ * Fetch package info from npm registry
38
+ */
39
+ async function fetchNpmPackage(packageName: string): Promise<NpmPackageInfo> {
40
+ const encodedName = encodeURIComponent(packageName);
41
+ const url = `https://registry.npmjs.org/${encodedName}/latest`;
42
+
43
+ const res = await fetch(url, {
44
+ headers: { Accept: 'application/json' },
45
+ });
46
+
47
+ if (!res.ok) {
48
+ if (res.status === 404) {
49
+ throw new Error(`Package "${packageName}" not found on npm`);
50
+ }
51
+ throw new Error(`npm registry error: ${res.status}`);
52
+ }
53
+
54
+ const data = (await res.json()) as {
55
+ name: string;
56
+ version: string;
57
+ description?: string;
58
+ repository?: string | { type?: string; url?: string };
59
+ };
60
+
61
+ // Extract and normalize GitHub URL from repository field
62
+ let repoUrl: string | undefined;
63
+ if (data.repository) {
64
+ if (typeof data.repository === 'string') {
65
+ repoUrl = data.repository;
66
+ } else if (data.repository.url) {
67
+ // Normalize: git+https://github.com/... or git://github.com/...
68
+ repoUrl = data.repository.url
69
+ .replace(/^git\+/, '')
70
+ .replace(/^git:\/\//, 'https://')
71
+ .replace(/\.git$/, '');
72
+ }
73
+ }
74
+
75
+ // Validate it's a GitHub URL
76
+ if (repoUrl && !repoUrl.includes('github.com')) {
77
+ repoUrl = undefined;
78
+ }
79
+
80
+ return {
81
+ name: data.name,
82
+ version: data.version,
83
+ description: data.description,
84
+ repository: repoUrl,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Analysis result summary (matches SpecSummary from Vercel API)
90
+ */
91
+ interface AnalysisSummary {
92
+ packageName: string;
93
+ version: string;
94
+ coverage: number;
95
+ exportCount: number;
96
+ documentedCount: number;
97
+ undocumentedCount: number;
98
+ driftCount: number;
99
+ topUndocumented: string[];
100
+ topDrift: Array<{ name: string; issue: string }>;
101
+ }
102
+
103
+ // GET /demo/analyze?package=lodash
104
+ demoRoute.get('/analyze', async (c) => {
105
+ const packageName = c.req.query('package');
106
+
107
+ if (!packageName) {
108
+ return c.json({ error: 'Package name required' }, 400);
109
+ }
110
+
111
+ // Validate package name (basic sanitation)
112
+ if (!/^(@[\w-]+\/)?[\w.-]+$/.test(packageName)) {
113
+ return c.json({ error: 'Invalid package name format' }, 400);
114
+ }
115
+
116
+ return streamSSE(c, async (stream) => {
117
+ const sendEvent = async (
118
+ type: 'status' | 'log' | 'result' | 'error',
119
+ data: { step?: string; message?: string; data?: unknown },
120
+ ) => {
121
+ await stream.writeSSE({
122
+ data: JSON.stringify({ type, ...data }),
123
+ event: type === 'error' ? 'error' : type === 'result' ? 'complete' : 'progress',
124
+ });
125
+ };
126
+
127
+ try {
128
+ // Step 1: Fetch from npm registry
129
+ await sendEvent('status', {
130
+ step: 'npm',
131
+ message: `Fetching ${packageName} from npm registry...`,
132
+ });
133
+
134
+ const npmInfo = await fetchNpmPackage(packageName);
135
+
136
+ await sendEvent('log', {
137
+ message: `Found ${npmInfo.name}@${npmInfo.version}`,
138
+ });
139
+
140
+ if (!npmInfo.repository) {
141
+ await sendEvent('error', {
142
+ message: 'No GitHub repository linked to this package',
143
+ });
144
+ return;
145
+ }
146
+
147
+ await sendEvent('log', {
148
+ message: `Repository: ${npmInfo.repository}`,
149
+ });
150
+
151
+ // Step 2: Generate build plan via /plan endpoint
152
+ await sendEvent('status', {
153
+ step: 'plan',
154
+ message: 'Generating build plan...',
155
+ });
156
+
157
+ const planResponse = await fetch(`${VERCEL_API_URL}/plan`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ url: npmInfo.repository,
162
+ package: packageName.startsWith('@') ? packageName : undefined,
163
+ }),
164
+ });
165
+
166
+ if (!planResponse.ok) {
167
+ const errorData = (await planResponse.json()) as { error?: string };
168
+ throw new Error(errorData.error || `Plan generation failed: ${planResponse.status}`);
169
+ }
170
+
171
+ const planData = (await planResponse.json()) as {
172
+ plan: unknown;
173
+ context: { isMonorepo: boolean; packageManager: string };
174
+ };
175
+
176
+ await sendEvent('log', {
177
+ message: `Build plan ready (${planData.context.packageManager}${planData.context.isMonorepo ? ', monorepo' : ''})`,
178
+ });
179
+
180
+ // Step 3: Execute build plan via /execute-stream endpoint
181
+ await sendEvent('status', {
182
+ step: 'build',
183
+ message: 'Building and analyzing...',
184
+ });
185
+
186
+ const executeResponse = await fetch(`${VERCEL_API_URL}/execute-stream`, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({ plan: planData.plan }),
190
+ });
191
+
192
+ if (!executeResponse.ok || !executeResponse.body) {
193
+ throw new Error(`Execution failed: ${executeResponse.status}`);
194
+ }
195
+
196
+ // Stream the execute-stream SSE events and forward relevant ones
197
+ const reader = executeResponse.body.getReader();
198
+ const decoder = new TextDecoder();
199
+ let buffer = '';
200
+
201
+ while (true) {
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+
205
+ buffer += decoder.decode(value, { stream: true });
206
+ const lines = buffer.split('\n');
207
+ buffer = lines.pop() || '';
208
+
209
+ for (const line of lines) {
210
+ if (line.startsWith('event:')) {
211
+ const eventType = line.slice(7).trim();
212
+
213
+ // Get the next data line
214
+ const dataLineIndex = lines.indexOf(line) + 1;
215
+ if (dataLineIndex < lines.length && lines[dataLineIndex].startsWith('data:')) {
216
+ const dataStr = lines[dataLineIndex].slice(5).trim();
217
+ try {
218
+ const eventData = JSON.parse(dataStr) as {
219
+ stage?: string;
220
+ message?: string;
221
+ stepId?: string;
222
+ name?: string;
223
+ success?: boolean;
224
+ summary?: {
225
+ name: string;
226
+ version: string;
227
+ coverage: number;
228
+ exports: number;
229
+ documented: number;
230
+ undocumented: number;
231
+ };
232
+ error?: string;
233
+ };
234
+
235
+ // Forward progress events
236
+ if (eventType === 'progress') {
237
+ await sendEvent('log', { message: eventData.message || eventData.stage });
238
+ } else if (eventType === 'step:start') {
239
+ await sendEvent('status', {
240
+ step: eventData.stepId === 'analyze' ? 'analyze' : 'build',
241
+ message: eventData.name || `Running ${eventData.stepId}...`,
242
+ });
243
+ } else if (eventType === 'step:complete' && eventData.stepId) {
244
+ await sendEvent('log', {
245
+ message: `${eventData.stepId} completed`,
246
+ });
247
+ } else if (eventType === 'complete' && eventData.summary) {
248
+ // Transform summary to our format
249
+ const summary: AnalysisSummary = {
250
+ packageName: eventData.summary.name,
251
+ version: eventData.summary.version,
252
+ coverage: eventData.summary.coverage,
253
+ exportCount: eventData.summary.exports,
254
+ documentedCount: eventData.summary.documented,
255
+ undocumentedCount: eventData.summary.undocumented,
256
+ driftCount: 0,
257
+ topUndocumented: [],
258
+ topDrift: [],
259
+ };
260
+
261
+ await sendEvent('log', {
262
+ message: `Found ${summary.exportCount} exports, ${summary.documentedCount} documented`,
263
+ });
264
+
265
+ await sendEvent('status', {
266
+ step: 'complete',
267
+ message: 'Analysis complete!',
268
+ });
269
+
270
+ await sendEvent('result', { data: summary });
271
+ return;
272
+ } else if (eventType === 'error') {
273
+ throw new Error(eventData.error || 'Execution failed');
274
+ }
275
+ } catch (parseError) {
276
+ // Ignore JSON parse errors for incomplete data
277
+ if (parseError instanceof SyntaxError) continue;
278
+ throw parseError;
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // If we get here without a complete event, something went wrong
286
+ throw new Error('Execution completed without results');
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : 'Analysis failed';
289
+ await sendEvent('error', { message });
290
+ }
291
+ });
292
+ });
293
+
294
+ // Health check
295
+ demoRoute.get('/health', (c) => {
296
+ return c.json({ status: 'ok' });
297
+ });