ts-procedures 6.0.1 → 6.1.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 (88) hide show
  1. package/agent_config/bin/setup.mjs +0 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
  4. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
  5. package/agent_config/copilot/copilot-instructions.md +2 -0
  6. package/agent_config/cursor/cursorrules +2 -0
  7. package/build/codegen/bin/cli.d.ts +25 -0
  8. package/build/codegen/bin/cli.js +88 -0
  9. package/build/codegen/bin/cli.js.map +1 -1
  10. package/build/codegen/bin/cli.test.js +180 -1
  11. package/build/codegen/bin/cli.test.js.map +1 -1
  12. package/build/codegen/index.d.ts +19 -0
  13. package/build/codegen/index.js +5 -0
  14. package/build/codegen/index.js.map +1 -1
  15. package/build/codegen/pipeline.d.ts +7 -0
  16. package/build/codegen/pipeline.js +57 -0
  17. package/build/codegen/pipeline.js.map +1 -1
  18. package/build/codegen/pipeline.test.js +162 -0
  19. package/build/codegen/pipeline.test.js.map +1 -1
  20. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +26 -0
  21. package/build/codegen/targets/kotlin/ajsc-adapter.js +38 -0
  22. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -0
  23. package/build/codegen/targets/kotlin/ajsc-adapter.test.d.ts +1 -0
  24. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +37 -0
  25. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -0
  26. package/build/codegen/targets/kotlin/e2e-compile.test.d.ts +1 -0
  27. package/build/codegen/targets/kotlin/e2e-compile.test.js +75 -0
  28. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -0
  29. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +15 -0
  30. package/build/codegen/targets/kotlin/emit-route-kotlin.js +80 -0
  31. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -0
  32. package/build/codegen/targets/kotlin/emit-route-kotlin.test.d.ts +1 -0
  33. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +207 -0
  34. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -0
  35. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +14 -0
  36. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +40 -0
  37. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -0
  38. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.d.ts +1 -0
  39. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +91 -0
  40. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -0
  41. package/build/codegen/targets/kotlin/format-kotlin.d.ts +15 -0
  42. package/build/codegen/targets/kotlin/format-kotlin.js +40 -0
  43. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -0
  44. package/build/codegen/targets/kotlin/format-kotlin.test.d.ts +1 -0
  45. package/build/codegen/targets/kotlin/format-kotlin.test.js +50 -0
  46. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -0
  47. package/build/codegen/targets/kotlin/integration.test.d.ts +1 -0
  48. package/build/codegen/targets/kotlin/integration.test.js +51 -0
  49. package/build/codegen/targets/kotlin/integration.test.js.map +1 -0
  50. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
  51. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
  52. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
  53. package/build/codegen/test-helpers/golden.d.ts +15 -0
  54. package/build/codegen/test-helpers/golden.js +30 -0
  55. package/build/codegen/test-helpers/golden.js.map +1 -0
  56. package/build/codegen/test-helpers/golden.test.d.ts +1 -0
  57. package/build/codegen/test-helpers/golden.test.js +76 -0
  58. package/build/codegen/test-helpers/golden.test.js.map +1 -0
  59. package/docs/codegen-kotlin.md +175 -0
  60. package/docs/http-integrations.md +32 -0
  61. package/docs/superpowers/plans/2026-04-24-kotlin-codegen-target.md +1265 -0
  62. package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
  63. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +401 -0
  64. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
  65. package/package.json +2 -2
  66. package/src/codegen/bin/cli.test.ts +200 -1
  67. package/src/codegen/bin/cli.ts +103 -0
  68. package/src/codegen/index.ts +27 -0
  69. package/src/codegen/pipeline.test.ts +175 -0
  70. package/src/codegen/pipeline.ts +79 -0
  71. package/src/codegen/targets/kotlin/__fixtures__/users-envelope.json +144 -0
  72. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
  73. package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
  74. package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
  75. package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
  76. package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
  77. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
  78. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +109 -0
  79. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
  80. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +65 -0
  81. package/src/codegen/targets/kotlin/format-kotlin.test.ts +70 -0
  82. package/src/codegen/targets/kotlin/format-kotlin.ts +45 -0
  83. package/src/codegen/targets/kotlin/integration.test.ts +77 -0
  84. package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
  85. package/src/codegen/test-helpers/golden.test.ts +80 -0
  86. package/src/codegen/test-helpers/golden.ts +34 -0
  87. package/src/implementations/http/README.md +2 -0
  88. package/src/implementations/http/hono-stream/README.md +15 -0
