ts-procedures 5.9.0 → 5.10.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 (80) hide show
  1. package/README.md +1 -1
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
  3. package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
  4. package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
  5. package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
  6. package/agent_config/claude-code/skills/review/SKILL.md +12 -17
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
  8. package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
  9. package/agent_config/lib/install-claude.mjs +22 -22
  10. package/docs/core.md +5 -9
  11. package/docs/streaming.md +9 -9
  12. package/package.json +3 -14
  13. package/src/client/call.test.ts +162 -0
  14. package/src/client/errors.test.ts +43 -0
  15. package/src/client/fetch-adapter.test.ts +340 -0
  16. package/src/client/hooks.test.ts +191 -0
  17. package/src/client/index.test.ts +290 -0
  18. package/src/client/request-builder.test.ts +184 -0
  19. package/src/client/stream.test.ts +331 -0
  20. package/src/codegen/bin/cli.test.ts +260 -0
  21. package/src/codegen/bin/cli.ts +282 -0
  22. package/src/codegen/constants.ts +1 -0
  23. package/src/codegen/e2e.test.ts +565 -0
  24. package/src/codegen/emit-client-runtime.test.ts +93 -0
  25. package/src/codegen/emit-client-runtime.ts +114 -0
  26. package/src/codegen/emit-client-types.test.ts +39 -0
  27. package/src/codegen/emit-client-types.ts +27 -0
  28. package/src/codegen/emit-errors.test.ts +202 -0
  29. package/src/codegen/emit-errors.ts +80 -0
  30. package/src/codegen/emit-index.test.ts +127 -0
  31. package/src/codegen/emit-index.ts +58 -0
  32. package/src/codegen/emit-scope.test.ts +624 -0
  33. package/src/codegen/emit-scope.ts +389 -0
  34. package/src/codegen/emit-types.test.ts +205 -0
  35. package/src/codegen/emit-types.ts +158 -0
  36. package/src/codegen/group-routes.test.ts +159 -0
  37. package/src/codegen/group-routes.ts +61 -0
  38. package/src/codegen/index.ts +30 -0
  39. package/src/codegen/naming.test.ts +50 -0
  40. package/src/codegen/naming.ts +25 -0
  41. package/src/codegen/pipeline.test.ts +316 -0
  42. package/src/codegen/pipeline.ts +108 -0
  43. package/src/codegen/resolve-envelope.test.ts +76 -0
  44. package/src/codegen/resolve-envelope.ts +61 -0
  45. package/src/errors.test.ts +163 -0
  46. package/src/errors.ts +107 -0
  47. package/src/exports.ts +7 -0
  48. package/src/implementations/http/doc-registry.test.ts +415 -0
  49. package/src/implementations/http/doc-registry.ts +143 -0
  50. package/src/implementations/http/express-rpc/README.md +6 -6
  51. package/src/implementations/http/express-rpc/index.test.ts +957 -0
  52. package/src/implementations/http/express-rpc/index.ts +266 -0
  53. package/src/implementations/http/express-rpc/types.ts +16 -0
  54. package/src/implementations/http/hono-api/index.test.ts +1341 -0
  55. package/src/implementations/http/hono-api/index.ts +463 -0
  56. package/src/implementations/http/hono-api/types.ts +16 -0
  57. package/src/implementations/http/hono-rpc/README.md +6 -6
  58. package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
  59. package/src/implementations/http/hono-rpc/index.ts +238 -0
  60. package/src/implementations/http/hono-rpc/types.ts +16 -0
  61. package/src/implementations/http/hono-stream/README.md +12 -12
  62. package/src/implementations/http/hono-stream/index.test.ts +1768 -0
  63. package/src/implementations/http/hono-stream/index.ts +456 -0
  64. package/src/implementations/http/hono-stream/types.ts +20 -0
  65. package/src/implementations/types.ts +174 -0
  66. package/src/index.test.ts +1185 -0
  67. package/src/index.ts +522 -0
  68. package/src/schema/compute-schema.test.ts +128 -0
  69. package/src/schema/compute-schema.ts +88 -0
  70. package/src/schema/extract-json-schema.test.ts +25 -0
  71. package/src/schema/extract-json-schema.ts +15 -0
  72. package/src/schema/parser.test.ts +182 -0
  73. package/src/schema/parser.ts +215 -0
  74. package/src/schema/resolve-schema-lib.test.ts +19 -0
  75. package/src/schema/resolve-schema-lib.ts +29 -0
  76. package/src/schema/types.ts +20 -0
  77. package/src/stack-utils.test.ts +94 -0
  78. package/src/stack-utils.ts +129 -0
  79. package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
  80. package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
