@powerduck/code-to-openapi
v0.14.0
Published
Scan a local codebase into OpenAPI 3.2 with a language-neutral AST engine, pluggable language and framework packs, and an optional AI gap resolver.
Maintainers
Readme
@powerduck/code-to-openapi
Scan a local backend codebase and reverse-engineer a validated OpenAPI 3.2 document. Extraction is deterministic by default: routes are proven through framework instance tracing and AST analysis, never guessed. An optional AI gap resolver fills only the specific pieces the static engine could not prove.
Part of the Powerduck toolchain — one local OpenAPI spec for design, debug, test, mock, documentation and MCP serving.
How it works
indexer → language packs (AST + type checker)
→ framework packs (route graphs, handlers)
→ completeness gate (proven / proven-empty / gap)
→ Discovery IR → validated OpenAPI 3.2- Language-neutral core. File indexing, route signature matching, a common type model, handler exit tracing and the completeness gate are shared. Language and framework packs only add evidence.
- Framework instance tracing. The engine follows
import/exportgraphs to prove that a call target is the realexpress()app orexpress.Router()instance. Lookalikes such ascache.get(...)are never mistaken for routes, and routers that are never mounted are reported as unreachable instead of emitted. - Type-first schemas. In TypeScript projects the compiler checker resolves
generics (
Response<User[]>,Request<Params, ResBody, ReqBody, Query>), named interfaces, enums, utility types (Partial/Pick/Omit) and Zod schemas. Named declarations becomecomponents.schemaswith$refs; anonymous shapes stay inline. - Completeness is a hard gate. Every parameter, request body and response is one of: proven with evidence, proven absent, or an explicit gap. A route is never emitted as a bare URL with empty contracts.
- AI only fills gaps. The host may supply a resolver. It receives small per-handler slices for specific gap codes; it can never invent a route, method or path. Source code is never sent anywhere by default.
- Incremental by design. A
.powerduck/discovery.jsonsidecar fingerprints files and routes so rescans diff added/changed/removed routes; user edits to the generated document are preserved by the host through JSON Patch.
Framework support
| Language | Framework | Status | | ---------- | --------- | ------ | | TypeScript / JavaScript | Express, Fastify, NestJS, Hono (Workers/Bun/Deno), Koa, Next.js route handlers, Elysia (Bun) | 0.9.x | | Python | FastAPI, Flask (Flask-RESTful resources), Django REST Framework, Starlette, SQLModel | 0.9.x | | Go | Gin, Chi (go-chi/render), net/http ServeMux, gorilla/mux, Echo, Fiber | 0.9.x | | Java | Spring Boot, JAX-RS (Jersey/Quarkus/Dropwizard), Micronaut | 0.9.x | | C# | ASP.NET Core (controllers + minimal API), FastEndpoints | 0.9.x | | Rust | Axum, actix-web, Rocket | 0.9.x | | PHP | Laravel, Symfony, Slim | 0.9.x |
HTTP is fully supported; SSE endpoints are emitted with the canonical
x-protocol: "sse" extension and a text/event-stream media type carrying
itemSchema (including named Spring SseEmitter events when the event name
and payload type are statically provable).
0.9.2 contract-first hardening
No new packs; this release hardens TypeScript/Bun and Go extraction against real open-source backends (RealWorld family and others), always preferring declared framework contracts over handler inference:
- Elysia: third-argument route options (
body,query,params,headers,response) are now extracted, including chained verbs onnew Elysia(), nested.group()prefixes (with:paramnormalization), bare and status-mapped responses, andreturn status(201, body). Schema DTOs resolve across barrels andtsconfigpath aliases, with converters for ArkType (type(), domains, bounds,.get().partial().array(),Record<...>), TypeBox (t.Object/Optional/Nullable/Union/...) and Zod. - Hono:
@hono/zod-openapicreateRoutecontracts resolve cross-file bodies/params/responses (including computed[StatusCodes.OK]keys),OpenAPIHonoinstances and advanced Zod chains (.merge/.shape/.partial/ .omit/.regex/.openapi). - Fastify:
@fastify/autoloaddirectory plugins (including CommonJS),fluent-json-schemachain schemas, and draft-07definitions/$defshoisted into OAS 3.xcomponents.schemas. - Next.js App Router: request bodies are inferred from the
schema.parse(await req.json())Zod idiom, andnew Response(JSON.stringify(payload))follows the serialized payload. - Go: Echo handlers that bind through local helper methods
(
req.bind(c, &u)), split-statementjson.NewDecoder/json.Unmarshalbodies in net/http, gorilla/mux and chi, andnew(T)payload values. - Declared response DTOs now authoritatively replace partial handler inference for the same status/media type.
0.9.1 robustness and real-project hardening
No new packs; this release hardens extraction against real-world code found while
validating the 28 packs against public backends (see
docs/real-project-verification.md):
- Express: no longer crashes on
app.listen()called with no arguments (seen in the Nest monorepo); the listen call is now guarded. - Gin: relative route patterns accepted by Go (
router.GET("favicon.ico", ...)) are normalized to a leading-slash OpenAPI path so the emitted document stays schema-valid. - The previously flaky AI-gap integration test now runs with an explicit, larger timeout; the suite is reliably green (262 tests).
0.9.0 framework expansion
Seventeen new framework packs were added and validated against real open-source projects, all sharing the same confidence scoring, component reuse and honest-gap machinery:
- TypeScript/JavaScript: Hono (including Cloudflare Workers/Bun/Deno and
@hono/zod-openapiroute definitions), Koa withkoa-router/@koa/router(ESM and CommonJS), Next.js file-based routes (App Routerroute.tshandlers and Pages Routerpages/api), and Elysia (Bun). - Python: Django REST Framework (function and class-based views,
ViewSets with routers and
@action, Serializer schemas) and Starlette (Route/WebSocketRouteregistration). - Go: standard library
net/httpServeMux (Go 1.22 method patterns), gorilla/mux, Echo and Fiber. - JVM: one shared JAX-RS pack covering Jersey, Quarkus RESTEasy Reactive
and Dropwizard (both
jakarta.ws.rsand legacyjavax.ws.rs), plus Micronaut. - Rust/.NET: actix-web and Rocket macro routing; top-level ASP.NET
Minimal API (
MapGroup,TypedResults) and FastEndpoints. - PHP: Symfony (
#[Route]attributes,MapRequestPayload) and Slim.
0.8.0 real-world hardening
The inference engine was validated against dozens of real, complex open-source backends (Koel, Bagisto, Snipe-IT, apipost-server, eladmin, novel-plus, Redash, Apache Superset, CleanArchitecture, hackathon-starter, the RealWorld family, go-chi/gin examples, Platformatic and others). Highlights:
- CommonJS Express apps are traced like ESM:
require('express'), mounted sub-routers, middleware arrays,module.exportscontroller objects, chainedRouter().use()composition andres.render/res.redirect. - Monorepo leaf discovery: a root with no server framework probes one
level of
packages/*,apps/*,services/*and workspace globs, then aggregates supported leaves into one document. - Go:
render.Render/render.RenderListfollow constructor return structs; Ginc.JSONfollows constructors and service calls; receiver method handlers and qualified registration helpers resolve. - Spring: handlers returning
service.method()follow the bean implementation through generics andResponseEntity/Pageenvelopes; named SSE events extract their payload DTOs. - Laravel: array-callable and
Route::controller()->group()handlers, API Resources/transformers/static helpers,JsonResponse,view()HTML, Facade chains and binary downloads (application/octet-stream). - Python/.NET: SQLModel models, FastAPI
Annotated[..., Depends]aliases andPath(alias=...), Flask-RESTfuladd_resource, and ASP.NET minimal-APIMapGet/MapPostgroups. - Path parameters default to the OpenAPI string segment type; duplicate operationIds are qualified and disambiguated in every pack.
The TypeScript/JavaScript layer uses the TypeScript compiler API (an optional
peer dependency; the pack degrades to syntactic analysis with explicit gaps
when it is not installed). Python, Go, Java, C#, Rust and PHP are parsed
through tree-sitter WASM, so no language toolchain is required. Framework
pack ids are express, fastify, nest, fastapi, flask, gin, chi,
spring, aspnet, axum and laravel.
Install
npm install @powerduck/code-to-openapi
# TypeScript projects benefit from an optional peer dependency:
npm install -D typescriptThe package ships ESM and CJS builds and runs on Node.js 18+.
Quick start
import { scanProject } from "@powerduck/code-to-openapi";
const result = await scanProject({ root: "/path/to/your/api/project" });
console.log(
`${result.report.routesConfirmed} confirmed, ` +
`${result.report.routesPartial} partial routes`,
);
const { document, documentValid, diagnostics } = await result.convert();
console.log("OpenAPI 3.2 valid:", documentValid);document is a validated OpenAPI 3.2 object. result.project is the
intermediate Discovery IR; result.report.gaps lists exactly what could not be
proven statically.
Run the bundled example against any Express project:
npx tsx node_modules/@powerduck/code-to-openapi/examples/basic.ts ./my-apiScan options
await scanProject({
root: "./api",
ignore: ["legacy/**"], // merged with .gitignore / .powerduckignore
includeTests: false, // include test and fixture files (default: false)
frameworks: ["express"], // restrict framework packs
maxFileBytes: 2 * 1024 * 1024, // skipped files are reported as unresolved
maxFiles: 10_000, // exceeding this source-file budget fails the scan
maxTotalBytes: 64 * 1024 * 1024, // total indexed source budget (64 MiB)
onProgress: (phase, detail) => console.log(phase, detail ?? ""),
gapResolver, // optional; omit for fully deterministic output
});Optional AI gap resolver
The scanning package never calls a model vendor itself. It ships the prompt contract and a strict response validator; the host performs the HTTP call (the desktop app does this behind an explicit opt-in, using the user's own model configuration):
import {
buildGapMessages,
parseGapResolution,
gapCacheKey,
GAP_PROMPT_VERSION,
scanProject,
type GapResolver,
} from "@powerduck/code-to-openapi";
const cache = new Map<string, unknown>();
const resolver: GapResolver = {
id: "openai-compatible-host",
async resolve(request) {
// Reuse resolutions for unchanged handler slices.
const key = gapCacheKey(request, GAP_PROMPT_VERSION);
const cached = cache.get(key);
if (cached) return cached as never;
const response = await fetch("https://your-model-host/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.MODEL_API_KEY}`,
},
body: JSON.stringify({
model: "your-model",
messages: buildGapMessages(request),
temperature: 0,
max_tokens: 4096,
response_format: { type: "json_object" },
}),
});
if (!response.ok) return null; // a failed fill is never fatal to the scan
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content ?? "";
const resolution = parseGapResolution(content); // clamps to a safe subset
if (resolution) cache.set(key, resolution);
return resolution; // null leaves the gap visible in the report
},
};
const result = await scanProject({ root: "./api", gapResolver: resolver });The model receives only the small handler slice for routes that actually have
gaps — never whole files — and its answer is clamped to a JSON Schema subset
(no $ref, bounded depth and property counts). The resolver can fill query
parameters, headers, the request body, status-keyed response schemas and SSE
event payloads; it can never invent a route, method or path.
Gap codes include path-dynamic, path-param-untyped, query-unknown,
header-unknown, body-unknown, body-schema-unknown, response-unknown,
response-schema-unknown, auth-unknown and sse-events-unknown.
Incremental rescans
import {
diffSidecars,
affectedFiles,
type DiscoverySidecar,
} from "@powerduck/code-to-openapi";
const diff = diffSidecars(previousSidecar, currentSidecar);
// diff.addedFiles / changedFiles / removedFiles
// diff.routeChanges: added | changed | removed, keyed by `${METHOD} ${path}`
const reanalyze = affectedFiles(diff);The sidecar is the only place scan provenance is stored. The generated OpenAPI document stays clean and is safe for users to edit; removed routes are flagged for review rather than deleted automatically.
Three-way merge into an edited document
On a rescan, merge the freshly scanned document into the user's current specification. Manual edits always win; the scan only refreshes structural contracts.
import { mergeScannedDocument } from "@powerduck/code-to-openapi";
const merged = mergeScannedDocument({
current: currentOpenApiDocument, // user-edited OAS object
scanned: scannedOpenApiDocument, // result.convert() output
previous: previousSidecar, // .powerduck/discovery.json on disk
next: scanResult.sidecar, // sidecar from the new scan
});
// merged.added / changed / removed / unchanged
// merged.document is the merged OAS object (inputs are never mutated)Merge rules:
- Added routes are inserted; unchanged routes are left exactly as the user wrote them.
- Changed routes refresh parameters, request bodies, responses and security
while preserving
summary,description,tags,externalDocs,deprecated,operationId, parameter/response descriptions and examples, and everyx-extension. User-only parameters and response statuses are kept. - Removed routes are never deleted; they stay in the document and are
returned in
removedfor explicit review. components.schemasandsecuritySchemesare add-only. A scanned component whose name collides with a different user schema is renamed (User2,User3, …) and its refs are rewritten automatically.info,serversand all other top-level user content are untouched.
Confidence and gaps
Every operation carries a confidence level:
high— framework trace plus types/literals prove the contract.medium— route and shape are proven but some schema detail is inferred.low— only syntactic evidence exists; gaps describe what is missing.
Unresolvable constructs (dynamic route expressions, orphan routers) appear in
project.unresolved / diagnostics instead of being guessed.
License
MIT © Powerduck limited. See LICENSE.
Website: https://www.powerduck.com/
Explicit generated Java sources
Generated Spring API interfaces and DTOs may be excluded by .gitignore (for example under target/). Generate them with your project's pinned toolchain first, then opt in to those source directories:
const result = await scanProject({
root: '/workspace/backend',
additionalSourceRoots: ['target/generated-sources/openapi/src/main/java'],
});The scanner never runs code generators or project build scripts. Additional roots must resolve to subdirectories inside the project; outside symlinks are rejected. Explicit ignore patterns and .powerduckignore still apply. Missing interface sources remain unresolved rather than fabricated. Spring controller implementations inherit interface mappings, parameter annotations and generated response annotations when the corresponding sources are present. This does not imply support for every generic interface or dynamic mapping.
