@doccov/api 0.5.0 → 0.6.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.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Spec routes - session authenticated endpoints for dashboard
3
+ */
4
+
5
+ import { Hono } from 'hono';
6
+ import { z } from 'zod';
7
+ import { auth } from '../auth/config';
8
+ import { db } from '../db/client';
9
+ import {
10
+ computeFullDiff,
11
+ type DiffOptions,
12
+ diffSpecs,
13
+ formatDiffResponse,
14
+ } from '../utils/spec-diff-core';
15
+
16
+ type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
17
+
18
+ type Env = {
19
+ Variables: {
20
+ session: NonNullable<Session>;
21
+ };
22
+ };
23
+
24
+ export const specRoute = new Hono<Env>();
25
+
26
+ // Middleware: require session auth
27
+ specRoute.use('*', async (c, next) => {
28
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
29
+ if (!session) {
30
+ return c.json({ error: 'Unauthorized' }, 401);
31
+ }
32
+ c.set('session', session);
33
+ await next();
34
+ });
35
+
36
+ // Request schemas
37
+ const GitHubDiffSchema = z.object({
38
+ mode: z.literal('github'),
39
+ owner: z.string().min(1),
40
+ repo: z.string().min(1),
41
+ base: z.string().min(1),
42
+ head: z.string().min(1),
43
+ installationId: z.string().optional(),
44
+ includeDocsImpact: z.boolean().optional(),
45
+ });
46
+
47
+ const SpecsDiffSchema = z.object({
48
+ mode: z.literal('specs'),
49
+ baseSpec: z.object({}).passthrough(),
50
+ headSpec: z.object({}).passthrough(),
51
+ markdownFiles: z
52
+ .array(
53
+ z.object({
54
+ path: z.string(),
55
+ content: z.string(),
56
+ }),
57
+ )
58
+ .optional(),
59
+ });
60
+
61
+ const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
62
+
63
+ /**
64
+ * POST /spec/diff - Compare two specs
65
+ *
66
+ * Supports two modes:
67
+ * 1. GitHub refs: Clone and compare specs from GitHub refs
68
+ * 2. Direct specs: Compare uploaded spec objects
69
+ */
70
+ specRoute.post('/diff', async (c) => {
71
+ const session = c.get('session');
72
+
73
+ // Parse and validate request body
74
+ let body: z.infer<typeof DiffRequestSchema>;
75
+ try {
76
+ const rawBody = await c.req.json();
77
+ body = DiffRequestSchema.parse(rawBody);
78
+ } catch (err) {
79
+ if (err instanceof z.ZodError) {
80
+ return c.json(
81
+ {
82
+ error: 'Invalid request',
83
+ details: err.errors,
84
+ },
85
+ 400,
86
+ );
87
+ }
88
+ return c.json({ error: 'Invalid JSON body' }, 400);
89
+ }
90
+
91
+ try {
92
+ if (body.mode === 'github') {
93
+ // GitHub mode: need to verify access and get installation
94
+ const { owner, repo, base, head, installationId, includeDocsImpact } = body;
95
+
96
+ // Find installation ID if not provided
97
+ let resolvedInstallationId = installationId;
98
+
99
+ if (!resolvedInstallationId) {
100
+ // Look up installation from user's orgs
101
+ const installation = await db
102
+ .selectFrom('github_installations')
103
+ .innerJoin('org_members', 'org_members.orgId', 'github_installations.orgId')
104
+ .where('org_members.userId', '=', session.user.id)
105
+ .select(['github_installations.installationId'])
106
+ .executeTakeFirst();
107
+
108
+ if (!installation) {
109
+ return c.json(
110
+ {
111
+ error: 'No GitHub App installation found for this repository',
112
+ hint: 'Install the DocCov GitHub App to compare repos',
113
+ },
114
+ 403,
115
+ );
116
+ }
117
+
118
+ resolvedInstallationId = installation.installationId;
119
+ }
120
+
121
+ // Compute diff with timeout
122
+ const diffOptions: DiffOptions = {
123
+ includeDocsImpact,
124
+ };
125
+
126
+ const result = await Promise.race([
127
+ computeFullDiff(
128
+ { owner, repo, ref: base, installationId: resolvedInstallationId },
129
+ { owner, repo, ref: head, installationId: resolvedInstallationId },
130
+ diffOptions,
131
+ ),
132
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
133
+ ]);
134
+
135
+ return c.json(formatDiffResponse(result));
136
+ }
137
+
138
+ // Specs mode: direct comparison
139
+ const { baseSpec, headSpec, markdownFiles } = body;
140
+
141
+ const diff = diffSpecs(
142
+ baseSpec as Parameters<typeof diffSpecs>[0],
143
+ headSpec as Parameters<typeof diffSpecs>[1],
144
+ markdownFiles,
145
+ );
146
+
147
+ return c.json({
148
+ // Core diff fields
149
+ breaking: diff.breaking,
150
+ nonBreaking: diff.nonBreaking,
151
+ docsOnly: diff.docsOnly,
152
+ coverageDelta: diff.coverageDelta,
153
+ oldCoverage: diff.oldCoverage,
154
+ newCoverage: diff.newCoverage,
155
+ driftIntroduced: diff.driftIntroduced,
156
+ driftResolved: diff.driftResolved,
157
+ newUndocumented: diff.newUndocumented,
158
+ improvedExports: diff.improvedExports,
159
+ regressedExports: diff.regressedExports,
160
+
161
+ // Extended fields
162
+ memberChanges: diff.memberChanges,
163
+ categorizedBreaking: diff.categorizedBreaking,
164
+ docsImpact: diff.docsImpact,
165
+
166
+ // Metadata
167
+ generatedAt: new Date().toISOString(),
168
+ cached: false,
169
+ });
170
+ } catch (err) {
171
+ if (err instanceof Error) {
172
+ if (err.message === 'TIMEOUT') {
173
+ return c.json({ error: 'Spec generation timed out' }, 408);
174
+ }
175
+ if (err.message.includes('not found') || err.message.includes('404')) {
176
+ return c.json({ error: 'Repository or ref not found' }, 404);
177
+ }
178
+ if (err.message.includes('No token')) {
179
+ return c.json({ error: 'GitHub App access required' }, 403);
180
+ }
181
+ }
182
+
183
+ console.error('Spec diff error:', err);
184
+ return c.json({ error: 'Failed to compute diff' }, 500);
185
+ }
186
+ });
@@ -2,12 +2,13 @@
2
2
  * GitHub Check Runs and PR Comments
