@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 ADDED
@@ -0,0 +1,62 @@
1
+ # @vayo-hq/ast
2
+
3
+ Vayo's static analysis pass — the part of `vayo scan` that reads your
4
+ Express app's source without needing any traffic first.
5
+
6
+ Given a bootstrapped Express app (`export default app` or `export const
7
+ app`), `scanProject(rootDir, config)` uses `express-list-endpoints` +
8
+ `ts-morph` to recover, per route: the middleware chain, an auth-required
9
+ guess (configurable middleware-name patterns), scopes (configurable
10
+ scope-check function names), a folder/group guess from your route file
11
+ layout, and — when your project uses Zod or a plain Mongoose model — a
12
+ best-effort request body schema, all without executing a single request.
13
+
14
+ ```ts
15
+ import { scanProject, type VayoConfig } from "@vayo-hq/ast";
16
+
17
+ const config: VayoConfig = { appEntryPath: "./src/app.js" };
18
+ const { routes } = await scanProject(process.cwd(), config);
19
+ ```
20
+
21
+ ## Optional JSDoc tags
22
+
23
+ A route's leading comment can carry explicit tags — recognized only inside
24
+ a comment that also has a bare `@vayo` sentinel line, so an unrelated TODO
25
+ or workaround comment is never misread as a declaration:
26
+
27
+ ```js
28
+ /**
29
+ * Fetch a single order by ID.
30
+ * @vayo
31
+ * @group Orders
32
+ * @deprecated
33
+ * @response 200 OrderSchema
34
+ * @example 404 {"message": "Order not found"}
35
+ * @description
36
+ * Longer, multi-line explanation of this endpoint — the counterpart to
37
+ * the one-line summary above.
38
+ */
39
+ router.get("/orders/:id", getOrder);
40
+ ```
41
+
42
+ - **`@group <name>`** (nested: `@group Admin/Users`) wins over both the
43
+ folder-layout guess and the URL-segment fallback, and locks the
44
+ endpoint's folder placement against being dragged elsewhere in the UI.
45
+ - **`@deprecated`** marks the endpoint deprecated independent of its API
46
+ version's own lifecycle, and locks it against being un-deprecated in
47
+ the UI.
48
+ - **`@response <status> <SchemaName>`** points at an existing Zod schema
49
+ to use for that status code's response shape.
50
+ - **`@example <status> <JSON>`** provides a literal example response
51
+ value for a status code.
52
+ - **`@description`** fills in a longer, multi-line description separate
53
+ from the one-line `summary` every route already gets for free.
54
+
55
+ Most people never call this directly —
56
+ [`vayo`](https://www.npmjs.com/package/vayo)'s `vayo scan` command
57
+ is the intended entry point. This package exists standalone for anyone
58
+ building custom tooling around the same static pass.
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,240 @@
1
+ import { Project, type CallExpression } from "ts-morph";
2
+ import type { JSONSchema } from "@vayo-hq/types";
3
+ export declare const DEFAULT_AUTH_MIDDLEWARE_PATTERNS: string[];
4
+ export declare const DEFAULT_SCOPE_CHECK_PATTERNS: string[];
5
+ /** Body-validation middleware names to recognize a Zod schema argument
6
+ * against (docs/04-capture-engine.md Step 2 #3) — configurable the same way
7
+ * auth/scope patterns are, since every team names this differently. */
8
+ export declare const DEFAULT_VALIDATION_MIDDLEWARE_PATTERNS: string[];
9
+ export interface VayoConfig {
10
+ appEntryPath: string;
11
+ authMiddlewarePatterns?: string[];
12
+ scopeCheckPatterns?: string[];
13
+ validationMiddlewarePatterns?: string[];
14
+ redact?: string[];
15
+ }
16
+ export interface StaticRouteResult {
17
+ method: string;
18
+ pathTemplate: string;
19
+ middlewareChain: string[];
20
+ authRequiredGuess: boolean;
21
+ scopes: string[];
22
+ group: string;
23
+ /** "declared" when an explicit `@group <name>` tag was found in the
24
+ * route's leading comment (swagger-jsdoc's own convention), "inferred"
25
+ * when `group` instead came from the `routes/` file-layout convention or
26
+ * the URL-segment fallback (docs/04-capture-engine.md Step 2 #4). Lets
27
+ * the UI treat a `"declared"` grouping as authoritative — e.g. refusing
28
+ * to let a drag-and-drop move it to a different sidebar folder, since
29
+ * that would silently diverge from what the code itself says. */
30
+ groupSource: "declared" | "inferred";
31
+ summary: string | null;
32
+ /** Longer, potentially multi-line/multi-paragraph explanation from an
33
+ * explicit `@description` tag — OpenAPI's own standard Operation Object
34
+ * distinguishes this from `summary` (a short one-liner); see
35
+ * `extractDescription`. `null` when absent, same as `summary`. */
36
+ description: string | null;
37
+ /** True when an explicit bare `@deprecated` tag was found in the route's
38
+ * leading comment (the same OpenAPI/swagger-jsdoc convention `deprecated:
39
+ * true` on an Operation Object mirrors) — docs/04-capture-engine.md Step
40
+ * 2 #4a. Always `false` when absent; there's no inferred/guessed
41
+ * "probably deprecated" signal the way `group` has multiple fallbacks. */
42
+ deprecated: boolean;
43
+ /** A Zod- or Mongoose-derived request body shape, when one could be
44
+ * traced statically — see `findRequestSchemaForRoute`/
45
+ * `findMongooseRequestSchemaForRoute` below. `null` (not an empty
46
+ * object) when nothing was found, so a merge never mistakes "found
47
+ * nothing" for "this endpoint genuinely takes no body." */
48
+ requestSchema: JSONSchema | null;
49
+ /** "declared" when `requestSchema` came from a Zod schema the code
50
+ * itself validates against, "inferred" when it came from a Mongoose
51
+ * model's schema instead (docs/03-data-model.md) — `null` exactly when
52
+ * `requestSchema` is. */
53
+ requestSchemaSource: "declared" | "inferred" | null;
54
+ /** Response body shape(s) declared via one or more `@response <status>
55
+ * <SchemaName>` tags in the route's leading comment, keyed by status code
56
+ * (docs/04-capture-engine.md Step 2 #4b) — see `extractDeclaredResponseSchemas`
57
+ * below. Empty when none were found; unlike `requestSchema` there's no
58
+ * "inferred" convention for a response shape (no Mongoose-model
59
+ * equivalent for "what does this endpoint send back"), so a declared
60
+ * response schema is always high-confidence when present at all. */
61
+ declaredResponseSchemas: Record<string, JSONSchema>;
62
+ /** Literal example response value(s) declared via one or more `@example
63
+ * <status> <JSON>` tags, keyed by status code — see
64
+ * `extractDeclaredExamples` below. Empty when none were found. */
65
+ declaredExamples: Record<string, unknown>;
66
+ }
67
+ export interface StaticScanResult {
68
+ routes: StaticRouteResult[];
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
+ export declare function inferGroup(pathTemplate: string, sourceFilePath: string): string;
82
+ /** True when `registrationPath` (a literal string found at a route
83
+ * registration site, e.g. `router.get("/:id", ...)`) resolves to
84
+ * `runtimePath` (the fully-mounted path `express-list-endpoints` reports,
85
+ * e.g. `/api/admin/products/:id`). Exact equality covers routes registered
86
+ * directly on `app` with their full path repeated (today's flat demo-app
87
+ * style); segment-suffix equality covers `express.Router()` composition
88
+ * mounted via `app.use(prefix, router)` at any nesting depth — the router's
89
+ * own registration only ever sees its path relative to wherever it gets
90
+ * mounted, but `express-list-endpoints` resolves the live app's actual
91
+ * router stack at runtime, so the runtime path is always the source of
92
+ * truth here; only the static side needs to tolerate the prefix it can't
93
+ * see. Segment-based (not raw substring) so `/id` can never accidentally
94
+ * suffix-match inside `/userid`. */
95
+ export declare function pathSegmentsMatch(registrationPath: string, runtimePath: string): boolean;
96
+ /** Joins an `express.Router()`'s own relative registration path onto the
97
+ * literal prefix it's mounted at (`app.use(prefix, router)`) — mirrors how
98
+ * Express itself resolves it, including the router-root special case
99
+ * (`router.get("/", ...)` mounted at `prefix` resolves to exactly `prefix`,
100
+ * not `prefix + "/"`). Empty `prefix` (a registration found directly on
101
+ * `app`, never resolved through a mount call) is a no-op, so today's flat
102
+ * style keeps matching by plain equality with zero special-casing. */
103
+ export declare function joinMountedPath(prefix: string, relativePath: string): string;
104
+ /** Scans every file in the project for `X.use("/prefix", router)` calls and
105
+ * maps each mounted router's *source file* to the literal prefix it's
106
+ * mounted at — the piece `express-list-endpoints` can't give us (it only
107
+ * reports the live app's fully-*resolved* runtime paths, not which static
108
+ * file each route came from). One level of mounting only (a router
109
+ * mounted directly on `app` or on another router) — sufficient for the
110
+ * "one router per domain, all mounted in one place" convention this is
111
+ * built around; deeper indirection just falls through to the
112
+ * `pathSegmentsMatch` fallback below. */
113
+ export declare function buildMountPrefixMap(project: Project): Map<string, string>;
114
+ /** Extracts the middleware chain from a static route registration's own
115
+ * arguments (e.g. `router.post("/", requireAuth, requireRole("admin"),
116
+ * handler)` -> `["requireAuth", "requireRole"]`), in registration order.
117
+ *
118
+ * Needed because `express-list-endpoints` (7.x) merges endpoints that share
119
+ * a literal path across different HTTP methods by concatenating their
120
+ * `methods` arrays, but silently keeps only the *first-registered* method's
121
+ * own `middlewares` array for the merged entry (see its `addEndpoints`) — a
122
+ * public `GET "/"` registered before a protected `POST "/"` on the same
123
+ * path makes the protected POST inherit GET's *empty* middleware chain.
124
+ * Reading it statically, per registration, sidesteps that upstream bug —
125
+ * this is the actual mechanism the Flowmap tab and `authRequiredGuess`
126
+ * below depend on being correct per method, not just per path.
127
+ *
128
+ * The first argument (the path string) and the last (the handler, always
129
+ * anonymous in every convention this cares about) are dropped; everything
130
+ * between is either a plain identifier (`requireAuth`) or a middleware
131
+ * factory call (`requireRole("admin")`, reported by its callee name). */
132
+ export declare function extractMiddlewareNames(call: CallExpression): string[];
133
+ /** Finds a request-body Zod schema statically associated with a route
134
+ * registration, trying two conventions in order: (1) a validation-
135
+ * middleware call among the route's own arguments (`validateBody(Schema)`,
136
+ * `zValidator("json", Schema)` — the schema is whichever argument actually
137
+ * resolves, checked last-to-first since the schema is usually the final
138
+ * arg), then (2) `Schema.parse(req.body)`/`.safeParse(...)` called directly
139
+ * inside the handler body. Returns null — never throws, never guesses —
140
+ * when neither convention matches. */
141
+ export declare function findRequestSchemaForRoute(call: CallExpression, validationPatterns: string[]): JSONSchema | null;
142
+ /** Finds a request-body schema inferred from a Mongoose model, when the
143
+ * Zod path above found nothing — see the two conventions documented
144
+ * above. Never throws, never guesses; null when neither matches. */
145
+ export declare function findMongooseRequestSchemaForRoute(call: CallExpression): JSONSchema | null;
146
+ /** JSDoc/leading-comment above a route registration statement, if any —
147
+ * higher fidelity than nothing, never required (docs/04-capture-engine.md
148
+ * Step 2 #6). Absent for the demo app on purpose: the M1 done-when bar
149
+ * requires zero annotations in demo-app's own code. Strips out any
150
+ * `@vayo`/`@group`/`@deprecated` tag lines (see `hasVayoDocSentinel`/
151
+ * `extractExplicitGroup`/`extractDeprecated` below) — same
152
+ * description-vs-structured-tags split swagger-jsdoc itself uses, so a tag
153
+ * doesn't show up twice (once as this summary, once as its own structured
154
+ * field). */
155
+ export declare function extractSummary(call: CallExpression): string | null;
156
+ /** Explicit `@description` tag in the same leading comment `extractSummary`
157
+ * reads — OpenAPI's own standard Operation Object distinguishes a short
158
+ * `summary` (one-liner) from a longer `description` (can be multiple
159
+ * paragraphs, markdown-supported); Vayo's zero-annotation `summary`
160
+ * extraction only ever produces the former. Unlike `@group`/`@deprecated`/
161
+ * `@response`/`@example`, the tag's own text can continue across multiple
162
+ * lines — everything from right after `@description` on its own line
163
+ * through the line before the next recognized `@`-tag (or the comment's
164
+ * end) is joined together, e.g.:
165
+ * ```
166
+ * * @description
167
+ * * Returns the full order record including line items.
168
+ * * Use `include=customer` to also embed customer info.
169
+ * ```
170
+ * Only recognized inside a comment carrying the `@vayo` sentinel
171
+ * (`hasVayoDocSentinel`) — same reasoning as every other structured tag
172
+ * here: an incidental "see the @description field" sentence elsewhere
173
+ * shouldn't silently become this endpoint's documented description. */
174
+ export declare function extractDescription(call: CallExpression): string | null;
175
+ /** Explicit `@group <name>` tag in the same leading comment `extractSummary`
176
+ * reads — swagger-jsdoc's own convention (e.g. `@group Orders - order
177
+ * management`) for declaring a route's documentation grouping directly in
178
+ * code, rather than leaving it to the `routes/` file-layout convention or a
179
+ * guessed URL segment (docs/04-capture-engine.md Step 2 #4). A trailing
180
+ * `- description` some tools also allow after the name is trimmed off,
181
+ * since that's redundant with `extractSummary`'s own result. Nested groups
182
+ * follow the same "/"-separated convention `inferGroup` uses, e.g.
183
+ * `@group Admin/Users`. Only recognized inside a comment carrying the
184
+ * `@vayo` sentinel (`hasVayoDocSentinel`) — otherwise this text is treated
185
+ * as ordinary prose, never a declaration. */
186
+ export declare function extractExplicitGroup(call: CallExpression): string | null;
187
+ /** Bare `@deprecated` tag in the same leading comment `extractSummary`
188
+ * reads — the OpenAPI/swagger-jsdoc convention for marking a specific
189
+ * route deprecated in code, independent of the whole API *version*'s own
190
+ * lifecycle (`ApiVersionDoc.status`, `07-api-versioning.md`): a route can
191
+ * be deprecated while the version it belongs to is still fully active.
192
+ * Unlike `@group`, this tag carries no value of its own — its mere
193
+ * presence is the signal, matching how `@deprecated` is used bare in both
194
+ * conventions, so the whole line must be nothing else (a stray "the
195
+ * @deprecated tag needs cleanup" TODO elsewhere in a legitimately
196
+ * `@vayo`-tagged block still shouldn't flip this on). Only recognized
197
+ * inside a comment carrying the `@vayo` sentinel — see
198
+ * `extractExplicitGroup`'s own comment for why that gate exists at all. */
199
+ export declare function extractDeprecated(call: CallExpression): boolean;
200
+ /** Explicit `@response <status> <SchemaName>` tag(s) in the same leading
201
+ * comment `extractSummary` reads — declares a route's response body shape
202
+ * for a given status code by pointing at an existing Zod schema identifier
203
+ * (`findSchemaByName` above), the mirror of how `findRequestSchemaForRoute`
204
+ * traces a *request* body from a Zod schema — just declared explicitly
205
+ * rather than found at a fixed call-site convention, since there's no
206
+ * equivalent "always the last argument to X" shape for a response the way
207
+ * there is for request-validation middleware. One line per status code,
208
+ * e.g. `@response 200 OrderSchema` / `@response 404 ErrorSchema` — multiple
209
+ * lines declare multiple statuses. A name that doesn't resolve to a real
210
+ * Zod schema anywhere in scope is silently skipped for that line, never
211
+ * guessed. Only recognized inside a comment carrying the `@vayo` sentinel —
212
+ * see `extractExplicitGroup`'s comment for why that gate exists at all. */
213
+ export declare function extractDeclaredResponseSchemas(call: CallExpression): Record<string, JSONSchema>;
214
+ /** Explicit `@example <status> <JSON>` tag(s) in the same leading comment —
215
+ * a literal example response value for a given status code, declared
216
+ * directly in code rather than captured from real traffic or pinned by a
217
+ * team member from Try It Now (docs/03-data-model.md `vayo_examples`).
218
+ * One line per status code, e.g. `@example 200 {"id":"abc","total":42}` —
219
+ * single-line JSON only (no multi-line objects), to keep parsing this out
220
+ * of a free-text comment unambiguous. Invalid JSON on a matching line is
221
+ * silently skipped for that line, never thrown — same "never guess" bar
222
+ * every other tag here holds itself to. Only recognized inside a comment
223
+ * carrying the `@vayo` sentinel. */
224
+ export declare function extractDeclaredExamples(call: CallExpression): Record<string, unknown>;
225
+ /**
226
+ * Runs the static pass against a bootstrapped instance of the user's app.
227
+ *
228
+ * `config.appEntryPath` (resolved relative to `rootDir`) must be a module
229
+ * loadable via dynamic `import()` that exports a already-configured Express
230
+ * app — either `export default app` or `export const app`. It should be the
231
+ * user's plain app (no Vayo middleware mounted), so express-list-endpoints
232
+ * reports only the user's own routes/middleware — see apps/demo-app for the
233
+ * create-app-vs-start-server split this implies.
234
+ *
235
+ * Async (unlike the synchronous signature sketched in
236
+ * docs/08-packages-and-repo-structure.md) because bootstrapping a live app
237
+ * instance is inherently a dynamic import — there's no way to do that
238
+ * synchronously in an ESM-resolution world.
239
+ */
240
+ export declare function scanProject(rootDir: string, config: VayoConfig): Promise<StaticScanResult>;