ts-procedures 6.0.2 → 6.2.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 (199) hide show
  1. package/agent_config/bin/setup.mjs +2 -2
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -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/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
  6. package/agent_config/copilot/copilot-instructions.md +3 -0
  7. package/agent_config/cursor/cursorrules +3 -0
  8. package/agent_config/lib/install-claude.mjs +1 -1
  9. package/build/codegen/bin/cli.d.ts +39 -0
  10. package/build/codegen/bin/cli.js +164 -0
  11. package/build/codegen/bin/cli.js.map +1 -1
  12. package/build/codegen/bin/cli.test.js +180 -1
  13. package/build/codegen/bin/cli.test.js.map +1 -1
  14. package/build/codegen/index.d.ts +36 -0
  15. package/build/codegen/index.js +8 -0
  16. package/build/codegen/index.js.map +1 -1
  17. package/build/codegen/pipeline.d.ts +22 -4
  18. package/build/codegen/pipeline.js +44 -86
  19. package/build/codegen/pipeline.js.map +1 -1
  20. package/build/codegen/pipeline.test.js +162 -0
  21. package/build/codegen/pipeline.test.js.map +1 -1
  22. package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
  23. package/build/codegen/targets/_shared/error-schemas.js +17 -0
  24. package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
  25. package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
  26. package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
  27. package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
  28. package/build/codegen/targets/_shared/indent.d.ts +6 -0
  29. package/build/codegen/targets/_shared/indent.js +13 -0
  30. package/build/codegen/targets/_shared/indent.js.map +1 -0
  31. package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
  32. package/build/codegen/targets/_shared/indent.test.js +21 -0
  33. package/build/codegen/targets/_shared/indent.test.js.map +1 -0
  34. package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
  35. package/build/codegen/targets/_shared/pascal-case.js +13 -0
  36. package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
  37. package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
  38. package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
  39. package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
  40. package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
  41. package/build/codegen/targets/_shared/path-utils.js +20 -0
  42. package/build/codegen/targets/_shared/path-utils.js.map +1 -0
  43. package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
  44. package/build/codegen/targets/_shared/path-utils.test.js +42 -0
  45. package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
  46. package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
  47. package/build/codegen/targets/_shared/pick-defined.js +21 -0
  48. package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
  49. package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
  50. package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
  51. package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
  52. package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
  53. package/build/codegen/targets/_shared/route-slots.js +17 -0
  54. package/build/codegen/targets/_shared/route-slots.js.map +1 -0
  55. package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
  56. package/build/codegen/targets/_shared/route-slots.test.js +43 -0
  57. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
  58. package/build/codegen/targets/_shared/target-run.d.ts +27 -0
  59. package/build/codegen/targets/_shared/target-run.js +2 -0
  60. package/build/codegen/targets/_shared/target-run.js.map +1 -0
  61. package/build/codegen/targets/_shared/write-files.d.ts +24 -0
  62. package/build/codegen/targets/_shared/write-files.js +35 -0
  63. package/build/codegen/targets/_shared/write-files.js.map +1 -0
  64. package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
  65. package/build/codegen/targets/_shared/write-files.test.js +79 -0
  66. package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
  67. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
  68. package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
  69. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
  70. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
  71. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
  72. package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
  73. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
  74. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
  75. package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -28
  76. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
  77. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
  78. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
  79. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
  80. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +12 -11
  81. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  82. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
  83. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
  84. package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -1
  85. package/build/codegen/targets/kotlin/format-kotlin.js +0 -7
  86. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  87. package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -8
  88. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  89. package/build/codegen/targets/kotlin/integration.test.js +27 -10
  90. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  91. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
  92. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
  93. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
  94. package/build/codegen/targets/kotlin/run.d.ts +11 -0
  95. package/build/codegen/targets/kotlin/run.js +51 -0
  96. package/build/codegen/targets/kotlin/run.js.map +1 -0
  97. package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
  98. package/build/codegen/targets/swift/access-level.test.js +98 -0
  99. package/build/codegen/targets/swift/access-level.test.js.map +1 -0
  100. package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
  101. package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
  102. package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
  103. package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
  104. package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
  105. package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
  106. package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
  107. package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
  108. package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
  109. package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
  110. package/build/codegen/targets/swift/emit-route-swift.js +64 -0
  111. package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
  112. package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
  113. package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
  114. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
  115. package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
  116. package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
  117. package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
  118. package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
  119. package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
  120. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
  121. package/build/codegen/targets/swift/format-swift.d.ts +2 -0
  122. package/build/codegen/targets/swift/format-swift.js +10 -0
  123. package/build/codegen/targets/swift/format-swift.js.map +1 -0
  124. package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
  125. package/build/codegen/targets/swift/format-swift.test.js +14 -0
  126. package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
  127. package/build/codegen/targets/swift/integration.test.d.ts +1 -0
  128. package/build/codegen/targets/swift/integration.test.js +53 -0
  129. package/build/codegen/targets/swift/integration.test.js.map +1 -0
  130. package/build/codegen/targets/swift/run.d.ts +11 -0
  131. package/build/codegen/targets/swift/run.js +47 -0
  132. package/build/codegen/targets/swift/run.js.map +1 -0
  133. package/build/codegen/targets/ts/run.d.ts +4 -0
  134. package/build/codegen/targets/ts/run.js +86 -0
  135. package/build/codegen/targets/ts/run.js.map +1 -0
  136. package/build/codegen/test-helpers/golden.d.ts +15 -0
  137. package/build/codegen/test-helpers/golden.js +30 -0
  138. package/build/codegen/test-helpers/golden.js.map +1 -0
  139. package/build/codegen/test-helpers/golden.test.d.ts +1 -0
  140. package/build/codegen/test-helpers/golden.test.js +76 -0
  141. package/build/codegen/test-helpers/golden.test.js.map +1 -0
  142. package/docs/codegen-kotlin.md +176 -0
  143. package/docs/codegen-swift.md +314 -0
  144. package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
  145. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
  146. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
  147. package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
  148. package/package.json +2 -2
  149. package/src/codegen/__fixtures__/users-envelope.json +144 -0
  150. package/src/codegen/bin/cli.test.ts +200 -1
  151. package/src/codegen/bin/cli.ts +187 -0
  152. package/src/codegen/index.ts +50 -0
  153. package/src/codegen/pipeline.test.ts +175 -0
  154. package/src/codegen/pipeline.ts +58 -101
  155. package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
  156. package/src/codegen/targets/_shared/error-schemas.ts +17 -0
  157. package/src/codegen/targets/_shared/indent.test.ts +25 -0
  158. package/src/codegen/targets/_shared/indent.ts +12 -0
  159. package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
  160. package/src/codegen/targets/_shared/pascal-case.ts +12 -0
  161. package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
  162. package/src/codegen/targets/_shared/path-utils.ts +21 -0
  163. package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
  164. package/src/codegen/targets/_shared/pick-defined.ts +23 -0
  165. package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
  166. package/src/codegen/targets/_shared/route-slots.ts +32 -0
  167. package/src/codegen/targets/_shared/target-run.ts +28 -0
  168. package/src/codegen/targets/_shared/write-files.test.ts +110 -0
  169. package/src/codegen/targets/_shared/write-files.ts +53 -0
  170. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
  171. package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
  172. package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
  173. package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
  174. package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
  175. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
  176. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +89 -0
  177. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
  178. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +60 -0
  179. package/src/codegen/targets/kotlin/format-kotlin.test.ts +26 -0
  180. package/src/codegen/targets/kotlin/format-kotlin.ts +13 -0
  181. package/src/codegen/targets/kotlin/integration.test.ts +77 -0
  182. package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
  183. package/src/codegen/targets/kotlin/run.ts +78 -0
  184. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
  185. package/src/codegen/targets/swift/access-level.test.ts +108 -0
  186. package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
  187. package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
  188. package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
  189. package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
  190. package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
  191. package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
  192. package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
  193. package/src/codegen/targets/swift/format-swift.test.ts +23 -0
  194. package/src/codegen/targets/swift/format-swift.ts +9 -0
  195. package/src/codegen/targets/swift/integration.test.ts +80 -0
  196. package/src/codegen/targets/swift/run.ts +74 -0
  197. package/src/codegen/targets/ts/run.ts +117 -0
  198. package/src/codegen/test-helpers/golden.test.ts +80 -0
  199. package/src/codegen/test-helpers/golden.ts +34 -0
