specproof 0.1.0 โ†’ 0.2.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.
@@ -3,7 +3,6 @@ name: ๐Ÿš€ Release
3
3
  on:
4
4
  push:
5
5
  branches: main
6
- workflow_dispatch:
7
6
 
8
7
  permissions:
9
8
  contents: read
@@ -1,20 +1,58 @@
1
- name: ๐Ÿงพ Proof Contract Tests
1
+ name: CI
2
2
 
3
3
  on:
4
- push:
5
- branches: main
6
4
  pull_request:
7
5
  branches: main
6
+ merge_group:
7
+ push:
8
+ branches: main
8
9
  workflow_dispatch:
9
10
 
10
11
  jobs:
11
- test-unit:
12
- name: ๐Ÿงพ Run proof contract tests
12
+ build:
13
+ name: Build
13
14
  runs-on: ubuntu-latest
15
+ steps:
16
+ - name: ๐Ÿ“ฅ Checkout code
17
+ uses: actions/checkout@v5
14
18
 
19
+ - name: ๐Ÿž Setup Bun
20
+ uses: oven-sh/setup-bun@v2
21
+ with:
22
+ bun-version: latest
23
+
24
+ - name: ๐Ÿงฐ Install dependencies
25
+ run: bun install
26
+
27
+ - name: ๐Ÿ—๏ธ Build
28
+ run: bun run build
29
+
30
+ lint:
31
+ name: Lint
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - name: ๐Ÿ“ฅ Checkout code
35
+ uses: actions/checkout@v5
36
+
37
+ - name: ๐Ÿž Setup Bun
38
+ uses: oven-sh/setup-bun@v2
39
+ with:
40
+ bun-version: latest
41
+
42
+ - name: ๐Ÿงฐ Install dependencies
43
+ run: bun install
44
+
45
+ - name: ๐Ÿงน ESLint
46
+ run: bun run lint:es
47
+
48
+ - name: ๐Ÿ”Ž Typecheck
49
+ run: bun run lint:ts
50
+
51
+ test:
52
+ name: Test
53
+ runs-on: ubuntu-latest
15
54
  env:
16
55
  NODE_ENV: test
17
-
18
56
  steps:
19
57
  - name: ๐Ÿ“ฅ Checkout code
20
58
  uses: actions/checkout@v5
@@ -27,10 +65,5 @@ jobs:
27
65
  - name: ๐Ÿงฐ Install dependencies
28
66
  run: bun install
29
67
 
30
- # The contract tests self-skip when no audited repo is configured.
31
- # To guard a specific target in CI, check it out here and set
32
- # SPECPROOF_REPO (and optionally SPECPROOF_SPEC) on the test step.
33
- - name: ๐Ÿงพ Run contract tests
68
+ - name: ๐Ÿงพ Run unit & contract tests
34
69
  run: bun run test:unit
35
- env:
36
- NODE_ENV: test
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-07-14
9
+
10
+ ### Added
11
+ - Bundled `example/` demo target: the fictional TaskFlow API (7 operations) plus fixture test files, audited automatically when no `SPECPROOF_REPO` is set โ€” the app now renders a meaningful proof out of the box, demonstrating every verdict state.
12
+ - Unit test suite for the coverage analyzer (`lib/api-test-coverage.test.ts`, 30 tests): path-param normalization, snippet extraction, test-file parsing, spec discovery, evidence merging, and report assembly with independently computed expectations.
13
+ - CI workflow with separate `Build`, `Lint`, and `Test` jobs, running on pull requests and in the merge queue.
14
+ - Merge queue ruleset on `main`: PRs required, all CI checks must pass, no direct pushes.
15
+ - Marketing showcase page (`marketing/`) and brand assets (`public/`), with a banner in the README.
16
+
17
+ ### Changed
18
+ - Releases now publish only after a PR merges to `main` through the merge queue; the manual release trigger was removed.
19
+ - `buildCoverageReport()` accepts an optional target repo root, and the analyzer's parsing internals are exported for testing.
20
+ - Larger SpecProof masthead in the coverage view.
21
+
8
22
  ## [0.1.0] - 2026-07-13
9
23
 
10
24
  ### Added