@@ -22,6 +22,9 @@ export interface CodegenConfig {
22
22
  selfContained?: boolean
23
23
  serviceName?: string
24
24
  cleanOutDir?: boolean
25
+ target?: 'ts' | 'kotlin'
26
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
27
+ unsupportedUnions?: 'throw' | 'fallback'
25
28
  }
26
29
 
27
30
  export interface ParsedArgs {
@@ -37,6 +40,9 @@ export interface ParsedArgs {
37
40
  selfContained: boolean
38
41
  serviceName?: string
39
42
  cleanOutDir: boolean
43
+ target?: 'ts' | 'kotlin'
44
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
45
+ unsupportedUnions?: 'throw' | 'fallback'
40
46
  }
41
47
 
42
48
  // ---------------------------------------------------------------------------
@@ -88,6 +94,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
88
94
  let selfContained = config?.selfContained ?? true
89
95
  let serviceName: string | undefined = config?.serviceName
90
96
  let cleanOutDir = config?.cleanOutDir ?? false
97
+ let target: 'ts' | 'kotlin' | undefined = config?.target
98
+ let kotlinPackage: string | undefined = config?.kotlin?.package
99
+ let kotlinSerializer: 'kotlinx' | 'none' | undefined = config?.kotlin?.serializer
100
+ let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
91
101
  let configPath: string | undefined
92
102
 
93
103
  for (let i = 0; i < argv.length; i++) {
@@ -137,6 +147,29 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
137
147
  cleanOutDir = true
138
148
  } else if (arg === '--no-clean-out-dir') {
139
149
  cleanOutDir = false
150
+ } else if (arg === '--target') {
151
+ const val = argv[++i]
152
+ if (val === 'ts' || val === 'kotlin') {
153
+ target = val
154
+ } else {
155
+ throw new Error(`Invalid --target value: ${val ?? '(missing)'} (expected 'ts' or 'kotlin')`)
156
+ }
157
+ } else if (arg === '--kotlin-package') {
158
+ kotlinPackage = argv[++i]
159
+ } else if (arg === '--kotlin-serializer') {
160
+ const val = argv[++i]
161
+ if (val === 'kotlinx' || val === 'none') {
162
+ kotlinSerializer = val
163
+ } else {
164
+ throw new Error(`Invalid --kotlin-serializer value: ${val ?? '(missing)'} (expected 'kotlinx' or 'none')`)
165
+ }
166
+ } else if (arg === '--unsupported-unions') {
167
+ const val = argv[++i]
168
+ if (val === 'throw' || val === 'fallback') {
169
+ unsupportedUnions = val
170
+ } else {
171
+ throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
172
+ }
140
173
  } else if (arg === '--config') {
141
174
  configPath = argv[++i]
142
175
  }
@@ -153,6 +186,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
153
186
  throw new Error('Missing required input source: provide --url <url> or --file <path>')
154
187
  }
155
188
 
189
+ if (target === 'kotlin' && (kotlinPackage === undefined || kotlinPackage === '')) {
190
+ throw new Error('Missing required argument: --kotlin-package <pkg> (required when --target kotlin)')
191
+ }
192
+
156
193
  return {
157
194
  url,
158
195
  file,
@@ -166,6 +203,16 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
166
203
  selfContained,
167
204
  ...(serviceName !== undefined ? { serviceName } : {}),
168
205
  cleanOutDir,
206
+ ...(target !== undefined ? { target } : {}),
207
+ ...(kotlinPackage !== undefined
208
+ ? {
209
+ kotlin: {
210
+ package: kotlinPackage,
211
+ ...(kotlinSerializer !== undefined ? { serializer: kotlinSerializer } : {}),
212
+ },
213
+ }
214
+ : {}),
215
+ ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
169
216
  }
