Generate OpenAPI from Express and NestJS code without writing annotations by hand
Node teams usually reach a spec in one of two painful ways: they hand-write OpenAPI and watch it drift from the code, or they decorate every handler and watch the decorators drift from the actual types. Both treat the document as a second thing to maintain. There is a third path: treat the running framework code as the source of truth, prove what it does statically, and reverse-engineer a validated OpenAPI 3.2 document from it. The difference between a scanner that works on real Express and NestJS monorepos and one that produces confident fiction is how it proves routes and types.
Routes must be traced to the real app instance
Naive extractors pattern-match app.get( across files. That approach over-reports immediately: a cache client exposes client.get(...), a feature flag SDK exposes flags.get(...), and neither is an HTTP route. It also under-reports: routes defined on a Router() in another file and mounted later are invisible unless the scanner follows the mount.
A deterministic scanner instead traces the import/export graph to prove that the call target is the actual express() application or an express.Router() instance. cache.get(...) is then correctly ignored, a router that is constructed but never mounted is reported as unreachable rather than emitted, and middleware arrays, chained Router().use() composition, and CommonJS require('express') modules are traced the same way as ESM. The same tracing covers the patterns real Express apps actually use: mounted sub-routers, module.exports controller objects, and res.render / res.redirect exits.
NestJS is traced through its declarations: @Controller() classes, @Get() / @Post() method decorators, @Body() and @Param() bindings, and the DTO classes those bindings reference. A Nest monorepo that calls app.listen() with no arguments must not crash the scan; the listen call is a server concern, not a route.
Schemas come from the type checker, not regex
In TypeScript the strongest signal is the compiler itself. The scanner uses the TypeScript checker to resolve the generics that carry real contracts:
import type { Request, Response } from "express";
interface CreateUserBody {
email: string;
role: "admin" | "member";
metadata?: Record<string, string>;
}
export async function createUser(
req: Request<{ orgId: string }, unknown, CreateUserBody, { invite?: string }>,
res: Response<{ id: string; email: string }>,
) {
const user = await users.create({
orgId: req.params.orgId,
email: req.body.email,
role: req.body.role,
});
res.status(201).json({ id: user.id, email: user.email });
}Resolving Request<Params, ResBody, ReqBody, Query> and Response<User[]> yields the path params, request body, query parameters, and status-keyed response shape in one pass. Named interfaces, enums, and utility types such as Partial, Pick, and Omit are resolved; Zod schemas are read as the validation contract they already are. Named declarations become reusable components.schemas with $refs, while one-off anonymous shapes stay inline. When the TypeScript package is not installed, the pack degrades to syntactic analysis and marks the types it could no longer prove as explicit gaps rather than silently dropping them.
A conversion function is not a type contract
Query parameters are where guessing is most tempting and most wrong. Seeing parseInt(req.query.limit, 10) does not prove that limit accepts only integers. Consider the actual branches:
app.get("/orders", (req, res) => {
const limit = parseInt(String(req.query.limit ?? "20"), 10);
const pageSize = Number.isFinite(limit) && limit > 0 && limit <= 100 ? limit : 20;
res.json(orders.list({ pageSize }));
});A missing parameter falls back to 20. An empty string, "abc", a negative number, or 99999 all hit the same defaulting branch. The real contract is "an optional query parameter that is coerced and clamped, with invalid input replaced by a default", not "a required integer". The scanner only narrows a parameter's type when the validation or default branch proves the accepted set. When the runtime behavior cannot be proven from the code, the parameter is a query-unknown gap instead of an invented integer.
Every contract is proven, proven absent, or a gap
The completeness gate is the property that separates a document you can trust from a bare list of URLs. Each parameter, request body, and response is classified as:
- proven, with evidence from the framework trace and types;
- proven absent, for example when a handler demonstrably takes no body;
- or an explicit gap with a code such as
query-unknown,body-schema-unknown,response-unknown, orauth-unknown.
Each operation then carries a confidence level: high when framework trace plus types and literals prove the contract, medium when the route and shape are proven but some schema detail is inferred, and low when only syntactic evidence exists. A route is never emitted as a URL with empty contracts, and dynamic route expressions or orphan routers land in an unresolved list instead of being guessed.
AI fills only the gaps, visibly
The scanning package itself never calls a model vendor. It ships the prompt contract and a strict response validator; the host application makes any model call, behind an explicit opt-in, using the user's own model configuration. When enabled, the model receives only the small handler slice for routes that actually have gaps, never whole files, and its answer is clamped to a safe JSON Schema subset with no $refs and bounded depth and property counts.
Two hard rules keep this honest. The resolver can fill query parameters, headers, request bodies, status-keyed response schemas, and SSE event payloads, but it can never invent a route, method, or path. And a failed fill is never fatal: returning null leaves the gap visible in the report. The desktop surface shows each proposed fill for review, where it can be accepted, edited, or rejected, so the model is an assistant to a human decision rather than an invisible source of schema.
Rescans preserve the work you already did
A .powerduck/discovery.json sidecar fingerprints files and routes; it is the only place scan provenance is stored, so the generated document stays clean and editable. A rescan diffs added, changed, and removed routes, and a three-way merge applies the result to your current spec with manual edits always winning:
- Added routes are inserted; unchanged routes are left exactly as you wrote them.
- Changed routes refresh structural contracts while preserving descriptions, tags, examples,
operationId, deprecation flags, and everyx-extension. - Removed routes are flagged for review, never deleted silently.
components.schemasandsecuritySchemesare add-only, with collisions renamed and their refs rewritten.
That is what makes scanning safe to run repeatedly in CI rather than a one-time import that overwrites human work.
Running it
import { scanProject } from "@powerduck/code-to-openapi";
const result = await scanProject({
root: "./api",
frameworks: ["express"], // or "nest"
});
console.log(
`${result.report.routesConfirmed} confirmed, ` +
`${result.report.routesPartial} partial`,
);
const { document, documentValid } = await result.convert();
console.log("OpenAPI 3.2 valid:", documentValid);TypeScript and JavaScript are analyzed with the compiler checker; other languages use tree-sitter parsers shipped as WASM, so no per-language toolchain has to be installed. Point it at an Express or NestJS project and the output is a validated document with honest gaps rather than a polished guess. You can try the same local-first, spec-driven workflow, including the reviewable AI gap fills, in the online demo, and compare it with the cross-language approach in the broader code-to-OpenAPI overview.