package/CLAUDE.md CHANGED
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
6
 
7
7
  SpecProof: 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 target repo's test assertions. Clicking a stamped verdict shows the actual test snippet that proves (or fails to prove) coverage.
8
8
 
9
- This repo does not contain an API or its tests โ€” it audits a target repo from the outside, the way OpenAPI tooling works when installed into a repo. Nothing repo-specific is baked in.
9
+ This repo does not contain a real API or its tests โ€” it audits a target repo from the outside, the way OpenAPI tooling works when installed into a repo. Nothing repo-specific is baked in. It does carry a hand-written demo target in `example/` (see "Example fixture" below), which is what gets audited when no `SPECPROOF_REPO` is set.
10
10
 
11
11
  ## Architecture: generate โ†’ checked-in artifact โ†’ contract-test
12
12
 
@@ -16,7 +16,15 @@ This repo does not contain an API or its tests โ€” it audits a target repo from
16
16
  2. **Analyzer** (`lib/api-test-coverage.ts`): joins the two sources into a `CoverageReport` (tags โ†’ operations โ†’ statuses, each status with assertion counts and extracted `it()` snippets). Operations are joined on the describe title's method + path; `{param}`, `[param]`, and `:param` segments are treated as equivalent. When several test files describe the same operation, the one with the most `it()` blocks wins.
17
17
  3. **Generator** (`scripts/generate-proof.ts`): calls the analyzer, writes the result to `app/proof.generated.json`. This is checked in, deterministic, and regenerated automatically before `dev`/`build`. When no target spec is found it leaves the existing artifact untouched (exit 0), so the app always builds.
18
18
  4. **Consumer** (`app/page.tsx` โ†’ `components/CoverageProof.tsx`): renders the checked-in artifact only. No target checkout is needed to build or deploy the app itself. An empty proof renders an empty-state hint.
19
- 5. **Drift guard** (`app/proof-contract.test.ts`): fails when the artifact is stale relative to the spec/tests, when the proof's audited operations don't exactly match the spec's operations, or when a quoted snippet no longer points at a real `it()`/`test()` line. This suite self-skips (via `describe.skipIf`) when no target spec is resolvable.
19
+ 5. **Drift guard** (`app/proof-contract.test.ts`): fails when the artifact is stale relative to the spec/tests, when the proof's audited operations don't exactly match the spec's operations, or when a quoted snippet no longer points at a real `it()`/`test()` line. This suite self-skips (via `describe.skipIf`) when no target spec is resolvable โ€” in this repo one always is (the example fixture), so `bun run test:unit` always exercises the full analyzer here.
20
+
21
+ ## Example fixture (`example/`)
22
+
23
+ `example/` is a hand-written demo target: `example/api/openapi.json` (the fictional "TaskFlow" API, 7 operations) plus `example/tests/*.test.ts` written in exactly the `describe("METHOD /path")` / `.status).toBe(NNN)` shape the parser expects. Because spec auto-discovery finds the shallowest `openapi*.json` under the target root (cwd by default), running `dev` / `generate:proof` / `test:unit` from this repo audits the example automatically โ€” no `SPECPROOF_REPO` needed. When the package is installed into another repo, the example sits under `node_modules/`, which the scanner excludes, so it never pollutes a real audit.
24
+
25
+ - The example files are **fixture data parsed as text**, never executed or compiled: they are excluded from vitest (`vitest.config.ts`), tsc (`tsconfig.json`), and eslint (`eslint.config.mjs`). They only need to look like a prettier-formatted test suite.
26
+ - The spec and tests are **deliberately out of step** so the demo shows every verdict state: fully proven operations (`POST /auth/login`, `GET /tasks`, `GET /tasks/{taskId}`), partial coverage (`POST /tasks`, `PATCH /tasks/{taskId}`), untested operations (`DELETE /tasks/{taskId}`, `GET /projects`), and one status the tests assert but the spec omits (422 on `POST /tasks`). Preserve that mix when editing.
27
+ - Editing anything in `example/` makes the checked-in proof stale and fails the contract tests: run `bun run generate:proof` and commit the regenerated `app/proof.generated.json`.
20
28
 
