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.
@@ -0,0 +1,387 @@
1
+ // Coverage analyzer for SpecProof — the source the generated proof is
2
+ // compiled from.
3
+ //
4
+ // Joins two sources of truth in the audited target repo:
5
+ // 1. The OpenAPI spec (auto-discovered `openapi*.json` / `swagger*.json`,
6
+ // or SPECPROOF_SPEC) — every documented operation and its response status
7
+ // codes.
8
+ // 2. The repo's test files (`*.test.ts` / `*.test.tsx` / `*.test.js`) —
9
+ // which statuses each operation actually has assertions for, parsed from
10
+ // `describe("METHOD /path")` blocks and their `.status).toBe(NNN)`
11
+ // expectations.
12
+ //
13
+ // Only the generator script (scripts/generate-proof.ts) and the contract
14
+ // tests run this; the app itself renders the checked-in
15
+ // app/proof.generated.json artifact. The output is deterministic for a given
16
+ // state of the target repo — that is what makes the artifact diffable and the
17
+ // drift guard possible.
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ /**
23
+ * The repo the proof audits. Configurable via the SPECPROOF_REPO env var;
24
+ * defaults to the directory SpecProof is run from, so installing it into a
25
+ * repo and running the generator there just works.
26
+ */
27
+ export const TARGET_REPO_ROOT = process.env.SPECPROOF_REPO
28
+ ? path.resolve(process.env.SPECPROOF_REPO)
29
+ : process.cwd();
30
+
31
+ /** Directories never worth scanning for specs or tests */
32
+ const EXCLUDED_DIRS = new Set([
33
+ 'node_modules',
34
+ '.git',
35
+ '.next',
36
+ 'dist',
37
+ 'build',
38
+ 'out',
39
+ 'coverage',
40
+ '.turbo',
41
+ '.vercel'
42
+ ]);
43
+
44
+ const SPEC_FILE_RE = /^(?:openapi|swagger)[^/]*\.json$/i;
45
+ const TEST_FILE_RE = /\.test\.(?:ts|tsx|js|jsx)$/;
46
+
47
+ /** Recursively collect files matching `matches`, skipping EXCLUDED_DIRS */
48
+ function walk(dir: string, matches: (name: string) => boolean): string[] {
49
+ const found: string[] = [];
50
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
51
+ if (entry.isDirectory()) {
52
+ if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
53
+ found.push(...walk(path.join(dir, entry.name), matches));
54
+ }
55
+ } else if (matches(entry.name)) {
56
+ found.push(path.join(dir, entry.name));
57
+ }
58
+ }
59
+ return found;
60
+ }
61
+
62
+ /**
63
+ * Locate the OpenAPI spec in the target repo: SPECPROOF_SPEC (relative to the
64
+ * repo root, or absolute) wins; otherwise the shallowest `openapi*.json` /
65
+ * `swagger*.json` in the tree. Returns null when there is nothing to audit.
66
+ */
67
+ export function resolveSpecPath(repoRoot: string = TARGET_REPO_ROOT): string | null {
68
+ if (process.env.SPECPROOF_SPEC) {
69
+ const explicit = path.resolve(repoRoot, process.env.SPECPROOF_SPEC);
70
+ return fs.existsSync(explicit) ? explicit : null;
71
+ }
72
+ if (!fs.existsSync(repoRoot)) return null;
73
+ const candidates = walk(repoRoot, (name) => SPEC_FILE_RE.test(name)).sort(
74
+ (a, b) =>
75
+ a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b)
76
+ );
77
+ return candidates[0] ?? null;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Types
82
+ // ============================================================================
83
+
84
+ export interface TestSnippet {
85
+ /** it() title */
86
+ title: string;
87
+ /** dedented source of the it() block */
88
+ source: string;
89
+ /** 1-indexed line in the test file where the block starts */
90
+ startLine: number;
91
+ }
92
+
93
+ export interface StatusCoverage {
94
+ /** HTTP status code, e.g. "200" */
95
+ code: string;
96
+ /** Response description from the OpenAPI spec (empty for undocumented) */
97
+ description: string;
98
+ /** Whether the spec documents this status for the operation */
99
+ documented: boolean;
100
+ /** Number of test assertions hitting this status for this method */
101
+ assertions: number;
102
+ /** The it() blocks asserting this status */
103
+ snippets: TestSnippet[];
104
+ }
105
+
106
+ export interface OperationCoverage {
107
+ method: string;
108
+ specPath: string;
109
+ summary: string;
110
+ /** Path of the operation's test file relative to the target repo root, if one exists */
111
+ testFile: string | null;
112
+ /** Number of it() blocks in this operation's describe segments */
113
+ testCount: number;
114
+ statuses: StatusCoverage[];
115
+ /** Documented statuses with at least one assertion */
116
+ coveredCount: number;
117
+ /** Documented statuses with no assertions */
118
+ gapCount: number;
119
+ }
120
+
121
+ export interface TagCoverage {
122
+ tag: string;
123
+ description: string;
124
+ operations: OperationCoverage[];
125
+ coveredCount: number;
126
+ totalCount: number;
127
+ }
128
+
129
+ export interface CoverageReport {
130
+ tags: TagCoverage[];
131
+ operationCount: number;
132
+ /** documented (path, method, status) pairs with assertions */
133
+ coveredCount: number;
134
+ totalCount: number;
135
+ /** operations with no test evidence at all */
136
+ untestedOperations: number;
137
+ }
138
+
139
+ // ============================================================================
140
+ // Test-file parsing
141
+ // ============================================================================
142
+
143
+ interface OperationEvidence {
144
+ /** test file the evidence came from, relative to the repo root */
145
+ testFile: string;
146
+ /** statuses asserted, with assertion counts */
147
+ statuses: Map<string, number>;
148
+ /** it() blocks per asserted status */
149
+ snippets: Map<string, TestSnippet[]>;
150
+ testCount: number;
151
+ }
152
+
153
+ const STATUS_ASSERT_RE = /\.status\)\.(?:toBe|toEqual)\(\s*(\d{3})\s*\)/g;
154
+
155
+ /**
156
+ * Normalize a URL path for joining spec operations against describe titles:
157
+ * `{param}`, `[param]`, and `:param` segments are interchangeable, and a
158
+ * trailing slash is ignored.
159
+ */
160
+ function normalizePath(urlPath: string): string {
161
+ return urlPath
162
+ .replace(/\/$/, '')
163
+ .split('/')
164
+ .map((part) =>
165
+ part.replace(/^\{(.+)\}$/, '{}').replace(/^\[(.+)\]$/, '{}').replace(/^:(.+)$/, '{}')
166
+ )
167
+ .join('/');
168
+ }
169
+
170
+ /** Join key for one operation: "get /api/repo/{}" */
171
+ function operationKey(method: string, urlPath: string): string {
172
+ return `${method.toLowerCase()} ${normalizePath(urlPath)}`;
173
+ }
174
+
175
+ /** Strip the common leading indentation from an extracted block */
176
+ function dedent(block: string): string {
177
+ const lines = block.split('\n');
178
+ const indents = lines
179
+ .filter((line) => line.trim().length > 0)
180
+ .map((line) => line.match(/^\s*/)![0].length);
181
+ const cut = Math.min(...indents);
182
+ return lines.map((line) => line.slice(cut)).join('\n');
183
+ }
184
+
185
+ /**
186
+ * Extract the it()/test() blocks from a describe segment. Blocks end at the
187
+ * first `});` back at the block's own indentation — reliable for
188
+ * prettier-consistent test files.
189
+ */
190
+ function extractItBlocks(
191
+ segment: string,
192
+ segmentOffset: number,
193
+ fullSource: string
194
+ ): Array<{ title: string; source: string; startLine: number; statuses: string[] }> {
195
+ const blocks: Array<{ title: string; source: string; startLine: number; statuses: string[] }> = [];
196
+ const itRe = /(?:^|\n)([ \t]*)(?:it|test)\(\s*["'`]([^"'`]+)["'`]/g;
197
+
198
+ let match: RegExpExecArray | null;
199
+ while ((match = itRe.exec(segment)) !== null) {
200
+ const indent = match[1];
201
+ const title = match[2];
202
+ const blockStart = match.index + (match[0].startsWith('\n') ? 1 : 0);
203
+ const closer = `\n${indent}});`;
204
+ const closeIndex = segment.indexOf(closer, blockStart);
205
+ const blockEnd = closeIndex === -1 ? segment.length : closeIndex + closer.length;
206
+ const source = segment.slice(blockStart, blockEnd);
207
+
208
+ blocks.push({
209
+ title,
210
+ source: dedent(source),
211
+ startLine: fullSource.slice(0, segmentOffset + blockStart).split('\n').length,
212
+ statuses: [...source.matchAll(STATUS_ASSERT_RE)].map((m) => m[1])
213
+ });
214
+ }
215
+ return blocks;
216
+ }
217
+
218
+ /**
219
+ * Parse a test file's source into per-operation evidence, keyed by
220
+ * operationKey. The convention joined on is `describe("METHOD /path", …)` —
221
+ * each such segment is scanned for `.status).toBe(NNN)` /
222
+ * `.status).toEqual(NNN)` assertions and it() blocks. describe blocks whose
223
+ * title doesn't start with an HTTP method + path are ignored.
224
+ */
225
+ function parseTestFile(source: string, testFile: string): Map<string, OperationEvidence> {
226
+ const byOperation = new Map<string, OperationEvidence>();
227
+ const describeRe = /describe\(\s*["'`]([^"'`]+)["'`]/g;
228
+
229
+ const segments: Array<{ key: string; start: number; end: number }> = [];
230
+ let match: RegExpExecArray | null;
231
+ while ((match = describeRe.exec(source)) !== null) {
232
+ const title = match[1];
233
+ const titleMatch = title.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/);
234
+ if (segments.length > 0) segments[segments.length - 1].end = match.index;
235
+ segments.push({
236
+ key: titleMatch ? operationKey(titleMatch[1], titleMatch[2]) : '',
237
+ start: match.index,
238
+ end: source.length
239
+ });
240
+ }
241
+
242
+ for (const segment of segments) {
243
+ if (!segment.key) continue;
244
+ const body = source.slice(segment.start, segment.end);
245
+ const evidence =
246
+ byOperation.get(segment.key) ??
247
+ { testFile, statuses: new Map(), snippets: new Map(), testCount: 0 };
248
+
249
+ for (const statusMatch of body.matchAll(STATUS_ASSERT_RE)) {
250
+ const code = statusMatch[1];
251
+ evidence.statuses.set(code, (evidence.statuses.get(code) ?? 0) + 1);
252
+ }
253
+
254
+ const blocks = extractItBlocks(body, segment.start, source);
255
+ evidence.testCount += blocks.length;
256
+ for (const block of blocks) {
257
+ for (const code of new Set(block.statuses)) {
258
+ const list = evidence.snippets.get(code) ?? [];
259
+ list.push({ title: block.title, source: block.source, startLine: block.startLine });
260
+ evidence.snippets.set(code, list);
261
+ }
262
+ }
263
+
264
+ byOperation.set(segment.key, evidence);
265
+ }
266
+
267
+ return byOperation;
268
+ }
269
+
270
+ /**
271
+ * Scan the whole target repo for test files and index their evidence by
272
+ * operation. When several files describe the same operation, the one with the
273
+ * most it() blocks wins (ties broken alphabetically) — snippets must all cite
274
+ * a single file.
275
+ */
276
+ function collectTestEvidence(repoRoot: string): Map<string, OperationEvidence> {
277
+ const best = new Map<string, OperationEvidence>();
278
+ const testFiles = walk(repoRoot, (name) => TEST_FILE_RE.test(name)).sort();
279
+
280
+ for (const testFileAbs of testFiles) {
281
+ const testFile = path.relative(repoRoot, testFileAbs).split(path.sep).join('/');
282
+ const parsed = parseTestFile(fs.readFileSync(testFileAbs, 'utf8'), testFile);
283
+ for (const [key, evidence] of parsed) {
284
+ const current = best.get(key);
285
+ if (!current || evidence.testCount > current.testCount) {
286
+ best.set(key, evidence);
287
+ }
288
+ }
289
+ }
290
+ return best;
291
+ }
292
+
293
+ // ============================================================================
294
+ // Report assembly
295
+ // ============================================================================
296
+
297
+ export function buildCoverageReport(): CoverageReport {
298
+ const specPath = resolveSpecPath();
299
+ if (!specPath) {
300
+ throw new Error(
301
+ `api-test-coverage: no OpenAPI spec found under ${TARGET_REPO_ROOT} — ` +
302
+ 'set SPECPROOF_REPO to the repo to audit, or SPECPROOF_SPEC to the spec file'
303
+ );
304
+ }
305
+
306
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')) as {
307
+ tags?: Array<{ name: string; description?: string }>;
308
+ paths: Record<
309
+ string,
310
+ Record<string, { summary?: string; tags?: string[]; responses?: Record<string, { description?: string }> }>
311
+ >;
312
+ };
313
+
314
+ const evidenceByOperation = collectTestEvidence(TARGET_REPO_ROOT);
315
+ const tagOrder = (spec.tags ?? []).map((t) => t.name);
316
+ const tagDescriptions = new Map((spec.tags ?? []).map((t) => [t.name, t.description ?? '']));
317
+ const byTag = new Map<string, OperationCoverage[]>();
318
+
319
+ for (const [specPathKey, methods] of Object.entries(spec.paths ?? {})) {
320
+ for (const [method, operation] of Object.entries(methods)) {
321
+ const methodEvidence = evidenceByOperation.get(operationKey(method, specPathKey));
322
+ const documented = Object.entries(operation.responses ?? {});
323
+ const assertedCodes = methodEvidence?.statuses ?? new Map<string, number>();
324
+
325
+ const statuses: StatusCoverage[] = documented.map(([code, response]) => ({
326
+ code,
327
+ description: response.description ?? '',
328
+ documented: true,
329
+ assertions: assertedCodes.get(code) ?? 0,
330
+ snippets: methodEvidence?.snippets.get(code) ?? []
331
+ }));
332
+ // Statuses the tests exercise but the spec doesn't document
333
+ for (const [code, assertions] of assertedCodes) {
334
+ if (!statuses.some((s) => s.code === code)) {
335
+ statuses.push({
336
+ code,
337
+ description: '',
338
+ documented: false,
339
+ assertions,
340
+ snippets: methodEvidence?.snippets.get(code) ?? []
341
+ });
342
+ }
343
+ }
344
+ statuses.sort((a, b) => a.code.localeCompare(b.code));
345
+
346
+ const coveredCount = statuses.filter((s) => s.documented && s.assertions > 0).length;
347
+ const gapCount = statuses.filter((s) => s.documented && s.assertions === 0).length;
348
+
349
+ const op: OperationCoverage = {
350
+ method,
351
+ specPath: specPathKey,
352
+ summary: operation.summary ?? '',
353
+ testFile: methodEvidence?.testFile ?? null,
354
+ testCount: methodEvidence?.testCount ?? 0,
355
+ statuses,
356
+ coveredCount,
357
+ gapCount
358
+ };
359
+
360
+ const tag = operation.tags?.[0] ?? 'Other';
361
+ byTag.set(tag, [...(byTag.get(tag) ?? []), op]);
362
+ }
363
+ }
364
+
365
+ const tags: TagCoverage[] = [...byTag.entries()]
366
+ .sort(([a], [b]) => {
367
+ const ai = tagOrder.indexOf(a);
368
+ const bi = tagOrder.indexOf(b);
369
+ return (ai === -1 ? tagOrder.length : ai) - (bi === -1 ? tagOrder.length : bi);
370
+ })
371
+ .map(([tag, operations]) => ({
372
+ tag,
373
+ description: tagDescriptions.get(tag) ?? '',
374
+ operations,
375
+ coveredCount: operations.reduce((n, op) => n + op.coveredCount, 0),
376
+ totalCount: operations.reduce((n, op) => n + op.coveredCount + op.gapCount, 0)
377
+ }));
378
+
379
+ const operations = tags.flatMap((t) => t.operations);
380
+ return {
381
+ tags,
382
+ operationCount: operations.length,
383
+ coveredCount: tags.reduce((n, t) => n + t.coveredCount, 0),
384
+ totalCount: tags.reduce((n, t) => n + t.totalCount, 0),
385
+ untestedOperations: operations.filter((op) => op.testFile === null || op.testCount === 0).length
386
+ };
387
+ }
package/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
package/next.config.js ADDED
@@ -0,0 +1,6 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: false,
4
+ };
5
+
6
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "specproof",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "repository": "github:Durable-Quality/specproof",
7
+ "homepage": "https://github.com/Durable-Quality/specproof",
8
+ "keywords": [
9
+ "openapi",
10
+ "swagger",
11
+ "test-coverage",
12
+ "api-testing",
13
+ "audit"
14
+ ],
15
+ "type": "module",
16
+ "scripts": {
17
+ "build": "bun run generate:proof && bunx next build",
18
+ "dev": "bun run generate:proof && bunx next dev --port 3001",
19
+ "generate:proof": "bun scripts/generate-proof.ts",
20
+ "lint": "bunx eslint . ; bunx tsc --noEmit",
21
+ "lint:es": "bunx eslint .",
22
+ "lint:ts": "bunx tsc --noEmit",
23
+ "start": "bunx next start --port 3001",
24
+ "test:unit": "vitest run",
25
+ "test:unit:watch": "vitest"
26
+ },
27
+ "packageManager": "bun@1.1.38",
28
+ "dependencies": {
29
+ "@radix-ui/react-accordion": "^1.2.16",
30
+ "@radix-ui/react-dialog": "^1.1.15",
31
+ "class-variance-authority": "^0.7.1",
32
+ "clsx": "^2.1.1",
33
+ "lucide-react": "^0.562.0",
34
+ "next": "^16.0.7",
35
+ "react": "19.2.3",
36
+ "react-dom": "19.2.3",
37
+ "tailwind-merge": "^3.4.0",
38
+ "tailwindcss-animate": "^1.0.7"
39
+ },
40
+ "devDependencies": {
41
+ "@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
+ "eslint": "^9.39.1",
47
+ "eslint-plugin-react": "^7.37.5",
48
+ "eslint-plugin-react-hooks": "^7.0.1",
49
+ "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
+ "typescript-eslint": "^8.48.1",
55
+ "vitest": "^4.1.8"
56
+ }
57
+ }
@@ -0,0 +1,7 @@
1
+ export default {
2
+ plugins: {
3
+ 'postcss-import': {},
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
@@ -0,0 +1,49 @@
1
+ // Generates the coverage proof rendered by the SpecProof app from the
2
+ // audited target repo's OpenAPI spec + test suites (the current directory by
3
+ // default, override with SPECPROOF_REPO; spec auto-discovered, override with
4
+ // SPECPROOF_SPEC).
5
+ //
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.
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ import { buildCoverageReport, resolveSpecPath, TARGET_REPO_ROOT } from '../lib/api-test-coverage';
17
+
18
+ const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ export const GENERATED_PROOF_PATH = path.join(
21
+ appRoot,
22
+ 'app/proof.generated.json'
23
+ );
24
+
25
+ export function buildProof(): Record<string, unknown> {
26
+ return buildCoverageReport() as unknown as Record<string, unknown>;
27
+ }
28
+
29
+ const isMain =
30
+ process.argv[1] &&
31
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
32
+
33
+ if (isMain) {
34
+ 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);
40
+ }
41
+ const proof = buildProof();
42
+ const operationCount = (proof.operationCount as number) ?? 0;
43
+ if (operationCount === 0) {
44
+ console.error('generate-proof: no operations found in the OpenAPI spec — refusing to write an empty proof');
45
+ process.exit(1);
46
+ }
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)`);
49
+ }
@@ -0,0 +1,61 @@
1
+ import type { Config } from "tailwindcss";
2
+ import tailwindcssAnimate from "tailwindcss-animate";
3
+
4
+ const config: Config = {
5
+ darkMode: ["class"],
6
+ content: [
7
+ "./app/**/*.{ts,tsx}",
8
+ "./components/**/*.{ts,tsx}",
9
+ ],
10
+ theme: {
11
+ extend: {
12
+ borderRadius: {
13
+ lg: 'var(--radius)',
14
+ md: 'calc(var(--radius) - 2px)',
15
+ sm: 'calc(var(--radius) - 4px)'
16
+ },
17
+ colors: {
18
+ background: 'hsl(var(--background))',
19
+ foreground: 'hsl(var(--foreground))',
20
+ primary: {
21
+ DEFAULT: 'hsl(var(--primary))',
22
+ foreground: 'hsl(var(--primary-foreground))'
23
+ },
24
+ secondary: {
25
+ DEFAULT: 'hsl(var(--secondary))',
26
+ foreground: 'hsl(var(--secondary-foreground))'
27
+ },
28
+ muted: {
29
+ DEFAULT: 'hsl(var(--muted))',
30
+ foreground: 'hsl(var(--muted-foreground))'
31
+ },
32
+ accent: {
33
+ DEFAULT: 'hsl(var(--accent))',
34
+ foreground: 'hsl(var(--accent-foreground))'
35
+ },
36
+ border: 'hsl(var(--border))',
37
+ input: 'hsl(var(--input))',
38
+ ring: 'hsl(var(--ring))'
39
+ },
40
+ keyframes: {
41
+ 'accordion-down': {
42
+ from: { height: '0' },
43
+ to: { height: 'var(--radix-accordion-content-height)' }
44
+ },
45
+ 'accordion-up': {
46
+ from: { height: 'var(--radix-accordion-content-height)' },
47
+ to: { height: '0' }
48
+ }
49
+ },
50
+ animation: {
51
+ 'accordion-down': 'accordion-down 0.2s ease-out',
52
+ 'accordion-up': 'accordion-up 0.2s ease-out'
53
+ }
54
+ }
55
+ },
56
+ plugins: [
57
+ tailwindcssAnimate,
58
+ ],
59
+ };
60
+
61
+ export default config;
package/tsconfig.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "es2020"
8
+ ],
9
+ "allowJs": false,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noEmit": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "react-jsx",
19
+ "esModuleInterop": true,
20
+ "incremental": true,
21
+ "baseUrl": ".",
22
+ "paths": {
23
+ "@/*": [
24
+ "./*"
25
+ ]
26
+ },
27
+ "plugins": [
28
+ {
29
+ "name": "next"
30
+ }
31
+ ]
32
+ },
33
+ "include": [
34
+ "next-env.d.ts",
35
+ "**/*.ts",
36
+ "**/*.tsx",
37
+ ".next/types/**/*.ts",
38
+ ".next/dev/types/**/*.ts"
39
+ ],
40
+ "exclude": [
41
+ "node_modules"
42
+ ]
43
+ }
@@ -0,0 +1,18 @@
1
+ import { resolve } from "node:path";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ // Contract-test config: the proof drift guards are pure node tests that read
5
+ // the checked-in artifact and the apps/web sources it derives from.
6
+ export default defineConfig({
7
+ resolve: {
8
+ alias: {
9
+ // Mirror the tsconfig "@/*" -> "./*" path mapping (specproof root).
10
+ "@": resolve(__dirname, "."),
11
+ },
12
+ },
13
+ test: {
14
+ environment: "node",
15
+ include: ["**/*.test.ts"],
16
+ exclude: ["node_modules/**", ".next/**"],
17
+ },
18
+ });