apiforge-analyzer 1.3.2 → 1.3.5

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.
@@ -0,0 +1,1076 @@
1
+ import { Project, SyntaxKind } from "ts-morph";
2
+ import * as fs from "fs";
3
+ function loadJsonCatalog(filePath) {
4
+ if (!filePath)
5
+ return {};
6
+ try {
7
+ const raw = fs.readFileSync(filePath, "utf-8");
8
+ return JSON.parse(raw);
9
+ }
10
+ catch (err) {
11
+ console.warn(`⚠️ APIForge: could not load catalog at ${filePath}`, err);
12
+ return {};
13
+ }
14
+ }
15
+ let tsProject = null;
16
+ function getProject() {
17
+ if (!tsProject) {
18
+ tsProject = new Project();
19
+ tsProject.addSourceFilesAtPaths([
20
+ "**/*.{js,ts,mjs,cjs}",
21
+ "!node_modules/**",
22
+ "!dist/**",
23
+ ]);
24
+ }
25
+ return tsProject;
26
+ }
27
+ function normalize(s) {
28
+ return s.replace(/\s+/g, " ").trim();
29
+ }
30
+ function findHandlerNode(handlerName) {
31
+ const project = getProject();
32
+ for (const sourceFile of project.getSourceFiles()) {
33
+ // Pehle jaisa top-level check rakho (fast path)
34
+ const topLevelFn = sourceFile.getFunction(handlerName);
35
+ if (topLevelFn)
36
+ return { node: topLevelFn, sourceFile };
37
+ // NAYA: nested function declarations bhi dhoondo (kisi bhi depth pe)
38
+ const nestedFns = sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration);
39
+ for (const fn of nestedFns) {
40
+ if (fn.getName() === handlerName) {
41
+ return { node: fn, sourceFile };
42
+ }
43
+ }
44
+ const props = sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment);
45
+ for (const prop of props) {
46
+ if (prop.getName() === handlerName) {
47
+ const initializer = prop.getInitializer();
48
+ if (initializer &&
49
+ (initializer.getKind() === SyntaxKind.ArrowFunction ||
50
+ initializer.getKind() === SyntaxKind.FunctionExpression)) {
51
+ return { node: prop, sourceFile };
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ function findNodeBySource(handlerFn) {
59
+ const rawSrc = normalize(handlerFn.toString());
60
+ const project = getProject();
61
+ for (const sourceFile of project.getSourceFiles()) {
62
+ const candidates = [
63
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.ArrowFunction),
64
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionExpression),
65
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration),
66
+ ];
67
+ for (const cand of candidates) {
68
+ if (normalize(cand.getText()) === rawSrc) {
69
+ return { node: cand, sourceFile };
70
+ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ function findCurriedFactoryHandler(handlerFn) {
76
+ const rawSrc = normalize(handlerFn.toString());
77
+ const project = getProject();
78
+ for (const sourceFile of project.getSourceFiles()) {
79
+ const varDecls = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
80
+ for (const decl of varDecls) {
81
+ const outerInit = decl.getInitializer();
82
+ if (!outerInit || outerInit.getKind() !== SyntaxKind.ArrowFunction)
83
+ continue;
84
+ const outerBody = outerInit.getBody();
85
+ if (!outerBody)
86
+ continue;
87
+ const isInnerFn = outerBody.getKind() === SyntaxKind.ArrowFunction ||
88
+ outerBody.getKind() === SyntaxKind.FunctionExpression;
89
+ if (!isInnerFn)
90
+ continue;
91
+ const factoryName = decl.getName();
92
+ for (const callSiteFile of project.getSourceFiles()) {
93
+ const calls = callSiteFile.getDescendantsOfKind(SyntaxKind.CallExpression);
94
+ for (const call of calls) {
95
+ if (call.getExpression().getText() !== factoryName)
96
+ continue;
97
+ const candidateText = normalize(outerBody.getText());
98
+ if (candidateText === rawSrc) {
99
+ return { node: outerBody, sourceFile };
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ function resolveDelegateParamName(node) {
108
+ if (typeof node.getParameters !== "function")
109
+ return null;
110
+ const params = node.getParameters().map((p) => p.getName());
111
+ if (params.length < 2)
112
+ return null;
113
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
114
+ for (const call of calls) {
115
+ const expr = call.getExpression();
116
+ if (expr.getKind() !== SyntaxKind.Identifier)
117
+ continue;
118
+ const args = call.getArguments().map((a) => a.getText());
119
+ if (args.length >= 2 && args[0] === params[0] && args[1] === params[1]) {
120
+ return expr.getText();
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ function resolveViaCallSite(node, delegateParamName, rawMethod, rawUrl) {
126
+ const enclosing = node.getFirstAncestor((a) => a.getKind() === SyntaxKind.FunctionDeclaration);
127
+ if (!enclosing)
128
+ return null;
129
+ const paramNames = enclosing.getParameters().map((p) => p.getName());
130
+ const handlerIndex = paramNames.indexOf(delegateParamName);
131
+ const methodIndex = paramNames.indexOf("method");
132
+ const suffixIndex = paramNames.indexOf("suffix");
133
+ if (handlerIndex === -1)
134
+ return null;
135
+ const enclosingName = enclosing.getName();
136
+ if (!enclosingName)
137
+ return null;
138
+ const stripQuotes = (s) => s.replace(/^['"`]|['"`]$/g, "");
139
+ const project = getProject();
140
+ const candidates = [];
141
+ for (const sourceFile of project.getSourceFiles()) {
142
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
143
+ for (const call of calls) {
144
+ if (call.getExpression().getText() !== enclosingName)
145
+ continue;
146
+ const args = call.getArguments();
147
+ const handlerArg = args[handlerIndex];
148
+ if (!handlerArg || handlerArg.getKind() !== SyntaxKind.Identifier)
149
+ continue;
150
+ if (methodIndex !== -1 && args[methodIndex]) {
151
+ const methodArg = stripQuotes(args[methodIndex].getText()).toLowerCase();
152
+ if (methodArg !== rawMethod.toLowerCase())
153
+ continue;
154
+ }
155
+ const suffix = suffixIndex !== -1 && args[suffixIndex]
156
+ ? stripQuotes(args[suffixIndex].getText())
157
+ : "";
158
+ candidates.push({ handlerName: handlerArg.getText(), suffix });
159
+ }
160
+ }
161
+ candidates.sort((a, b) => b.suffix.length - a.suffix.length);
162
+ for (const c of candidates) {
163
+ if (rawUrl.endsWith(c.suffix))
164
+ return c.handlerName;
165
+ }
166
+ return null;
167
+ }
168
+ function matchAll(src, re) {
169
+ return [...src.matchAll(re)].map((m) => m[1]).filter(Boolean);
170
+ }
171
+ function unique(items) {
172
+ return Array.from(new Set(items.filter(Boolean)));
173
+ }
174
+ function parseZodChain(initializer) {
175
+ const info = { type: "unknown" };
176
+ let current = initializer;
177
+ const calls = [];
178
+ while (current && current.getKind() === SyntaxKind.CallExpression) {
179
+ const expr = current.getExpression();
180
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
181
+ const methodName = expr.getName();
182
+ const args = current.getArguments().map((a) => a.getText());
183
+ calls.unshift({ name: methodName, args });
184
+ current = expr.getExpression();
185
+ }
186
+ else if (expr.getKind() === SyntaxKind.Identifier &&
187
+ expr.getText() === "z") {
188
+ break;
189
+ }
190
+ else {
191
+ current = null;
192
+ }
193
+ }
194
+ for (const call of calls) {
195
+ switch (call.name) {
196
+ case "string":
197
+ info.type = "string";
198
+ break;
199
+ case "number":
200
+ info.type = "number";
201
+ break;
202
+ case "boolean":
203
+ info.type = "boolean";
204
+ break;
205
+ case "enum":
206
+ info.type = "enum";
207
+ info.enum = call.args[0]
208
+ ?.replace(/[\[\]'"]/g, "")
209
+ .split(",")
210
+ .map((s) => s.trim());
211
+ break;
212
+ case "min":
213
+ info.min = Number(call.args[0]);
214
+ break;
215
+ case "max":
216
+ info.max = Number(call.args[0]);
217
+ break;
218
+ case "length": {
219
+ const len = Number(call.args[0]);
220
+ info.min = len;
221
+ info.max = len;
222
+ break;
223
+ }
224
+ case "default": {
225
+ const raw = call.args[0]?.replace(/['"]/g, "");
226
+ info.default = info.type === "number" ? Number(raw) : raw;
227
+ info.required = false;
228
+ break;
229
+ }
230
+ case "optional":
231
+ info.required = false;
232
+ break;
233
+ }
234
+ }
235
+ if (info.required === undefined)
236
+ info.required = true;
237
+ return info;
238
+ }
239
+ /** Convert an internal zod field descriptor into a JSON-Schema-shaped fragment. */
240
+ function zodFieldToJsonSchema(field) {
241
+ const schema = {
242
+ type: field.type === "enum" ? "string" : field.type,
243
+ };
244
+ if (field.enum)
245
+ schema.enum = field.enum;
246
+ if (field.min !== undefined) {
247
+ if (schema.type === "string")
248
+ schema.minLength = field.min;
249
+ else
250
+ schema.minimum = field.min;
251
+ }
252
+ if (field.max !== undefined) {
253
+ if (schema.type === "string")
254
+ schema.maxLength = field.max;
255
+ else
256
+ schema.maximum = field.max;
257
+ }
258
+ if (field.default !== undefined)
259
+ schema.default = field.default;
260
+ return schema;
261
+ }
262
+ function unwrapToObjectCall(init) {
263
+ let current = init;
264
+ while (current && current.getKind() === SyntaxKind.CallExpression) {
265
+ const expr = current.getExpression();
266
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression)
267
+ return null;
268
+ const methodName = expr.getName();
269
+ const target = expr.getExpression();
270
+ if (methodName === "object" &&
271
+ target.getKind() === SyntaxKind.Identifier &&
272
+ target.getText() === "z") {
273
+ return current;
274
+ }
275
+ current = target;
276
+ }
277
+ return null;
278
+ }
279
+ function extractZodFields(schemaVarName) {
280
+ const project = getProject();
281
+ for (const sourceFile of project.getSourceFiles()) {
282
+ const varDecls = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
283
+ for (const decl of varDecls) {
284
+ if (decl.getName() !== schemaVarName)
285
+ continue;
286
+ const init = decl.getInitializer();
287
+ if (!init || init.getKind() !== SyntaxKind.CallExpression)
288
+ continue;
289
+ const objectCall = unwrapToObjectCall(init);
290
+ if (!objectCall)
291
+ continue;
292
+ const objArg = objectCall.getArguments()[0];
293
+ if (!objArg || objArg.getKind() !== SyntaxKind.ObjectLiteralExpression)
294
+ continue;
295
+ const fields = [];
296
+ for (const prop of objArg.getProperties()) {
297
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment)
298
+ continue;
299
+ const name = prop.getName();
300
+ const fieldInit = prop.getInitializer();
301
+ const parsed = parseZodChain(fieldInit);
302
+ fields.push({ name, ...parsed });
303
+ }
304
+ return fields;
305
+ }
306
+ }
307
+ return [];
308
+ }
309
+ function extractZodUsage(src, target) {
310
+ // Native zod method call: schemaVar.parse(request.query)
311
+ const methodStyle = new RegExp(`(\\w+)\\.parse\\s*\\(\\s*req(?:uest)?\\.${target}\\s*\\)`);
312
+ const methodMatch = src.match(methodStyle);
313
+ if (methodMatch)
314
+ return methodMatch[1];
315
+ // Wrapper-function call: parse(schemaVar, request.query) — schema passed as
316
+ // an argument rather than the receiver. Idiomatic in codebases that centralize
317
+ // parsing (e.g. to attach a shared error formatter) instead of calling zod directly.
318
+ const wrapperStyle = new RegExp(`\\bparse\\s*\\(\\s*(\\w+)\\s*,\\s*req(?:uest)?\\.${target}\\s*\\)`);
319
+ const wrapperMatch = src.match(wrapperStyle);
320
+ return wrapperMatch ? wrapperMatch[1] : null;
321
+ }
322
+ function getLeadingJsDoc(node) {
323
+ if (typeof node.getFullText !== "function")
324
+ return undefined;
325
+ const fullText = node.getFullText();
326
+ const nodeText = node.getText();
327
+ const leading = fullText.slice(0, fullText.length - nodeText.length);
328
+ const match = leading.match(/\/\*\*([\s\S]*?)\*\//);
329
+ if (!match)
330
+ return undefined;
331
+ return (match[1]
332
+ .split("\n")
333
+ .map((l) => l.replace(/^\s*\*\s?/, "").trim())
334
+ .filter(Boolean)
335
+ .join(" ") || undefined);
336
+ }
337
+ const GUARD_FUNCTION_NAMES = ["requireScope", "requireOperator"];
338
+ function resolveGuardAliases(sourceFile, guardNames) {
339
+ const aliases = [];
340
+ const decls = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
341
+ for (const decl of decls) {
342
+ const init = decl.getInitializer();
343
+ if (init &&
344
+ init.getKind() === SyntaxKind.Identifier &&
345
+ guardNames.includes(init.getText())) {
346
+ aliases.push(decl.getName());
347
+ }
348
+ }
349
+ return aliases;
350
+ }
351
+ function resolveScopeArgExpression(enclosingNode, argExpr) {
352
+ if (!argExpr)
353
+ return {};
354
+ if (argExpr.getKind() === SyntaxKind.StringLiteral) {
355
+ return { scope: argExpr.getLiteralText() };
356
+ }
357
+ if (argExpr.getKind() === SyntaxKind.ConditionalExpression) {
358
+ const whenTrue = argExpr.getWhenTrue();
359
+ const whenFalse = argExpr.getWhenFalse();
360
+ if (whenTrue.getKind() === SyntaxKind.StringLiteral &&
361
+ whenFalse.getKind() === SyntaxKind.StringLiteral) {
362
+ return {
363
+ scopes: [whenTrue.getLiteralText(), whenFalse.getLiteralText()],
364
+ scopeCondition: argExpr.getCondition().getText(),
365
+ };
366
+ }
367
+ return { scopeUnresolved: true, scopeExpression: argExpr.getText() };
368
+ }
369
+ if (argExpr.getKind() === SyntaxKind.Identifier) {
370
+ const varName = argExpr.getText();
371
+ const decls = enclosingNode.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
372
+ for (const decl of decls) {
373
+ if (decl.getName() === varName) {
374
+ const init = decl.getInitializer();
375
+ if (init)
376
+ return resolveScopeArgExpression(enclosingNode, init);
377
+ }
378
+ }
379
+ return { scopeUnresolved: true, scopeExpression: varName };
380
+ }
381
+ return { scopeUnresolved: true, scopeExpression: argExpr.getText() };
382
+ }
383
+ function extractScopeInfoAst(node) {
384
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
385
+ for (const call of calls) {
386
+ const expr = call.getExpression();
387
+ if (expr.getKind() !== SyntaxKind.Identifier)
388
+ continue;
389
+ if (expr.getText() !== "requireScope")
390
+ continue;
391
+ const args = call.getArguments();
392
+ if (args.length < 2)
393
+ continue;
394
+ const line = typeof call.getStartLineNumber === "function"
395
+ ? call.getStartLineNumber()
396
+ : undefined;
397
+ return { ...resolveScopeArgExpression(node, args[1]), scopeLine: line };
398
+ }
399
+ return {};
400
+ }
401
+ // ---- NEW: dynamic status code resolution (same pattern as scope resolution) ----
402
+ function resolveStatusArgExpression(argExpr) {
403
+ if (!argExpr)
404
+ return {};
405
+ if (argExpr.getKind() === SyntaxKind.NumericLiteral) {
406
+ return { literal: Number(argExpr.getText()) };
407
+ }
408
+ if (argExpr.getKind() === SyntaxKind.ConditionalExpression) {
409
+ const whenTrue = argExpr.getWhenTrue();
410
+ const whenFalse = argExpr.getWhenFalse();
411
+ if (whenTrue.getKind() === SyntaxKind.NumericLiteral &&
412
+ whenFalse.getKind() === SyntaxKind.NumericLiteral) {
413
+ return {
414
+ options: [Number(whenTrue.getText()), Number(whenFalse.getText())],
415
+ condition: argExpr.getCondition().getText(),
416
+ };
417
+ }
418
+ // ternary exists but branches aren't plain numbers (nested calls, vars) —
419
+ // don't guess, report the full expression as unresolved.
420
+ return { unresolved: true, expression: argExpr.getText() };
421
+ }
422
+ // identifier, member access, function call, etc. — can't resolve statically
423
+ return { unresolved: true, expression: argExpr.getText() };
424
+ }
425
+ function extractStatusInfoAst(node) {
426
+ const responseStatuses = [];
427
+ const dynamicStatuses = [];
428
+ const statusSources = [];
429
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
430
+ for (const call of calls) {
431
+ const expr = call.getExpression();
432
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression)
433
+ continue;
434
+ const methodName = expr.getName();
435
+ if (methodName !== "status" && methodName !== "code")
436
+ continue;
437
+ const args = call.getArguments();
438
+ if (args.length < 1)
439
+ continue;
440
+ const line = typeof call.getStartLineNumber === "function"
441
+ ? call.getStartLineNumber()
442
+ : undefined;
443
+ const result = resolveStatusArgExpression(args[0]);
444
+ if (result.literal !== undefined) {
445
+ responseStatuses.push(result.literal);
446
+ statusSources.push({ status: result.literal, line });
447
+ }
448
+ else if (result.options) {
449
+ responseStatuses.push(...result.options);
450
+ dynamicStatuses.push({
451
+ options: result.options,
452
+ condition: result.condition,
453
+ line,
454
+ });
455
+ }
456
+ else if (result.unresolved) {
457
+ dynamicStatuses.push({
458
+ unresolved: true,
459
+ expression: result.expression,
460
+ line,
461
+ });
462
+ }
463
+ }
464
+ return {
465
+ responseStatuses: unique(responseStatuses),
466
+ dynamicStatuses,
467
+ statusSources,
468
+ };
469
+ }
470
+ // ---- END NEW ----
471
+ function detectStreaming(node) {
472
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
473
+ for (const call of calls) {
474
+ const expr = call.getExpression();
475
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression)
476
+ continue;
477
+ const methodName = expr.getName();
478
+ const target = expr.getExpression();
479
+ const targetText = target.getText();
480
+ // reply.raw.writeHead(...) / reply.raw.write(...)
481
+ if ((methodName === "writeHead" || methodName === "write") &&
482
+ /\.raw$/.test(targetText)) {
483
+ return true;
484
+ }
485
+ // reply.hijack()
486
+ if (methodName === "hijack") {
487
+ return true;
488
+ }
489
+ }
490
+ return false;
491
+ }
492
+ function collectContentTypeParsers() {
493
+ const project = getProject();
494
+ const parsers = [];
495
+ for (const sourceFile of project.getSourceFiles()) {
496
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
497
+ for (const call of calls) {
498
+ const expr = call.getExpression();
499
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression)
500
+ continue;
501
+ const methodName = expr.getName();
502
+ const isAdd = methodName === "addContentTypeParser";
503
+ const isRemove = methodName === "removeContentTypeParser";
504
+ if (!isAdd && !isRemove)
505
+ continue;
506
+ const args = call.getArguments();
507
+ if (args.length < 1)
508
+ continue;
509
+ // First arg can be a string literal or an array of string literals
510
+ const firstArg = args[0];
511
+ const contentTypes = [];
512
+ if (firstArg.getKind() === SyntaxKind.StringLiteral) {
513
+ contentTypes.push(firstArg.getLiteralText());
514
+ }
515
+ else if (firstArg.getKind() === SyntaxKind.ArrayLiteralExpression) {
516
+ for (const el of firstArg.getElements()) {
517
+ if (el.getKind() === SyntaxKind.StringLiteral) {
518
+ contentTypes.push(el.getLiteralText());
519
+ }
520
+ }
521
+ }
522
+ else {
523
+ // dynamic content-type expression — can't resolve statically,
524
+ // record the raw expression text instead of a real content type
525
+ contentTypes.push(`unresolved:${firstArg.getText()}`);
526
+ }
527
+ const prefixes = findEnclosingRegisterPrefixes(call);
528
+ const prefix = prefixes.length > 0 ? prefixes.join("") : undefined;
529
+ for (const ct of contentTypes) {
530
+ parsers.push({
531
+ action: isAdd ? "add" : "remove",
532
+ contentType: ct,
533
+ scope: prefix ? "scoped" : "global",
534
+ prefix,
535
+ file: sourceFile.getFilePath(),
536
+ line: typeof call.getStartLineNumber === "function"
537
+ ? call.getStartLineNumber()
538
+ : undefined,
539
+ });
540
+ }
541
+ }
542
+ }
543
+ return parsers;
544
+ }
545
+ function applicableContentTypeParsersForPath(path, allParsers) {
546
+ return allParsers.filter((p) => p.scope === "global" || (p.prefix && path.startsWith(p.prefix)));
547
+ }
548
+ function findEnclosingRegisterPrefixes(node) {
549
+ const prefixes = [];
550
+ let current = node;
551
+ while (current) {
552
+ const fnAncestor = current.getFirstAncestor((a) => a.getKind() === SyntaxKind.ArrowFunction ||
553
+ a.getKind() === SyntaxKind.FunctionExpression);
554
+ if (!fnAncestor)
555
+ break;
556
+ const callExpr = fnAncestor.getParent();
557
+ if (callExpr && callExpr.getKind() === SyntaxKind.CallExpression) {
558
+ const callee = callExpr.getExpression();
559
+ if (callee.getKind() === SyntaxKind.PropertyAccessExpression &&
560
+ callee.getName() === "register") {
561
+ const args = callExpr.getArguments();
562
+ const optsArg = args[1];
563
+ if (optsArg &&
564
+ optsArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
565
+ const prefixProp = optsArg
566
+ .getProperties()
567
+ .find((p) => p.getKind() === SyntaxKind.PropertyAssignment &&
568
+ p.getName() === "prefix");
569
+ if (prefixProp) {
570
+ const init = prefixProp.getInitializer();
571
+ if (init && init.getKind() === SyntaxKind.StringLiteral) {
572
+ prefixes.unshift(init.getLiteralText());
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ current = fnAncestor;
579
+ }
580
+ return prefixes;
581
+ }
582
+ const IGNORED_HOOK_TYPES = new Set([
583
+ "onRoute",
584
+ "onRegister",
585
+ "onReady",
586
+ "onClose",
587
+ "onListen",
588
+ ]);
589
+ function collectGlobalHooks() {
590
+ const project = getProject();
591
+ const hooks = [];
592
+ for (const sourceFile of project.getSourceFiles()) {
593
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
594
+ for (const call of calls) {
595
+ const expr = call.getExpression();
596
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression)
597
+ continue;
598
+ if (expr.getName() !== "addHook")
599
+ continue;
600
+ const args = call.getArguments();
601
+ if (args.length < 1 || args[0].getKind() !== SyntaxKind.StringLiteral)
602
+ continue;
603
+ const hookType = args[0].getLiteralText();
604
+ if (IGNORED_HOOK_TYPES.has(hookType))
605
+ continue;
606
+ const prefixes = findEnclosingRegisterPrefixes(call);
607
+ const prefix = prefixes.length > 0 ? prefixes.join("") : undefined;
608
+ hooks.push({
609
+ type: hookType,
610
+ scope: prefix ? "scoped" : "global",
611
+ prefix,
612
+ file: sourceFile.getFilePath(),
613
+ line: typeof call.getStartLineNumber === "function"
614
+ ? call.getStartLineNumber()
615
+ : undefined,
616
+ });
617
+ }
618
+ }
619
+ return hooks;
620
+ }
621
+ function applicableHooksForPath(path, allHooks) {
622
+ return allHooks.filter((h) => h.scope === "global" || (h.prefix && path.startsWith(h.prefix)));
623
+ }
624
+ function extractHeaderInfoAst(node) {
625
+ const results = [];
626
+ const elementAccesses = node.getDescendantsOfKind(SyntaxKind.ElementAccessExpression);
627
+ for (const el of elementAccesses) {
628
+ const target = el.getExpression();
629
+ if (!/req(?:uest)?\.headers$/.test(target.getText()))
630
+ continue;
631
+ const argExpr = el.getArgumentExpression();
632
+ if (!argExpr || argExpr.getKind() !== SyntaxKind.StringLiteral)
633
+ continue;
634
+ results.push({
635
+ name: argExpr.getLiteralText(),
636
+ line: typeof el.getStartLineNumber === "function"
637
+ ? el.getStartLineNumber()
638
+ : undefined,
639
+ });
640
+ }
641
+ return results;
642
+ }
643
+ function extractFromNode(node, sourceFile) {
644
+ const src = node.getText();
645
+ let queryParams = unique(matchAll(src, /req(?:uest)?\.query\.([a-zA-Z0-9_$]+)/g));
646
+ let requestBodyFields = unique(matchAll(src, /req(?:uest)?\.body\.([a-zA-Z0-9_$]+)/g));
647
+ let queryParamsSchema = [];
648
+ let requestBodySchema = [];
649
+ const queryZodVar = extractZodUsage(src, "query");
650
+ if (queryZodVar) {
651
+ queryParamsSchema = extractZodFields(queryZodVar);
652
+ queryParams = queryParamsSchema.map((f) => f.name);
653
+ }
654
+ const bodyZodVar = extractZodUsage(src, "body");
655
+ if (bodyZodVar) {
656
+ requestBodySchema = extractZodFields(bodyZodVar);
657
+ requestBodyFields = requestBodySchema.map((f) => f.name);
658
+ }
659
+ const headerInfoAst = extractHeaderInfoAst(node);
660
+ return {
661
+ queryParams,
662
+ requestBodyFields,
663
+ queryParamsSchema,
664
+ requestBodySchema,
665
+ headerParams: unique(headerInfoAst.map((h) => h.name)),
666
+ headerSources: headerInfoAst,
667
+ hasAuth: (() => {
668
+ const aliases = sourceFile
669
+ ? resolveGuardAliases(sourceFile, GUARD_FUNCTION_NAMES)
670
+ : [];
671
+ const names = [...GUARD_FUNCTION_NAMES, ...aliases]
672
+ .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
673
+ .join("|");
674
+ const re = new RegExp(`\\b(${names})\\s*\\(|auth\\.(either|client|machine)`);
675
+ return re.test(src);
676
+ })(),
677
+ ...extractStatusInfoAst(node),
678
+ ...extractScopeInfoAst(node),
679
+ streaming: detectStreaming(node),
680
+ };
681
+ }
682
+ function resolveHandlerInfo(handlerFn, raw) {
683
+ const empty = {
684
+ queryParams: [],
685
+ requestBodyFields: [],
686
+ queryParamsSchema: [],
687
+ requestBodySchema: [],
688
+ headerParams: [],
689
+ responseStatuses: [],
690
+ dynamicStatuses: [],
691
+ hasAuth: false,
692
+ resolved: false,
693
+ deprecated: false,
694
+ scope: undefined,
695
+ scopeUnresolved: undefined,
696
+ scopeExpression: undefined,
697
+ scopeLine: undefined,
698
+ statusSources: [],
699
+ headerSources: [],
700
+ streaming: false,
701
+ };
702
+ if (!handlerFn || typeof handlerFn !== "function")
703
+ return empty;
704
+ try {
705
+ const handlerName = handlerFn.name;
706
+ let found = handlerName ? findHandlerNode(handlerName) : null;
707
+ if (!found)
708
+ found = findNodeBySource(handlerFn);
709
+ if (!found)
710
+ found = findCurriedFactoryHandler(handlerFn);
711
+ if (!found)
712
+ return empty;
713
+ let { node, sourceFile } = found;
714
+ const wrapperSrc = node.getText();
715
+ const deprecated = /reply\.header\s*\(\s*['"]deprecation['"]/.test(wrapperSrc);
716
+ const delegateParamName = resolveDelegateParamName(node);
717
+ if (delegateParamName) {
718
+ const direct = findHandlerNode(delegateParamName);
719
+ if (direct) {
720
+ node = direct.node;
721
+ sourceFile = direct.sourceFile;
722
+ }
723
+ else {
724
+ const targetName = resolveViaCallSite(node, delegateParamName, raw.method, raw.url);
725
+ if (targetName) {
726
+ const targetFound = findHandlerNode(targetName);
727
+ if (targetFound) {
728
+ node = targetFound.node;
729
+ sourceFile = targetFound.sourceFile;
730
+ }
731
+ }
732
+ }
733
+ }
734
+ const jsDoc = getLeadingJsDoc(node);
735
+ const extracted = extractFromNode(node, sourceFile);
736
+ return {
737
+ file: sourceFile.getFilePath(),
738
+ line: typeof node.getStartLineNumber === "function"
739
+ ? node.getStartLineNumber()
740
+ : undefined,
741
+ description: jsDoc,
742
+ ...extracted,
743
+ resolved: true,
744
+ deprecated,
745
+ };
746
+ }
747
+ catch (err) {
748
+ console.warn("⚠️ APIForge: handler resolution error", err);
749
+ return empty;
750
+ }
751
+ }
752
+ export async function analyzeFastify(app, options) {
753
+ const scopeCatalog = loadJsonCatalog(options?.scopeCatalogPath);
754
+ const errorCatalog = loadJsonCatalog(options?.errorCatalogPath);
755
+ const envelopes = options?.envelopes || {};
756
+ const sidecarAnnotations = loadJsonCatalog(options?.annotationsPath);
757
+ const rawRoutes = [];
758
+ app.addHook("onRoute", (routeOptions) => {
759
+ rawRoutes.push({
760
+ method: routeOptions.method,
761
+ url: routeOptions.url,
762
+ handlerFn: routeOptions.handler,
763
+ config: routeOptions.config,
764
+ schema: routeOptions.schema,
765
+ onRequest: routeOptions.onRequest,
766
+ preHandler: routeOptions.preHandler,
767
+ });
768
+ });
769
+ await app.ready();
770
+ const globalHooks = collectGlobalHooks();
771
+ const contentTypeParsers = collectContentTypeParsers();
772
+ const filtered = rawRoutes.filter((r) => r.method !== "HEAD");
773
+ const routes = filtered.map((r) => buildRoute(r, scopeCatalog, errorCatalog, envelopes, globalHooks, contentTypeParsers, sidecarAnnotations));
774
+ // Stale annotation warning: any sidecar entry that doesn't match a
775
+ // discovered runtime route is pointing at something that no longer exists.
776
+ const discoveredKeys = new Set(filtered.map((r) => `${r.method} ${r.url}`));
777
+ for (const key of Object.keys(sidecarAnnotations)) {
778
+ if (!discoveredKeys.has(key)) {
779
+ console.warn(`⚠️ Stale APIForge annotation: ${key}`);
780
+ }
781
+ }
782
+ console.log(`📦 Fastify: captured ${routes.length} routes`);
783
+ return routes;
784
+ }
785
+ function mergeAnnotation(inferred, annotation, errorCatalog = {}, routeKey) {
786
+ if (!annotation || typeof annotation !== "object")
787
+ return inferred;
788
+ // An annotation can name error codes that never showed up as a resolved
789
+ // response status (e.g. thrown from a shared error middleware, not a
790
+ // literal `reply.status(...)` in the handler) — look those up too instead
791
+ // of only ever joining on inferred.responseStatuses.
792
+ const errors = Array.isArray(annotation.errors)
793
+ ? annotation.errors.map((code) => {
794
+ if (!(code in errorCatalog)) {
795
+ console.warn(`⚠️ APIForge: annotation on ${routeKey || "a route"} names error code "${code}", which is not in the error catalog — reported without status/retry/message`);
796
+ }
797
+ return { code, ...(errorCatalog[code] || {}) };
798
+ })
799
+ : inferred.errors;
800
+ return {
801
+ ...inferred,
802
+ summary: typeof annotation.summary === "string"
803
+ ? annotation.summary
804
+ : inferred.summary,
805
+ description: typeof annotation.description === "string"
806
+ ? annotation.description
807
+ : inferred.description,
808
+ tags: Array.isArray(annotation.tags) ? annotation.tags : inferred.tags,
809
+ deprecated: typeof annotation.deprecated === "boolean"
810
+ ? annotation.deprecated
811
+ : inferred.deprecated,
812
+ errors,
813
+ };
814
+ }
815
+ const AUTH_HOOK_NAME_PATTERN = /auth|jwt|verify|require|guard|protect|session/i;
816
+ const AUTH_HOOK_BODY_PATTERN = /jwtVerify|verifyJwt|jwt\.verify|\.verify\(|Authorization|Bearer|req(?:uest)?\.headers\[?['"]?authorization['"]?\]?|unauthorized|401/i;
817
+ function inspectRouteLevelAuth(raw) {
818
+ const collect = (val) => {
819
+ if (!val)
820
+ return [];
821
+ return Array.isArray(val) ? val : [val];
822
+ };
823
+ const hooks = [...collect(raw.onRequest), ...collect(raw.preHandler)];
824
+ const allHookNames = hooks
825
+ .map((fn) => typeof fn === "function" ? fn.name || "(anonymous)" : String(fn))
826
+ .filter(Boolean);
827
+ const matchedAuthHookNames = hooks
828
+ .filter((fn) => {
829
+ if (typeof fn !== "function")
830
+ return false;
831
+ // Pehle naam se try karo (fast path)
832
+ if (fn.name && AUTH_HOOK_NAME_PATTERN.test(fn.name))
833
+ return true;
834
+ // Naam anonymous ho toh function ka source body check karo
835
+ try {
836
+ const src = fn.toString();
837
+ return AUTH_HOOK_BODY_PATTERN.test(src);
838
+ }
839
+ catch {
840
+ return false;
841
+ }
842
+ })
843
+ .map((fn) => fn.name || "(anonymous, matched via body pattern)");
844
+ return {
845
+ hasRouteLevelHooks: hooks.length > 0,
846
+ matchedAuthHookNames,
847
+ allHookNames,
848
+ };
849
+ }
850
+ function buildRoute(raw, scopeCatalog = {}, errorCatalog = {}, envelopes = {}, globalHooks = [], contentTypeParsers = [], sidecarAnnotations = {}) {
851
+ const method = raw.method;
852
+ const path = raw.url;
853
+ const pathParams = [...path.matchAll(/:(\w+)/g)].map((m) => m[1]);
854
+ const inferredTag = path
855
+ .split("/")
856
+ .filter(Boolean)
857
+ .find((s) => !s.startsWith(":") && !/^v\d+$/.test(s)) ||
858
+ "general";
859
+ const operationId = `${method.toLowerCase()}_${path
860
+ .replace(/[^a-zA-Z0-9]/g, "_")
861
+ .replace(/_+/g, "_")
862
+ .replace(/^_|_$/, "")}`;
863
+ const handlerInfo = resolveHandlerInfo(raw.handlerFn, raw);
864
+ const routeLevelAuth = inspectRouteLevelAuth(raw);
865
+ let hasAuthFinal;
866
+ let authSource;
867
+ if (routeLevelAuth.matchedAuthHookNames.length > 0) {
868
+ // Route-level decorator jaise server.authenticate — sabse reliable signal
869
+ hasAuthFinal = true;
870
+ authSource = `route-level hook: ${routeLevelAuth.matchedAuthHookNames.join(", ")}`;
871
+ }
872
+ else if (handlerInfo.resolved && handlerInfo.hasAuth) {
873
+ // Handler ke andar requireScope/requireOperator/auth.either pattern mila
874
+ hasAuthFinal = true;
875
+ authSource = "handler-level guard call";
876
+ }
877
+ else if (routeLevelAuth.hasRouteLevelHooks) {
878
+ // Koi onRequest/preHandler hook hai, lekin naam se auth confirm nahi ho paya —
879
+ // "false" bolna galat hoga (spec requirement #34), isliye "unknown"
880
+ hasAuthFinal = "unknown";
881
+ authSource = `unresolved route-level hook(s): ${routeLevelAuth.allHookNames.join(", ")} — inspect manually`;
882
+ }
883
+ else if (!handlerInfo.resolved) {
884
+ // Handler khud resolve nahi hua, toh confidently "no auth" nahi bol sakte
885
+ hasAuthFinal = "unknown";
886
+ authSource = "handler could not be resolved";
887
+ }
888
+ else {
889
+ // Handler resolve ho gaya AND koi route-level hook nahi AND koi guard call nahi
890
+ hasAuthFinal = false;
891
+ }
892
+ const sourceFor = (line) => handlerInfo.file && line ? `${handlerInfo.file}:${line}` : undefined;
893
+ let scopeInfo = undefined;
894
+ if (handlerInfo.scope) {
895
+ scopeInfo = {
896
+ name: handlerInfo.scope,
897
+ ...(scopeCatalog[handlerInfo.scope] || {}),
898
+ source: sourceFor(handlerInfo.scopeLine),
899
+ };
900
+ }
901
+ else if (handlerInfo.scopes) {
902
+ scopeInfo = {
903
+ options: handlerInfo.scopes.map((s) => ({
904
+ name: s,
905
+ ...(scopeCatalog[s] || {}),
906
+ })),
907
+ condition: handlerInfo.scopeCondition,
908
+ source: sourceFor(handlerInfo.scopeLine),
909
+ };
910
+ }
911
+ else if (handlerInfo.scopeUnresolved) {
912
+ scopeInfo = {
913
+ unresolved: true,
914
+ expression: handlerInfo.scopeExpression,
915
+ source: sourceFor(handlerInfo.scopeLine),
916
+ };
917
+ }
918
+ const errorEntries = Object.entries(errorCatalog).filter(([, v]) => handlerInfo.responseStatuses?.includes(v.status));
919
+ const errors = errorEntries.map(([code, v]) => ({
920
+ code,
921
+ ...v,
922
+ }));
923
+ const prefixMatch = path.match(/^\/(v\d+)(\/|$)/);
924
+ const routePrefix = prefixMatch ? `/${prefixMatch[1]}` : undefined;
925
+ let responseEnvelope = undefined;
926
+ if (routePrefix && envelopes[routePrefix]) {
927
+ const envConfig = envelopes[routePrefix];
928
+ if (!("bare" in envConfig && envConfig.bare)) {
929
+ const isExcluded = envConfig.excluded?.includes(path);
930
+ if (!isExcluded) {
931
+ responseEnvelope = {
932
+ wrapper: envConfig.wrapper,
933
+ dataField: envConfig.dataField,
934
+ };
935
+ }
936
+ }
937
+ }
938
+ else if (routePrefix && Object.keys(envelopes).length > 0) {
939
+ // The caller declared envelope shapes for some prefixes but not this one.
940
+ // Silently defaulting to "bare" here is exactly the bug this feature exists
941
+ // to prevent — a new prefix must be declared (wrapped or explicitly `bare`),
942
+ // not inherit a guess. Surface it instead.
943
+ console.warn(`⚠️ APIForge: no envelope config declared for prefix "${routePrefix}" (route ${method} ${path}) — add an entry to \`envelopes\` (wrapped, or { bare: true }) so the response shape is reported correctly`);
944
+ }
945
+ // Only build an envelope-aware `responses` object when the caller actually
946
+ // configured envelopes — otherwise leave `responses` unset so the generic
947
+ // fallback in export.ts's buildResponses() behaves exactly as before.
948
+ let responses = undefined;
949
+ if (Object.keys(envelopes).length > 0) {
950
+ const upperMethod = method.toUpperCase();
951
+ const primaryStatus = handlerInfo.responseStatuses?.find((s) => s >= 200 && s < 300) ??
952
+ (upperMethod === "POST" ? 201 : upperMethod === "DELETE" ? 204 : 200);
953
+ if (primaryStatus !== 204) {
954
+ const bareSchema = {
955
+ type: "object",
956
+ properties: {
957
+ success: { type: "boolean", example: true },
958
+ endpoint: { type: "string", example: `${upperMethod} ${path}` },
959
+ resource: { type: "string", example: inferredTag.toLowerCase() },
960
+ },
961
+ };
962
+ const schema = responseEnvelope
963
+ ? {
964
+ type: "object",
965
+ description: responseEnvelope.wrapper,
966
+ properties: { [responseEnvelope.dataField]: bareSchema },
967
+ }
968
+ : bareSchema;
969
+ responses = {
970
+ [String(primaryStatus)]: {
971
+ description: "Successful response",
972
+ content: { "application/json": { schema } },
973
+ },
974
+ };
975
+ }
976
+ }
977
+ // Zod-derived field info has real types (enum members, min/max, defaults) —
978
+ // surface it via the standard Route.parameters / Route.requestBody shape,
979
+ // not just the bare field-name arrays, or uploadRoutes()/exportToJSON() (which
980
+ // only ever look at `parameters`/`requestBody`) silently discard it and fall
981
+ // back to generic `{ type: 'string' }` placeholders.
982
+ const zodParameters = (handlerInfo.queryParamsSchema || []).map((f) => ({
983
+ name: f.name,
984
+ in: "query",
985
+ required: f.required !== false,
986
+ type: f.type,
987
+ schema: zodFieldToJsonSchema(f),
988
+ description: `Query parameter: ${f.name}.`,
989
+ }));
990
+ let requestBody = undefined;
991
+ if (handlerInfo.requestBodySchema && handlerInfo.requestBodySchema.length > 0) {
992
+ const properties = {};
993
+ const required = [];
994
+ for (const f of handlerInfo.requestBodySchema) {
995
+ properties[f.name] = zodFieldToJsonSchema(f);
996
+ if (f.required !== false)
997
+ required.push(f.name);
998
+ }
999
+ requestBody = {
1000
+ required: required.length > 0,
1001
+ content: {
1002
+ "application/json": {
1003
+ schema: { type: "object", required, properties },
1004
+ },
1005
+ },
1006
+ };
1007
+ }
1008
+ const inferred = {
1009
+ method,
1010
+ path,
1011
+ tag: inferredTag,
1012
+ tags: [inferredTag],
1013
+ operationId,
1014
+ summary: `${method} ${path}`,
1015
+ description: handlerInfo.resolved
1016
+ ? handlerInfo.deprecated
1017
+ ? `[Deprecated] ${handlerInfo.description || ""}`.trim()
1018
+ : handlerInfo.description
1019
+ : "⚠️ Handler could not be resolved (wrapper/delegated handler) — auth, params, and schema may be incomplete",
1020
+ middleware: [],
1021
+ authRequired: hasAuthFinal,
1022
+ pathParams,
1023
+ queryParams: handlerInfo.queryParams,
1024
+ requestBodyFields: handlerInfo.requestBodyFields,
1025
+ queryParamsSchema: handlerInfo.queryParamsSchema,
1026
+ requestBodySchema: handlerInfo.requestBodySchema,
1027
+ parameters: zodParameters.length > 0 ? zodParameters : undefined,
1028
+ requestBody,
1029
+ responses,
1030
+ headerParams: handlerInfo.headerParams,
1031
+ responseStatuses: handlerInfo.responseStatuses,
1032
+ dynamicStatuses: (handlerInfo.dynamicStatuses || []).map((d) => ({
1033
+ ...d,
1034
+ line: undefined,
1035
+ source: sourceFor(d.line),
1036
+ })),
1037
+ hooks: applicableHooksForPath(path, globalHooks).map((h) => ({
1038
+ type: h.type,
1039
+ scope: h.scope,
1040
+ ...(h.prefix ? { prefix: h.prefix } : {}),
1041
+ })),
1042
+ streaming: handlerInfo.streaming || false,
1043
+ contentTypeParsers: applicableContentTypeParsersForPath(path, contentTypeParsers).map((p) => ({
1044
+ action: p.action,
1045
+ contentType: p.contentType,
1046
+ scope: p.scope,
1047
+ ...(p.prefix ? { prefix: p.prefix } : {}),
1048
+ })),
1049
+ statusSources: (handlerInfo.statusSources || []).map((s) => ({
1050
+ status: s.status,
1051
+ source: sourceFor(s.line),
1052
+ })),
1053
+ headerSources: (handlerInfo.headerSources || []).map((h) => ({
1054
+ name: h.name,
1055
+ source: sourceFor(h.line),
1056
+ })),
1057
+ errors,
1058
+ responseEnvelope,
1059
+ scope: scopeInfo,
1060
+ linkedPaths: [],
1061
+ hasAuth: hasAuthFinal,
1062
+ authSource,
1063
+ framework: "fastify",
1064
+ deprecated: handlerInfo.deprecated,
1065
+ };
1066
+ const routeKey = `${method} ${path}`;
1067
+ const sidecarAnnotation = sidecarAnnotations[routeKey];
1068
+ const inlineAnnotation = raw.config?.apiforge;
1069
+ let merged = mergeAnnotation(inferred, sidecarAnnotation, errorCatalog, routeKey);
1070
+ merged = mergeAnnotation(merged, inlineAnnotation, errorCatalog, routeKey);
1071
+ if (sidecarAnnotation ||
1072
+ (inlineAnnotation && typeof inlineAnnotation === "object")) {
1073
+ merged.annotated = true;
1074
+ }
1075
+ return merged;
1076
+ }