21
29
  Because of this split, most day-to-day work happens in exactly one of two places:
22
30
  - Changing what's audited/how coverage is computed โ†’ `lib/api-test-coverage.ts` (+ regenerate the proof).
@@ -32,16 +40,16 @@ bun run dev # generate:proof + next dev (port 3001)
32
40
  bun run build # generate:proof + next build
33
41
  bun run start # serve the production build (port 3001)
34
42
  bun run generate:proof # regenerate app/proof.generated.json only
35
- bun run test:unit # contract tests (vitest run; self-skips without a configured target)
43
+ bun run test:unit # contract tests (vitest run; audits example/ unless SPECPROOF_REPO is set)
36
44
  bun run test:unit:watch # vitest watch mode
37
45
  bun run lint # eslint + tsc --noEmit
38
46
  bun run lint:es # eslint only
39
47
  bun run lint:ts # tsc --noEmit only
40
48
  ```
41
49
 
42
- To run a single test file: `bunx vitest run app/proof-contract.test.ts` (there is currently only the one test file).
50
+ To run a single test file: `bunx vitest run app/proof-contract.test.ts` (contract tests) or `bunx vitest run lib/api-test-coverage.test.ts` (analyzer unit tests). The unit tests' inline fixtures deliberately use operations that don't exist in the example spec (`/widgets`, `/gadgets`) โ€” the analyzer parses this repo's own `*.test.ts` files as raw text when auditing `example/`, and colliding paths would pollute the generated proof.
43
51
 
44
- The generator/contract tests need a target repo:
52
+ To audit a real repo instead of the bundled example:
45
53
 
46
54
  ```bash
47
55
  SPECPROOF_REPO=/path/to/target-repo bun run generate:proof
@@ -58,9 +66,11 @@ SPECPROOF_REPO=/path/to/target-repo bun run generate:proof
58
66
  git add app/proof.generated.json && git commit
