specproof 0.1.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/.github/workflows/release.yml +50 -0
- package/.github/workflows/test-unit.yml +36 -0
- package/CHANGELOG.md +33 -0
- package/CLAUDE.md +72 -0
- package/README.md +51 -0
- package/app/globals.css +31 -0
- package/app/layout.tsx +29 -0
- package/app/page.tsx +20 -0
- package/app/proof-contract.test.ts +95 -0
- package/app/proof.css +217 -0
- package/app/proof.generated.json +7 -0
- package/bun.lock +991 -0
- package/components/CoverageProof.tsx +387 -0
- package/components/ui/accordion.tsx +57 -0
- package/components/ui/sheet.tsx +140 -0
- package/eslint.config.mjs +43 -0
- package/lib/api-test-coverage.ts +387 -0
- package/lib/utils.ts +6 -0
- package/next.config.js +6 -0
- package/package.json +57 -0
- package/postcss.config.js +7 -0
- package/scripts/generate-proof.ts +49 -0
- package/tailwind.config.ts +61 -0
- package/tsconfig.json +43 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: ๐ Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: main
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write # npm provenance
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
name: ๐ Publish to npm
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
env:
|
|
18
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: ๐ฅ Checkout code
|
|
22
|
+
uses: actions/checkout@v5
|
|
23
|
+
|
|
24
|
+
- name: ๐ข Setup Node
|
|
25
|
+
uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: 22
|
|
28
|
+
registry-url: https://registry.npmjs.org
|
|
29
|
+
|
|
30
|
+
- name: ๐ Skip if this version is already on npm
|
|
31
|
+
id: check
|
|
32
|
+
run: |
|
|
33
|
+
NAME=$(node -p "require('./package.json').name")
|
|
34
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
35
|
+
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
|
36
|
+
echo "::notice::$NAME@$VERSION is already published โ nothing to do"
|
|
37
|
+
echo "published=true" >> "$GITHUB_OUTPUT"
|
|
38
|
+
else
|
|
39
|
+
echo "published=false" >> "$GITHUB_OUTPUT"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
- name: โ ๏ธ Warn when NPM_TOKEN is missing
|
|
43
|
+
if: steps.check.outputs.published == 'false' && env.NPM_TOKEN == ''
|
|
44
|
+
run: echo "::warning::NPM_TOKEN secret is not set โ skipping npm publish. Add it under Settings โ Secrets and variables โ Actions."
|
|
45
|
+
|
|
46
|
+
- name: ๐ Publish to npm
|
|
47
|
+
if: steps.check.outputs.published == 'false' && env.NPM_TOKEN != ''
|
|
48
|
+
run: npm publish --provenance --access public
|
|
49
|
+
env:
|
|
50
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: ๐งพ Proof Contract Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: main
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: main
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test-unit:
|
|
12
|
+
name: ๐งพ Run proof contract tests
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
env:
|
|
16
|
+
NODE_ENV: test
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: ๐ฅ Checkout code
|
|
20
|
+
uses: actions/checkout@v5
|
|
21
|
+
|
|
22
|
+
- name: ๐ Setup Bun
|
|
23
|
+
uses: oven-sh/setup-bun@v2
|
|
24
|
+
with:
|
|
25
|
+
bun-version: latest
|
|
26
|
+
|
|
27
|
+
- name: ๐งฐ Install dependencies
|
|
28
|
+
run: bun install
|
|
29
|
+
|
|
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
|
|
34
|
+
run: bun run test:unit
|
|
35
|
+
env:
|
|
36
|
+
NODE_ENV: test
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial public release under new name SpecProof (npm package `specproof`).
|
|
12
|
+
- GitHub Actions release workflow: automatically publishes to npm when package version is updated.
|
|
13
|
+
- Comprehensive project documentation in CLAUDE.md.
|
|
14
|
+
- Generalized spec/test discovery: auto-discovers OpenAPI specs (`openapi*.json`, `swagger*.json`) and test files in any target repo; no longer OmniLens-specific.
|
|
15
|
+
- Support for explicit spec path via `SPECPROOF_SPEC` environment variable.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Breaking**: Renamed environment variable `OMNILENS_REPO` โ `SPECPROOF_REPO` for specifying the target repo to audit.
|
|
19
|
+
- **Breaking**: Renamed environment variable `OMNILENS_SPEC` โ `SPECPROOF_SPEC` for specifying the OpenAPI spec path.
|
|
20
|
+
- Renamed npm package from `test-ledger` to `specproof`.
|
|
21
|
+
- Renamed generator script `scripts/generate-ledger.ts` โ `scripts/generate-proof.ts` and npm command `generate:ledger` โ `generate:proof`.
|
|
22
|
+
- Renamed generated artifact `app/ledger.generated.json` โ `app/proof.generated.json`.
|
|
23
|
+
- Renamed contract test file `app/ledger-contract.test.ts` โ `app/proof-contract.test.ts`.
|
|
24
|
+
- Renamed stylesheet `app/ledger.css` โ `app/proof.css`.
|
|
25
|
+
- Renamed React component `CoverageLedger` โ `CoverageProof`.
|
|
26
|
+
- Updated CSS class prefix `lg-` โ `sp-` throughout the codebase.
|
|
27
|
+
- Generalized code comments and documentation from OmniLens-specific to target-agnostic language.
|
|
28
|
+
- GitHub Actions workflow `test-unit.yml` renamed to "Proof Contract Tests" with updated environment variable references.
|
|
29
|
+
- Removed `"private": true` from package.json to allow npm publishing.
|
|
30
|
+
- Updated package.json metadata: added `repository`, `homepage`, `description`, and `keywords`.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
- OmniLens-specific hardcoded paths and configuration.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Architecture: generate โ checked-in artifact โ contract-test
|
|
12
|
+
|
|
13
|
+
1. **Sources of truth**, read from the target repo (`SPECPROOF_REPO` env var; defaults to the current working directory):
|
|
14
|
+
- The OpenAPI spec โ auto-discovered as the shallowest `openapi*.json` / `swagger*.json` in the tree, or set explicitly with `SPECPROOF_SPEC` (relative to the repo root).
|
|
15
|
+
- The repo's `*.test.ts` / `*.test.tsx` / `*.test.js` files โ parsed via regex for `describe("METHOD /path")` blocks and `.status).toBe(NNN)` / `.status).toEqual(NNN)` assertions.
|
|
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
|
+
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
|
+
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.
|
|
20
|
+
|
|
21
|
+
Because of this split, most day-to-day work happens in exactly one of two places:
|
|
22
|
+
- Changing what's audited/how coverage is computed โ `lib/api-test-coverage.ts` (+ regenerate the proof).
|
|
23
|
+
- Changing how the proof is displayed โ `components/CoverageProof.tsx` / `app/proof.css`, reading the existing `app/proof.generated.json` as fixture data โ no target checkout needed.
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
Requires [Bun](https://bun.sh).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun install
|
|
31
|
+
bun run dev # generate:proof + next dev (port 3001)
|
|
32
|
+
bun run build # generate:proof + next build
|
|
33
|
+
bun run start # serve the production build (port 3001)
|
|
34
|
+
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)
|
|
36
|
+
bun run test:unit:watch # vitest watch mode
|
|
37
|
+
bun run lint # eslint + tsc --noEmit
|
|
38
|
+
bun run lint:es # eslint only
|
|
39
|
+
bun run lint:ts # tsc --noEmit only
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
To run a single test file: `bunx vitest run app/proof-contract.test.ts` (there is currently only the one test file).
|
|
43
|
+
|
|
44
|
+
The generator/contract tests need a target repo:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
SPECPROOF_REPO=/path/to/target-repo bun run generate:proof
|
|
48
|
+
# optionally, if the spec has a nonstandard name/location:
|
|
49
|
+
SPECPROOF_REPO=/path/to/target-repo SPECPROOF_SPEC=docs/api-spec.json bun run generate:proof
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Updating the proof
|
|
53
|
+
|
|
54
|
+
When a target repo's test or OpenAPI spec changes, the checked-in `app/proof.generated.json` goes stale and the contract tests fail. Fix:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
SPECPROOF_REPO=/path/to/target-repo bun run generate:proof
|
|
58
|
+
git add app/proof.generated.json && git commit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Releasing
|
|
62
|
+
|
|
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.
|
|
64
|
+
|
|
65
|
+
## Notes on the parsing approach
|
|
66
|
+
|
|
67
|
+
`lib/api-test-coverage.ts` parses test files with regexes rather than a real TS/AST parser, relying on prettier-consistent formatting conventions in the audited repo:
|
|
68
|
+
- `describe("METHOD /path")` titles are matched for the HTTP method prefix (`GET|POST|PUT|DELETE|PATCH`) followed by the operation path.
|
|
69
|
+
- `it()`/`test()` blocks are extracted by matching the opening call, then scanning for the next `});` at the same indentation level โ not a brace-matching parser, so it depends on consistent formatting in the audited repo.
|
|
70
|
+
- Snippet source is dedented before being stored/rendered.
|
|
71
|
+
|
|
72
|
+
If this parsing logic needs updating, check it against real test files in a target repo โ the contract test's "every quoted snippet points at a real it()/test() block" check is what catches regressions here.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SpecProof
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
Point it at a repo the same way OpenAPI tooling works when installed into one โ no repo-specific configuration is baked in:
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **Generator**: `scripts/generate-proof.ts` compiles them into `app/proof.generated.json` โ checked in, deterministic, regenerated automatically before `dev`/`build`.
|
|
9
|
+
- **Consumer**: the app renders the checked-in artifact; no target checkout is needed to build or deploy.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Requires [Bun](https://bun.sh).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun install
|
|
18
|
+
SPECPROOF_REPO=/path/to/your-repo bun run dev # compiles the proof, then serves on http://localhost:3001
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`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).
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bun run dev # generate:proof + next dev (port 3001)
|
|
27
|
+
bun run build # generate:proof + next build
|
|
28
|
+
bun run start # serve the production build (port 3001)
|
|
29
|
+
bun run generate:proof # regenerate app/proof.generated.json only
|
|
30
|
+
bun run test:unit # contract tests (self-skip without a configured target repo)
|
|
31
|
+
bun run lint # ESLint + tsc
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Updating the proof
|
|
35
|
+
|
|
36
|
+
When the target repo's tests or OpenAPI spec change, the checked-in artifact goes stale and the contract tests fail. The fix:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
SPECPROOF_REPO=/path/to/your-repo bun run generate:proof
|
|
40
|
+
git add app/proof.generated.json && git commit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Conventions the parser relies on
|
|
44
|
+
|
|
45
|
+
- Test suites titled `describe("METHOD /path")` (`GET|POST|PUT|DELETE|PATCH`) โ the path is matched against the spec's paths.
|
|
46
|
+
- Status assertions written as `.status).toBe(NNN)` or `.status).toEqual(NNN)`.
|
|
47
|
+
- Prettier-consistent formatting: `it()`/`test()` blocks are extracted by indentation, not a full parser.
|
|
48
|
+
|
|
49
|
+
## Releasing
|
|
50
|
+
|
|
51
|
+
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).
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@import 'tailwindcss/base';
|
|
2
|
+
@import 'tailwindcss/components';
|
|
3
|
+
@import 'tailwindcss/utilities';
|
|
4
|
+
|
|
5
|
+
/* Same dark theme tokens as apps/web โ the proof renders on the shadcn
|
|
6
|
+
variable vocabulary (background/foreground/muted/border/โฆ). */
|
|
7
|
+
:root {
|
|
8
|
+
--background: 0 0% 0.4%;
|
|
9
|
+
--foreground: 0 0% 98%;
|
|
10
|
+
--primary: 0 0% 98%;
|
|
11
|
+
--primary-foreground: 0 0% 9%;
|
|
12
|
+
--secondary: 0 0% 14.9%;
|
|
13
|
+
--secondary-foreground: 0 0% 98%;
|
|
14
|
+
--muted: 0 0% 25%;
|
|
15
|
+
--muted-foreground: 0 0% 75%;
|
|
16
|
+
--accent: 0 0% 25%;
|
|
17
|
+
--accent-foreground: 0 0% 98%;
|
|
18
|
+
--border: 0 0% 20%;
|
|
19
|
+
--input: 0 0% 14.9%;
|
|
20
|
+
--ring: 0 0% 83.1%;
|
|
21
|
+
--radius: 0.5rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
* {
|
|
25
|
+
border-color: hsl(var(--border));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
background-color: hsl(var(--background));
|
|
30
|
+
color: hsl(var(--foreground));
|
|
31
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "SpecProof",
|
|
7
|
+
description:
|
|
8
|
+
"Every documented API operation and status code, cross-examined against the repo's test suites."
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<html lang="en" className="dark">
|
|
14
|
+
<head>
|
|
15
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
16
|
+
<link
|
|
17
|
+
rel="preconnect"
|
|
18
|
+
href="https://fonts.gstatic.com"
|
|
19
|
+
crossOrigin="anonymous"
|
|
20
|
+
/>
|
|
21
|
+
<link
|
|
22
|
+
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap"
|
|
23
|
+
rel="stylesheet"
|
|
24
|
+
/>
|
|
25
|
+
</head>
|
|
26
|
+
<body className="min-h-screen bg-background antialiased">{children}</body>
|
|
27
|
+
</html>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/app/page.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CoverageReport } from '@/lib/api-test-coverage';
|
|
2
|
+
import { CoverageProof } from '@/components/CoverageProof';
|
|
3
|
+
|
|
4
|
+
import proof from './proof.generated.json';
|
|
5
|
+
|
|
6
|
+
import './proof.css';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SpecProof
|
|
10
|
+
*
|
|
11
|
+
* Audit view of a repo's API test coverage: the OpenAPI spec's operations and
|
|
12
|
+
* response statuses joined against the repo's test assertions. Renders the
|
|
13
|
+
* checked-in proof.generated.json artifact โ regenerated automatically
|
|
14
|
+
* before dev/build and kept in lockstep with the sources by the
|
|
15
|
+
* proof-contract test.
|
|
16
|
+
*/
|
|
17
|
+
export default function SpecProofPage() {
|
|
18
|
+
const report = proof as unknown as CoverageReport;
|
|
19
|
+
return <CoverageProof report={report} compiledAt={new Date().toISOString()} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
resolveSpecPath,
|
|
7
|
+
TARGET_REPO_ROOT,
|
|
8
|
+
type CoverageReport,
|
|
9
|
+
} from "@/lib/api-test-coverage";
|
|
10
|
+
import { buildProof, GENERATED_PROOF_PATH } from "@/scripts/generate-proof";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Contract tests that keep the rendered proof in lockstep with the target
|
|
14
|
+
* repo it audits. Three drift modes are covered:
|
|
15
|
+
*
|
|
16
|
+
* 1. The OpenAPI spec gains/loses an operation without the proof following
|
|
17
|
+
* (spec <-> proof, both directions).
|
|
18
|
+
* 2. A quoted test snippet no longer points at a real it()/test() block in
|
|
19
|
+
* its test file โ the evidence the UI shows must be re-readable from the
|
|
20
|
+
* source it cites.
|
|
21
|
+
* 3. The spec or a test file changed without regenerating
|
|
22
|
+
* proof.generated.json.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function loadCheckedInProof(): CoverageReport {
|
|
26
|
+
return JSON.parse(fs.readFileSync(GENERATED_PROOF_PATH, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The drift guards compare the artifact against the audited target repo
|
|
30
|
+
// (SPECPROOF_REPO, or the current directory). Without an OpenAPI spec to audit
|
|
31
|
+
// there is nothing to compare against, so the suite self-skips.
|
|
32
|
+
const specPath = resolveSpecPath();
|
|
33
|
+
if (!specPath) {
|
|
34
|
+
console.warn(
|
|
35
|
+
`proof-contract: skipping โ no OpenAPI spec found under ${TARGET_REPO_ROOT} (set SPECPROOF_REPO to the repo to audit, or SPECPROOF_SPEC to the spec file)`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!specPath)("SpecProof contract", () => {
|
|
40
|
+
it("audits every operation in the OpenAPI spec, and nothing else", () => {
|
|
41
|
+
const spec = JSON.parse(fs.readFileSync(specPath!, "utf8")) as {
|
|
42
|
+
paths: Record<string, Record<string, unknown>>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const documented = Object.entries(spec.paths)
|
|
46
|
+
.flatMap(([opPath, methods]) =>
|
|
47
|
+
Object.keys(methods).map((method) => `${method} ${opPath}`),
|
|
48
|
+
)
|
|
49
|
+
.sort();
|
|
50
|
+
|
|
51
|
+
const audited = loadCheckedInProof()
|
|
52
|
+
.tags.flatMap((tag) => tag.operations)
|
|
53
|
+
.map((op) => `${op.method} ${op.specPath}`)
|
|
54
|
+
.sort();
|
|
55
|
+
|
|
56
|
+
expect(documented.length).toBeGreaterThan(0);
|
|
57
|
+
expect(audited).toEqual(documented);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("every quoted snippet points at a real it()/test() block in its test file", () => {
|
|
61
|
+
const sources = new Map<string, string[]>();
|
|
62
|
+
const readLines = (testFile: string) => {
|
|
63
|
+
const cached = sources.get(testFile);
|
|
64
|
+
if (cached) return cached;
|
|
65
|
+
const lines = fs
|
|
66
|
+
.readFileSync(path.join(TARGET_REPO_ROOT, testFile), "utf8")
|
|
67
|
+
.split("\n");
|
|
68
|
+
sources.set(testFile, lines);
|
|
69
|
+
return lines;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const tag of loadCheckedInProof().tags) {
|
|
73
|
+
for (const op of tag.operations) {
|
|
74
|
+
if (!op.testFile) continue;
|
|
75
|
+
for (const status of op.statuses) {
|
|
76
|
+
for (const snippet of status.snippets) {
|
|
77
|
+
const line = readLines(op.testFile)[snippet.startLine - 1] ?? "";
|
|
78
|
+
expect(
|
|
79
|
+
/(?:it|test)\(/.test(line),
|
|
80
|
+
`${op.method} ${op.specPath} ${status.code}: snippet "${snippet.title}" cites ${op.testFile}:${snippet.startLine}, but that line is not an it()/test() block`,
|
|
81
|
+
).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("proof.generated.json is up to date with the spec and tests", () => {
|
|
89
|
+
const checkedIn = loadCheckedInProof();
|
|
90
|
+
// If this fails, the OpenAPI spec or a test file changed without
|
|
91
|
+
// regenerating the proof: run `bun run generate:proof` and commit the
|
|
92
|
+
// result.
|
|
93
|
+
expect(checkedIn).toEqual(buildProof());
|
|
94
|
+
});
|
|
95
|
+
});
|
package/app/proof.css
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
SpecProof โ functional coverage report
|
|
3
|
+
Single-typeface Geist Mono tabulation: dotted index leaders, punch-card
|
|
4
|
+
status marks, stamped verdicts. All rules scoped under .proof-root.
|
|
5
|
+
============================================================================ */
|
|
6
|
+
|
|
7
|
+
.proof-root {
|
|
8
|
+
--sp-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
9
|
+
|
|
10
|
+
/* method colors โ same accent vocabulary as the marketing/POC surfaces */
|
|
11
|
+
--sp-get: #00e5a0;
|
|
12
|
+
--sp-post: #4d9fff;
|
|
13
|
+
--sp-put: #f59e0b;
|
|
14
|
+
--sp-delete: #ef4444;
|
|
15
|
+
--sp-patch: #c084fc;
|
|
16
|
+
|
|
17
|
+
/* verdicts */
|
|
18
|
+
--sp-ok: #00e5a0;
|
|
19
|
+
--sp-gap: #ff5c5c;
|
|
20
|
+
--sp-undoc: #f5a623;
|
|
21
|
+
|
|
22
|
+
--sp-ink: #e6e6e2;
|
|
23
|
+
--sp-faint: rgba(255, 255, 255, 0.38);
|
|
24
|
+
--sp-hair: rgba(255, 255, 255, 0.09);
|
|
25
|
+
--sp-hair-strong: rgba(255, 255, 255, 0.22);
|
|
26
|
+
|
|
27
|
+
font-family: var(--sp-mono);
|
|
28
|
+
color: var(--sp-ink);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.proof-page {
|
|
32
|
+
position: relative;
|
|
33
|
+
isolation: isolate;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* faint top glow behind content */
|
|
37
|
+
.proof-page::before {
|
|
38
|
+
content: "";
|
|
39
|
+
position: absolute;
|
|
40
|
+
inset: 0;
|
|
41
|
+
z-index: -1;
|
|
42
|
+
background: radial-gradient(120% 40% at 50% -5%, rgba(0, 229, 160, 0.055), transparent 60%);
|
|
43
|
+
pointer-events: none;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---- entrance choreography ------------------------------------------------ */
|
|
47
|
+
@keyframes sp-rise {
|
|
48
|
+
from {
|
|
49
|
+
opacity: 0;
|
|
50
|
+
transform: translateY(14px);
|
|
51
|
+
}
|
|
52
|
+
to {
|
|
53
|
+
opacity: 1;
|
|
54
|
+
transform: translateY(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.sp-rise {
|
|
59
|
+
opacity: 0;
|
|
60
|
+
animation: sp-rise 0.65s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
|
61
|
+
animation-delay: calc(var(--sp-stagger, 0) * 70ms);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (prefers-reduced-motion: reduce) {
|
|
65
|
+
.sp-rise {
|
|
66
|
+
animation: none;
|
|
67
|
+
opacity: 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ---- proof rules ----------------------------------------------------------- */
|
|
72
|
+
.sp-rule-double {
|
|
73
|
+
border-top: 3px double var(--sp-hair-strong);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.sp-leader {
|
|
77
|
+
flex: 1 1 2rem;
|
|
78
|
+
min-width: 2rem;
|
|
79
|
+
border-bottom: 1px dotted rgba(255, 255, 255, 0.18);
|
|
80
|
+
transform: translateY(-0.3em);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ---- method chips ----------------------------------------------------------- */
|
|
84
|
+
.sp-method {
|
|
85
|
+
--mc: var(--sp-faint);
|
|
86
|
+
color: var(--mc);
|
|
87
|
+
border-left: 3px solid var(--mc);
|
|
88
|
+
padding-left: 0.6rem;
|
|
89
|
+
font-size: 0.72rem;
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
letter-spacing: 0.14em;
|
|
92
|
+
line-height: 1.9;
|
|
93
|
+
transition: border-left-width 150ms ease;
|
|
94
|
+
}
|
|
95
|
+
.sp-method[data-method="get"] { --mc: var(--sp-get); }
|
|
96
|
+
.sp-method[data-method="post"] { --mc: var(--sp-post); }
|
|
97
|
+
.sp-method[data-method="put"] { --mc: var(--sp-put); }
|
|
98
|
+
.sp-method[data-method="delete"] { --mc: var(--sp-delete); }
|
|
99
|
+
.sp-method[data-method="patch"] { --mc: var(--sp-patch); }
|
|
100
|
+
|
|
101
|
+
.sp-oprow:hover .sp-method,
|
|
102
|
+
.sp-oprow[data-state="open"] .sp-method {
|
|
103
|
+
border-left-width: 6px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ---- punch-card status marks (trigger row) ---------------------------------- */
|
|
107
|
+
.sp-marks {
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
gap: 3px;
|
|
110
|
+
align-items: center;
|
|
111
|
+
}
|
|
112
|
+
.sp-mark {
|
|
113
|
+
width: 7px;
|
|
114
|
+
height: 11px;
|
|
115
|
+
border: 1px solid var(--sp-faint);
|
|
116
|
+
background: transparent;
|
|
117
|
+
}
|
|
118
|
+
.sp-mark[data-state="ok"] {
|
|
119
|
+
background: var(--sp-ok);
|
|
120
|
+
border-color: var(--sp-ok);
|
|
121
|
+
}
|
|
122
|
+
.sp-mark[data-state="gap"] {
|
|
123
|
+
border-color: var(--sp-gap);
|
|
124
|
+
border-style: dashed;
|
|
125
|
+
}
|
|
126
|
+
.sp-mark[data-state="undoc"] {
|
|
127
|
+
border-color: var(--sp-undoc);
|
|
128
|
+
background: repeating-linear-gradient(
|
|
129
|
+
45deg,
|
|
130
|
+
var(--sp-undoc) 0 1px,
|
|
131
|
+
transparent 1px 3px
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ---- status-code ink by class ------------------------------------------------ */
|
|
136
|
+
.sp-code[data-class="2"] { color: var(--sp-ok); }
|
|
137
|
+
.sp-code[data-class="3"] { color: var(--sp-post); }
|
|
138
|
+
.sp-code[data-class="4"] { color: var(--sp-undoc); }
|
|
139
|
+
.sp-code[data-class="5"] { color: var(--sp-gap); }
|
|
140
|
+
|
|
141
|
+
/* ---- verdict stamps ----------------------------------------------------------- */
|
|
142
|
+
.sp-stamp {
|
|
143
|
+
font-size: 0.62rem;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
letter-spacing: 0.16em;
|
|
146
|
+
padding: 0.18rem 0.5rem;
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
}
|
|
149
|
+
.sp-stamp[data-verdict="ok"] {
|
|
150
|
+
color: var(--sp-ok);
|
|
151
|
+
border: 1px solid color-mix(in srgb, var(--sp-ok) 45%, transparent);
|
|
152
|
+
background: color-mix(in srgb, var(--sp-ok) 8%, transparent);
|
|
153
|
+
}
|
|
154
|
+
.sp-stamp[data-verdict="gap"] {
|
|
155
|
+
color: var(--sp-gap);
|
|
156
|
+
border: 1px dashed color-mix(in srgb, var(--sp-gap) 55%, transparent);
|
|
157
|
+
}
|
|
158
|
+
.sp-stamp[data-verdict="undoc"] {
|
|
159
|
+
color: var(--sp-undoc);
|
|
160
|
+
border: 1px solid color-mix(in srgb, var(--sp-undoc) 45%, transparent);
|
|
161
|
+
background: repeating-linear-gradient(
|
|
162
|
+
45deg,
|
|
163
|
+
color-mix(in srgb, var(--sp-undoc) 10%, transparent) 0 4px,
|
|
164
|
+
transparent 4px 8px
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* clickable stamps (verified / undocumented open the test-code panel) */
|
|
169
|
+
button.sp-stamp {
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
transition: transform 120ms ease, box-shadow 120ms ease;
|
|
172
|
+
}
|
|
173
|
+
button.sp-stamp:hover {
|
|
174
|
+
transform: translateY(-1px);
|
|
175
|
+
box-shadow: 0 3px 14px -4px color-mix(in srgb, currentColor 55%, transparent);
|
|
176
|
+
}
|
|
177
|
+
button.sp-stamp:focus-visible {
|
|
178
|
+
outline: 1px solid currentColor;
|
|
179
|
+
outline-offset: 2px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ---- test-code panel ----------------------------------------------------------- */
|
|
183
|
+
.sp-codeblock {
|
|
184
|
+
border: 1px solid var(--sp-hair);
|
|
185
|
+
background: rgba(255, 255, 255, 0.02);
|
|
186
|
+
font-size: 0.72rem;
|
|
187
|
+
line-height: 1.7;
|
|
188
|
+
overflow-x: auto;
|
|
189
|
+
}
|
|
190
|
+
.sp-codeline {
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-columns: 3.2rem 1fr;
|
|
193
|
+
padding-right: 1rem;
|
|
194
|
+
white-space: pre;
|
|
195
|
+
}
|
|
196
|
+
.sp-codeline[data-hit] {
|
|
197
|
+
background: color-mix(in srgb, var(--sp-ok) 8%, transparent);
|
|
198
|
+
box-shadow: inset 2px 0 0 var(--sp-ok);
|
|
199
|
+
}
|
|
200
|
+
.sp-lineno {
|
|
201
|
+
padding-right: 0.9rem;
|
|
202
|
+
text-align: right;
|
|
203
|
+
color: rgba(255, 255, 255, 0.22);
|
|
204
|
+
user-select: none;
|
|
205
|
+
}
|
|
206
|
+
.sp-codeline[data-hit] .sp-lineno {
|
|
207
|
+
color: var(--sp-ok);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ---- row hover bleed ----------------------------------------------------------- */
|
|
211
|
+
.sp-oprow {
|
|
212
|
+
transition: background-color 180ms ease;
|
|
213
|
+
}
|
|
214
|
+
.sp-oprow:hover,
|
|
215
|
+
.sp-oprow[data-state="open"] {
|
|
216
|
+
background: rgba(255, 255, 255, 0.025);
|
|
217
|
+
}
|