@aloma.io/integration-sdk 3.8.67 → 3.8.69

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.
Files changed (65) hide show
  1. package/README.md +60 -0
  2. package/build/testing/fake-aloma.d.mts +87 -0
  3. package/build/testing/fake-aloma.mjs +248 -0
  4. package/build/testing/harness.d.mts +51 -0
  5. package/build/testing/harness.mjs +139 -0
  6. package/build/testing/index.d.mts +15 -0
  7. package/build/testing/index.mjs +13 -0
  8. package/build/testing/keys.d.mts +9 -0
  9. package/build/testing/keys.mjs +15 -0
  10. package/build/testing/network-guard.d.mts +3 -0
  11. package/build/testing/network-guard.mjs +49 -0
  12. package/build/testing/runtime-connector.d.mts +11 -0
  13. package/build/testing/runtime-connector.mjs +38 -0
  14. package/build/testing/vendor-mock.d.mts +59 -0
  15. package/build/testing/vendor-mock.mjs +121 -0
  16. package/build/transform/index.d.mts +2 -2
  17. package/build/transform/index.mjs +493 -78
  18. package/package.json +8 -4
  19. package/src/testing/fake-aloma.mts +306 -0
  20. package/src/testing/harness.mts +172 -0
  21. package/src/testing/index.mts +15 -0
  22. package/src/testing/keys.mts +19 -0
  23. package/src/testing/network-guard.mts +57 -0
  24. package/src/testing/runtime-connector.mts +50 -0
  25. package/src/testing/vendor-mock.mts +166 -0
  26. package/src/transform/index.mts +530 -96
  27. package/test/MUTATION-entry.md +21 -0
  28. package/test/MUTATION.md +43 -0
  29. package/test/fixtures/transform/basic/expected.json +9 -0
  30. package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
  31. package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
  32. package/test/fixtures/transform/basic/tsconfig.json +1 -0
  33. package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
  34. package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
  35. package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
  36. package/test/fixtures/transform/overloads/expected.json +6 -0
  37. package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
  38. package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
  39. package/test/fixtures/transform/overloads/tsconfig.json +1 -0
  40. package/test/fixtures/transform/tdd-stub/expected.json +8 -0
  41. package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
  42. package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
  43. package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
  44. package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
  45. package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
  46. package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
  47. package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
  48. package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
  49. package/test/fixtures/transform/type-error/tsconfig.json +1 -0
  50. package/test/fixtures/transform/typed/expected.json +8 -0
  51. package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
  52. package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
  53. package/test/fixtures/transform/typed/tsconfig.json +1 -0
  54. package/test/fixtures/transform/visibility/expected.json +9 -0
  55. package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
  56. package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
  57. package/test/fixtures/transform/visibility/tsconfig.json +1 -0
  58. package/test/harness-connector-smoke.test.mjs +72 -0
  59. package/test/harness.test.mjs +375 -0
  60. package/test/openapi-to-connector.test.mts +25 -7
  61. package/test/scenarios.test.mts +15 -7
  62. package/test/transform.test.mjs +68 -0
  63. package/tools/mutation-run-entry.mjs +52 -0
  64. package/tools/mutation-run.mjs +120 -0
  65. package/tools/run-tests.mjs +64 -0
