specproof 0.1.0 → 0.2.1

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,83 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+
3
+ import { api, authenticate } from "./client";
4
+
5
+ beforeAll(async () => {
6
+ const login = await api.post("/auth/login", {
7
+ email: "ada@example.com",
8
+ password: "correct horse battery staple",
9
+ });
10
+ authenticate(login.body.token ?? null);
11
+ });
12
+
13
+ describe("GET /tasks", () => {
14
+ it("lists tasks for the authenticated user", async () => {
15
+ const res = await api.get("/tasks");
16
+
17
+ expect(res.status).toBe(200);
18
+ expect(Array.isArray(res.body.tasks)).toBe(true);
19
+ });
20
+
21
+ it("rejects requests without a bearer token", async () => {
22
+ const res = await api.get("/tasks", { auth: false });
23
+
24
+ expect(res.status).toBe(401);
25
+ });
26
+ });
27
+
28
+ describe("POST /tasks", () => {
29
+ it("creates a task and returns it", async () => {
30
+ const res = await api.post("/tasks", {
31
+ title: "Ship the Q3 roadmap",
32
+ projectId: "proj_1",
33
+ });
34
+
35
+ expect(res.status).toBe(201);
36
+ expect(res.body.id).toBeDefined();
37
+ expect(res.body.title).toBe("Ship the Q3 roadmap");
38
+ });
39
+
40
+ it("rejects a task without a title", async () => {
41
+ const res = await api.post("/tasks", { projectId: "proj_1" });
42
+
43
+ expect(res.status).toBe(400);
44
+ expect(res.body.error).toBe("title is required");
45
+ });
46
+
47
+ it("rejects a due date in the past", async () => {
48
+ const res = await api.post("/tasks", {
49
+ title: "Time travel",
50
+ dueDate: "1999-12-31",
51
+ });
52
+
53
+ expect(res.status).toBe(422);
54
+ });
55
+ });
56
+
57
+ describe("GET /tasks/{taskId}", () => {
58
+ it("returns a task by id", async () => {
59
+ const created = await api.post("/tasks", { title: "Write release notes" });
60
+ const res = await api.get(`/tasks/${created.body.id}`);
61
+
62
+ expect(res.status).toBe(200);
63
+ expect(res.body.id).toBe(created.body.id);
64
+ });
65
+
66
+ it("returns 404 for an unknown task id", async () => {
67
+ const res = await api.get("/tasks/task_does_not_exist");
68
+
69
+ expect(res.status).toBe(404);
70
+ });
71
+ });
72
+
73
+ describe("PATCH /tasks/{taskId}", () => {
74
+ it("updates the fields of a task", async () => {
75
+ const created = await api.post("/tasks", { title: "Refine the backlog" });
76
+ const res = await api.patch(`/tasks/${created.body.id}`, {
77
+ title: "Refine and prioritize the backlog",
78
+ });
79
+
80
+ expect(res.status).toBe(200);
81
+ expect(res.body.title).toBe("Refine and prioritize the backlog");
82
+ });
83
+ });
@@ -0,0 +1,423 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ buildCoverageReport,
8
+ collectTestEvidence,
9
+ dedent,
10
+ extractItBlocks,
11
+ operationKey,
12
+ parseTestFile,
13
+ resolveSpecPath
14
+ } from '@/lib/api-test-coverage';
15
+
16
+ // The analyzer scans this repo's own *.test.ts files (as raw text) when
17
+ // auditing the bundled example, so every inline fixture below uses operations
18
+ // (/widgets, /gadgets) that do not exist in example/api/openapi.json — that
19
+ // keeps this file invisible to the generated proof.
20
+
21
+ const tmpRoots: string[] = [];
22
+
23
+ /** Materialize a fixture repo in a temp directory */
24
+ function makeRepo(files: Record<string, string>): string {
25
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'specproof-unit-'));
26
+ tmpRoots.push(root);
27
+ for (const [rel, content] of Object.entries(files)) {
28
+ const abs = path.join(root, rel);
29
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
30
+ fs.writeFileSync(abs, content);
31
+ }
32
+ return root;
33
+ }
34
+
35
+ const savedSpecEnv = process.env.SPECPROOF_SPEC;
36
+ delete process.env.SPECPROOF_SPEC;
37
+
38
+ afterEach(() => {
39
+ delete process.env.SPECPROOF_SPEC;
40
+ while (tmpRoots.length > 0) {
41
+ fs.rmSync(tmpRoots.pop()!, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ afterAll(() => {
46
+ if (savedSpecEnv !== undefined) process.env.SPECPROOF_SPEC = savedSpecEnv;
47
+ });
48
+
49
+ describe('operationKey', () => {
50
+ it('lowercases the method and keeps literal segments', () => {
51
+ expect(operationKey('GET', '/widgets')).toBe('get /widgets');
52
+ });
53
+
54
+ it('treats {param}, [param], and :param segments as equivalent', () => {
55
+ const key = operationKey('GET', '/widgets/{widgetId}');
56
+ expect(operationKey('get', '/widgets/[widgetId]')).toBe(key);
57
+ expect(operationKey('GET', '/widgets/:widgetId')).toBe(key);
58
+ });
59
+
60
+ it('normalizes params in any segment position', () => {
61
+ expect(operationKey('PUT', '/a/{x}/b/[y]/:z')).toBe('put /a/{}/b/{}/{}');
62
+ });
63
+
64
+ it('ignores a trailing slash', () => {
65
+ expect(operationKey('GET', '/widgets/')).toBe(operationKey('GET', '/widgets'));
66
+ });
67
+ });
68
+
69
+ describe('dedent', () => {
70
+ it('strips the common leading indentation', () => {
71
+ expect(dedent(' a();\n b();\n c();')).toBe('a();\n b();\nc();');
72
+ });
73
+
74
+ it('ignores blank lines when measuring the indent', () => {
75
+ expect(dedent(' a();\n\n b();')).toBe('a();\n\nb();');
76
+ });
77
+ });
78
+
79
+ describe('extractItBlocks', () => {
80
+ const segment = [
81
+ 'describe("GET /widgets", () => {',
82
+ ' it("lists widgets", async () => {',
83
+ ' const res = await api.get("/widgets");',
84
+ ' expect(res.status).toBe(200);',
85
+ ' });',
86
+ '',
87
+ " test('rejects anonymous calls', async () => {",
88
+ ' const res = await api.get("/widgets", { auth: false });',
89
+ ' expect(res.status).toEqual(401);',
90
+ ' });',
91
+ '});'
92
+ ].join('\n');
93
+
94
+ it('finds it() and test() blocks across quote styles', () => {
95
+ const blocks = extractItBlocks(segment, 0, segment);
96
+ expect(blocks.map((b) => b.title)).toEqual(['lists widgets', 'rejects anonymous calls']);
97
+
98
+ const backtick = 'describe("GET /widgets", () => {\n it(`uses backticks`, () => {\n });\n});';
99
+ expect(extractItBlocks(backtick, 0, backtick)[0].title).toBe('uses backticks');
100
+ });
101
+
102
+ it('extracts statuses from both toBe and toEqual assertions', () => {
103
+ const blocks = extractItBlocks(segment, 0, segment);
104
+ expect(blocks[0].statuses).toEqual(['200']);
105
+ expect(blocks[1].statuses).toEqual(['401']);
106
+ });
107
+
108
+ it('dedents the extracted source', () => {
109
+ const blocks = extractItBlocks(segment, 0, segment);
110
+ expect(blocks[0].source.startsWith('it("lists widgets"')).toBe(true);
111
+ expect(blocks[0].source.endsWith('\n});')).toBe(true);
112
+ });
113
+
114
+ it('reports 1-indexed start lines, offset into the full source', () => {
115
+ const blocks = extractItBlocks(segment, 0, segment);
116
+ expect(blocks[0].startLine).toBe(2);
117
+ expect(blocks[1].startLine).toBe(7);
118
+
119
+ const prefix = '// header\n\n';
120
+ const fullSource = prefix + segment;
121
+ const shifted = extractItBlocks(segment, prefix.length, fullSource);
122
+ expect(shifted[0].startLine).toBe(4);
123
+ });
124
+
125
+ it('skips nested closers at deeper indentation', () => {
126
+ const nested = [
127
+ 'describe("POST /widgets", () => {',
128
+ ' it("creates a widget", async () => {',
129
+ ' await withRetry(async () => {',
130
+ ' await api.post("/widgets");',
131
+ ' });',
132
+ ' expect(res.status).toBe(201);',
133
+ ' });',
134
+ '});'
135
+ ].join('\n');
136
+ const blocks = extractItBlocks(nested, 0, nested);
137
+ expect(blocks).toHaveLength(1);
138
+ expect(blocks[0].statuses).toEqual(['201']);
139
+ expect(blocks[0].source.endsWith('\n});')).toBe(true);
140
+ });
141
+
142
+ it('runs to the segment end when no closer matches', () => {
143
+ const unclosed = 'describe("GET /widgets", () => {\n it("dangles", () => {\n expect(res.status).toBe(200);';
144
+ const blocks = extractItBlocks(unclosed, 0, unclosed);
145
+ expect(blocks).toHaveLength(1);
146
+ expect(blocks[0].statuses).toEqual(['200']);
147
+ });
148
+ });
149
+
150
+ describe('parseTestFile', () => {
151
+ it('keys evidence by operation and ignores non-operation describes', () => {
152
+ const source = [
153
+ 'import { api } from "./client";',
154
+ '',
155
+ 'describe("GET /widgets", () => {',
156
+ ' it("lists widgets", async () => {',
157
+ ' expect(res.status).toBe(200);',
158
+ ' });',
159
+ '});',
160
+ '',
161
+ 'describe("widget helpers", () => {',
162
+ ' it("is not an operation", () => {',
163
+ ' expect(res.status).toBe(500);',
164
+ ' });',
165
+ '});',
166
+ '',
167
+ 'describe("POST /widgets", () => {',
168
+ ' it("creates twice", async () => {',
169
+ ' expect(one.status).toBe(201);',
170
+ ' expect(two.status).toBe(201);',
171
+ ' });',
172
+ '});'
173
+ ].join('\n');
174
+
175
+ const parsed = parseTestFile(source, 'tests/widgets.test.ts');
176
+ expect([...parsed.keys()].sort()).toEqual(['get /widgets', 'post /widgets']);
177
+
178
+ const get = parsed.get('get /widgets')!;
179
+ expect(get.testFile).toBe('tests/widgets.test.ts');
180
+ expect(get.testCount).toBe(1);
181
+ expect([...get.statuses.entries()]).toEqual([['200', 1]]);
182
+
183
+ // The 500 in the non-operation describe is attributed to nothing.
184
+ const post = parsed.get('post /widgets')!;
185
+ expect(post.statuses.has('500')).toBe(false);
186
+ // Two assertions of the same status count twice, but the single it()
187
+ // block yields one snippet.
188
+ expect(post.statuses.get('201')).toBe(2);
189
+ expect(post.snippets.get('201')).toHaveLength(1);
190
+ });
191
+
192
+ it('merges evidence when two describes name the same operation', () => {
193
+ const source = [
194
+ 'describe("GET /gadgets", () => {',
195
+ ' it("lists", async () => {',
196
+ ' expect(res.status).toBe(200);',
197
+ ' });',
198
+ '});',
199
+ 'describe("GET /gadgets", () => {',
200
+ ' it("paginates", async () => {',
201
+ ' expect(res.status).toBe(206);',
202
+ ' });',
203
+ '});'
204
+ ].join('\n');
205
+
206
+ const parsed = parseTestFile(source, 'tests/gadgets.test.ts');
207
+ const evidence = parsed.get('get /gadgets')!;
208
+ expect(evidence.testCount).toBe(2);
209
+ expect([...evidence.statuses.keys()].sort()).toEqual(['200', '206']);
210
+ });
211
+
212
+ it('files one it() asserting two statuses as a snippet under each', () => {
213
+ const source = [
214
+ 'describe("DELETE /gadgets/{gadgetId}", () => {',
215
+ ' it("deletes then 404s", async () => {',
216
+ ' expect(first.status).toBe(204);',
217
+ ' expect(second.status).toBe(404);',
218
+ ' });',
219
+ '});'
220
+ ].join('\n');
221
+
222
+ const evidence = parseTestFile(source, 'tests/gadgets.test.ts').get('delete /gadgets/{}')!;
223
+ expect(evidence.snippets.get('204')![0].title).toBe('deletes then 404s');
224
+ expect(evidence.snippets.get('404')![0].title).toBe('deletes then 404s');
225
+ });
226
+
227
+ it('counts assertions outside it() blocks but yields no snippet for them', () => {
228
+ const source = [
229
+ 'describe("PATCH /gadgets/{gadgetId}", () => {',
230
+ ' const conflict = (res) => expect(res.status).toBe(409);',
231
+ ' it("renames a gadget", async () => {',
232
+ ' expect(res.status).toBe(200);',
233
+ ' });',
234
+ '});'
235
+ ].join('\n');
236
+
237
+ const evidence = parseTestFile(source, 'tests/gadgets.test.ts').get('patch /gadgets/{}')!;
238
+ expect(evidence.statuses.get('409')).toBe(1);
239
+ expect(evidence.snippets.has('409')).toBe(false);
240
+ expect(evidence.snippets.get('200')).toHaveLength(1);
241
+ });
242
+ });
243
+
244
+ describe('resolveSpecPath', () => {
245
+ it('picks the shallowest spec in the tree', () => {
246
+ const root = makeRepo({
247
+ 'docs/openapi.json': '{}',
248
+ 'openapi.json': '{}'
249
+ });
250
+ expect(resolveSpecPath(root)).toBe(path.join(root, 'openapi.json'));
251
+ });
252
+
253
+ it('breaks equal-depth ties alphabetically', () => {
254
+ const root = makeRepo({
255
+ 'b/openapi.json': '{}',
256
+ 'a/openapi.json': '{}'
257
+ });
258
+ expect(resolveSpecPath(root)).toBe(path.join(root, 'a/openapi.json'));
259
+ });
260
+
261
+ it('matches swagger-prefixed specs', () => {
262
+ const root = makeRepo({ 'swagger-v2.json': '{}' });
263
+ expect(resolveSpecPath(root)).toBe(path.join(root, 'swagger-v2.json'));
264
+ });
265
+
266
+ it('never looks inside excluded or dot directories', () => {
267
+ const root = makeRepo({
268
+ 'node_modules/dep/openapi.json': '{}',
269
+ '.hidden/openapi.json': '{}'
270
+ });
271
+ expect(resolveSpecPath(root)).toBeNull();
272
+ });
273
+
274
+ it('returns null for a nonexistent root', () => {
275
+ expect(resolveSpecPath(path.join(os.tmpdir(), 'specproof-no-such-dir'))).toBeNull();
276
+ });
277
+
278
+ it('SPECPROOF_SPEC overrides discovery', () => {
279
+ const root = makeRepo({
280
+ 'api/custom-spec.json': '{}',
281
+ 'openapi.json': '{}'
282
+ });
283
+ process.env.SPECPROOF_SPEC = 'api/custom-spec.json';
284
+ expect(resolveSpecPath(root)).toBe(path.join(root, 'api/custom-spec.json'));
285
+ });
286
+
287
+ it('SPECPROOF_SPEC pointing at a missing file yields null, not a fallback', () => {
288
+ const root = makeRepo({ 'openapi.json': '{}' });
289
+ process.env.SPECPROOF_SPEC = 'missing.json';
290
+ expect(resolveSpecPath(root)).toBeNull();
291
+ });
292
+ });
293
+
294
+ const widgetDescribe = (title: string, its: Array<[string, string]>) =>
295
+ [
296
+ `describe("${title}", () => {`,
297
+ ...its.flatMap(([name, status]) => [
298
+ ` it("${name}", async () => {`,
299
+ ` expect(res.status).toBe(${status});`,
300
+ ' });'
301
+ ]),
302
+ '});'
303
+ ].join('\n');
304
+
305
+ describe('collectTestEvidence', () => {
306
+ it('lets the file with the most it() blocks win an operation', () => {
307
+ const root = makeRepo({
308
+ 'tests/rich.test.ts': widgetDescribe('GET /widgets', [
309
+ ['lists', '200'],
310
+ ['rejects', '401']
311
+ ]),
312
+ 'tests/poor.test.ts': widgetDescribe('GET /widgets', [['lists', '200']])
313
+ });
314
+ const evidence = collectTestEvidence(root).get('get /widgets')!;
315
+ expect(evidence.testFile).toBe('tests/rich.test.ts');
316
+ expect(evidence.testCount).toBe(2);
317
+ });
318
+
319
+ it('breaks ties in favor of the alphabetically first file', () => {
320
+ const root = makeRepo({
321
+ 'tests/b.test.ts': widgetDescribe('GET /widgets', [['lists', '200']]),
322
+ 'tests/a.test.ts': widgetDescribe('GET /widgets', [['lists', '200']])
323
+ });
324
+ expect(collectTestEvidence(root).get('get /widgets')!.testFile).toBe('tests/a.test.ts');
325
+ });
326
+ });
327
+
328
+ describe('buildCoverageReport', () => {
329
+ const spec = {
330
+ tags: [{ name: 'Widgets', description: 'Widget operations' }],
331
+ paths: {
332
+ '/widgets': {
333
+ get: {
334
+ summary: 'List widgets',
335
+ tags: ['Widgets'],
336
+ responses: {
337
+ '200': { description: 'OK' },
338
+ '401': { description: 'Unauthorized' }
339
+ }
340
+ },
341
+ post: {
342
+ tags: ['Widgets'],
343
+ responses: { '201': { description: 'Created' } }
344
+ }
345
+ },
346
+ '/widgets/{widgetId}': {
347
+ delete: { tags: ['Admin'], responses: { '204': { description: 'Deleted' } } }
348
+ },
349
+ '/gadgets': {
350
+ get: { responses: { '200': { description: 'OK' } } }
351
+ }
352
+ }
353
+ };
354
+
355
+ const fixtureRepo = () =>
356
+ makeRepo({
357
+ 'openapi.json': JSON.stringify(spec),
358
+ 'tests/widgets.test.ts': [
359
+ widgetDescribe('GET /widgets', [['lists widgets', '200']]),
360
+ widgetDescribe('POST /widgets', [
361
+ ['creates a widget', '201'],
362
+ ['rejects bad payloads', '422']
363
+ ])
364
+ ].join('\n'),
365
+ // Trailing slash in the describe title still joins against /gadgets.
366
+ 'tests/gadgets.test.ts': widgetDescribe('GET /gadgets/', [['lists gadgets', '200']])
367
+ });
368
+
369
+ it('joins spec operations against test evidence and counts coverage', () => {
370
+ const report = buildCoverageReport(fixtureRepo());
371
+
372
+ expect(report.operationCount).toBe(4);
373
+ expect(report.coveredCount).toBe(3);
374
+ expect(report.totalCount).toBe(5);
375
+ expect(report.untestedOperations).toBe(1);
376
+
377
+ const widgets = report.tags.find((t) => t.tag === 'Widgets')!;
378
+ const get = widgets.operations.find((op) => op.method === 'get')!;
379
+ expect(get.summary).toBe('List widgets');
380
+ expect(get.coveredCount).toBe(1);
381
+ expect(get.gapCount).toBe(1);
382
+ expect(get.testFile).toBe('tests/widgets.test.ts');
383
+ });
384
+
385
+ it('flags statuses the tests assert but the spec omits, sorted by code', () => {
386
+ const report = buildCoverageReport(fixtureRepo());
387
+ const post = report.tags
388
+ .find((t) => t.tag === 'Widgets')!
389
+ .operations.find((op) => op.method === 'post')!;
390
+
391
+ expect(post.statuses.map((s) => s.code)).toEqual(['201', '422']);
392
+ expect(post.statuses[1].documented).toBe(false);
393
+ expect(post.statuses[1].assertions).toBe(1);
394
+ // Undocumented statuses count toward neither coverage nor gaps.
395
+ expect(post.coveredCount).toBe(1);
396
+ expect(post.gapCount).toBe(0);
397
+ expect(post.summary).toBe('');
398
+ });
399
+
400
+ it('joins param-style and trailing-slash variants of spec paths', () => {
401
+ const report = buildCoverageReport(fixtureRepo());
402
+ const gadgets = report.tags.find((t) => t.tag === 'Other')!.operations[0];
403
+ expect(gadgets.testFile).toBe('tests/gadgets.test.ts');
404
+ expect(gadgets.coveredCount).toBe(1);
405
+ });
406
+
407
+ it('orders tags by the spec, unknown tags last, untagged ops under Other', () => {
408
+ const report = buildCoverageReport(fixtureRepo());
409
+ expect(report.tags.map((t) => t.tag)).toEqual(['Widgets', 'Admin', 'Other']);
410
+ expect(report.tags[0].description).toBe('Widget operations');
411
+ expect(report.tags[1].description).toBe('');
412
+
413
+ const del = report.tags[1].operations[0];
414
+ expect(del.testFile).toBeNull();
415
+ expect(del.testCount).toBe(0);
416
+ expect(del.gapCount).toBe(1);
417
+ });
418
+
419
+ it('throws when the repo has no spec to audit', () => {
420
+ const root = makeRepo({ 'readme.md': 'nothing here' });
421
+ expect(() => buildCoverageReport(root)).toThrow(/no OpenAPI spec found/);
422
+ });
423
+ });
@@ -140,7 +140,7 @@ export interface CoverageReport {
140
140
  // Test-file parsing
141
141
  // ============================================================================
142
142
 
143
- interface OperationEvidence {
143
+ export interface OperationEvidence {
144
144
  /** test file the evidence came from, relative to the repo root */
145
145
  testFile: string;
146
146
  /** statuses asserted, with assertion counts */
@@ -168,12 +168,12 @@ function normalizePath(urlPath: string): string {
168
168
  }
169
169
 
170
170
  /** Join key for one operation: "get /api/repo/{}" */
171
- function operationKey(method: string, urlPath: string): string {
171
+ export function operationKey(method: string, urlPath: string): string {
172
172
  return `${method.toLowerCase()} ${normalizePath(urlPath)}`;
173
173
  }
174
174
 
175
175
  /** Strip the common leading indentation from an extracted block */
176
- function dedent(block: string): string {
176
+ export function dedent(block: string): string {
177
177
  const lines = block.split('\n');
178
178
  const indents = lines
179
179
  .filter((line) => line.trim().length > 0)
@@ -187,7 +187,7 @@ function dedent(block: string): string {
187
187
  * first `});` back at the block's own indentation — reliable for
188
188
  * prettier-consistent test files.
189
189
  */
190
- function extractItBlocks(
190
+ export function extractItBlocks(
191
191
  segment: string,
192
192
  segmentOffset: number,
193
193
  fullSource: string
@@ -222,7 +222,7 @@ function extractItBlocks(
222
222
  * `.status).toEqual(NNN)` assertions and it() blocks. describe blocks whose
223
223
  * title doesn't start with an HTTP method + path are ignored.
224
224
  */
225
- function parseTestFile(source: string, testFile: string): Map<string, OperationEvidence> {
225
+ export function parseTestFile(source: string, testFile: string): Map<string, OperationEvidence> {
226
226
  const byOperation = new Map<string, OperationEvidence>();
227
227
  const describeRe = /describe\(\s*["'`]([^"'`]+)["'`]/g;
228
228
 
@@ -273,7 +273,7 @@ function parseTestFile(source: string, testFile: string): Map<string, OperationE
273
273
  * most it() blocks wins (ties broken alphabetically) — snippets must all cite
274
274
  * a single file.
275
275
  */
276
- function collectTestEvidence(repoRoot: string): Map<string, OperationEvidence> {
276
+ export function collectTestEvidence(repoRoot: string): Map<string, OperationEvidence> {
277
277
  const best = new Map<string, OperationEvidence>();
278
278
  const testFiles = walk(repoRoot, (name) => TEST_FILE_RE.test(name)).sort();
279
279
 
@@ -294,11 +294,11 @@ function collectTestEvidence(repoRoot: string): Map<string, OperationEvidence> {
294
294
  // Report assembly
295
295
  // ============================================================================
296
296
 
297
- export function buildCoverageReport(): CoverageReport {
298
- const specPath = resolveSpecPath();
297
+ export function buildCoverageReport(repoRoot: string = TARGET_REPO_ROOT): CoverageReport {
298
+ const specPath = resolveSpecPath(repoRoot);
299
299
  if (!specPath) {
300
300
  throw new Error(
301
- `api-test-coverage: no OpenAPI spec found under ${TARGET_REPO_ROOT} — ` +
301
+ `api-test-coverage: no OpenAPI spec found under ${repoRoot} — ` +
302
302
  'set SPECPROOF_REPO to the repo to audit, or SPECPROOF_SPEC to the spec file'
303
303
  );
304
304
  }
@@ -311,7 +311,7 @@ export function buildCoverageReport(): CoverageReport {
311
311
  >;
312
312
  };
313
313
 
314
- const evidenceByOperation = collectTestEvidence(TARGET_REPO_ROOT);
314
+ const evidenceByOperation = collectTestEvidence(repoRoot);
315
315
  const tagOrder = (spec.tags ?? []).map((t) => t.name);
316
316
  const tagDescriptions = new Map((spec.tags ?? []).map((t) => [t.name, t.description ?? '']));
317
317
  const byTag = new Map<string, OperationCoverage[]>();