@@ -1,6 +1,8 @@
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'
5
+ import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
4
6
 
5
7
  export interface GenerateClientOptions extends ResolveInput {
6
8
  outDir: string
@@ -11,6 +13,16 @@ export interface GenerateClientOptions extends ResolveInput {
11
13
  selfContained?: boolean
12
14
  serviceName?: string
13
15
  cleanOutDir?: boolean
16
+ target?: 'ts' | 'kotlin' | 'swift'
17
+ kotlinPackage?: string
18
+ kotlinSerializer?: 'kotlinx' | 'none'
19
+ unsupportedUnions?: 'throw' | 'fallback'
20
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
21
+ kotlinEmitter?: KotlinEmitter
22
+ swiftSerializer?: 'codable' | 'none'
23
+ swiftAccessLevel?: 'public' | 'internal'
24
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
25
+ swiftEmitter?: SwiftEmitter
14
26
  }
15
27
 
16
28
  export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
@@ -25,8 +37,46 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
25
37
  selfContained: options.selfContained,
26
38
  serviceName: options.serviceName,
27
39
  cleanOutDir: options.cleanOutDir,
40
+ target: options.target,
41
+ kotlinPackage: options.kotlinPackage,
42
+ kotlinSerializer: options.kotlinSerializer,
43
+ unsupportedUnions: options.unsupportedUnions,
44
+ kotlinEmitter: options.kotlinEmitter,
45
+ swiftSerializer: options.swiftSerializer,
46
+ swiftAccessLevel: options.swiftAccessLevel,
47
+ swiftEmitter: options.swiftEmitter,
28
48
  })
