@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/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
+ }