diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 0000000..456a17d --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,334 @@ +#!/usr/bin/env node +/** + * OpenAPI 3.0 generator / CI-checker / annotation-scaffolder for Dockhand. + * + * Runs under plain Node via tsx (no Bun-specific APIs) — see the + * "generate:openapi"/"generate:openapi:check" scripts in package.json. + * + * Three modes, one engine (scripts/openapi/{lib,build-spec}.ts): + * + * npx tsx scripts/generate-openapi.ts -> writes static/openapi.json + * npx tsx scripts/generate-openapi.ts --check -> CI gate, exits non-zero on drift + * npx tsx scripts/generate-openapi.ts --check --strict-coverage + * -> also fails on <100% @openapi coverage + * npx tsx scripts/generate-openapi.ts --scaffold -> prints a code-grounded JSDoc draft + * for every un-annotated handler in + * + * Path/method/tag/auth are derived automatically from the SvelteKit route + * tree (src/routes/**\/+server.ts, ANY depth — not just src/routes/api, + * this also picks up src/routes/metrics and src/routes/audit* for free) and + * from hooks.server.ts PUBLIC_PATHS. Adding a new endpoint costs ZERO manual + * spec edits for those fields. Request/response schemas + examples come from + * an optional, additive `@openapi` JSDoc block — see scripts/openapi/lib.ts + * for the full grammar. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + type HandlerAnnotation, + type HttpMethod, + analyzeHandlerBody, + countRawMarkers, + discoverRoutes, + extractPublicPaths, + isPublic, + parseAnnotations, + splitMethodBodies +} from './openapi/lib'; +import { buildSpec } from './openapi/build-spec'; + +// `import.meta.dir` is Bun-only; this script runs under plain Node via tsx, +// so derive the script directory the portable way. +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = join(SCRIPT_DIR, '..'); +const ROUTES_ROOT = join(ROOT_DIR, 'src', 'routes'); +const HOOKS_FILE = join(ROOT_DIR, 'src', 'hooks.server.ts'); +const PACKAGE_JSON = join(ROOT_DIR, 'package.json'); +// Bundled as an importable module (src/lib) rather than read from disk at +// runtime — see src/routes/api/docs/+server.ts. The production Docker image +// only copies build/, not static/, so a static/-only spec would 404 in the +// container even though it exists in the repo/build client assets. +const LIB_OUT_FILE = join(ROOT_DIR, 'src', 'lib', 'openapi.generated.json'); +// Also written to static/ so it's copied into build/client/ verbatim by the +// SvelteKit build (useful for direct static-asset access / debugging), even +// though /api/docs no longer reads from this path. +const STATIC_OUT_FILE = join(ROOT_DIR, 'static', 'openapi.json'); + +const args = process.argv.slice(2); +const mode = args.includes('--check') ? 'check' : args.includes('--scaffold') ? 'scaffold' : 'generate'; +const strictCoverage = args.includes('--strict-coverage'); + +function loadEverything() { + const { routes, skipped, fileContents } = discoverRoutes([ROUTES_ROOT], ROUTES_ROOT); + const publicPaths = extractPublicPaths(HOOKS_FILE); + const isPublicFn = (p: string) => isPublic(p, publicPaths); + + const annotationsByPath: Record>> = {}; + const orphanMarkers: { filePath: string; rawCount: number; matchedCount: number }[] = []; + + for (const route of routes) { + const content = fileContents.get(route.filePath) ?? readFileSync(route.filePath, 'utf-8'); + const annotations = parseAnnotations(content); + if (Object.keys(annotations).length > 0) annotationsByPath[route.openapiPath] = annotations; + + const rawCount = countRawMarkers(content); + const matchedCount = Object.keys(annotations).length; + if (rawCount !== matchedCount) orphanMarkers.push({ filePath: route.filePath, rawCount, matchedCount }); + } + + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8')); + + return { routes, skipped, fileContents, publicPaths, isPublicFn, annotationsByPath, orphanMarkers, version: pkg.version ?? '0.0.0' }; +} + +// --------------------------------------------------------------------------- +// generate +// --------------------------------------------------------------------------- + +// Self-hosted Scalar API Reference — the interactive docs viewer at +// GET /api/docs/ui, with built-in multi-language request code samples. +// Vendor the standalone browser bundle so it never depends on a CDN +// (secret-safe, offline-friendly, works behind an airgapped/internal +// Dockhand deployment too). +const SCALAR_ASSETS = ['standalone.js']; + +function copyScalarAssets() { + const srcDir = join(ROOT_DIR, 'node_modules', '@scalar', 'api-reference', 'dist', 'browser'); + const destDir = join(ROOT_DIR, 'static', 'scalar'); + if (!existsSync(srcDir)) { + console.warn('@scalar/api-reference not found in node_modules — skipping asset copy (run `npm install` first)'); + return false; + } + mkdirSync(destDir, { recursive: true }); + for (const asset of SCALAR_ASSETS) { + copyFileSync(join(srcDir, asset), join(destDir, asset)); + } + return true; +} + +// Writes the spec to both the importable src/lib/ location (what +// /api/docs actually serves — bundled into the built server, works +// regardless of whether static/ ships in the runtime image) and to +// static/ (copied verbatim into build/client/ by the SvelteKit build; +// kept for direct static-asset access / debugging parity with before). +function writeSpecOutputs(spec: unknown) { + const json = JSON.stringify(spec, null, 2); + mkdirSync(dirname(LIB_OUT_FILE), { recursive: true }); + writeFileSync(LIB_OUT_FILE, json); + mkdirSync(dirname(STATIC_OUT_FILE), { recursive: true }); + writeFileSync(STATIC_OUT_FILE, json); +} + +function runGenerate() { + const { routes, skipped, fileContents, publicPaths, isPublicFn, annotationsByPath, version } = loadEverything(); + const { spec, stats } = buildSpec({ routes, fileContents, annotationsByPath, publicPaths, isPublicFn, version }); + + writeSpecOutputs(spec); + const scalarAssetsCopied = copyScalarAssets(); + + const totalOperations = Object.values(spec.paths).reduce((sum, item: any) => sum + Object.keys(item).length, 0); + const annotatedHandlerCount = Object.values(annotationsByPath).reduce((sum, m) => sum + Object.keys(m).length, 0); + + console.log(`\n=== OpenAPI Generator ===`); + console.log(`Route files walked: ${routes.length + skipped.length}`); + console.log(`Routes with methods found: ${routes.length}`); + console.log(`Unique OpenAPI paths: ${Object.keys(spec.paths).length}`); + console.log(`Total operations: ${totalOperations}`); + console.log(`Tags discovered: ${spec.tags.length}`); + console.log(`Public (no-auth) paths: ${routes.filter((r) => isPublicFn(r.openapiPath)).length}`); + console.log(`Annotated handlers: ${annotatedHandlerCount} of ${totalOperations} (${((annotatedHandlerCount / totalOperations) * 100).toFixed(1)}%)`); + console.log(`Operations with parameters (auto+annotated): ${stats.autoParamsCount} of ${totalOperations}`); + console.log(`Operations with >1 response (auto+annotated): ${stats.autoMultiResponseCount} of ${totalOperations}`); + console.log(`Operations with a requestBody (auto+annotated): ${stats.autoBodyCount} of ${totalOperations}`); + console.log(`Skipped files (no method): ${skipped.length}`); + for (const s of skipped) console.log(` - ${relative(ROOT_DIR, s.filePath)}: ${s.reason}`); + console.log(`Scalar assets copied: ${scalarAssetsCopied ? 'yes (static/scalar/)' : 'SKIPPED (see warning above)'}`); + console.log(`\nOutput written to: ${relative(ROOT_DIR, LIB_OUT_FILE)} (served by /api/docs) and ${relative(ROOT_DIR, STATIC_OUT_FILE)} (static asset copy)`); +} + +// --------------------------------------------------------------------------- +// --check (6 gates) +// --------------------------------------------------------------------------- + +function runCheck(): number { + const { routes, publicPaths, isPublicFn, annotationsByPath, orphanMarkers, fileContents, version } = loadEverything(); + const { spec } = buildSpec({ routes, fileContents, annotationsByPath, publicPaths, isPublicFn, version }); + const totalOperations = Object.values(spec.paths).reduce((sum, item: any) => sum + Object.keys(item).length, 0); + const annotatedHandlerCount = Object.values(annotationsByPath).reduce((sum, m) => sum + Object.keys(m).length, 0); + + let hardFailures = 0; + const report: string[] = []; + + // Gate 1: coverage (soft by default) + const coveragePct = (annotatedHandlerCount / totalOperations) * 100; + report.push(`[Gate 1] Coverage: ${annotatedHandlerCount}/${totalOperations} handlers annotated (${coveragePct.toFixed(1)}%)`); + if (strictCoverage && coveragePct < 100) { + report.push(` HARD FAIL (--strict-coverage): coverage below 100%`); + hardFailures++; + } else if (coveragePct < 100) { + report.push(` (warning only — pass --strict-coverage to make this a hard gate)`); + } + + // Gate 2: path-param consistency (annotation path: name must exist in route.pathParams) + let pathParamIssues = 0; + for (const route of routes) { + const perMethod = annotationsByPath[route.openapiPath]; + if (!perMethod) continue; + for (const [method, ann] of Object.entries(perMethod)) { + for (const name of Object.keys(ann.path)) { + if (!route.pathParams.includes(name)) { + report.push(` [Gate 2] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: annotation "path: ${name}" does not match any {${name}} in the route path`); + pathParamIssues++; + } + } + } + } + report.splice(report.length - pathParamIssues, 0, `[Gate 2] Path-param consistency: ${pathParamIssues} issue(s)`); + if (pathParamIssues > 0) hardFailures++; + + // Gates 3+4: query-param drift, status-code drift (only for annotated handlers) + let queryDrift = 0; + let statusDrift = 0; + const driftLines: string[] = []; + for (const route of routes) { + const perMethod = annotationsByPath[route.openapiPath]; + if (!perMethod) continue; + const content = fileContents.get(route.filePath) ?? readFileSync(route.filePath, 'utf-8'); + const bodies = splitMethodBodies(content); + for (const [method, ann] of Object.entries(perMethod) as [HttpMethod, HandlerAnnotation][]) { + const body = bodies[method]; + if (!body) continue; + const analysis = analyzeHandlerBody(body, route.pathParams); + + const docQuery = new Set(Object.keys(ann.query)); + const codeQuery = new Set(analysis.queryParams.map((q) => q.name)); + for (const q of codeQuery) { + if (!docQuery.has(q)) { + driftLines.push(` [Gate 3] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: query param "${q}" used in code but not documented`); + queryDrift++; + } + } + for (const q of docQuery) { + if (!codeQuery.has(q)) { + driftLines.push(` [Gate 3] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: query param "${q}" documented but not found in code (stale?)`); + queryDrift++; + } + } + + const docCodes = new Set(Object.keys(ann.responses)); + const codeCodes = new Set(analysis.statusCodes.filter((c) => c !== '200')); + for (const c of codeCodes) { + if (!docCodes.has(c)) { + driftLines.push(` [Gate 4] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: status ${c} used in code but not documented`); + statusDrift++; + } + } + for (const c of docCodes) { + if (c !== '200' && !codeCodes.has(c)) { + driftLines.push(` [Gate 4] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: response ${c} documented but no matching status(${c})/error(${c}) found in code (stale?)`); + statusDrift++; + } + } + } + } + report.push(`[Gate 3] Query-param drift: ${queryDrift} issue(s)`); + report.push(`[Gate 4] Status-code drift: ${statusDrift} issue(s)`); + report.push(...driftLines); + if (queryDrift > 0) hardFailures++; + if (statusDrift > 0) hardFailures++; + + // Gate 5: orphan JSDoc (a @openapi marker that didn't attach to an export) + report.push(`[Gate 5] Orphan @openapi blocks: ${orphanMarkers.length} file(s)`); + for (const o of orphanMarkers) { + report.push(` ${relative(ROOT_DIR, o.filePath)}: ${o.rawCount} @openapi marker(s) found, only ${o.matchedCount} attached to an export`); + } + if (orphanMarkers.length > 0) hardFailures++; + + // Gate 6: spec validity (write both outputs, lint the static copy) + writeSpecOutputs(spec); + let validatorsOk = true; + try { + execFileSync('npx', ['--yes', '@redocly/cli@2.43.1', 'lint', STATIC_OUT_FILE, '--format=summary'], { + cwd: ROOT_DIR, + stdio: 'pipe' + }); + } catch (err: any) { + validatorsOk = false; + report.push(`[Gate 6] @redocly/cli lint FAILED:\n${err.stdout?.toString().slice(-1000) ?? err.message}`); + } + report.push(`[Gate 6] Spec validity (@redocly/cli lint): ${validatorsOk ? 'OK' : 'FAILED'}`); + if (!validatorsOk) hardFailures++; + + console.log(`\n=== OpenAPI --check Report ===`); + for (const line of report) console.log(line); + console.log(`\nHard failures: ${hardFailures}`); + return hardFailures > 0 ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// --scaffold +// --------------------------------------------------------------------------- + +function runScaffold() { + const idx = args.indexOf('--scaffold'); + const target = args[idx + 1]; + if (!target) { + console.error('Usage: generate-openapi.ts --scaffold '); + process.exitCode = 1; + return; + } + const filePath = target.startsWith('/') ? target : join(ROOT_DIR, target); + if (!existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exitCode = 1; + return; + } + const content = readFileSync(filePath, 'utf-8'); + const existing = parseAnnotations(content); + const bodies = splitMethodBodies(content); + + const { routes } = discoverRoutes([ROUTES_ROOT], ROUTES_ROOT); + const route = routes.find((r) => r.filePath === filePath); + + console.log(`# Scaffold draft for ${relative(ROOT_DIR, filePath)}`); + console.log(`# (code-grounded from static analysis — review and refine before committing)\n`); + + for (const [method, body] of Object.entries(bodies)) { + if (existing[method as HttpMethod]) { + console.log(`## ${method}: already has an @openapi annotation, skipped\n`); + continue; + } + const analysis = analyzeHandlerBody(body, route?.pathParams ?? []); + console.log(`## ${method} ${route?.openapiPath ?? '(path unknown)'}\n`); + console.log('/**'); + console.log(' * @openapi'); + console.log(' * summary: TODO — one-line description'); + for (const q of analysis.queryParams) { + console.log(` * query: ${q.name}:${q.type} TODO — description (verify required/type)`); + } + if (analysis.bodyFields.length > 0) { + console.log(` * body: {${analysis.bodyFields.map((f) => `${f}:string`).join(', ')}} # TODO: verify types + required (!) markers`); + } + const codes = analysis.statusCodes.length > 0 ? analysis.statusCodes : ['200']; + for (const c of codes) { + console.log(` * resp-${c}: TODO — description${c === '200' ? ' (or a {field:type} schema)' : ''}`); + } + console.log(' */'); + console.log(`export const ${method}: RequestHandler = ...\n`); + } +} + +// --------------------------------------------------------------------------- +// entrypoint +// --------------------------------------------------------------------------- + +if (mode === 'check') { + process.exitCode = runCheck(); +} else if (mode === 'scaffold') { + runScaffold(); +} else { + runGenerate(); +} diff --git a/scripts/openapi/build-spec.ts b/scripts/openapi/build-spec.ts new file mode 100644 index 0000000..a288fd4 --- /dev/null +++ b/scripts/openapi/build-spec.ts @@ -0,0 +1,220 @@ +/** + * Assembles the final OpenAPI 3.0.3 document from discovered routes + + * static analysis of every handler + (optional, additive) annotations. + * Shared by `generate` and `--check` (check needs the assembled spec to + * hand to the validators). + * + * Layering, cheapest to most precise: + * 1. Auto-base — path/method/tag/auth (always, from the route tree + + * hooks.server.ts) PLUS parameters/responses/requestBody + * derived from static analysis of the handler's own code + * (query params via url.searchParams, status codes via + * status:/error(), body fields via destructuring). + * 2. JSDoc override — an `@openapi` block on the handler replaces/extends + * the auto values with hand-authored summaries, exact + * types, examples, and precise descriptions. + * An un-annotated handler is therefore NOT a bare stub anymore — it shows + * every parameter, status code, and body field the static analysis could + * find in its own source, just with generic descriptions instead of + * hand-written ones. + */ + +import { + type DiscoveredRoute, + type HandlerAnnotation, + type HttpMethod, + type StaticAnalysis, + analyzeHandlerBody, + miniSchemaToOpenApi, + splitMethodBodies, + synthesizeExample +} from './lib'; + +export interface BuildSpecInput { + routes: DiscoveredRoute[]; + fileContents: Map; + annotationsByPath: Record>>; + publicPaths: string[]; + isPublicFn: (path: string) => boolean; + version: string; +} + +function genericParamDescription(kind: 'path' | 'query', name: string): string { + return `${kind === 'path' ? 'Path' : 'Query'} parameter "${name}" (auto-detected from the handler's source — no @openapi annotation for this parameter yet)`; +} + +export function buildSpec({ routes, fileContents, annotationsByPath, isPublicFn, version }: BuildSpecInput) { + const paths: Record> = {}; + + // Track auto-enrichment stats for the generator's own report (parameters + // beyond path, responses beyond a single 200, or a requestBody — any of + // which the static analysis contributed without a human writing a line). + let autoParamsCount = 0; + let autoMultiResponseCount = 0; + let autoBodyCount = 0; + + for (const route of routes) { + const pathItem = (paths[route.openapiPath] ??= {}); + const security = isPublicFn(route.openapiPath) ? [] : [{ cookieAuth: [] }, { bearerAuth: [] }]; + const content = fileContents.get(route.filePath); + const bodies = content ? splitMethodBodies(content) : {}; + + for (const method of route.methods) { + const operationId = `${method.toLowerCase()}_${route.openapiPath.replace(/^\//, '').replace(/[{}]/g, '').replace(/\//g, '_')}`; + const annotation = annotationsByPath[route.openapiPath]?.[method]; + const methodBody = bodies[method]; + const analysis: StaticAnalysis = methodBody + ? analyzeHandlerBody(methodBody, route.pathParams) + : { queryParams: [], pathParamTypes: {}, statusCodes: [], bodyFields: [] }; + + // --- parameters: auto path + auto query, JSDoc enriches/adds ------- + const pathParamAnnotations = annotation?.path ?? {}; + const parameters: Record[] = route.pathParams.map((p) => { + const enrich = pathParamAnnotations[p]; + const autoType = analysis.pathParamTypes[p]; // only set when inferred as non-string + return { + name: p, + in: 'path', + required: true, + schema: { type: enrich?.type ?? autoType ?? 'string' }, + description: enrich?.description || genericParamDescription('path', p) + }; + }); + + // Query params: union of auto-detected (from code) and annotated + // (JSDoc can also document a query param the static analysis missed, + // e.g. a dynamically-built key). Annotation wins when both exist. + const annotatedQueryNames = new Set(Object.keys(annotation?.query ?? {})); + for (const q of analysis.queryParams) { + if (annotatedQueryNames.has(q.name)) continue; // annotation takes precedence, added below + parameters.push({ + name: q.name, + in: 'query', + required: false, // static analysis can't safely tell required vs optional for query params + schema: { type: q.type }, + description: genericParamDescription('query', q.name) + }); + } + if (annotation) { + for (const [name, q] of Object.entries(annotation.query)) { + parameters.push({ name, in: 'query', required: q.required, schema: { type: q.type }, description: q.description }); + } + } + if (parameters.length > route.pathParams.length) autoParamsCount++; + + // --- responses: auto status codes, JSDoc enriches/adds ------------ + const responses: Record> = {}; + const annotatedCodes = new Set(Object.keys(annotation?.responses ?? {})); + for (const code of analysis.statusCodes) { + if (annotatedCodes.has(code)) continue; // annotation takes precedence, added below + responses[code] = { + description: code.startsWith('2') + ? 'Successful response (auto-detected status code — no @openapi annotation for this response yet)' + : 'Error response (auto-detected status code — no @openapi annotation for this response yet)' + }; + } + if (annotation) { + for (const [code, resp] of Object.entries(annotation.responses)) { + const entry: Record = { description: resp.description }; + if (resp.schema) { + const example = resp.example ?? synthesizeExample(resp.schema); + entry.content = { 'application/json': { schema: miniSchemaToOpenApi(resp.schema), example } }; + } + responses[code] = entry; + } + } + const hasSuccessResponse = Object.keys(responses).some((code) => code.startsWith('2')); + if (!hasSuccessResponse) { + // Neither static analysis nor annotation found an explicit 2xx — + // every SvelteKit handler that doesn't throw returns *some* + // success response, so default to 200 rather than omit it. + responses['200'] = { description: 'Successful response' }; + } + if (Object.keys(responses).length > 1) autoMultiResponseCount++; + + // --- requestBody: auto body fields (POST/PUT/PATCH only), JSDoc enriches/replaces --- + let requestBody: Record | undefined; + if (annotation?.body) { + const example = annotation.bodyExample ?? synthesizeExample(annotation.body); + requestBody = { + required: true, + content: { 'application/json': { schema: miniSchemaToOpenApi(annotation.body), example } } + }; + } else if (['POST', 'PUT', 'PATCH'].includes(method) && analysis.bodyFields.length > 0) { + const properties = Object.fromEntries(analysis.bodyFields.map((f) => [f, { type: 'string' }])); + requestBody = { + required: false, // static analysis can't tell which destructured fields are actually required + content: { + 'application/json': { + schema: { + type: 'object', + properties, + description: + 'Fields auto-detected from the handler’s destructuring of the request body — generic string type, no @openapi annotation for this body yet.' + } + } + } + }; + } + if (requestBody) autoBodyCount++; + + const operation: Record = { + operationId, + tags: [route.tag], + summary: annotation?.summary ?? `${method} ${route.openapiPath} (auto-generated — no @openapi annotation yet)`, + parameters, + responses, + security + }; + if (annotation?.description) operation.description = annotation.description; + if (requestBody) operation.requestBody = requestBody; + + pathItem[method.toLowerCase()] = operation; + } + } + + const spec = { + openapi: '3.0.3', + info: { + title: 'Dockhand API', + version, + description: + 'Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. ' + + 'Path, HTTP method, tag, and auth requirement are derived automatically from the ' + + 'route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and ' + + 'request body fields are additionally auto-detected from each handler’s own source ' + + '(query params via url.searchParams, status codes via status:/error(), body fields via ' + + 'destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show ' + + 'up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a ' + + 'handler replaces the generic auto-descriptions with hand-written summaries, exact types, ' + + 'and examples.' + }, + servers: [{ url: '/' }], + tags: Array.from(new Set(routes.map((r) => r.tag))) + .sort() + .map((t) => ({ name: t })), + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'dockhand_session', + description: 'Session cookie set on login (src/lib/server/auth.ts validateSession).' + }, + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'dh_<43-char base64url>', + description: + 'User-scoped API token (src/lib/server/api-tokens.ts). Only evaluated on /api/* and ' + + '/metrics when no session cookie is present (src/hooks.server.ts). Rate-limited: ' + + '10 failures/IP -> 429 for 5 minutes.' + } + } + }, + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + paths + }; + + return { spec, stats: { autoParamsCount, autoMultiResponseCount, autoBodyCount } }; +} diff --git a/scripts/openapi/lib.ts b/scripts/openapi/lib.ts new file mode 100644 index 0000000..9247eed --- /dev/null +++ b/scripts/openapi/lib.ts @@ -0,0 +1,545 @@ +/** + * OpenAPI generation — shared engine. + * + * Used by `scripts/generate-openapi.ts` in three modes (generate / --check / + * --scaffold). Everything here is pure, dependency-free (plain node:fs + + * node:path) — no swagger-jsdoc (CJS-only, the thing PR #816 avoided), no + * AST framework. A regex/string-scan approach is sufficient because this + * codebase is 100% consistent in its `export const METHOD:` convention + * (verified during Phase A: 0 exceptions across 232 route files). + * + * Three responsibilities, kept separate so --check and --scaffold can reuse + * exactly what `generate` uses (no drift between "what the spec says" and + * "what the checker validates against"): + * + * 1. Route discovery — filesystem walk -> path/method/tag (ZERO manual steps) + * 2. Annotation parsing — optional @openapi JSDoc blocks -> rich operations + * 3. Static analysis — best-effort extraction of query params / status + * codes / body fields ACTUALLY used in a handler's + * source, so --check can flag drift between code + * and annotation, and --scaffold can propose a + * code-grounded starting annotation. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'] as const; +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +export interface DiscoveredRoute { + filePath: string; // ABSOLUTE path — callers relativize for display as needed + openapiPath: string; // e.g. /api/stacks/{name}/env + pathParams: string[]; // e.g. ['name'] + methods: HttpMethod[]; + tag: string; +} + +export type MiniSchema = + | { kind: 'string' | 'integer' | 'number' | 'boolean' } + | { kind: 'object'; properties: Record; required: string[] } + | { kind: 'array'; items: MiniSchema }; + +export interface ParamAnnotation { + type: string; + required: boolean; + description: string; +} + +export interface ResponseAnnotation { + schema?: MiniSchema; + description: string; + example?: unknown; +} + +export interface HandlerAnnotation { + summary?: string; + /** Optional long-form explanation (OpenAPI operation.description) — use for + * context the one-line summary can't carry: purpose, read-only/write + * semantics, or where a related write operation lives. Markdown-safe. */ + description?: string; + query: Record; + path: Record; + body?: MiniSchema; + bodyExample?: unknown; + responses: Record; + /** Raw block text, kept only for --check's orphan-block diagnostics. */ + raw: string; +} + +export type InferredScalarType = 'string' | 'integer' | 'boolean'; + +export interface QueryParamAnalysis { + name: string; + type: InferredScalarType; +} + +export interface StaticAnalysis { + queryParams: QueryParamAnalysis[]; // deduped, sorted by name, with inferred scalar type + pathParamTypes: Record; // inferred from parseInt(params.X)/Number(params.X) — string params not in here default to 'string' + statusCodes: string[]; // deduped, sorted, as strings ("200", "403", ...) + bodyFields: string[]; // deduped, sorted, top-level destructured field names +} + +// --------------------------------------------------------------------------- +// 1. Route discovery +// --------------------------------------------------------------------------- + +export function walkServerFiles(rootDirs: string[]): string[] { + const acc: string[] = []; + function walk(dir: string) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; // root dir may not exist (e.g. optional feature dirs) — skip silently + } + for (const entry of entries) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full); + else if (entry === '+server.ts') acc.push(full); + } + } + for (const root of rootDirs) walk(root); + return acc; +} + +export function toOpenApiPath(serverFile: string, routesRoot: string): { path: string; params: string[] } { + const rel = relative(routesRoot, dirname(serverFile)); + const segments = rel.split('/').filter(Boolean); + const params: string[] = []; + const converted = segments.map((seg) => { + const m = seg.match(/^\[(\.\.\.)?([^\]=]+?)(=.*)?\]$/); + if (m) { + const isRest = !!m[1]; + const name = m[2]; + params.push(name); + return isRest ? `{${name}*}` : `{${name}}`; + } + return seg; + }); + return { path: '/' + converted.join('/'), params }; +} + +export function deriveTag(openapiPath: string): string { + const segments = openapiPath.split('/').filter(Boolean); + if (segments[0] === 'api') return segments[1] ?? 'root'; + return segments[0] ?? 'root'; +} + +export function extractMethods(content: string): HttpMethod[] { + const found: HttpMethod[] = []; + for (const m of HTTP_METHODS) { + if (new RegExp(`^export const ${m}\\s*:`, 'm').test(content)) found.push(m); + } + return found; +} + +/** + * Rough per-method body isolation: from `export const METHOD:` to the next + * `export const :` (or EOF). Not a real parser — good enough + * for the static-analysis heuristics below, which only need "roughly the + * right chunk of source", not a compiled AST. + */ +export function splitMethodBodies(content: string): Partial> { + const positions: { method: HttpMethod; start: number }[] = []; + for (const m of HTTP_METHODS) { + const re = new RegExp(`^export const ${m}\\s*:`, 'm'); + const match = re.exec(content); + if (match) positions.push({ method: m, start: match.index! }); + } + positions.sort((a, b) => a.start - b.start); + const result: Partial> = {}; + for (let idx = 0; idx < positions.length; idx++) { + const start = positions[idx].start; + const end = idx + 1 < positions.length ? positions[idx + 1].start : content.length; + result[positions[idx].method] = content.slice(start, end); + } + return result; +} + +export function discoverRoutes(rootDirs: string[], routesRoot: string): { routes: DiscoveredRoute[]; skipped: { filePath: string; reason: string }[]; fileContents: Map } { + const routes: DiscoveredRoute[] = []; + const skipped: { filePath: string; reason: string }[] = []; + const fileContents = new Map(); + const files = walkServerFiles(rootDirs); + + for (const file of files) { + const content = readFileSync(file, 'utf-8'); + fileContents.set(file, content); + const methods = extractMethods(content); + if (methods.length === 0) { + skipped.push({ filePath: file, reason: 'no export const : match found' }); + continue; + } + const { path, params } = toOpenApiPath(file, routesRoot); + routes.push({ filePath: file, openapiPath: path, pathParams: params, methods, tag: deriveTag(path) }); + } + return { routes, skipped, fileContents }; +} + +// --------------------------------------------------------------------------- +// 2. Annotation parsing — @openapi JSDoc blocks with a compact typed mini-DSL +// --------------------------------------------------------------------------- +// +// /** +// * @openapi +// * summary: +// * description: (optional; longer explanation +// * than summary — purpose, +// * read-only/write semantics, +// * pointer to a related endpoint) +// * query: :[!] (repeatable) +// * path: :[!] (repeatable; enriches an +// * auto-detected {name} segment) +// * body: +// * body-example: +// * resp-: OR resp-: +// * resp--desc: +// * resp--example: +// */ +// +// mini-schema := 'string'|'integer'|'number'|'boolean' | '{' (name':'type'!'?','?)* '}' | 'array<' type '>' + +export function parseMiniSchema(str: string): MiniSchema { + let i = 0; + const s = str; + + function skipWs() { + while (i < s.length && /\s/.test(s[i])) i++; + } + + function parseType(): MiniSchema { + skipWs(); + if (s[i] === '{') return parseObject(); + if (s.slice(i, i + 6) === 'array<') { + i += 6; + const items = parseType(); + skipWs(); + if (s[i] === '>') i++; + return { kind: 'array', items }; + } + const start = i; + while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++; + const word = s.slice(start, i) || 'string'; + if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word }; + return { kind: 'string' }; + } + + function parseObject(): MiniSchema { + i++; // '{' + const properties: Record = {}; + const required: string[] = []; + skipWs(); + while (i < s.length && s[i] !== '}') { + skipWs(); + const nameStart = i; + while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++; + const name = s.slice(nameStart, i); + skipWs(); + if (s[i] === ':') i++; + const type = parseType(); + skipWs(); + let isRequired = false; + if (s[i] === '!') { + isRequired = true; + i++; + } + if (name) { + properties[name] = type; + if (isRequired) required.push(name); + } + skipWs(); + if (s[i] === ',') { + i++; + skipWs(); + } + } + if (s[i] === '}') i++; + return { kind: 'object', properties, required }; + } + + return parseType(); +} + +export function miniSchemaToOpenApi(schema: MiniSchema): Record { + switch (schema.kind) { + case 'object': { + const out: Record = { + type: 'object', + properties: Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, miniSchemaToOpenApi(v)])) + }; + if (schema.required.length > 0) out.required = schema.required; + return out; + } + case 'array': + return { type: 'array', items: miniSchemaToOpenApi(schema.items) }; + default: + return { type: schema.kind }; + } +} + +export function synthesizeExample(schema: MiniSchema): unknown { + switch (schema.kind) { + case 'object': + return Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, synthesizeExample(v)])); + case 'array': + return [synthesizeExample(schema.items)]; + case 'integer': + case 'number': + return 0; + case 'boolean': + return true; + default: + return 'string'; + } +} + +// Both halves use a negated-lookahead scan `(?:(?!\*\/)[\s\S])*?` instead of +// plain `[\s\S]*?` so the match can never cross a `*/` — i.e. it can only ever +// consume characters that belong to ONE comment block. Without this guard, a +// handler preceded by an earlier, unrelated `/** file-header */` JSDoc comment +// (with no `@openapi` of its own) makes the lazy quantifier skip past that +// comment's own `*/` and swallow every line of CODE in between as if it were +// part of the annotation body — including any `key: value,` object-literal +// line (e.g. `description: sys.description,`) that happens to match a +// recognized annotation key. Real `/** ... */` comments can never contain a +// literal `*/` (that would end the comment in valid JS/TS), so this +// restriction never rejects a legitimate block. +const ANNOTATION_BLOCK_RE = + /\/\*\*((?:(?!\*\/)[\s\S])*?@openapi\b(?:(?!\*\/)[\s\S])*?)\*\/\s*export const (GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s*:/g; + +/** Every `@openapi` marker found in a file, regardless of whether it could be + * matched to a following `export const METHOD:` — used by --check's + * "orphan JSDoc" gate to catch a block that's present but detached (typo'd + * method, comment not directly above the export, etc). */ +export function countRawMarkers(content: string): number { + return (content.match(/@openapi\b/g) ?? []).length; +} + +export function parseAnnotations(content: string): Partial> { + const result: Partial> = {}; + let m: RegExpExecArray | null; + ANNOTATION_BLOCK_RE.lastIndex = 0; + while ((m = ANNOTATION_BLOCK_RE.exec(content)) !== null) { + const [raw, blockBody, method] = m; + const lines = blockBody + .split('\n') + .map((l) => l.replace(/^\s*\*\s?/, '').trim()) + .filter((l) => l && l !== '@openapi'); + + const annotation: HandlerAnnotation = { query: {}, path: {}, responses: {}, raw }; + const respDescOverride: Record = {}; + const respExampleOverride: Record = {}; + + for (const line of lines) { + const kv = line.match(/^([a-z0-9-]+)\s*:\s*(.+)$/i); + if (!kv) continue; + const [, key, rawValue] = kv; + + if (key === 'summary') { + annotation.summary = rawValue; + continue; + } + if (key === 'description') { + annotation.description = rawValue; + continue; + } + if (key === 'query' || key === 'path') { + const pm = rawValue.match(/^([a-zA-Z0-9_]+)\s*:\s*([a-zA-Z0-9_]+)(!)?\s*(.*)$/); + if (pm) { + const [, name, type, required, description] = pm; + (annotation[key] as Record)[name] = { + type, + required: !!required, + description: description.trim() + }; + } + continue; + } + if (key === 'body') { + annotation.body = parseMiniSchema(rawValue); + continue; + } + if (key === 'body-example') { + try { + annotation.bodyExample = JSON.parse(rawValue); + } catch { + /* ignore malformed example */ + } + continue; + } + const respMatch = key.match(/^resp-(\d{3})(-desc|-example)?$/); + if (respMatch) { + const [, code, suffix] = respMatch; + if (suffix === '-desc') respDescOverride[code] = rawValue; + else if (suffix === '-example') { + try { + respExampleOverride[code] = JSON.parse(rawValue); + } catch { + /* ignore malformed example */ + } + } else { + const looksLikeSchema = /^(\{|array<)/.test(rawValue.trim()); + annotation.responses[code] = looksLikeSchema + ? { schema: parseMiniSchema(rawValue), description: code === '200' ? 'Successful response' : 'Response' } + : { description: rawValue }; + } + } + } + + for (const [code, desc] of Object.entries(respDescOverride)) { + annotation.responses[code] = { ...(annotation.responses[code] ?? { description: desc }), description: desc }; + } + for (const [code, ex] of Object.entries(respExampleOverride)) { + annotation.responses[code] = { ...(annotation.responses[code] ?? { description: 'Response' }), example: ex }; + } + + result[method as HttpMethod] = annotation; + } + return result; +} + +// --------------------------------------------------------------------------- +// 3. Static analysis — code-grounded facts used by --check (drift) and +// --scaffold (starting point). Deliberately conservative: false negatives +// (missing a query param) are more likely than false positives, so this +// is a HELPER, not a source of truth — the annotation is still authored +// by a human who reads the handler. +// --------------------------------------------------------------------------- + +function parseDestructuredFields(inner: string): string[] { + // Naive top-level split on commas. Doesn't handle nested destructuring + // (`{ a: { b } }`) correctly, but every body in this codebase destructures + // shallowly (verified via spot-check across the annotated + scaffold sets). + const fields: string[] = []; + let depth = 0; + let current = ''; + for (const ch of inner) { + if (ch === '{' || ch === '[') depth++; + if (ch === '}' || ch === ']') depth--; + if (ch === ',' && depth === 0) { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) fields.push(current); + return fields + .map((f) => f.split(':')[0].split('=')[0].trim()) + .filter((f) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(f)); +} + +/** + * Type inference is deliberately conservative: a query/path param defaults to + * 'string' unless the code contains an UNAMBIGUOUS, textually-local signal + * (parseInt/Number wrapping the same local variable or the same literal + * searchParams key, or a `=== 'true'`/`!== 'false'` boolean comparison). + * When in doubt this returns 'string' — never a guessed 'integer'/'boolean' + * that isn't backed by an actual pattern match in the source. + */ +function inferQueryParamType(body: string, key: string): InferredScalarType { + // Direct wrap: parseInt(url.searchParams.get('key')) / Number(url.searchParams.get('key')) + const directNumRe = new RegExp(`(?:parseInt|Number)\\(\\s*url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)`); + if (directNumRe.test(body)) return 'integer'; + + // Two-step: const NAME = url.searchParams.get('key'); ... parseInt(NAME) / Number(NAME) + const assignRe = new RegExp(`(?:const|let)\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*=\\s*url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)`); + const assignMatch = assignRe.exec(body); + if (assignMatch) { + const varName = assignMatch[1]; + const numRe = new RegExp(`(?:parseInt|Number)\\(\\s*${varName}\\b`); + if (numRe.test(body)) return 'integer'; + const boolRe = new RegExp(`\\b${varName}\\s*(?:===|!==)\\s*['"](?:true|false)['"]`); + if (boolRe.test(body)) return 'boolean'; + } + + // Direct boolean comparison: url.searchParams.get('key') === 'true' / !== 'false' + const directBoolRe = new RegExp(`url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)\\s*(?:===|!==)\\s*['"](?:true|false)['"]`); + if (directBoolRe.test(body)) return 'boolean'; + + return 'string'; +} + +function inferPathParamType(body: string, name: string): InferredScalarType { + const numRe = new RegExp(`(?:parseInt|Number)\\(\\s*params\\.${name}\\b`); + return numRe.test(body) ? 'integer' : 'string'; +} + +export function analyzeHandlerBody(body: string, pathParamNames: string[] = []): StaticAnalysis { + const queryKeys = new Set(); + const qRe = /url\.searchParams\.get(?:All)?\(\s*['"]([a-zA-Z0-9_]+)['"]\s*\)/g; + let qm; + while ((qm = qRe.exec(body)) !== null) queryKeys.add(qm[1]); + const queryParams: QueryParamAnalysis[] = [...queryKeys] + .sort() + .map((name) => ({ name, type: inferQueryParamType(body, name) })); + + const pathParamTypes: Record = {}; + for (const name of pathParamNames) { + const type = inferPathParamType(body, name); + if (type !== 'string') pathParamTypes[name] = type; // only record non-default to keep the map small + } + + const statusCodes = new Set(); + const statusRe = /\bstatus:\s*(\d{3})\b/g; + let sm; + while ((sm = statusRe.exec(body)) !== null) statusCodes.add(sm[1]); + const errorRe = /\berror\(\s*(\d{3})/g; + let em; + while ((em = errorRe.exec(body)) !== null) statusCodes.add(em[1]); + // SvelteKit `redirect(3xx, location)` — e.g. `throw redirect(302, '/login')`. + // Without this, an annotated resp-3xx on a redirect-only handler is flagged + // as stale drift by Gate 4, even though the code genuinely emits it. + const redirectRe = /\bredirect\(\s*(\d{3})/g; + let rm; + while ((rm = redirectRe.exec(body)) !== null) statusCodes.add(rm[1]); + + const bodyFields = new Set(); + // `const { a, b } = await request.json();` and `const { a, b } = body;` + const destructureRe = /const\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:request\.json\(\)|body)\b/g; + let dm; + while ((dm = destructureRe.exec(body)) !== null) { + for (const f of parseDestructuredFields(dm[1])) bodyFields.add(f); + } + + return { + queryParams, + pathParamTypes, + statusCodes: [...statusCodes].sort(), + bodyFields: [...bodyFields].sort() + }; +} + +// --------------------------------------------------------------------------- +// PUBLIC_PATHS extraction (from hooks.server.ts) — auth-exemption discovery +// --------------------------------------------------------------------------- + +export function extractPublicPaths(hooksFile: string): string[] { + const content = readFileSync(hooksFile, 'utf-8'); + const m = content.match(/const PUBLIC_PATHS\s*=\s*\[([\s\S]*?)\];/); + if (!m) return []; + const paths: string[] = []; + const strRe = /'([^']+)'/g; + let sm; + while ((sm = strRe.exec(m[1])) !== null) paths.push(sm[1]); + return paths; +} + +// Two exceptions hardcoded directly in isPublicPath() as regexes (webhook +// signature/secret auth instead of session/token) — not expressible as a +// simple PUBLIC_PATHS prefix string. The one manual special-case this +// generator needs; documented in the research doc's coverage-gap section. +export const PUBLIC_PATH_REGEXES = [/^\/api\/git\/stacks\/\d+\/webhook$/, /^\/api\/git\/webhook\/\d+$/]; + +export function isPublic(openapiPath: string, publicPaths: string[]): boolean { + if (publicPaths.some((p) => openapiPath === p || openapiPath.startsWith(p + '/'))) return true; + const asConcretePath = openapiPath.replace(/\{[^}]+\}/g, '1'); + return PUBLIC_PATH_REGEXES.some((re) => re.test(asConcretePath)); +}