@@ -1,4 +1,417 @@
1
- import {parseFromFiles} from '@ts-ast-parser/core';
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 transform = (meta: any) => {
11
- if (!meta?.length) throw new Error('metadata is empty');
12
- meta = meta[0];
13
-
14
- if (meta.getDeclarations()?.length !== 1) {
15
- throw new Error('connector file needs to export default class');
16
- }
17
-
18
- const methods = {};
19
- const decl = meta.getDeclarations()[0];
20
-
21
- const members = decl.getMethods().filter((member: any) => {
22
- return !(
23
- member.isStatic() ||
24
- member.isInherited() ||
25
- member.getKind() !== 'Method' ||
26
- member.getModifier() !== 'public' ||
27
- stripQuotes(member.getName()).startsWith('_')
28
- );
29
- });
30
-
31
- const text = members
32
- .map((member: any) => {
33
- const methodName = stripQuotes(member.getName());
34
- methods[methodName] = true;
35
-
36
- return member
37
- .getSignatures()
38
- .map((sig: any) => {
39
- const docs = sig.getJSDoc().serialize() || [];
40
- const desc = docs.find((what: any) => what.kind === 'description')?.value;
41
-
42
- const example = docs.find((what: any) => what.kind === 'example')?.value;
43
-
44
- const ns = docs.find((what: any) => what.kind === 'namespace')?.value;
45
- let space = ns ? `@namespace ${ns}` : '';
46
-
47
- let eg;
48
- if (example) {
49
- const parts = example.split(/```/);
50
- const backticks = '```';
51
- eg = `@example ${parts[0] || 'usage'}\n${backticks}${parts[1]}${backticks}`;
52
- }
53
-
54
- const paramDocs = docs.filter((what: any) => what.kind === 'param');
55
-
56
- const params = sig
57
- .getParameters()
58
- .filter((param) => param.isNamed())
59
- .map((param: any) => {
60
- const serialized = param.serialize();
61
-
62
- const prefix = param
63
- .getNamedElements()
64
- .map((p) => {
65
- const defaultVal = p.getDefault() != null ? ' = ' + p.getDefault() : '';
66
-
67
- return `${p.getName()}${defaultVal}`;
68
- })
69
- .join('; ');
70
-
71
- const suffix = serialized.type.properties
72
- .map((p) => {
73
- const comment = paramDocs.find((what) => what.value.name === p.name);
74
- const desc = (comment?.value.description || '').replace(/\\@/gi, '@');
75
-
76
- return `\n/**\n${desc}\n */\n ${p.name}: ${p.type.text}`;
77
- })
78
- .join('; ');
79
-
80
- return `{${prefix}}: {${suffix}}`;
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
- const retVal = sig
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('\n');
100
- })
101
- .join('');
511
+ .join(', ');
102
512
 
103
- return {text, methods: Object.keys(methods)};
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 (path: string) => {
107
- const parsed = await parseFromFiles([path]);
108
- if (parsed.errors?.length) throw new Error(path + ' ' + JSON.stringify(parsed.errors));
109
- return transform(parsed.project?.getModules() || []);
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
  };
@@ -0,0 +1,21 @@
1
+ # Mutation check for the SDK tests that `yarn test` now runs
2
+
3
+ Result: **8/8 mutants killed** (24 Oct). Run with `yarn build && node tools/mutation-run-entry.mjs`.
4
+
5
+ Each row is one deliberate single-point bug in `src/openapi-to-connector.mts` (the OpenAPI to connector generator). The
6
+ script applies it alone, rebuilds, runs the full `yarn test`, expects a failure, and restores the file.
7
+
8
+ | # | Bug | Result |
9
+ |---|-----|--------|
10
+ | E01 | the HTTP verb is taken in lower case | killed |
11
+ | E02 | path parameter substitution is guarded by the wrong condition | killed |
12
+ | E03 | fallback method name drops the separator | killed |
13
+ | E04 | body properties are documented with the wrong required flag | killed |
14
+ | E05 | headers are dropped from the generated options type | killed |
15
+ | E06 | options is not defaulted in generated methods | killed |
16
+ | E07 | invalid specs are no longer rejected | killed |
17
+ | E08 | a property is required only if both the schema and the body say so | killed |
18
+
19
+ A first run left two survivors: E02 (nothing asserted the path parameter substitution) and a draft E03 aimed at
20
+ `disambiguateMethodName` (no test reaches it, so a different mutant on the fallback name was used). A test for E02 was
21
+ added to `test/openapi-to-connector.test.mts`; `disambiguateMethodName` remains without direct coverage.