#!/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'); // `body` can be `request.body` (a ReadableStream), not the parsed JSON. Only the // UNAMBIGUOUS stream/Body methods belong here: names that could plausibly be a real // JSON request field (json, text, blob, bytes, on, once, locked, cancel, tee) are // deliberately EXCLUDED - keeping them would let a genuinely-undocumented field of // that name slip past the drift check. A JSON-object-body handler never calls these // stream methods, so this narrow set is safe. const STREAM_BODY_MEMBERS = new Set([ 'arrayBuffer', 'formData', 'pipe', 'pipeTo', 'pipeThrough', 'getReader' ]); 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+7: query-param, status-code, body-field drift (only for annotated handlers) let queryDrift = 0; let statusDrift = 0; let bodyDrift = 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++; } } // Body-field drift (ONE direction: read in code but not documented). // Only for handlers that DO document a JSON object body - a missing body: // annotation is a coverage concern, not drift. body-raw/body-multipart // are non-JSON, so they have no field list to compare. The other direction // (documented but not read locally) is deliberately NOT hard: a field can be // forwarded to a helper without a local `body.x`. if (ann.body && ann.body.kind === 'object') { const docBody = new Set(Object.keys(ann.body.properties)); for (const f of analysis.bodyFields) { if (STREAM_BODY_MEMBERS.has(f)) continue; // request.body stream methods, not JSON fields if (!docBody.has(f)) { driftLines.push(` [Gate 7] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: body field "${f}" read in code but not documented`); bodyDrift++; } } } } } report.push(`[Gate 3] Query-param drift: ${queryDrift} issue(s)`); report.push(`[Gate 4] Status-code drift: ${statusDrift} issue(s)`); report.push(`[Gate 7] Body-field drift: ${bodyDrift} issue(s)`); report.push(...driftLines); if (queryDrift > 0) hardFailures++; if (statusDrift > 0) hardFailures++; if (bodyDrift > 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) via the LOCAL // @redocly/cli (a devDependency). It is NOT invoked through `npx --yes`: an on-demand // npx fetch in a fresh CI container hung this gate for HOURS. Resolve the binary from // node_modules; if it is not installed, SKIP (infra), do not block the release. A lint // that RUNS and exits non-zero is a real spec failure and is hard. writeSpecOutputs(spec); const redoclyBin = join(ROOT_DIR, 'node_modules', '.bin', 'redocly'); if (!existsSync(redoclyBin)) { report.push(`[Gate 6] Spec validity: SKIPPED (@redocly/cli not installed - run npm i)`); } else { try { execFileSync(redoclyBin, ['lint', STATIC_OUT_FILE, '--format=summary'], { cwd: ROOT_DIR, stdio: 'pipe', timeout: 120_000, // belt-and-suspenders: never hang the gate env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: '1' } }); report.push(`[Gate 6] Spec validity (@redocly/cli lint): OK`); } catch (err: any) { // A spawn/timeout failure (ENOENT/ETIMEDOUT) is infra -> skip; a non-zero exit // from a lint that actually ran is a real spec problem -> hard fail. if (err.code === 'ETIMEDOUT' || err.code === 'ENOENT') { report.push(`[Gate 6] Spec validity: SKIPPED (validator did not run: ${err.code})`); } else { const out = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? ''); report.push(`[Gate 6] @redocly/cli lint FAILED:\n${out.slice(-1000)}`); 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(); }