add the missing OpenAPI generator (scripts/generate-openapi.ts + scripts/openapi) so a clean checkout builds
This commit is contained in:
3 files changed
+1099
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user