add the missing OpenAPI generator (scripts/generate-openapi.ts + scripts/openapi) so a clean checkout builds

This commit is contained in:
Jarek Krochmalski committed 2026-08-18 05:12:37 +02:00
1 parent 7a007bd623
commit 5c0fa6aa91
3 files changed
+1099

No files matched your search

+334
View File
@@ -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 <file> -> prints a code-grounded JSDoc draft
* for every un-annotated handler in <file>
*
* 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<string, Partial<Record<HttpMethod, HandlerAnnotation>>> = {};
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 <file>
// ---------------------------------------------------------------------------
function runScaffold() {
const idx = args.indexOf('--scaffold');
const target = args[idx + 1];
if (!target) {
console.error('Usage: generate-openapi.ts --scaffold <path/to/+server.ts>');
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();
}
+220
View File
@@ -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<string, string>;
annotationsByPath: Record<string, Partial<Record<HttpMethod, HandlerAnnotation>>>;
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<string, Record<string, unknown>> = {};
// 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<string, unknown>[] = 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<string, Record<string, unknown>> = {};
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<string, unknown> = { 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<string, unknown> | 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<string, unknown> = {
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 } };
}
+545
View File
@@ -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<string, MiniSchema>; 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<string, ParamAnnotation>;
path: Record<string, ParamAnnotation>;
body?: MiniSchema;
bodyExample?: unknown;
responses: Record<string, ResponseAnnotation>;
/** 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<string, InferredScalarType>; // 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 <OTHER_METHOD>:` (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<Record<HttpMethod, string>> {
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<Record<HttpMethod, string>> = {};
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<string, string> } {
const routes: DiscoveredRoute[] = [];
const skipped: { filePath: string; reason: string }[] = [];
const fileContents = new Map<string, string>();
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 <METHOD>: 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: <text>
// * description: <text> (optional; longer explanation
// * than summary — purpose,
// * read-only/write semantics,
// * pointer to a related endpoint)
// * query: <name>:<type>[!] <description> (repeatable)
// * path: <name>:<type>[!] <description> (repeatable; enriches an
// * auto-detected {name} segment)
// * body: <mini-schema>
// * body-example: <json>
// * resp-<code>: <mini-schema> OR resp-<code>: <plain text>
// * resp-<code>-desc: <text>
// * resp-<code>-example: <json>
// */
//
// 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<string, MiniSchema> = {};
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<string, unknown> {
switch (schema.kind) {
case 'object': {
const out: Record<string, unknown> = {
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<Record<HttpMethod, HandlerAnnotation>> {
const result: Partial<Record<HttpMethod, HandlerAnnotation>> = {};
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<string, string> = {};
const respExampleOverride: Record<string, unknown> = {};
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<string, ParamAnnotation>)[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<string>();
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<string, InferredScalarType> = {};
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<string>();
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<string>();
// `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));
}