29
49
  }
30
50
 
31
51
  export type { AjscOptions } from './emit-types.js'
32
52
  export type { ResolveInput } from './resolve-envelope.js'
53
+
54
+ /**
55
+ * @internal Subject to change with ajsc minor versions. Use for test injection
56
+ * only; consumer code should not depend on this shape.
57
+ */
58
+ export type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
59
+
60
+ /**
61
+ * @internal Mirrors ajsc's `KotlinConverterOpts` and may shift with ajsc
62
+ * minor versions. Use for test injection only.
63
+ */
64
+ export type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
65
+
66
+ /** Result shape produced by `KotlinEmitter.emit`; stable, useful for stub builders in tests. */
67
+ export type { KotlinEmitResult } from './targets/kotlin/ajsc-adapter.js'
68
+
69
+ /**
70
+ * @internal Subject to change with ajsc minor versions. Use for test injection
71
+ * only; consumer code should not depend on this shape.
72
+ */
73
+ export type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
74
+
75
+ /**
76
+ * @internal Mirrors ajsc's `SwiftConverterOpts` and may shift with ajsc
77
+ * minor versions. Use for test injection only.
78
+ */
79
+ export type { SwiftEmitOptions } from './targets/swift/ajsc-adapter.js'
80
+
81
+ /** Result shape produced by `SwiftEmitter.emit`; stable, useful for stub builders in tests. */
82
+ export type { SwiftEmitResult } from './targets/swift/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
+ })
@@ -1,15 +1,14 @@
1
- import { mkdir, rm, writeFile } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
1
  import { createHash } from 'node:crypto'
4
2
  import type { DocEnvelope } from '../implementations/types.js'
5
3
  import type { AjscOptions } from './emit-types.js'
6
4
  import { groupRoutesByScope } from './group-routes.js'
7
- import { emitScopeFile } from './emit-scope.js'
8
- import { emitIndexFile } from './emit-index.js'
9
- import { emitErrorsFile } from './emit-errors.js'
10
- import { emitClientTypesFile } from './emit-client-types.js'
11
- import { emitClientRuntimeFile } from './emit-client-runtime.js'
12
5
  import { validateServiceName } from './naming.js'
