@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @doccov/api
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat: add spec routes with caching/diff, auth system, cleanup unused pages
8
+
3
9
  ## 0.5.0
4
10
 
5
11
  ### Minor Changes
package/api/index.ts CHANGED
@@ -12,9 +12,10 @@ import type {
12
12
  BuildPlanStep,
13
13
  BuildPlanStepResult,
14
14
  BuildPlanTarget,
15
+ EnrichedOpenPkg,
15
16
  GitHubProjectContext,
16
17
  } from '@doccov/sdk';
17
- import { fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
18
+ import { enrichSpec, fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
18
19
  import type { OpenPkg } from '@openpkg-ts/spec';
19
20
  import type { VercelRequest, VercelResponse } from '@vercel/node';
20
21
  import { Sandbox } from '@vercel/sandbox';
@@ -163,6 +164,9 @@ const LOCAL_BINARIES = new Set([
163
164
  'publint',
164
165
  'attw',
165
166
  'are-the-types-wrong',
167
+ // Monorepo tools
168
+ 'lerna',
169
+ 'nx',
166
170
  ]);
167
171
 
168
172
  /**
@@ -213,28 +217,53 @@ interface SpecSummary {
213
217
  types: number;
214
218
  documented: number;
215
219
  undocumented: number;
220
+ driftCount: number;
221
+ topUndocumented: string[];
222
+ topDrift: Array<{ name: string; issue: string }>;
216
223
  }
217
224
 
218
225
  function createSpecSummary(spec: OpenPkg): SpecSummary {
219
- const exports = spec.exports?.length ?? 0;
220
- const types = spec.types?.length ?? 0;
226
+ // Enrich spec to get coverage and drift data
227
+ const enriched = enrichSpec(spec) as EnrichedOpenPkg;
221
228
 
222
- // Count documented vs undocumented exports
223
- const documented =
224
- spec.exports?.filter((e) => e.description && e.description.trim().length > 0).length ?? 0;
225
- const undocumented = exports - documented;
229
+ const totalExports = enriched.exports?.length ?? 0;
230
+ const types = enriched.types?.length ?? 0;
226
231
 
227
- // Calculate coverage (documented / total * 100)
228
- const coverage = exports > 0 ? Math.round((documented / exports) * 100) : 0;
232
+ // Use SDK coverage data
233
+ const coverageScore = enriched.docs?.coverageScore ?? 0;
234
+ const documented = enriched.docs?.documented ?? 0;
235
+ const undocumented = totalExports - documented;
236
+
237
+ // Collect drift issues from enriched exports
238
+ const driftItems: Array<{ name: string; issue: string }> = [];
239
+ const undocumentedNames: string[] = [];
240
+
241
+ for (const exp of enriched.exports ?? []) {
242
+ // Collect undocumented exports (no description)
243
+ if (!exp.description || exp.description.trim().length === 0) {
244
+ undocumentedNames.push(exp.name);
245
+ }
246
+
247
+ // Collect drift issues
248
+ for (const drift of exp.docs?.drift ?? []) {
249
+ driftItems.push({
250
+ name: exp.name,
251
+ issue: drift.issue ?? 'Documentation drift detected',
252
+ });
253
+ }
254
+ }
229
255
 
230
256
  return {
231
- name: spec.meta?.name ?? 'unknown',
232
- version: spec.meta?.version ?? '0.0.0',
233
- coverage,
234
- exports,
257
+ name: enriched.meta?.name ?? 'unknown',
258
+ version: enriched.meta?.version ?? '0.0.0',
259
+ coverage: coverageScore,
260
+ exports: totalExports,
235
261
  types,
236
262
  documented,
237
263
  undocumented,
264
+ driftCount: driftItems.length,
265
+ topUndocumented: undocumentedNames.slice(0, 5),
266
+ topDrift: driftItems.slice(0, 5),
238
267
  };
239
268
  }
240
269
 
@@ -345,6 +374,27 @@ Install Commands by Package Manager:
345
374
  - bun with lockfile: ["bun", "install", "--frozen-lockfile"]
346
375
  - bun without lockfile: ["bun", "install"]
347
376
 
377
+ Monorepo Build Strategy (CRITICAL):
378
+ When a target package is specified in a monorepo, ONLY build that package and its dependencies:
379
+
380
+ - Lerna monorepos: Use "lerna run build --scope=<package-name> --include-dependencies"
381
+ Example for @stacks/transactions: ["lerna", "run", "build", "--scope=@stacks/transactions", "--include-dependencies"]
382
+
383
+ - Turbo monorepos: Use "turbo run build --filter=<package-name>"
384
+ Example: ["turbo", "run", "build", "--filter=@stacks/transactions"]
385
+
386
+ - Nx monorepos: Use "nx run <project>:build"
387
+ Example: ["nx", "run", "transactions:build"]
388
+
389
+ - pnpm workspaces: Use "pnpm --filter <package-name> run build"
390
+ Example: ["pnpm", "--filter", "@stacks/transactions", "run", "build"]
391
+
392
+ - yarn workspaces: Use "yarn workspace <package-name> run build"
393
+ Example: ["yarn", "workspace", "@stacks/transactions", "run", "build"]
394
+
395
+ NEVER run a full monorepo build (npm run build at root) when a target package is specified.
396
+ This avoids building 20+ packages when we only need one.
397
+
348
398
  General Guidelines:
349
399
  - For TypeScript projects, look for "types" or "exports" fields in package.json
350
400
  - For monorepos, focus on the target package if specified
@@ -386,11 +436,22 @@ function transformToBuildPlan(
386
436
  context: GitHubProjectContext,
387
437
  options: GenerateBuildPlanOptions,
388
438
  ): BuildPlan {
439
+ // Derive package directory from entry points (e.g., "packages/transactions/src/index.ts" -> "packages/transactions")
440
+ let rootPath: string | undefined;
441
+ if (options.targetPackage && output.entryPoints.length > 0) {
442
+ const firstEntry = output.entryPoints[0];
443
+ // Match patterns like "packages/foo/src/..." or "packages/foo/dist/..."
444
+ const match = firstEntry.match(/^(packages\/[^/]+)\//);
445
+ if (match) {
446
+ rootPath = match[1];
447
+ }
448
+ }
449
+
389
450
  const target: BuildPlanTarget = {
390
451
  type: 'github',
391
452
  repoUrl: `https://github.com/${context.metadata.owner}/${context.metadata.repo}`,
392
453
  ref: context.ref,
393
- rootPath: options.targetPackage,
454
+ rootPath,
394
455
  entryPoints: output.entryPoints,
395
456
  };
396
457
 
@@ -745,13 +806,22 @@ async function handleExecute(req: VercelRequest, res: VercelResponse): Promise<v
745
806
  const specFilePath = plan.target.rootPath
746
807
  ? `${plan.target.rootPath}/openpkg.json`
747
808
  : 'openpkg.json';
748
- const specStream = await sandbox.readFile({ path: specFilePath });
749
- const chunks: Buffer[] = [];
750
- for await (const chunk of specStream as AsyncIterable<Buffer>) {
751
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
809
+
810
+ let spec: OpenPkg;
811
+ try {
812
+ const specStream = await sandbox.readFile({ path: specFilePath });
813
+ const chunks: Buffer[] = [];
814
+ for await (const chunk of specStream as AsyncIterable<Buffer>) {
815
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
816
+ }
817
+ const specContent = Buffer.concat(chunks).toString('utf-8');
818
+ spec = JSON.parse(specContent) as OpenPkg;
819
+ } catch (readError) {
820
+ console.error(`Failed to read ${specFilePath}:`, readError);
821
+ throw new Error(
822
+ `Failed to read spec file (${specFilePath}): ${readError instanceof Error ? readError.message : 'Unknown error'}`,
823
+ );
752
824
  }
753
- const specContent = Buffer.concat(chunks).toString('utf-8');
754
- const spec = JSON.parse(specContent) as OpenPkg;
755
825
  const summary = createSpecSummary(spec);
756
826
 
757
827
  const result = {
@@ -931,13 +1001,22 @@ async function handleExecuteStream(req: VercelRequest, res: VercelResponse): Pro
931
1001
  const specFilePath = plan.target.rootPath
932
1002
  ? `${plan.target.rootPath}/openpkg.json`
933
1003
  : 'openpkg.json';
934
- const specStream = await sandbox.readFile({ path: specFilePath });
935
- const chunks: Buffer[] = [];
936
- for await (const chunk of specStream as AsyncIterable<Buffer>) {
937
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1004
+
1005
+ let spec: OpenPkg;
1006
+ try {
1007
+ const specStream = await sandbox.readFile({ path: specFilePath });
1008
+ const chunks: Buffer[] = [];
1009
+ for await (const chunk of specStream as AsyncIterable<Buffer>) {
1010
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1011
+ }
1012
+ const specContent = Buffer.concat(chunks).toString('utf-8');
1013
+ spec = JSON.parse(specContent) as OpenPkg;
1014
+ } catch (readError) {
1015
+ console.error(`Failed to read ${specFilePath}:`, readError);
1016
+ throw new Error(
1017
+ `Failed to read spec file (${specFilePath}): ${readError instanceof Error ? readError.message : 'Unknown error'}`,
1018
+ );
938
1019
  }
939
- const specContent = Buffer.concat(chunks).toString('utf-8');
940
- const spec = JSON.parse(specContent) as OpenPkg;
941
1020
  const summary = createSpecSummary(spec);
942
1021
 
943
1022
  const result = {
@@ -0,0 +1,41 @@
1
+ import type { Kysely } from 'kysely';
2
+
3
+ /**
4
+ * Create coverage_snapshots table with SDK-aligned field names.
5
+ */
6
+ export async function up(db: Kysely<unknown>): Promise<void> {
7
+ await db.schema
8
+ .createTable('coverage_snapshots')
9
+ .addColumn('id', 'text', (col) => col.primaryKey())
10
+ .addColumn('project_id', 'text', (col) =>
11
+ col.notNull().references('projects.id').onDelete('cascade'),
12
+ )
13
+ .addColumn('version', 'text')
14
+ .addColumn('branch', 'text')
15
+ .addColumn('commit_sha', 'text')
16
+ // SDK-aligned field names
17
+ .addColumn('coverage_score', 'integer', (col) => col.notNull())
18
+ .addColumn('documented_exports', 'integer', (col) => col.notNull())
19
+ .addColumn('total_exports', 'integer', (col) => col.notNull())
20
+ .addColumn('drift_count', 'integer', (col) => col.notNull().defaultTo(0))
21
+ .addColumn('source', 'text', (col) => col.notNull().defaultTo('manual'))
22
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
23
+ .execute();
24
+
25
+ // Index for efficient project history queries
26
+ await db.schema
27
+ .createIndex('idx_coverage_snapshots_project_id')
28
+ .on('coverage_snapshots')
29
+ .column('project_id')
30
+ .execute();
31
+
32
+ await db.schema
33
+ .createIndex('idx_coverage_snapshots_created_at')
34
+ .on('coverage_snapshots')
35
+ .column('created_at')
36
+ .execute();
37
+ }
38
+
39
+ export async function down(db: Kysely<unknown>): Promise<void> {
40
+ await db.schema.dropTable('coverage_snapshots').execute();
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/api",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "DocCov API - Badge endpoint and coverage services",
5
5
  "keywords": [
6
6
  "doccov",
@@ -29,7 +29,6 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@ai-sdk/anthropic": "^2.0.55",
32
- "@doccov/db": "workspace:*",
33
32
  "@doccov/sdk": "^0.18.0",
34
33
  "@hono/node-server": "^1.14.3",
35
34
  "@openpkg-ts/spec": "^0.10.0",
@@ -42,6 +41,7 @@
42
41
  "ms": "^2.1.3",
43
42
  "nanoid": "^5.0.0",
44
43
  "pg": "^8.13.0",
44
+ "vercel": "^50.1.3",
45
45
  "zod": "^3.25.0"
46
46
  },
47
47
  "devDependencies": {
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ import { githubAppRoute } from './routes/github-app';
16
16
  import { invitesRoute } from './routes/invites';
17
17
  import { orgsRoute } from './routes/orgs';
18
18
  import { planRoute } from './routes/plan';
19
+ import { specRoute } from './routes/spec';
20
+ import { specV1Route } from './routes/spec-v1';
19
21
 
20
22
  const app = new Hono();
21
23
 
@@ -54,8 +56,10 @@ app.get('/', (c) => {
54
56
  invites: '/invites/:token',
55
57
  orgs: '/orgs/*',
56
58
  plan: '/plan',
59
+ spec: '/spec/diff (POST, session auth)',
57
60
  v1: {
58
61
  ai: '/v1/ai/generate (POST), /v1/ai/quota (GET)',
62
+ spec: '/v1/spec/diff (POST, API key)',
59
63
  },
60
64
  health: '/health',
61
65
  },
@@ -95,10 +99,12 @@ app.route('/billing', billingRoute);
95
99
  app.route('/coverage', coverageRoute);
96
100
  app.route('/orgs', orgsRoute);
97
101
  app.route('/plan', planRoute);
102
+ app.route('/spec', specRoute);
98
103
 
99
104
  // API endpoints (API key required)
100
105
  app.use('/v1/*', requireApiKey(), orgRateLimit());
101
106
  app.route('/v1/ai', aiRoute);
107
+ app.route('/v1/spec', specV1Route);
102
108
 
103
109
  // Vercel serverless handler + Bun auto-serves this export
104
110
  export default app;
@@ -92,13 +92,9 @@ coverageRoute.get('/projects/:projectId/history', async (c) => {
92
92
  'version',
93
93
  'branch',
94
94
  'commitSha',
95
- 'coveragePercent',
96
- 'documentedCount',
97
- 'totalCount',
98
- 'descriptionCount',
99
- 'paramsCount',
100
- 'returnsCount',
101
- 'examplesCount',
95
+ 'coverageScore',
96
+ 'documentedExports',
97
+ 'totalExports',
102
98
  'driftCount',
103
99
  'source',
104
100
  'createdAt',
@@ -129,13 +125,9 @@ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
129
125
  version?: string;
130
126
  branch?: string;
131
127
  commitSha?: string;
132
- coveragePercent: number;
133
- documentedCount: number;
134
- totalCount: number;
135
- descriptionCount?: number;
136
- paramsCount?: number;
137
- returnsCount?: number;
138
- examplesCount?: number;
128
+ coverageScore: number;
129
+ documentedExports: number;
130
+ totalExports: number;
139
131
  driftCount?: number;
140
132
  source?: 'ci' | 'manual' | 'scheduled';
141
133
  }>();
@@ -162,13 +154,9 @@ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
162
154
  version: body.version || null,
163
155
  branch: body.branch || null,
164
156
  commitSha: body.commitSha || null,
165
- coveragePercent: body.coveragePercent,
166
- documentedCount: body.documentedCount,
167
- totalCount: body.totalCount,
168
- descriptionCount: body.descriptionCount || null,
169
- paramsCount: body.paramsCount || null,
170
- returnsCount: body.returnsCount || null,
171
- examplesCount: body.examplesCount || null,
157
+ coverageScore: body.coverageScore,
158
+ documentedExports: body.documentedExports,
159
+ totalExports: body.totalExports,
172
160
  driftCount: body.driftCount || 0,
173
161
  source: body.source || 'manual',
174
162
  })
@@ -179,7 +167,7 @@ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
179
167
  await db
180
168
  .updateTable('projects')
181
169
  .set({
182
- coverageScore: body.coveragePercent,
170
+ coverageScore: body.coverageScore,
183
171
  driftCount: body.driftCount || 0,
184
172
  lastAnalyzedAt: new Date(),
185
173
  })
@@ -192,9 +180,9 @@ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
192
180
  // Helper: Generate insights from coverage data
193
181
  interface Snapshot {
194
182
  version: string | null;
195
- coveragePercent: number;
196
- documentedCount: number;
197
- totalCount: number;
183
+ coverageScore: number;
184
+ documentedExports: number;
185
+ totalExports: number;
198
186
  driftCount: number;
199
187
  }
200
188
 
@@ -210,7 +198,7 @@ function generateInsights(snapshots: Snapshot[]): Insight[] {
210
198
 
211
199
  const first = snapshots[0];
212
200
  const last = snapshots[snapshots.length - 1];
213
- const diff = last.coveragePercent - first.coveragePercent;
201
+ const diff = last.coverageScore - first.coverageScore;
214
202
 
215
203
  // Overall improvement/regression
216
204
  if (diff > 0) {
@@ -228,8 +216,8 @@ function generateInsights(snapshots: Snapshot[]): Insight[] {
228
216
  }
229
217
 
230
218
  // Predict time to 100%
231
- if (diff > 0 && last.coveragePercent < 100) {
232
- const remaining = 100 - last.coveragePercent;
219
+ if (diff > 0 && last.coverageScore < 100) {
220
+ const remaining = 100 - last.coverageScore;
233
221
  const avgGainPerSnapshot = diff / (snapshots.length - 1);
234
222
  if (avgGainPerSnapshot > 0) {
235
223
  const snapshotsToComplete = Math.ceil(remaining / avgGainPerSnapshot);
@@ -245,8 +233,7 @@ function generateInsights(snapshots: Snapshot[]): Insight[] {
245
233
  const milestones = [50, 75, 90, 100];
246
234
  for (const milestone of milestones) {
247
235
  const crossedAt = snapshots.findIndex(
248
- (s, i) =>
249
- i > 0 && s.coveragePercent >= milestone && snapshots[i - 1].coveragePercent < milestone,
236
+ (s, i) => i > 0 && s.coverageScore >= milestone && snapshots[i - 1].coverageScore < milestone,
250
237
  );
251
238
  if (crossedAt > 0) {
252
239
  insights.push({
@@ -271,7 +258,7 @@ function detectRegression(
271
258
  for (let i = 1; i < recent.length; i++) {
272
259
  const prev = recent[i - 1];
273
260
  const curr = recent[i];
274
- const drop = prev.coveragePercent - curr.coveragePercent;
261
+ const drop = prev.coverageScore - curr.coverageScore;
275
262
 
276
263
  if (drop >= 3) {
277
264
  // 3% or more drop
@@ -279,7 +266,7 @@ function detectRegression(
279
266
  fromVersion: prev.version || `v${i}`,
280
267
  toVersion: curr.version || `v${i + 1}`,
281
268
  coverageDrop: Math.round(drop),
282
- exportsLost: prev.documentedCount - curr.documentedCount,
269
+ exportsLost: prev.documentedExports - curr.documentedExports,
283
270
  };
284
271
  }
285
272
  }