59
67
  ```
60
68
 
61
- ## Releasing
69
+ ## CI & Releasing
70
+
71
+ `main` is protected by a repo ruleset (PR required, merge queue, no direct pushes). The flow: open a PR โ†’ the CI workflow (`.github/workflows/test-unit.yml`, jobs `Build` / `Lint` / `Test`) runs on the PR and again in the merge queue โ†’ all three checks must pass โ†’ clicking merge queues the PR and GitHub merges it (squash).
62
72
 
63
- Merges to `main` trigger `.github/workflows/release.yml`, which publishes to npm when `package.json`'s version isn't already on the registry (needs the `NPM_TOKEN` repo secret). To release: bump `version` in `package.json` and merge.
73
+ Every merge to `main` then triggers `.github/workflows/release.yml`, which publishes to npm when `package.json`'s version isn't already on the registry (needs the `NPM_TOKEN` repo secret). To release: bump `version` in `package.json`, open a PR, and merge. There is no manual release trigger.
64
74
 
65
75
  ## Notes on the parsing approach
66
76
 
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="public/banner.png" alt="SpecProof" />
2
+
1
3
  # SpecProof
2
4
 
3
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.
@@ -1,7 +1,288 @@
1
1
  {
2
- "tags": [],
3
- "operationCount": 0,
4
- "coveredCount": 0,
5
- "totalCount": 0,
6
- "untestedOperations": 0
2
+ "tags": [
3
+ {
4
+ "tag": "Auth",
5
+ "description": "Session and token management",
6
+ "operations": [
7
+ {
8
+ "method": "post",
9
+ "specPath": "/auth/login",
10
+ "summary": "Exchange credentials for a session token",
11
+ "testFile": "example/tests/auth.test.ts",
12
+ "testCount": 2,
13
+ "statuses": [
14
+ {
15
+ "code": "200",
16
+ "description": "Session token issued",
17
+ "documented": true,
18
+ "assertions": 1,
19
+ "snippets": [
20
+ {
21
+ "title": "returns a session token for valid credentials",
22
+ "source": "it(\"returns a session token for valid credentials\", async () => {\n const res = await api.post(\"/auth/login\", {\n email: \"ada@example.com\",\n password: \"correct horse battery staple\",\n });\n\n expect(res.status).toBe(200);\n expect(res.body.token).toMatch(/^tk_/);\n});",
23
+ "startLine": 6
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "code": "401",
29
+ "description": "Invalid credentials",
30
+ "documented": true,
31
+ "assertions": 1,
32
+ "snippets": [
33
+ {
34
+ "title": "rejects invalid credentials",
35
+ "source": "it(\"rejects invalid credentials\", async () => {\n const res = await api.post(\"/auth/login\", {\n email: \"ada@example.com\",\n password: \"not-the-password\",\n });\n\n expect(res.status).toBe(401);\n expect(res.body.token).toBeUndefined();\n});",
36
+ "startLine": 16
37
+ }
38
+ ]
39
+ }
40
+ ],
41
+ "coveredCount": 2,
42
+ "gapCount": 0
43
+ }
44
+ ],
45
+ "coveredCount": 2,
46
+ "totalCount": 2
47
+ },
48
+ {
49
+ "tag": "Tasks",
50
+ "description": "Create and manage tasks",
51
+ "operations": [
52
+ {
53
+ "method": "get",
54
+ "specPath": "/tasks",
55
+ "summary": "List tasks for the authenticated user",
56
+ "testFile": "example/tests/tasks.test.ts",
57
+ "testCount": 2,
58
+ "statuses": [
59
+ {
60
+ "code": "200",
61
+ "description": "Task list",
62
+ "documented": true,
63
+ "assertions": 1,
64
+ "snippets": [
65
+ {
66
+ "title": "lists tasks for the authenticated user",
67
+ "source": "it(\"lists tasks for the authenticated user\", async () => {\n const res = await api.get(\"/tasks\");\n\n expect(res.status).toBe(200);\n expect(Array.isArray(res.body.tasks)).toBe(true);\n});",
68
+ "startLine": 14
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "code": "401",
74
+ "description": "Missing or invalid bearer token",
75
+ "documented": true,
76
+ "assertions": 1,
77
+ "snippets": [
78
+ {
79
+ "title": "rejects requests without a bearer token",
80
+ "source": "it(\"rejects requests without a bearer token\", async () => {\n const res = await api.get(\"/tasks\", { auth: false });\n\n expect(res.status).toBe(401);\n});",
81
+ "startLine": 21
82
+ }
83
+ ]
84
+ }
85
+ ],
86
+ "coveredCount": 2,
87
+ "gapCount": 0
88
+ },
89
+ {
90
+ "method": "post",
91
+ "specPath": "/tasks",
92
+ "summary": "Create a task",
93
+ "testFile": "example/tests/tasks.test.ts",
94
+ "testCount": 3,
95
+ "statuses": [
96
+ {
97
+ "code": "201",
98
+ "description": "Task created",
99
+ "documented": true,
100
+ "assertions": 1,
101
+ "snippets": [
102
+ {
103
+ "title": "creates a task and returns it",
104
+ "source": "it(\"creates a task and returns it\", async () => {\n const res = await api.post(\"/tasks\", {\n title: \"Ship the Q3 roadmap\",\n projectId: \"proj_1\",\n });\n\n expect(res.status).toBe(201);\n expect(res.body.id).toBeDefined();\n expect(res.body.title).toBe(\"Ship the Q3 roadmap\");\n});",
105
+ "startLine": 29
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "code": "400",
111
+ "description": "Malformed request body",
112
+ "documented": true,
113
+ "assertions": 1,
114
+ "snippets": [
115
+ {
116
+ "title": "rejects a task without a title",
117
+ "source": "it(\"rejects a task without a title\", async () => {\n const res = await api.post(\"/tasks\", { projectId: \"proj_1\" });\n\n expect(res.status).toBe(400);\n expect(res.body.error).toBe(\"title is required\");\n});",
118
+ "startLine": 40
119
+ }
120
+ ]
121
+ },
122
+ {
123
+ "code": "401",
124
+ "description": "Missing or invalid bearer token",
125
+ "documented": true,
126
+ "assertions": 0,
127
+ "snippets": []
128
+ },
129
+ {
130
+ "code": "422",
131
+ "description": "",
132
+ "documented": false,
133
+ "assertions": 1,
134
+ "snippets": [
135
+ {
136
+ "title": "rejects a due date in the past",
137
+ "source": "it(\"rejects a due date in the past\", async () => {\n const res = await api.post(\"/tasks\", {\n title: \"Time travel\",\n dueDate: \"1999-12-31\",\n });\n\n expect(res.status).toBe(422);\n});",
138
+ "startLine": 47
139
+ }
140
+ ]
141
+ }
142
+ ],
143
+ "coveredCount": 2,
144
+ "gapCount": 1
145
+ },
146
+ {
147
+ "method": "get",
148
+ "specPath": "/tasks/{taskId}",
149
+ "summary": "Fetch a single task",
150
+ "testFile": "example/tests/tasks.test.ts",
151
+ "testCount": 2,
152
+ "statuses": [
153
+ {
154
+ "code": "200",
155
+ "description": "The task",
156
+ "documented": true,
157
+ "assertions": 1,
158
+ "snippets": [
159
+ {
160
+ "title": "returns a task by id",
161
+ "source": "it(\"returns a task by id\", async () => {\n const created = await api.post(\"/tasks\", { title: \"Write release notes\" });\n const res = await api.get(`/tasks/${created.body.id}`);\n\n expect(res.status).toBe(200);\n expect(res.body.id).toBe(created.body.id);\n});",
162
+ "startLine": 58
163
+ }
164
+ ]
165
+ },
166
+ {
167
+ "code": "404",
168
+ "description": "No task with this id",
169
+ "documented": true,
170
+ "assertions": 1,
171
+ "snippets": [
172
+ {
173
+ "title": "returns 404 for an unknown task id",
174
+ "source": "it(\"returns 404 for an unknown task id\", async () => {\n const res = await api.get(\"/tasks/task_does_not_exist\");\n\n expect(res.status).toBe(404);\n});",
175
+ "startLine": 66
176
+ }
177
+ ]
178
+ }
179
+ ],
180
+ "coveredCount": 2,
181
+ "gapCount": 0
182
+ },
183
+ {
184
+ "method": "patch",
185
+ "specPath": "/tasks/{taskId}",
186
+ "summary": "Update a task's fields",
187
+ "testFile": "example/tests/tasks.test.ts",
188
+ "testCount": 1,
189
+ "statuses": [
190
+ {
191
+ "code": "200",
192
+ "description": "Updated task",
193
+ "documented": true,
194
+ "assertions": 1,
195
+ "snippets": [
196
+ {
197
+ "title": "updates the fields of a task",
198
+ "source": "it(\"updates the fields of a task\", async () => {\n const created = await api.post(\"/tasks\", { title: \"Refine the backlog\" });\n const res = await api.patch(`/tasks/${created.body.id}`, {\n title: \"Refine and prioritize the backlog\",\n });\n\n expect(res.status).toBe(200);\n expect(res.body.title).toBe(\"Refine and prioritize the backlog\");\n});",
199
+ "startLine": 74
200
+ }
201
+ ]
202
+ },
203
+ {
204
+ "code": "404",
205
+ "description": "No task with this id",
206
+ "documented": true,
207
+ "assertions": 0,
208
+ "snippets": []
209
+ },
210
+ {
211
+ "code": "409",
212
+ "description": "Task was modified concurrently",
213
+ "documented": true,
214
+ "assertions": 0,
215
+ "snippets": []
216
+ }
217
+ ],
218
+ "coveredCount": 1,
219
+ "gapCount": 2
220
+ },
221
+ {
222
+ "method": "delete",
223
+ "specPath": "/tasks/{taskId}",
224
+ "summary": "Delete a task",
225
+ "testFile": null,
226
+ "testCount": 0,
227
+ "statuses": [
228
+ {
229
+ "code": "204",
230
+ "description": "Task deleted",
231
+ "documented": true,
232
+ "assertions": 0,
233
+ "snippets": []
234
+ },
235
+ {
236
+ "code": "404",
237
+ "description": "No task with this id",
238
+ "documented": true,
239
+ "assertions": 0,
240
+ "snippets": []
241
+ }
242
+ ],
243
+ "coveredCount": 0,
244
+ "gapCount": 2
245
+ }
246
+ ],
247
+ "coveredCount": 7,
248
+ "totalCount": 12
249
+ },
250
+ {
251
+ "tag": "Projects",
252
+ "description": "Group tasks into projects",
253
+ "operations": [
254
+ {
255
+ "method": "get",
256
+ "specPath": "/projects",
257
+ "summary": "List projects",
258
+ "testFile": null,
259
+ "testCount": 0,
260
+ "statuses": [
261
+ {
262
+ "code": "200",
263
+ "description": "Project list",
264
+ "documented": true,
265
+ "assertions": 0,
266
+ "snippets": []
267
+ },
268
+ {
269
+ "code": "401",
270
+ "description": "Missing or invalid bearer token",
271
+ "documented": true,
272
+ "assertions": 0,
273
+ "snippets": []
274
+ }
275
+ ],
276
+ "coveredCount": 0,
277
+ "gapCount": 2
278
+ }
279
+ ],
280
+ "coveredCount": 0,
281
+ "totalCount": 2
282
+ }
283
+ ],
284
+ "operationCount": 7,
285
+ "coveredCount": 9,
286
+ "totalCount": 16,
287
+ "untestedOperations": 2
7
288
  }
@@ -315,7 +315,7 @@ export function CoverageProof({
315
315
  {/* masthead */}
316
316
  <header className="sp-rise" style={{ '--sp-stagger': 0 } as React.CSSProperties}>
317
317
  <div className="flex flex-wrap items-end justify-between gap-6">
318
- <h1 className="text-2xl font-semibold tracking-tight">SpecProof</h1>
318
+ <h1 className="text-5xl font-semibold tracking-tight">SpecProof</h1>
319
319
  <div className="text-right">
320
320
  <div className="text-5xl font-semibold tabular-nums leading-none">
321
321
  {Math.round((report.coveredCount / Math.max(report.totalCount, 1)) * 100)}
package/eslint.config.mjs CHANGED
@@ -6,7 +6,7 @@ import pluginReactHooks from "eslint-plugin-react-hooks";
6
6
 
7
7
  export default [
8
8
  {
9
- ignores: ["**/.next/**", "**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**"],
9
+ ignores: ["**/.next/**", "**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**", "example/**"],
10
10
  },
11
11
  {
12
12
  files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
@@ -0,0 +1,88 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "TaskFlow API",
5
+ "version": "1.0.0",
6
+ "description": "Example API used to demo SpecProof and validate its analyzer. The spec and the tests in example/tests are deliberately out of step in places, so the audit shows every verdict state: proven coverage, gaps, untested operations, and an undocumented status."
7
+ },
8
+ "tags": [
9
+ { "name": "Auth", "description": "Session and token management" },
10
+ { "name": "Tasks", "description": "Create and manage tasks" },
11
+ { "name": "Projects", "description": "Group tasks into projects" }
12
+ ],
13
+ "paths": {
14
+ "/auth/login": {
15
+ "post": {
16
+ "operationId": "login",
17
+ "summary": "Exchange credentials for a session token",
18
+ "tags": ["Auth"],
19
+ "responses": {
20
+ "200": { "description": "Session token issued" },
21
+ "401": { "description": "Invalid credentials" }
22
+ }
23
+ }
24
+ },
25
+ "/tasks": {
26
+ "get": {
27
+ "operationId": "listTasks",
28
+ "summary": "List tasks for the authenticated user",
29
+ "tags": ["Tasks"],
30
+ "responses": {
31
+ "200": { "description": "Task list" },
32
+ "401": { "description": "Missing or invalid bearer token" }
33
+ }
34
+ },
35
+ "post": {
36
+ "operationId": "createTask",
37
+ "summary": "Create a task",
38
+ "tags": ["Tasks"],
39
+ "responses": {
40
+ "201": { "description": "Task created" },
41
+ "400": { "description": "Malformed request body" },
42
+ "401": { "description": "Missing or invalid bearer token" }
43
+ }
44
+ }
45
+ },
46
+ "/tasks/{taskId}": {
47
+ "get": {
48
+ "operationId": "getTask",
49
+ "summary": "Fetch a single task",
50
+ "tags": ["Tasks"],
51
+ "responses": {
52
+ "200": { "description": "The task" },
53
+ "404": { "description": "No task with this id" }
54
+ }
55
+ },
56
+ "patch": {
57
+ "operationId": "updateTask",
58
+ "summary": "Update a task's fields",
59
+ "tags": ["Tasks"],
60
+ "responses": {
61
+ "200": { "description": "Updated task" },
62
+ "404": { "description": "No task with this id" },
63
+ "409": { "description": "Task was modified concurrently" }
64
+ }
65
+ },
66
+ "delete": {
67
+ "operationId": "deleteTask",
68
+ "summary": "Delete a task",
69
+ "tags": ["Tasks"],
70
+ "responses": {
71
+ "204": { "description": "Task deleted" },
72
+ "404": { "description": "No task with this id" }
73
+ }
74
+ }
75
+ },
76
+ "/projects": {
77
+ "get": {
78
+ "operationId": "listProjects",
79
+ "summary": "List projects",
80
+ "tags": ["Projects"],
81
+ "responses": {
82
+ "200": { "description": "Project list" },
83
+ "401": { "description": "Missing or invalid bearer token" }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { api } from "./client";
4
+
5
+ describe("POST /auth/login", () => {
6
+ it("returns a session token for valid credentials", async () => {
7
+ const res = await api.post("/auth/login", {
8
+ email: "ada@example.com",
9
+ password: "correct horse battery staple",
10
+ });
11
+
12
+ expect(res.status).toBe(200);
13
+ expect(res.body.token).toMatch(/^tk_/);
14
+ });
15
+
16
+ it("rejects invalid credentials", async () => {
17
+ const res = await api.post("/auth/login", {
18
+ email: "ada@example.com",
19
+ password: "not-the-password",
20
+ });
21
+
22
+ expect(res.status).toBe(401);
23
+ expect(res.body.token).toBeUndefined();
24
+ });
25
+ });
@@ -0,0 +1,53 @@
1
+ // Minimal HTTP client the example tests are written against.
2
+ //
3
+ // The example suite is fixture data for SpecProof's analyzer โ€” it is excluded
4
+ // from this repo's typecheck, lint, and vitest runs and never executes here.
5
+ // It exists so the tests read like a real integration suite.
6
+
7
+ const BASE_URL = process.env.API_URL ?? "http://localhost:4010";
8
+
9
+ export interface ApiResponse {
10
+ status: number;
11
+ body: Record<string, unknown> & { id?: string; token?: string };
12
+ }
13
+
14
+ interface RequestOptions {
15
+ /** Set to false to send the request without a bearer token */
16
+ auth?: boolean;
17
+ }
18
+
19
+ let sessionToken: string | null = null;
20
+
21
+ export function authenticate(token: string | null) {
22
+ sessionToken = token;
23
+ }
24
+
25
+ async function request(
26
+ method: string,
27
+ path: string,
28
+ payload?: unknown,
29
+ options: RequestOptions = {},
30
+ ): Promise<ApiResponse> {
31
+ const withAuth = options.auth !== false && sessionToken !== null;
32
+ const res = await fetch(`${BASE_URL}${path}`, {
33
+ method,
34
+ headers: {
35
+ "content-type": "application/json",
36
+ ...(withAuth ? { authorization: `Bearer ${sessionToken}` } : {}),
37
+ },
38
+ body: payload === undefined ? undefined : JSON.stringify(payload),
39
+ });
40
+ const text = await res.text();
41
+ return { status: res.status, body: text ? JSON.parse(text) : {} };
42
+ }
43
+
44
+ export const api = {
45
+ get: (path: string, options?: RequestOptions) =>
46
+ request("GET", path, undefined, options),
47
+ post: (path: string, payload?: unknown, options?: RequestOptions) =>
48
+ request("POST", path, payload, options),
49
+ patch: (path: string, payload?: unknown, options?: RequestOptions) =>
50
+ request("PATCH", path, payload, options),
51
+ delete: (path: string, options?: RequestOptions) =>
52
+ request("DELETE", path, undefined, options),
53
+ };