@aws-blocks/core 0.4.0 → 0.5.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.
Files changed (90) hide show
  1. package/README.md +12 -0
  2. package/dist/cdk/blocks-backend.d.ts +4 -1
  3. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  4. package/dist/cdk/blocks-backend.js +56 -10
  5. package/dist/cdk/blocks-backend.test.js +71 -1
  6. package/dist/cdk/blocks-defaults.d.ts +11 -0
  7. package/dist/cdk/blocks-defaults.d.ts.map +1 -1
  8. package/dist/cdk/blocks-stack.test.js +23 -3
  9. package/dist/cdk/compute/compute.d.ts +80 -2
  10. package/dist/cdk/compute/compute.d.ts.map +1 -1
  11. package/dist/cdk/compute/compute.js +57 -3
  12. package/dist/cdk/config-registry.test.js +12 -0
  13. package/dist/cdk/dashboard-registry.d.ts +41 -0
  14. package/dist/cdk/dashboard-registry.d.ts.map +1 -0
  15. package/dist/cdk/dashboard-registry.js +61 -0
  16. package/dist/cdk/index.d.ts +67 -11
  17. package/dist/cdk/index.d.ts.map +1 -1
  18. package/dist/cdk/index.js +100 -18
  19. package/dist/cdk/internal.d.ts +3 -1
  20. package/dist/cdk/internal.d.ts.map +1 -1
  21. package/dist/cdk/internal.js +4 -1
  22. package/dist/cdk/tracer-registry.d.ts +31 -0
  23. package/dist/cdk/tracer-registry.d.ts.map +1 -0
  24. package/dist/cdk/tracer-registry.js +49 -0
  25. package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
  26. package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
  27. package/dist/cdk/vpc-requirements-registry.js +46 -0
  28. package/dist/cdk/vpc-types.d.ts +151 -0
  29. package/dist/cdk/vpc-types.d.ts.map +1 -0
  30. package/dist/cdk/vpc-types.js +3 -0
  31. package/dist/cdk/vpc.d.ts +59 -0
  32. package/dist/cdk/vpc.d.ts.map +1 -0
  33. package/dist/cdk/vpc.js +298 -0
  34. package/dist/cdk/vpc.test.d.ts +2 -0
  35. package/dist/cdk/vpc.test.d.ts.map +1 -0
  36. package/dist/cdk/vpc.test.js +285 -0
  37. package/dist/errors.d.ts +5 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js +5 -0
  40. package/dist/hosting.d.ts.map +1 -1
  41. package/dist/hosting.js +2 -0
  42. package/dist/hosting.test.js +39 -1
  43. package/dist/index.cdk.d.ts +2 -1
  44. package/dist/index.cdk.d.ts.map +1 -1
  45. package/dist/index.cdk.js +1 -1
  46. package/dist/lambda-handler.js +9 -2
  47. package/dist/lambda-handler.test.js +61 -1
  48. package/dist/raw-route.d.ts +15 -1
  49. package/dist/raw-route.d.ts.map +1 -1
  50. package/dist/raw-route.js +96 -12
  51. package/dist/raw-route.test.js +332 -1
  52. package/dist/scripts/dev-server.d.ts.map +1 -1
  53. package/dist/scripts/dev-server.js +11 -0
  54. package/dist/scripts/extract-ts-types.d.ts.map +1 -1
  55. package/dist/scripts/extract-ts-types.js +107 -23
  56. package/dist/scripts/extract-ts-types.test.js +225 -26
  57. package/dist/scripts/generate-spec.d.ts.map +1 -1
  58. package/dist/scripts/generate-spec.js +14 -5
  59. package/dist/scripts/generate-spec.test.js +93 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +8 -1
  63. package/src/cdk/blocks-backend.test.ts +144 -60
  64. package/src/cdk/blocks-backend.ts +298 -239
  65. package/src/cdk/blocks-defaults.ts +12 -0
  66. package/src/cdk/blocks-stack.test.ts +32 -13
  67. package/src/cdk/compute/compute.ts +105 -3
  68. package/src/cdk/config-registry.test.ts +14 -0
  69. package/src/cdk/dashboard-registry.ts +68 -0
  70. package/src/cdk/index.ts +426 -298
  71. package/src/cdk/internal.ts +6 -2
  72. package/src/cdk/tracer-registry.ts +54 -0
  73. package/src/cdk/vpc-requirements-registry.ts +63 -0
  74. package/src/cdk/vpc-types.ts +158 -0
  75. package/src/cdk/vpc.test.ts +348 -0
  76. package/src/cdk/vpc.ts +336 -0
  77. package/src/errors.ts +5 -0
  78. package/src/hosting.test.ts +59 -1
  79. package/src/hosting.ts +3 -0
  80. package/src/index.cdk.ts +7 -0
  81. package/src/lambda-handler.test.ts +79 -1
  82. package/src/lambda-handler.ts +11 -2
  83. package/src/raw-route.test.ts +427 -1
  84. package/src/raw-route.ts +125 -12
  85. package/src/scripts/dev-server.ts +12 -1
  86. package/src/scripts/extract-ts-types.test.ts +228 -26
  87. package/src/scripts/extract-ts-types.ts +104 -20
  88. package/src/scripts/generate-spec.test.ts +101 -0
  89. package/src/scripts/generate-spec.ts +15 -5
  90. package/src/version.ts +1 -1
