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 CHANGED
@@ -1,53 +1,61 @@
1
- <img src="public/banner.png" alt="SpecProof" />
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
- A standalone Next.js app that renders an audit view of any repo's API test coverage: every OpenAPI operation and response status, cross-examined against the repo's test assertions. Click a stamped verdict to read the test itself.
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
- Point it at a repo the same way OpenAPI tooling works when installed into one — no repo-specific configuration is baked in:
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
- SPECPROOF_REPO=/path/to/your-repo bun run dev # compiles the proof, then serves on http://localhost:3001
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
- `SPECPROOF_REPO` defaults to the current working directory, so running the generator from inside the target repo needs no configuration at all. If the spec isn't named `openapi*.json` / `swagger*.json`, point `SPECPROOF_SPEC` at it (relative to the repo root).
16
+ The OpenAPI spec is auto-discovered (`openapi*.json` / `swagger*.json`); tests are your `*.test.ts` / `*.test.tsx` / `*.test.js` files.
24
17
 
25
- ## Commands
18
+ ## CLI
26
19
 
27
20
  ```bash
28
- bun run dev # generate:proof + next dev (port 3001)
29
- bun run build # generate:proof + next build
30
- bun run start # serve the production build (port 3001)
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
- ## Updating the proof
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
- When the target repo's tests or OpenAPI spec change, the checked-in artifact goes stale and the contract tests fail. The fix:
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
- SPECPROOF_REPO=/path/to/your-repo bun run generate:proof
42
- git add app/proof.generated.json && git commit
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`) the path is matched against the spec's paths.
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
- ## Releasing
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
- Merges to `main` run the [Release workflow](.github/workflows/release.yml), which publishes the package to npm whenever `package.json`'s version isn't on the registry yet (requires the `NPM_TOKEN` repository secret).
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-leader hidden sm:block" aria-hidden />
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
- <section className="sp-rise" style={{ '--sp-stagger': index + 3 } as React.CSSProperties}>
268
- <header className="mb-1 flex items-baseline gap-4 border-b pb-2">
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
- <h2 className="text-sm font-semibold uppercase tracking-[0.08em]">{tag.tag}</h2>
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="sp-leader" aria-hidden />
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
- </header>
279
- <Accordion type="multiple" className="divide-y divide-[var(--sp-hair)]">
280
- {tag.operations.map((operation) => (
281
- <OperationRow
282
- key={`${operation.method} ${operation.specPath}`}
283
- operation={operation}
284
- onShowEvidence={onShowEvidence}
285
- />
286
- ))}
287
- </Accordion>
288
- </section>
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-4xl flex-col gap-14 px-6 py-16">
319
+ <div className="mx-auto flex max-w-5xl flex-col gap-14 px-6 py-16">
315
320
  {/* masthead */}
316
- <header className="sp-rise" style={{ '--sp-stagger': 0 } as React.CSSProperties}>
317
- <div className="flex flex-wrap items-end justify-between gap-6">
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-5xl font-semibold tabular-nums leading-none">
321
- {Math.round((report.coveredCount / Math.max(report.totalCount, 1)) * 100)}
322
- <span className="text-2xl font-normal text-muted-foreground">%</span>
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
- {/* summary strip */}
332
- <dl
333
- className="sp-rise sp-rule-double flex flex-wrap items-baseline gap-x-10 gap-y-3 border-b py-3"
334
- style={{ '--sp-stagger': 1 } as React.CSSProperties}
335
- >
336
- {facts.map(([label, value]) => (
337
- <div key={label} className="flex items-baseline gap-2.5">
338
- <dd className="text-xl tabular-nums">{value}</dd>
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
- {label.toUpperCase()}
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
- <div className="ml-auto text-[0.65rem] tracking-[0.18em] text-muted-foreground">
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">SPECPROOF_REPO=/path/to/repo</code>
374
- <code className="text-foreground/70">bun run generate:proof</code>
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
- report.tags.map((tag, i) => (
379
- <TagSection key={tag.tag} tag={tag} index={i} onShowEvidence={setEvidence} />
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
 
@@ -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.2.1",
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-animate": "^1.0.7"
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`). The output is checked in at
8
- // app/proof.generated.json; the proof-contract test fails if it goes stale
9
- // relative to the spec or the tests. When no target spec is found, the
10
- // existing artifact is left untouched so the app still builds and renders.
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 { buildCoverageReport, resolveSpecPath, TARGET_REPO_ROOT } from '../lib/api-test-coverage';
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
- const isMain =
30
- process.argv[1] &&
31
- path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
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
- console.warn(
36
- `generate-proof: no OpenAPI spec found under ${TARGET_REPO_ROOT} — ` +
37
- 'set SPECPROOF_REPO to the repo to audit (or SPECPROOF_SPEC to the spec file); keeping the existing proof'
38
- );
39
- process.exit(0);
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
- process.exit(1);
106
+ return 1;
46
107
  }
47
- fs.writeFileSync(GENERATED_PROOF_PATH, JSON.stringify(proof, null, 2) + '\n');
48
- console.log(`generate-proof: wrote ${path.relative(appRoot, GENERATED_PROOF_PATH)} (${operationCount} operations)`);
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
  }