@aloma.io/integration-sdk 3.8.68 → 3.8.70
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/FINDINGS-PLAT-47.md +253 -0
- package/OPENAPI_TO_CONNECTOR.md +4 -1
- package/build/internal/fetcher/fetcher.d.mts +8 -0
- package/build/internal/fetcher/fetcher.mjs +47 -3
- package/build/openapi-to-connector.mjs +5 -2
- package/build/transform/index.d.mts +2 -2
- package/build/transform/index.mjs +493 -78
- package/package.json +3 -3
- package/src/internal/fetcher/fetcher.mts +52 -3
- package/src/openapi-to-connector.mts +6 -2
- package/src/transform/index.mts +530 -96
- package/test/fetcher-query-params.test.mjs +294 -0
- package/test/fixtures/transform/basic/expected.json +9 -0
- package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
- package/test/fixtures/transform/basic/tsconfig.json +1 -0
- package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
- package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
- package/test/fixtures/transform/overloads/expected.json +6 -0
- package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
- package/test/fixtures/transform/overloads/tsconfig.json +1 -0
- package/test/fixtures/transform/tdd-stub/expected.json +8 -0
- package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
- package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
- package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
- package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
- package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
- package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
- package/test/fixtures/transform/type-error/tsconfig.json +1 -0
- package/test/fixtures/transform/typed/expected.json +8 -0
- package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
- package/test/fixtures/transform/typed/tsconfig.json +1 -0
- package/test/fixtures/transform/visibility/expected.json +9 -0
- package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
- package/test/fixtures/transform/visibility/tsconfig.json +1 -0
- package/test/openapi-to-connector.test.mts +7 -0
- package/test/transform.test.mjs +68 -0
- package/tools/mutation-run.mjs +23 -5
package/src/transform/index.mts
CHANGED
|
@@ -1,4 +1,417 @@
|
|
|
1
|
-
|
|
1
|
+
// Reads a connector's controller source and produces the method declarations (`text`) and the method names
|
|
2
|
+
// (`methods`) that `cli build` writes to build/.controller.json.
|
|
3
|
+
//
|
|
4
|
+
// This used to go through @ts-ast-parser/core. That package has had no release since 2024 and its `engines` field
|
|
5
|
+
// stops at Node 20, so `yarn install` failed on Node 22 and 24. This file does the few things the SDK needs from it
|
|
6
|
+
// directly with the TypeScript compiler API (typescript is already a dependency) and `comment-parser` (the same
|
|
7
|
+
// JSDoc parser the old package used, so JSDoc tags come out the same).
|
|
8
|
+
import {parse as parseComment} from 'comment-parser';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
13
|
+
// program
|
|
14
|
+
|
|
15
|
+
const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
16
|
+
experimentalDecorators: true,
|
|
17
|
+
target: ts.ScriptTarget.ES2020,
|
|
18
|
+
module: ts.ModuleKind.ES2020,
|
|
19
|
+
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'],
|
|
20
|
+
declaration: true,
|
|
21
|
+
skipLibCheck: true,
|
|
22
|
+
skipDefaultLibCheck: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// the connector's tsconfig.json (found from the working directory upwards), else defaults
|
|
26
|
+
const compilerOptions = (): ts.CompilerOptions => {
|
|
27
|
+
const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, 'tsconfig.json');
|
|
28
|
+
const config = configPath && ts.readConfigFile(configPath, ts.sys.readFile);
|
|
29
|
+
if (!configPath || !config || config.error) return DEFAULT_COMPILER_OPTIONS;
|
|
30
|
+
|
|
31
|
+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath), {}, configPath);
|
|
32
|
+
return parsed.errors.length ? DEFAULT_COMPILER_OPTIONS : parsed.options;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const createProgram = (file: string) => {
|
|
36
|
+
const options = compilerOptions();
|
|
37
|
+
const program = ts.createProgram({rootNames: [file], options, host: ts.createCompilerHost(options, true)});
|
|
38
|
+
|
|
39
|
+
const diagnostics = ts.sortAndDeduplicateDiagnostics([
|
|
40
|
+
...program.getSemanticDiagnostics(),
|
|
41
|
+
...program.getSyntacticDiagnostics(),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
if (diagnostics.length) {
|
|
45
|
+
const host: ts.FormatDiagnosticsHost = {
|
|
46
|
+
getCanonicalFileName: (name) => name,
|
|
47
|
+
getCurrentDirectory: () => process.cwd(),
|
|
48
|
+
getNewLine: () => '\n',
|
|
49
|
+
};
|
|
50
|
+
throw new Error(`${file} has TypeScript errors:\n${ts.formatDiagnostics(diagnostics, host)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const source = program.getSourceFile(file);
|
|
54
|
+
if (!source) throw new Error(`Unable to analyse file ${file}`);
|
|
55
|
+
|
|
56
|
+
return {program, checker: program.getTypeChecker(), source};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
60
|
+
// JSDoc: the tags of the comment in front of a node, as [{kind, value}] in first-appearance order of the kind
|
|
61
|
+
|
|
62
|
+
const trimNewLines = (str = '') => str.replace(/^\s+|\s+$/g, '');
|
|
63
|
+
|
|
64
|
+
const tagValue = (tagName: string, tag: any) => {
|
|
65
|
+
const result: any = {};
|
|
66
|
+
const description = trimNewLines(((d) => (d.startsWith('- ') ? d.slice(2) : d))(tag.description ?? ''));
|
|
67
|
+
const name = tag.name ?? '';
|
|
68
|
+
|
|
69
|
+
if ((tag.default ?? '') !== '') result.default = tag.default;
|
|
70
|
+
if (tag.optional) result.optional = true;
|
|
71
|
+
if ((tag.type ?? '') !== '') result.type = tag.type;
|
|
72
|
+
if (name !== '') result.name = name;
|
|
73
|
+
if (description !== '') result.description = description;
|
|
74
|
+
|
|
75
|
+
const count = Object.keys(result).length;
|
|
76
|
+
|
|
77
|
+
// only one string key: the value is that string
|
|
78
|
+
if (count === 1 && (result.description ?? result.name)) return name || description;
|
|
79
|
+
|
|
80
|
+
// name and description with no "-" between them: one string
|
|
81
|
+
if (count === 2 && result.description && result.name) {
|
|
82
|
+
const line = tag.source.find((s: any) => s.source.includes(tagName));
|
|
83
|
+
const text = (line?.tokens.name ?? '') + (line?.tokens.description ?? '');
|
|
84
|
+
if (!text.includes('-')) return [name, description].join(' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const jsDoc = (node: ts.Node) => {
|
|
91
|
+
const text = node.getSourceFile()?.text;
|
|
92
|
+
if (!text) return [];
|
|
93
|
+
|
|
94
|
+
const comment = (ts.getLeadingCommentRanges(text, node.pos) ?? [])
|
|
95
|
+
.map((range) => text.substring(range.pos, range.end))
|
|
96
|
+
.join('\n');
|
|
97
|
+
|
|
98
|
+
const byKind: Record<string, any[]> = {};
|
|
99
|
+
const add = (kind: string, value: any) => (byKind[kind] ??= []).push(value);
|
|
100
|
+
|
|
101
|
+
for (const block of parseComment(comment, {spacing: 'preserve'})) {
|
|
102
|
+
if (block.problems.length) continue;
|
|
103
|
+
|
|
104
|
+
const description = trimNewLines(block.description ?? '');
|
|
105
|
+
if (description !== '') add('description', description);
|
|
106
|
+
|
|
107
|
+
for (const tag of block.tags) add(tag.tag ?? '', tagValue(tag.tag ?? '', tag));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.entries(byKind).flatMap(([kind, values]) => values.map((value) => ({kind, value})));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const isIgnored = (docs: {kind: string}[]) => docs.some(({kind}) => ['ignore', 'internal', 'private'].includes(kind));
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
116
|
+
// expressions and types
|
|
117
|
+
|
|
118
|
+
const isThirdParty = (path: string) => path.length < 1000 && (/.*node_modules\/.+/.test(path) || /^https?:\/\/.+/.test(path));
|
|
119
|
+
|
|
120
|
+
// the default value of a parameter or binding element, as text/number/boolean/null
|
|
121
|
+
const resolveExpression = (checker: ts.TypeChecker, expression: ts.Expression | undefined): any => {
|
|
122
|
+
let expr: any = expression;
|
|
123
|
+
if (expr == null) return undefined;
|
|
124
|
+
|
|
125
|
+
if (ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isParenthesizedExpression(expr) || ts.isComputedPropertyName(expr)) {
|
|
126
|
+
expr = expr.expression;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (ts.isPrefixUnaryExpression(expr)) {
|
|
130
|
+
const value = resolveExpression(checker, expr.operand);
|
|
131
|
+
if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') return value;
|
|
132
|
+
if (expr.operator === ts.SyntaxKind.MinusToken) return -value;
|
|
133
|
+
if (expr.operator === ts.SyntaxKind.ExclamationToken) return !value;
|
|
134
|
+
if (expr.operator === ts.SyntaxKind.PlusToken) return +value;
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (expr == null) return undefined;
|
|
139
|
+
|
|
140
|
+
const text = expr.getText() ?? '';
|
|
141
|
+
if (ts.isStringLiteralLike(expr) || ts.isArrayLiteralExpression(expr) || ts.isObjectLiteralExpression(expr)) return text;
|
|
142
|
+
if (ts.isNumericLiteral(expr)) {
|
|
143
|
+
const parsed = parseFloat(text);
|
|
144
|
+
return isNaN(parsed) ? text : parsed;
|
|
145
|
+
}
|
|
146
|
+
if (expr.kind === ts.SyntaxKind.FalseKeyword || expr.kind === ts.SyntaxKind.TrueKeyword) return text === 'true';
|
|
147
|
+
if (expr.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
148
|
+
if (expr.kind === ts.SyntaxKind.UndefinedKeyword) return undefined;
|
|
149
|
+
|
|
150
|
+
if (ts.isIdentifier(expr) || ts.isPropertyAccessExpression(expr)) {
|
|
151
|
+
let symbol = checker.getSymbolAtLocation(expr);
|
|
152
|
+
while (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol);
|
|
153
|
+
|
|
154
|
+
const declaration = (symbol ?? (expr as any).symbol)?.declarations?.[0];
|
|
155
|
+
if (declaration == null || isThirdParty(declaration.getSourceFile().fileName)) return text;
|
|
156
|
+
if (ts.isVariableDeclaration(declaration) || ts.isPropertyDeclaration(declaration)) {
|
|
157
|
+
return resolveExpression(checker, declaration.initializer);
|
|
158
|
+
}
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return text;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const KEYWORD_TEXT: Record<number, string> = {
|
|
166
|
+
[ts.SyntaxKind.NumberKeyword]: 'number',
|
|
167
|
+
[ts.SyntaxKind.BooleanKeyword]: 'boolean',
|
|
168
|
+
[ts.SyntaxKind.StringKeyword]: 'string',
|
|
169
|
+
[ts.SyntaxKind.UndefinedKeyword]: 'undefined',
|
|
170
|
+
[ts.SyntaxKind.NullKeyword]: 'null',
|
|
171
|
+
[ts.SyntaxKind.ObjectKeyword]: 'object',
|
|
172
|
+
[ts.SyntaxKind.SymbolKeyword]: 'symbol',
|
|
173
|
+
[ts.SyntaxKind.BigIntKeyword]: 'bigint',
|
|
174
|
+
[ts.SyntaxKind.VoidKeyword]: 'void',
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const UNKNOWN_KEYWORD_TEXT: Record<number, string> = {
|
|
178
|
+
[ts.SyntaxKind.AnyKeyword]: 'any',
|
|
179
|
+
[ts.SyntaxKind.NeverKeyword]: 'never',
|
|
180
|
+
[ts.SyntaxKind.ObjectKeyword]: 'object',
|
|
181
|
+
[ts.SyntaxKind.UnknownKeyword]: 'unknown',
|
|
182
|
+
[ts.SyntaxKind.IntrinsicKeyword]: 'intrinsic',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// the text of a type, given a type node (from source) or a type (turned into a node by the checker)
|
|
186
|
+
const typeText = (checker: ts.TypeChecker, nodeOrType: any): string => {
|
|
187
|
+
let node: ts.TypeNode;
|
|
188
|
+
let type: ts.Type;
|
|
189
|
+
|
|
190
|
+
if ('kind' in nodeOrType) {
|
|
191
|
+
node = nodeOrType;
|
|
192
|
+
type = checker.getTypeFromTypeNode(nodeOrType);
|
|
193
|
+
} else {
|
|
194
|
+
type = nodeOrType;
|
|
195
|
+
const made = checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.IgnoreErrors);
|
|
196
|
+
if (!made) return checker.typeToString(type);
|
|
197
|
+
node = made;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// parentheses carry no information for the reader
|
|
201
|
+
if (ts.isParenthesizedTypeNode(node)) {
|
|
202
|
+
node = node.type;
|
|
203
|
+
type = checker.getTypeFromTypeNode(node);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const nodeAtStart = node;
|
|
207
|
+
const asString = () => checker.typeToString(type) ?? '';
|
|
208
|
+
// a node written in the source has text; a node made by the checker does not
|
|
209
|
+
const sourceText = () => {
|
|
210
|
+
try {
|
|
211
|
+
return nodeAtStart.getText() ?? '';
|
|
212
|
+
} catch {
|
|
213
|
+
return asString();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const child = (n: ts.TypeNode) => typeText(checker, n);
|
|
217
|
+
|
|
218
|
+
switch (node.kind) {
|
|
219
|
+
case ts.SyntaxKind.TypeLiteral:
|
|
220
|
+
case ts.SyntaxKind.ConditionalType:
|
|
221
|
+
case ts.SyntaxKind.UnionType:
|
|
222
|
+
case ts.SyntaxKind.IntersectionType:
|
|
223
|
+
case ts.SyntaxKind.TypeOperator:
|
|
224
|
+
return sourceText();
|
|
225
|
+
case ts.SyntaxKind.ArrayType:
|
|
226
|
+
return `${child((node as ts.ArrayTypeNode).elementType)}[]`;
|
|
227
|
+
case ts.SyntaxKind.TupleType:
|
|
228
|
+
return `[${(node as ts.TupleTypeNode).elements.map(child).join(', ')}]`;
|
|
229
|
+
case ts.SyntaxKind.NamedTupleMember:
|
|
230
|
+
return `${(node as ts.NamedTupleMember).name.getText() ?? ''}: ${child((node as ts.NamedTupleMember).type)}`;
|
|
231
|
+
case ts.SyntaxKind.OptionalType:
|
|
232
|
+
return `${child((node as ts.OptionalTypeNode).type)}?`;
|
|
233
|
+
case ts.SyntaxKind.RestType:
|
|
234
|
+
return `...${child((node as ts.RestTypeNode).type)}`;
|
|
235
|
+
case ts.SyntaxKind.IndexedAccessType:
|
|
236
|
+
return `${child((node as ts.IndexedAccessTypeNode).objectType)}[${child((node as ts.IndexedAccessTypeNode).indexType)}]`;
|
|
237
|
+
case ts.SyntaxKind.TypeReference: {
|
|
238
|
+
if ((type.flags & ts.TypeFlags.Any) === 0) return asString();
|
|
239
|
+
const ref = node as ts.TypeReferenceNode;
|
|
240
|
+
const name = ts.isIdentifier(ref.typeName) ? ref.typeName.text : ref.typeName.right.text;
|
|
241
|
+
const args = (ref.typeArguments ?? []).map(child);
|
|
242
|
+
return args.length > 0 ? `${name}<${args.join(', ')}>` : name;
|
|
243
|
+
}
|
|
244
|
+
case ts.SyntaxKind.LiteralType: {
|
|
245
|
+
const literal: any = (node as ts.LiteralTypeNode).literal;
|
|
246
|
+
switch (literal.kind) {
|
|
247
|
+
case ts.SyntaxKind.TrueKeyword:
|
|
248
|
+
return 'true';
|
|
249
|
+
case ts.SyntaxKind.FalseKeyword:
|
|
250
|
+
return 'false';
|
|
251
|
+
case ts.SyntaxKind.NullKeyword:
|
|
252
|
+
return 'null';
|
|
253
|
+
case ts.SyntaxKind.PrefixUnaryExpression:
|
|
254
|
+
if (literal.operand.kind === ts.SyntaxKind.NumericLiteral) return literal.getText();
|
|
255
|
+
if (literal.operand.kind === ts.SyntaxKind.BigIntLiteral) return literal.getText().replace('n', '');
|
|
256
|
+
return '';
|
|
257
|
+
case ts.SyntaxKind.NumericLiteral:
|
|
258
|
+
case ts.SyntaxKind.BigIntLiteral:
|
|
259
|
+
case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
|
|
260
|
+
return literal.text;
|
|
261
|
+
case ts.SyntaxKind.StringLiteral:
|
|
262
|
+
return `"${literal.text}"`;
|
|
263
|
+
default:
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
case ts.SyntaxKind.InferType: {
|
|
268
|
+
const parameter = (node as ts.InferTypeNode).typeParameter;
|
|
269
|
+
return parameter.constraint ? `infer ${parameter.name.text} extends ${child(parameter.constraint)}` : `infer ${parameter.name.text}`;
|
|
270
|
+
}
|
|
271
|
+
case ts.SyntaxKind.TypePredicate: {
|
|
272
|
+
const predicate = node as ts.TypePredicateNode;
|
|
273
|
+
const name = ts.isThisTypeNode(predicate.parameterName) ? 'this' : predicate.parameterName.getText();
|
|
274
|
+
const out = predicate.assertsModifier ? ['asserts', name] : [name];
|
|
275
|
+
if (predicate.type) out.push('is', child(predicate.type));
|
|
276
|
+
return out.join(' ');
|
|
277
|
+
}
|
|
278
|
+
case ts.SyntaxKind.FunctionType:
|
|
279
|
+
case ts.SyntaxKind.TypeQuery:
|
|
280
|
+
case ts.SyntaxKind.MappedType:
|
|
281
|
+
case ts.SyntaxKind.TemplateLiteralType:
|
|
282
|
+
return asString();
|
|
283
|
+
case ts.SyntaxKind.AnyKeyword:
|
|
284
|
+
case ts.SyntaxKind.NeverKeyword:
|
|
285
|
+
case ts.SyntaxKind.UnknownKeyword:
|
|
286
|
+
case ts.SyntaxKind.IntrinsicKeyword:
|
|
287
|
+
return UNKNOWN_KEYWORD_TEXT[node.kind];
|
|
288
|
+
default:
|
|
289
|
+
// intrinsic keywords (string, number, ...), else whatever the checker prints
|
|
290
|
+
return KEYWORD_TEXT[node.kind] ?? asString();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// type of a declaration with no checker symbol (the declared type, literals widened)
|
|
295
|
+
const typeOfDeclaration = (checker: ts.TypeChecker, node: ts.Node): ts.Type | ts.TypeNode => {
|
|
296
|
+
const fromDoc = ts.getJSDocType(node);
|
|
297
|
+
if (fromDoc) return fromDoc;
|
|
298
|
+
const type = checker.getTypeAtLocation(node);
|
|
299
|
+
const assertion =
|
|
300
|
+
ts.hasOnlyExpressionInitializer(node) &&
|
|
301
|
+
!!node.initializer &&
|
|
302
|
+
(ts.isAsExpression(node.initializer) || ts.isTypeAssertionExpression(node));
|
|
303
|
+
return assertion ? type : checker.getBaseTypeOfLiteralType(type);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
307
|
+
// class and methods
|
|
308
|
+
|
|
309
|
+
const hasFlag = (flags: number, flag: number) => (flags & flag) === flag;
|
|
310
|
+
|
|
311
|
+
const isFunctionProperty = (decl: ts.Declaration) =>
|
|
312
|
+
ts.isPropertyDeclaration(decl) && !!decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer));
|
|
313
|
+
|
|
314
|
+
const isPublic = (member: ts.Node) => {
|
|
315
|
+
const flags = ts.getCombinedModifierFlags(member as ts.Declaration);
|
|
316
|
+
const name: any = (member as any).name;
|
|
317
|
+
return !(hasFlag(flags, ts.ModifierFlags.Private) || (name && ts.isPrivateIdentifier(name)) || hasFlag(flags, ts.ModifierFlags.Protected));
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const modifierFlag = (node: ts.Node, flag: number) =>
|
|
321
|
+
!!node && ts.canHaveModifiers(node) && !ts.isVariableStatement(node) && hasFlag(ts.getCombinedModifierFlags(node as ts.Declaration), flag);
|
|
322
|
+
|
|
323
|
+
// is the member declared in the class's base class
|
|
324
|
+
const isInherited = (checker: ts.TypeChecker, classNode: ts.Node, member: ts.Symbol) => {
|
|
325
|
+
const base = checker.getTypeAtLocation(classNode).getBaseTypes?.()?.[0];
|
|
326
|
+
if (!base) return false;
|
|
327
|
+
|
|
328
|
+
const parents: ts.Node[] = (base.getSymbol()?.getDeclarations() ?? []).slice();
|
|
329
|
+
parents.push(...parents.flatMap((parent) => (ts.isClassDeclaration(parent) ? parent.members.filter(ts.isConstructorDeclaration) : [])));
|
|
330
|
+
|
|
331
|
+
return parents.some((parent) => member.getDeclarations()?.some((decl) => decl.parent === parent));
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// the class declared by a statement: `class X {}` or `const X = class {}`
|
|
335
|
+
const classOf = (node: ts.Node): ts.ClassLikeDeclaration | null => {
|
|
336
|
+
if (ts.isClassDeclaration(node)) return node;
|
|
337
|
+
if (!ts.isVariableStatement(node)) return null;
|
|
338
|
+
const initializer = node.declarationList.declarations[0]?.initializer;
|
|
339
|
+
return initializer && ts.isClassExpression(initializer) ? initializer : null;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
type Method = {
|
|
343
|
+
name: string;
|
|
344
|
+
decl: ts.Declaration;
|
|
345
|
+
type: ts.Type;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// public instance methods the connector class declares itself (not inherited, not from node_modules)
|
|
349
|
+
const methodsOf = (checker: ts.TypeChecker, classNode: ts.ClassLikeDeclaration): Method[] => {
|
|
350
|
+
const symbol = checker.getTypeAtLocation(classNode).getSymbol();
|
|
351
|
+
const declared = symbol && checker.getDeclaredTypeOfSymbol(symbol);
|
|
352
|
+
const methods: Method[] = [];
|
|
353
|
+
|
|
354
|
+
for (const prop of declared?.getProperties() ?? []) {
|
|
355
|
+
if (hasFlag(prop.flags, ts.SymbolFlags.ModuleMember) || hasFlag(prop.flags, ts.SymbolFlags.Prototype)) continue;
|
|
356
|
+
|
|
357
|
+
const decl = prop.getDeclarations()?.[0];
|
|
358
|
+
if (!decl) continue;
|
|
359
|
+
if (isThirdParty(decl.getSourceFile()?.fileName ?? '')) continue;
|
|
360
|
+
if (!ts.isMethodDeclaration(decl) && !isFunctionProperty(decl)) continue;
|
|
361
|
+
|
|
362
|
+
const overrides = modifierFlag(decl, ts.ModifierFlags.Override);
|
|
363
|
+
if (!overrides && isInherited(checker, classNode, prop)) continue;
|
|
364
|
+
if (modifierFlag(decl, ts.ModifierFlags.Static) || !isPublic(decl)) continue;
|
|
365
|
+
|
|
366
|
+
methods.push({
|
|
367
|
+
name: (decl as any).name?.getText() ?? '',
|
|
368
|
+
decl,
|
|
369
|
+
type: checker.getTypeOfSymbolAtLocation(prop, classNode),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return methods;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// the single class a connector file exports (`export default class ...`)
|
|
377
|
+
const exportedClass = (source: ts.SourceFile) => {
|
|
378
|
+
// names exported by `export {A}`, `export {A as B}` and `export default A`
|
|
379
|
+
const exportedNames = new Set<string>();
|
|
380
|
+
for (const statement of source.statements) {
|
|
381
|
+
if (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression)) exportedNames.add(statement.expression.text);
|
|
382
|
+
if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
383
|
+
for (const element of statement.exportClause.elements) exportedNames.add((element.propertyName ?? element.name).text);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const declarations: {name: string; node: ts.Node}[] = [];
|
|
388
|
+
const consider = (name: string, node: ts.Node, exportedHere: boolean) => {
|
|
389
|
+
if (!exportedHere && !exportedNames.has(name)) return;
|
|
390
|
+
if (isIgnored(jsDoc(node))) return;
|
|
391
|
+
if (!declarations.some((d) => d.name === name)) declarations.push({name, node});
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
for (const statement of source.statements) {
|
|
395
|
+
const exportedHere = ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
396
|
+
|
|
397
|
+
if (ts.isVariableStatement(statement)) {
|
|
398
|
+
for (const declaration of statement.declarationList.declarations) consider(declaration.name.getText(), statement, exportedHere);
|
|
399
|
+
} else if (
|
|
400
|
+
ts.isFunctionDeclaration(statement) ||
|
|
401
|
+
ts.isClassDeclaration(statement) ||
|
|
402
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
403
|
+
ts.isEnumDeclaration(statement) ||
|
|
404
|
+
ts.isTypeAliasDeclaration(statement)
|
|
405
|
+
) {
|
|
406
|
+
consider(statement.name?.getText() ?? 'default', statement, exportedHere);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return declarations.length === 1 ? classOf(declarations[0].node) : null;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
414
|
+
// declarations
|
|
2
415
|
|
|
3
416
|
function stripQuotes(name: string): string {
|
|
4
417
|
if ((name.startsWith("'") && name.endsWith("'")) || (name.startsWith('"') && name.endsWith('"'))) {
|
|
@@ -7,104 +420,125 @@ function stripQuotes(name: string): string {
|
|
|
7
420
|
return name;
|
|
8
421
|
}
|
|
9
422
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
423
|
+
const symbolOf = (checker: ts.TypeChecker, node: any): ts.Symbol | null => {
|
|
424
|
+
let symbol: ts.Symbol | undefined;
|
|
425
|
+
try {
|
|
426
|
+
symbol = checker.getSymbolAtLocation(node) ?? (node.name ? checker.getSymbolAtLocation(node.name) : undefined);
|
|
427
|
+
} catch {
|
|
428
|
+
symbol = undefined;
|
|
429
|
+
}
|
|
430
|
+
while (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol);
|
|
431
|
+
return symbol ?? node.symbol ?? null;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// the properties of the object type of a destructured parameter: [name, type text]
|
|
435
|
+
//
|
|
436
|
+
// The type is turned into a type literal node and each property is read from that node, the way the previous
|
|
437
|
+
// implementation did, so the declarations stay identical (a property written as `email: string` in a
|
|
438
|
+
// `{email}: {email: string}` parameter is printed as `any`: a node the checker made cannot be resolved again).
|
|
439
|
+
const propertiesOf = (checker: ts.TypeChecker, parameter: ts.ParameterDeclaration, symbol: ts.Symbol | undefined): [string, string][] => {
|
|
440
|
+
const type = symbol ? checker.getTypeOfSymbolAtLocation(symbol, parameter) : typeOfDeclaration(checker, parameter);
|
|
441
|
+
|
|
442
|
+
let node: ts.TypeNode | undefined = 'kind' in type ? type : checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.IgnoreErrors);
|
|
443
|
+
if (node && ts.isParenthesizedTypeNode(node)) node = node.type;
|
|
444
|
+
|
|
445
|
+
if (node && ts.isTypeLiteralNode(node)) {
|
|
446
|
+
const literal = node;
|
|
447
|
+
return literal.members.filter(ts.isPropertySignature).map((member) => {
|
|
448
|
+
const name = ts.isIdentifier(member.name) ? String(member.name.escapedText) : member.name.getText();
|
|
449
|
+
const memberSymbol = symbolOf(checker, member);
|
|
450
|
+
const memberType = memberSymbol && checker.getTypeOfSymbolAtLocation(memberSymbol, literal);
|
|
451
|
+
|
|
452
|
+
const fromDoc = ts.getJSDocType(member);
|
|
453
|
+
if (fromDoc) return [name, typeText(checker, fromDoc)];
|
|
454
|
+
if (memberType) return [name, typeText(checker, memberType)];
|
|
455
|
+
if (member.type) return [name, typeText(checker, member.type)];
|
|
456
|
+
return [name, typeText(checker, typeOfDeclaration(checker, member))];
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// not a type literal (an interface or a type alias, say): list its properties with the checker's types
|
|
461
|
+
const realType = 'kind' in type ? checker.getTypeFromTypeNode(type) : type;
|
|
462
|
+
return checker.getPropertiesOfType(realType).map((prop) => [prop.getName(), typeText(checker, checker.getTypeOfSymbolAtLocation(prop, parameter))]);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const declare = (checker: ts.TypeChecker, method: Method) => {
|
|
466
|
+
const methodName = stripQuotes(method.name);
|
|
467
|
+
|
|
468
|
+
return method.type
|
|
469
|
+
.getNonNullableType()
|
|
470
|
+
.getCallSignatures()
|
|
471
|
+
.map((sig) => {
|
|
472
|
+
const declaration: any = sig.getDeclaration();
|
|
473
|
+
const docs = jsDoc(declaration);
|
|
474
|
+
const desc = docs.find((what) => what.kind === 'description')?.value;
|
|
475
|
+
const example = docs.find((what) => what.kind === 'example')?.value;
|
|
476
|
+
const ns = docs.find((what) => what.kind === 'namespace')?.value;
|
|
477
|
+
const space = ns ? `@namespace ${ns}` : '';
|
|
478
|
+
|
|
479
|
+
let eg;
|
|
480
|
+
if (example) {
|
|
481
|
+
const parts = example.split(/```/);
|
|
482
|
+
const backticks = '```';
|
|
483
|
+
eg = `@example ${parts[0] || 'usage'}\n${backticks}${parts[1]}${backticks}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const paramDocs = docs.filter((what) => what.kind === 'param');
|
|
487
|
+
|
|
488
|
+
// only destructured parameters ({a, b}: {...}) are part of the declaration
|
|
489
|
+
const params = (declaration.parameters as ts.ParameterDeclaration[])
|
|
490
|
+
.map((parameter, index) => ({parameter, symbol: sig.parameters[index]}))
|
|
491
|
+
.filter(({parameter}) => !isIgnored(jsDoc(parameter)) && ts.isObjectBindingPattern(parameter.name))
|
|
492
|
+
.map(({parameter, symbol}) => {
|
|
493
|
+
const prefix = (parameter.name as ts.ObjectBindingPattern).elements
|
|
494
|
+
.map((p) => {
|
|
495
|
+
const defaultVal = resolveExpression(checker, p.initializer);
|
|
496
|
+
return `${p.name.getText()}${defaultVal != null ? ' = ' + defaultVal : ''}`;
|
|
497
|
+
})
|
|
498
|
+
.join('; ');
|
|
499
|
+
|
|
500
|
+
const suffix = propertiesOf(checker, parameter, symbol)
|
|
501
|
+
.map(([name, type]) => {
|
|
502
|
+
const comment = paramDocs.find((what) => what.value.name === name);
|
|
503
|
+
const description = (comment?.value.description || '').replace(/\\@/gi, '@');
|
|
504
|
+
|
|
505
|
+
return `\n/**\n${description}\n */\n ${name}: ${type}`;
|
|
81
506
|
})
|
|
82
|
-
.join('
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.serialize()
|
|
86
|
-
.return.type.text.replace(/^Promise</, '')
|
|
87
|
-
.replace(/>$/, '');
|
|
88
|
-
|
|
89
|
-
return `
|
|
90
|
-
/**
|
|
91
|
-
* ${desc || ''}
|
|
92
|
-
*
|
|
93
|
-
* ${space || ''}
|
|
94
|
-
* ${eg || ''}
|
|
95
|
-
**/
|
|
96
|
-
declare function ${methodName}(${params}): ${retVal};
|
|
97
|
-
`;
|
|
507
|
+
.join('; ');
|
|
508
|
+
|
|
509
|
+
return `{${prefix}}: {${suffix}}`;
|
|
98
510
|
})
|
|
99
|
-
.join('
|
|
100
|
-
})
|
|
101
|
-
.join('');
|
|
511
|
+
.join(', ');
|
|
102
512
|
|
|
103
|
-
|
|
513
|
+
const fromDoc = ts.getJSDocReturnType(declaration);
|
|
514
|
+
const retVal = typeText(checker, fromDoc ?? checker.getReturnTypeOfSignature(sig)).replace(/^Promise</, '').replace(/>$/, '');
|
|
515
|
+
|
|
516
|
+
// the exact whitespace (including the spaces after "**/") is part of the output other tools read
|
|
517
|
+
return [
|
|
518
|
+
'',
|
|
519
|
+
'/**',
|
|
520
|
+
` * ${desc || ''}`,
|
|
521
|
+
' *',
|
|
522
|
+
` * ${space || ''}`,
|
|
523
|
+
` * ${eg || ''}`,
|
|
524
|
+
' **/ ',
|
|
525
|
+
`declare function ${methodName}(${params}): ${retVal};`,
|
|
526
|
+
' ',
|
|
527
|
+
].join('\n');
|
|
528
|
+
})
|
|
529
|
+
.join('\n');
|
|
104
530
|
};
|
|
105
531
|
|
|
106
|
-
export default async (
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
532
|
+
export default async (file: string) => {
|
|
533
|
+
const {checker, source} = createProgram(file);
|
|
534
|
+
|
|
535
|
+
const classNode = exportedClass(source);
|
|
536
|
+
if (!classNode) throw new Error('connector file needs to export default class');
|
|
537
|
+
|
|
538
|
+
const methods = methodsOf(checker, classNode).filter((method) => !stripQuotes(method.name).startsWith('_'));
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
text: methods.map((method) => declare(checker, method)).join(''),
|
|
542
|
+
methods: [...new Set(methods.map((method) => stripQuotes(method.name)))],
|
|
543
|
+
};
|
|
110
544
|
};
|