@@ -46,13 +46,16 @@ export function extractSkipCodegenMethods(sourcePath) {
46
46
  return result;
47
47
  const sourceFile = ts.createSourceFile(absPath, fileText, ts.ScriptTarget.ESNext,
48
48
  /*setParentNodes*/ true, ts.ScriptKind.TS);
49
- function visitObjectLiteral(obj) {
49
+ function visitObjectLiteral(obj, namespaceName) {
50
50
  for (const prop of obj.properties) {
51
51
  if (ts.isMethodDeclaration(prop) &&
52
52
  prop.name &&
53
53
  ts.isIdentifier(prop.name) &&
54
54
  hasBlocksSkipCodegenTag(prop)) {
55
- result.add(prop.name.text);
55
+ // Qualify by namespace (like extractMethodTypes) so a tag on one
56
+ // namespace's `create` doesn't cause another namespace's `create` to be
57
+ // dropped from the spec. Bare key only when the namespace is unknown.
58
+ result.add(namespaceName ? `${namespaceName}.${prop.name.text}` : prop.name.text);
56
59
  }
57
60
  }
58
61
  }
@@ -61,23 +64,24 @@ export function extractSkipCodegenMethods(sourcePath) {
61
64
  const callee = node.expression;
62
65
  if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace' && node.arguments && node.arguments.length >= 2) {
63
66
  const handler = node.arguments[node.arguments.length - 1];
67
+ const nsName = enclosingBindingName(node);
64
68
  if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) {
65
69
  const body = handler.body;
66
70
  if (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) {
67
- visitObjectLiteral(body.expression);
71
+ visitObjectLiteral(body.expression, nsName);
68
72
  }
69
73
  else if (ts.isObjectLiteralExpression(body)) {
70
- visitObjectLiteral(body);
74
+ visitObjectLiteral(body, nsName);
71
75
  }
72
76
  else if (ts.isBlock(body)) {
73
77
  for (const stmt of body.statements) {
74
78
  if (ts.isReturnStatement(stmt) && stmt.expression) {
75
79
  if (ts.isObjectLiteralExpression(stmt.expression)) {
76
- visitObjectLiteral(stmt.expression);
80
+ visitObjectLiteral(stmt.expression, nsName);
77
81
  }
78
82
  else if (ts.isParenthesizedExpression(stmt.expression) &&
79
83
  ts.isObjectLiteralExpression(stmt.expression.expression)) {
80
- visitObjectLiteral(stmt.expression.expression);
84
+ visitObjectLiteral(stmt.expression.expression, nsName);
81
85
  }
82
86
  }
83
87
  }
@@ -135,7 +139,7 @@ export function extractMethodTypes(sourcePath) {
135
139
  const fnName = node.expression;
136
140
  if (ts.isIdentifier(fnName) && fnName.text === 'ApiNamespace' && node.arguments && node.arguments.length >= 2) {
137
141
  const handlerArg = node.arguments[node.arguments.length - 1];
138
- extractMethodsFromHandler(handlerArg, checker, sourceFile, result);
142
+ extractMethodsFromHandler(handlerArg, checker, sourceFile, result, enclosingBindingName(node));
139
143
  }
140
144
  }
141
145
  ts.forEachChild(node, visit);
@@ -148,19 +152,67 @@ export function extractMethodTypes(sourcePath) {
148
152
  if (!ts.isVariableStatement(node))
149
153
  return;
150
154
  for (const decl of node.declarationList.declarations) {
151
- if (!decl.initializer || !ts.isIdentifier(decl.name))
155
+ if (!decl.initializer)
156
+ continue;
157
+ // (a) Simple binding: `const api = auth.createApi()` /
158
+ // `export const api = backend.namespace`.
159
+ if (ts.isIdentifier(decl.name)) {
160
+ // Skip if we already extracted methods for this via the AST walk
161
+ if (ts.isNewExpression(decl.initializer)) {
162
+ const callee = decl.initializer.expression;
163
+ if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace')
164
+ continue;
165
+ }
166
+ // Use the type checker to get the type of the initializer expression.
167
+ // For `auth.createApi()`, this resolves through the method's return type
168
+ // to the `AsyncAPI<T>` type that ApiNamespace returns.
169
+ const initType = checker.getTypeAtLocation(decl.initializer);
170
+ extractMethodsFromResolvedType(initType, checker, result, decl.name.text);
152
171
  continue;
153
- // Skip if we already extracted methods for this via the AST walk
154
- if (ts.isNewExpression(decl.initializer)) {
155
- const callee = decl.initializer.expression;
156
- if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace')
157
- continue;
158
172
  }
159
- // Use the type checker to get the type of the initializer expression.
160
- // For `auth.createApi()`, this resolves through the method's return type
161
- // to the `AsyncAPI<T>` type that ApiNamespace returns.
162
- const initType = checker.getTypeAtLocation(decl.initializer);
163
- extractMethodsFromResolvedType(initType, checker, result);
173
+ // (b) Object binding pattern: `const { indirectNamespace } = factory()`.
174
+ // Each destructured binding is a separate namespace whose name is the
175
+ // LOCAL binding name (the name it's exported under). Resolve the property
176
+ // type off the initializer and extract methods keyed by that name, so a
177
+ // factory-returned namespace keys qualified (`indirectNamespace.method`)
178
+ // like a directly-constructed one — instead of landing on a bare key that
179
+ // collides with same-named methods in other namespaces (#444, #445).
180
+ //
181
+ // This adds the qualified key ALONGSIDE the bare key the first-pass AST
182
+ // walk already emitted for the same `new ApiNamespace(...)` (its
183
+ // `enclosingBindingName` returns undefined behind the factory, so it keys
184
+ // bare). Leaving the bare key is harmless: `generate-spec` looks up
185
+ // `tsTypes.get(qualified) ?? tsTypes.get(bare)`, so the qualified key
186
+ // added here always wins for an attributable namespace, and the bare
187
+ // entry never influences the emitted schema (a regression test asserts
188
+ // this under a name collision). The #498 bare-name fallback is retained
189
+ // on purpose — it's the last resort for namespaces this pass still can't
190
+ // attribute (default exports, deeper indirection, the patterns below), so
191
+ // it is NOT redundant.
192
+ //
193
+ // Boundary: only a top-level identifier (a) and a shallow object binding
194
+ // pattern (b) are attributed. Array patterns (`const [ns] = factory()`)
195
+ // and nested patterns (`const { a: { b } } = …`) fall through to the
196
+ // bare-key path, where the #498 fallback still resolves the schema when
197
+ // there's no collision. These shapes are rare for namespace exports and
198
+ // fail soft; widening the traversal can come later if a real case appears.
199
+ if (ts.isObjectBindingPattern(decl.name)) {
200
+ const initType = checker.getTypeAtLocation(decl.initializer);
201
+ for (const element of decl.name.elements) {
202
+ if (!ts.isIdentifier(element.name))
203
+ continue;
204
+ const localName = element.name.text;
205
+ // `const { foo: bar } = …` binds `bar` but reads property `foo`.
206
+ const propKey = element.propertyName && ts.isIdentifier(element.propertyName)
207
+ ? element.propertyName.text
208
+ : localName;
209
+ const propSymbol = initType.getProperty(propKey);
210
+ if (!propSymbol)
211
+ continue;
212
+ const propType = checker.getTypeOfSymbol(propSymbol);
213
+ extractMethodsFromResolvedType(propType, checker, result, localName);
214
+ }
215
+ }
164
216
  }
165
217
  });
166
218
  return result;
@@ -180,7 +232,7 @@ function findTsConfig(dir) {
180
232
  return undefined;
181
233
  }
182
234
  // ── Handler extraction ──────────────────────────────────────────────────────
183
- function extractMethodsFromHandler(handlerNode, checker, sourceFile, result) {
235
+ function extractMethodsFromHandler(handlerNode, checker, sourceFile, result, namespaceName) {
184
236
  let objectLiteral;
185
237
  if (ts.isArrowFunction(handlerNode) || ts.isFunctionExpression(handlerNode)) {
186
238
  const body = handlerNode.body;
@@ -212,11 +264,42 @@ function extractMethodsFromHandler(handlerNode, checker, sourceFile, result) {
212
264
  if (ts.isMethodDeclaration(prop) && prop.name && ts.isIdentifier(prop.name)) {
213
265
  const methodName = prop.name.text;
214
266
  const info = extractMethodTypeInfo(prop, checker, sourceFile);
267
+ // Key by the namespace-qualified name when the namespace is known, so two
268
+ // namespaces sharing a method name (e.g. widgets.create / subscriptions.create)
269
+ // don't collide and cross-assign schemas. Fall back to the bare name when the
270
+ // namespace can't be determined (consumers try qualified, then bare).
215
271
  if (info)
216
- result.set(methodName, info);
272
+ result.set(namespaceName ? `${namespaceName}.${methodName}` : methodName, info);
217
273
  }
218
274
  }
219
275
  }
276
+ /**
277
+ * Walk up from a `new ApiNamespace(...)` node to the name it's bound to
278
+ * (`export const widgets = new ApiNamespace(...)` → `"widgets"`). This is the
279
+ * name the dev server / Lambda handler route by, and the one `generate-spec`
280
+ * uses to qualify method names. Returns `undefined` when the namespace is
281
+ * created behind indirection (factory return, etc.), in which case methods key
282
+ * by their bare name.
283
+ *
284
+ * Limitation: this is the local binding name, not the export name. An export
285
+ * rename — `const widgets = new ApiNamespace(...); export { widgets as gadgets }`
286
+ * — keys `widgets.*` while `generate-spec` looks up `gadgets.*`, so the qualified
287
+ * lookup misses and falls back to the bare key (schemas resolve if there's no
288
+ * collision, else `unknown`). Rare; resolving the export alias would remove it.
289
+ */
290
+ function enclosingBindingName(node) {
291
+ let current = node.parent;
292
+ while (current) {
293
+ if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name))
294
+ return current.name.text;
295
+ // Don't cross a function/block boundary — beyond it the binding no longer
296
+ // corresponds to a directly-exported namespace.
297
+ if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isFunctionLike(current))
298
+ return undefined;
299
+ current = current.parent;
300
+ }
301
+ return undefined;
302
+ }
220
303
  function extractMethodTypeInfo(method, checker, sourceFile) {
221
304
  const params = [];
222
305
  for (const param of method.parameters) {
@@ -284,7 +367,7 @@ function unwrapPromiseTypeNode(node) {
284
367
  * We also handle the case where the type is callable (the raw handler function
285
368
  * before `AsyncAPI` wrapping) — we call-resolve it and inspect the result.
286
369
  */
287
- function extractMethodsFromResolvedType(type, checker, result) {
370
+ function extractMethodsFromResolvedType(type, checker, result, namespaceName) {
288
371
  // The runtime value of an ApiNamespace export is the handler function itself
289
372
  // (with the marker symbol attached). Its TS type is `AsyncAPI<T>`, which is
290
373
  // an object type with method-like properties. But it could also be typed as
@@ -309,8 +392,9 @@ function extractMethodsFromResolvedType(type, checker, result) {
309
392
  // Skip internal symbols and the marker
310
393
  if (propName.startsWith('_') || propName === 'Symbol(blocks:ApiNamespace)')
311
394
  continue;
395
+ const key = namespaceName ? `${namespaceName}.${propName}` : propName;
312
396
  // Don't overwrite methods already found by the AST walk
313
- if (result.has(propName))
397
+ if (result.has(key))
314
398
  continue;
315
399
  const propType = checker.getTypeOfSymbol(prop);
316
400
  const methodSigs = propType.getCallSignatures();
@@ -346,7 +430,7 @@ function extractMethodsFromResolvedType(type, checker, result) {
346
430
  if (declaration && hasBlocksSkipCodegenTag(declaration)) {
347
431
  skipCodegen = true;
348
432
  }
349
- result.set(propName, { params, returnType, transferable, skipCodegen });
433
+ result.set(key, { params, returnType, transferable, skipCodegen });
350
434
  }
351
435
  }
352
436
  // ── Transferable detection ───────────────────────────────────────────────────
@@ -25,6 +25,45 @@ const API_NS_MOCK = `
25
25
  interface ApiNamespaceConstructor { new <T>(scope: any, name: string, handler: (ctx: any) => T): T; }
26
26
  const ApiNamespace: ApiNamespaceConstructor = class {} as any;
27
27
  `;
28
+ describe('extractMethodTypes — namespaces sharing a method name (regression: #445)', () => {
29
+ it('keys methods by namespace, so a shared method name does not cross-assign schemas', () => {
30
+ const dir = createTempProject({
31
+ 'tsconfig.json': JSON.stringify({
32
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
33
+ }),
34
+ 'index.ts': `
35
+ ${API_NS_MOCK}
36
+
37
+ // Two namespaces both expose \`create\`, with deliberately incompatible contracts.
38
+ export const widgets = new ApiNamespace(null, 'widgets', (context) => ({
39
+ async create(label: string): Promise<{ widgetId: string }> {
40
+ return { widgetId: 'w' };
41
+ },
42
+ }));
43
+ export const subscriptions = new ApiNamespace(null, 'subscriptions', (context) => ({
44
+ async create(topicCount: number): Promise<{ subId: number }> {
45
+ return { subId: 1 };
46
+ },
47
+ }));
48
+ `,
49
+ });
50
+ try {
51
+ const types = extractMethodTypes(join(dir, 'index.ts'));
52
+ // Distinct qualified keys — no collision.
53
+ assert.ok(types.has('widgets.create'), 'widgets.create should be keyed by namespace');
54
+ assert.ok(types.has('subscriptions.create'), 'subscriptions.create should be keyed by namespace');
55
+ assert.ok(!types.has('create'), 'no bare-name entry that could cross-assign');
56
+ // Each keeps its OWN parameter type.
57
+ assert.strictEqual(types.get('widgets.create').params[0].name, 'label');
58
+ assert.strictEqual(types.get('widgets.create').params[0].schema.type, 'string');
59
+ assert.strictEqual(types.get('subscriptions.create').params[0].name, 'topicCount');
60
+ assert.strictEqual(types.get('subscriptions.create').params[0].schema.type, 'number');
61
+ }
62
+ finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ });
66
+ });
28
67
  describe('extractMethodTypes — direct ApiNamespace calls', () => {
29
68
  it('extracts param and return types from inline ApiNamespace', () => {
30
69
  const dir = createTempProject({
@@ -53,13 +92,13 @@ describe('extractMethodTypes — direct ApiNamespace calls', () => {
53
92
  });
54
93
  try {
55
94
  const types = extractMethodTypes(join(dir, 'index.ts'));
56
- assert.ok(types.has('getUser'), 'should find getUser');
57
- assert.ok(types.has('createUser'), 'should find createUser');
58
- const getUser = types.get('getUser');
95
+ assert.ok(types.has('api.getUser'), 'should find getUser');
96
+ assert.ok(types.has('api.createUser'), 'should find createUser');
97
+ const getUser = types.get('api.getUser');
59
98
  assert.strictEqual(getUser.params.length, 1);
60
99
  assert.strictEqual(getUser.params[0].name, 'id');
61
100
  assert.strictEqual(getUser.params[0].schema.type, 'string');
62
- const createUser = types.get('createUser');
101
+ const createUser = types.get('api.createUser');
63
102
  assert.strictEqual(createUser.params.length, 2);
64
103
  assert.strictEqual(createUser.params[0].schema.type, 'string');
65
104
  assert.strictEqual(createUser.params[1].schema.type, 'number');
@@ -94,9 +133,34 @@ describe('extractSkipCodegenMethods — pure-AST scan', () => {
94
133
  try {
95
134
  const skips = extractSkipCodegenMethods(join(dir, 'index.ts'));
96
135
  assert.strictEqual(skips.size, 1);
97
- assert.ok(skips.has('devOnly'));
98
- assert.ok(!skips.has('normalMethod'));
99
- assert.ok(!skips.has('alsoNormal'));
136
+ assert.ok(skips.has('api.devOnly'), 'skip set is namespace-qualified');
137
+ assert.ok(!skips.has('api.normalMethod'));
138
+ assert.ok(!skips.has('api.alsoNormal'));
139
+ }
140
+ finally {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+ it('qualifies skip names by namespace so a tag on one namespace does not drop another (regression: #445)', () => {
145
+ const dir = createTempProject({
146
+ 'index.ts': `
147
+ class ApiNamespace { constructor(scope: any, name: any, handler: any) {} }
148
+
149
+ export const widgets = new ApiNamespace(null, 'widgets', () => ({
150
+ /** @blocksSkipCodegen */
151
+ async create() { return null; },
152
+ }));
153
+ // subscriptions.create is NOT tagged and must survive.
154
+ export const subscriptions = new ApiNamespace(null, 'subscriptions', () => ({
155
+ async create() { return null; },
156
+ }));
157
+ `,
158
+ });
159
+ try {
160
+ const skips = extractSkipCodegenMethods(join(dir, 'index.ts'));
161
+ assert.ok(skips.has('widgets.create'), 'tagged method is skipped, qualified');
162
+ assert.ok(!skips.has('subscriptions.create'), 'untagged same-named method must NOT be skipped');
163
+ assert.ok(!skips.has('create'), 'no bare key that would drop both');
100
164
  }
101
165
  finally {
102
166
  rmSync(dir, { recursive: true, force: true });
@@ -146,8 +210,8 @@ describe('extractMethodTypes — @blocksSkipCodegen JSDoc tag', () => {
146
210
  });
147
211
  try {
148
212
  const types = extractMethodTypes(join(dir, 'index.ts'));
149
- assert.strictEqual(types.get('normalMethod')?.skipCodegen, undefined);
150
- assert.strictEqual(types.get('getLastCode')?.skipCodegen, true);
213
+ assert.strictEqual(types.get('api.normalMethod')?.skipCodegen, undefined);
214
+ assert.strictEqual(types.get('api.getLastCode')?.skipCodegen, true);
151
215
  }
152
216
  finally {
153
217
  rmSync(dir, { recursive: true, force: true });
@@ -206,20 +270,20 @@ describe('extractMethodTypes — indirect ApiNamespace calls (e.g., auth.createA
206
270
  try {
207
271
  const types = extractMethodTypes(join(dir, 'index.ts'));
208
272
  // Direct ApiNamespace methods should still work
209
- assert.ok(types.has('greet'), 'should find greet from direct ApiNamespace');
210
- const greet = types.get('greet');
273
+ assert.ok(types.has('api.greet'), 'should find greet from direct ApiNamespace');
274
+ const greet = types.get('api.greet');
211
275
  assert.strictEqual(greet.params[0].name, 'name');
212
276
  assert.strictEqual(greet.params[0].schema.type, 'string');
213
277
  // Indirect methods from auth.createApi() should now be resolved
214
- assert.ok(types.has('getAuthState'), 'should find getAuthState from auth.createApi()');
215
- assert.ok(types.has('setAuthState'), 'should find setAuthState from auth.createApi()');
216
- const getAuthState = types.get('getAuthState');
278
+ assert.ok(types.has('authApi.getAuthState'), 'should find getAuthState from auth.createApi()');
279
+ assert.ok(types.has('authApi.setAuthState'), 'should find setAuthState from auth.createApi()');
280
+ const getAuthState = types.get('authApi.getAuthState');
217
281
  assert.strictEqual(getAuthState.params.length, 0, 'getAuthState has no params');
218
282
  assert.strictEqual(getAuthState.returnType.type, 'object', 'return type should be object');
219
283
  assert.ok(getAuthState.returnType.properties, 'return type should have properties');
220
284
  assert.ok('isSignedIn' in getAuthState.returnType.properties, 'should have isSignedIn');
221
285
  assert.ok('username' in getAuthState.returnType.properties, 'should have username');
222
- const setAuthState = types.get('setAuthState');
286
+ const setAuthState = types.get('authApi.setAuthState');
223
287
  assert.strictEqual(setAuthState.params.length, 2, 'setAuthState has 2 params');
224
288
  assert.strictEqual(setAuthState.params[0].name, 'action');
225
289
  assert.strictEqual(setAuthState.params[0].schema.type, 'string');
@@ -255,9 +319,9 @@ describe('extractMethodTypes — indirect ApiNamespace calls (e.g., auth.createA
255
319
  });
256
320
  try {
257
321
  const types = extractMethodTypes(join(dir, 'index.ts'));
258
- assert.ok(types.has('greet'), 'should find greet');
322
+ assert.ok(types.has('api.greet'), 'should find greet');
259
323
  // The AST-walk version should be preserved (it has richer info from the declaration)
260
- assert.strictEqual(types.get('greet').params[0].name, 'name');
324
+ assert.strictEqual(types.get('api.greet').params[0].name, 'name');
261
325
  }
262
326
  finally {
263
327
  rmSync(dir, { recursive: true, force: true });
@@ -315,7 +379,7 @@ describe('extractMethodTypes — transferable detection', () => {
315
379
  `,
316
380
  });
317
381
  try {
318
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getCursorChannel');
382
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getCursorChannel');
319
383
  assert.ok(info, 'method should be discovered');
320
384
  assert.ok(info.transferable, 'should be detected as transferable');
321
385
  assert.strictEqual(info.transferable.blocksTag, 'realtime/channel');
@@ -340,7 +404,7 @@ describe('extractMethodTypes — transferable detection', () => {
340
404
  `,
341
405
  });
342
406
  try {
343
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getDownload');
407
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getDownload');
344
408
  assert.ok(info?.transferable);
345
409
  assert.strictEqual(info.transferable.blocksTag, 'file-bucket/download');
346
410
  assert.deepStrictEqual(info.transferable.typeArgs, []);
@@ -362,8 +426,8 @@ describe('extractMethodTypes — transferable detection', () => {
362
426
  });
363
427
  try {
364
428
  const types = extractMethodTypes(join(dir, 'index.ts'));
365
- assert.deepStrictEqual(types.get('defaulted')?.transferable?.typeArgs, []);
366
- assert.deepStrictEqual(types.get('explicit')?.transferable?.typeArgs, [{}]);
429
+ assert.deepStrictEqual(types.get('api.defaulted')?.transferable?.typeArgs, []);
430
+ assert.deepStrictEqual(types.get('api.explicit')?.transferable?.typeArgs, [{}]);
367
431
  }
368
432
  finally {
369
433
  rmSync(dir, { recursive: true, force: true });
@@ -384,8 +448,8 @@ describe('extractMethodTypes — transferable detection', () => {
384
448
  });
385
449
  try {
386
450
  const types = extractMethodTypes(join(dir, 'index.ts'));
387
- assert.strictEqual(types.get('plain')?.transferable, undefined);
388
- assert.strictEqual(types.get('loose')?.transferable, undefined);
451
+ assert.strictEqual(types.get('api.plain')?.transferable, undefined);
452
+ assert.strictEqual(types.get('api.loose')?.transferable, undefined);
389
453
  }
390
454
  finally {
391
455
  rmSync(dir, { recursive: true, force: true });
@@ -411,7 +475,7 @@ describe('extractMethodTypes — transferable detection', () => {
411
475
  `,
412
476
  });
413
477
  try {
414
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getNotifications');
478
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getNotifications');
415
479
  assert.ok(info?.transferable);
416
480
  assert.strictEqual(info.transferable.blocksTag, 'realtime/channel');
417
481
  assert.strictEqual(info.transferable.typeArgs[0]?.title, 'Notification');
@@ -445,7 +509,7 @@ describe('extractMethodTypes — open-shape intersections', () => {
445
509
  `,
446
510
  });
447
511
  try {
448
- const info = extractMethodTypes(join(dir, 'index.ts')).get('signUp');
512
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.signUp');
449
513
  assert.ok(info, 'should find signUp');
450
514
  const inputSchema = info.params[0].schema;
451
515
  assert.strictEqual(inputSchema.type, 'object');
@@ -482,7 +546,7 @@ describe('extractMethodTypes — open-shape intersections', () => {
482
546
  `,
483
547
  });
484
548
  try {
485
- const info = extractMethodTypes(join(dir, 'index.ts')).get('tag');
549
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.tag');
486
550
  assert.ok(info, 'should find tag');
487
551
  const inputSchema = info.params[0].schema;
488
552
  assert.strictEqual(inputSchema.type, 'object');
@@ -495,3 +559,138 @@ describe('extractMethodTypes — open-shape intersections', () => {
495
559
  }
496
560
  });
497
561
  });
562
+ describe('extractMethodTypes — namespace returned by a factory, then destructured (regression: #444)', () => {
563
+ it('keys a destructured factory namespace qualified, with real schemas', () => {
564
+ const dir = createTempProject({
565
+ 'tsconfig.json': JSON.stringify({
566
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
567
+ }),
568
+ 'index.ts': `
569
+ ${API_NS_MOCK}
570
+ class Scope { constructor(id: string) {} }
571
+
572
+ class NamespaceFactory {
573
+ constructor(private readonly scope: Scope) {}
574
+ createNamespaces() {
575
+ return {
576
+ indirectNamespace: new ApiNamespace(this.scope, 'indirectNamespace', () => ({
577
+ async getIndirectGreeting(times: number): Promise<string> { return 'hi'; },
578
+ })),
579
+ };
580
+ }
581
+ }
582
+
583
+ const scope = new Scope('repro');
584
+ export const directNamespace = new ApiNamespace(scope, 'directNamespace', () => ({
585
+ async getDirectGreeting(name: string): Promise<number> { return name.length; },
586
+ }));
587
+ const { indirectNamespace } = new NamespaceFactory(scope).createNamespaces();
588
+ export { indirectNamespace };
589
+ `,
590
+ });
591
+ try {
592
+ const types = extractMethodTypes(join(dir, 'index.ts'));
593
+ // The factory-returned namespace is now attributed to its binding name,
594
+ // exactly like the directly-constructed one.
595
+ const indirect = types.get('indirectNamespace.getIndirectGreeting');
596
+ assert.ok(indirect, 'indirectNamespace.getIndirectGreeting should be keyed by namespace');
597
+ assert.strictEqual(indirect.params[0].schema.type, 'number');
598
+ assert.strictEqual(indirect.returnType.type, 'string');
599
+ const direct = types.get('directNamespace.getDirectGreeting');
600
+ assert.ok(direct, 'directNamespace.getDirectGreeting should be present');
601
+ assert.strictEqual(direct.params[0].schema.type, 'string');
602
+ assert.strictEqual(direct.returnType.type, 'number');
603
+ }
604
+ finally {
605
+ rmSync(dir, { recursive: true, force: true });
606
+ }
607
+ });
608
+ it('a factory namespace sharing a method name with a direct one does NOT cross-assign', () => {
609
+ const dir = createTempProject({
610
+ 'tsconfig.json': JSON.stringify({
611
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
612
+ }),
613
+ 'index.ts': `
614
+ ${API_NS_MOCK}
615
+ class Scope { constructor(id: string) {} }
616
+
617
+ class Factory {
618
+ constructor(private readonly scope: Scope) {}
619
+ build() {
620
+ return {
621
+ subscriptions: new ApiNamespace(this.scope, 'subscriptions', () => ({
622
+ async create(topicCount: number): Promise<{ subId: number }> { return { subId: 1 }; },
623
+ })),
624
+ };
625
+ }
626
+ }
627
+
628
+ const scope = new Scope('app');
629
+ export const widgets = new ApiNamespace(scope, 'widgets', () => ({
630
+ async create(label: string): Promise<{ widgetId: string }> { return { widgetId: 'w' }; },
631
+ }));
632
+ const { subscriptions } = new Factory(scope).build();
633
+ export { subscriptions };
634
+ `,
635
+ });
636
+ try {
637
+ const types = extractMethodTypes(join(dir, 'index.ts'));
638
+ // Each namespace keeps its OWN param type under a distinct qualified key.
639
+ assert.strictEqual(types.get('widgets.create')?.params[0].schema?.type, 'string');
640
+ assert.strictEqual(types.get('subscriptions.create')?.params[0].schema?.type, 'number');
641
+ // The first-pass AST walk still emits a lingering bare `create` for the
642
+ // factory namespace, but it never overrides a qualified key: the
643
+ // direct namespace's qualified `widgets.create` is unaffected above, and
644
+ // `generate-spec` prefers the qualified key (see the collision test in
645
+ // generate-spec.test.ts, which asserts this end-to-end in the output).
646
+ // So the bare entry can't reintroduce the #445 cross-assignment here.
647
+ assert.strictEqual(types.get('widgets.create')?.params[0].schema?.type, 'string');
648
+ }
649
+ finally {
650
+ rmSync(dir, { recursive: true, force: true });
651
+ }
652
+ });
653
+ it('array/nested destructuring falls through to the bare key (documented boundary)', () => {
654
+ // Only a top-level identifier and a shallow object binding pattern are
655
+ // attributed to a namespace. An array pattern (`const [ns] = factory()`)
656
+ // falls through to the bare-key path — the schema is still recovered (the
657
+ // AST walk found `new ApiNamespace(...)`), just under the bare method name,
658
+ // which generate-spec's #498 fallback resolves when there's no collision.
659
+ const dir = createTempProject({
660
+ 'tsconfig.json': JSON.stringify({
661
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
662
+ }),
663
+ 'index.ts': `
664
+ ${API_NS_MOCK}
665
+ class Scope { constructor(id: string) {} }
666
+
667
+ class Factory {
668
+ constructor(private readonly scope: Scope) {}
669
+ build() {
670
+ return [
671
+ new ApiNamespace(this.scope, 'tupleNs', () => ({
672
+ async ping(count: number): Promise<string> { return 'p'; },
673
+ })),
674
+ ] as const;
675
+ }
676
+ }
677
+ const scope = new Scope('app');
678
+ const [tupleNs] = new Factory(scope).build();
679
+ export { tupleNs };
680
+ `,
681
+ });
682
+ try {
683
+ const types = extractMethodTypes(join(dir, 'index.ts'));
684
+ // Not attributed to the binding (array pattern), so no qualified key…
685
+ assert.ok(!types.has('tupleNs.ping'), 'array pattern is not attributed (documented boundary)');
686
+ // …but the schema is still present under the bare name (soft fallback).
687
+ const bare = types.get('ping');
688
+ assert.ok(bare, 'method schema is still recovered under the bare key');
689
+ assert.strictEqual(bare.params[0].schema?.type, 'number');
690
+ assert.strictEqual(bare.returnType?.type, 'string');
691
+ }
692
+ finally {
693
+ rmSync(dir, { recursive: true, force: true });
694
+ }
695
+ });
696
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"generate-spec.d.ts","sourceRoot":"","sources":["../../src/scripts/generate-spec.ts"],"names":[],"mappings":"AA2BA,UAAU,eAAe;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC1C,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;KAAE,CAAC;CACtD;AAED,UAAU,aAAa;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;CACtB;AAED,UAAU,YAAY;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,aAAa;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,UAAU;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAsKD;;;;;;;;;;;GAWG;AACH;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAIrF,wBAAsB,YAAY,CACjC,cAAc,EAAE,MAAM,EACtB,MAAM,GAAE,gBAAgC,GACtC,OAAO,CAAC,eAAe,CAAC,CAsQ1B;AAyQD;;;GAGG;AACH,wBAAsB,SAAS,CAC9B,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC,CA0Bf"}
1
+ {"version":3,"file":"generate-spec.d.ts","sourceRoot":"","sources":["../../src/scripts/generate-spec.ts"],"names":[],"mappings":"AA2BA,UAAU,eAAe;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC1C,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;KAAE,CAAC;CACtD;AAED,UAAU,aAAa;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;CACtB;AAED,UAAU,YAAY;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,aAAa;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,UAAU;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAsKD;;;;;;;;;;;GAWG;AACH;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAIrF,wBAAsB,YAAY,CACjC,cAAc,EAAE,MAAM,EACtB,MAAM,GAAE,gBAAgC,GACtC,OAAO,CAAC,eAAe,CAAC,CAgR1B;AAyQD;;;GAGG;AACH,wBAAsB,SAAS,CAC9B,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC,CA0Bf"}
@@ -257,6 +257,15 @@ export async function generateSpec(foundationPath, loader = defaultLoader) {
257
257
  for (const [methodName, methodFn] of Object.entries(methodMap)) {
258
258
  if (typeof methodFn !== 'function')
259
259
  continue;
260
+ const qualifiedName = `${routingName}.${methodName}`;
261
+ // Prefer the namespace-qualified TS types; fall back to the bare method
262
+ // name for entries the extractor couldn't attribute to a namespace.
263
+ // Qualified keys stop two namespaces that share a method name (e.g.
264
+ // widgets.create / subscriptions.create) from cross-assigning schemas.
265
+ // Residual edge: namespaces the extractor can't attribute (default export,
266
+ // factory-wrapped, or an `export { x as y }` rename) still land on bare
267
+ // keys and could overwrite each other here — same class as #445, but rare.
268
+ const tsInfo = tsTypes.get(qualifiedName) ?? tsTypes.get(methodName);
260
269
  // `@blocksSkipCodegen` is a JSDoc tag the customer puts on a method
261
270
  // they want callable from the generated TS client (Proxy-based,
262
271
  // doesn't enumerate) but absent from the OpenRPC spec — so native
@@ -267,9 +276,11 @@ export async function generateSpec(foundationPath, loader = defaultLoader) {
267
276
  // type-resolution errors and silently empty its Map — we always
268
277
  // want the JSDoc tag to be honored, so the AST-only scan acts as
269
278
  // a safety net.
270
- if (tsTypes.get(methodName)?.skipCodegen || skipCodegenNames.has(methodName))
279
+ // The skip set is namespace-qualified too (with a bare fallback), so a
280
+ // `@blocksSkipCodegen` tag on one namespace's method doesn't drop another
281
+ // namespace's same-named method.
282
+ if (tsInfo?.skipCodegen || skipCodegenNames.has(qualifiedName) || skipCodegenNames.has(methodName))
271
283
  continue;
272
- const qualifiedName = `${routingName}.${methodName}`;
273
284
  const resultName = capitalize(methodName) + 'Result';
274
285
  const paramNames = extractParamNames(methodFn);
275
286
  const fnSource = methodFn.toString();
@@ -305,7 +316,6 @@ export async function generateSpec(foundationPath, loader = defaultLoader) {
305
316
  }
306
317
  else {
307
318
  // No Zod schema — fall back to TypeScript AST types
308
- const tsInfo = tsTypes.get(methodName);
309
319
  if (tsInfo) {
310
320
  params = tsInfo.params.map((p) => ({
311
321
  name: p.name,
@@ -322,8 +332,7 @@ export async function generateSpec(foundationPath, loader = defaultLoader) {
322
332
  }));
323
333
  }
324
334
  }
325
- // Return type: Zod doesn't cover this yet, so use TS types
326
- const tsInfo = tsTypes.get(methodName);
335
+ // Return type: Zod doesn't cover this yet, so use TS types (tsInfo resolved above)
327
336
  const rawResultSchema = tsInfo?.returnType && tsInfo.returnType.type !== 'unknown'
328
337
  ? tsInfo.returnType
329
338
  : { type: 'unknown' };