@@ -0,0 +1,624 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { emitScopeFile } from './emit-scope.js'
3
+ import type { ScopeGroup } from './group-routes.js'
4
+ import type { RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc } from '../implementations/types.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const rpcGroup: ScopeGroup = {
11
+ scopeKey: 'users',
12
+ camelCase: 'users',
13
+ routes: [
14
+ {
15
+ kind: 'rpc',
16
+ name: 'GetUser',
17
+ path: '/users/1',
18
+ method: 'post',
19
+ scope: 'users',
20
+ version: 1,
21
+ jsonSchema: {
22
+ body: {
23
+ type: 'object',
24
+ properties: { id: { type: 'string' } },
25
+ required: ['id'],
26
+ },
27
+ response: {
28
+ type: 'object',
29
+ properties: { name: { type: 'string' } },
30
+ required: ['name'],
31
+ },
32
+ },
33
+ } satisfies RPCHttpRouteDoc,
34
+ ],
35
+ }
36
+
37
+ const apiGroup: ScopeGroup = {
38
+ scopeKey: 'posts',
39
+ camelCase: 'posts',
40
+ routes: [
41
+ {
42
+ kind: 'api',
43
+ name: 'UpdatePost',
44
+ path: '/posts/:id',
45
+ method: 'put',
46
+ fullPath: '/api/posts/:id',
47
+ scope: 'posts',
48
+ jsonSchema: {
49
+ pathParams: {
50
+ type: 'object',
51
+ properties: { id: { type: 'string' } },
52
+ required: ['id'],
53
+ },
54
+ body: {
55
+ type: 'object',
56
+ properties: { title: { type: 'string' } },
57
+ required: ['title'],
58
+ },
59
+ response: {
60
+ type: 'object',
61
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
62
+ required: ['id', 'title'],
63
+ },
64
+ },
65
+ } satisfies APIHttpRouteDoc,
66
+ ],
67
+ }
68
+
69
+ const streamGroup: ScopeGroup = {
70
+ scopeKey: 'events',
71
+ camelCase: 'events',
72
+ routes: [
73
+ {
74
+ kind: 'stream',
75
+ name: 'WatchEvents',
76
+ path: '/events/stream',
77
+ methods: ['get'],
78
+ streamMode: 'sse',
79
+ scope: 'events',
80
+ version: 1,
81
+ jsonSchema: {
82
+ params: {
83
+ type: 'object',
84
+ properties: { filter: { type: 'string' } },
85
+ required: [],
86
+ },
87
+ // SSE envelope: data wraps the actual yield type
88
+ yieldType: {
89
+ type: 'object',
90
+ properties: {
91
+ data: {
92
+ type: 'object',
93
+ properties: { message: { type: 'string' } },
94
+ required: ['message'],
95
+ },
96
+ event: { type: 'string' },
97
+ id: { type: 'string' },
98
+ retry: { type: 'number' },
99
+ },
100
+ required: ['data'],
101
+ },
102
+ returnType: {
103
+ type: 'object',
104
+ properties: { total: { type: 'number' } },
105
+ required: ['total'],
106
+ },
107
+ },
108
+ } satisfies StreamHttpRouteDoc,
109
+ ],
110
+ }
111
+
112
+ // Versioned fixtures: v1 and v2 of the same RPC in one scope
113
+ const rpcVersionedGroup: ScopeGroup = {
114
+ scopeKey: 'users',
115
+ camelCase: 'users',
116
+ routes: [
117
+ {
118
+ kind: 'rpc',
119
+ name: 'GetUser',
120
+ path: '/users/get-user/1',
121
+ method: 'post',
122
+ scope: 'users',
123
+ version: 1,
124
+ jsonSchema: {
125
+ body: {
126
+ type: 'object',
127
+ properties: { id: { type: 'string' } },
128
+ required: ['id'],
129
+ },
130
+ response: {
131
+ type: 'object',
132
+ properties: { name: { type: 'string' } },
133
+ required: ['name'],
134
+ },
135
+ },
136
+ } satisfies RPCHttpRouteDoc,
137
+ {
138
+ kind: 'rpc',
139
+ name: 'GetUser',
140
+ path: '/users/get-user/2',
141
+ method: 'post',
142
+ scope: 'users',
143
+ version: 2,
144
+ jsonSchema: {
145
+ body: {
146
+ type: 'object',
147
+ properties: { id: { type: 'string' }, includeProfile: { type: 'boolean' } },
148
+ required: ['id'],
149
+ },
150
+ response: {
151
+ type: 'object',
152
+ properties: { name: { type: 'string' }, profile: { type: 'object' } },
153
+ required: ['name'],
154
+ },
155
+ },
156
+ } satisfies RPCHttpRouteDoc,
157
+ ],
158
+ }
159
+
160
+ // Versioned stream fixture
161
+ const streamVersionedGroup: ScopeGroup = {
162
+ scopeKey: 'events',
163
+ camelCase: 'events',
164
+ routes: [
165
+ {
166
+ kind: 'stream',
167
+ name: 'WatchEvents',
168
+ path: '/events/watch-events/1',
169
+ methods: ['get'],
170
+ streamMode: 'sse',
171
+ scope: 'events',
172
+ version: 1,
173
+ jsonSchema: {
174
+ params: {
175
+ type: 'object',
176
+ properties: { filter: { type: 'string' } },
177
+ required: [],
178
+ },
179
+ yieldType: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] },
180
+ returnType: { type: 'object', properties: { total: { type: 'number' } }, required: ['total'] },
181
+ },
182
+ } satisfies StreamHttpRouteDoc,
183
+ {
184
+ kind: 'stream',
185
+ name: 'WatchEvents',
186
+ path: '/events/watch-events/2',
187
+ methods: ['get', 'post'],
188
+ streamMode: 'sse',
189
+ scope: 'events',
190
+ version: 2,
191
+ jsonSchema: {
192
+ params: {
193
+ type: 'object',
194
+ properties: { filter: { type: 'string' }, cursor: { type: 'string' } },
195
+ required: [],
196
+ },
197
+ yieldType: { type: 'object', properties: { message: { type: 'string' }, ts: { type: 'number' } }, required: ['message', 'ts'] },
198
+ returnType: { type: 'object', properties: { total: { type: 'number' }, nextCursor: { type: 'string' } }, required: ['total'] },
199
+ },
200
+ } satisfies StreamHttpRouteDoc,
201
+ ],
202
+ }
203
+
204
+ const streamGroupText: ScopeGroup = {
205
+ scopeKey: 'feed',
206
+ camelCase: 'feed',
207
+ routes: [
208
+ {
209
+ kind: 'stream',
210
+ name: 'GetFeed',
211
+ path: '/feed/stream',
212
+ methods: ['get'],
213
+ streamMode: 'text',
214
+ scope: 'feed',
215
+ version: 1,
216
+ jsonSchema: {
217
+ params: undefined,
218
+ yieldType: {
219
+ type: 'string',
220
+ },
221
+ returnType: undefined,
222
+ },
223
+ } satisfies StreamHttpRouteDoc,
224
+ ],
225
+ }
226
+
227
+ // Fixture with nested objects for testing extracted sub-types
228
+ const rpcGroupNested: ScopeGroup = {
229
+ scopeKey: 'accounts',
230
+ camelCase: 'accounts',
231
+ routes: [
232
+ {
233
+ kind: 'rpc',
234
+ name: 'CreateAccount',
235
+ path: '/accounts',
236
+ method: 'post',
237
+ scope: 'accounts',
238
+ version: 1,
239
+ jsonSchema: {
240
+ body: {
241
+ type: 'object',
242
+ properties: {
243
+ owner: {
244
+ type: 'object',
245
+ properties: { name: { type: 'string' }, email: { type: 'string' } },
246
+ required: ['name', 'email'],
247
+ },
248
+ status: { type: 'string', enum: ['active', 'pending'] },
249
+ },
250
+ required: ['owner', 'status'],
251
+ },
252
+ response: {
253
+ type: 'object',
254
+ properties: { id: { type: 'string' } },
255
+ required: ['id'],
256
+ },
257
+ },
258
+ } satisfies RPCHttpRouteDoc,
259
+ ],
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Tests
264
+ // ---------------------------------------------------------------------------
265
+
266
+ describe('emitScopeFile', () => {
267
+ describe('RPC scope', () => {
268
+ it('includes the auto-generated header comment', async () => {
269
+ const output = await emitScopeFile(rpcGroup)
270
+ expect(output).toContain('// Auto-generated by ts-procedures-codegen — do not edit')
271
+ })
272
+
273
+ it('imports ClientInstance and ProcedureCallOptions but not TypedStream', async () => {
274
+ const output = await emitScopeFile(rpcGroup)
275
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions } from 'ts-procedures/client'")
276
+ expect(output).not.toContain('TypedStream')
277
+ })
278
+
279
+ it('exports param type from jsonSchema.body', async () => {
280
+ const output = await emitScopeFile(rpcGroup)
281
+ expect(output).toContain('export type GetUserParams')
282
+ })
283
+
284
+ it('exports response type from jsonSchema.response', async () => {
285
+ const output = await emitScopeFile(rpcGroup)
286
+ expect(output).toContain('export type GetUserResponse')
287
+ })
288
+
289
+ it('emits the bindUsersScope function', async () => {
290
+ const output = await emitScopeFile(rpcGroup)
291
+ expect(output).toContain('export function bindUsersScope(client: ClientInstance)')
292
+ })
293
+
294
+ it('callable uses client.call with kind: rpc', async () => {
295
+ const output = await emitScopeFile(rpcGroup)
296
+ expect(output).toContain("kind: 'rpc'")
297
+ expect(output).toContain('client.call')
298
+ })
299
+
300
+ it('callable includes JSDoc with method and path', async () => {
301
+ const output = await emitScopeFile(rpcGroup)
302
+ expect(output).toContain('POST')
303
+ expect(output).toContain('/users/1')
304
+ })
305
+ })
306
+
307
+ describe('API scope', () => {
308
+ it('generates PascalCase channel types', async () => {
309
+ const output = await emitScopeFile(apiGroup)
310
+ expect(output).toContain('export type UpdatePostPathParams')
311
+ expect(output).toContain('export type UpdatePostBody')
312
+ })
313
+
314
+ it('composes structured params type', async () => {
315
+ const output = await emitScopeFile(apiGroup)
316
+ expect(output).toContain('export type UpdatePostParams =')
317
+ expect(output).toContain('pathParams: UpdatePostPathParams')
318
+ expect(output).toContain('body: UpdatePostBody')
319
+ })
320
+
321
+ it('exports response type', async () => {
322
+ const output = await emitScopeFile(apiGroup)
323
+ expect(output).toContain('export type UpdatePostResponse')
324
+ })
325
+
326
+ it('callable uses client.call with kind: api', async () => {
327
+ const output = await emitScopeFile(apiGroup)
328
+ expect(output).toContain("kind: 'api'")
329
+ expect(output).toContain('client.call')
330
+ })
331
+
332
+ it('callable includes JSDoc with method and fullPath', async () => {
333
+ const output = await emitScopeFile(apiGroup)
334
+ expect(output).toContain('PUT')
335
+ expect(output).toContain('/api/posts/:id')
336
+ })
337
+
338
+ it('callable path field uses fullPath not path', async () => {
339
+ const output = await emitScopeFile(apiGroup)
340
+ expect(output).toContain("path: '/api/posts/:id'")
341
+ expect(output).not.toContain("path: '/posts/:id'")
342
+ })
343
+ })
344
+
345
+ describe('Stream scope', () => {
346
+ it('imports TypedStream when stream routes are present', async () => {
347
+ const output = await emitScopeFile(streamGroup)
348
+ expect(output).toContain('TypedStream')
349
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, TypedStream } from 'ts-procedures/client'")
350
+ })
351
+
352
+ it('unwraps SSE envelope for yieldType', async () => {
353
+ const output = await emitScopeFile(streamGroup)
354
+ expect(output).toContain('export type WatchEventsYield')
355
+ const yieldTypeMatch = output.match(/export type WatchEventsYield = ([^;]+)/)
356
+ expect(yieldTypeMatch).not.toBeNull()
357
+ })
358
+
359
+ it('emits params type from jsonSchema.params', async () => {
360
+ const output = await emitScopeFile(streamGroup)
361
+ expect(output).toContain('export type WatchEventsParams')
362
+ })
363
+
364
+ it('emits return type from jsonSchema.returnType', async () => {
365
+ const output = await emitScopeFile(streamGroup)
366
+ expect(output).toContain('export type WatchEventsReturn')
367
+ })
368
+
369
+ it('callable uses client.stream with kind: stream and streamMode', async () => {
370
+ const output = await emitScopeFile(streamGroup)
371
+ expect(output).toContain("kind: 'stream'")
372
+ expect(output).toContain("streamMode: 'sse'")
373
+ expect(output).toContain('client.stream')
374
+ })
375
+
376
+ it('callable returns TypedStream<Yield, Return>', async () => {
377
+ const output = await emitScopeFile(streamGroup)
378
+ expect(output).toContain('TypedStream<WatchEventsYield, WatchEventsReturn>')
379
+ })
380
+
381
+ it('does not import TypedStream for non-stream scopes', async () => {
382
+ const output = await emitScopeFile(rpcGroup)
383
+ expect(output).not.toContain('TypedStream')
384
+ })
385
+
386
+ it('handles text stream without SSE unwrapping', async () => {
387
+ const output = await emitScopeFile(streamGroupText)
388
+ expect(output).toContain('export type GetFeedYield')
389
+ })
390
+
391
+ it('omits params type when params schema is undefined', async () => {
392
+ const output = await emitScopeFile(streamGroupText)
393
+ expect(output).not.toContain('GetFeedParams')
394
+ })
395
+ })
396
+
397
+ describe('bindScope function structure', () => {
398
+ it('wraps callables in the bind function return object', async () => {
399
+ const output = await emitScopeFile(rpcGroup)
400
+ expect(output).toMatch(/export function bindUsersScope\(client: ClientInstance\)/)
401
+ expect(output).toContain('return {')
402
+ })
403
+
404
+ it('places types section before callables section', async () => {
405
+ const output = await emitScopeFile(rpcGroup)
406
+ const typesIdx = output.indexOf('// ── Types')
407
+ const callablesIdx = output.indexOf('// ── Callables')
408
+ expect(typesIdx).toBeLessThan(callablesIdx)
409
+ })
410
+ })
411
+
412
+ describe('namespaceTypes', () => {
413
+ describe('RPC scope', () => {
414
+ it('wraps types in outer scope namespace and inner route namespace', async () => {
415
+ const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
416
+ expect(output).toContain('export namespace Users {')
417
+ expect(output).toContain(' export namespace GetUser {')
418
+ })
419
+
420
+ it('uses simplified type names inside namespace', async () => {
421
+ const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
422
+ expect(output).toContain('export type Params =')
423
+ expect(output).toContain('export type Response =')
424
+ expect(output).not.toContain('export type GetUserParams')
425
+ expect(output).not.toContain('export type GetUserResponse')
426
+ })
427
+
428
+ it('callable references fully qualified namespace types', async () => {
429
+ const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
430
+ expect(output).toContain('params: Users.GetUser.Params')
431
+ expect(output).toContain('Promise<Users.GetUser.Response>')
432
+ })
433
+
434
+ it('still emits the bind function', async () => {
435
+ const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
436
+ expect(output).toContain('export function bindUsersScope(client: ClientInstance)')
437
+ })
438
+ })
439
+
440
+ describe('API scope', () => {
441
+ it('wraps channel types and structured Params in namespace', async () => {
442
+ const output = await emitScopeFile(apiGroup, { namespaceTypes: true })
443
+ expect(output).toContain('export namespace Posts {')
444
+ expect(output).toContain(' export namespace UpdatePost {')
445
+ expect(output).toContain('export type PathParams =')
446
+ expect(output).toContain('export type Body =')
447
+ expect(output).toContain('export type Response =')
448
+ })
449
+
450
+ it('structured Params uses namespace-local channel names', async () => {
451
+ const output = await emitScopeFile(apiGroup, { namespaceTypes: true })
452
+ expect(output).toContain('export type Params = { pathParams: PathParams; body: Body }')
453
+ })
454
+
455
+ it('callable references fully qualified namespace types', async () => {
456
+ const output = await emitScopeFile(apiGroup, { namespaceTypes: true })
457
+ expect(output).toContain('params: Posts.UpdatePost.Params')
458
+ expect(output).toContain('Promise<Posts.UpdatePost.Response>')
459
+ })
460
+ })
461
+
462
+ describe('Stream scope', () => {
463
+ it('wraps stream types in namespace', async () => {
464
+ const output = await emitScopeFile(streamGroup, { namespaceTypes: true })
465
+ expect(output).toContain('export namespace Events {')
466
+ expect(output).toContain(' export namespace WatchEvents {')
467
+ expect(output).toContain('export type Params =')
468
+ expect(output).toContain('export type Yield =')
469
+ expect(output).toContain('export type Return =')
470
+ })
471
+
472
+ it('callable references fully qualified namespace types', async () => {
473
+ const output = await emitScopeFile(streamGroup, { namespaceTypes: true })
474
+ expect(output).toContain('params: Events.WatchEvents.Params')
475
+ expect(output).toContain('TypedStream<Events.WatchEvents.Yield, Events.WatchEvents.Return>')
476
+ })
477
+ })
478
+
479
+ describe('extracted sub-types (inlineTypes: false)', () => {
480
+ it('extracts nested object types into the namespace', async () => {
481
+ const output = await emitScopeFile(rpcGroupNested, { namespaceTypes: true })
482
+ // ajsc should extract the nested 'owner' object as a named type
483
+ expect(output).toContain('export namespace Accounts {')
484
+ expect(output).toContain('export namespace CreateAccount {')
485
+ expect(output).toContain('export type Owner =')
486
+ expect(output).toContain('export type Params =')
487
+ })
488
+
489
+ it('enables enumStyle: enum in namespace mode', async () => {
490
+ const output = await emitScopeFile(rpcGroupNested, {
491
+ namespaceTypes: true,
492
+ ajsc: { enumStyle: 'enum' },
493
+ })
494
+ // With enumStyle: 'enum', the status enum should be extracted
495
+ expect(output).toContain('export enum Status')
496
+ })
497
+
498
+ it('ajsc formatting options are ignored in flat mode', async () => {
499
+ const output = await emitScopeFile(rpcGroupNested, {
500
+ ajsc: { enumStyle: 'enum' },
501
+ })
502
+ // In flat mode (inlineTypes: true), enumStyle: 'enum' has no effect
503
+ expect(output).not.toContain('export enum')
504
+ expect(output).toContain('"active" | "pending"')
505
+ })
506
+ })
507
+
508
+ it('defaults to flat mode when namespaceTypes is false', async () => {
509
+ const output = await emitScopeFile(rpcGroup, { namespaceTypes: false })
510
+ expect(output).not.toContain('export namespace')
511
+ expect(output).toContain('export type GetUserParams')
512
+ })
513
+ })
514
+
515
+ describe('version suffix', () => {
516
+ describe('RPC routes', () => {
517
+ it('v1 types have no version suffix', async () => {
518
+ const output = await emitScopeFile(rpcVersionedGroup)
519
+ expect(output).toContain('export type GetUserParams')
520
+ expect(output).toContain('export type GetUserResponse')
521
+ })
522
+
523
+ it('v2 types use V2 suffix', async () => {
524
+ const output = await emitScopeFile(rpcVersionedGroup)
525
+ expect(output).toContain('export type GetUserV2Params')
526
+ expect(output).toContain('export type GetUserV2Response')
527
+ })
528
+
529
+ it('v1 callable has no version suffix', async () => {
530
+ const output = await emitScopeFile(rpcVersionedGroup)
531
+ expect(output).toContain('GetUser(params: GetUserParams')
532
+ })
533
+
534
+ it('v2 callable uses V2 suffix', async () => {
535
+ const output = await emitScopeFile(rpcVersionedGroup)
536
+ expect(output).toContain('GetUserV2(params: GetUserV2Params')
537
+ })
538
+
539
+ it('v1 callable uses its own path', async () => {
540
+ const output = await emitScopeFile(rpcVersionedGroup)
541
+ expect(output).toContain("path: '/users/get-user/1'")
542
+ })
543
+
544
+ it('v2 callable uses its own path', async () => {
545
+ const output = await emitScopeFile(rpcVersionedGroup)
546
+ expect(output).toContain("path: '/users/get-user/2'")
547
+ })
548
+ })
549
+
550
+ describe('Stream routes', () => {
551
+ it('v1 types have no version suffix', async () => {
552
+ const output = await emitScopeFile(streamVersionedGroup)
553
+ expect(output).toContain('export type WatchEventsParams')
554
+ expect(output).toContain('export type WatchEventsYield')
555
+ expect(output).toContain('export type WatchEventsReturn')
556
+ })
557
+
558
+ it('v2 types use V2 suffix', async () => {
559
+ const output = await emitScopeFile(streamVersionedGroup)
560
+ expect(output).toContain('export type WatchEventsV2Params')
561
+ expect(output).toContain('export type WatchEventsV2Yield')
562
+ expect(output).toContain('export type WatchEventsV2Return')
563
+ })
564
+
565
+ it('v1 callable has no version suffix', async () => {
566
+ const output = await emitScopeFile(streamVersionedGroup)
567
+ expect(output).toContain('WatchEvents(params: WatchEventsParams')
568
+ })
569
+
570
+ it('v2 callable uses V2 suffix', async () => {
571
+ const output = await emitScopeFile(streamVersionedGroup)
572
+ expect(output).toContain('WatchEventsV2(params: WatchEventsV2Params')
573
+ })
574
+
575
+ it('v2 callable returns TypedStream with V2 types', async () => {
576
+ const output = await emitScopeFile(streamVersionedGroup)
577
+ expect(output).toContain('TypedStream<WatchEventsV2Yield, WatchEventsV2Return>')
578
+ })
579
+ })
580
+
581
+ describe('namespace mode', () => {
582
+ it('v1 uses unversioned namespace name', async () => {
583
+ const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
584
+ expect(output).toContain('export namespace GetUser {')
585
+ })
586
+
587
+ it('v2 uses versioned namespace name', async () => {
588
+ const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
589
+ expect(output).toContain('export namespace GetUserV2 {')
590
+ })
591
+
592
+ it('v1 callable references unversioned namespace types', async () => {
593
+ const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
594
+ expect(output).toContain('params: Users.GetUser.Params')
595
+ expect(output).toContain('Promise<Users.GetUser.Response>')
596
+ })
597
+
598
+ it('v2 callable references versioned namespace types', async () => {
599
+ const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
600
+ expect(output).toContain('params: Users.GetUserV2.Params')
601
+ expect(output).toContain('Promise<Users.GetUserV2.Response>')
602
+ })
603
+ })
604
+ })
605
+
606
+ describe('clientImportPath', () => {
607
+ it('uses custom clientImportPath in import statement', async () => {
608
+ const output = await emitScopeFile(rpcGroup, { clientImportPath: '@my-app/client' })
609
+ expect(output).toContain("from '@my-app/client'")
610
+ expect(output).not.toContain("from 'ts-procedures/client'")
611
+ })
612
+
613
+ it('uses custom clientImportPath for stream scope imports', async () => {
614
+ const output = await emitScopeFile(streamGroup, { clientImportPath: '@my-app/client' })
615
+ expect(output).toContain("from '@my-app/client'")
616
+ expect(output).toContain('TypedStream')
617
+ })
618
+
619
+ it('defaults to ts-procedures/client when not specified', async () => {
620
+ const output = await emitScopeFile(rpcGroup)
621
+ expect(output).toContain("from 'ts-procedures/client'")
622
+ })
623
+ })
624
+ })