170
217
  }
171
218
 
@@ -201,6 +248,18 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
201
248
  cleanOutDir: parsed.cleanOutDir,
202
249
  }
203
250
 
251
+ // Resolve the kotlin emitter once at watch start; it's stateless and reused per tick.
252
+ const kotlinWiring =
253
+ parsed.target === 'kotlin'
254
+ ? {
255
+ target: 'kotlin' as const,
256
+ kotlinPackage: parsed.kotlin!.package,
257
+ kotlinEmitter: await (
258
+ await import('../targets/kotlin/ajsc-adapter.js')
259
+ ).resolveProductionKotlinEmitter(),
260
+ }
261
+ : {}
262
+
204
263
  let lastHash: string | undefined
205
264
 
206
265
  const run = async (): Promise<void> => {
@@ -226,6 +285,9 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
226
285
  selfContained: parsed.selfContained,
227
286
  serviceName: parsed.serviceName,
228
287
  cleanOutDir: parsed.cleanOutDir,
288
+ ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
289
+ ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
290
+ ...kotlinWiring,
229
291
  })
230
292
  console.log(`[ts-procedures-codegen] Generated client files → ${parsed.outDir}`)
231
293
  } catch (err) {
@@ -243,6 +305,33 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
243
305
  // Main
244
306
  // ---------------------------------------------------------------------------
245
307
 
308
+ const KOTLIN_SETUP_GUIDE_URL =
309
+ 'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md'
310
+
311
+ export function printPostRunHints(parsed: { target?: 'ts' | 'kotlin' }): void {
312
+ if (parsed.target === 'kotlin') {
313
+ console.log(`[ts-procedures-codegen] Kotlin setup guide: ${KOTLIN_SETUP_GUIDE_URL}`)
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Warns about flags that are currently no-ops for the Kotlin target.
319
+ * Currently: `--unsupported-unions` is a no-op because ajsc v7.2's Kotlin
320
+ * emitter silently emits an empty data class for untagged oneOf regardless
321
+ * of the flag (see docs/codegen-kotlin.md#untagged-unions).
322
+ */
323
+ export function warnIfKotlinNoOpFlags(parsed: {
324
+ target?: 'ts' | 'kotlin'
325
+ unsupportedUnions?: 'throw' | 'fallback'
326
+ }): void {
327
+ if (parsed.target === 'kotlin' && parsed.unsupportedUnions !== undefined) {
328
+ console.warn(
329
+ '[ts-procedures-codegen] Note: --unsupported-unions is currently a no-op for --target kotlin ' +
330
+ '(ajsc v7.2 emits an empty data class regardless). See docs/codegen-kotlin.md#untagged-unions.',
331
+ )
332
+ }
333
+ }
334
+
246
335
  async function main(): Promise<void> {
247
336
  const argv = process.argv.slice(2)
248
337
  const configPath = extractConfigPath(argv)
@@ -251,6 +340,7 @@ async function main(): Promise<void> {
251
340
  console.log(`[ts-procedures-codegen] Loaded config from ${configPath ?? DEFAULT_CONFIG_NAME}`)
252
341
  }
253
342
  const parsed = parseArgs(argv, config)
343
+ warnIfKotlinNoOpFlags(parsed)
254
344
 
255
345
  const source = parsed.url ?? parsed.file!
256
346
  console.log(`[ts-procedures-codegen] Reading docs from ${source}...`)
@@ -258,6 +348,15 @@ async function main(): Promise<void> {
258
348
  if (parsed.watch) {
259
349
  await runWithWatch(parsed)
260
350
  } else {
351
+ const kotlinWiring =
352
+ parsed.target === 'kotlin'
353
+ ? {
354
+ target: 'kotlin' as const,
355
+ kotlinPackage: parsed.kotlin!.package,
356
+ kotlinEmitter: await (await import('../targets/kotlin/ajsc-adapter.js')).resolveProductionKotlinEmitter(),
357
+ }
358
+ : {}
359
+
261
360
  const result = await generateClient({
262
361
  url: parsed.url,
263
362
  file: parsed.file,
@@ -269,11 +368,15 @@ async function main(): Promise<void> {
269
368
  selfContained: parsed.selfContained,
270
369
  serviceName: parsed.serviceName,
271
370
  cleanOutDir: parsed.cleanOutDir,
371
+ ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
372
+ ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
373
+ ...kotlinWiring,
272
374
  })
273
375
  if (parsed.dryRun) {
274
376
  console.log(`[ts-procedures-codegen] Dry run complete — ${result.length} files would be generated`)
275
377
  } else {
276
378
  console.log(`[ts-procedures-codegen] Generated ${result.length} files → ${parsed.outDir}`)
379
+ printPostRunHints(parsed)
277
380
  }
278
381
  }
279
382
  }
@@ -1,6 +1,7 @@
1
1
  import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
2
2
  import { runPipeline, type GeneratedFile } from './pipeline.js'
3
3
  import type { AjscOptions } from './emit-types.js'
4
+ import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
4
5
 
5
6
  export interface GenerateClientOptions extends ResolveInput {
6
7
  outDir: string
@@ -11,6 +12,12 @@ export interface GenerateClientOptions extends ResolveInput {
11
12
  selfContained?: boolean
12
13
  serviceName?: string
13
14
  cleanOutDir?: boolean
15
+ target?: 'ts' | 'kotlin'
16
+ kotlinPackage?: string
17
+ kotlinSerializer?: 'kotlinx' | 'none'
18
+ unsupportedUnions?: 'throw' | 'fallback'
19
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
20
+ kotlinEmitter?: KotlinEmitter
14
21
  }
15
22
 
16
23
  export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
@@ -25,8 +32,28 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
25
32
  selfContained: options.selfContained,
26
33
  serviceName: options.serviceName,
27
34
  cleanOutDir: options.cleanOutDir,
35
+ target: options.target,
36
+ kotlinPackage: options.kotlinPackage,
37
+ kotlinSerializer: options.kotlinSerializer,
38
+ unsupportedUnions: options.unsupportedUnions,
39
+ kotlinEmitter: options.kotlinEmitter,
28
40
  })
29
41
  }
30
42
 
31
43
  export type { AjscOptions } from './emit-types.js'
32
44
  export type { ResolveInput } from './resolve-envelope.js'
45
+
46
+ /**
47
+ * @internal Subject to change with ajsc minor versions. Use for test injection
48
+ * only; consumer code should not depend on this shape.
49
+ */
50
+ export type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
51
+
52
+ /**
53
+ * @internal Mirrors ajsc's `KotlinConverterOpts` and may shift with ajsc
54
+ * minor versions. Use for test injection only.
55
+ */
56
+ export type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
57
+
58
+ /** Result shape produced by `KotlinEmitter.emit`; stable, useful for stub builders in tests. */
59
+ export type { KotlinEmitResult } from './targets/kotlin/ajsc-adapter.js'
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect, afterEach, vi } from 'vitest'
2
2
  import { generateClient } from './index.js'
3
3
  import { runPipeline } from './pipeline.js'
4
+ import { createStubKotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
5
+ import type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
4
6
  import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
5
7
  import { join } from 'node:path'
6
8
  import { tmpdir } from 'node:os'
@@ -359,3 +361,176 @@ describe('generateClient pipeline', () => {
359
361
  )
360
362
  })
361
363
  })
364
+
365
+ describe('runPipeline (kotlin target)', () => {
366
+ it('emits only .kt files when target is "kotlin"', async () => {
367
+ const envelope = {
368
+ basePath: '/api',
369
+ headers: [],
370
+ version: '1' as const,
371
+ routes: [
372
+ {
373
+ kind: 'api',
374
+ name: 'GetUser',
375
+ scope: 'users',
376
+ method: 'GET',
377
+ fullPath: '/users/:id',
378
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
379
+ errors: [],
380
+ },
381
+ ],
382
+ errors: [],
383
+ } as any
384
+
385
+ const files = await runPipeline({
386
+ envelope,
387
+ outDir: 'out',
388
+ dryRun: true,
389
+ target: 'kotlin',
390
+ kotlinPackage: 'com.example.api',
391
+ kotlinEmitter: createStubKotlinEmitter({
392
+ PathParams: {
393
+ code: '@Serializable data class PathParams(val id: String)',
394
+ rootTypeName: 'PathParams',
395
+ extractedTypeNames: [],
396
+ imports: ['kotlinx.serialization.Serializable'],
397
+ },
398
+ Response: {
399
+ code: '@Serializable data class Response(val id: String)',
400
+ rootTypeName: 'Response',
401
+ extractedTypeNames: [],
402
+ imports: ['kotlinx.serialization.Serializable'],
403
+ },
404
+ }),
405
+ })
406
+
407
+ expect(files.map((f) => f.path)).toEqual([join('out', 'Users.kt')])
408
+ expect(files[0]!.code).toContain('object Users {')
409
+ })
410
+
411
+ it('does not emit _errors.ts, index.ts, or client runtime files when target is "kotlin"', async () => {
412
+ const envelope = { basePath: '/api', headers: [], version: '1', routes: [], errors: [] } as any
413
+ const files = await runPipeline({
414
+ envelope,
415
+ outDir: 'out',
416
+ dryRun: true,
417
+ target: 'kotlin',
418
+ kotlinPackage: 'p',
419
+ kotlinEmitter: createStubKotlinEmitter({}),
420
+ })
421
+ expect(files.find((f) => f.path.endsWith('_errors.ts'))).toBeUndefined()
422
+ expect(files.find((f) => f.path.endsWith('index.ts'))).toBeUndefined()
423
+ expect(files.find((f) => f.path.endsWith('_client.ts'))).toBeUndefined()
424
+ expect(files.find((f) => f.path.endsWith('_types.ts'))).toBeUndefined()
425
+ })
426
+
427
+ it('threads kotlinSerializer and unsupportedUnions to the emitter', async () => {
428
+ const calls: KotlinEmitOptions[] = []
429
+ const captureEmitter = {
430
+ emit(_s: unknown, opts: KotlinEmitOptions) {
431
+ calls.push(opts)
432
+ return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
433
+ },
434
+ }
435
+
436
+ const envelope = {
437
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
438
+ routes: [
439
+ {
440
+ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/users/:id',
441
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
442
+ errors: [],
443
+ },
444
+ ],
445
+ } as any
446
+
447
+ await runPipeline({
448
+ envelope, outDir: 'out', dryRun: true,
449
+ target: 'kotlin', kotlinPackage: 'p',
450
+ kotlinSerializer: 'none',
451
+ unsupportedUnions: 'fallback',
452
+ kotlinEmitter: captureEmitter,
453
+ })
454
+
455
+ expect(calls.length).toBeGreaterThan(0)
456
+ for (const c of calls) {
457
+ expect(c.serializer).toBe('none')
458
+ expect(c.unsupportedUnions).toBe('fallback')
459
+ expect(c.inlineTypes).toBe(true)
460
+ }
461
+ })
462
+
463
+ it('logs a single summary line for skipped stream routes', async () => {
464
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
465
+ try {
466
+ const envelope = {
467
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
468
+ routes: [
469
+ { kind: 'stream', name: 'WatchA', scope: 's', method: 'GET', path: '/a', schema: {}, errors: [] },
470
+ { kind: 'stream', name: 'WatchB', scope: 's', method: 'GET', path: '/b', schema: {}, errors: [] },
471
+ { kind: 'api', name: 'GetThing', scope: 's', method: 'GET', fullPath: '/c', schema: { returnType: { type: 'object' } }, errors: [] },
472
+ ],
473
+ } as any
474
+ await runPipeline({
475
+ envelope, outDir: 'out', dryRun: true,
476
+ target: 'kotlin', kotlinPackage: 'p',
477
+ kotlinEmitter: createStubKotlinEmitter({
478
+ Response: { code: 'data class Response(val ok: Boolean)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] },
479
+ }),
480
+ })
481
+ const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
482
+ expect(summary).toBeDefined()
483
+ expect(String(summary![0])).toContain('Skipped 2 stream routes')
484
+ expect(String(summary![0])).toContain('WatchA')
485
+ expect(String(summary![0])).toContain('WatchB')
486
+ } finally {
487
+ logSpy.mockRestore()
488
+ }
489
+ })
490
+
491
+ it('does not log a summary when there are no skipped streams', async () => {
492
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
493
+ try {
494
+ const envelope = {
495
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
496
+ routes: [{ kind: 'api', name: 'X', scope: 's', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] }],
497
+ } as any
498
+ await runPipeline({
499
+ envelope, outDir: 'out', dryRun: true,
500
+ target: 'kotlin', kotlinPackage: 'p',
501
+ kotlinEmitter: createStubKotlinEmitter({ Response: { code: 'data class Response(val x: Int)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] } }),
502
+ })
503
+ const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
504
+ expect(summary).toBeUndefined()
505
+ } finally {
506
+ logSpy.mockRestore()
507
+ }
508
+ })
509
+
510
+ it('forwards ajsc passthroughs (arrayItemNaming/depluralize/uncountableWords) to the kotlin emitter', async () => {
511
+ const calls: KotlinEmitOptions[] = []
512
+ const captureEmitter = {
513
+ emit(_s: unknown, opts: KotlinEmitOptions) {
514
+ calls.push(opts)
515
+ return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
516
+ },
517
+ }
518
+
519
+ const envelope = {
520
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
521
+ routes: [{ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] }],
522
+ } as any
523
+
524
+ await runPipeline({
525
+ envelope, outDir: 'out', dryRun: true,
526
+ target: 'kotlin', kotlinPackage: 'p',
527
+ ajsc: { arrayItemNaming: 'Item', depluralize: true, uncountableWords: ['data'] },
528
+ kotlinEmitter: captureEmitter,
529
+ })
530
+
531
+ expect(calls.length).toBe(1)
532
+ expect(calls[0]!.arrayItemNaming).toBe('Item')
533
+ expect(calls[0]!.depluralize).toBe(true)
534
+ expect(calls[0]!.uncountableWords).toEqual(['data'])
535
+ })
536
+ })
@@ -10,6 +10,9 @@ import { emitErrorsFile } from './emit-errors.js'
10
10
  import { emitClientTypesFile } from './emit-client-types.js'
11
11
  import { emitClientRuntimeFile } from './emit-client-runtime.js'
12
12
  import { validateServiceName } from './naming.js'
13
+ import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
14
+ import { emitKotlinScope } from './targets/kotlin/emit-scope-kotlin.js'
15
+ import { pickDefined } from './targets/kotlin/format-kotlin.js'
13
16
 
14
17
  export interface PipelineOptions {
15
18
  envelope: DocEnvelope
@@ -21,6 +24,12 @@ export interface PipelineOptions {
21
24
  selfContained?: boolean
22
25
  serviceName?: string
23
26
  cleanOutDir?: boolean
27
+ target?: 'ts' | 'kotlin'
28
+ kotlinPackage?: string
29
+ kotlinSerializer?: 'kotlinx' | 'none'
30
+ unsupportedUnions?: 'throw' | 'fallback'
31
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
32
+ kotlinEmitter?: KotlinEmitter
24
33
  }
25
34
 
26
35
  export interface GeneratedFile {
@@ -43,6 +52,76 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
43
52
  const groups = groupRoutesByScope(envelope.routes)
44
53
  const groupArray = Array.from(groups.values())
45
54
 
55
+ if (options.target === 'kotlin') {
56
+ if (options.kotlinPackage == null) {
57
+ throw new Error('[ts-procedures-codegen] target=kotlin requires kotlinPackage')
58
+ }
59
+ if (options.kotlinEmitter == null) {
60
+ throw new Error(
61
+ '[ts-procedures-codegen] target=kotlin requires a kotlinEmitter (CLI resolves via resolveProductionKotlinEmitter)'
62
+ )
63
+ }
64
+
65
+ // Route errors are taxonomy keys (string[]); look up actual schemas from the
66
+ // envelope's top-level errors array. Mirrors the existing TS scope emitter.
67
+ const errorSchemas = new Map<string, unknown>()
68
+ for (const e of envelope.errors) {
69
+ if (e.schema != null) errorSchemas.set(e.name, e.schema)
70
+ }
71
+
72
+ // Spec §"CLI flags" — `--array-item-naming`, `--depluralize`, `--uncountable-words`
73
+ // also apply to the Kotlin target. The CLI parks them on `options.ajsc` (a TS-target
74
+ // structure historically); copy the Kotlin-relevant subset onto the scope opts here.
75
+ const ajscPassthrough = options.ajsc ?? {}
76
+
77
+ const AJSC_PASSTHROUGH_KEYS = ['arrayItemNaming', 'depluralize', 'uncountableWords'] as const
78
+
79
+ const kotlinFiles: GeneratedFile[] = []
80
+ const allSkipped: string[] = []
81
+ for (const group of groupArray) {
82
+ const emitted = emitKotlinScope(
83
+ group,
84
+ {
85
+ kotlinPackage: options.kotlinPackage,
86
+ sourceHash: hash,
87
+ ...(options.kotlinSerializer !== undefined ? { serializer: options.kotlinSerializer } : {}),
88
+ ...(options.unsupportedUnions !== undefined ? { unsupportedUnions: options.unsupportedUnions } : {}),
89
+ ...pickDefined(ajscPassthrough, AJSC_PASSTHROUGH_KEYS),
90
+ },
91
+ options.kotlinEmitter,
92
+ errorSchemas,
93
+ )
94
+ kotlinFiles.push({ path: join(outDir, emitted.filename), code: emitted.code })
95
+ allSkipped.push(...emitted.skippedStreams)
96
+ }
97
+
98
+ if (allSkipped.length > 0) {
99
+ console.log(
100
+ `[ts-procedures-codegen] Skipped ${allSkipped.length} stream route${allSkipped.length === 1 ? '' : 's'} (kotlin target): ${allSkipped.join(', ')}`,
101
+ )
102
+ }
103
+
104
+ if (dryRun) {
105
+ if (cleanOutDir) {
106
+ console.log(`[dry-run] Would clean outDir: ${outDir}`)
107
+ }
108
+ for (const f of kotlinFiles) {
109
+ const bytes = Buffer.byteLength(f.code, 'utf-8')
110
+ console.log(`[dry-run] Would write: ${f.path} (${bytes} bytes)`)
111
+ }
112
+ } else {
113
+ if (cleanOutDir) {
114
+ await rm(outDir, { recursive: true, force: true })
115
+ }
116
+ await mkdir(outDir, { recursive: true })
117
+ for (const f of kotlinFiles) {
118
+ await writeFile(f.path, f.code, 'utf-8')
119
+ }
120
+ }
121
+
122
+ return kotlinFiles
123
+ }
124
+
46
125
  // Error keys that will be emitted in `_errors.ts` — only those with a schema.
47
126
  // Scope emit uses this to filter `route.errors` so generated code never
48
127
  // references an undefined error type.
@@ -0,0 +1,144 @@
1
+ {
2
+ "version": "1",
3
+ "basePath": "/api",
4
+ "headers": [],
5
+ "routes": [
6
+ {
7
+ "kind": "api",
8
+ "name": "GetUser",
9
+ "scope": "users",
10
+ "method": "GET",
11
+ "fullPath": "/users/:id",
12
+ "schema": {
13
+ "input": {
14
+ "pathParams": {
15
+ "type": "object",
16
+ "properties": { "id": { "type": "string" } },
17
+ "required": ["id"]
18
+ }
19
+ },
20
+ "returnType": {
21
+ "type": "object",
22
+ "properties": {
23
+ "id": { "type": "string" },
24
+ "name": { "type": "string" },
25
+ "created-at": { "type": "string", "format": "date-time" },
26
+ "address": {
27
+ "type": "object",
28
+ "properties": {
29
+ "street": { "type": "string" },
30
+ "city": { "type": "string" }
31
+ },
32
+ "required": ["street", "city"]
33
+ }
34
+ },
35
+ "required": ["id", "name", "created-at", "address"]
36
+ }
37
+ },
38
+ "errors": ["NotFound"]
39
+ },
40
+ {
41
+ "kind": "api",
42
+ "name": "CreateUser",
43
+ "scope": "users",
44
+ "method": "POST",
45
+ "fullPath": "/users",
46
+ "schema": {
47
+ "input": {
48
+ "body": {
49
+ "oneOf": [
50
+ {
51
+ "type": "object",
52
+ "properties": {
53
+ "kind": { "const": "guest" },
54
+ "displayName": { "type": "string" }
55
+ },
56
+ "required": ["kind", "displayName"]
57
+ },
58
+ {
59
+ "type": "object",
60
+ "properties": {
61
+ "kind": { "const": "registered" },
62
+ "email": { "type": "string" },
63
+ "name": { "type": "string" }
64
+ },
65
+ "required": ["kind", "email", "name"]
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ "returnType": {
71
+ "type": "object",
72
+ "properties": { "id": { "type": "string" } },
73
+ "required": ["id"]
74
+ }
75
+ },
76
+ "errors": ["ValidationError"]
77
+ },
78
+ {
79
+ "kind": "api",
80
+ "name": "ListUsers",
81
+ "scope": "users",
82
+ "method": "GET",
83
+ "fullPath": "/users",
84
+ "schema": {
85
+ "input": {
86
+ "query": {
87
+ "type": "object",
88
+ "properties": {
89
+ "status": { "type": "string", "enum": ["active", "inactive"] },
90
+ "limit": { "type": "integer" }
91
+ }
92
+ }
93
+ },
94
+ "returnType": {
95
+ "type": "object",
96
+ "properties": {
97
+ "items": {
98
+ "type": "array",
99
+ "items": {
100
+ "type": "object",
101
+ "properties": {
102
+ "id": { "type": "string" },
103
+ "name": { "type": "string" }
104
+ },
105
+ "required": ["id", "name"]
106
+ }
107
+ }
108
+ },
109
+ "required": ["items"]
110
+ }
111
+ },
112
+ "errors": []
113
+ }
114
+ ],
115
+ "errors": [
116
+ {
117
+ "name": "NotFound",
118
+ "statusCode": 404,
119
+ "description": "Resource not found",
120
+ "schema": {
121
+ "type": "object",
122
+ "properties": {
123
+ "name": { "const": "NotFound" },
124
+ "message": { "type": "string" }
125
+ },
126
+ "required": ["name", "message"]
127
+ }
128
+ },
129
+ {
130
+ "name": "ValidationError",
131
+ "statusCode": 400,
132
+ "description": "Input failed validation",
133
+ "schema": {
134
+ "type": "object",
135
+ "properties": {
136
+ "name": { "const": "ValidationError" },
137
+ "message": { "type": "string" },
138
+ "field": { "type": "string" }
139
+ },
140
+ "required": ["name", "message"]
141
+ }
142
+ }
143
+ ]
144
+ }