specproof 0.2.1 → 0.4.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/README.md +35 -27
- package/components/CoverageProof.tsx +61 -63
- package/lib/api-test-coverage.ts +16 -0
- package/next.config.js +3 -0
- package/package.json +26 -10
- package/scripts/cli.ts +113 -0
- package/scripts/generate-proof.ts +102 -17
- package/.github/workflows/ci.yml +0 -69
- package/.github/workflows/release.yml +0 -49
- package/CHANGELOG.md +0 -52
- package/CLAUDE.md +0 -82
- package/app/proof-contract.test.ts +0 -95
- package/app/proof.generated.json +0 -288
- package/bun.lock +0 -991
- package/eslint.config.mjs +0 -43
- package/example/api/openapi.json +0 -88
- package/example/tests/auth.test.ts +0 -25
- package/example/tests/client.ts +0 -53
- package/example/tests/tasks.test.ts +0 -83
- package/lib/api-test-coverage.test.ts +0 -423
- package/marketing/index.html +0 -1083
- package/marketing/package.json +0 -10
- package/marketing/server.ts +0 -27
- package/public/banner.png +0 -0
- package/public/icon.png +0 -0
- package/public/logo.png +0 -0
- package/vitest.config.ts +0 -19
package/README.md
CHANGED
|
@@ -1,53 +1,61 @@
|
|
|
1
|
-
<img src="public/
|
|
1
|
+
<img src="https://raw.githubusercontent.com/Durable-Quality/specproof/main/public/icon.png" alt="SpecProof" width="120" />
|
|
2
2
|
|
|
3
3
|
# SpecProof
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Audit your API test coverage against your OpenAPI spec. SpecProof cross-examines every operation and response status in the spec against your test suite's assertions and renders the verdicts as a browsable report. Click any verdict to read the test that proves it, or see exactly what's untested.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **Sources of truth** (read from the target repo, `SPECPROOF_REPO`): the OpenAPI spec (auto-discovered `openapi*.json` / `swagger*.json`, or set `SPECPROOF_SPEC`) + the repo's `*.test.ts` files, parsed from `describe("METHOD /path")` blocks and `.status).toBe(NNN)` assertions. `{param}`, `[param]`, and `:param` path segments are treated as equivalent when matching.
|
|
10
|
-
- **Generator**: `scripts/generate-proof.ts` compiles them into `app/proof.generated.json` — checked in, deterministic, regenerated automatically before `dev`/`build`.
|
|
11
|
-
- **Consumer**: the app renders the checked-in artifact; no target checkout is needed to build or deploy.
|
|
12
|
-
- **Drift guard**: `app/proof-contract.test.ts` fails CI when the artifact is stale relative to the spec/tests, when the proof doesn't cover exactly the spec's operations, or when a quoted snippet no longer points at a real `it()` block. It self-skips when no target repo is configured.
|
|
13
|
-
|
|
14
|
-
## Setup
|
|
7
|
+
## Quick start
|
|
15
8
|
|
|
16
9
|
Requires [Bun](https://bun.sh).
|
|
17
10
|
|
|
18
11
|
```bash
|
|
19
|
-
bun install
|
|
20
|
-
|
|
12
|
+
bun add -d specproof # or npm install -D specproof
|
|
13
|
+
bunx specproof dev # audit the current repo → http://localhost:3001
|
|
21
14
|
```
|
|
22
15
|
|
|
23
|
-
|
|
16
|
+
The OpenAPI spec is auto-discovered (`openapi*.json` / `swagger*.json`); tests are your `*.test.ts` / `*.test.tsx` / `*.test.js` files.
|
|
24
17
|
|
|
25
|
-
##
|
|
18
|
+
## CLI
|
|
26
19
|
|
|
27
20
|
```bash
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
bun run generate:proof # regenerate app/proof.generated.json only
|
|
32
|
-
bun run test:unit # contract tests (self-skip without a configured target repo)
|
|
33
|
-
bun run lint # ESLint + tsc
|
|
21
|
+
specproof generate [--out proof.json] [--check] # compile the coverage proof
|
|
22
|
+
specproof dev # generate + serve the report
|
|
23
|
+
specproof build && specproof start # production build + serve
|
|
34
24
|
```
|
|
35
25
|
|
|
36
|
-
|
|
26
|
+
| Option | Env var | Meaning |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `--repo <path>` | `SPECPROOF_REPO` | Repo to audit (default: current directory) |
|
|
29
|
+
| `--spec <path>` | `SPECPROOF_SPEC` | Spec file, relative to the repo root, when auto-discovery doesn't apply |
|
|
30
|
+
| `--out <path>` | `SPECPROOF_OUT` | Where `generate` writes the proof |
|
|
31
|
+
| `--port <port>` | | Port for `dev` / `start` (default: 3001) |
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
## Drift guard in CI
|
|
34
|
+
|
|
35
|
+
Commit the proof next to your code, then have CI fail whenever the spec or tests change without regenerating it:
|
|
39
36
|
|
|
40
37
|
```bash
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
specproof generate --out specproof.json # regenerate + commit
|
|
39
|
+
specproof generate --out specproof.json --check # CI: exits 1 when stale
|
|
43
40
|
```
|
|
44
41
|
|
|
45
42
|
## Conventions the parser relies on
|
|
46
43
|
|
|
47
|
-
- Test suites titled `describe("METHOD /path")` (`GET|POST|PUT|DELETE|PATCH`)
|
|
44
|
+
- Test suites titled `describe("METHOD /path")` (`GET|POST|PUT|DELETE|PATCH`). The path is matched against the spec's paths; `{param}`, `[param]`, and `:param` segments are equivalent.
|
|
48
45
|
- Status assertions written as `.status).toBe(NNN)` or `.status).toEqual(NNN)`.
|
|
49
46
|
- Prettier-consistent formatting: `it()`/`test()` blocks are extracted by indentation, not a full parser.
|
|
50
47
|
|
|
51
|
-
##
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bun install
|
|
52
|
+
bun run dev # audits the bundled example/ fixture on port 3001
|
|
53
|
+
bun run test:unit # analyzer unit tests + proof drift guards
|
|
54
|
+
bun run lint # ESLint + tsc
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Point a checkout at a real repo with `SPECPROOF_REPO=/path/to/repo bun run dev`. See [CLAUDE.md](CLAUDE.md) for architecture notes.
|
|
58
|
+
|
|
59
|
+
## License
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
MIT
|
|
@@ -228,8 +228,7 @@ function OperationRow({
|
|
|
228
228
|
{operation.method}
|
|
229
229
|
</span>
|
|
230
230
|
<PathInk specPath={operation.specPath} />
|
|
231
|
-
<span className="sp-
|
|
232
|
-
<span className="sp-marks shrink-0" aria-hidden>
|
|
231
|
+
<span className="sp-marks ml-auto shrink-0" aria-hidden>
|
|
233
232
|
{operation.statuses.map((status) => (
|
|
234
233
|
<span key={status.code} className="sp-mark" data-state={verdictOf(status)} />
|
|
235
234
|
))}
|
|
@@ -264,28 +263,33 @@ function TagSection({
|
|
|
264
263
|
onShowEvidence: (evidence: Evidence) => void;
|
|
265
264
|
}) {
|
|
266
265
|
return (
|
|
267
|
-
<
|
|
268
|
-
|
|
266
|
+
<AccordionItem
|
|
267
|
+
value={tag.tag}
|
|
268
|
+
className="sp-rise border-b-0"
|
|
269
|
+
style={{ '--sp-stagger': index + 3 } as React.CSSProperties}
|
|
270
|
+
>
|
|
271
|
+
<AccordionTrigger className="items-baseline gap-4 border-b py-0 pb-2 hover:no-underline">
|
|
269
272
|
<span className="text-[0.65rem] tracking-[0.2em] text-muted-foreground">
|
|
270
273
|
{String(index + 1).padStart(2, '0')}
|
|
271
274
|
</span>
|
|
272
|
-
<
|
|
275
|
+
<span className="text-sm font-semibold uppercase tracking-[0.08em]">{tag.tag}</span>
|
|
273
276
|
<span className="hidden text-xs text-muted-foreground sm:block">{tag.description}</span>
|
|
274
|
-
<span className="
|
|
275
|
-
<span className="text-sm tabular-nums text-muted-foreground">
|
|
277
|
+
<span className="ml-auto text-sm font-normal tabular-nums text-muted-foreground">
|
|
276
278
|
<span className="text-foreground">{tag.coveredCount}</span>/{tag.totalCount} verified
|
|
277
279
|
</span>
|
|
278
|
-
</
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
280
|
+
</AccordionTrigger>
|
|
281
|
+
<AccordionContent className="pb-0 pt-1">
|
|
282
|
+
<Accordion type="multiple" className="divide-y divide-[var(--sp-hair)]">
|
|
283
|
+
{tag.operations.map((operation) => (
|
|
284
|
+
<OperationRow
|
|
285
|
+
key={`${operation.method} ${operation.specPath}`}
|
|
286
|
+
operation={operation}
|
|
287
|
+
onShowEvidence={onShowEvidence}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</Accordion>
|
|
291
|
+
</AccordionContent>
|
|
292
|
+
</AccordionItem>
|
|
289
293
|
);
|
|
290
294
|
}
|
|
291
295
|
|
|
@@ -302,6 +306,7 @@ export function CoverageProof({
|
|
|
302
306
|
}) {
|
|
303
307
|
const [evidence, setEvidence] = useState<Evidence | null>(null);
|
|
304
308
|
const gapCount = report.totalCount - report.coveredCount;
|
|
309
|
+
const verifiedPct = Math.round((report.coveredCount / Math.max(report.totalCount, 1)) * 100);
|
|
305
310
|
const facts: Array<[string, string]> = [
|
|
306
311
|
['operations', String(report.operationCount)],
|
|
307
312
|
['status pairs verified', `${report.coveredCount}/${report.totalCount}`],
|
|
@@ -311,56 +316,43 @@ export function CoverageProof({
|
|
|
311
316
|
|
|
312
317
|
return (
|
|
313
318
|
<div className="proof-root proof-page min-h-screen">
|
|
314
|
-
<div className="mx-auto flex max-w-
|
|
319
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-14 px-6 py-16">
|
|
315
320
|
{/* masthead */}
|
|
316
|
-
<header
|
|
317
|
-
|
|
321
|
+
<header
|
|
322
|
+
className="sp-rise flex flex-col gap-8"
|
|
323
|
+
style={{ '--sp-stagger': 0 } as React.CSSProperties}
|
|
324
|
+
>
|
|
325
|
+
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
318
326
|
<h1 className="text-5xl font-semibold tracking-tight">SpecProof</h1>
|
|
319
327
|
<div className="text-right">
|
|
320
|
-
<div className="text-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
</div>
|
|
324
|
-
<div className="mt-2 text-[0.65rem] tracking-[0.14em] text-muted-foreground">
|
|
325
|
-
DOCUMENTED RESPONSES VERIFIED
|
|
328
|
+
<div className="text-2xl font-semibold tracking-tight">{report.repoName}</div>
|
|
329
|
+
<div className="mt-2 text-[0.65rem] tracking-[0.18em] tabular-nums text-muted-foreground">
|
|
330
|
+
{new Date(compiledAt).toISOString().slice(0, 16).replace('T', ' ')} UTC
|
|
326
331
|
</div>
|
|
327
332
|
</div>
|
|
328
333
|
</div>
|
|
329
|
-
</header>
|
|
330
334
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
{/* report metadata */}
|
|
336
|
+
<dl className="sp-rule-double flex flex-wrap items-end justify-between gap-x-8 gap-y-4 border-b py-4">
|
|
337
|
+
{facts.map(([label, value]) => (
|
|
338
|
+
<div key={label}>
|
|
339
|
+
<dt className="text-[0.65rem] tracking-[0.18em] text-muted-foreground">
|
|
340
|
+
{label.toUpperCase()}
|
|
341
|
+
</dt>
|
|
342
|
+
<dd className="mt-2 text-2xl font-semibold tabular-nums">{value}</dd>
|
|
343
|
+
</div>
|
|
344
|
+
))}
|
|
345
|
+
<div className="text-right">
|
|
339
346
|
<dt className="text-[0.65rem] tracking-[0.18em] text-muted-foreground">
|
|
340
|
-
|
|
347
|
+
RESPONSES VERIFIED
|
|
341
348
|
</dt>
|
|
349
|
+
<dd className="mt-2 text-2xl font-semibold tabular-nums">
|
|
350
|
+
{verifiedPct}
|
|
351
|
+
<span className="text-base font-normal text-muted-foreground">%</span>
|
|
352
|
+
</dd>
|
|
342
353
|
</div>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
COMPILED {new Date(compiledAt).toISOString().slice(0, 10)}
|
|
346
|
-
</div>
|
|
347
|
-
</dl>
|
|
348
|
-
|
|
349
|
-
{/* legend */}
|
|
350
|
-
<div
|
|
351
|
-
className="sp-rise -mt-8 flex flex-wrap items-center gap-x-6 gap-y-2 text-[0.65rem] tracking-[0.14em] text-muted-foreground"
|
|
352
|
-
style={{ '--sp-stagger': 2 } as React.CSSProperties}
|
|
353
|
-
>
|
|
354
|
-
<span className="flex items-center gap-2">
|
|
355
|
-
<span className="sp-mark" data-state="ok" /> VERIFIED
|
|
356
|
-
</span>
|
|
357
|
-
<span className="flex items-center gap-2">
|
|
358
|
-
<span className="sp-mark" data-state="gap" /> NO TEST
|
|
359
|
-
</span>
|
|
360
|
-
<span className="flex items-center gap-2">
|
|
361
|
-
<span className="sp-mark" data-state="undoc" /> UNDOCUMENTED
|
|
362
|
-
</span>
|
|
363
|
-
</div>
|
|
354
|
+
</dl>
|
|
355
|
+
</header>
|
|
364
356
|
|
|
365
357
|
{/* tag sections */}
|
|
366
358
|
{report.operationCount === 0 ? (
|
|
@@ -370,14 +362,20 @@ export function CoverageProof({
|
|
|
370
362
|
>
|
|
371
363
|
<p className="text-sm font-semibold tracking-[0.14em]">NO API DEFINITION PROVIDED</p>
|
|
372
364
|
<div className="mt-4 flex flex-col gap-1 text-xs text-muted-foreground">
|
|
373
|
-
<code className="text-foreground/70">
|
|
374
|
-
<code className="text-foreground/70">
|
|
365
|
+
<code className="text-foreground/70">specproof dev --repo /path/to/repo</code>
|
|
366
|
+
<code className="text-foreground/70">specproof generate --spec path/to/openapi.json</code>
|
|
375
367
|
</div>
|
|
376
368
|
</section>
|
|
377
369
|
) : (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
370
|
+
<Accordion
|
|
371
|
+
type="multiple"
|
|
372
|
+
defaultValue={report.tags.map((tag) => tag.tag)}
|
|
373
|
+
className="flex flex-col gap-14"
|
|
374
|
+
>
|
|
375
|
+
{report.tags.map((tag, i) => (
|
|
376
|
+
<TagSection key={tag.tag} tag={tag} index={i} onShowEvidence={setEvidence} />
|
|
377
|
+
))}
|
|
378
|
+
</Accordion>
|
|
381
379
|
)}
|
|
382
380
|
</div>
|
|
383
381
|
|
package/lib/api-test-coverage.ts
CHANGED
|
@@ -127,6 +127,8 @@ export interface TagCoverage {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export interface CoverageReport {
|
|
130
|
+
/** Display name of the audited repo (its package.json "name", falling back to the directory name) */
|
|
131
|
+
repoName: string;
|
|
130
132
|
tags: TagCoverage[];
|
|
131
133
|
operationCount: number;
|
|
132
134
|
/** documented (path, method, status) pairs with assertions */
|
|
@@ -136,6 +138,19 @@ export interface CoverageReport {
|
|
|
136
138
|
untestedOperations: number;
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
/** The audited repo's display name: its package.json "name", falling back to the directory name. */
|
|
142
|
+
export function resolveRepoName(repoRoot: string): string {
|
|
143
|
+
try {
|
|
144
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
|
|
145
|
+
name?: string;
|
|
146
|
+
};
|
|
147
|
+
if (pkg.name) return pkg.name;
|
|
148
|
+
} catch {
|
|
149
|
+
// no readable package.json — fall through to the directory name
|
|
150
|
+
}
|
|
151
|
+
return path.basename(repoRoot);
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
// ============================================================================
|
|
140
155
|
// Test-file parsing
|
|
141
156
|
// ============================================================================
|
|
@@ -378,6 +393,7 @@ export function buildCoverageReport(repoRoot: string = TARGET_REPO_ROOT): Covera
|
|
|
378
393
|
|
|
379
394
|
const operations = tags.flatMap((t) => t.operations);
|
|
380
395
|
return {
|
|
396
|
+
repoName: resolveRepoName(repoRoot),
|
|
381
397
|
tags,
|
|
382
398
|
operationCount: operations.length,
|
|
383
399
|
coveredCount: tags.reduce((n, t) => n + t.coveredCount, 0),
|
package/next.config.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** @type {import('next').NextConfig} */
|
|
2
2
|
const nextConfig = {
|
|
3
3
|
reactStrictMode: false,
|
|
4
|
+
// The app is built from source even when installed under node_modules
|
|
5
|
+
// (`specproof dev`/`build`); without this, Next's loaders skip its files.
|
|
6
|
+
transpilePackages: ['specproof'],
|
|
4
7
|
};
|
|
5
8
|
|
|
6
9
|
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specproof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Audit view of a repo's API test coverage: every OpenAPI operation and response status, cross-examined against the repo's test assertions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:Durable-Quality/specproof",
|
|
@@ -13,6 +13,22 @@
|
|
|
13
13
|
"audit"
|
|
14
14
|
],
|
|
15
15
|
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"specproof": "scripts/cli.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"app",
|
|
21
|
+
"components",
|
|
22
|
+
"lib",
|
|
23
|
+
"scripts",
|
|
24
|
+
"!app/proof-contract.test.ts",
|
|
25
|
+
"!app/proof.generated.json",
|
|
26
|
+
"!lib/api-test-coverage.test.ts",
|
|
27
|
+
"next.config.js",
|
|
28
|
+
"postcss.config.js",
|
|
29
|
+
"tailwind.config.ts",
|
|
30
|
+
"tsconfig.json"
|
|
31
|
+
],
|
|
16
32
|
"scripts": {
|
|
17
33
|
"build": "bun run generate:proof && bunx next build",
|
|
18
34
|
"dev": "bun run generate:proof && bunx next dev --port 3001",
|
|
@@ -28,29 +44,29 @@
|
|
|
28
44
|
"dependencies": {
|
|
29
45
|
"@radix-ui/react-accordion": "^1.2.16",
|
|
30
46
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
47
|
+
"@types/node": "24.1.0",
|
|
48
|
+
"@types/react": "^19.2.0",
|
|
49
|
+
"@types/react-dom": "^19.2.0",
|
|
50
|
+
"autoprefixer": "^10.4.22",
|
|
31
51
|
"class-variance-authority": "^0.7.1",
|
|
32
52
|
"clsx": "^2.1.1",
|
|
33
53
|
"lucide-react": "^0.562.0",
|
|
34
54
|
"next": "^16.0.7",
|
|
55
|
+
"postcss": "^8.5.6",
|
|
56
|
+
"postcss-import": "^16.1.1",
|
|
35
57
|
"react": "19.2.3",
|
|
36
58
|
"react-dom": "19.2.3",
|
|
37
59
|
"tailwind-merge": "^3.4.0",
|
|
38
|
-
"tailwindcss
|
|
60
|
+
"tailwindcss": "^3.4.18",
|
|
61
|
+
"tailwindcss-animate": "^1.0.7",
|
|
62
|
+
"typescript": "^5.9.3"
|
|
39
63
|
},
|
|
40
64
|
"devDependencies": {
|
|
41
65
|
"@eslint/js": "^9.39.1",
|
|
42
|
-
"@types/node": "24.1.0",
|
|
43
|
-
"@types/react": "^19.2.0",
|
|
44
|
-
"@types/react-dom": "^19.2.0",
|
|
45
|
-
"autoprefixer": "^10.4.22",
|
|
46
66
|
"eslint": "^9.39.1",
|
|
47
67
|
"eslint-plugin-react": "^7.37.5",
|
|
48
68
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
49
69
|
"globals": "^16.5.0",
|
|
50
|
-
"postcss": "^8.5.6",
|
|
51
|
-
"postcss-import": "^16.1.1",
|
|
52
|
-
"tailwindcss": "^3.4.18",
|
|
53
|
-
"typescript": "^5.9.3",
|
|
54
70
|
"typescript-eslint": "^8.48.1",
|
|
55
71
|
"vitest": "^4.1.8"
|
|
56
72
|
}
|
package/scripts/cli.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// SpecProof CLI — the entrypoint when the package is installed into a target
|
|
3
|
+
// repo (`bunx specproof <command>`). Audits the repo it is run from (override
|
|
4
|
+
// with --repo / SPECPROOF_REPO) while Next.js dev/build/start run against the
|
|
5
|
+
// SpecProof package directory itself, wherever it is installed.
|
|
6
|
+
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { createRequire } from 'module';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
|
|
14
|
+
const USAGE = `specproof — audit a repo's API test coverage against its OpenAPI spec
|
|
15
|
+
|
|
16
|
+
Usage: specproof <command> [options]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
generate Compile the coverage proof from the target repo's spec + tests
|
|
20
|
+
dev generate, then serve the audit view with next dev
|
|
21
|
+
build generate, then production-build the audit view
|
|
22
|
+
start Serve the production build
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--repo <path> Repo to audit (default: current directory; env SPECPROOF_REPO)
|
|
26
|
+
--spec <path> OpenAPI spec, relative to the repo root when the auto-discovery
|
|
27
|
+
of openapi*.json / swagger*.json doesn't apply (env SPECPROOF_SPEC)
|
|
28
|
+
--out <path> generate only: where to write the proof (default: the app's
|
|
29
|
+
bundled artifact; env SPECPROOF_OUT)
|
|
30
|
+
--check generate only: verify the proof at --out is up to date instead
|
|
31
|
+
of writing — exits 1 on drift (the CI guard)
|
|
32
|
+
--port <port> dev/start only: port to serve on (default: 3001)
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function fail(message: string): never {
|
|
36
|
+
console.error(`specproof: ${message}\n\n${USAGE}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
41
|
+
|
|
42
|
+
let port = '3001';
|
|
43
|
+
const generateArgs: string[] = [];
|
|
44
|
+
for (let i = 0; i < rest.length; i++) {
|
|
45
|
+
const arg = rest[i];
|
|
46
|
+
const value = () => {
|
|
47
|
+
const v = rest[++i];
|
|
48
|
+
if (!v) fail(`${arg} requires a value`);
|
|
49
|
+
return v;
|
|
50
|
+
};
|
|
51
|
+
// --repo/--spec become env vars so the analyzer (which reads them at import
|
|
52
|
+
// time) picks them up when the generator is dynamically imported below.
|
|
53
|
+
if (arg === '--repo') process.env.SPECPROOF_REPO = value();
|
|
54
|
+
else if (arg === '--spec') process.env.SPECPROOF_SPEC = value();
|
|
55
|
+
else if (arg === '--port') port = value();
|
|
56
|
+
else if (arg === '--out' || arg === '--check') {
|
|
57
|
+
if (command !== 'generate') fail(`${arg} only applies to the generate command`);
|
|
58
|
+
generateArgs.push(arg);
|
|
59
|
+
if (arg === '--out') generateArgs.push(value());
|
|
60
|
+
} else fail(`unknown option: ${arg}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function generate(argv: string[]): number {
|
|
64
|
+
// Dynamic import so the env vars set above are read, not the values at CLI
|
|
65
|
+
// startup. require() keeps this synchronous under Bun and Node alike.
|
|
66
|
+
const require = createRequire(import.meta.url);
|
|
67
|
+
const { parseGenerateArgs, runGenerate } = require('./generate-proof') as
|
|
68
|
+
typeof import('./generate-proof');
|
|
69
|
+
return runGenerate(parseGenerateArgs(argv));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nextCli(...args: string[]): number {
|
|
73
|
+
const require = createRequire(import.meta.url);
|
|
74
|
+
// Resolve Next's bin relative to this package so the CLI works no matter
|
|
75
|
+
// where the consumer's package manager hoisted the dependency tree.
|
|
76
|
+
const nextBin = require.resolve('next/dist/bin/next', { paths: [appRoot] });
|
|
77
|
+
const result = spawnSync(process.execPath, [nextBin, ...args], {
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
env: process.env,
|
|
80
|
+
});
|
|
81
|
+
return result.status ?? 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
switch (command) {
|
|
85
|
+
case 'generate':
|
|
86
|
+
process.exit(generate(generateArgs));
|
|
87
|
+
break;
|
|
88
|
+
case 'dev':
|
|
89
|
+
case 'build': {
|
|
90
|
+
// dev/build always refresh the bundled artifact — it is what the app
|
|
91
|
+
// renders. A consumer's committed copy (--out) is generate's concern.
|
|
92
|
+
const generated = generate([]);
|
|
93
|
+
if (generated !== 0) process.exit(generated);
|
|
94
|
+
process.exit(
|
|
95
|
+
command === 'dev'
|
|
96
|
+
? nextCli('dev', appRoot, '--port', port)
|
|
97
|
+
: nextCli('build', appRoot)
|
|
98
|
+
);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'start':
|
|
102
|
+
process.exit(nextCli('start', appRoot, '--port', port));
|
|
103
|
+
break;
|
|
104
|
+
case 'help':
|
|
105
|
+
case '--help':
|
|
106
|
+
case '-h':
|
|
107
|
+
case undefined:
|
|
108
|
+
console.log(USAGE);
|
|
109
|
+
process.exit(command ? 0 : 1);
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
fail(`unknown command: ${command}`);
|
|
113
|
+
}
|
|
@@ -4,16 +4,25 @@
|
|
|
4
4
|
// SPECPROOF_SPEC).
|
|
5
5
|
//
|
|
6
6
|
// Run via `bun run generate:proof` (also runs automatically before
|
|
7
|
-
// `bun run dev` / `bun run build`)
|
|
8
|
-
// app/proof.generated.json;
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// `bun run dev` / `bun run build`), or `specproof generate` when installed.
|
|
8
|
+
// The output defaults to the checked-in artifact at app/proof.generated.json;
|
|
9
|
+
// override with --out / SPECPROOF_OUT to write the proof into the audited
|
|
10
|
+
// repo instead (e.g. to commit it there). --check verifies the output file is
|
|
11
|
+
// up to date without writing — the CI drift guard. When no target spec is
|
|
12
|
+
// found, the existing artifact is left untouched so the app still builds and
|
|
13
|
+
// renders.
|
|
11
14
|
|
|
12
15
|
import fs from 'fs';
|
|
13
16
|
import path from 'path';
|
|
14
17
|
import { fileURLToPath } from 'url';
|
|
15
18
|
|
|
16
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
buildCoverageReport,
|
|
21
|
+
resolveRepoName,
|
|
22
|
+
resolveSpecPath,
|
|
23
|
+
TARGET_REPO_ROOT,
|
|
24
|
+
type CoverageReport,
|
|
25
|
+
} from '../lib/api-test-coverage';
|
|
17
26
|
|
|
18
27
|
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
28
|
|
|
@@ -26,24 +35,100 @@ export function buildProof(): Record<string, unknown> {
|
|
|
26
35
|
return buildCoverageReport() as unknown as Record<string, unknown>;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
export interface GenerateOptions {
|
|
39
|
+
/** Where to write (or, with check, what to verify). Resolved against cwd.
|
|
40
|
+
* Defaults to SPECPROOF_OUT, then the app's checked-in artifact. */
|
|
41
|
+
out?: string;
|
|
42
|
+
/** Verify the file at `out` matches a fresh proof instead of writing. */
|
|
43
|
+
check?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parses --out <path> / --check from a generate argv slice. Throws on
|
|
47
|
+
* anything unrecognized so callers surface their own usage text. */
|
|
48
|
+
export function parseGenerateArgs(argv: string[]): GenerateOptions {
|
|
49
|
+
const options: GenerateOptions = {};
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const arg = argv[i];
|
|
52
|
+
if (arg === '--check') {
|
|
53
|
+
options.check = true;
|
|
54
|
+
} else if (arg === '--out') {
|
|
55
|
+
const value = argv[++i];
|
|
56
|
+
if (!value) throw new Error('--out requires a path');
|
|
57
|
+
options.out = value;
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runGenerate(options: GenerateOptions = {}): number {
|
|
66
|
+
const outPath = path.resolve(
|
|
67
|
+
options.out ?? process.env.SPECPROOF_OUT ?? GENERATED_PROOF_PATH
|
|
68
|
+
);
|
|
69
|
+
const relative = path.relative(process.cwd(), outPath);
|
|
70
|
+
const outLabel = relative && !relative.startsWith('..') ? relative : outPath;
|
|
32
71
|
|
|
33
|
-
if (isMain) {
|
|
34
72
|
if (!resolveSpecPath()) {
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
73
|
+
const hint =
|
|
74
|
+
`no OpenAPI spec found under ${TARGET_REPO_ROOT} — ` +
|
|
75
|
+
'set SPECPROOF_REPO to the repo to audit (or SPECPROOF_SPEC to the spec file)';
|
|
76
|
+
if (options.check) {
|
|
77
|
+
// A drift check with nothing to check against is a failure, not a skip:
|
|
78
|
+
// CI asked us to verify the proof and we can't.
|
|
79
|
+
console.error(`generate-proof: ${hint}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (!fs.existsSync(outPath)) {
|
|
83
|
+
// The artifact is not published with the package, so a fresh install has
|
|
84
|
+
// no proof at all. Write an empty one: the app needs the file to build,
|
|
85
|
+
// and an empty report renders the "no API definition" state.
|
|
86
|
+
const empty: CoverageReport = {
|
|
87
|
+
repoName: resolveRepoName(TARGET_REPO_ROOT),
|
|
88
|
+
tags: [],
|
|
89
|
+
operationCount: 0,
|
|
90
|
+
coveredCount: 0,
|
|
91
|
+
totalCount: 0,
|
|
92
|
+
untestedOperations: 0,
|
|
93
|
+
};
|
|
94
|
+
fs.writeFileSync(outPath, JSON.stringify(empty, null, 2) + '\n');
|
|
95
|
+
console.warn(`generate-proof: ${hint}; wrote an empty proof to ${outLabel}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
console.warn(`generate-proof: ${hint}; keeping the existing proof`);
|
|
99
|
+
return 0;
|
|
40
100
|
}
|
|
101
|
+
|
|
41
102
|
const proof = buildProof();
|
|
42
103
|
const operationCount = (proof.operationCount as number) ?? 0;
|
|
43
104
|
if (operationCount === 0) {
|
|
44
105
|
console.error('generate-proof: no operations found in the OpenAPI spec — refusing to write an empty proof');
|
|
45
|
-
|
|
106
|
+
return 1;
|
|
46
107
|
}
|
|
47
|
-
|
|
48
|
-
|
|
108
|
+
const serialized = JSON.stringify(proof, null, 2) + '\n';
|
|
109
|
+
|
|
110
|
+
if (options.check) {
|
|
111
|
+
const existing = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null;
|
|
112
|
+
if (existing !== serialized) {
|
|
113
|
+
console.error(
|
|
114
|
+
`generate-proof: ${outLabel} is stale relative to the spec/tests — ` +
|
|
115
|
+
'regenerate it (specproof generate) and commit the result'
|
|
116
|
+
);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
console.log(`generate-proof: ${outLabel} is up to date (${operationCount} operations)`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fs.writeFileSync(outPath, serialized);
|
|
124
|
+
console.log(`generate-proof: wrote ${outLabel} (${operationCount} operations)`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isMain =
|
|
129
|
+
process.argv[1] &&
|
|
130
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
131
|
+
|
|
132
|
+
if (isMain) {
|
|
133
|
+
process.exit(runGenerate(parseGenerateArgs(process.argv.slice(2))));
|
|
49
134
|
}
|