6
+ import type { GeneratedFile } from './targets/_shared/write-files.js'
7
+ import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
8
+ import { runKotlinPipeline } from './targets/kotlin/run.js'
9
+ import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
10
+ import { runSwiftPipeline } from './targets/swift/run.js'
11
+ import { runTsPipeline } from './targets/ts/run.js'
13
12
 
14
13
  export interface PipelineOptions {
15
14
  envelope: DocEnvelope
@@ -21,117 +20,75 @@ export interface PipelineOptions {
21
20
  selfContained?: boolean
22
21
  serviceName?: string
23
22
  cleanOutDir?: boolean
23
+ target?: 'ts' | 'kotlin' | 'swift'
24
+ kotlinPackage?: string
25
+ kotlinSerializer?: 'kotlinx' | 'none'
26
+ unsupportedUnions?: 'throw' | 'fallback'
27
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
28
+ kotlinEmitter?: KotlinEmitter
29
+ swiftSerializer?: 'codable' | 'none'
30
+ swiftAccessLevel?: 'public' | 'internal'
31
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
32
+ swiftEmitter?: SwiftEmitter
24
33
  }
25
34
 
26
- export interface GeneratedFile {
27
- path: string
28
- code: string
29
- }
35
+ export type { GeneratedFile } from './targets/_shared/write-files.js'
30
36
 
37
+ /**
38
+ * Top-level codegen entry. Validates the service name, computes the source
39
+ * hash, groups routes by scope, then dispatches to the per-target run module.
40
+ *
41
+ * Per-target modules own their language-specific emission and the file-write
42
+ * tail (dryRun / cleanOutDir / mkdir / writeFile via
43
+ * `_shared/write-files.ts`).
44
+ */
31
45
  export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
32
46
  const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = false } = options
33
47
  const serviceName = options.serviceName ?? 'Api'
34
48
  validateServiceName(serviceName)
49
+
35
50
  const clientImportPath = selfContained ? './_types' : options.clientImportPath
36
51
  if (selfContained && options.clientImportPath != null) {
37
52
  console.warn('[ts-procedures-codegen] --self-contained overrides --client-import-path; using ./_types')
38
53
  }
39
54
 
40
55
  const hash = createHash('md5').update(JSON.stringify(envelope)).digest('hex')
41
- const hashComment = `// Source hash: ${hash}`
42
-
43
- const groups = groupRoutesByScope(envelope.routes)
44
- const groupArray = Array.from(groups.values())
45
-
46
- // Error keys that will be emitted in `_errors.ts` — only those with a schema.
47
- // Scope emit uses this to filter `route.errors` so generated code never
48
- // references an undefined error type.
49
- const errorKeys = new Set(
50
- envelope.errors.filter((e) => e.schema != null).map((e) => e.name)
51
- )
52
-
53
- if (selfContained) {
54
- for (const group of groupArray) {
55
- if (group.scopeKey === '_types' || group.scopeKey === '_client') {
56
- throw new Error(
57
- `[ts-procedures-codegen] Scope "${group.scopeKey}" conflicts with self-contained mode reserved filename "${group.scopeKey}.ts". Rename the scope to avoid collision.`
58
- )
59
- }
60
- }
61
- }
62
-
63
- const files: GeneratedFile[] = []
64
-
65
- for (const group of groupArray) {
66
- const rawCode = await emitScopeFile(group, {
67
- ajsc: ajscOpts,
68
- clientImportPath,
69
- namespaceTypes,
70
- serviceName,
71
- errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
72
- })
73
- const lines = rawCode.split('\n')
74
- lines.splice(1, 0, hashComment)
75
- const code = lines.join('\n')
76
- files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
77
- }
78
-
79
- const errorsCode = await emitErrorsFile(envelope.errors, { ajsc: ajscOpts, clientImportPath, namespaceTypes, serviceName })
80
- const hasErrors = errorsCode != null
81
- if (errorsCode != null) {
82
- const errorsLines = errorsCode.split('\n')
83
- errorsLines.splice(1, 0, hashComment)
84
- const errorsWithHash = errorsLines.join('\n')
85
- files.push({ path: join(outDir, '_errors.ts'), code: errorsWithHash })
86
- }
56
+ const groups = Array.from(groupRoutesByScope(envelope.routes).values())
87
57
 