3
3
  */
4
4
 
5
+ import type { SpecDiffWithDocs } from '@doccov/sdk';
5
6
  import { getTokenByInstallationId } from './github-app';
6
7
 
7
8
  interface AnalysisResult {
8
- coveragePercent: number;
9
- documentedCount: number;
10
- totalCount: number;
9
+ coverageScore: number;
10
+ documentedExports: number;
11
+ totalExports: number;
11
12
  driftCount: number;
12
13
  qualityErrors?: number;
13
14
  qualityWarnings?: number;
@@ -20,6 +21,15 @@ interface AnalysisDiff {
20
21
  driftDelta: number;
21
22
  }
22
23
 
24
+ /**
25
+ * Rich diff result from spec-diff-core
26
+ */
27
+ export interface RichDiffResult {
28
+ diff: SpecDiffWithDocs;
29
+ base: { ref: string; sha: string };
30
+ head: { ref: string; sha: string };
31
+ }
32
+
23
33
  /**
24
34
  * Format a delta value with sign and styling
25
35
  */
@@ -49,14 +59,14 @@ export async function createCheckRun(
49
59
  const hasIssues = result.driftCount > 0 || (result.qualityErrors ?? 0) > 0;
50
60
  const conclusion = hasIssues ? 'neutral' : 'success';
51
61
 
52
- const title = `Coverage: ${result.coveragePercent.toFixed(1)}%${coverageDelta}`;
62
+ const title = `Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`;
53
63
 
54
64
  const summaryLines = [
55
65
  `## Documentation Coverage`,
56
66
  '',
57
67
  `\`\`\``,
58
- `Coverage: ${result.coveragePercent.toFixed(1)}%${coverageDelta}`,
59
- `├─ Documented: ${result.documentedCount}/${result.totalCount} exports`,
68
+ `Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`,
69
+ `├─ Documented: ${result.documentedExports}/${result.totalExports} exports`,
60
70
  `├─ Drift: ${result.driftCount} issue${result.driftCount !== 1 ? 's' : ''}${diff ? formatDelta(-diff.driftDelta) : ''}`,
61
71
  ];
62
72
 
@@ -153,13 +163,13 @@ export async function postPRComment(
153
163
  bodyLines.push('| Metric | This PR | Base | Δ |');
154
164
  bodyLines.push('|--------|---------|------|---|');
155
165
  bodyLines.push(
156
- `| Coverage | **${result.coveragePercent.toFixed(1)}%** | ${(result.coveragePercent - diff.coverageDelta).toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
166
+ `| Coverage | **${result.coverageScore.toFixed(1)}%** | ${(result.coverageScore - diff.coverageDelta).toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
157
167
  );
158
168
  bodyLines.push(
159
- `| Documented | ${result.documentedCount} | ${result.documentedCount - diff.documentedDelta} | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
169
+ `| Documented | ${result.documentedExports} | ${result.documentedExports - diff.documentedDelta} | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
160
170
  );
161
171
  bodyLines.push(
162
- `| Total exports | ${result.totalCount} | ${result.totalCount - diff.totalDelta} | ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} |`,
172
+ `| Total exports | ${result.totalExports} | ${result.totalExports - diff.totalDelta} | ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} |`,
163
173
  );
164
174
  const baseDrift = result.driftCount - diff.driftDelta;
165
175
  const driftSign = diff.driftDelta >= 0 ? '+' : '';
@@ -169,8 +179,8 @@ export async function postPRComment(
169
179
  } else {
170
180
  bodyLines.push('| Metric | Value |');
171
181
  bodyLines.push('|--------|-------|');
172
- bodyLines.push(`| Coverage | **${result.coveragePercent.toFixed(1)}%** |`);
173
- bodyLines.push(`| Documented | ${result.documentedCount} / ${result.totalCount} |`);
182
+ bodyLines.push(`| Coverage | **${result.coverageScore.toFixed(1)}%** |`);
183
+ bodyLines.push(`| Documented | ${result.documentedExports} / ${result.totalExports} |`);
174
184
  bodyLines.push(`| Drift issues | ${result.driftCount} |`);
175
185
  }
176
186
 
@@ -276,3 +286,213 @@ async function findExistingComment(
276
286
  return null;
277
287
  }
278
288
  }
289
+
290
+ /**
291
+ * Post enhanced PR comment with breaking changes and member changes
292
+ */
293
+ export async function postEnhancedPRComment(
294
+ installationId: string,
295
+ owner: string,
296
+ repo: string,
297
+ prNumber: number,
298
+ richDiff: RichDiffResult,
299
+ ): Promise<boolean> {
300
+ const token = await getTokenByInstallationId(installationId);
301
+ if (!token) return false;
302
+
303
+ const { diff, base, head } = richDiff;
304
+
305
+ const coverageEmoji = diff.coverageDelta > 0 ? '📈' : diff.coverageDelta < 0 ? '📉' : '➡️';
306
+
307
+ const bodyLines = [`## ${coverageEmoji} DocCov Report`, ''];
308
+
309
+ // Coverage summary
310
+ bodyLines.push('### Coverage');
311
+ bodyLines.push('| Metric | This PR | Base | Δ |');
312
+ bodyLines.push('|--------|---------|------|---|');
313
+ bodyLines.push(
314
+ `| Coverage | **${diff.newCoverage.toFixed(1)}%** | ${diff.oldCoverage.toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
315
+ );
316
+
317
+ if (diff.driftIntroduced > 0 || diff.driftResolved > 0) {
318
+ const driftChange = diff.driftIntroduced - diff.driftResolved;
319
+ bodyLines.push(
320
+ `| Drift | ${diff.driftIntroduced > 0 ? `+${diff.driftIntroduced} new` : '0 new'} | ${diff.driftResolved > 0 ? `${diff.driftResolved} fixed` : '0 fixed'} | ${driftChange >= 0 ? '+' : ''}${driftChange} |`,
321
+ );
322
+ }
323
+
324
+ bodyLines.push('');
325
+
326
+ // Breaking changes section
327
+ if (diff.breaking.length > 0) {
328
+ bodyLines.push('### ⚠️ Breaking Changes');
329
+ bodyLines.push('');
330
+
331
+ // Group by severity if available
332
+ if (diff.categorizedBreaking && diff.categorizedBreaking.length > 0) {
333
+ const high = diff.categorizedBreaking.filter((b) => b.severity === 'high');
334
+ const medium = diff.categorizedBreaking.filter((b) => b.severity === 'medium');
335
+ const low = diff.categorizedBreaking.filter((b) => b.severity === 'low');
336
+
337
+ if (high.length > 0) {
338
+ bodyLines.push(`**🔴 High Impact (${high.length})**`);
339
+ for (const b of high.slice(0, 5)) {
340
+ bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
341
+ }
342
+ if (high.length > 5) bodyLines.push(`- ...and ${high.length - 5} more`);
343
+ bodyLines.push('');
344
+ }
345
+
346
+ if (medium.length > 0) {
347
+ bodyLines.push(`**🟡 Medium Impact (${medium.length})**`);
348
+ for (const b of medium.slice(0, 5)) {
349
+ bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
350
+ }
351
+ if (medium.length > 5) bodyLines.push(`- ...and ${medium.length - 5} more`);
352
+ bodyLines.push('');
353
+ }
354
+
355
+ if (low.length > 0) {
356
+ bodyLines.push(`**🟢 Low Impact (${low.length})**`);
357
+ for (const b of low.slice(0, 3)) {
358
+ bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
359
+ }
360
+ if (low.length > 3) bodyLines.push(`- ...and ${low.length - 3} more`);
361
+ bodyLines.push('');
362
+ }
363
+ } else {
364
+ // Simple list (breaking is string[])
365
+ for (const b of diff.breaking.slice(0, 10)) {
366
+ bodyLines.push(`- \`${b}\``);
367
+ }
368
+ if (diff.breaking.length > 10) {
369
+ bodyLines.push(`- ...and ${diff.breaking.length - 10} more`);
370
+ }
371
+ bodyLines.push('');
372
+ }
373
+ }
374
+
375
+ // Member-level changes section
376
+ if (diff.memberChanges && diff.memberChanges.length > 0) {
377
+ bodyLines.push('### Method/Property Changes');
378
+ bodyLines.push('');
379
+
380
+ const removed = diff.memberChanges.filter((m) => m.changeType === 'removed');
381
+ const changed = diff.memberChanges.filter((m) => m.changeType === 'signature-changed');
382
+ const added = diff.memberChanges.filter((m) => m.changeType === 'added');
383
+
384
+ if (removed.length > 0) {
385
+ bodyLines.push(`**Removed (${removed.length})**`);
386
+ for (const m of removed.slice(0, 5)) {
387
+ bodyLines.push(`- \`${m.className}.${m.memberName}\``);
388
+ }
389
+ if (removed.length > 5) bodyLines.push(`- ...and ${removed.length - 5} more`);
390
+ bodyLines.push('');
391
+ }
392
+
393
+ if (changed.length > 0) {
394
+ bodyLines.push(`**Signature Changed (${changed.length})**`);
395
+ for (const m of changed.slice(0, 5)) {
396
+ bodyLines.push(`- \`${m.className}.${m.memberName}\``);
397
+ }
398
+ if (changed.length > 5) bodyLines.push(`- ...and ${changed.length - 5} more`);
399
+ bodyLines.push('');
400
+ }
401
+
402
+ if (added.length > 0) {
403
+ bodyLines.push(`**Added (${added.length})**`);
404
+ for (const m of added.slice(0, 3)) {
405
+ bodyLines.push(`- \`${m.className}.${m.memberName}\``);
406
+ }
407
+ if (added.length > 3) bodyLines.push(`- ...and ${added.length - 3} more`);
408
+ bodyLines.push('');
409
+ }
410
+ }
411
+
412
+ // New exports section
413
+ if (diff.nonBreaking.length > 0) {
414
+ bodyLines.push(`### ✨ New Exports (${diff.nonBreaking.length})`);
415
+ for (const name of diff.nonBreaking.slice(0, 5)) {
416
+ bodyLines.push(`- \`${name}\``);
417
+ }
418
+ if (diff.nonBreaking.length > 5) {
419
+ bodyLines.push(`- ...and ${diff.nonBreaking.length - 5} more`);
420
+ }
421
+ bodyLines.push('');
422
+ }
423
+
424
+ // New undocumented exports warning
425
+ if (diff.newUndocumented.length > 0) {
426
+ bodyLines.push(`### ⚠️ New Undocumented Exports (${diff.newUndocumented.length})`);
427
+ bodyLines.push('');
428
+ bodyLines.push('These new exports are missing documentation:');
429
+ for (const name of diff.newUndocumented.slice(0, 5)) {
430
+ bodyLines.push(`- \`${name}\``);
431
+ }
432
+ if (diff.newUndocumented.length > 5) {
433
+ bodyLines.push(`- ...and ${diff.newUndocumented.length - 5} more`);
434
+ }
435
+ bodyLines.push('');
436
+ }
437
+
438
+ // Status message
439
+ if (diff.breaking.length > 0) {
440
+ bodyLines.push('❌ **Breaking changes detected.** Review carefully before merging.');
441
+ } else if (diff.newUndocumented.length > 0) {
442
+ bodyLines.push('⚠️ New exports need documentation. Run `doccov check --fix --generate`.');
443
+ } else if (diff.coverageDelta < 0) {
444
+ bodyLines.push('⚠️ Coverage decreased. Consider adding documentation.');
445
+ } else {
446
+ bodyLines.push('✅ No breaking changes. Documentation is in sync.');
447
+ }
448
+
449
+ bodyLines.push('');
450
+ bodyLines.push(
451
+ `<sub>Comparing \`${base.ref}\` (${base.sha.slice(0, 7)}) → \`${head.ref}\` (${head.sha.slice(0, 7)})</sub>`,
452
+ );
453
+ bodyLines.push('');
454
+ bodyLines.push('---');
455
+ bodyLines.push('*Generated by [DocCov](https://doccov.com)*');
456
+
457
+ const body = bodyLines.join('\n');
458
+
459
+ try {
460
+ const existingId = await findExistingComment(token, owner, repo, prNumber);
461
+
462
+ if (existingId) {
463
+ const response = await fetch(
464
+ `https://api.github.com/repos/${owner}/${repo}/issues/comments/${existingId}`,
465
+ {
466
+ method: 'PATCH',
467
+ headers: {
468
+ Authorization: `Bearer ${token}`,
469
+ Accept: 'application/vnd.github+json',
470
+ 'X-GitHub-Api-Version': '2022-11-28',
471
+ 'Content-Type': 'application/json',
472
+ },
473
+ body: JSON.stringify({ body }),
474
+ },
475
+ );
476
+ return response.ok;
477
+ }
478
+
479
+ const response = await fetch(
480
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
481
+ {
482
+ method: 'POST',
483
+ headers: {
484
+ Authorization: `Bearer ${token}`,
485
+ Accept: 'application/vnd.github+json',
486
+ 'X-GitHub-Api-Version': '2022-11-28',
487
+ 'Content-Type': 'application/json',
488
+ },
489
+ body: JSON.stringify({ body }),
490
+ },
491
+ );
492
+
493
+ return response.ok;
494
+ } catch (err) {
495
+ console.error('Error posting enhanced PR comment:', err);
496
+ return false;
497
+ }
498
+ }
@@ -13,9 +13,9 @@ import { getTokenByInstallationId } from './github-app';
13
13
  * Result from remote analysis.
