Days 2 through 4 covered the easy case: a new API where the spec can come first. Most of us live in the other case. The service is three years old, the original team is gone, the docs are a Postman collection from 2023, and two hundred routes keep production running. Day 5 of the the series is about reversing the direction — scanning existing code into an OpenAPI document that is accurate enough to trust.
Why the obvious approaches disappoint
Recording traffic through a proxy is fast and permanently incomplete. It only sees the paths someone exercises. Error branches, admin routes, seasonal endpoints, and the 409 nobody has triggered this quarter stay invisible, and you cannot tell the difference between "does not exist" and "was not called."
Hand-writing the spec produces the best document anyone has ever shipped — for about a week. Code moves on a Friday, the spec moves never, and within a sprint it is the same authoritative-looking fiction as the wiki.
Pointing a general-purpose AI at the repo works at demo scale and hallucinates at production scale. Feed it a framework it patterns-matchily knows and it will confidently assign nullability, auth requirements, and response fields that the code never promised.
What actually worked was a dedicated AST scanner with an explicit human review gate.
Parsing code is not grepping code
The difference between a text search and a syntax-tree walk shows up the moment routing leaves the obvious file. In the Express service we inherited, routes were registered across nested routers:
app.use('/v1/admin', adminRouter);
// inside adminRouter:
router.use('/projects', projectRouter);
// inside projectRouter:
projectRouter.post('/:id/history/compare', compareProjectHistory);
A grep finds the handler and loses both prefixes, producing POST /:id/history/compare — a path that does not exist. An AST walk follows the router mounting, tracks the prefixes, binds the path parameters, and reconstructs POST /v1/admin/projects/{id}/history/compare. The same principle applies to decorator-based frameworks (Spring's @RequestMapping, FastAPI's path operations, Gin route groups) and to attribute routing in ASP.NET.
Schemas come from the type system where one exists. TypeScript interfaces, Pydantic models, Java DTOs, Go structs, and C# records resolve into request bodies, parameters, and response shapes with no AI in the loop. Strongly typed handlers produce complete operations deterministically.
The scanner I used (Powerduck's desktop code scan, powered by the open @powerduck/code-to-openapi engine) covers eight languages — TypeScript, JavaScript, Python, Go, Java, C#, Rust, PHP — through framework packs for Express, Fastify, NestJS, Koa, Hono and Next.js; FastAPI, Flask, Django REST and Starlette; Gin, Chi, Echo, Fiber, net/http and Gorilla mux; Spring, JAX-RS and Micronaut; ASP.NET and FastEndpoints; Axum, Actix and Rocket; Laravel, Symfony and Slim. HTTP and SSE are both first-class outputs.
Honest gaps beat confident guesses
This is the design decision that makes the output trustworthy. When the engine cannot know something — an untyped req.body in loose JavaScript, a response assembled dynamically, middleware applied in another file, SSE event names built at runtime — it does not invent a plausible schema. It flags a gap on that operation:
| Gap flag | What it is really telling you |
|---|---|
body-schema-unknown |
The handler accepts an untyped object; the type system never described the input |
response-schema-unknown |
At least one response branch returns a shape the framework cannot see |
auth-unknown |
Auth middleware is applied somewhere the static trace cannot follow |
query-unknown / header-unknown
|
Parameters are read dynamically instead of declared |
sse-events-unknown |
The stream exists but its event names or payloads are assembled at runtime |
Every discovered route lands in a review dialog with its method, reconstructed path, confidence level, and gap list. Nothing becomes an unknown field in the finished spec without a human seeing it first. For the gaps worth filling, an optional AI resolver sends the single relevant handler — not the whole repo — to whatever model you configured, proposes a schema, and waits for approval. The default scan has no AI step at all; turning it on raises recall on loosely typed code, and the review gate stays either way.
Two properties make this safe for proprietary code. The scan runs entirely on the local machine; source is never uploaded. And the optional AI call is opt-in, per gap, scoped to one handler.
What the first scan actually caught
- A controller returning different DTOs per status code ended up with two explicit response schemas instead of one optimistic 200.
- An SSE endpoint was documented as
text/event-streamwith an item schema under the same protocol extension the rest of the workspace uses for mocks and scenarios. - A route registered in two places with different middleware was flagged instead of silently merged, which surfaced a genuine auth inconsistency.
- Several query parameters appeared that had never existed in the old Postman collection — nobody had clicked that tab in years.
Rescans are diffs, which is what makes it survive
A one-time import is a snapshot; a snapshot rots. After the first confirmed import, the scanner writes a discovery sidecar into the workspace. On the next sprint's rescan, instead of a fresh document you get a change set: five new routes, two removed, one renamed parameter. New operations merge into the existing spec, and the descriptions, examples, and manual edits from the first pass survive.
That closes the loop from the earlier days: the mock, the scenarios, the docs, and the MCP server all derive from the document, and the document can now be rebuilt from what the team actually shipped. The cost of keeping the contract honest drops from "a documentation project" to "rescan, review the diff, merge."
Know the limits
The scanner states them plainly rather than papering over them. Routes assembled from configuration files, handlers dispatched through deeply dynamic proxies, and completely untyped request bodies stay flagged. That honesty is the feature. A spec with a marked gap can be fixed deliberately — often by adding a type annotation upstream, which improves the codebase too. A spec with a confident guess fails in production at the worst possible moment and quietly destroys trust in the whole document.
The long-form write-up of the inherited-service scan, including the full gap table and what each finding meant, is here.
Tomorrow, Day 6: the spec exists, whether designed up front or scanned from code. Now give it to the coding agent — not as a pasted summary, but as tools it can call.
Top comments (0)