88
- // In self-contained mode types come from `./_types` but the runtime
89
- // (`createClient`) lives in `./_client`. In regular mode both share the
90
- // single `clientImportPath` (e.g. `ts-procedures/client`).
91
- const clientRuntimeImportPath = selfContained ? './_client' : clientImportPath
92
- const rawIndexCode = emitIndexFile(groupArray, {
58
+ const base = {
59
+ envelope,
60
+ outDir,
61
+ hash,
62
+ groups,
63
+ serviceName,
64
+ ajsc: ajscOpts,
93
65
  clientImportPath,
94
- clientRuntimeImportPath,
95
- hasErrors,
66
+ dryRun,
96
67
  namespaceTypes,
97
- serviceName,
98
- })
99
- const indexLines = rawIndexCode.split('\n')
100
- indexLines.splice(1, 0, hashComment)
101
- const indexCode = indexLines.join('\n')
102
- files.push({ path: join(outDir, 'index.ts'), code: indexCode })
103
-
104
- if (selfContained) {
105
- const rawTypesCode = await emitClientTypesFile()
106
- const typesLines = rawTypesCode.split('\n')
107
- typesLines.splice(1, 0, hashComment)
108
- const typesCode = typesLines.join('\n')
109
- files.push({ path: join(outDir, '_types.ts'), code: typesCode })
110
-
111
- const rawClientCode = await emitClientRuntimeFile()
112
- const clientLines = rawClientCode.split('\n')
113
- clientLines.splice(1, 0, hashComment)
114
- const clientCode = clientLines.join('\n')
115
- files.push({ path: join(outDir, '_client.ts'), code: clientCode })
68
+ selfContained,
69
+ cleanOutDir,
116
70
  }
117
71
 
118
- if (dryRun) {
119
- if (cleanOutDir) {
120
- console.log(`[dry-run] Would clean outDir: ${outDir}`)
121
- }
122
- for (const file of files) {
123
- const bytes = Buffer.byteLength(file.code, 'utf-8')
124
- console.log(`[dry-run] Would write: ${file.path} (${bytes} bytes)`)
125
- }
126
- } else {
127
- if (cleanOutDir) {
128
- await rm(outDir, { recursive: true, force: true })
129
- }
130
- await mkdir(outDir, { recursive: true })
131
- for (const file of files) {
132
- await writeFile(file.path, file.code, 'utf-8')
133
- }
72
+ switch (options.target) {
73
+ case 'kotlin':
74
+ return runKotlinPipeline({
75
+ ...base,
76
+ kotlinPackage: options.kotlinPackage,
77
+ kotlinSerializer: options.kotlinSerializer,
78
+ unsupportedUnions: options.unsupportedUnions,
79
+ kotlinEmitter: options.kotlinEmitter,
80
+ })
81
+ case 'swift':
82
+ return runSwiftPipeline({
83
+ ...base,
84
+ swiftSerializer: options.swiftSerializer,
85
+ swiftAccessLevel: options.swiftAccessLevel,
86
+ unsupportedUnions: options.unsupportedUnions,
87
+ swiftEmitter: options.swiftEmitter,
88
+ })
89
+ case 'ts':
90
+ case undefined:
91
+ default:
92
+ return runTsPipeline(base)
134
93
  }
135
-
136
- return files
137
94
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildErrorSchemasMap } from './error-schemas.js'
3
+ import type { ErrorDoc } from '../../../implementations/types.js'
4
+
5
+ describe('buildErrorSchemasMap', () => {
6
+ it('returns an empty map when there are no errors', () => {
7
+ expect(buildErrorSchemasMap([])).toEqual(new Map())
8
+ })
9
+
10
+ it('includes errors with schemas', () => {
11
+ const schemaA = { type: 'object', tag: 'a' }
12
+ const schemaB = { type: 'object', tag: 'b' }
13
+ const errors: ErrorDoc[] = [
14
+ { name: 'A', statusCode: 400, description: '', schema: schemaA },
15
+ { name: 'B', statusCode: 500, description: '', schema: schemaB },
16
+ ]
17
+ const map = buildErrorSchemasMap(errors)
18
+ expect(map.size).toBe(2)
19
+ expect(map.get('A')).toBe(schemaA)
20
+ expect(map.get('B')).toBe(schemaB)
21
+ })
22
+
23
+ it('skips errors without a schema', () => {
24
+ const schemaA = { type: 'object' }
25
+ const errors: ErrorDoc[] = [
26
+ { name: 'A', statusCode: 400, description: '', schema: schemaA },
27
+ { name: 'NoSchema', statusCode: 500, description: '' },
28
+ ]
29
+ const map = buildErrorSchemasMap(errors)
30
+ expect(map.size).toBe(1)
31
+ expect(map.has('A')).toBe(true)
32
+ expect(map.has('NoSchema')).toBe(false)
33
+ })
34
+
35
+ it('keys the map by error name', () => {
36
+ const schema = { type: 'object' }
37
+ const map = buildErrorSchemasMap([
38
+ { name: 'NotFound', statusCode: 404, description: '', schema },
39
+ ])
40
+ expect(Array.from(map.keys())).toEqual(['NotFound'])
41
+ })
42
+ })
@@ -0,0 +1,17 @@
1
+ import type { ErrorDoc } from '../../../implementations/types.js'
2
+
3
+ /**
4
+ * Builds a lookup map from a {@link DocEnvelope}'s top-level `errors` array
5
+ * keyed by `name`. Only entries with a `schema` are included — errors
6
+ * documented without a schema cannot have a corresponding generated type.
7
+ *
8
+ * Targets pass this map to per-route emission so taxonomy keys on
9
+ * `route.errors` (a `string[]`) can be resolved to actual JSON Schemas.
10
+ */
11
+ export function buildErrorSchemasMap(errors: readonly ErrorDoc[]): Map<string, unknown> {
12
+ const errorSchemas = new Map<string, unknown>()
13
+ for (const e of errors) {
14
+ if (e.schema != null) errorSchemas.set(e.name, e.schema)
15
+ }
16
+ return errorSchemas
17
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { indent } from './indent.js'
3
+
4
+ describe('indent', () => {
5
+ it('indents every line by 4 spaces per level', () => {
6
+ expect(indent('a\nb', 1)).toBe(' a\n b')
7
+ expect(indent('a', 2)).toBe(' a')
8
+ })
9
+
10
+ it('preserves blank lines without trailing whitespace when indenting', () => {
11
+ expect(indent('a\n\nb', 1)).toBe(' a\n\n b')
12
+ })
13
+
14
+ it('returns the input unchanged at level 0', () => {
15
+ expect(indent('a\n\nb', 0)).toBe('a\n\nb')
16
+ })
17
+
18
+ it('indents a single line', () => {
19
+ expect(indent('hello', 1)).toBe(' hello')
20
+ })
21
+
22
+ it('returns empty string for empty input', () => {
23
+ expect(indent('', 1)).toBe('')
24
+ })
25
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Indents every non-blank line of `text` by `level * 4` spaces. Blank lines
3
+ * are preserved as empty (no trailing whitespace). Suitable for languages
4
+ * that use 4-space indentation (Kotlin, Swift).
5
+ */
6
+ export function indent(text: string, level: number): string {
7
+ const prefix = ' '.repeat(level)
8
+ return text
9
+ .split('\n')
10
+ .map((line) => (line.length === 0 ? line : `${prefix}${line}`))
11
+ .join('\n')
12
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pascalCase } from './pascal-case.js'
3
+
4
+ describe('pascalCase', () => {
5
+ it('converts a single-word scope', () => {
6
+ expect(pascalCase('users')).toBe('Users')
7
+ })
8
+
9
+ it('converts a kebab-case scope', () => {
10
+ expect(pascalCase('user-management')).toBe('UserManagement')
11
+ })
12
+
13
+ it('handles multi-segment kebab-case', () => {
14
+ expect(pascalCase('a-b-c')).toBe('ABC')
15
+ })
16
+
17
+ it('drops empty segments from leading/trailing/double dashes', () => {
18
+ expect(pascalCase('-users')).toBe('Users')
19
+ expect(pascalCase('users-')).toBe('Users')
20
+ expect(pascalCase('user--mgmt')).toBe('UserMgmt')
21
+ })
22
+
23
+ it('returns empty string for empty input', () => {
24
+ expect(pascalCase('')).toBe('')
25
+ })
26
+
27
+ it('preserves casing of letters after the leading character', () => {
28
+ expect(pascalCase('user-API')).toBe('UserAPI')
29
+ })
30
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Converts a kebab-case (or single-word) scope key to PascalCase. Used by
3
+ * codegen targets to derive top-level type/namespace identifiers from scope
4
+ * names (e.g. `user-management` → `UserManagement`).
5
+ */
6
+ export function pascalCase(scope: string): string {
7
+ return scope
8
+ .split('-')
9
+ .filter((p) => p.length > 0)
10
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
11
+ .join('')
12
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { COLON_PARAM_RE, toBracePath, pathParamNames } from './path-utils.js'
3
+
4
+ describe('path-utils', () => {
5
+ describe('toBracePath', () => {
6
+ it('converts colon params to brace form', () => {
7
+ expect(toBracePath('/users/:id')).toBe('/users/{id}')
8
+ })
9
+
10
+ it('converts multiple params', () => {
11
+ expect(toBracePath('/orgs/:orgId/users/:userId')).toBe('/orgs/{orgId}/users/{userId}')
12
+ })
13
+
14
+ it('returns the template unchanged when there are no params', () => {
15
+ expect(toBracePath('/users')).toBe('/users')
16
+ })
17
+
18
+ it('supports underscore-prefixed param names', () => {
19
+ expect(toBracePath('/x/:_id')).toBe('/x/{_id}')
20
+ })
21
+
22
+ it('does not match leading-digit identifiers', () => {
23
+ // ":1abc" is not a valid identifier; should not be rewritten.
24
+ expect(toBracePath('/x/:1abc')).toBe('/x/:1abc')
25
+ })
26
+ })
27
+
28
+ describe('pathParamNames', () => {
29
+ it('returns an empty list for templates without params', () => {
30
+ expect(pathParamNames('/users')).toEqual([])
31
+ })
32
+
33
+ it('returns the single param name', () => {
34
+ expect(pathParamNames('/users/:id')).toEqual(['id'])
35
+ })
36
+
37
+ it('preserves the order of multiple params', () => {
38
+ expect(pathParamNames('/orgs/:orgId/users/:userId')).toEqual(['orgId', 'userId'])
39
+ })
40
+
41
+ it('matches identifiers with digits and underscores', () => {
42
+ expect(pathParamNames('/x/:user_id1')).toEqual(['user_id1'])
43
+ })
44
+ })
45
+
46
+ describe('COLON_PARAM_RE', () => {
47
+ it('is a global regex (so matchAll works)', () => {
48
+ expect(COLON_PARAM_RE.global).toBe(true)
49
+ })
50
+ })
51
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Language-agnostic path-template utilities used by every codegen target that
3
+ * emits HTTP routes. Routes carry colon-prefixed path templates (e.g.
4
+ * `/users/:id`); targets typically need both the brace form (`/users/{id}`)
5
+ * for documentation/template constants and the parameter-name list for
6
+ * generating typed path-builder functions.
7
+ */
8
+
9
+ export const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
10
+
11
+ /** Converts colon-prefixed path params (`:foo`) to brace form (`{foo}`). */
12
+ export function toBracePath(template: string): string {
13
+ return template.replace(COLON_PARAM_RE, '{$1}')
14
+ }
15
+
16
+ /** Extracts the parameter names from a colon-prefixed path template. */
17
+ export function pathParamNames(template: string): string[] {
18
+ const names: string[] = []
19
+ for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
20
+ return names
21
+ }