@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
@@ -29,6 +29,47 @@ const API_NS_MOCK = `
29
29
  const ApiNamespace: ApiNamespaceConstructor = class {} as any;
30
30
  `;
31
31
 
32
+ describe('extractMethodTypes — namespaces sharing a method name (regression: #445)', () => {
33
+ it('keys methods by namespace, so a shared method name does not cross-assign schemas', () => {
34
+ const dir = createTempProject({
35
+ 'tsconfig.json': JSON.stringify({
36
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
37
+ }),
38
+ 'index.ts': `
39
+ ${API_NS_MOCK}
40
+
41
+ // Two namespaces both expose \`create\`, with deliberately incompatible contracts.
42
+ export const widgets = new ApiNamespace(null, 'widgets', (context) => ({
43
+ async create(label: string): Promise<{ widgetId: string }> {
44
+ return { widgetId: 'w' };
45
+ },
46
+ }));
47
+ export const subscriptions = new ApiNamespace(null, 'subscriptions', (context) => ({
48
+ async create(topicCount: number): Promise<{ subId: number }> {
49
+ return { subId: 1 };
50
+ },
51
+ }));
52
+ `,
53
+ });
54
+
55
+ try {
56
+ const types = extractMethodTypes(join(dir, 'index.ts'));
57
+ // Distinct qualified keys — no collision.
58
+ assert.ok(types.has('widgets.create'), 'widgets.create should be keyed by namespace');
59
+ assert.ok(types.has('subscriptions.create'), 'subscriptions.create should be keyed by namespace');
60
+ assert.ok(!types.has('create'), 'no bare-name entry that could cross-assign');
61
+
62
+ // Each keeps its OWN parameter type.
63
+ assert.strictEqual(types.get('widgets.create')!.params[0].name, 'label');
64
+ assert.strictEqual(types.get('widgets.create')!.params[0].schema.type, 'string');
65
+ assert.strictEqual(types.get('subscriptions.create')!.params[0].name, 'topicCount');
66
+ assert.strictEqual(types.get('subscriptions.create')!.params[0].schema.type, 'number');
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ });
72
+
32
73
  describe('extractMethodTypes — direct ApiNamespace calls', () => {
33
74
  it('extracts param and return types from inline ApiNamespace', () => {
34
75
  const dir = createTempProject({
@@ -58,15 +99,15 @@ describe('extractMethodTypes — direct ApiNamespace calls', () => {
58
99
 
59
100
  try {
60
101
  const types = extractMethodTypes(join(dir, 'index.ts'));
61
- assert.ok(types.has('getUser'), 'should find getUser');
62
- assert.ok(types.has('createUser'), 'should find createUser');
102
+ assert.ok(types.has('api.getUser'), 'should find getUser');
103
+ assert.ok(types.has('api.createUser'), 'should find createUser');
63
104
 
64
- const getUser = types.get('getUser')!;
105
+ const getUser = types.get('api.getUser')!;
65
106
  assert.strictEqual(getUser.params.length, 1);
66
107
  assert.strictEqual(getUser.params[0].name, 'id');
67
108
  assert.strictEqual(getUser.params[0].schema.type, 'string');
68
109
 
69
- const createUser = types.get('createUser')!;
110
+ const createUser = types.get('api.createUser')!;
70
111
  assert.strictEqual(createUser.params.length, 2);
71
112
  assert.strictEqual(createUser.params[0].schema.type, 'string');
72
113
  assert.strictEqual(createUser.params[1].schema.type, 'number');
@@ -102,9 +143,34 @@ describe('extractSkipCodegenMethods — pure-AST scan', () => {
102
143
  try {
103
144
  const skips = extractSkipCodegenMethods(join(dir, 'index.ts'));
104
145
  assert.strictEqual(skips.size, 1);
105
- assert.ok(skips.has('devOnly'));
106
- assert.ok(!skips.has('normalMethod'));
107
- assert.ok(!skips.has('alsoNormal'));
146
+ assert.ok(skips.has('api.devOnly'), 'skip set is namespace-qualified');
147
+ assert.ok(!skips.has('api.normalMethod'));
148
+ assert.ok(!skips.has('api.alsoNormal'));
149
+ } finally {
150
+ rmSync(dir, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ it('qualifies skip names by namespace so a tag on one namespace does not drop another (regression: #445)', () => {
155
+ const dir = createTempProject({
156
+ 'index.ts': `
157
+ class ApiNamespace { constructor(scope: any, name: any, handler: any) {} }
158
+
159
+ export const widgets = new ApiNamespace(null, 'widgets', () => ({
160
+ /** @blocksSkipCodegen */
161
+ async create() { return null; },
162
+ }));
163
+ // subscriptions.create is NOT tagged and must survive.
164
+ export const subscriptions = new ApiNamespace(null, 'subscriptions', () => ({
165
+ async create() { return null; },
166
+ }));
167
+ `,
168
+ });
169
+ try {
170
+ const skips = extractSkipCodegenMethods(join(dir, 'index.ts'));
171
+ assert.ok(skips.has('widgets.create'), 'tagged method is skipped, qualified');
172
+ assert.ok(!skips.has('subscriptions.create'), 'untagged same-named method must NOT be skipped');
173
+ assert.ok(!skips.has('create'), 'no bare key that would drop both');
108
174
  } finally {
109
175
  rmSync(dir, { recursive: true, force: true });
110
176
  }
@@ -155,8 +221,8 @@ describe('extractMethodTypes — @blocksSkipCodegen JSDoc tag', () => {
155
221
 
156
222
  try {
157
223
  const types = extractMethodTypes(join(dir, 'index.ts'));
158
- assert.strictEqual(types.get('normalMethod')?.skipCodegen, undefined);
159
- assert.strictEqual(types.get('getLastCode')?.skipCodegen, true);
224
+ assert.strictEqual(types.get('api.normalMethod')?.skipCodegen, undefined);
225
+ assert.strictEqual(types.get('api.getLastCode')?.skipCodegen, true);
160
226
  } finally {
161
227
  rmSync(dir, { recursive: true, force: true });
162
228
  }
@@ -217,23 +283,23 @@ describe('extractMethodTypes — indirect ApiNamespace calls (e.g., auth.createA
217
283
  const types = extractMethodTypes(join(dir, 'index.ts'));
218
284
 
219
285
  // Direct ApiNamespace methods should still work
220
- assert.ok(types.has('greet'), 'should find greet from direct ApiNamespace');
221
- const greet = types.get('greet')!;
286
+ assert.ok(types.has('api.greet'), 'should find greet from direct ApiNamespace');
287
+ const greet = types.get('api.greet')!;
222
288
  assert.strictEqual(greet.params[0].name, 'name');
223
289
  assert.strictEqual(greet.params[0].schema.type, 'string');
224
290
 
225
291
  // Indirect methods from auth.createApi() should now be resolved
226
- assert.ok(types.has('getAuthState'), 'should find getAuthState from auth.createApi()');
227
- assert.ok(types.has('setAuthState'), 'should find setAuthState from auth.createApi()');
292
+ assert.ok(types.has('authApi.getAuthState'), 'should find getAuthState from auth.createApi()');
293
+ assert.ok(types.has('authApi.setAuthState'), 'should find setAuthState from auth.createApi()');
228
294
 
229
- const getAuthState = types.get('getAuthState')!;
295
+ const getAuthState = types.get('authApi.getAuthState')!;
230
296
  assert.strictEqual(getAuthState.params.length, 0, 'getAuthState has no params');
231
297
  assert.strictEqual(getAuthState.returnType.type, 'object', 'return type should be object');
232
298
  assert.ok(getAuthState.returnType.properties, 'return type should have properties');
233
299
  assert.ok('isSignedIn' in getAuthState.returnType.properties!, 'should have isSignedIn');
234
300
  assert.ok('username' in getAuthState.returnType.properties!, 'should have username');
235
301
 
236
- const setAuthState = types.get('setAuthState')!;
302
+ const setAuthState = types.get('authApi.setAuthState')!;
237
303
  assert.strictEqual(setAuthState.params.length, 2, 'setAuthState has 2 params');
238
304
  assert.strictEqual(setAuthState.params[0].name, 'action');
239
305
  assert.strictEqual(setAuthState.params[0].schema.type, 'string');
@@ -271,9 +337,9 @@ describe('extractMethodTypes — indirect ApiNamespace calls (e.g., auth.createA
271
337
 
272
338
  try {
273
339
  const types = extractMethodTypes(join(dir, 'index.ts'));
274
- assert.ok(types.has('greet'), 'should find greet');
340
+ assert.ok(types.has('api.greet'), 'should find greet');
275
341
  // The AST-walk version should be preserved (it has richer info from the declaration)
276
- assert.strictEqual(types.get('greet')!.params[0].name, 'name');
342
+ assert.strictEqual(types.get('api.greet')!.params[0].name, 'name');
277
343
  } finally {
278
344
  rmSync(dir, { recursive: true, force: true });
279
345
  }
@@ -335,7 +401,7 @@ describe('extractMethodTypes — transferable detection', () => {
335
401
  `,
336
402
  });
337
403
  try {
338
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getCursorChannel');
404
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getCursorChannel');
339
405
  assert.ok(info, 'method should be discovered');
340
406
  assert.ok(info.transferable, 'should be detected as transferable');
341
407
  assert.strictEqual(info.transferable.blocksTag, 'realtime/channel');
@@ -360,7 +426,7 @@ describe('extractMethodTypes — transferable detection', () => {
360
426
  `,
361
427
  });
362
428
  try {
363
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getDownload');
429
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getDownload');
364
430
  assert.ok(info?.transferable);
365
431
  assert.strictEqual(info.transferable.blocksTag, 'file-bucket/download');
366
432
  assert.deepStrictEqual(info.transferable.typeArgs, []);
@@ -382,8 +448,8 @@ describe('extractMethodTypes — transferable detection', () => {
382
448
  });
383
449
  try {
384
450
  const types = extractMethodTypes(join(dir, 'index.ts'));
385
- assert.deepStrictEqual(types.get('defaulted')?.transferable?.typeArgs, []);
386
- assert.deepStrictEqual(types.get('explicit')?.transferable?.typeArgs, [{}]);
451
+ assert.deepStrictEqual(types.get('api.defaulted')?.transferable?.typeArgs, []);
452
+ assert.deepStrictEqual(types.get('api.explicit')?.transferable?.typeArgs, [{}]);
387
453
  } finally {
388
454
  rmSync(dir, { recursive: true, force: true });
389
455
  }
@@ -404,8 +470,8 @@ describe('extractMethodTypes — transferable detection', () => {
404
470
  });
405
471
  try {
406
472
  const types = extractMethodTypes(join(dir, 'index.ts'));
407
- assert.strictEqual(types.get('plain')?.transferable, undefined);
408
- assert.strictEqual(types.get('loose')?.transferable, undefined);
473
+ assert.strictEqual(types.get('api.plain')?.transferable, undefined);
474
+ assert.strictEqual(types.get('api.loose')?.transferable, undefined);
409
475
  } finally {
410
476
  rmSync(dir, { recursive: true, force: true });
411
477
  }
@@ -431,7 +497,7 @@ describe('extractMethodTypes — transferable detection', () => {
431
497
  `,
432
498
  });
433
499
  try {
434
- const info = extractMethodTypes(join(dir, 'index.ts')).get('getNotifications');
500
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.getNotifications');
435
501
  assert.ok(info?.transferable);
436
502
  assert.strictEqual(info.transferable.blocksTag, 'realtime/channel');
437
503
  assert.strictEqual(info.transferable.typeArgs[0]?.title, 'Notification');
@@ -465,7 +531,7 @@ describe('extractMethodTypes — open-shape intersections', () => {
465
531
  `,
466
532
  });
467
533
  try {
468
- const info = extractMethodTypes(join(dir, 'index.ts')).get('signUp');
534
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.signUp');
469
535
  assert.ok(info, 'should find signUp');
470
536
  const inputSchema = info.params[0].schema as any;
471
537
  assert.strictEqual(inputSchema.type, 'object');
@@ -502,7 +568,7 @@ describe('extractMethodTypes — open-shape intersections', () => {
502
568
  `,
503
569
  });
504
570
  try {
505
- const info = extractMethodTypes(join(dir, 'index.ts')).get('tag');
571
+ const info = extractMethodTypes(join(dir, 'index.ts')).get('api.tag');
506
572
  assert.ok(info, 'should find tag');
507
573
  const inputSchema = info.params[0].schema as any;
508
574
  assert.strictEqual(inputSchema.type, 'object');
@@ -514,3 +580,139 @@ describe('extractMethodTypes — open-shape intersections', () => {
514
580
  }
515
581
  });
516
582
  });
583
+
584
+ describe('extractMethodTypes — namespace returned by a factory, then destructured (regression: #444)', () => {
585
+ it('keys a destructured factory namespace qualified, with real schemas', () => {
586
+ const dir = createTempProject({
587
+ 'tsconfig.json': JSON.stringify({
588
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
589
+ }),
590
+ 'index.ts': `
591
+ ${API_NS_MOCK}
592
+ class Scope { constructor(id: string) {} }
593
+
594
+ class NamespaceFactory {
595
+ constructor(private readonly scope: Scope) {}
596
+ createNamespaces() {
597
+ return {
598
+ indirectNamespace: new ApiNamespace(this.scope, 'indirectNamespace', () => ({
599
+ async getIndirectGreeting(times: number): Promise<string> { return 'hi'; },
600
+ })),
601
+ };
602
+ }
603
+ }
604
+
605
+ const scope = new Scope('repro');
606
+ export const directNamespace = new ApiNamespace(scope, 'directNamespace', () => ({
607
+ async getDirectGreeting(name: string): Promise<number> { return name.length; },
608
+ }));
609
+ const { indirectNamespace } = new NamespaceFactory(scope).createNamespaces();
610
+ export { indirectNamespace };
611
+ `,
612
+ });
613
+ try {
614
+ const types = extractMethodTypes(join(dir, 'index.ts'));
615
+ // The factory-returned namespace is now attributed to its binding name,
616
+ // exactly like the directly-constructed one.
617
+ const indirect = types.get('indirectNamespace.getIndirectGreeting');
618
+ assert.ok(indirect, 'indirectNamespace.getIndirectGreeting should be keyed by namespace');
619
+ assert.strictEqual((indirect.params[0].schema as any).type, 'number');
620
+ assert.strictEqual((indirect.returnType as any).type, 'string');
621
+
622
+ const direct = types.get('directNamespace.getDirectGreeting');
623
+ assert.ok(direct, 'directNamespace.getDirectGreeting should be present');
624
+ assert.strictEqual((direct.params[0].schema as any).type, 'string');
625
+ assert.strictEqual((direct.returnType as any).type, 'number');
626
+ } finally {
627
+ rmSync(dir, { recursive: true, force: true });
628
+ }
629
+ });
630
+
631
+ it('a factory namespace sharing a method name with a direct one does NOT cross-assign', () => {
632
+ const dir = createTempProject({
633
+ 'tsconfig.json': JSON.stringify({
634
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
635
+ }),
636
+ 'index.ts': `
637
+ ${API_NS_MOCK}
638
+ class Scope { constructor(id: string) {} }
639
+
640
+ class Factory {
641
+ constructor(private readonly scope: Scope) {}
642
+ build() {
643
+ return {
644
+ subscriptions: new ApiNamespace(this.scope, 'subscriptions', () => ({
645
+ async create(topicCount: number): Promise<{ subId: number }> { return { subId: 1 }; },
646
+ })),
647
+ };
648
+ }
649
+ }
650
+
651
+ const scope = new Scope('app');
652
+ export const widgets = new ApiNamespace(scope, 'widgets', () => ({
653
+ async create(label: string): Promise<{ widgetId: string }> { return { widgetId: 'w' }; },
654
+ }));
655
+ const { subscriptions } = new Factory(scope).build();
656
+ export { subscriptions };
657
+ `,
658
+ });
659
+ try {
660
+ const types = extractMethodTypes(join(dir, 'index.ts'));
661
+ // Each namespace keeps its OWN param type under a distinct qualified key.
662
+ assert.strictEqual((types.get('widgets.create')?.params[0].schema as any)?.type, 'string');
663
+ assert.strictEqual((types.get('subscriptions.create')?.params[0].schema as any)?.type, 'number');
664
+ // The first-pass AST walk still emits a lingering bare `create` for the
665
+ // factory namespace, but it never overrides a qualified key: the
666
+ // direct namespace's qualified `widgets.create` is unaffected above, and
667
+ // `generate-spec` prefers the qualified key (see the collision test in
668
+ // generate-spec.test.ts, which asserts this end-to-end in the output).
669
+ // So the bare entry can't reintroduce the #445 cross-assignment here.
670
+ assert.strictEqual((types.get('widgets.create')?.params[0].schema as any)?.type, 'string');
671
+ } finally {
672
+ rmSync(dir, { recursive: true, force: true });
673
+ }
674
+ });
675
+
676
+ it('array/nested destructuring falls through to the bare key (documented boundary)', () => {
677
+ // Only a top-level identifier and a shallow object binding pattern are
678
+ // attributed to a namespace. An array pattern (`const [ns] = factory()`)
679
+ // falls through to the bare-key path — the schema is still recovered (the
680
+ // AST walk found `new ApiNamespace(...)`), just under the bare method name,
681
+ // which generate-spec's #498 fallback resolves when there's no collision.
682
+ const dir = createTempProject({
683
+ 'tsconfig.json': JSON.stringify({
684
+ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true },
685
+ }),
686
+ 'index.ts': `
687
+ ${API_NS_MOCK}
688
+ class Scope { constructor(id: string) {} }
689
+
690
+ class Factory {
691
+ constructor(private readonly scope: Scope) {}
692
+ build() {
693
+ return [
694
+ new ApiNamespace(this.scope, 'tupleNs', () => ({
695
+ async ping(count: number): Promise<string> { return 'p'; },
696
+ })),
697
+ ] as const;
698
+ }
699
+ }
700
+ const scope = new Scope('app');
701
+ const [tupleNs] = new Factory(scope).build();
702
+ export { tupleNs };
703
+ `,
704
+ });
705
+ try {
706
+ const types = extractMethodTypes(join(dir, 'index.ts'));
707
+ // Not attributed to the binding (array pattern), so no qualified key…
708
+ assert.ok(!types.has('tupleNs.ping'), 'array pattern is not attributed (documented boundary)');
709
+ // …but the schema is still present under the bare name (soft fallback).
710
+ const bare = types.get('ping');
711
+ assert.ok(bare, 'method schema is still recovered under the bare key');
712
+ assert.strictEqual((bare.params[0].schema as any)?.type, 'number');
713
+ assert.strictEqual((bare.returnType as any)?.type, 'string');
714
+ } finally {
715
+ rmSync(dir, { recursive: true, force: true });
716
+ }
717
+ });
718
+ });
@@ -80,7 +80,7 @@ export function extractSkipCodegenMethods(sourcePath: string): Set<string> {
80
80
  ts.ScriptKind.TS,
81
81
  );
82
82
 
83
- function visitObjectLiteral(obj: ts.ObjectLiteralExpression) {
83
+ function visitObjectLiteral(obj: ts.ObjectLiteralExpression, namespaceName?: string) {
84
84
  for (const prop of obj.properties) {
85
85
  if (
86
86
  ts.isMethodDeclaration(prop) &&
@@ -88,7 +88,10 @@ export function extractSkipCodegenMethods(sourcePath: string): Set<string> {
88
88
  ts.isIdentifier(prop.name) &&
89
89
  hasBlocksSkipCodegenTag(prop)
90
90
  ) {
91
- result.add(prop.name.text);
91
+ // Qualify by namespace (like extractMethodTypes) so a tag on one
92
+ // namespace's `create` doesn't cause another namespace's `create` to be
93
+ // dropped from the spec. Bare key only when the namespace is unknown.
94
+ result.add(namespaceName ? `${namespaceName}.${prop.name.text}` : prop.name.text);
92
95
  }
93
96
  }
94
97
  }
@@ -98,22 +101,23 @@ export function extractSkipCodegenMethods(sourcePath: string): Set<string> {
98
101
  const callee = node.expression;
99
102
  if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace' && node.arguments && node.arguments.length >= 2) {
100
103
  const handler = node.arguments[node.arguments.length - 1];
104
+ const nsName = enclosingBindingName(node);
101
105
  if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) {
102
106
  const body = handler.body;
103
107
  if (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) {
104
- visitObjectLiteral(body.expression);
108
+ visitObjectLiteral(body.expression, nsName);
105
109
  } else if (ts.isObjectLiteralExpression(body)) {
106
- visitObjectLiteral(body);
110
+ visitObjectLiteral(body, nsName);
107
111
  } else if (ts.isBlock(body)) {
108
112
  for (const stmt of body.statements) {
109
113
  if (ts.isReturnStatement(stmt) && stmt.expression) {
110
114
  if (ts.isObjectLiteralExpression(stmt.expression)) {
111
- visitObjectLiteral(stmt.expression);
115
+ visitObjectLiteral(stmt.expression, nsName);
112
116
  } else if (
113
117
  ts.isParenthesizedExpression(stmt.expression) &&
114
118
  ts.isObjectLiteralExpression(stmt.expression.expression)
115
119
  ) {
116
- visitObjectLiteral(stmt.expression.expression);
120
+ visitObjectLiteral(stmt.expression.expression, nsName);
117
121
  }
118
122
  }
119
123
  }
@@ -197,7 +201,7 @@ export function extractMethodTypes(sourcePath: string): Map<string, MethodTypeIn
197
201
  const fnName = node.expression;
198
202
  if (ts.isIdentifier(fnName) && fnName.text === 'ApiNamespace' && node.arguments && node.arguments.length >= 2) {
199
203
  const handlerArg = node.arguments[node.arguments.length - 1];
200
- extractMethodsFromHandler(handlerArg, checker, sourceFile!, result);
204
+ extractMethodsFromHandler(handlerArg, checker, sourceFile!, result, enclosingBindingName(node));
201
205
  }
202
206
  }
203
207
  ts.forEachChild(node, visit);
@@ -210,18 +214,65 @@ export function extractMethodTypes(sourcePath: string): Map<string, MethodTypeIn
210
214
  ts.forEachChild(sourceFile, (node) => {
211
215
  if (!ts.isVariableStatement(node)) return;
212
216
  for (const decl of node.declarationList.declarations) {
213
- if (!decl.initializer || !ts.isIdentifier(decl.name)) continue;
214
- // Skip if we already extracted methods for this via the AST walk
215
- if (ts.isNewExpression(decl.initializer)) {
216
- const callee = decl.initializer.expression;
217
- if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace') continue;
217
+ if (!decl.initializer) continue;
218
+
219
+ // (a) Simple binding: `const api = auth.createApi()` /
220
+ // `export const api = backend.namespace`.
221
+ if (ts.isIdentifier(decl.name)) {
222
+ // Skip if we already extracted methods for this via the AST walk
223
+ if (ts.isNewExpression(decl.initializer)) {
224
+ const callee = decl.initializer.expression;
225
+ if (ts.isIdentifier(callee) && callee.text === 'ApiNamespace') continue;
226
+ }
227
+ // Use the type checker to get the type of the initializer expression.
228
+ // For `auth.createApi()`, this resolves through the method's return type
229
+ // to the `AsyncAPI<T>` type that ApiNamespace returns.
230
+ const initType = checker.getTypeAtLocation(decl.initializer);
231
+ extractMethodsFromResolvedType(initType, checker, result, decl.name.text);
232
+ continue;
218
233
  }
219
234
 
220
- // Use the type checker to get the type of the initializer expression.
221
- // For `auth.createApi()`, this resolves through the method's return type
222
- // to the `AsyncAPI<T>` type that ApiNamespace returns.
223
- const initType = checker.getTypeAtLocation(decl.initializer);
224
- extractMethodsFromResolvedType(initType, checker, result);
235
+ // (b) Object binding pattern: `const { indirectNamespace } = factory()`.
236
+ // Each destructured binding is a separate namespace whose name is the
237
+ // LOCAL binding name (the name it's exported under). Resolve the property
238
+ // type off the initializer and extract methods keyed by that name, so a
239
+ // factory-returned namespace keys qualified (`indirectNamespace.method`)
240
+ // like a directly-constructed one — instead of landing on a bare key that
241
+ // collides with same-named methods in other namespaces (#444, #445).
242
+ //
243
+ // This adds the qualified key ALONGSIDE the bare key the first-pass AST
244
+ // walk already emitted for the same `new ApiNamespace(...)` (its
245
+ // `enclosingBindingName` returns undefined behind the factory, so it keys
246
+ // bare). Leaving the bare key is harmless: `generate-spec` looks up
247
+ // `tsTypes.get(qualified) ?? tsTypes.get(bare)`, so the qualified key
248
+ // added here always wins for an attributable namespace, and the bare
249
+ // entry never influences the emitted schema (a regression test asserts
250
+ // this under a name collision). The #498 bare-name fallback is retained
251
+ // on purpose — it's the last resort for namespaces this pass still can't
252
+ // attribute (default exports, deeper indirection, the patterns below), so
253
+ // it is NOT redundant.
254
+ //
255
+ // Boundary: only a top-level identifier (a) and a shallow object binding
256
+ // pattern (b) are attributed. Array patterns (`const [ns] = factory()`)
257
+ // and nested patterns (`const { a: { b } } = …`) fall through to the
258
+ // bare-key path, where the #498 fallback still resolves the schema when
259
+ // there's no collision. These shapes are rare for namespace exports and
260
+ // fail soft; widening the traversal can come later if a real case appears.
261
+ if (ts.isObjectBindingPattern(decl.name)) {
262
+ const initType = checker.getTypeAtLocation(decl.initializer);
263
+ for (const element of decl.name.elements) {
264
+ if (!ts.isIdentifier(element.name)) continue;
265
+ const localName = element.name.text;
266
+ // `const { foo: bar } = …` binds `bar` but reads property `foo`.
267
+ const propKey = element.propertyName && ts.isIdentifier(element.propertyName)
268
+ ? element.propertyName.text
269
+ : localName;
270
+ const propSymbol = initType.getProperty(propKey);
271
+ if (!propSymbol) continue;
272
+ const propType = checker.getTypeOfSymbol(propSymbol);
273
+ extractMethodsFromResolvedType(propType, checker, result, localName);
274
+ }
275
+ }
225
276
  }
226
277
  });
227
278
 
@@ -249,6 +300,7 @@ function extractMethodsFromHandler(
249
300
  checker: ts.TypeChecker,
250
301
  sourceFile: ts.SourceFile,
251
302
  result: Map<string, MethodTypeInfo>,
303
+ namespaceName?: string,
252
304
  ): void {
253
305
  let objectLiteral: ts.ObjectLiteralExpression | undefined;
254
306
 
@@ -280,11 +332,41 @@ function extractMethodsFromHandler(
280
332
  if (ts.isMethodDeclaration(prop) && prop.name && ts.isIdentifier(prop.name)) {
281
333
  const methodName = prop.name.text;
282
334
  const info = extractMethodTypeInfo(prop, checker, sourceFile);
283
- if (info) result.set(methodName, info);
335
+ // Key by the namespace-qualified name when the namespace is known, so two
336
+ // namespaces sharing a method name (e.g. widgets.create / subscriptions.create)
337
+ // don't collide and cross-assign schemas. Fall back to the bare name when the
338
+ // namespace can't be determined (consumers try qualified, then bare).
339
+ if (info) result.set(namespaceName ? `${namespaceName}.${methodName}` : methodName, info);
284
340
  }
285
341
  }
286
342
  }
287
343
 
344
+ /**
345
+ * Walk up from a `new ApiNamespace(...)` node to the name it's bound to
346
+ * (`export const widgets = new ApiNamespace(...)` → `"widgets"`). This is the
347
+ * name the dev server / Lambda handler route by, and the one `generate-spec`
348
+ * uses to qualify method names. Returns `undefined` when the namespace is
349
+ * created behind indirection (factory return, etc.), in which case methods key
350
+ * by their bare name.
351
+ *
352
+ * Limitation: this is the local binding name, not the export name. An export
353
+ * rename — `const widgets = new ApiNamespace(...); export { widgets as gadgets }`
354
+ * — keys `widgets.*` while `generate-spec` looks up `gadgets.*`, so the qualified
355
+ * lookup misses and falls back to the bare key (schemas resolve if there's no
356
+ * collision, else `unknown`). Rare; resolving the export alias would remove it.
357
+ */
358
+ function enclosingBindingName(node: ts.Node): string | undefined {
359
+ let current: ts.Node | undefined = node.parent;
360
+ while (current) {
361
+ if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text;
362
+ // Don't cross a function/block boundary — beyond it the binding no longer
363
+ // corresponds to a directly-exported namespace.
364
+ if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isFunctionLike(current)) return undefined;
365
+ current = current.parent;
366
+ }
367
+ return undefined;
368
+ }
369
+
288
370
  function extractMethodTypeInfo(
289
371
  method: ts.MethodDeclaration,
290
372
  checker: ts.TypeChecker,
@@ -365,6 +447,7 @@ function extractMethodsFromResolvedType(
365
447
  type: ts.Type,
366
448
  checker: ts.TypeChecker,
367
449
  result: Map<string, MethodTypeInfo>,
450
+ namespaceName?: string,
368
451
  ): void {
369
452
  // The runtime value of an ApiNamespace export is the handler function itself
370
453
  // (with the marker symbol attached). Its TS type is `AsyncAPI<T>`, which is
@@ -392,8 +475,9 @@ function extractMethodsFromResolvedType(
392
475
  const propName = prop.getName();
393
476
  // Skip internal symbols and the marker
394
477
  if (propName.startsWith('_') || propName === 'Symbol(blocks:ApiNamespace)') continue;
478
+ const key = namespaceName ? `${namespaceName}.${propName}` : propName;
395
479
  // Don't overwrite methods already found by the AST walk
396
- if (result.has(propName)) continue;
480
+ if (result.has(key)) continue;
397
481
 
398
482
  const propType = checker.getTypeOfSymbol(prop);
399
483
  const methodSigs = propType.getCallSignatures();
@@ -433,7 +517,7 @@ function extractMethodsFromResolvedType(
433
517
  skipCodegen = true;
434
518
  }
435
519
 
436
- result.set(propName, { params, returnType, transferable, skipCodegen });
520
+ result.set(key, { params, returnType, transferable, skipCodegen });
437
521
  }
438
522
  }
439
523