14
14
  */
15
15
  export interface RemoteAnalysisResult {
16
- coveragePercent: number;
17
- documentedCount: number;
18
- totalCount: number;
16
+ coverageScore: number;
17
+ documentedExports: number;
18
+ totalExports: number;
19
19
  driftCount: number;
20
20
  qualityErrors: number;
21
21
  qualityWarnings: number;
@@ -190,9 +190,9 @@ export async function analyzeRemoteRepo(
190
190
 
191
191
  // Extract metrics
192
192
  const docs = enriched.docs;
193
- const coveragePercent = docs?.coverageScore ?? 0;
194
- const documentedCount = docs?.documented ?? 0;
195
- const totalCount = docs?.total ?? 0;
193
+ const coverageScore = docs?.coverageScore ?? 0;
194
+ const documentedExports = docs?.documented ?? 0;
195
+ const totalExports = docs?.total ?? 0;
196
196
  const driftCount = docs?.drift?.length ?? 0;
197
197
 
198
198
  // Count quality issues
@@ -207,9 +207,9 @@ export async function analyzeRemoteRepo(
207
207
  }
208
208
 
209
209
  return {
210
- coveragePercent,
211
- documentedCount,
212
- totalCount,
210
+ coverageScore,
211
+ documentedExports,
212
+ totalExports,
213
213
  driftCount,
214
214
  qualityErrors,
215
215
  qualityWarnings,
@@ -243,9 +243,9 @@ export function computeAnalysisDiff(
243
243
  driftDelta: number;
244
244
  } {
245
245
  return {
246
- coverageDelta: Number((head.coveragePercent - base.coveragePercent).toFixed(1)),
247
- documentedDelta: head.documentedCount - base.documentedCount,
248
- totalDelta: head.totalCount - base.totalCount,
246
+ coverageDelta: Number((head.coverageScore - base.coverageScore).toFixed(1)),
247
+ documentedDelta: head.documentedExports - base.documentedExports,
248
+ totalDelta: head.totalExports - base.totalExports,
249
249
  driftDelta: head.driftCount - base.driftCount,
250
250
  };
251
251
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * In-memory LRU cache for specs and diffs.
3
+ * TTL: 1 hour, Max entries: 100 per cache
4
+ */
5
+
6
+ import type { SpecDiffWithDocs } from '@doccov/sdk';
7
+ import type { OpenPkg } from '@openpkg-ts/spec';
8
+
9
+ interface CacheEntry<T> {
10
+ value: T;
11
+ createdAt: number;
12
+ }
13
+
14
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
15
+ const MAX_ENTRIES = 100;
16
+
17
+ // Spec cache: key = `${owner}/${repo}/${sha}`
18
+ const specCache = new Map<string, CacheEntry<OpenPkg>>();
19
+
20
+ // Diff cache: key = `${baseSha}_${headSha}`
21
+ const diffCache = new Map<string, CacheEntry<SpecDiffWithDocs>>();
22
+
23
+ /**
24
+ * Evict oldest entries if cache exceeds max size
25
+ */
26
+ function evictOldest<T>(cache: Map<string, CacheEntry<T>>): void {
27
+ if (cache.size <= MAX_ENTRIES) return;
28
+
29
+ // Find and delete oldest entry
30
+ let oldestKey: string | null = null;
31
+ let oldestTime = Infinity;
32
+
33
+ for (const [key, entry] of cache) {
34
+ if (entry.createdAt < oldestTime) {
35
+ oldestTime = entry.createdAt;
36
+ oldestKey = key;
37
+ }
38
+ }
39
+
40
+ if (oldestKey) {
41
+ cache.delete(oldestKey);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Check if entry is still valid (not expired)
47
+ */
48
+ function isValid<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
49
+ if (!entry) return false;
50
+ return Date.now() - entry.createdAt < CACHE_TTL_MS;
51
+ }
52
+
53
+ /**
54
+ * Generate cache key for spec
55
+ */
56
+ export function specCacheKey(owner: string, repo: string, sha: string): string {
57
+ return `${owner}/${repo}/${sha}`;
58
+ }
59
+
60
+ /**
61
+ * Generate cache key for diff
62
+ */
63
+ export function diffCacheKey(baseSha: string, headSha: string): string {
64
+ return `${baseSha}_${headSha}`;
65
+ }
66
+
67
+ /**
68
+ * Get cached spec if available and not expired
69
+ */
70
+ export function getCachedSpec(owner: string, repo: string, sha: string): OpenPkg | null {
71
+ const key = specCacheKey(owner, repo, sha);
72
+ const entry = specCache.get(key);
73
+
74
+ if (!isValid(entry)) {
75
+ if (entry) specCache.delete(key);
76
+ return null;
77
+ }
78
+
79
+ return entry.value;
80
+ }
81
+
82
+ /**
83
+ * Cache a spec
84
+ */
85
+ export function setCachedSpec(owner: string, repo: string, sha: string, spec: OpenPkg): void {
86
+ const key = specCacheKey(owner, repo, sha);
87
+ specCache.set(key, { value: spec, createdAt: Date.now() });
88
+ evictOldest(specCache);
89
+ }
90
+
91
+ /**
92
+ * Get cached diff if available and not expired
93
+ */
94
+ export function getCachedDiff(baseSha: string, headSha: string): SpecDiffWithDocs | null {
95
+ const key = diffCacheKey(baseSha, headSha);
96
+ const entry = diffCache.get(key);
97
+
98
+ if (!isValid(entry)) {
99
+ if (entry) diffCache.delete(key);
100
+ return null;
101
+ }
102
+
103
+ return entry.value;
104
+ }
105
+
106
+ /**
107
+ * Cache a diff result
108
+ */
109
+ export function setCachedDiff(baseSha: string, headSha: string, diff: SpecDiffWithDocs): void {
110
+ const key = diffCacheKey(baseSha, headSha);
111
+ diffCache.set(key, { value: diff, createdAt: Date.now() });
112
+ evictOldest(diffCache);
113
+ }
114
+
115
+ /**
116
+ * Clear all caches (for testing)
117
+ */
118
+ export function clearCaches(): void {
119
+ specCache.clear();
120
+ diffCache.clear();
121
+ }
122
+
123
+ /**
124
+ * Get cache stats (for monitoring)
125
+ */
126
+ export function getCacheStats(): { specCount: number; diffCount: number } {
127
+ return {
128
+ specCount: specCache.size,
129
+ diffCount: diffCache.size,
130
+ };
131
+ }