@vayo-hq/ast 0.1.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/dist/index.d.ts +240 -0
- package/dist/index.js +1167 -0
- package/package.json +27 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @vayo-hq/ast
|
|
3
|
+
// Static analysis pass — docs/04-capture-engine.md Step 2 and §3a.
|
|
4
|
+
// Framework-specific bootstrapping (getting a live `app` instance) is
|
|
5
|
+
// isolated behind the adapter path passed in via VayoConfig; this module's
|
|
6
|
+
// own logic doesn't otherwise assume Express beyond that one boundary
|
|
7
|
+
// (and the express-list-endpoints dependency itself, per
|
|
8
|
+
// docs/08-packages-and-repo-structure.md's own description of this package).
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.DEFAULT_VALIDATION_MIDDLEWARE_PATTERNS = exports.DEFAULT_SCOPE_CHECK_PATTERNS = exports.DEFAULT_AUTH_MIDDLEWARE_PATTERNS = void 0;
|
|
14
|
+
exports.inferGroup = inferGroup;
|
|
15
|
+
exports.pathSegmentsMatch = pathSegmentsMatch;
|
|
16
|
+
exports.joinMountedPath = joinMountedPath;
|
|
17
|
+
exports.buildMountPrefixMap = buildMountPrefixMap;
|
|
18
|
+
exports.extractMiddlewareNames = extractMiddlewareNames;
|
|
19
|
+
exports.findRequestSchemaForRoute = findRequestSchemaForRoute;
|
|
20
|
+
exports.findMongooseRequestSchemaForRoute = findMongooseRequestSchemaForRoute;
|
|
21
|
+
exports.extractSummary = extractSummary;
|
|
22
|
+
exports.extractDescription = extractDescription;
|
|
23
|
+
exports.extractExplicitGroup = extractExplicitGroup;
|
|
24
|
+
exports.extractDeprecated = extractDeprecated;
|
|
25
|
+
exports.extractDeclaredResponseSchemas = extractDeclaredResponseSchemas;
|
|
26
|
+
exports.extractDeclaredExamples = extractDeclaredExamples;
|
|
27
|
+
exports.scanProject = scanProject;
|
|
28
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
29
|
+
const node_url_1 = require("node:url");
|
|
30
|
+
const express_list_endpoints_1 = __importDefault(require("express-list-endpoints"));
|
|
31
|
+
const ts_morph_1 = require("ts-morph");
|
|
32
|
+
exports.DEFAULT_AUTH_MIDDLEWARE_PATTERNS = [
|
|
33
|
+
"authenticate",
|
|
34
|
+
"requireAuth",
|
|
35
|
+
"isLoggedIn",
|
|
36
|
+
"verifyToken",
|
|
37
|
+
"passport.authenticate",
|
|
38
|
+
];
|
|
39
|
+
exports.DEFAULT_SCOPE_CHECK_PATTERNS = [
|
|
40
|
+
"requireScope",
|
|
41
|
+
"checkPermission",
|
|
42
|
+
"authorize",
|
|
43
|
+
];
|
|
44
|
+
/** Body-validation middleware names to recognize a Zod schema argument
|
|
45
|
+
* against (docs/04-capture-engine.md Step 2 #3) — configurable the same way
|
|
46
|
+
* auth/scope patterns are, since every team names this differently. */
|
|
47
|
+
exports.DEFAULT_VALIDATION_MIDDLEWARE_PATTERNS = [
|
|
48
|
+
"validateBody",
|
|
49
|
+
"validate",
|
|
50
|
+
"validateRequest",
|
|
51
|
+
"zValidator",
|
|
52
|
+
];
|
|
53
|
+
const HTTP_METHOD_PROPERTY_NAMES = new Set(["get", "post", "put", "patch", "delete", "all"]);
|
|
54
|
+
/** Unwraps `export default app`'s value out of a dynamically-`import()`ed
|
|
55
|
+
* module. Some TS-execution loaders (tsx among them) double-wrap a CJS
|
|
56
|
+
* module's default export when it's dynamically imported from a CJS
|
|
57
|
+
* caller — `mod.default` ends up being the raw `module.exports` object
|
|
58
|
+
* (itself `{ default: app }`) rather than `app` directly. An Express app is
|
|
59
|
+
* always a callable function, so unwrap one more `.default` layer at a time
|
|
60
|
+
* until we find one (or run out of layers). */
|
|
61
|
+
function unwrapApp(mod) {
|
|
62
|
+
let candidate = mod.default ?? mod.app;
|
|
63
|
+
while (candidate !== null &&
|
|
64
|
+
typeof candidate === "object" &&
|
|
65
|
+
"default" in candidate) {
|
|
66
|
+
candidate = candidate.default;
|
|
67
|
+
}
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
/** Folder/mount-path convention (docs/04-capture-engine.md Step 2 #4): a
|
|
71
|
+
* route registered from a file under `routes/orders/*.ts` -> "Orders", and
|
|
72
|
+
* one under `routes/admin/users/*.ts` -> "Admin/Users" — every directory
|
|
73
|
+
* segment between `routes/` and the file itself becomes one level of the
|
|
74
|
+
* "/"-separated group path, so `@vayo-hq/db-mongo`'s `autoOrganizeFolders` can
|
|
75
|
+
* turn a nested route-file layout into real nested sidebar folders instead
|
|
76
|
+
* of flattening it to one level. Falls back to the first meaningful path
|
|
77
|
+
* segment (never nested — a URL's own segments aren't a reliable
|
|
78
|
+
* organizational signal the way a deliberate file layout is) when the entry
|
|
79
|
+
* file isn't organized that way at all (e.g. a single flat app.ts, as in
|
|
80
|
+
* the demo app). */
|
|
81
|
+
function inferGroup(pathTemplate, sourceFilePath) {
|
|
82
|
+
const folderMatch = sourceFilePath.replace(/\\/g, "/").match(/\/routes\/(.+)\/[^/]+$/i);
|
|
83
|
+
if (folderMatch) {
|
|
84
|
+
const segments = folderMatch[1].split("/").filter((s) => s.length > 0);
|
|
85
|
+
if (segments.length > 0) {
|
|
86
|
+
return segments.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("/");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const raw = pathTemplate.split("/").find((s) => s.length > 0 && !s.startsWith(":") && !/^v\d+$/i.test(s) && s !== "api");
|
|
90
|
+
if (!raw)
|
|
91
|
+
return "General";
|
|
92
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
93
|
+
}
|
|
94
|
+
/** True when `registrationPath` (a literal string found at a route
|
|
95
|
+
* registration site, e.g. `router.get("/:id", ...)`) resolves to
|
|
96
|
+
* `runtimePath` (the fully-mounted path `express-list-endpoints` reports,
|
|
97
|
+
* e.g. `/api/admin/products/:id`). Exact equality covers routes registered
|
|
98
|
+
* directly on `app` with their full path repeated (today's flat demo-app
|
|
99
|
+
* style); segment-suffix equality covers `express.Router()` composition
|
|
100
|
+
* mounted via `app.use(prefix, router)` at any nesting depth — the router's
|
|
101
|
+
* own registration only ever sees its path relative to wherever it gets
|
|
102
|
+
* mounted, but `express-list-endpoints` resolves the live app's actual
|
|
103
|
+
* router stack at runtime, so the runtime path is always the source of
|
|
104
|
+
* truth here; only the static side needs to tolerate the prefix it can't
|
|
105
|
+
* see. Segment-based (not raw substring) so `/id` can never accidentally
|
|
106
|
+
* suffix-match inside `/userid`. */
|
|
107
|
+
function pathSegmentsMatch(registrationPath, runtimePath) {
|
|
108
|
+
if (registrationPath === runtimePath)
|
|
109
|
+
return true;
|
|
110
|
+
const regSegments = registrationPath.split("/").filter((s) => s.length > 0);
|
|
111
|
+
const runtimeSegments = runtimePath.split("/").filter((s) => s.length > 0);
|
|
112
|
+
if (regSegments.length === 0 || regSegments.length > runtimeSegments.length)
|
|
113
|
+
return false;
|
|
114
|
+
const suffix = runtimeSegments.slice(runtimeSegments.length - regSegments.length);
|
|
115
|
+
return suffix.every((segment, i) => segment === regSegments[i]);
|
|
116
|
+
}
|
|
117
|
+
function calleeName(call) {
|
|
118
|
+
const expr = call.getExpression();
|
|
119
|
+
if (ts_morph_1.Node.isIdentifier(expr))
|
|
120
|
+
return expr.getText();
|
|
121
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
122
|
+
return expr.getName();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/** Finds every `app.<method>("/path", ...)` (or `router.<method>(...)`,
|
|
126
|
+
* `someRouter.<method>(...)` — any receiver, since a route can be registered
|
|
127
|
+
* on `app` directly or on any `express.Router()`) call in the source file —
|
|
128
|
+
* the literal registration sites we can pattern-match scope-check calls and
|
|
129
|
+
* JSDoc summaries against. Captures the method name too since two different
|
|
130
|
+
* methods are frequently registered at the identical literal path (e.g.
|
|
131
|
+
* `router.get("/", list)` and `router.post("/", create)` both mounted at the
|
|
132
|
+
* same prefix) — matching on path alone would silently pick the wrong one. */
|
|
133
|
+
function findRouteRegistrations(sourceFile) {
|
|
134
|
+
const registrations = [];
|
|
135
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
136
|
+
const expr = call.getExpression();
|
|
137
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
138
|
+
continue;
|
|
139
|
+
if (!HTTP_METHOD_PROPERTY_NAMES.has(expr.getName()))
|
|
140
|
+
continue;
|
|
141
|
+
const [first] = call.getArguments();
|
|
142
|
+
if (!first || !ts_morph_1.Node.isStringLiteral(first))
|
|
143
|
+
continue;
|
|
144
|
+
registrations.push({ method: expr.getName().toUpperCase(), pathTemplate: first.getLiteralValue(), call });
|
|
145
|
+
}
|
|
146
|
+
return registrations;
|
|
147
|
+
}
|
|
148
|
+
/** Joins an `express.Router()`'s own relative registration path onto the
|
|
149
|
+
* literal prefix it's mounted at (`app.use(prefix, router)`) — mirrors how
|
|
150
|
+
* Express itself resolves it, including the router-root special case
|
|
151
|
+
* (`router.get("/", ...)` mounted at `prefix` resolves to exactly `prefix`,
|
|
152
|
+
* not `prefix + "/"`). Empty `prefix` (a registration found directly on
|
|
153
|
+
* `app`, never resolved through a mount call) is a no-op, so today's flat
|
|
154
|
+
* style keeps matching by plain equality with zero special-casing. */
|
|
155
|
+
function joinMountedPath(prefix, relativePath) {
|
|
156
|
+
if (relativePath === "/" || relativePath === "")
|
|
157
|
+
return prefix || "/";
|
|
158
|
+
return `${prefix}${relativePath}`;
|
|
159
|
+
}
|
|
160
|
+
/** Resolves the identifier passed as the second argument of an
|
|
161
|
+
* `X.use("/prefix", identifier)` call back to the source file it was
|
|
162
|
+
* imported from — e.g. `import productsRouter from "./routes/products/
|
|
163
|
+
* products.routes.js"` — by reading the *default* import bindings actually
|
|
164
|
+
* declared in `callSiteFile`, purely syntactically (no full symbol/alias
|
|
165
|
+
* resolution). This only ever recognizes the "one exported router per
|
|
166
|
+
* file, imported and mounted by identifier" convention; anything else
|
|
167
|
+
* (named exports, re-exports, indirection through a barrel file) simply
|
|
168
|
+
* fails to resolve a prefix for that router, which is always a safe
|
|
169
|
+
* fallback — `pathSegmentsMatch` below still catches it. */
|
|
170
|
+
function resolveRouterSourceFile(identifierName, callSiteFile) {
|
|
171
|
+
for (const importDecl of callSiteFile.getImportDeclarations()) {
|
|
172
|
+
const defaultImport = importDecl.getDefaultImport();
|
|
173
|
+
if (defaultImport?.getText() === identifierName) {
|
|
174
|
+
return importDecl.getModuleSpecifierSourceFile() ?? null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Scans every file in the project for `X.use("/prefix", router)` calls and
|
|
180
|
+
* maps each mounted router's *source file* to the literal prefix it's
|
|
181
|
+
* mounted at — the piece `express-list-endpoints` can't give us (it only
|
|
182
|
+
* reports the live app's fully-*resolved* runtime paths, not which static
|
|
183
|
+
* file each route came from). One level of mounting only (a router
|
|
184
|
+
* mounted directly on `app` or on another router) — sufficient for the
|
|
185
|
+
* "one router per domain, all mounted in one place" convention this is
|
|
186
|
+
* built around; deeper indirection just falls through to the
|
|
187
|
+
* `pathSegmentsMatch` fallback below. */
|
|
188
|
+
function buildMountPrefixMap(project) {
|
|
189
|
+
const prefixByFilePath = new Map();
|
|
190
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
191
|
+
for (const call of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
192
|
+
const expr = call.getExpression();
|
|
193
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr) || expr.getName() !== "use")
|
|
194
|
+
continue;
|
|
195
|
+
const [prefixArg, routerArg] = call.getArguments();
|
|
196
|
+
if (!prefixArg || !routerArg || !ts_morph_1.Node.isStringLiteral(prefixArg) || !ts_morph_1.Node.isIdentifier(routerArg))
|
|
197
|
+
continue;
|
|
198
|
+
const routerFile = resolveRouterSourceFile(routerArg.getText(), sourceFile);
|
|
199
|
+
if (routerFile)
|
|
200
|
+
prefixByFilePath.set(routerFile.getFilePath(), prefixArg.getLiteralValue());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return prefixByFilePath;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Scope detection (docs/04-capture-engine.md §3a): looks for calls to a
|
|
207
|
+
* configurable set of scope-check function names anywhere inside a route
|
|
208
|
+
* registration call (e.g. `requireScope("admin:read")` passed as one of
|
|
209
|
+
* `app.get(path, ...middlewares, handler)`'s arguments) and extracts the
|
|
210
|
+
* literal string(s) passed to it. Never invents a scope from runtime
|
|
211
|
+
* behavior — if no matching call exists, this returns [].
|
|
212
|
+
*/
|
|
213
|
+
function extractScopes(call, scopePatterns) {
|
|
214
|
+
const scopes = new Set();
|
|
215
|
+
for (const nested of call.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
216
|
+
const name = calleeName(nested);
|
|
217
|
+
if (!name || !scopePatterns.includes(name))
|
|
218
|
+
continue;
|
|
219
|
+
for (const arg of nested.getArguments()) {
|
|
220
|
+
if (ts_morph_1.Node.isStringLiteral(arg)) {
|
|
221
|
+
scopes.add(arg.getLiteralValue());
|
|
222
|
+
}
|
|
223
|
+
else if (ts_morph_1.Node.isArrayLiteralExpression(arg)) {
|
|
224
|
+
for (const element of arg.getElements()) {
|
|
225
|
+
if (ts_morph_1.Node.isStringLiteral(element))
|
|
226
|
+
scopes.add(element.getLiteralValue());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [...scopes].sort();
|
|
232
|
+
}
|
|
233
|
+
/** Extracts the middleware chain from a static route registration's own
|
|
234
|
+
* arguments (e.g. `router.post("/", requireAuth, requireRole("admin"),
|
|
235
|
+
* handler)` -> `["requireAuth", "requireRole"]`), in registration order.
|
|
236
|
+
*
|
|
237
|
+
* Needed because `express-list-endpoints` (7.x) merges endpoints that share
|
|
238
|
+
* a literal path across different HTTP methods by concatenating their
|
|
239
|
+
* `methods` arrays, but silently keeps only the *first-registered* method's
|
|
240
|
+
* own `middlewares` array for the merged entry (see its `addEndpoints`) — a
|
|
241
|
+
* public `GET "/"` registered before a protected `POST "/"` on the same
|
|
242
|
+
* path makes the protected POST inherit GET's *empty* middleware chain.
|
|
243
|
+
* Reading it statically, per registration, sidesteps that upstream bug —
|
|
244
|
+
* this is the actual mechanism the Flowmap tab and `authRequiredGuess`
|
|
245
|
+
* below depend on being correct per method, not just per path.
|
|
246
|
+
*
|
|
247
|
+
* The first argument (the path string) and the last (the handler, always
|
|
248
|
+
* anonymous in every convention this cares about) are dropped; everything
|
|
249
|
+
* between is either a plain identifier (`requireAuth`) or a middleware
|
|
250
|
+
* factory call (`requireRole("admin")`, reported by its callee name). */
|
|
251
|
+
function extractMiddlewareNames(call) {
|
|
252
|
+
const middlewareArgs = call.getArguments().slice(1, -1);
|
|
253
|
+
const names = [];
|
|
254
|
+
for (const arg of middlewareArgs) {
|
|
255
|
+
if (ts_morph_1.Node.isIdentifier(arg)) {
|
|
256
|
+
names.push(arg.getText());
|
|
257
|
+
}
|
|
258
|
+
else if (ts_morph_1.Node.isCallExpression(arg)) {
|
|
259
|
+
const name = calleeName(arg);
|
|
260
|
+
if (name)
|
|
261
|
+
names.push(name);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
/** Walks a Zod builder chain (`z.string().email().describe("x")`) from the
|
|
267
|
+
* outermost call inward until it hits the literal `z.<type>(...)` base —
|
|
268
|
+
* returns null for anything that isn't a chain rooted at the `z` import
|
|
269
|
+
* itself (schema composition via `.extend()`/`.merge()`, a reused variable
|
|
270
|
+
* as the base, `z.union()`/`z.record()`, etc. — all intentionally
|
|
271
|
+
* unsupported rather than guessed at). */
|
|
272
|
+
function unwindZodChain(expr) {
|
|
273
|
+
const modifiers = [];
|
|
274
|
+
let current = expr;
|
|
275
|
+
// eslint-disable-next-line no-constant-condition
|
|
276
|
+
while (true) {
|
|
277
|
+
if (!ts_morph_1.Node.isCallExpression(current))
|
|
278
|
+
return null;
|
|
279
|
+
const callee = current.getExpression();
|
|
280
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(callee))
|
|
281
|
+
return null;
|
|
282
|
+
const method = callee.getName();
|
|
283
|
+
const args = current.getArguments();
|
|
284
|
+
const receiver = callee.getExpression();
|
|
285
|
+
if (ts_morph_1.Node.isIdentifier(receiver) && receiver.getText() === "z") {
|
|
286
|
+
return { base: { method, args }, modifiers: modifiers.reverse() };
|
|
287
|
+
}
|
|
288
|
+
modifiers.push({ method, args });
|
|
289
|
+
current = receiver;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function zodBaseToJsonSchema(base) {
|
|
293
|
+
switch (base.method) {
|
|
294
|
+
case "object": {
|
|
295
|
+
const [shape] = base.args;
|
|
296
|
+
if (!shape || !ts_morph_1.Node.isObjectLiteralExpression(shape))
|
|
297
|
+
return null;
|
|
298
|
+
const properties = {};
|
|
299
|
+
const required = [];
|
|
300
|
+
for (const prop of shape.getProperties()) {
|
|
301
|
+
if (!ts_morph_1.Node.isPropertyAssignment(prop))
|
|
302
|
+
continue;
|
|
303
|
+
const key = prop.getName();
|
|
304
|
+
const valueExpr = prop.getInitializer();
|
|
305
|
+
if (!valueExpr)
|
|
306
|
+
continue;
|
|
307
|
+
const field = zodExpressionToField(valueExpr);
|
|
308
|
+
if (!field)
|
|
309
|
+
continue;
|
|
310
|
+
properties[key] = field.schema;
|
|
311
|
+
if (!field.optional)
|
|
312
|
+
required.push(key);
|
|
313
|
+
}
|
|
314
|
+
return { type: "object", properties, ...(required.length > 0 ? { required } : {}) };
|
|
315
|
+
}
|
|
316
|
+
case "string":
|
|
317
|
+
return { type: "string" };
|
|
318
|
+
case "number":
|
|
319
|
+
return { type: "number" };
|
|
320
|
+
case "boolean":
|
|
321
|
+
return { type: "boolean" };
|
|
322
|
+
case "array": {
|
|
323
|
+
const [itemExpr] = base.args;
|
|
324
|
+
const itemSchema = itemExpr ? zodExpressionToField(itemExpr)?.schema : undefined;
|
|
325
|
+
return itemSchema ? { type: "array", items: itemSchema } : { type: "array" };
|
|
326
|
+
}
|
|
327
|
+
case "enum": {
|
|
328
|
+
const [arr] = base.args;
|
|
329
|
+
if (arr && ts_morph_1.Node.isArrayLiteralExpression(arr)) {
|
|
330
|
+
const values = [];
|
|
331
|
+
for (const el of arr.getElements()) {
|
|
332
|
+
if (ts_morph_1.Node.isStringLiteral(el))
|
|
333
|
+
values.push(el.getLiteralValue());
|
|
334
|
+
}
|
|
335
|
+
if (values.length > 0)
|
|
336
|
+
return { type: "string", enum: values };
|
|
337
|
+
}
|
|
338
|
+
return { type: "string" };
|
|
339
|
+
}
|
|
340
|
+
default:
|
|
341
|
+
return null; // z.date()/z.union()/z.record()/z.any()/... — not modeled
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Applies chained modifier calls on top of a base schema — `.describe()`
|
|
345
|
+
* for the field description this whole pass exists to recover, plus a
|
|
346
|
+
* handful of the most common validators. Anything unrecognized
|
|
347
|
+
* (`.refine()`, `.transform()`, `.default()`, `.strict()`, ...) is silently
|
|
348
|
+
* skipped rather than failing the field. */
|
|
349
|
+
function applyZodModifiers(schema, modifiers) {
|
|
350
|
+
let optional = false;
|
|
351
|
+
const result = { ...schema };
|
|
352
|
+
for (const mod of modifiers) {
|
|
353
|
+
const [arg] = mod.args;
|
|
354
|
+
switch (mod.method) {
|
|
355
|
+
case "describe":
|
|
356
|
+
if (arg && ts_morph_1.Node.isStringLiteral(arg))
|
|
357
|
+
result.description = arg.getLiteralValue();
|
|
358
|
+
break;
|
|
359
|
+
case "optional":
|
|
360
|
+
case "nullish":
|
|
361
|
+
optional = true;
|
|
362
|
+
break;
|
|
363
|
+
case "email":
|
|
364
|
+
result.format = "email";
|
|
365
|
+
break;
|
|
366
|
+
case "uuid":
|
|
367
|
+
result.format = "uuid";
|
|
368
|
+
break;
|
|
369
|
+
case "url":
|
|
370
|
+
result.format = "uri";
|
|
371
|
+
break;
|
|
372
|
+
case "datetime":
|
|
373
|
+
result.format = "date-time";
|
|
374
|
+
break;
|
|
375
|
+
case "int":
|
|
376
|
+
result.type = "integer";
|
|
377
|
+
break;
|
|
378
|
+
case "min":
|
|
379
|
+
if (arg && ts_morph_1.Node.isNumericLiteral(arg)) {
|
|
380
|
+
const n = Number(arg.getText());
|
|
381
|
+
if (result.type === "string")
|
|
382
|
+
result.minLength = n;
|
|
383
|
+
else if (result.type === "number" || result.type === "integer")
|
|
384
|
+
result.minimum = n;
|
|
385
|
+
}
|
|
386
|
+
break;
|
|
387
|
+
case "max":
|
|
388
|
+
if (arg && ts_morph_1.Node.isNumericLiteral(arg)) {
|
|
389
|
+
const n = Number(arg.getText());
|
|
390
|
+
if (result.type === "string")
|
|
391
|
+
result.maxLength = n;
|
|
392
|
+
else if (result.type === "number" || result.type === "integer")
|
|
393
|
+
result.maximum = n;
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
default:
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { schema: result, optional };
|
|
401
|
+
}
|
|
402
|
+
function zodExpressionToField(expr) {
|
|
403
|
+
const chain = unwindZodChain(expr);
|
|
404
|
+
if (!chain)
|
|
405
|
+
return null;
|
|
406
|
+
const base = zodBaseToJsonSchema(chain.base);
|
|
407
|
+
if (!base)
|
|
408
|
+
return null;
|
|
409
|
+
return applyZodModifiers(base, chain.modifiers);
|
|
410
|
+
}
|
|
411
|
+
/** Resolves a bare identifier (`CreateOrderSchema`) back to whatever it was
|
|
412
|
+
* declared as (`const CreateOrderSchema = z.object({...})`), wherever that
|
|
413
|
+
* declaration lives — same file or a different one, ts-morph's own
|
|
414
|
+
* definition-lookup handles cross-file resolution, unlike the narrower
|
|
415
|
+
* hand-rolled import-following `resolveRouterSourceFile` needs elsewhere in
|
|
416
|
+
* this file for a different purpose. */
|
|
417
|
+
function resolveIdentifierInitializer(identifier) {
|
|
418
|
+
if (!ts_morph_1.Node.isIdentifier(identifier))
|
|
419
|
+
return null;
|
|
420
|
+
for (const def of identifier.getDefinitionNodes()) {
|
|
421
|
+
if (ts_morph_1.Node.isVariableDeclaration(def)) {
|
|
422
|
+
const init = def.getInitializer();
|
|
423
|
+
if (init)
|
|
424
|
+
return init;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/** A schema reference is either the Zod expression written inline, or an
|
|
430
|
+
* identifier pointing at one declared elsewhere — tries both. */
|
|
431
|
+
function resolveZodSchema(node) {
|
|
432
|
+
const direct = zodExpressionToField(node)?.schema;
|
|
433
|
+
if (direct)
|
|
434
|
+
return direct;
|
|
435
|
+
const resolved = resolveIdentifierInitializer(node);
|
|
436
|
+
return resolved ? zodExpressionToField(resolved)?.schema ?? null : null;
|
|
437
|
+
}
|
|
438
|
+
/** Finds a request-body Zod schema statically associated with a route
|
|
439
|
+
* registration, trying two conventions in order: (1) a validation-
|
|
440
|
+
* middleware call among the route's own arguments (`validateBody(Schema)`,
|
|
441
|
+
* `zValidator("json", Schema)` — the schema is whichever argument actually
|
|
442
|
+
* resolves, checked last-to-first since the schema is usually the final
|
|
443
|
+
* arg), then (2) `Schema.parse(req.body)`/`.safeParse(...)` called directly
|
|
444
|
+
* inside the handler body. Returns null — never throws, never guesses —
|
|
445
|
+
* when neither convention matches. */
|
|
446
|
+
function findRequestSchemaForRoute(call, validationPatterns) {
|
|
447
|
+
const middlewareArgs = call.getArguments().slice(1, -1);
|
|
448
|
+
for (const arg of middlewareArgs) {
|
|
449
|
+
if (!ts_morph_1.Node.isCallExpression(arg))
|
|
450
|
+
continue;
|
|
451
|
+
const name = calleeName(arg);
|
|
452
|
+
if (!name || !validationPatterns.includes(name))
|
|
453
|
+
continue;
|
|
454
|
+
const schemaArgs = arg.getArguments();
|
|
455
|
+
for (let i = schemaArgs.length - 1; i >= 0; i--) {
|
|
456
|
+
const candidate = schemaArgs[i];
|
|
457
|
+
const resolved = candidate ? resolveZodSchema(candidate) : null;
|
|
458
|
+
if (resolved)
|
|
459
|
+
return resolved;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const handler = call.getArguments().at(-1);
|
|
463
|
+
if (handler && (ts_morph_1.Node.isArrowFunction(handler) || ts_morph_1.Node.isFunctionExpression(handler))) {
|
|
464
|
+
for (const inner of handler.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
465
|
+
const expr = inner.getExpression();
|
|
466
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
467
|
+
continue;
|
|
468
|
+
if (expr.getName() !== "parse" && expr.getName() !== "safeParse")
|
|
469
|
+
continue;
|
|
470
|
+
const resolved = resolveZodSchema(expr.getExpression());
|
|
471
|
+
if (resolved)
|
|
472
|
+
return resolved;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
// ---------------------------------------------------------------------
|
|
478
|
+
// Mongoose model → JSON Schema (docs/04-capture-engine.md Step 2 #3b): the
|
|
479
|
+
// other real-world source of "what does this request body look like" for
|
|
480
|
+
// the very common case where a project validates through nothing more
|
|
481
|
+
// formal than its Mongoose model — the model *is* the closest thing to a
|
|
482
|
+
// declared schema many real Express APIs ever write. Two conventions,
|
|
483
|
+
// tried in order, each a single fixed pattern rather than a general
|
|
484
|
+
// data-flow analysis — same "detect a convention, never guess" bar the Zod
|
|
485
|
+
// path above holds itself to:
|
|
486
|
+
//
|
|
487
|
+
// 1. Direct passthrough — `Model.create(req.body)`, `new Model(req.body)`,
|
|
488
|
+
// `Model.findByIdAndUpdate(id, req.body)`,
|
|
489
|
+
// `Model.findOneAndUpdate(filter, req.body)`,
|
|
490
|
+
// `Model.updateOne(filter, req.body)` — the model's full schema shape
|
|
491
|
+
// wins outright, highest fidelity of the two.
|
|
492
|
+
// 2. Destructure-and-cross-reference — `const { a, b } = req.body;`
|
|
493
|
+
// followed somewhere in the same handler by a call on an identifier
|
|
494
|
+
// that resolves to a Mongoose model (the extremely common "pull named
|
|
495
|
+
// fields off req.body, then build the doc from local variables"
|
|
496
|
+
// style, e.g. `new Model({ a, b, ...stampedFields })` a few lines
|
|
497
|
+
// later) — the schema is restricted to exactly the destructured
|
|
498
|
+
// names, each one's type looked up from that model's own field
|
|
499
|
+
// definitions when the name matches (falling back to a generic
|
|
500
|
+
// string for a name the model doesn't declare, e.g. a
|
|
501
|
+
// request-only/computed field).
|
|
502
|
+
//
|
|
503
|
+
// Both are marked `requestSchemaSource: "inferred"` (docs/03-data-model.md)
|
|
504
|
+
// rather than "declared" like Zod: a Mongoose schema describes the
|
|
505
|
+
// *stored document*, not necessarily the exact accepted request shape.
|
|
506
|
+
// ---------------------------------------------------------------------
|
|
507
|
+
const MONGOOSE_DIRECT_WRITE_METHODS = new Set(["create", "findByIdAndUpdate", "findOneAndUpdate", "updateOne"]);
|
|
508
|
+
function isReqBodyExpression(node) {
|
|
509
|
+
return ts_morph_1.Node.isPropertyAccessExpression(node) && node.getName() === "body" && node.getExpression().getText() === "req";
|
|
510
|
+
}
|
|
511
|
+
/** `mongoose.Schema({...})` / `new mongoose.Schema({...})` / bare
|
|
512
|
+
* `Schema({...})` / `new Schema({...})` — Mongoose supports the factory
|
|
513
|
+
* call and `new` forms interchangeably, and a project may destructure
|
|
514
|
+
* `Schema` off the `mongoose` import instead of qualifying it every time;
|
|
515
|
+
* matched purely by callee name (`.Schema`/`Schema`), same syntactic-only
|
|
516
|
+
* pragmatism `unwindZodChain` already applies to recognizing `z`. */
|
|
517
|
+
function schemaCallShapeArgument(node) {
|
|
518
|
+
if (!ts_morph_1.Node.isCallExpression(node) && !ts_morph_1.Node.isNewExpression(node))
|
|
519
|
+
return null;
|
|
520
|
+
const expr = node.getExpression();
|
|
521
|
+
const name = ts_morph_1.Node.isIdentifier(expr) ? expr.getText() : ts_morph_1.Node.isPropertyAccessExpression(expr) ? expr.getName() : null;
|
|
522
|
+
if (name !== "Schema")
|
|
523
|
+
return null;
|
|
524
|
+
const [shape] = node.getArguments();
|
|
525
|
+
return shape ?? null;
|
|
526
|
+
}
|
|
527
|
+
/** Resolves an identifier back to whatever it was declared as, wherever
|
|
528
|
+
* that declaration lives. TypeScript's own checker (even with plain JS +
|
|
529
|
+
* `allowJs`) already follows both ES `import` bindings AND CommonJS
|
|
530
|
+
* `require()` bindings all the way to the exporting declaration —
|
|
531
|
+
* `const X = require("./path")` + `module.exports = Y`, and a destructured
|
|
532
|
+
* `const { name } = require("./path")` + `exports.name = Y` / `module.
|
|
533
|
+
* exports.name = Y`, both resolve `getDefinitionNodes()` straight to the
|
|
534
|
+
* *exports* property-access itself (confirmed empirically — there's no
|
|
535
|
+
* public API for this, it falls out of the language service's ordinary
|
|
536
|
+
* "go to definition"). This only has to peel back one more layer: take
|
|
537
|
+
* that property access's parent assignment and return its right-hand
|
|
538
|
+
* side, the actual exported value. A `module.exports = { name }` shorthand
|
|
539
|
+
* object literal instead resolves `getDefinitionNodes()` straight to that
|
|
540
|
+
* `ShorthandPropertyAssignment` itself (confirmed empirically, alongside
|
|
541
|
+
* the property-access case above) — one more hop through *its* own name
|
|
542
|
+
* node's definition (an ordinary same-file variable reference) reaches the
|
|
543
|
+
* real declaration. */
|
|
544
|
+
function resolveIdentifierDeclarationInitializer(node) {
|
|
545
|
+
if (!ts_morph_1.Node.isIdentifier(node))
|
|
546
|
+
return null;
|
|
547
|
+
for (const def of node.getDefinitionNodes()) {
|
|
548
|
+
if (ts_morph_1.Node.isVariableDeclaration(def)) {
|
|
549
|
+
const init = def.getInitializer();
|
|
550
|
+
if (init)
|
|
551
|
+
return init;
|
|
552
|
+
}
|
|
553
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(def)) {
|
|
554
|
+
const assignment = def.getParentIfKind(ts_morph_1.SyntaxKind.BinaryExpression);
|
|
555
|
+
if (assignment && assignment.getLeft() === def)
|
|
556
|
+
return assignment.getRight();
|
|
557
|
+
}
|
|
558
|
+
if (ts_morph_1.Node.isShorthandPropertyAssignment(def)) {
|
|
559
|
+
const resolved = resolveIdentifierDeclarationInitializer(def.getNameNode());
|
|
560
|
+
if (resolved)
|
|
561
|
+
return resolved;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
/** Resolves the schema identifier named in an `@response <status> <Name>`
|
|
567
|
+
* tag (see `extractDeclaredResponseSchemas` below) back to its Zod schema,
|
|
568
|
+
* by *name* rather than from an already-found AST reference the way every
|
|
569
|
+
* other resolver in this file works — the tag only gives us plain text
|
|
570
|
+
* pulled out of a comment, not a node. Tries, in order: a same-file
|
|
571
|
+
* `const <Name> = ...`, a named ESM import (`import { Name } from "..."`),
|
|
572
|
+
* and a CommonJS destructured require (`const { Name } = require("...")`) —
|
|
573
|
+
* the same three binding shapes `findMongooseRequestSchemaForRoute`'s
|
|
574
|
+
* cross-reference case already resolves via identifier references, just
|
|
575
|
+
* located here by name first. Returns null (never guesses) when `name`
|
|
576
|
+
* doesn't resolve to a real Zod schema through any of the three. */
|
|
577
|
+
function findSchemaByName(sourceFile, name) {
|
|
578
|
+
// Deliberately a descendant search, not `sourceFile.getVariableDeclaration`/
|
|
579
|
+
// `getVariableDeclarations()` — those only ever see declarations directly
|
|
580
|
+
// at the file's top level, missing the extremely common real-world case
|
|
581
|
+
// of a schema declared inside a `createApp()`-style factory function
|
|
582
|
+
// (this project's own demo app and CLI-generated scaffold both use
|
|
583
|
+
// exactly that shape) — confirmed empirically, not an assumption.
|
|
584
|
+
const allVarDecls = sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration);
|
|
585
|
+
for (const varDecl of allVarDecls) {
|
|
586
|
+
const nameNode = varDecl.getNameNode();
|
|
587
|
+
if (ts_morph_1.Node.isIdentifier(nameNode) && nameNode.getText() === name) {
|
|
588
|
+
const init = varDecl.getInitializer();
|
|
589
|
+
const schema = init ? zodExpressionToField(init)?.schema : undefined;
|
|
590
|
+
if (schema)
|
|
591
|
+
return schema;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
595
|
+
const named = imp.getNamedImports().find((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === name);
|
|
596
|
+
if (!named)
|
|
597
|
+
continue;
|
|
598
|
+
const resolved = resolveIdentifierDeclarationInitializer(named.getAliasNode() ?? named.getNameNode());
|
|
599
|
+
const schema = resolved ? zodExpressionToField(resolved)?.schema : undefined;
|
|
600
|
+
if (schema)
|
|
601
|
+
return schema;
|
|
602
|
+
}
|
|
603
|
+
for (const varDecl of allVarDecls) {
|
|
604
|
+
const nameNode = varDecl.getNameNode();
|
|
605
|
+
if (!ts_morph_1.Node.isObjectBindingPattern(nameNode))
|
|
606
|
+
continue;
|
|
607
|
+
for (const element of nameNode.getElements()) {
|
|
608
|
+
if (element.getName() !== name)
|
|
609
|
+
continue;
|
|
610
|
+
const resolved = resolveIdentifierDeclarationInitializer(element.getNameNode());
|
|
611
|
+
const schema = resolved ? zodExpressionToField(resolved)?.schema : undefined;
|
|
612
|
+
if (schema)
|
|
613
|
+
return schema;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
/** A single Mongoose schema field's type reference — `String`, `Number`,
|
|
619
|
+
* `mongoose.Schema.Types.ObjectId`/`Schema.Types.ObjectId` (a Mongo
|
|
620
|
+
* document reference, documented as a plain string id), or an array of
|
|
621
|
+
* either (`[String]`, `[{...subdocument...}]`). Anything else (a custom
|
|
622
|
+
* class, `Schema.Types.Mixed`, a getter/setter function) falls back to an
|
|
623
|
+
* untyped field rather than guessing. */
|
|
624
|
+
function mongooseTypeRefToJsonSchema(expr) {
|
|
625
|
+
if (ts_morph_1.Node.isArrayLiteralExpression(expr)) {
|
|
626
|
+
const [item] = expr.getElements();
|
|
627
|
+
return item ? { type: "array", items: mongooseFieldValueToJsonSchema(item).schema } : { type: "array" };
|
|
628
|
+
}
|
|
629
|
+
const text = expr.getText();
|
|
630
|
+
if (/\bObjectId\b/.test(text))
|
|
631
|
+
return { type: "string" };
|
|
632
|
+
switch (text) {
|
|
633
|
+
case "String":
|
|
634
|
+
return { type: "string" };
|
|
635
|
+
case "Number":
|
|
636
|
+
return { type: "number" };
|
|
637
|
+
case "Boolean":
|
|
638
|
+
return { type: "boolean" };
|
|
639
|
+
case "Date":
|
|
640
|
+
return { type: "string", format: "date-time" };
|
|
641
|
+
case "Buffer":
|
|
642
|
+
return { type: "string", format: "binary" };
|
|
643
|
+
default:
|
|
644
|
+
return {};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Converts one schema-shape object literal (`{ field: String, other: {
|
|
648
|
+
* type: Number, required: true }, nested: { city: String } }`) into JSON
|
|
649
|
+
* Schema — the single recursive step both the top-level schema and any
|
|
650
|
+
* nested subdocument share. A property's value is one of: a full field
|
|
651
|
+
* definition (an object literal that itself has a `type` key), a bare
|
|
652
|
+
* type reference (identifier/array/property-access), or — when the object
|
|
653
|
+
* literal has no `type` key at all — a nested subdocument, recursed into
|
|
654
|
+
* as its own schema shape. */
|
|
655
|
+
function mongooseSchemaShapeToJsonSchema(shape) {
|
|
656
|
+
if (!ts_morph_1.Node.isObjectLiteralExpression(shape))
|
|
657
|
+
return { type: "object" };
|
|
658
|
+
const properties = {};
|
|
659
|
+
const required = [];
|
|
660
|
+
for (const prop of shape.getProperties()) {
|
|
661
|
+
if (!ts_morph_1.Node.isPropertyAssignment(prop))
|
|
662
|
+
continue;
|
|
663
|
+
const key = prop.getName();
|
|
664
|
+
const valueExpr = prop.getInitializer();
|
|
665
|
+
if (!valueExpr)
|
|
666
|
+
continue;
|
|
667
|
+
const field = mongooseFieldValueToJsonSchema(valueExpr);
|
|
668
|
+
properties[key] = field.schema;
|
|
669
|
+
if (field.required)
|
|
670
|
+
required.push(key);
|
|
671
|
+
}
|
|
672
|
+
return { type: "object", properties, ...(required.length > 0 ? { required } : {}) };
|
|
673
|
+
}
|
|
674
|
+
function mongooseFieldValueToJsonSchema(valueExpr) {
|
|
675
|
+
if (ts_morph_1.Node.isObjectLiteralExpression(valueExpr)) {
|
|
676
|
+
const typeProp = valueExpr.getProperty("type");
|
|
677
|
+
if (!typeProp || !ts_morph_1.Node.isPropertyAssignment(typeProp)) {
|
|
678
|
+
// No "type" key at all -> a nested subdocument, not a field definition.
|
|
679
|
+
return { schema: mongooseSchemaShapeToJsonSchema(valueExpr), required: false };
|
|
680
|
+
}
|
|
681
|
+
const typeExpr = typeProp.getInitializer();
|
|
682
|
+
const schema = typeExpr ? mongooseTypeRefToJsonSchema(typeExpr) : {};
|
|
683
|
+
const enumProp = valueExpr.getProperty("enum");
|
|
684
|
+
if (enumProp && ts_morph_1.Node.isPropertyAssignment(enumProp)) {
|
|
685
|
+
const enumInit = enumProp.getInitializer();
|
|
686
|
+
if (enumInit && ts_morph_1.Node.isArrayLiteralExpression(enumInit)) {
|
|
687
|
+
const values = enumInit
|
|
688
|
+
.getElements()
|
|
689
|
+
.filter(ts_morph_1.Node.isStringLiteral)
|
|
690
|
+
.map((el) => el.getLiteralValue());
|
|
691
|
+
if (values.length > 0)
|
|
692
|
+
schema.enum = values;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const requiredProp = valueExpr.getProperty("required");
|
|
696
|
+
let required = false;
|
|
697
|
+
if (requiredProp && ts_morph_1.Node.isPropertyAssignment(requiredProp)) {
|
|
698
|
+
const requiredInit = requiredProp.getInitializer();
|
|
699
|
+
if (requiredInit) {
|
|
700
|
+
// `required: true` or `required: [true, "message"]` (Mongoose's
|
|
701
|
+
// own "required with a custom message" shorthand) — either way,
|
|
702
|
+
// only the leading boolean literal matters here.
|
|
703
|
+
const boolNode = ts_morph_1.Node.isArrayLiteralExpression(requiredInit) ? requiredInit.getElements()[0] : requiredInit;
|
|
704
|
+
required = boolNode?.getKind() === ts_morph_1.SyntaxKind.TrueKeyword;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { schema, required };
|
|
708
|
+
}
|
|
709
|
+
return { schema: mongooseTypeRefToJsonSchema(valueExpr), required: false };
|
|
710
|
+
}
|
|
711
|
+
/** Resolves a model identifier (`customerModel` in `import customerModel
|
|
712
|
+
* from "../models/customerModel.js"`) back to the Mongoose schema shape it
|
|
713
|
+
* was declared with — `const customerModel = mongoose.model("customer",
|
|
714
|
+
* customerSchema)`, then one further hop if the schema itself was
|
|
715
|
+
* assigned to its own variable first (`const customerSchema =
|
|
716
|
+
* mongoose.Schema({...})`) rather than passed inline. Returns null at any
|
|
717
|
+
* unresolved step (an unrecognized export/declaration style, a model
|
|
718
|
+
* defined via a factory function, ...) rather than guessing. */
|
|
719
|
+
function resolveMongooseModelSchemaShape(modelIdentifier) {
|
|
720
|
+
const modelInit = resolveIdentifierDeclarationInitializer(modelIdentifier);
|
|
721
|
+
if (!modelInit || !ts_morph_1.Node.isCallExpression(modelInit))
|
|
722
|
+
return null;
|
|
723
|
+
if (calleeName(modelInit) !== "model")
|
|
724
|
+
return null;
|
|
725
|
+
const [, schemaArg] = modelInit.getArguments();
|
|
726
|
+
if (!schemaArg)
|
|
727
|
+
return null;
|
|
728
|
+
const inlineShape = schemaCallShapeArgument(schemaArg);
|
|
729
|
+
if (inlineShape)
|
|
730
|
+
return inlineShape;
|
|
731
|
+
const resolvedSchemaExpr = resolveIdentifierDeclarationInitializer(schemaArg);
|
|
732
|
+
return resolvedSchemaExpr ? schemaCallShapeArgument(resolvedSchemaExpr) : null;
|
|
733
|
+
}
|
|
734
|
+
/** Resolves the last argument of a route registration (the handler) to the
|
|
735
|
+
* function body this pass actually searches — following one cross-file
|
|
736
|
+
* hop when the handler is a bare identifier imported from a controller
|
|
737
|
+
* file (`import { addCustomer } from "../controllers/customerController.js"`,
|
|
738
|
+
* `router.post("/add", addCustomer)` — the dominant real-world convention,
|
|
739
|
+
* routes referencing named controller exports rather than inlining
|
|
740
|
+
* handlers), then unwrapping exactly one layer of HOC-wrapping
|
|
741
|
+
* (`expressAsyncHandler(async (req, res) => {...})`,
|
|
742
|
+
* `express-async-handler`'s own convention and others like it) to reach
|
|
743
|
+
* the actual function body. */
|
|
744
|
+
function resolveHandlerFunctionBody(handlerExpr) {
|
|
745
|
+
let target = handlerExpr;
|
|
746
|
+
if (ts_morph_1.Node.isIdentifier(handlerExpr)) {
|
|
747
|
+
target = resolveIdentifierDeclarationInitializer(handlerExpr);
|
|
748
|
+
}
|
|
749
|
+
if (target && ts_morph_1.Node.isCallExpression(target)) {
|
|
750
|
+
const inner = target.getArguments().find((a) => ts_morph_1.Node.isArrowFunction(a) || ts_morph_1.Node.isFunctionExpression(a));
|
|
751
|
+
if (inner)
|
|
752
|
+
target = inner;
|
|
753
|
+
}
|
|
754
|
+
return target && (ts_morph_1.Node.isArrowFunction(target) || ts_morph_1.Node.isFunctionExpression(target)) ? target : null;
|
|
755
|
+
}
|
|
756
|
+
/** Convention 1 — a call anywhere in the handler body passing `req.body`
|
|
757
|
+
* straight into a recognized Mongoose write method, on a receiver that
|
|
758
|
+
* resolves to an actual Mongoose model. */
|
|
759
|
+
function findDirectPassthroughSchema(handlerBody) {
|
|
760
|
+
for (const call of handlerBody.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
761
|
+
const expr = call.getExpression();
|
|
762
|
+
const args = call.getArguments();
|
|
763
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr) && MONGOOSE_DIRECT_WRITE_METHODS.has(expr.getName())) {
|
|
764
|
+
const bodyArg = expr.getName() === "create" ? args[0] : args[1];
|
|
765
|
+
if (!bodyArg || !isReqBodyExpression(bodyArg))
|
|
766
|
+
continue;
|
|
767
|
+
const shape = ts_morph_1.Node.isIdentifier(expr.getExpression()) ? resolveMongooseModelSchemaShape(expr.getExpression()) : null;
|
|
768
|
+
if (shape)
|
|
769
|
+
return mongooseSchemaShapeToJsonSchema(shape);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const newExpr of handlerBody.getDescendantsOfKind(ts_morph_1.SyntaxKind.NewExpression)) {
|
|
773
|
+
const callee = newExpr.getExpression();
|
|
774
|
+
const [arg] = newExpr.getArguments();
|
|
775
|
+
if (!arg || !isReqBodyExpression(arg) || !ts_morph_1.Node.isIdentifier(callee))
|
|
776
|
+
continue;
|
|
777
|
+
const shape = resolveMongooseModelSchemaShape(callee);
|
|
778
|
+
if (shape)
|
|
779
|
+
return mongooseSchemaShapeToJsonSchema(shape);
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
/** Convention 2 — `const { a, b } = req.body;` plus some other
|
|
784
|
+
* model-resolving identifier called anywhere in the same handler,
|
|
785
|
+
* restricting the model's full schema down to just the destructured
|
|
786
|
+
* field names. */
|
|
787
|
+
function findDestructuredCrossReferencedSchema(handlerBody) {
|
|
788
|
+
let destructuredNames = null;
|
|
789
|
+
for (const stmt of handlerBody.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
|
|
790
|
+
const nameNode = stmt.getNameNode();
|
|
791
|
+
const init = stmt.getInitializer();
|
|
792
|
+
if (init && isReqBodyExpression(init) && ts_morph_1.Node.isObjectBindingPattern(nameNode)) {
|
|
793
|
+
destructuredNames = nameNode.getElements().map((el) => (el.getPropertyNameNode() ?? el.getNameNode()).getText());
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (!destructuredNames || destructuredNames.length === 0)
|
|
798
|
+
return null;
|
|
799
|
+
for (const call of handlerBody.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
800
|
+
const expr = call.getExpression();
|
|
801
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr) || !ts_morph_1.Node.isIdentifier(expr.getExpression()))
|
|
802
|
+
continue;
|
|
803
|
+
const shape = resolveMongooseModelSchemaShape(expr.getExpression());
|
|
804
|
+
if (!shape)
|
|
805
|
+
continue;
|
|
806
|
+
const fullSchema = mongooseSchemaShapeToJsonSchema(shape);
|
|
807
|
+
const fullProperties = fullSchema.properties ?? {};
|
|
808
|
+
const fullRequired = new Set(fullSchema.required ?? []);
|
|
809
|
+
const properties = {};
|
|
810
|
+
const required = [];
|
|
811
|
+
for (const name of destructuredNames) {
|
|
812
|
+
properties[name] = fullProperties[name] ?? { type: "string" };
|
|
813
|
+
if (fullRequired.has(name))
|
|
814
|
+
required.push(name);
|
|
815
|
+
}
|
|
816
|
+
return { type: "object", properties, ...(required.length > 0 ? { required } : {}) };
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
/** Finds a request-body schema inferred from a Mongoose model, when the
|
|
821
|
+
* Zod path above found nothing — see the two conventions documented
|
|
822
|
+
* above. Never throws, never guesses; null when neither matches. */
|
|
823
|
+
function findMongooseRequestSchemaForRoute(call) {
|
|
824
|
+
const handler = call.getArguments().at(-1);
|
|
825
|
+
if (!handler)
|
|
826
|
+
return null;
|
|
827
|
+
const handlerBody = resolveHandlerFunctionBody(handler);
|
|
828
|
+
if (!handlerBody)
|
|
829
|
+
return null;
|
|
830
|
+
return findDirectPassthroughSchema(handlerBody) ?? findDestructuredCrossReferencedSchema(handlerBody);
|
|
831
|
+
}
|
|
832
|
+
/** Shared by `extractSummary`/`extractExplicitGroup` below — the leading
|
|
833
|
+
* JSDoc/comment above a route registration statement, stripped of comment
|
|
834
|
+
* syntax (`/** */ `, `; //`, leading ` * `), or null if there isn't one. */
|
|
835
|
+
function getCleanedLeadingComment(call) {
|
|
836
|
+
const statement = call.getParentIfKind(ts_morph_1.SyntaxKind.ExpressionStatement) ?? call;
|
|
837
|
+
const ranges = statement.getLeadingCommentRanges();
|
|
838
|
+
if (ranges.length === 0)
|
|
839
|
+
return null;
|
|
840
|
+
const text = ranges[ranges.length - 1].getText();
|
|
841
|
+
const cleaned = text
|
|
842
|
+
.replace(/^\/\*\*?|\*\/$/g, "")
|
|
843
|
+
.replace(/^\/\/\s?/, "")
|
|
844
|
+
.replace(/^\s*\*\s?/gm, "")
|
|
845
|
+
.trim();
|
|
846
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
847
|
+
}
|
|
848
|
+
/** A route's leading comment is often NOT written for Vayo at all — a
|
|
849
|
+
* TODO, a workaround explanation, a lint-disable justification — and any
|
|
850
|
+
* such comment could otherwise accidentally contain text that *looks* like
|
|
851
|
+
* a `@group`/`@deprecated` tag ("routes moved out of the old @group of
|
|
852
|
+
* helpers", "the @deprecated flag was removed from the validator") without
|
|
853
|
+
* meaning to declare anything. Since those two tags carry real behavioral
|
|
854
|
+
* weight (they lock the endpoint's folder placement / deprecation status
|
|
855
|
+
* against being changed in the UI — docs/04-capture-engine.md Step 2
|
|
856
|
+
* #4/#4a), misreading an unrelated comment as one of them would be a real,
|
|
857
|
+
* silent correctness problem, not just a cosmetic one the way a wrong
|
|
858
|
+
* `summary` guess would be.
|
|
859
|
+
*
|
|
860
|
+
* A bare `@vayo` line anywhere in the comment is the required opt-in
|
|
861
|
+
* signal — the same role `@swagger`/`@openapi` play in swagger-jsdoc,
|
|
862
|
+
* marking a comment block as deliberately written for API-doc annotation
|
|
863
|
+
* rather than incidentally sitting above a route registration. Without it,
|
|
864
|
+
* `@group`/`@deprecated` are never parsed, no matter what text appears —
|
|
865
|
+
* they're just prose, exactly as if the tag characters weren't there at
|
|
866
|
+
* all. This gate does NOT apply to the plain-text summary itself (see
|
|
867
|
+
* `extractSummary`): a summary being "whatever the nearest comment says" is
|
|
868
|
+
* the existing, zero-annotation-required M1 behavior and carries no
|
|
869
|
+
* locking behavior, so there's nothing to guard there. */
|
|
870
|
+
function hasVayoDocSentinel(cleaned) {
|
|
871
|
+
return cleaned.split("\n").some((line) => /^@vayo\b/i.test(line.trim()));
|
|
872
|
+
}
|
|
873
|
+
/** JSDoc/leading-comment above a route registration statement, if any —
|
|
874
|
+
* higher fidelity than nothing, never required (docs/04-capture-engine.md
|
|
875
|
+
* Step 2 #6). Absent for the demo app on purpose: the M1 done-when bar
|
|
876
|
+
* requires zero annotations in demo-app's own code. Strips out any
|
|
877
|
+
* `@vayo`/`@group`/`@deprecated` tag lines (see `hasVayoDocSentinel`/
|
|
878
|
+
* `extractExplicitGroup`/`extractDeprecated` below) — same
|
|
879
|
+
* description-vs-structured-tags split swagger-jsdoc itself uses, so a tag
|
|
880
|
+
* doesn't show up twice (once as this summary, once as its own structured
|
|
881
|
+
* field). */
|
|
882
|
+
function extractSummary(call) {
|
|
883
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
884
|
+
if (!cleaned)
|
|
885
|
+
return null;
|
|
886
|
+
const lines = cleaned.split("\n");
|
|
887
|
+
const descriptionRange = findDescriptionBlockRange(lines);
|
|
888
|
+
const withoutTags = lines
|
|
889
|
+
.filter((line, i) => {
|
|
890
|
+
if (/^@(vayo|group|deprecated|response|example|description)\b/i.test(line.trim()))
|
|
891
|
+
return false;
|
|
892
|
+
// A multi-line @description block's continuation lines don't start
|
|
893
|
+
// with "@" themselves, so the filter above alone wouldn't catch them
|
|
894
|
+
// — they'd otherwise leak into summary too, duplicating the text.
|
|
895
|
+
if (descriptionRange && i > descriptionRange.start && i < descriptionRange.end)
|
|
896
|
+
return false;
|
|
897
|
+
return true;
|
|
898
|
+
})
|
|
899
|
+
.join("\n")
|
|
900
|
+
.trim();
|
|
901
|
+
return withoutTags.length > 0 ? withoutTags : null;
|
|
902
|
+
}
|
|
903
|
+
/** Line range (inclusive start, exclusive end) of a multi-line `@description`
|
|
904
|
+
* block within a cleaned comment's lines — from the `@description` line
|
|
905
|
+
* itself through the line before the next `@`-tag, or the comment's end.
|
|
906
|
+
* Shared by `extractSummary` (to exclude the block's continuation lines,
|
|
907
|
+
* which don't start with `@` and so wouldn't otherwise be filtered as tag
|
|
908
|
+
* lines) and `extractDescription` (to capture it). */
|
|
909
|
+
function findDescriptionBlockRange(lines) {
|
|
910
|
+
const start = lines.findIndex((line) => /^@description\b/i.test(line.trim()));
|
|
911
|
+
if (start === -1)
|
|
912
|
+
return null;
|
|
913
|
+
let end = lines.length;
|
|
914
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
915
|
+
if (/^@\w+/.test(lines[i].trim())) {
|
|
916
|
+
end = i;
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return { start, end };
|
|
921
|
+
}
|
|
922
|
+
/** Explicit `@description` tag in the same leading comment `extractSummary`
|
|
923
|
+
* reads — OpenAPI's own standard Operation Object distinguishes a short
|
|
924
|
+
* `summary` (one-liner) from a longer `description` (can be multiple
|
|
925
|
+
* paragraphs, markdown-supported); Vayo's zero-annotation `summary`
|
|
926
|
+
* extraction only ever produces the former. Unlike `@group`/`@deprecated`/
|
|
927
|
+
* `@response`/`@example`, the tag's own text can continue across multiple
|
|
928
|
+
* lines — everything from right after `@description` on its own line
|
|
929
|
+
* through the line before the next recognized `@`-tag (or the comment's
|
|
930
|
+
* end) is joined together, e.g.:
|
|
931
|
+
* ```
|
|
932
|
+
* * @description
|
|
933
|
+
* * Returns the full order record including line items.
|
|
934
|
+
* * Use `include=customer` to also embed customer info.
|
|
935
|
+
* ```
|
|
936
|
+
* Only recognized inside a comment carrying the `@vayo` sentinel
|
|
937
|
+
* (`hasVayoDocSentinel`) — same reasoning as every other structured tag
|
|
938
|
+
* here: an incidental "see the @description field" sentence elsewhere
|
|
939
|
+
* shouldn't silently become this endpoint's documented description. */
|
|
940
|
+
function extractDescription(call) {
|
|
941
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
942
|
+
if (!cleaned || !hasVayoDocSentinel(cleaned))
|
|
943
|
+
return null;
|
|
944
|
+
const lines = cleaned.split("\n");
|
|
945
|
+
const range = findDescriptionBlockRange(lines);
|
|
946
|
+
if (!range)
|
|
947
|
+
return null;
|
|
948
|
+
const firstLine = lines[range.start].trim().replace(/^@description\s*/i, "");
|
|
949
|
+
const rest = lines.slice(range.start + 1, range.end).map((line) => line.trim());
|
|
950
|
+
const combined = [firstLine, ...rest].join("\n").trim();
|
|
951
|
+
return combined.length > 0 ? combined : null;
|
|
952
|
+
}
|
|
953
|
+
/** Explicit `@group <name>` tag in the same leading comment `extractSummary`
|
|
954
|
+
* reads — swagger-jsdoc's own convention (e.g. `@group Orders - order
|
|
955
|
+
* management`) for declaring a route's documentation grouping directly in
|
|
956
|
+
* code, rather than leaving it to the `routes/` file-layout convention or a
|
|
957
|
+
* guessed URL segment (docs/04-capture-engine.md Step 2 #4). A trailing
|
|
958
|
+
* `- description` some tools also allow after the name is trimmed off,
|
|
959
|
+
* since that's redundant with `extractSummary`'s own result. Nested groups
|
|
960
|
+
* follow the same "/"-separated convention `inferGroup` uses, e.g.
|
|
961
|
+
* `@group Admin/Users`. Only recognized inside a comment carrying the
|
|
962
|
+
* `@vayo` sentinel (`hasVayoDocSentinel`) — otherwise this text is treated
|
|
963
|
+
* as ordinary prose, never a declaration. */
|
|
964
|
+
function extractExplicitGroup(call) {
|
|
965
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
966
|
+
if (!cleaned || !hasVayoDocSentinel(cleaned))
|
|
967
|
+
return null;
|
|
968
|
+
for (const line of cleaned.split("\n")) {
|
|
969
|
+
const match = line.trim().match(/^@group\s+([^-]+)/i);
|
|
970
|
+
if (match) {
|
|
971
|
+
const name = match[1].trim();
|
|
972
|
+
return name.length > 0 ? name : null;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
/** Bare `@deprecated` tag in the same leading comment `extractSummary`
|
|
978
|
+
* reads — the OpenAPI/swagger-jsdoc convention for marking a specific
|
|
979
|
+
* route deprecated in code, independent of the whole API *version*'s own
|
|
980
|
+
* lifecycle (`ApiVersionDoc.status`, `07-api-versioning.md`): a route can
|
|
981
|
+
* be deprecated while the version it belongs to is still fully active.
|
|
982
|
+
* Unlike `@group`, this tag carries no value of its own — its mere
|
|
983
|
+
* presence is the signal, matching how `@deprecated` is used bare in both
|
|
984
|
+
* conventions, so the whole line must be nothing else (a stray "the
|
|
985
|
+
* @deprecated tag needs cleanup" TODO elsewhere in a legitimately
|
|
986
|
+
* `@vayo`-tagged block still shouldn't flip this on). Only recognized
|
|
987
|
+
* inside a comment carrying the `@vayo` sentinel — see
|
|
988
|
+
* `extractExplicitGroup`'s own comment for why that gate exists at all. */
|
|
989
|
+
function extractDeprecated(call) {
|
|
990
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
991
|
+
if (!cleaned || !hasVayoDocSentinel(cleaned))
|
|
992
|
+
return false;
|
|
993
|
+
return cleaned.split("\n").some((line) => /^@deprecated$/i.test(line.trim()));
|
|
994
|
+
}
|
|
995
|
+
/** Explicit `@response <status> <SchemaName>` tag(s) in the same leading
|
|
996
|
+
* comment `extractSummary` reads — declares a route's response body shape
|
|
997
|
+
* for a given status code by pointing at an existing Zod schema identifier
|
|
998
|
+
* (`findSchemaByName` above), the mirror of how `findRequestSchemaForRoute`
|
|
999
|
+
* traces a *request* body from a Zod schema — just declared explicitly
|
|
1000
|
+
* rather than found at a fixed call-site convention, since there's no
|
|
1001
|
+
* equivalent "always the last argument to X" shape for a response the way
|
|
1002
|
+
* there is for request-validation middleware. One line per status code,
|
|
1003
|
+
* e.g. `@response 200 OrderSchema` / `@response 404 ErrorSchema` — multiple
|
|
1004
|
+
* lines declare multiple statuses. A name that doesn't resolve to a real
|
|
1005
|
+
* Zod schema anywhere in scope is silently skipped for that line, never
|
|
1006
|
+
* guessed. Only recognized inside a comment carrying the `@vayo` sentinel —
|
|
1007
|
+
* see `extractExplicitGroup`'s comment for why that gate exists at all. */
|
|
1008
|
+
function extractDeclaredResponseSchemas(call) {
|
|
1009
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
1010
|
+
const result = {};
|
|
1011
|
+
if (!cleaned || !hasVayoDocSentinel(cleaned))
|
|
1012
|
+
return result;
|
|
1013
|
+
const sourceFile = call.getSourceFile();
|
|
1014
|
+
for (const line of cleaned.split("\n")) {
|
|
1015
|
+
const match = line.trim().match(/^@response\s+(\d{3})\s+([A-Za-z_$][\w$]*)\s*$/i);
|
|
1016
|
+
if (!match)
|
|
1017
|
+
continue;
|
|
1018
|
+
const [, status, name] = match;
|
|
1019
|
+
const schema = findSchemaByName(sourceFile, name);
|
|
1020
|
+
if (schema)
|
|
1021
|
+
result[status] = schema;
|
|
1022
|
+
}
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
/** Explicit `@example <status> <JSON>` tag(s) in the same leading comment —
|
|
1026
|
+
* a literal example response value for a given status code, declared
|
|
1027
|
+
* directly in code rather than captured from real traffic or pinned by a
|
|
1028
|
+
* team member from Try It Now (docs/03-data-model.md `vayo_examples`).
|
|
1029
|
+
* One line per status code, e.g. `@example 200 {"id":"abc","total":42}` —
|
|
1030
|
+
* single-line JSON only (no multi-line objects), to keep parsing this out
|
|
1031
|
+
* of a free-text comment unambiguous. Invalid JSON on a matching line is
|
|
1032
|
+
* silently skipped for that line, never thrown — same "never guess" bar
|
|
1033
|
+
* every other tag here holds itself to. Only recognized inside a comment
|
|
1034
|
+
* carrying the `@vayo` sentinel. */
|
|
1035
|
+
function extractDeclaredExamples(call) {
|
|
1036
|
+
const cleaned = getCleanedLeadingComment(call);
|
|
1037
|
+
const result = {};
|
|
1038
|
+
if (!cleaned || !hasVayoDocSentinel(cleaned))
|
|
1039
|
+
return result;
|
|
1040
|
+
for (const line of cleaned.split("\n")) {
|
|
1041
|
+
const match = line.trim().match(/^@example\s+(\d{3})\s+(.+)$/i);
|
|
1042
|
+
if (!match)
|
|
1043
|
+
continue;
|
|
1044
|
+
const [, status, json] = match;
|
|
1045
|
+
try {
|
|
1046
|
+
result[status] = JSON.parse(json);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
// not valid JSON — skip rather than guess
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Runs the static pass against a bootstrapped instance of the user's app.
|
|
1056
|
+
*
|
|
1057
|
+
* `config.appEntryPath` (resolved relative to `rootDir`) must be a module
|
|
1058
|
+
* loadable via dynamic `import()` that exports a already-configured Express
|
|
1059
|
+
* app — either `export default app` or `export const app`. It should be the
|
|
1060
|
+
* user's plain app (no Vayo middleware mounted), so express-list-endpoints
|
|
1061
|
+
* reports only the user's own routes/middleware — see apps/demo-app for the
|
|
1062
|
+
* create-app-vs-start-server split this implies.
|
|
1063
|
+
*
|
|
1064
|
+
* Async (unlike the synchronous signature sketched in
|
|
1065
|
+
* docs/08-packages-and-repo-structure.md) because bootstrapping a live app
|
|
1066
|
+
* instance is inherently a dynamic import — there's no way to do that
|
|
1067
|
+
* synchronously in an ESM-resolution world.
|
|
1068
|
+
*/
|
|
1069
|
+
async function scanProject(rootDir, config) {
|
|
1070
|
+
const entryAbsPath = node_path_1.default.resolve(rootDir, config.appEntryPath);
|
|
1071
|
+
const mod = (await import((0, node_url_1.pathToFileURL)(entryAbsPath).href));
|
|
1072
|
+
const app = unwrapApp(mod);
|
|
1073
|
+
if (!app) {
|
|
1074
|
+
throw new Error(`@vayo-hq/ast: ${config.appEntryPath} must export a bootstrapped Express app as "export default app" or "export const app"`);
|
|
1075
|
+
}
|
|
1076
|
+
const authPatterns = [...exports.DEFAULT_AUTH_MIDDLEWARE_PATTERNS, ...(config.authMiddlewarePatterns ?? [])];
|
|
1077
|
+
const scopePatterns = [...exports.DEFAULT_SCOPE_CHECK_PATTERNS, ...(config.scopeCheckPatterns ?? [])];
|
|
1078
|
+
const validationPatterns = [...exports.DEFAULT_VALIDATION_MIDDLEWARE_PATTERNS, ...(config.validationMiddlewarePatterns ?? [])];
|
|
1079
|
+
const endpoints = (0, express_list_endpoints_1.default)(app);
|
|
1080
|
+
// Route registrations (and the scope-check/JSDoc calls inside them) live
|
|
1081
|
+
// wherever the user actually wrote `app.get(...)` — often not the entry
|
|
1082
|
+
// file itself, which may just import and call a `createApp()` factory
|
|
1083
|
+
// (as apps/demo-app does). Resolve the entry's own module graph so every
|
|
1084
|
+
// file it imports gets searched too, not just the entry file.
|
|
1085
|
+
//
|
|
1086
|
+
// `allowJs` is required, not optional, here — without it TypeScript's own
|
|
1087
|
+
// module resolution (which `resolveSourceFileDependencies` relies on)
|
|
1088
|
+
// silently refuses to follow imports into plain `.js` files, so a project
|
|
1089
|
+
// written in JavaScript rather than TypeScript would only ever see its
|
|
1090
|
+
// single entry file and nothing it imports. `vayo.config.js` itself (the
|
|
1091
|
+
// CLI's own config format) is plain JS specifically so it doesn't need a
|
|
1092
|
+
// TS loader — this has to work for a project written the same way.
|
|
1093
|
+
const project = new ts_morph_1.Project({
|
|
1094
|
+
skipAddingFilesFromTsConfig: true,
|
|
1095
|
+
useInMemoryFileSystem: false,
|
|
1096
|
+
compilerOptions: { allowJs: true },
|
|
1097
|
+
});
|
|
1098
|
+
project.addSourceFileAtPath(entryAbsPath);
|
|
1099
|
+
project.resolveSourceFileDependencies();
|
|
1100
|
+
const registrations = project.getSourceFiles().flatMap((sourceFile) => findRouteRegistrations(sourceFile));
|
|
1101
|
+
const mountPrefixByFilePath = buildMountPrefixMap(project);
|
|
1102
|
+
// Two-pass match per (method, runtime path): first the exact match once
|
|
1103
|
+
// each registration's own router-mount prefix (if any) is resolved and
|
|
1104
|
+
// joined on — this is what makes `express.Router()` composition resolve
|
|
1105
|
+
// correctly, including a router's own root path. Falls back to the
|
|
1106
|
+
// looser segment-suffix heuristic only when no registration resolves to
|
|
1107
|
+
// an exact match (an unrecognized composition style, e.g. re-exported or
|
|
1108
|
+
// indirectly-referenced routers `buildMountPrefixMap` couldn't trace).
|
|
1109
|
+
function findRegistration(method, runtimePath) {
|
|
1110
|
+
const sameMethod = registrations.filter((r) => r.method === method);
|
|
1111
|
+
const exact = sameMethod.find((r) => {
|
|
1112
|
+
const prefix = mountPrefixByFilePath.get(r.call.getSourceFile().getFilePath()) ?? "";
|
|
1113
|
+
return joinMountedPath(prefix, r.pathTemplate) === runtimePath;
|
|
1114
|
+
});
|
|
1115
|
+
if (exact)
|
|
1116
|
+
return exact;
|
|
1117
|
+
const suffixMatches = sameMethod.filter((r) => pathSegmentsMatch(r.pathTemplate, runtimePath));
|
|
1118
|
+
suffixMatches.sort((a, b) => b.pathTemplate.length - a.pathTemplate.length);
|
|
1119
|
+
return suffixMatches[0];
|
|
1120
|
+
}
|
|
1121
|
+
const routes = [];
|
|
1122
|
+
for (const endpoint of endpoints) {
|
|
1123
|
+
for (const method of endpoint.methods) {
|
|
1124
|
+
const registration = findRegistration(method, endpoint.path);
|
|
1125
|
+
// Static, per-method chain wins over express-list-endpoints' merged
|
|
1126
|
+
// (and, across differently-protected sibling methods, wrong) one —
|
|
1127
|
+
// see extractMiddlewareNames. Only falls back to the runtime-derived
|
|
1128
|
+
// chain when no registration was found at all (an unrecognized
|
|
1129
|
+
// composition style neither match strategy in findRegistration
|
|
1130
|
+
// above could trace).
|
|
1131
|
+
const middlewareChain = registration
|
|
1132
|
+
? extractMiddlewareNames(registration.call)
|
|
1133
|
+
: endpoint.middlewares.filter((name) => name !== "anonymous");
|
|
1134
|
+
const authRequiredGuess = middlewareChain.some((name) => authPatterns.some((pattern) => name === pattern || name.toLowerCase() === pattern.toLowerCase()));
|
|
1135
|
+
const scopes = registration ? extractScopes(registration.call, scopePatterns) : [];
|
|
1136
|
+
const summary = registration ? extractSummary(registration.call) : null;
|
|
1137
|
+
const description = registration ? extractDescription(registration.call) : null;
|
|
1138
|
+
const explicitGroup = registration ? extractExplicitGroup(registration.call) : null;
|
|
1139
|
+
const group = explicitGroup ?? inferGroup(endpoint.path, registration?.call.getSourceFile().getFilePath() ?? entryAbsPath);
|
|
1140
|
+
const groupSource = explicitGroup ? "declared" : "inferred";
|
|
1141
|
+
const deprecated = registration ? extractDeprecated(registration.call) : false;
|
|
1142
|
+
const zodRequestSchema = registration ? findRequestSchemaForRoute(registration.call, validationPatterns) : null;
|
|
1143
|
+
const mongooseRequestSchema = registration && !zodRequestSchema ? findMongooseRequestSchemaForRoute(registration.call) : null;
|
|
1144
|
+
const requestSchema = zodRequestSchema ?? mongooseRequestSchema;
|
|
1145
|
+
const requestSchemaSource = zodRequestSchema ? "declared" : mongooseRequestSchema ? "inferred" : null;
|
|
1146
|
+
const declaredResponseSchemas = registration ? extractDeclaredResponseSchemas(registration.call) : {};
|
|
1147
|
+
const declaredExamples = registration ? extractDeclaredExamples(registration.call) : {};
|
|
1148
|
+
routes.push({
|
|
1149
|
+
method,
|
|
1150
|
+
pathTemplate: endpoint.path,
|
|
1151
|
+
middlewareChain,
|
|
1152
|
+
authRequiredGuess,
|
|
1153
|
+
scopes,
|
|
1154
|
+
group,
|
|
1155
|
+
groupSource,
|
|
1156
|
+
summary,
|
|
1157
|
+
description,
|
|
1158
|
+
deprecated,
|
|
1159
|
+
requestSchema,
|
|
1160
|
+
requestSchemaSource,
|
|
1161
|
+
declaredResponseSchemas,
|
|
1162
|
+
declaredExamples,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return { routes };
|
|
1167
|
+
}
|