@goboost/scanner-typescript 1.2.1
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/package.json +19 -0
- package/src/controller-extractor.ts +364 -0
- package/src/dto-extractor.ts +363 -0
- package/src/guard-extractor.ts +206 -0
- package/src/index.ts +284 -0
- package/src/prisma-schema-extractor.ts +345 -0
- package/src/unsupported-detector.ts +253 -0
- package/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goboost/scanner-typescript",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "TypeScript AST scanner for source-framework-to-Go migration",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"scan": "node dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@prisma/internals": "^6.0.0",
|
|
13
|
+
"ts-morph": "^21.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^20.10.0",
|
|
17
|
+
"typescript": "^5.3.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller extractor - finds @HybridRoute + @Controller decorated classes
|
|
3
|
+
* Extracts basePath, method decorators, handler names, path/query params, and guard decorators
|
|
4
|
+
*/
|
|
5
|
+
import { Project, SourceFile, ClassDeclaration, Decorator, MethodDeclaration, ParameterDeclaration, Node } from 'ts-morph';
|
|
6
|
+
|
|
7
|
+
interface ControllerSpec {
|
|
8
|
+
name: string;
|
|
9
|
+
basePath: string;
|
|
10
|
+
sourceFile: string;
|
|
11
|
+
guards: string[];
|
|
12
|
+
dependencies: string[];
|
|
13
|
+
routes: RouteMapping[];
|
|
14
|
+
unsupportedFeatures: string[];
|
|
15
|
+
hasHybridRoute: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RouteMapping {
|
|
19
|
+
id: string;
|
|
20
|
+
httpMethod: string;
|
|
21
|
+
path: string;
|
|
22
|
+
handlerName: string;
|
|
23
|
+
pathParams: ParamDef[];
|
|
24
|
+
queryParams: ParamDef[];
|
|
25
|
+
requestBody: DtoRef | null;
|
|
26
|
+
responseBody: DtoRef | null;
|
|
27
|
+
statusCode: number;
|
|
28
|
+
guards: string[];
|
|
29
|
+
unsupportedFeatures: string[];
|
|
30
|
+
canGenerate: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DtoRef {
|
|
34
|
+
$ref: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParamDef {
|
|
38
|
+
name: string;
|
|
39
|
+
type: string;
|
|
40
|
+
isRequired: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HTTP_METHOD_DECORATORS = ['Get', 'Post', 'Put', 'Delete', 'Patch'];
|
|
44
|
+
|
|
45
|
+
// Status code mapping based on HTTP method
|
|
46
|
+
const DEFAULT_STATUS_CODES: Record<string, number> = {
|
|
47
|
+
'GET': 200,
|
|
48
|
+
'POST': 201,
|
|
49
|
+
'PUT': 200,
|
|
50
|
+
'PATCH': 200,
|
|
51
|
+
'DELETE': 200,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function extractControllers(project: Project): ControllerSpec[] {
|
|
55
|
+
const controllers: ControllerSpec[] = [];
|
|
56
|
+
|
|
57
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
58
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
59
|
+
if (hasHybridRouteDecorator(classDecl)) {
|
|
60
|
+
const controller = extractController(classDecl, sourceFile);
|
|
61
|
+
controllers.push(controller);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Sort controllers alphabetically by name for determinism
|
|
67
|
+
controllers.sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
|
|
69
|
+
return controllers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasHybridRouteDecorator(classDecl: ClassDeclaration): boolean {
|
|
73
|
+
const decorators = classDecl.getDecorators();
|
|
74
|
+
return decorators.some(d => d.getName() === 'HybridRoute');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractController(classDecl: ClassDeclaration, sourceFile: SourceFile): ControllerSpec {
|
|
78
|
+
const name = classDecl.getName() || 'AnonymousController';
|
|
79
|
+
const basePath = extractBasePath(classDecl);
|
|
80
|
+
const classGuards = extractClassGuards(classDecl);
|
|
81
|
+
const dependencies = extractDependencies(classDecl);
|
|
82
|
+
const { routes, unsupportedFeatures } = extractRoutes(classDecl, name, classGuards);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
name,
|
|
86
|
+
basePath,
|
|
87
|
+
sourceFile: getRelativePath(sourceFile.getFilePath()),
|
|
88
|
+
guards: classGuards,
|
|
89
|
+
dependencies,
|
|
90
|
+
routes,
|
|
91
|
+
unsupportedFeatures,
|
|
92
|
+
hasHybridRoute: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractBasePath(classDecl: ClassDeclaration): string {
|
|
97
|
+
const controllerDecorator = classDecl.getDecorators().find(d => d.getName() === 'Controller');
|
|
98
|
+
if (!controllerDecorator) return '';
|
|
99
|
+
|
|
100
|
+
const args = controllerDecorator.getArguments();
|
|
101
|
+
if (args.length > 0) {
|
|
102
|
+
const arg = args[0];
|
|
103
|
+
if (Node.isStringLiteral(arg)) {
|
|
104
|
+
return arg.getText().replace(/['"]/g, '');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractClassGuards(classDecl: ClassDeclaration): string[] {
|
|
111
|
+
const guards: string[] = [];
|
|
112
|
+
const useGuardsDecorator = classDecl.getDecorators().find(d => d.getName() === 'UseGuards');
|
|
113
|
+
if (useGuardsDecorator) {
|
|
114
|
+
const args = useGuardsDecorator.getArguments();
|
|
115
|
+
for (const arg of args) {
|
|
116
|
+
const guardName = arg.getText().replace('()', '').trim();
|
|
117
|
+
guards.push(guardName);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return guards;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractDependencies(classDecl: ClassDeclaration): string[] {
|
|
124
|
+
const dependencies: string[] = [];
|
|
125
|
+
for (const prop of classDecl.getProperties()) {
|
|
126
|
+
const type = prop.getType().getText();
|
|
127
|
+
// Extract service names from injected properties
|
|
128
|
+
if (type.includes('Service')) {
|
|
129
|
+
dependencies.push(type);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return dependencies;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractRoutes(
|
|
136
|
+
classDecl: ClassDeclaration,
|
|
137
|
+
controllerName: string,
|
|
138
|
+
classGuards: string[]
|
|
139
|
+
): { routes: RouteMapping[]; unsupportedFeatures: string[] } {
|
|
140
|
+
const routes: RouteMapping[] = [];
|
|
141
|
+
const controllerUnsupported: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const method of classDecl.getMethods()) {
|
|
144
|
+
for (const decoratorName of HTTP_METHOD_DECORATORS) {
|
|
145
|
+
const httpDecorator = method.getDecorators().find(d => d.getName() === decoratorName);
|
|
146
|
+
if (httpDecorator) {
|
|
147
|
+
const routePath = extractDecoratorPath(httpDecorator);
|
|
148
|
+
const methodGuards = extractMethodGuards(method);
|
|
149
|
+
const allGuards = [...new Set([...classGuards, ...methodGuards])];
|
|
150
|
+
|
|
151
|
+
const route: RouteMapping = {
|
|
152
|
+
id: `${controllerName}:${decoratorName.toUpperCase()}:${routePath || '/'}`,
|
|
153
|
+
httpMethod: decoratorName.toUpperCase(),
|
|
154
|
+
path: routePath || '/',
|
|
155
|
+
handlerName: method.getName(),
|
|
156
|
+
pathParams: extractPathParams(method),
|
|
157
|
+
queryParams: extractQueryParams(method),
|
|
158
|
+
requestBody: extractRequestBody(method),
|
|
159
|
+
responseBody: extractResponseBody(method),
|
|
160
|
+
statusCode: extractStatusCode(method, decoratorName.toUpperCase()),
|
|
161
|
+
guards: methodGuards, // Only route-level guards
|
|
162
|
+
unsupportedFeatures: [],
|
|
163
|
+
canGenerate: true,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
routes.push(route);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Sort routes by method then path for determinism
|
|
173
|
+
routes.sort((a, b) => {
|
|
174
|
+
const methodCompare = a.httpMethod.localeCompare(b.httpMethod);
|
|
175
|
+
if (methodCompare !== 0) return methodCompare;
|
|
176
|
+
return a.path.localeCompare(b.path);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Edge case: Empty controller (no routes)
|
|
180
|
+
// Add warning flag if controller has @HybridRoute but no HTTP method decorators
|
|
181
|
+
if (routes.length === 0) {
|
|
182
|
+
controllerUnsupported.push('empty-controller');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { routes, unsupportedFeatures: controllerUnsupported };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractDecoratorPath(decorator: Decorator): string {
|
|
189
|
+
const args = decorator.getArguments();
|
|
190
|
+
if (args.length > 0) {
|
|
191
|
+
const arg = args[0];
|
|
192
|
+
if (Node.isStringLiteral(arg)) {
|
|
193
|
+
return arg.getText().replace(/['"]/g, '');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractPathParams(method: MethodDeclaration): ParamDef[] {
|
|
200
|
+
const params: ParamDef[] = [];
|
|
201
|
+
for (const param of method.getParameters()) {
|
|
202
|
+
const paramDecorator = param.getDecorators().find(d => d.getName() === 'Param');
|
|
203
|
+
if (paramDecorator) {
|
|
204
|
+
const args = paramDecorator.getArguments();
|
|
205
|
+
const paramName = args.length > 0 && Node.isStringLiteral(args[0])
|
|
206
|
+
? args[0].getText().replace(/['"]/g, '')
|
|
207
|
+
: param.getName();
|
|
208
|
+
|
|
209
|
+
params.push({
|
|
210
|
+
name: paramName,
|
|
211
|
+
type: mapTypeToGo(param.getType().getText()),
|
|
212
|
+
isRequired: true,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return params;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extractQueryParams(method: MethodDeclaration): ParamDef[] {
|
|
220
|
+
const params: ParamDef[] = [];
|
|
221
|
+
for (const param of method.getParameters()) {
|
|
222
|
+
const queryDecorator = param.getDecorators().find(d => d.getName() === 'Query');
|
|
223
|
+
if (queryDecorator) {
|
|
224
|
+
const args = queryDecorator.getArguments();
|
|
225
|
+
const paramName = args.length > 0 && Node.isStringLiteral(args[0])
|
|
226
|
+
? args[0].getText().replace(/['"]/g, '')
|
|
227
|
+
: param.getName();
|
|
228
|
+
|
|
229
|
+
params.push({
|
|
230
|
+
name: paramName,
|
|
231
|
+
type: mapTypeToGo(param.getType().getText()),
|
|
232
|
+
isRequired: !param.hasQuestionToken(),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return params;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function extractRequestBody(method: MethodDeclaration): DtoRef | null {
|
|
240
|
+
for (const param of method.getParameters()) {
|
|
241
|
+
const bodyDecorator = param.getDecorators().find(d => d.getName() === 'Body');
|
|
242
|
+
if (bodyDecorator) {
|
|
243
|
+
const type = param.getType();
|
|
244
|
+
const typeName = extractTypeName(type.getText());
|
|
245
|
+
if (typeName) {
|
|
246
|
+
return { $ref: typeName };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function extractResponseBody(method: MethodDeclaration): DtoRef | null {
|
|
254
|
+
const returnType = method.getReturnType();
|
|
255
|
+
const returnText = returnType.getText();
|
|
256
|
+
|
|
257
|
+
// Extract from Promise<T> or Observable<T>
|
|
258
|
+
const promiseMatch = returnText.match(/Promise<(.+)>/);
|
|
259
|
+
const observableMatch = returnText.match(/Observable<(.+)>/);
|
|
260
|
+
|
|
261
|
+
let typeName = returnText;
|
|
262
|
+
if (promiseMatch) {
|
|
263
|
+
typeName = promiseMatch[1];
|
|
264
|
+
} else if (observableMatch) {
|
|
265
|
+
typeName = observableMatch[1];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Skip void and primitive types
|
|
269
|
+
if (typeName === 'void' || isPrimitiveType(typeName)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const cleanName = extractTypeName(typeName);
|
|
274
|
+
if (cleanName) {
|
|
275
|
+
return { $ref: cleanName };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractStatusCode(method: MethodDeclaration, httpMethod: string): number {
|
|
282
|
+
const httpCodeDecorator = method.getDecorators().find(d => d.getName() === 'HttpCode');
|
|
283
|
+
if (httpCodeDecorator) {
|
|
284
|
+
const args = httpCodeDecorator.getArguments();
|
|
285
|
+
if (args.length > 0) {
|
|
286
|
+
const code = parseInt(args[0].getText(), 10);
|
|
287
|
+
if (!isNaN(code)) {
|
|
288
|
+
return code;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return DEFAULT_STATUS_CODES[httpMethod] || 200;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function extractMethodGuards(method: MethodDeclaration): string[] {
|
|
296
|
+
const guards: string[] = [];
|
|
297
|
+
const useGuardsDecorator = method.getDecorators().find(d => d.getName() === 'UseGuards');
|
|
298
|
+
if (useGuardsDecorator) {
|
|
299
|
+
const args = useGuardsDecorator.getArguments();
|
|
300
|
+
for (const arg of args) {
|
|
301
|
+
const guardName = arg.getText().replace('()', '').trim();
|
|
302
|
+
guards.push(guardName);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return guards;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function extractTypeName(typeText: string): string | null {
|
|
309
|
+
// Handle arrays
|
|
310
|
+
if (typeText.endsWith('[]')) {
|
|
311
|
+
return extractTypeName(typeText.slice(0, -2));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle generics like Array<T>
|
|
315
|
+
const arrayMatch = typeText.match(/Array<(.+)>/);
|
|
316
|
+
if (arrayMatch) {
|
|
317
|
+
return extractTypeName(arrayMatch[1]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Skip primitives
|
|
321
|
+
if (isPrimitiveType(typeText)) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Clean up type name (remove imports path, etc.)
|
|
326
|
+
const cleanName = typeText.split('.').pop()?.split('<')[0].trim();
|
|
327
|
+
return cleanName || null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isPrimitiveType(type: string): boolean {
|
|
331
|
+
const primitives = [
|
|
332
|
+
'string', 'number', 'boolean', 'void', 'any', 'unknown',
|
|
333
|
+
'null', 'undefined', 'object', 'Date', 'Buffer',
|
|
334
|
+
'Promise<void>', 'Observable<void>'
|
|
335
|
+
];
|
|
336
|
+
return primitives.some(p => type === p || type.includes(p + '<') || type === p + '[]');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function mapTypeToGo(tsType: string): string {
|
|
340
|
+
const typeMap: Record<string, string> = {
|
|
341
|
+
'string': 'string',
|
|
342
|
+
'number': 'float64',
|
|
343
|
+
'boolean': 'bool',
|
|
344
|
+
'Date': 'time.Time',
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
for (const [ts, go] of Object.entries(typeMap)) {
|
|
348
|
+
if (tsType.includes(ts)) {
|
|
349
|
+
return go;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return 'any';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getRelativePath(filePath: string): string {
|
|
357
|
+
// Convert absolute path to relative from project root
|
|
358
|
+
const parts = filePath.split(/[\\/]/);
|
|
359
|
+
const srcIndex = parts.indexOf('src');
|
|
360
|
+
if (srcIndex !== -1) {
|
|
361
|
+
return parts.slice(srcIndex).join('/');
|
|
362
|
+
}
|
|
363
|
+
return filePath;
|
|
364
|
+
}
|