inviton-backduck 1.0.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/LICENSE +21 -0
- package/README.md +302 -0
- package/dist/apidoc/api-doc-generator.d.ts +58 -0
- package/dist/apidoc/api-doc-generator.d.ts.map +1 -0
- package/dist/apidoc/api-doc-generator.js +201 -0
- package/dist/apidoc/api-doc-generator.js.map +1 -0
- package/dist/apidoc/config.d.ts +153 -0
- package/dist/apidoc/config.d.ts.map +1 -0
- package/dist/apidoc/config.js +254 -0
- package/dist/apidoc/config.js.map +1 -0
- package/dist/apidoc/controller-parser.d.ts +208 -0
- package/dist/apidoc/controller-parser.d.ts.map +1 -0
- package/dist/apidoc/controller-parser.js +686 -0
- package/dist/apidoc/controller-parser.js.map +1 -0
- package/dist/apidoc/html-generator.d.ts +290 -0
- package/dist/apidoc/html-generator.d.ts.map +1 -0
- package/dist/apidoc/html-generator.js +2295 -0
- package/dist/apidoc/html-generator.js.map +1 -0
- package/dist/apidoc/index.d.ts +20 -0
- package/dist/apidoc/index.d.ts.map +1 -0
- package/dist/apidoc/index.js +16 -0
- package/dist/apidoc/index.js.map +1 -0
- package/dist/apidoc/openapi-builder.d.ts +169 -0
- package/dist/apidoc/openapi-builder.d.ts.map +1 -0
- package/dist/apidoc/openapi-builder.js +634 -0
- package/dist/apidoc/openapi-builder.js.map +1 -0
- package/dist/apidoc/parameterGeneratorRegistry.d.ts +20 -0
- package/dist/apidoc/parameterGeneratorRegistry.d.ts.map +1 -0
- package/dist/apidoc/parameterGeneratorRegistry.js +6 -0
- package/dist/apidoc/parameterGeneratorRegistry.js.map +1 -0
- package/dist/apidoc/test-type-resolver.d.ts +2 -0
- package/dist/apidoc/test-type-resolver.d.ts.map +1 -0
- package/dist/apidoc/test-type-resolver.js +6 -0
- package/dist/apidoc/test-type-resolver.js.map +1 -0
- package/dist/apidoc/type-resolver.d.ts +266 -0
- package/dist/apidoc/type-resolver.d.ts.map +1 -0
- package/dist/apidoc/type-resolver.js +1226 -0
- package/dist/apidoc/type-resolver.js.map +1 -0
- package/dist/apidoc/verify-type-resolution.d.ts +3 -0
- package/dist/apidoc/verify-type-resolution.d.ts.map +1 -0
- package/dist/apidoc/verify-type-resolution.js +29 -0
- package/dist/apidoc/verify-type-resolution.js.map +1 -0
- package/dist/bun/bunRouter.d.ts +70 -0
- package/dist/bun/bunRouter.d.ts.map +1 -0
- package/dist/bun/bunRouter.js +324 -0
- package/dist/bun/bunRouter.js.map +1 -0
- package/dist/bun/bunServer.d.ts +72 -0
- package/dist/bun/bunServer.d.ts.map +1 -0
- package/dist/bun/bunServer.js +218 -0
- package/dist/bun/bunServer.js.map +1 -0
- package/dist/bun/bunStaticFiles.d.ts +76 -0
- package/dist/bun/bunStaticFiles.d.ts.map +1 -0
- package/dist/bun/bunStaticFiles.js +251 -0
- package/dist/bun/bunStaticFiles.js.map +1 -0
- package/dist/bun/index.d.ts +7 -0
- package/dist/bun/index.d.ts.map +1 -0
- package/dist/bun/index.js +7 -0
- package/dist/bun/index.js.map +1 -0
- package/dist/data-contracts.d.ts +132 -0
- package/dist/data-contracts.d.ts.map +1 -0
- package/dist/data-contracts.js +2 -0
- package/dist/data-contracts.js.map +1 -0
- package/dist/decorators.d.ts +75 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +101 -0
- package/dist/decorators.js.map +1 -0
- package/dist/express/expressFrontendRouter.d.ts +17 -0
- package/dist/express/expressFrontendRouter.d.ts.map +1 -0
- package/dist/express/expressFrontendRouter.js +33 -0
- package/dist/express/expressFrontendRouter.js.map +1 -0
- package/dist/express/expressRouter.d.ts +25 -0
- package/dist/express/expressRouter.d.ts.map +1 -0
- package/dist/express/expressRouter.js +150 -0
- package/dist/express/expressRouter.js.map +1 -0
- package/dist/express/index.d.ts +6 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +6 -0
- package/dist/express/index.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +162 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +350 -0
- package/dist/router.js.map +1 -0
- package/dist/runtime-detect.d.ts +20 -0
- package/dist/runtime-detect.d.ts.map +1 -0
- package/dist/runtime-detect.js +20 -0
- package/dist/runtime-detect.js.map +1 -0
- package/dist/server.d.ts +126 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +181 -0
- package/dist/server.js.map +1 -0
- package/dist/utils.d.ts +83 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +157 -0
- package/dist/utils.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Node, Project, } from 'ts-morph';
|
|
4
|
+
import { ApiDocConfig } from './config';
|
|
5
|
+
/**
|
|
6
|
+
* TypeScript type resolver for OpenAPI schema generation
|
|
7
|
+
* Handles inheritance, utility types (Pick, Omit), Temporal types, and JSDoc extraction
|
|
8
|
+
*/
|
|
9
|
+
export class TypeResolver {
|
|
10
|
+
project;
|
|
11
|
+
serverDir;
|
|
12
|
+
resolvedTypes = new Map();
|
|
13
|
+
resolvingStack = new Set();
|
|
14
|
+
serviceInterfaceCache = new Map();
|
|
15
|
+
conversionDepth = 0;
|
|
16
|
+
MAX_CONVERSION_DEPTH = 20;
|
|
17
|
+
/** Cache for OpenAPI type conversions keyed by type text */
|
|
18
|
+
openApiTypeCache = new Map();
|
|
19
|
+
/** Cache for loaded source files */
|
|
20
|
+
sourceFileCache = new Map();
|
|
21
|
+
/** Cache for directory file listings */
|
|
22
|
+
directoryFilesCache = new Map();
|
|
23
|
+
constructor(serverDir) {
|
|
24
|
+
this.serverDir = path.resolve(serverDir);
|
|
25
|
+
this.project = new Project({
|
|
26
|
+
tsConfigFilePath: path.join(this.serverDir, 'tsconfig.json'),
|
|
27
|
+
skipAddingFilesFromTsConfig: true,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a type by name and return its OpenAPI schema
|
|
32
|
+
*/
|
|
33
|
+
resolveType(typeName, sourceFile) {
|
|
34
|
+
// Check cache first
|
|
35
|
+
const cached = this.resolvedTypes.get(typeName);
|
|
36
|
+
if (cached) {
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
// Check for circular reference
|
|
40
|
+
if (this.resolvingStack.has(typeName)) {
|
|
41
|
+
return {
|
|
42
|
+
name: typeName,
|
|
43
|
+
description: null,
|
|
44
|
+
properties: [],
|
|
45
|
+
isEnum: false,
|
|
46
|
+
sourcePath: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Mark as resolving to detect circular references
|
|
50
|
+
this.resolvingStack.add(typeName);
|
|
51
|
+
try {
|
|
52
|
+
const resolved = this.resolveTypeInternal(typeName, sourceFile);
|
|
53
|
+
if (resolved) {
|
|
54
|
+
this.resolvedTypes.set(typeName, resolved);
|
|
55
|
+
}
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.resolvingStack.delete(typeName);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convert a resolved type to OpenAPI schema
|
|
64
|
+
*/
|
|
65
|
+
typeToOpenApiSchema(resolvedType) {
|
|
66
|
+
if (resolvedType.isEnum && resolvedType.enumValues) {
|
|
67
|
+
return {
|
|
68
|
+
type: typeof resolvedType.enumValues[0] === 'string' ? 'string' : 'integer',
|
|
69
|
+
enum: resolvedType.enumValues,
|
|
70
|
+
description: resolvedType.description || undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const properties = {};
|
|
74
|
+
const required = [];
|
|
75
|
+
for (const prop of resolvedType.properties) {
|
|
76
|
+
properties[prop.name] = {
|
|
77
|
+
...prop.type,
|
|
78
|
+
description: prop.description || prop.type.description,
|
|
79
|
+
};
|
|
80
|
+
if (!prop.optional) {
|
|
81
|
+
required.push(prop.name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties,
|
|
87
|
+
required: required.length > 0 ? required : undefined,
|
|
88
|
+
description: resolvedType.description || undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Convert a TypeScript type directly to OpenAPI type
|
|
93
|
+
*/
|
|
94
|
+
tsTypeToOpenApi(type, propertyName) {
|
|
95
|
+
// Guard against infinite recursion
|
|
96
|
+
if (this.conversionDepth >= this.MAX_CONVERSION_DEPTH) {
|
|
97
|
+
return { type: 'object', additionalProperties: true };
|
|
98
|
+
}
|
|
99
|
+
// Check cache first (use type text as key)
|
|
100
|
+
const typeText = type.getText();
|
|
101
|
+
const cached = this.openApiTypeCache.get(typeText);
|
|
102
|
+
if (cached) {
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
this.conversionDepth++;
|
|
106
|
+
try {
|
|
107
|
+
const result = this.tsTypeToOpenApiInternal(type, propertyName);
|
|
108
|
+
// Cache the result (only for non-trivial types)
|
|
109
|
+
if (typeText.length > 10) {
|
|
110
|
+
this.openApiTypeCache.set(typeText, result);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
this.conversionDepth--;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Internal implementation of tsTypeToOpenApi
|
|
120
|
+
*/
|
|
121
|
+
tsTypeToOpenApiInternal(type, _propertyName) {
|
|
122
|
+
const typeText = type.getText();
|
|
123
|
+
// Check for special types (Temporal, Date, etc.)
|
|
124
|
+
const specialSchema = ApiDocConfig.getSpecialTypeSchema(typeText);
|
|
125
|
+
if (specialSchema) {
|
|
126
|
+
return specialSchema;
|
|
127
|
+
}
|
|
128
|
+
// Handle Temporal namespace types
|
|
129
|
+
if (typeText.includes('Temporal.')) {
|
|
130
|
+
const temporalMatch = typeText.match(/Temporal\.(PlainDateTime|PlainDate|PlainTime|Instant)/);
|
|
131
|
+
if (temporalMatch) {
|
|
132
|
+
const schema = ApiDocConfig.getSpecialTypeSchema(`Temporal.${temporalMatch[1]}`);
|
|
133
|
+
if (schema) {
|
|
134
|
+
return schema;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Handle enum types - check if we can resolve to get member info
|
|
139
|
+
if (type.isEnum() || type.isEnumLiteral()) {
|
|
140
|
+
const symbol = type.getSymbol() || type.getAliasSymbol();
|
|
141
|
+
if (symbol) {
|
|
142
|
+
const enumName = symbol.getName();
|
|
143
|
+
const resolved = this.resolveType(enumName);
|
|
144
|
+
if (resolved && resolved.isEnum && resolved.enumMembers) {
|
|
145
|
+
return {
|
|
146
|
+
'type': typeof resolved.enumValues?.[0] === 'string' ? 'string' : 'integer',
|
|
147
|
+
'enum': resolved.enumValues,
|
|
148
|
+
'x-enum-members': resolved.enumMembers,
|
|
149
|
+
'description': resolved.description || undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Handle primitives
|
|
155
|
+
if (type.isString() || type.isStringLiteral()) {
|
|
156
|
+
return { type: 'string' };
|
|
157
|
+
}
|
|
158
|
+
if (type.isNumber() || type.isNumberLiteral()) {
|
|
159
|
+
return { type: 'number' };
|
|
160
|
+
}
|
|
161
|
+
if (type.isBoolean() || type.isBooleanLiteral()) {
|
|
162
|
+
return { type: 'boolean' };
|
|
163
|
+
}
|
|
164
|
+
// Handle null/undefined
|
|
165
|
+
if (typeText === 'null' || typeText === 'undefined') {
|
|
166
|
+
return { type: 'string', nullable: true };
|
|
167
|
+
}
|
|
168
|
+
// Handle 'any' type
|
|
169
|
+
if (typeText === 'any') {
|
|
170
|
+
return { type: 'object', additionalProperties: true };
|
|
171
|
+
}
|
|
172
|
+
// Handle arrays
|
|
173
|
+
if (type.isArray()) {
|
|
174
|
+
const elementType = type.getArrayElementType();
|
|
175
|
+
if (elementType) {
|
|
176
|
+
return {
|
|
177
|
+
type: 'array',
|
|
178
|
+
items: this.tsTypeToOpenApi(elementType),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { type: 'array', items: { type: 'object' } };
|
|
182
|
+
}
|
|
183
|
+
// Handle union types
|
|
184
|
+
if (type.isUnion()) {
|
|
185
|
+
const unionTypes = type.getUnionTypes();
|
|
186
|
+
const nonNullTypes = unionTypes.filter(t => t.getText() !== 'undefined' && t.getText() !== 'null');
|
|
187
|
+
const hasNull = unionTypes.length !== nonNullTypes.length;
|
|
188
|
+
if (nonNullTypes.length === 1) {
|
|
189
|
+
const result = this.tsTypeToOpenApi(nonNullTypes[0]);
|
|
190
|
+
if (hasNull) {
|
|
191
|
+
result.nullable = true;
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
// Check if all union members are literals (enum-like)
|
|
196
|
+
const literals = nonNullTypes.filter(t => t.isStringLiteral() || t.isNumberLiteral());
|
|
197
|
+
if (literals.length === nonNullTypes.length && literals.length > 0) {
|
|
198
|
+
const values = literals.map((t) => {
|
|
199
|
+
if (t.isStringLiteral()) {
|
|
200
|
+
return t.getLiteralValue();
|
|
201
|
+
}
|
|
202
|
+
return t.getLiteralValue();
|
|
203
|
+
});
|
|
204
|
+
// Try to find enum member info by checking if all literals are from the same enum
|
|
205
|
+
const firstLiteral = nonNullTypes[0];
|
|
206
|
+
const symbol = firstLiteral.getSymbol();
|
|
207
|
+
if (symbol) {
|
|
208
|
+
// Try to get the parent enum declaration
|
|
209
|
+
const declarations = symbol.getDeclarations();
|
|
210
|
+
if (declarations.length > 0) {
|
|
211
|
+
const firstDecl = declarations[0];
|
|
212
|
+
const parent = firstDecl.getParent();
|
|
213
|
+
if (parent && Node.isEnumDeclaration(parent)) {
|
|
214
|
+
const enumName = parent.getName();
|
|
215
|
+
const resolved = this.resolveType(enumName);
|
|
216
|
+
if (resolved && resolved.isEnum && resolved.enumMembers) {
|
|
217
|
+
return {
|
|
218
|
+
'type': typeof values[0] === 'string' ? 'string' : 'integer',
|
|
219
|
+
'enum': values,
|
|
220
|
+
'x-enum-members': resolved.enumMembers,
|
|
221
|
+
'nullable': hasNull || undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
type: typeof values[0] === 'string' ? 'string' : 'integer',
|
|
229
|
+
enum: values,
|
|
230
|
+
nullable: hasNull || undefined,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// Multiple different types - use oneOf
|
|
234
|
+
return {
|
|
235
|
+
oneOf: nonNullTypes.map(t => this.tsTypeToOpenApi(t)),
|
|
236
|
+
nullable: hasNull || undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Handle intersection types (merge properties)
|
|
240
|
+
if (type.isIntersection()) {
|
|
241
|
+
const intersectionTypes = type.getIntersectionTypes();
|
|
242
|
+
const allProperties = {};
|
|
243
|
+
const allRequired = [];
|
|
244
|
+
for (const intersectType of intersectionTypes) {
|
|
245
|
+
const resolved = this.tsTypeToOpenApi(intersectType);
|
|
246
|
+
if (resolved.properties) {
|
|
247
|
+
Object.assign(allProperties, resolved.properties);
|
|
248
|
+
}
|
|
249
|
+
if (resolved.required) {
|
|
250
|
+
allRequired.push(...resolved.required.filter(r => !allRequired.includes(r)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: Object.keys(allProperties).length > 0 ? allProperties : undefined,
|
|
256
|
+
required: allRequired.length > 0 ? allRequired : undefined,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// Handle object types with properties
|
|
260
|
+
if (type.isObject()) {
|
|
261
|
+
const symbol = type.getSymbol() || type.getAliasSymbol();
|
|
262
|
+
if (symbol) {
|
|
263
|
+
const name = symbol.getName();
|
|
264
|
+
// Skip built-in types
|
|
265
|
+
if ([
|
|
266
|
+
'Array',
|
|
267
|
+
'Object',
|
|
268
|
+
'Promise',
|
|
269
|
+
'Map',
|
|
270
|
+
'Set',
|
|
271
|
+
].includes(name)) {
|
|
272
|
+
return { type: 'object', additionalProperties: true };
|
|
273
|
+
}
|
|
274
|
+
// Try to resolve as a known type
|
|
275
|
+
const resolved = this.resolveTypeFromSymbol(symbol);
|
|
276
|
+
if (resolved) {
|
|
277
|
+
// Return reference for complex types
|
|
278
|
+
return {
|
|
279
|
+
$ref: `#/components/schemas/${resolved.name}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Handle inline object types
|
|
284
|
+
const properties = type.getProperties();
|
|
285
|
+
if (properties.length > 0) {
|
|
286
|
+
const props = {};
|
|
287
|
+
const required = [];
|
|
288
|
+
for (const prop of properties) {
|
|
289
|
+
const propName = prop.getName();
|
|
290
|
+
const declarations = prop.getDeclarations();
|
|
291
|
+
if (declarations.length > 0) {
|
|
292
|
+
const decl = declarations[0];
|
|
293
|
+
if (Node.isPropertySignature(decl)) {
|
|
294
|
+
const propType = decl.getType();
|
|
295
|
+
props[propName] = this.tsTypeToOpenApi(propType, propName);
|
|
296
|
+
if (!decl.hasQuestionToken()) {
|
|
297
|
+
required.push(propName);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: props,
|
|
305
|
+
required: required.length > 0 ? required : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Handle Record<K, V> type
|
|
310
|
+
if (typeText.startsWith('Record<')) {
|
|
311
|
+
return {
|
|
312
|
+
type: 'object',
|
|
313
|
+
additionalProperties: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Default to object
|
|
317
|
+
return { type: 'object' };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Extract JSDoc description from a property
|
|
321
|
+
*/
|
|
322
|
+
extractPropertyJsDoc(property) {
|
|
323
|
+
const jsDocs = property.getJsDocs();
|
|
324
|
+
if (jsDocs.length > 0) {
|
|
325
|
+
const description = jsDocs[0].getDescription();
|
|
326
|
+
return this.cleanJsDocDescription(description?.trim() || null, true);
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Check if JSDoc contains @privateField tag (case insensitive)
|
|
332
|
+
* Fields with this tag should be omitted from API documentation
|
|
333
|
+
*/
|
|
334
|
+
hasPrivateFieldTag(jsdocText) {
|
|
335
|
+
if (!jsdocText) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
return /@privatefield\b/i.test(jsdocText);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Check if JSDoc contains @enterpriseOnly tag (case insensitive)
|
|
342
|
+
* Fields with this tag are only available in enterprise mode
|
|
343
|
+
*/
|
|
344
|
+
hasEnterpriseOnlyTag(jsdocText) {
|
|
345
|
+
if (!jsdocText) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return /@enterpriseonly\b/i.test(jsdocText);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Clean JSDoc description by removing other JSDoc tags but preserving {@link} tags
|
|
352
|
+
* The {@link} tags will be converted to HTML links later by the HTML generator
|
|
353
|
+
* @param description The raw JSDoc description
|
|
354
|
+
* @param preserveNewlines Whether to preserve newlines (default: false for backward compatibility)
|
|
355
|
+
*/
|
|
356
|
+
cleanJsDocDescription(description, preserveNewlines = false) {
|
|
357
|
+
if (!description) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
// Preserve {@link TypeName} and {@link TypeName|text} for later HTML conversion
|
|
361
|
+
// Only remove other JSDoc inline tags like {@see ...}, {@code ...}, etc.
|
|
362
|
+
// Use a negative lookahead to exclude @link from the removal pattern
|
|
363
|
+
let cleaned = description.replace(/\{@(?!link\s)(\w+)\s[^}]*\}/g, '');
|
|
364
|
+
if (!preserveNewlines) {
|
|
365
|
+
// Normalize whitespace - replace multiple whitespace with single space
|
|
366
|
+
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
// Preserve newlines AND indentation (for code blocks in descriptions)
|
|
370
|
+
cleaned = cleaned.trim();
|
|
371
|
+
}
|
|
372
|
+
return cleaned || null;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Get all resolved types (for component schemas)
|
|
376
|
+
*/
|
|
377
|
+
getAllResolvedTypes() {
|
|
378
|
+
return this.resolvedTypes;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Clear the resolved types cache
|
|
382
|
+
*/
|
|
383
|
+
clearCache() {
|
|
384
|
+
this.resolvedTypes.clear();
|
|
385
|
+
this.resolvingStack.clear();
|
|
386
|
+
this.serviceInterfaceCache.clear();
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Add a source file to the project for type resolution (with caching)
|
|
390
|
+
*/
|
|
391
|
+
addSourceFile(filePath) {
|
|
392
|
+
// Check cache first
|
|
393
|
+
const cached = this.sourceFileCache.get(filePath);
|
|
394
|
+
if (cached) {
|
|
395
|
+
return cached;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
399
|
+
this.sourceFileCache.set(filePath, sourceFile);
|
|
400
|
+
return sourceFile;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Extract extra parameters generator info from @extraParameters JSDoc tag in service interface method.
|
|
408
|
+
* @param interfaceName The name of the service interface (e.g., "IOfferService")
|
|
409
|
+
* @param methodName The name of the method to extract extra parameters from
|
|
410
|
+
* @returns Extra parameter generator info or null if not found
|
|
411
|
+
*/
|
|
412
|
+
extractServiceMethodExtraParameters(interfaceName, methodName) {
|
|
413
|
+
// Check cache first
|
|
414
|
+
let interfaceDecl = this.serviceInterfaceCache.get(interfaceName);
|
|
415
|
+
if (interfaceDecl === undefined) {
|
|
416
|
+
// Not in cache, try to find it
|
|
417
|
+
const typeDecl = this.findTypeDeclaration(interfaceName);
|
|
418
|
+
if (typeDecl && Node.isInterfaceDeclaration(typeDecl)) {
|
|
419
|
+
interfaceDecl = typeDecl;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
interfaceDecl = null;
|
|
423
|
+
}
|
|
424
|
+
// Cache the result (even if null to avoid repeated lookups)
|
|
425
|
+
this.serviceInterfaceCache.set(interfaceName, interfaceDecl);
|
|
426
|
+
}
|
|
427
|
+
if (!interfaceDecl) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
// Find the method property in the interface
|
|
431
|
+
const methodProperty = interfaceDecl.getProperty(methodName);
|
|
432
|
+
if (methodProperty && Node.isPropertySignature(methodProperty)) {
|
|
433
|
+
return this.extractExtraParametersFromPropertyJsDoc(methodProperty);
|
|
434
|
+
}
|
|
435
|
+
// Also try getMethod for method signatures (e.g., methodName(): ReturnType)
|
|
436
|
+
const methodSignature = interfaceDecl.getMethod(methodName);
|
|
437
|
+
if (methodSignature) {
|
|
438
|
+
const jsDocs = methodSignature.getJsDocs();
|
|
439
|
+
if (jsDocs.length > 0) {
|
|
440
|
+
return this.extractExtraParametersFromJsDoc(jsDocs[0]);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Extract extra parameters generator info from JSDoc
|
|
447
|
+
*/
|
|
448
|
+
extractExtraParametersFromJsDoc(jsDoc) {
|
|
449
|
+
const tags = jsDoc.getTags();
|
|
450
|
+
const extraParamsTag = tags.find((tag) => tag.getTagName() === 'extraParameters');
|
|
451
|
+
if (!extraParamsTag) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const comment = 'getComment' in extraParamsTag && typeof extraParamsTag.getComment === 'function'
|
|
455
|
+
? extraParamsTag.getComment()
|
|
456
|
+
: undefined;
|
|
457
|
+
if (typeof comment !== 'string') {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const trimmed = comment.trim();
|
|
461
|
+
if (!trimmed) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const parts = trimmed.split(/\s+/);
|
|
465
|
+
if (parts.length === 0) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const generatorName = parts[0];
|
|
469
|
+
const rawArgs = parts.slice(1);
|
|
470
|
+
// Convert args: try to parse as number, otherwise keep as string
|
|
471
|
+
const args = rawArgs.map((arg) => {
|
|
472
|
+
const num = parseFloat(arg);
|
|
473
|
+
return Number.isNaN(num) ? arg : num;
|
|
474
|
+
});
|
|
475
|
+
return { name: generatorName, args };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Extract extra parameters generator info from property JSDoc
|
|
479
|
+
*/
|
|
480
|
+
extractExtraParametersFromPropertyJsDoc(property) {
|
|
481
|
+
const jsDocs = property.getJsDocs();
|
|
482
|
+
if (jsDocs.length === 0) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
return this.extractExtraParametersFromJsDoc(jsDocs[0]);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Extract JSDoc description from a service interface method
|
|
489
|
+
* @param interfaceName The name of the service interface (e.g., "ICartService")
|
|
490
|
+
* @param methodName The name of the method to extract JSDoc from (e.g., "validateCart")
|
|
491
|
+
* @returns The JSDoc description or null if not found
|
|
492
|
+
*/
|
|
493
|
+
extractServiceMethodJsDoc(interfaceName, methodName) {
|
|
494
|
+
// Check cache first
|
|
495
|
+
let interfaceDecl = this.serviceInterfaceCache.get(interfaceName);
|
|
496
|
+
if (interfaceDecl === undefined) {
|
|
497
|
+
// Not in cache, try to find it
|
|
498
|
+
const typeDecl = this.findTypeDeclaration(interfaceName);
|
|
499
|
+
if (typeDecl && Node.isInterfaceDeclaration(typeDecl)) {
|
|
500
|
+
interfaceDecl = typeDecl;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
interfaceDecl = null;
|
|
504
|
+
}
|
|
505
|
+
// Cache the result (even if null to avoid repeated lookups)
|
|
506
|
+
this.serviceInterfaceCache.set(interfaceName, interfaceDecl);
|
|
507
|
+
}
|
|
508
|
+
if (!interfaceDecl) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
// Find the method property in the interface
|
|
512
|
+
const methodProperty = interfaceDecl.getProperty(methodName);
|
|
513
|
+
if (methodProperty && Node.isPropertySignature(methodProperty)) {
|
|
514
|
+
return this.extractPropertyJsDoc(methodProperty);
|
|
515
|
+
}
|
|
516
|
+
// Also try getMethod for method signatures (e.g., methodName(): ReturnType)
|
|
517
|
+
const methodSignature = interfaceDecl.getMethod(methodName);
|
|
518
|
+
if (methodSignature) {
|
|
519
|
+
const jsDocs = methodSignature.getJsDocs();
|
|
520
|
+
if (jsDocs.length > 0) {
|
|
521
|
+
const description = jsDocs[0].getDescription();
|
|
522
|
+
return this.cleanJsDocDescription(description?.trim() || null, true);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Internal type resolution logic
|
|
529
|
+
*/
|
|
530
|
+
resolveTypeInternal(typeName, sourceFile) {
|
|
531
|
+
// Clean the type name
|
|
532
|
+
const cleanName = this.cleanTypeName(typeName);
|
|
533
|
+
// Try to find the type declaration
|
|
534
|
+
const typeDecl = this.findTypeDeclaration(cleanName, sourceFile);
|
|
535
|
+
if (!typeDecl) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
// Handle interface declarations
|
|
539
|
+
if (Node.isInterfaceDeclaration(typeDecl)) {
|
|
540
|
+
return this.resolveInterface(typeDecl);
|
|
541
|
+
}
|
|
542
|
+
// Handle class declarations
|
|
543
|
+
if (Node.isClassDeclaration(typeDecl)) {
|
|
544
|
+
return this.resolveClass(typeDecl);
|
|
545
|
+
}
|
|
546
|
+
// Handle type alias declarations
|
|
547
|
+
if (Node.isTypeAliasDeclaration(typeDecl)) {
|
|
548
|
+
return this.resolveTypeAlias(typeDecl);
|
|
549
|
+
}
|
|
550
|
+
// Handle enum declarations
|
|
551
|
+
if (Node.isEnumDeclaration(typeDecl)) {
|
|
552
|
+
const members = typeDecl.getMembers();
|
|
553
|
+
const enumMembers = members.map((m) => {
|
|
554
|
+
const memberName = m.getName();
|
|
555
|
+
const value = m.getValue();
|
|
556
|
+
const memberValue = value !== undefined ? value : memberName;
|
|
557
|
+
// Extract JSDoc from member
|
|
558
|
+
const jsDocs = m.getJsDocs();
|
|
559
|
+
let description = null;
|
|
560
|
+
if (jsDocs.length > 0) {
|
|
561
|
+
description = this.cleanJsDocDescription(jsDocs[0].getDescription()?.trim() || null);
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
name: memberName,
|
|
565
|
+
value: memberValue,
|
|
566
|
+
description,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
return {
|
|
570
|
+
name: cleanName,
|
|
571
|
+
description: this.extractNodeJsDoc(typeDecl),
|
|
572
|
+
properties: [],
|
|
573
|
+
isEnum: true,
|
|
574
|
+
enumValues: enumMembers.map(m => m.value),
|
|
575
|
+
enumMembers,
|
|
576
|
+
sourcePath: typeDecl.getSourceFile().getFilePath(),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Resolve an interface declaration including inherited properties
|
|
583
|
+
*/
|
|
584
|
+
resolveInterface(interfaceDecl) {
|
|
585
|
+
const properties = [];
|
|
586
|
+
// Get properties from extended interfaces/classes first
|
|
587
|
+
const extendedTypes = interfaceDecl.getExtends();
|
|
588
|
+
for (const extendedType of extendedTypes) {
|
|
589
|
+
const extendedTypeName = extendedType.getText();
|
|
590
|
+
const extendedResolved = this.resolveType(this.cleanTypeName(extendedTypeName));
|
|
591
|
+
if (extendedResolved) {
|
|
592
|
+
properties.push(...extendedResolved.properties);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Get own properties (override inherited ones with same name)
|
|
596
|
+
const ownProperties = interfaceDecl.getProperties();
|
|
597
|
+
for (const prop of ownProperties) {
|
|
598
|
+
const propName = prop.getName();
|
|
599
|
+
const existingIndex = properties.findIndex(p => p.name === propName);
|
|
600
|
+
const resolvedProp = this.resolveProperty(prop);
|
|
601
|
+
// Skip properties with @privateField tag
|
|
602
|
+
if (resolvedProp === null) {
|
|
603
|
+
// If this property overrides an inherited one, remove it
|
|
604
|
+
if (existingIndex >= 0) {
|
|
605
|
+
properties.splice(existingIndex, 1);
|
|
606
|
+
}
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (existingIndex >= 0) {
|
|
610
|
+
properties[existingIndex] = resolvedProp;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
properties.push(resolvedProp);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
name: interfaceDecl.getName(),
|
|
618
|
+
description: this.extractNodeJsDoc(interfaceDecl),
|
|
619
|
+
properties,
|
|
620
|
+
isEnum: false,
|
|
621
|
+
sourcePath: interfaceDecl.getSourceFile().getFilePath(),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Resolve a class declaration including inherited properties
|
|
626
|
+
*/
|
|
627
|
+
resolveClass(classDecl) {
|
|
628
|
+
const properties = [];
|
|
629
|
+
// Get properties from extended class first
|
|
630
|
+
const extendedClass = classDecl.getExtends();
|
|
631
|
+
if (extendedClass) {
|
|
632
|
+
const extendedTypeName = extendedClass.getText();
|
|
633
|
+
const extendedResolved = this.resolveType(this.cleanTypeName(extendedTypeName));
|
|
634
|
+
if (extendedResolved) {
|
|
635
|
+
properties.push(...extendedResolved.properties);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Get own properties (override inherited ones with same name)
|
|
639
|
+
const ownProperties = classDecl.getProperties();
|
|
640
|
+
for (const prop of ownProperties) {
|
|
641
|
+
const propName = prop.getName();
|
|
642
|
+
const existingIndex = properties.findIndex(p => p.name === propName);
|
|
643
|
+
const resolvedProp = this.resolveClassProperty(prop);
|
|
644
|
+
// Skip properties with @privateField tag
|
|
645
|
+
if (resolvedProp === null) {
|
|
646
|
+
// If this property overrides an inherited one, remove it
|
|
647
|
+
if (existingIndex >= 0) {
|
|
648
|
+
properties.splice(existingIndex, 1);
|
|
649
|
+
}
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (existingIndex >= 0) {
|
|
653
|
+
properties[existingIndex] = resolvedProp;
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
properties.push(resolvedProp);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
name: classDecl.getName() || 'UnknownClass',
|
|
661
|
+
description: this.extractNodeJsDoc(classDecl),
|
|
662
|
+
properties,
|
|
663
|
+
isEnum: false,
|
|
664
|
+
sourcePath: classDecl.getSourceFile().getFilePath(),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Resolve a class property declaration to ResolvedProperty
|
|
669
|
+
* Returns null if the property has @privateField tag
|
|
670
|
+
*/
|
|
671
|
+
resolveClassProperty(property) {
|
|
672
|
+
// Check for @privateField tag in JSDoc - skip this property if found
|
|
673
|
+
const jsDocs = property.getJsDocs();
|
|
674
|
+
let isEnterpriseOnly = false;
|
|
675
|
+
if (jsDocs.length > 0) {
|
|
676
|
+
const fullText = jsDocs[0].getText();
|
|
677
|
+
if (this.hasPrivateFieldTag(fullText)) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
// Check for @enterpriseOnly tag
|
|
681
|
+
isEnterpriseOnly = this.hasEnterpriseOnlyTag(fullText);
|
|
682
|
+
}
|
|
683
|
+
const name = property.getName();
|
|
684
|
+
const optional = property.hasQuestionToken();
|
|
685
|
+
const description = this.extractClassPropertyJsDoc(property);
|
|
686
|
+
const type = property.getType();
|
|
687
|
+
// Try to get enum member info from the type annotation
|
|
688
|
+
const typeNode = property.getTypeNode();
|
|
689
|
+
let openApiType = this.tsTypeToOpenApi(type, name);
|
|
690
|
+
if (typeNode) {
|
|
691
|
+
const typeAnnotation = typeNode.getText();
|
|
692
|
+
const resolvedEnum = this.resolveType(typeAnnotation);
|
|
693
|
+
if (resolvedEnum && resolvedEnum.isEnum && resolvedEnum.enumMembers) {
|
|
694
|
+
openApiType = {
|
|
695
|
+
...openApiType,
|
|
696
|
+
'x-enum-members': resolvedEnum.enumMembers,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Add enterprise-only marker if tagged
|
|
701
|
+
if (isEnterpriseOnly) {
|
|
702
|
+
openApiType = {
|
|
703
|
+
...openApiType,
|
|
704
|
+
'x-enterprise-only': true,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
name,
|
|
709
|
+
type: openApiType,
|
|
710
|
+
optional,
|
|
711
|
+
description,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Extract JSDoc description from a class property
|
|
716
|
+
*/
|
|
717
|
+
extractClassPropertyJsDoc(property) {
|
|
718
|
+
const jsDocs = property.getJsDocs();
|
|
719
|
+
if (jsDocs.length > 0) {
|
|
720
|
+
const description = jsDocs[0].getDescription();
|
|
721
|
+
const cleaned = this.cleanJsDocDescription(description?.trim() || null);
|
|
722
|
+
// Debug logging (uncomment for troubleshooting)
|
|
723
|
+
// console.log(`[DEBUG] Class property ${property.getName()}: JSDoc="${cleaned}"`);
|
|
724
|
+
return cleaned;
|
|
725
|
+
}
|
|
726
|
+
// Try getting leading comment trivia as fallback
|
|
727
|
+
const leadingCommentRanges = property.getLeadingCommentRanges();
|
|
728
|
+
for (const range of leadingCommentRanges) {
|
|
729
|
+
const text = range.getText();
|
|
730
|
+
if (text.startsWith('/**')) {
|
|
731
|
+
// Extract content between /** and */
|
|
732
|
+
const match = text.match(/\/\*\*\s*\*?\s*(.+?)\s*\*?\s*\*\//s);
|
|
733
|
+
if (match) {
|
|
734
|
+
return this.cleanJsDocDescription(match[1].trim());
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Resolve a type alias declaration (handles Pick, Omit, etc.)
|
|
742
|
+
*/
|
|
743
|
+
resolveTypeAlias(typeAlias) {
|
|
744
|
+
const name = typeAlias.getName();
|
|
745
|
+
const typeNode = typeAlias.getTypeNode();
|
|
746
|
+
if (!typeNode) {
|
|
747
|
+
return {
|
|
748
|
+
name,
|
|
749
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
750
|
+
properties: [],
|
|
751
|
+
isEnum: false,
|
|
752
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const typeText = typeNode.getText();
|
|
756
|
+
// Handle Pick<T, K>
|
|
757
|
+
if (typeText.startsWith('Pick<')) {
|
|
758
|
+
return this.resolvePickType(typeAlias, typeText);
|
|
759
|
+
}
|
|
760
|
+
// Handle Omit<T, K>
|
|
761
|
+
if (typeText.startsWith('Omit<')) {
|
|
762
|
+
return this.resolveOmitType(typeAlias, typeText);
|
|
763
|
+
}
|
|
764
|
+
// Handle Partial<T>
|
|
765
|
+
if (typeText.startsWith('Partial<')) {
|
|
766
|
+
return this.resolvePartialType(typeAlias, typeText);
|
|
767
|
+
}
|
|
768
|
+
// Handle Required<T>
|
|
769
|
+
if (typeText.startsWith('Required<')) {
|
|
770
|
+
return this.resolveRequiredType(typeAlias, typeText);
|
|
771
|
+
}
|
|
772
|
+
// Handle intersection types (Type1 & Type2 & { extra: string })
|
|
773
|
+
if (typeText.includes(' & ')) {
|
|
774
|
+
return this.resolveIntersectionTypeAlias(typeAlias);
|
|
775
|
+
}
|
|
776
|
+
// Handle direct type references
|
|
777
|
+
const type = typeAlias.getType();
|
|
778
|
+
if (type.isObject()) {
|
|
779
|
+
const properties = this.resolveTypeProperties(type);
|
|
780
|
+
return {
|
|
781
|
+
name,
|
|
782
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
783
|
+
properties,
|
|
784
|
+
isEnum: false,
|
|
785
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// Handle enum-like union types
|
|
789
|
+
if (type.isUnion()) {
|
|
790
|
+
const unionTypes = type.getUnionTypes();
|
|
791
|
+
const literals = unionTypes.filter(t => t.isStringLiteral() || t.isNumberLiteral());
|
|
792
|
+
if (literals.length === unionTypes.length) {
|
|
793
|
+
const values = literals.map(t => t.getLiteralValue());
|
|
794
|
+
return {
|
|
795
|
+
name,
|
|
796
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
797
|
+
properties: [],
|
|
798
|
+
isEnum: true,
|
|
799
|
+
enumValues: values,
|
|
800
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
name,
|
|
806
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
807
|
+
properties: [],
|
|
808
|
+
isEnum: false,
|
|
809
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Resolve Pick<T, K> utility type
|
|
814
|
+
*/
|
|
815
|
+
resolvePickType(typeAlias, typeText) {
|
|
816
|
+
const match = typeText.match(/Pick<([^,]+),\s*(.+)>/);
|
|
817
|
+
if (!match) {
|
|
818
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
819
|
+
}
|
|
820
|
+
const baseTypeName = match[1].trim();
|
|
821
|
+
const pickedKeys = this.parseUnionKeys(match[2]);
|
|
822
|
+
const baseResolved = this.resolveType(this.cleanTypeName(baseTypeName));
|
|
823
|
+
if (!baseResolved) {
|
|
824
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
825
|
+
}
|
|
826
|
+
const properties = baseResolved.properties.filter(p => pickedKeys.includes(p.name));
|
|
827
|
+
return {
|
|
828
|
+
name: typeAlias.getName(),
|
|
829
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
830
|
+
properties,
|
|
831
|
+
isEnum: false,
|
|
832
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Resolve Omit<T, K> utility type
|
|
837
|
+
*/
|
|
838
|
+
resolveOmitType(typeAlias, typeText) {
|
|
839
|
+
const match = typeText.match(/Omit<([^,]+),\s*(.+)>/);
|
|
840
|
+
if (!match) {
|
|
841
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
842
|
+
}
|
|
843
|
+
const baseTypeName = match[1].trim();
|
|
844
|
+
const omittedKeys = this.parseUnionKeys(match[2]);
|
|
845
|
+
const baseResolved = this.resolveType(this.cleanTypeName(baseTypeName));
|
|
846
|
+
if (!baseResolved) {
|
|
847
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
848
|
+
}
|
|
849
|
+
const properties = baseResolved.properties.filter(p => !omittedKeys.includes(p.name));
|
|
850
|
+
return {
|
|
851
|
+
name: typeAlias.getName(),
|
|
852
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
853
|
+
properties,
|
|
854
|
+
isEnum: false,
|
|
855
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Resolve Partial<T> utility type
|
|
860
|
+
*/
|
|
861
|
+
resolvePartialType(typeAlias, typeText) {
|
|
862
|
+
const match = typeText.match(/Partial<(.+)>/);
|
|
863
|
+
if (!match) {
|
|
864
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
865
|
+
}
|
|
866
|
+
const baseTypeName = match[1].trim();
|
|
867
|
+
const baseResolved = this.resolveType(this.cleanTypeName(baseTypeName));
|
|
868
|
+
if (!baseResolved) {
|
|
869
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
870
|
+
}
|
|
871
|
+
// Make all properties optional
|
|
872
|
+
const properties = baseResolved.properties.map(p => ({
|
|
873
|
+
...p,
|
|
874
|
+
optional: true,
|
|
875
|
+
}));
|
|
876
|
+
return {
|
|
877
|
+
name: typeAlias.getName(),
|
|
878
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
879
|
+
properties,
|
|
880
|
+
isEnum: false,
|
|
881
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Resolve Required<T> utility type
|
|
886
|
+
*/
|
|
887
|
+
resolveRequiredType(typeAlias, typeText) {
|
|
888
|
+
const match = typeText.match(/Required<(.+)>/);
|
|
889
|
+
if (!match) {
|
|
890
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
891
|
+
}
|
|
892
|
+
const baseTypeName = match[1].trim();
|
|
893
|
+
const baseResolved = this.resolveType(this.cleanTypeName(baseTypeName));
|
|
894
|
+
if (!baseResolved) {
|
|
895
|
+
return this.createEmptyResolvedType(typeAlias);
|
|
896
|
+
}
|
|
897
|
+
// Make all properties required
|
|
898
|
+
const properties = baseResolved.properties.map(p => ({
|
|
899
|
+
...p,
|
|
900
|
+
optional: false,
|
|
901
|
+
}));
|
|
902
|
+
return {
|
|
903
|
+
name: typeAlias.getName(),
|
|
904
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
905
|
+
properties,
|
|
906
|
+
isEnum: false,
|
|
907
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Resolve intersection type alias (Type1 & Type2 & { extra })
|
|
912
|
+
*/
|
|
913
|
+
resolveIntersectionTypeAlias(typeAlias) {
|
|
914
|
+
const type = typeAlias.getType();
|
|
915
|
+
const properties = this.resolveTypeProperties(type);
|
|
916
|
+
return {
|
|
917
|
+
name: typeAlias.getName(),
|
|
918
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
919
|
+
properties,
|
|
920
|
+
isEnum: false,
|
|
921
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Resolve properties from a Type object
|
|
926
|
+
*/
|
|
927
|
+
resolveTypeProperties(type) {
|
|
928
|
+
const properties = [];
|
|
929
|
+
const typeProperties = type.getProperties();
|
|
930
|
+
for (const prop of typeProperties) {
|
|
931
|
+
const propName = prop.getName();
|
|
932
|
+
const declarations = prop.getDeclarations();
|
|
933
|
+
let optional = false;
|
|
934
|
+
let description = null;
|
|
935
|
+
let propType = null;
|
|
936
|
+
if (declarations.length > 0) {
|
|
937
|
+
const decl = declarations[0];
|
|
938
|
+
if (Node.isPropertySignature(decl)) {
|
|
939
|
+
optional = decl.hasQuestionToken();
|
|
940
|
+
description = this.extractPropertyJsDoc(decl);
|
|
941
|
+
propType = decl.getType();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (!propType) {
|
|
945
|
+
propType = prop.getValueDeclaration()?.getType() || null;
|
|
946
|
+
}
|
|
947
|
+
if (propType) {
|
|
948
|
+
properties.push({
|
|
949
|
+
name: propName,
|
|
950
|
+
type: this.tsTypeToOpenApi(propType, propName),
|
|
951
|
+
optional,
|
|
952
|
+
description,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return properties;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Resolve a property signature to ResolvedProperty
|
|
960
|
+
* Returns null if the property has @privateField tag
|
|
961
|
+
*/
|
|
962
|
+
resolveProperty(property) {
|
|
963
|
+
// Check for @privateField tag in JSDoc - skip this property if found
|
|
964
|
+
const jsDocs = property.getJsDocs();
|
|
965
|
+
let isEnterpriseOnly = false;
|
|
966
|
+
if (jsDocs.length > 0) {
|
|
967
|
+
const fullText = jsDocs[0].getText();
|
|
968
|
+
if (this.hasPrivateFieldTag(fullText)) {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
// Check for @enterpriseOnly tag
|
|
972
|
+
isEnterpriseOnly = this.hasEnterpriseOnlyTag(fullText);
|
|
973
|
+
}
|
|
974
|
+
const name = property.getName();
|
|
975
|
+
const optional = property.hasQuestionToken();
|
|
976
|
+
const description = this.extractPropertyJsDoc(property);
|
|
977
|
+
const type = property.getType();
|
|
978
|
+
// Try to get enum member info from the type annotation
|
|
979
|
+
const typeNode = property.getTypeNode();
|
|
980
|
+
let openApiType = this.tsTypeToOpenApi(type, name);
|
|
981
|
+
if (typeNode) {
|
|
982
|
+
const typeAnnotation = typeNode.getText();
|
|
983
|
+
const resolvedEnum = this.resolveType(typeAnnotation);
|
|
984
|
+
if (resolvedEnum && resolvedEnum.isEnum && resolvedEnum.enumMembers) {
|
|
985
|
+
openApiType = {
|
|
986
|
+
...openApiType,
|
|
987
|
+
'x-enum-members': resolvedEnum.enumMembers,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// Add enterprise-only marker if tagged
|
|
992
|
+
if (isEnterpriseOnly) {
|
|
993
|
+
openApiType = {
|
|
994
|
+
...openApiType,
|
|
995
|
+
'x-enterprise-only': true,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
name,
|
|
1000
|
+
type: openApiType,
|
|
1001
|
+
optional,
|
|
1002
|
+
description,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve type from a Symbol
|
|
1007
|
+
*/
|
|
1008
|
+
resolveTypeFromSymbol(symbol) {
|
|
1009
|
+
const name = symbol.getName();
|
|
1010
|
+
const declarations = symbol.getDeclarations();
|
|
1011
|
+
if (declarations.length === 0) {
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
const decl = declarations[0];
|
|
1015
|
+
const sourceFile = decl.getSourceFile();
|
|
1016
|
+
return this.resolveType(name, sourceFile);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Find type declaration by name
|
|
1020
|
+
*/
|
|
1021
|
+
findTypeDeclaration(typeName, sourceFile) {
|
|
1022
|
+
// First search in the provided source file
|
|
1023
|
+
if (sourceFile) {
|
|
1024
|
+
const found = this.findTypeInSourceFile(typeName, sourceFile);
|
|
1025
|
+
if (found) {
|
|
1026
|
+
return found;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Search in all loaded source files
|
|
1030
|
+
for (const sf of this.project.getSourceFiles()) {
|
|
1031
|
+
const found = this.findTypeInSourceFile(typeName, sf);
|
|
1032
|
+
if (found) {
|
|
1033
|
+
return found;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Try to find in services directory
|
|
1037
|
+
const servicesDir = path.join(this.serverDir, 'src', 'services');
|
|
1038
|
+
const argsFiles = this.findFilesRecursive(servicesDir, '**/*.ts');
|
|
1039
|
+
for (const filePath of argsFiles) {
|
|
1040
|
+
try {
|
|
1041
|
+
const sf = this.project.addSourceFileAtPath(filePath);
|
|
1042
|
+
const found = this.findTypeInSourceFile(typeName, sf);
|
|
1043
|
+
if (found) {
|
|
1044
|
+
return found;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// File already added or invalid
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Try to find in data directory
|
|
1052
|
+
const dataDir = path.join(this.serverDir, 'src', 'data');
|
|
1053
|
+
const dataFiles = this.findFilesRecursive(dataDir, '**/*.ts');
|
|
1054
|
+
for (const filePath of dataFiles) {
|
|
1055
|
+
try {
|
|
1056
|
+
const sf = this.project.addSourceFileAtPath(filePath);
|
|
1057
|
+
const found = this.findTypeInSourceFile(typeName, sf);
|
|
1058
|
+
if (found) {
|
|
1059
|
+
return found;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
// File already added or invalid
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Find type in a specific source file
|
|
1070
|
+
*/
|
|
1071
|
+
findTypeInSourceFile(typeName, sourceFile) {
|
|
1072
|
+
// Check interfaces
|
|
1073
|
+
const interfaceDecl = sourceFile.getInterface(typeName);
|
|
1074
|
+
if (interfaceDecl) {
|
|
1075
|
+
return interfaceDecl;
|
|
1076
|
+
}
|
|
1077
|
+
// Check classes by name directly
|
|
1078
|
+
const classDecl = sourceFile.getClass(typeName);
|
|
1079
|
+
if (classDecl) {
|
|
1080
|
+
return classDecl;
|
|
1081
|
+
}
|
|
1082
|
+
// Check all classes in the file (including default exported ones)
|
|
1083
|
+
const allClasses = sourceFile.getClasses();
|
|
1084
|
+
for (const cls of allClasses) {
|
|
1085
|
+
const className = cls.getName();
|
|
1086
|
+
if (className === typeName) {
|
|
1087
|
+
return cls;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
// Check type aliases
|
|
1091
|
+
const typeAlias = sourceFile.getTypeAlias(typeName);
|
|
1092
|
+
if (typeAlias) {
|
|
1093
|
+
return typeAlias;
|
|
1094
|
+
}
|
|
1095
|
+
// Check enums
|
|
1096
|
+
const enumDecl = sourceFile.getEnum(typeName);
|
|
1097
|
+
if (enumDecl) {
|
|
1098
|
+
return enumDecl;
|
|
1099
|
+
}
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Extract JSDoc description from a node
|
|
1104
|
+
*/
|
|
1105
|
+
extractNodeJsDoc(node) {
|
|
1106
|
+
if (Node.isJSDocable(node)) {
|
|
1107
|
+
const jsDocs = node.getJsDocs();
|
|
1108
|
+
if (jsDocs.length > 0) {
|
|
1109
|
+
const description = jsDocs[0].getDescription();
|
|
1110
|
+
return this.cleanJsDocDescription(description?.trim() || null);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Parse union keys from a string like "'id' | 'name' | 'email'"
|
|
1117
|
+
*/
|
|
1118
|
+
parseUnionKeys(keysString) {
|
|
1119
|
+
// Remove trailing > if present
|
|
1120
|
+
const cleaned = keysString.replace(/>\s*$/, '').trim();
|
|
1121
|
+
// Handle cases like "keyof SomeType" - can't resolve these
|
|
1122
|
+
if (cleaned.startsWith('keyof')) {
|
|
1123
|
+
return [];
|
|
1124
|
+
}
|
|
1125
|
+
// Split by | and extract string literals
|
|
1126
|
+
return cleaned
|
|
1127
|
+
.split('|')
|
|
1128
|
+
.map(k => k.trim().replace(/^['"]|['"]$/g, ''))
|
|
1129
|
+
.filter(k => k.length > 0);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Clean type name by removing import() paths and namespaces
|
|
1133
|
+
*/
|
|
1134
|
+
cleanTypeName(typeName) {
|
|
1135
|
+
// Remove import() syntax
|
|
1136
|
+
let cleaned = typeName.replace(/import\([^)]+\)\./g, '');
|
|
1137
|
+
// Remove namespace prefixes except Temporal
|
|
1138
|
+
cleaned = cleaned.replace(/(\w+)\./g, (match, namespace) => {
|
|
1139
|
+
if (namespace === 'Temporal') {
|
|
1140
|
+
return match;
|
|
1141
|
+
}
|
|
1142
|
+
return '';
|
|
1143
|
+
});
|
|
1144
|
+
// Remove generic parameters for simple lookup
|
|
1145
|
+
const genericIndex = cleaned.indexOf('<');
|
|
1146
|
+
if (genericIndex > 0) {
|
|
1147
|
+
cleaned = cleaned.substring(0, genericIndex);
|
|
1148
|
+
}
|
|
1149
|
+
return cleaned.trim();
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Create an empty resolved type for error cases
|
|
1153
|
+
*/
|
|
1154
|
+
createEmptyResolvedType(typeAlias) {
|
|
1155
|
+
return {
|
|
1156
|
+
name: typeAlias.getName(),
|
|
1157
|
+
description: this.extractNodeJsDoc(typeAlias),
|
|
1158
|
+
properties: [],
|
|
1159
|
+
isEnum: false,
|
|
1160
|
+
sourcePath: typeAlias.getSourceFile().getFilePath(),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Find files recursively matching a pattern
|
|
1165
|
+
*/
|
|
1166
|
+
findFilesRecursive(dir, pattern) {
|
|
1167
|
+
// Check cache first
|
|
1168
|
+
const cacheKey = `${dir}:${pattern}`;
|
|
1169
|
+
const cached = this.directoryFilesCache.get(cacheKey);
|
|
1170
|
+
if (cached) {
|
|
1171
|
+
return cached;
|
|
1172
|
+
}
|
|
1173
|
+
const results = [];
|
|
1174
|
+
if (!fs.existsSync(dir)) {
|
|
1175
|
+
return results;
|
|
1176
|
+
}
|
|
1177
|
+
const patternRegex = this.globToRegex(pattern);
|
|
1178
|
+
this.walkDirectory(fs, dir, '', patternRegex, results);
|
|
1179
|
+
// Cache the results
|
|
1180
|
+
this.directoryFilesCache.set(cacheKey, results);
|
|
1181
|
+
return results;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Walk directory recursively
|
|
1185
|
+
*/
|
|
1186
|
+
walkDirectory(fs, baseDir, relativePath, pattern, results) {
|
|
1187
|
+
const currentDir = path.join(baseDir, relativePath);
|
|
1188
|
+
try {
|
|
1189
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
1190
|
+
for (const entry of entries) {
|
|
1191
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
1192
|
+
if (entry.isDirectory()) {
|
|
1193
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
1194
|
+
this.walkDirectory(fs, baseDir, entryRelativePath, pattern, results);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
else if (entry.isFile()) {
|
|
1198
|
+
if (pattern.test(entryRelativePath)) {
|
|
1199
|
+
results.push(path.join(baseDir, entryRelativePath));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
// Directory access error - skip
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Convert glob pattern to regex
|
|
1210
|
+
*/
|
|
1211
|
+
globToRegex(glob) {
|
|
1212
|
+
// Handle **/*.ts pattern specially - it should match both root files and nested files
|
|
1213
|
+
if (glob === '**/*.ts') {
|
|
1214
|
+
// Match: file.ts OR dir/file.ts OR dir/subdir/file.ts etc.
|
|
1215
|
+
return /^(.+\/)?[^/]+\.ts$/;
|
|
1216
|
+
}
|
|
1217
|
+
const escaped = glob
|
|
1218
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
1219
|
+
.replace(/\*\*/g, '{{DOUBLE_STAR}}')
|
|
1220
|
+
.replace(/\*/g, '[^/]*')
|
|
1221
|
+
.replace(/\{\{DOUBLE_STAR\}\}/g, '.*')
|
|
1222
|
+
.replace(/\?/g, '.');
|
|
1223
|
+
return new RegExp(`^${escaped}$`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
//# sourceMappingURL=type-resolver.js.map
|